✨ feat(vscode): 注释里的仓库图片按比例显示
This commit is contained in:
1 parent
57b2f2a115
commit
9faf9cde9e
13 files changed
+312
-8
No files matched your search
@@ -18,6 +18,7 @@ import { useChangeNav } from './useChangeNav'
|
||||
import { useDefinitionLinks } from './useDefinitionLinks'
|
||||
import { HighlightContext, useOccurrences } from './useOccurrences'
|
||||
import { useQa } from './useQa'
|
||||
import { useRepoImages } from './useRepoImages'
|
||||
import { useRepoLinks } from './useRepoLinks'
|
||||
import { useReveal } from './useReveal'
|
||||
import { useRewrite } from './useRewrite'
|
||||
@@ -84,6 +85,7 @@ export function App(): ReactElement | null {
|
||||
useReveal(doc, reveal, collapsed, unshown, covered, show, expand, revealed)
|
||||
useDefinitionLinks(doc?.version)
|
||||
useRepoLinks()
|
||||
useRepoImages()
|
||||
const nav = useChangeNav(stops, collapsed, expand)
|
||||
const occurrencePage = useMemo((): OccurrencePage | undefined => doc
|
||||
? {
|
||||
|
||||
@@ -290,6 +290,15 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-colored { color: var(--c
|
||||
text-decoration: underline wavy;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
/* 仓库图片:按原图比例,宽不超过注释区。点了是仓库链接,打开原图 */
|
||||
.cr-markdown .cr-repo-image img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.cr-markdown .cr-repo-image a,
|
||||
.cr-markdown .cr-repo-image a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
.cr-markdown blockquote {
|
||||
padding: 0 8px;
|
||||
border-left: 3px solid var(--vscode-textBlockQuote-border);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderMarkdown, renderMarkdownDiff } from './markdown'
|
||||
import { repoImageInner } from './repoImage'
|
||||
|
||||
describe('renderMarkdown', () => {
|
||||
it('一行文字是一个段落;段内换行是软换行,空行分段', () => {
|
||||
@@ -42,6 +43,32 @@ describe('renderMarkdown', () => {
|
||||
expect(renderMarkdown('')).not.toContain('<img')
|
||||
})
|
||||
|
||||
it('合法仓库图片路径会变成图片;问到之前只是路径', () => {
|
||||
const path = 'frontend/src/components/overview/screenshots/OverviewSection.png'
|
||||
const html = renderMarkdown(``)
|
||||
expect(html).toContain(`data-path="${path}"`)
|
||||
expect(html).toContain(`>${path}<`)
|
||||
expect(html).not.toContain('<img')
|
||||
const src = 'https://file+.vscode-resource.vscode-cdn.net/OverviewSection.png'
|
||||
const shown = repoImageInner(path, '概览卡片', { src })
|
||||
expect(shown).toBe(`<a href="${path}"><img src="${src}" alt="概览卡片"></a>`)
|
||||
})
|
||||
|
||||
it('http、..、./ 的图片不渲染', () => {
|
||||
expect(renderMarkdown('')).not.toContain('<img')
|
||||
expect(renderMarkdown('')).not.toContain('cr-repo-image')
|
||||
expect(renderMarkdown('')).not.toContain('<img')
|
||||
expect(renderMarkdown('')).not.toContain('cr-repo-image')
|
||||
expect(renderMarkdown('')).not.toContain('<img')
|
||||
expect(renderMarkdown('')).not.toContain('cr-repo-image')
|
||||
})
|
||||
|
||||
it('文件不存在时是「找不到图片」加路径,不是图片', () => {
|
||||
const shown = repoImageInner('frontend/missing.png', '图', 'missing')
|
||||
expect(shown).toBe('找不到图片 frontend/missing.png')
|
||||
expect(shown).not.toContain('<img')
|
||||
})
|
||||
|
||||
it('仓库路径链接能点,带类名或类名.成员也一样', () => {
|
||||
expect(renderMarkdown('[智能体定义](vendor/agents)')).toBe('<p><a href="vendor/agents">智能体定义</a></p>\n')
|
||||
expect(renderMarkdown('[智能体表](backend/src/luffy_agent/agent/models.py#Agent)')).toBe('<p><a href="backend/src/luffy_agent/agent/models.py#Agent">智能体表</a></p>\n')
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { isRepoImageFile, repoImagePlaceholder } from './repoImage'
|
||||
import { parseRepoLink } from './repoLink'
|
||||
|
||||
/**
|
||||
* 注释、函数和类说明、问 AI 的回答的 Markdown 排版:CommonMark 加 GFM 的表格、删除线,带 http(s):// 的网址和邮箱自动成链接。
|
||||
* 源码里的 HTML 当文字显示。链接认 http / https / mailto,以及从仓库根起的路径(可带 #类名 或 #类名.成员);
|
||||
* 别的写法照原文显示。webview 会拦下 http 链接的点击交给 VS Code 在外部打开;仓库路径链接由页面另发给扩展端,不走外部打开。
|
||||
* 图片不渲染:webview 的 CSP 不放行外部图片。
|
||||
* 图片只认仓库里的图片文件:先显示路径,问到文件在了再按原图比例显示。http / https 图片不显示。
|
||||
*/
|
||||
const md = new MarkdownIt({ html: false, linkify: true }).disable('image')
|
||||
const md = new MarkdownIt({ html: false, linkify: true })
|
||||
md.validateLink = url => /^(?:https?|mailto):/i.test(url) || parseRepoLink(url) !== undefined
|
||||
|
||||
/** 仓库图片先占位。http / https 和不是图片的仓库路径仍是「!」加链接,不画出图片。 */
|
||||
md.renderer.rules.image = (tokens, idx) => {
|
||||
const token = tokens[idx]
|
||||
const rawSrc = token?.attrGet('src')
|
||||
const src = typeof rawSrc === 'string' ? rawSrc : ''
|
||||
const alt = token?.content ?? ''
|
||||
const link = parseRepoLink(src)
|
||||
if (link !== undefined && link.symbol === undefined && isRepoImageFile(link.path))
|
||||
return repoImagePlaceholder(link.path, alt)
|
||||
const esc = md.utils.escapeHtml
|
||||
return `!<a href="${esc(src)}">${esc(alt)}</a>`
|
||||
}
|
||||
|
||||
/** 链接的标题(`[文字](网址 "标题")`)写成 data-tooltip,和页面上别的悬停提示走同一个提示框(components/ui/tooltip.tsx),不用原生 title。 */
|
||||
md.core.ruler.push('link_title_tooltip', (state) => {
|
||||
for (const block of state.tokens) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { WebviewToExtension } from './types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { repoImageInner, RepoImages } from './repoImage'
|
||||
|
||||
describe('repoImages', () => {
|
||||
function setup(): { images: RepoImages, sent: WebviewToExtension[] } {
|
||||
const sent: WebviewToExtension[] = []
|
||||
return { images: new RepoImages(msg => sent.push(msg)), sent }
|
||||
}
|
||||
|
||||
it('路径变了才问哪些在,还没回是路径,不在是找不到图片,迟到的回答丢掉', () => {
|
||||
const { images, sent } = setup()
|
||||
const path = 'frontend/src/components/overview/screenshots/OverviewSection.png'
|
||||
images.scan([path])
|
||||
images.scan([path])
|
||||
expect(sent).toEqual([{ type: 'checkRepoImages', id: 1, paths: [path] }])
|
||||
expect(images.state(path)).toBe('pending')
|
||||
expect(repoImageInner(path, '概览卡片', images.state(path))).toBe(path)
|
||||
|
||||
expect(images.checked(0, [{ path }])).toBe(false)
|
||||
expect(images.state(path)).toBe('pending')
|
||||
expect(images.checked(1, [{ path }])).toBe(true)
|
||||
expect(images.state(path)).toBe('missing')
|
||||
expect(repoImageInner(path, '概览卡片', 'missing')).toBe(`找不到图片 ${path}`)
|
||||
expect(repoImageInner(path, '概览卡片', 'missing')).not.toContain('<img')
|
||||
|
||||
const src = 'https://file+.vscode-resource.vscode-cdn.net/OverviewSection.png'
|
||||
expect(images.checked(1, [{ path, src }])).toBe(true)
|
||||
expect(repoImageInner(path, '概览卡片', images.state(path))).toContain('<img')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { WebviewToExtension } from './types'
|
||||
|
||||
/**
|
||||
* 注释里当图片显示的文件后缀。
|
||||
* 扩展端 `isRepoImagePath` 用同一份:只放行这些,不给别的仓库文件做预览地址。
|
||||
*/
|
||||
const IMAGE_FILE = /\.(?:avif|bmp|gif|ico|jpe?g|png|svg|webp)$/i
|
||||
|
||||
/** 这个仓库相对路径是图片文件。路径安不安全由调用方先认。 */
|
||||
export function isRepoImageFile(path: string): boolean {
|
||||
return IMAGE_FILE.test(path)
|
||||
}
|
||||
|
||||
const ESCAPE: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/[&<>"]/g, char => ESCAPE[char] ?? char)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一张仓库图片现在该显示成什么。
|
||||
* 还没问到:路径文字。问到在:图片,点了走仓库链接。问到不在:「找不到图片」加路径,不是图片。
|
||||
*/
|
||||
export function repoImageInner(path: string, alt: string, state: 'pending' | 'missing' | { src: string }): string {
|
||||
if (state === 'pending')
|
||||
return escapeHtml(path)
|
||||
if (state === 'missing')
|
||||
return `找不到图片 ${escapeHtml(path)}`
|
||||
return `<a href="${escapeHtml(path)}"><img src="${escapeHtml(state.src)}" alt="${escapeHtml(alt)}"></a>`
|
||||
}
|
||||
|
||||
/** 渲染进注释的占位。先是路径文字,问完再换成图片或「找不到图片」。 */
|
||||
export function repoImagePlaceholder(path: string, alt: string): string {
|
||||
const shown = repoImageInner(path, alt, 'pending')
|
||||
return `<span class="cr-repo-image" data-path="${escapeHtml(path)}" data-alt="${escapeHtml(alt)}">${shown}</span>`
|
||||
}
|
||||
|
||||
/** 扩展端对一张仓库图片的回答。src 是预览里能直接用的地址;没有就是文件不在。 */
|
||||
export interface RepoImageAnswer {
|
||||
path: string
|
||||
src?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 注释、说明、问 AI 的回答里的仓库图片。
|
||||
* 页面上的路径变了才问一次;还没回的不算不在。不是最近一次的回答丢掉。
|
||||
*/
|
||||
export class RepoImages {
|
||||
private request = 0
|
||||
/** 最近一次问过的路径,没变就不再问 */
|
||||
private sent = ''
|
||||
/** 问到的结果。值没有(undefined)是文件不在 */
|
||||
private known = new Map<string, string | undefined>()
|
||||
|
||||
constructor(private readonly post: (msg: WebviewToExtension) => void) {}
|
||||
|
||||
/** 页面上现有的仓库图片。路径变了才问扩展端哪些在。 */
|
||||
scan(paths: readonly string[]): void {
|
||||
const unique = [...new Set(paths)].sort()
|
||||
const key = unique.join('\n')
|
||||
if (key === this.sent)
|
||||
return
|
||||
this.sent = key
|
||||
if (unique.length === 0)
|
||||
return
|
||||
this.request += 1
|
||||
this.post({ type: 'checkRepoImages', id: this.request, paths: unique })
|
||||
}
|
||||
|
||||
/** id 那次询问的回答。不是最近一次的丢掉,返回这次有没有用上。 */
|
||||
checked(id: number, images: readonly RepoImageAnswer[]): boolean {
|
||||
if (id !== this.request)
|
||||
return false
|
||||
this.known = new Map(images.map(image => [image.path, image.src]))
|
||||
return true
|
||||
}
|
||||
|
||||
/** 还没问到是 pending。问到不在是 missing。问到在了带预览地址。 */
|
||||
state(path: string): 'pending' | 'missing' | { src: string } {
|
||||
if (!this.known.has(path))
|
||||
return 'pending'
|
||||
const src = this.known.get(path)
|
||||
return src ? { src } : 'missing'
|
||||
}
|
||||
}
|
||||
@@ -401,6 +401,7 @@ export interface DocumentDiff {
|
||||
* repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径
|
||||
* repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有
|
||||
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在
|
||||
* repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在
|
||||
*/
|
||||
export type ExtensionToWebview
|
||||
= | DocumentMessage
|
||||
@@ -420,6 +421,7 @@ export type ExtensionToWebview
|
||||
| { type: 'repoLinksChecked', id: number, missing: string[] }
|
||||
| { type: 'repoLinkBroken', path: string, symbol?: string }
|
||||
| { type: 'repoLinkOpened', path: string, symbol?: string }
|
||||
| { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] }
|
||||
|
||||
/**
|
||||
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进)
|
||||
@@ -440,6 +442,7 @@ export type ExtensionToWebview
|
||||
* occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发
|
||||
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在资源管理器里定位目录
|
||||
* checkRepoLinks:预览里这批仓库路径,问哪些文件或目录没有。id 标出是哪一次问
|
||||
* checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问
|
||||
*/
|
||||
export type WebviewToExtension
|
||||
= | { type: 'ready' }
|
||||
@@ -460,3 +463,4 @@ export type WebviewToExtension
|
||||
| { type: 'occurrenceNavigation', available: boolean }
|
||||
| { type: 'openRepoLink', path: string, symbol?: string }
|
||||
| { type: 'checkRepoLinks', id: number, paths: string[] }
|
||||
| { type: 'checkRepoImages', id: number, paths: string[] }
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { repoImageInner, RepoImages } from './repoImage'
|
||||
import { onMessage, postMessage } from './vscode'
|
||||
|
||||
/** 按已经问到的结果把占位换成图片或「找不到图片」。还没问到的保持路径文字。 */
|
||||
function paint(images: RepoImages): void {
|
||||
for (const node of document.querySelectorAll<HTMLElement>('.cr-markdown .cr-repo-image')) {
|
||||
const path = node.dataset.path
|
||||
if (!path)
|
||||
continue
|
||||
const next = repoImageInner(path, node.dataset.alt ?? '', images.state(path))
|
||||
if (node.dataset.shown === next)
|
||||
continue
|
||||
node.innerHTML = next
|
||||
node.dataset.shown = next
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注释、说明、问 AI 的回答里的仓库图片,走同一条:画出来之后把路径发去问在不在。
|
||||
* 点图片打开原图靠里面的仓库链接,不另做点击。
|
||||
*/
|
||||
export function useRepoImages(): void {
|
||||
const [images] = useState(() => new RepoImages(postMessage))
|
||||
|
||||
useEffect(() => {
|
||||
const off = onMessage((msg) => {
|
||||
if (msg.type !== 'repoImagesChecked')
|
||||
return
|
||||
if (images.checked(msg.id, msg.images))
|
||||
paint(images)
|
||||
})
|
||||
let timer = 0
|
||||
const scan = (): void => {
|
||||
window.clearTimeout(timer)
|
||||
timer = window.setTimeout(() => {
|
||||
const nodes = [...document.querySelectorAll<HTMLElement>('.cr-markdown .cr-repo-image')]
|
||||
paint(images)
|
||||
images.scan(nodes.flatMap(node => node.dataset.path ? [node.dataset.path] : []))
|
||||
}, 50)
|
||||
}
|
||||
const observer = new MutationObserver(scan)
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
scan()
|
||||
return () => {
|
||||
off()
|
||||
observer.disconnect()
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [images])
|
||||
}
|
||||
Reference in new issue
Block a user