✨ feat(vscode): 注释里的仓库图片按比例显示

This commit is contained in:
cruldra committed 2026-10-03 03:22:20 +08:00
1 parent 57b2f2a115
commit 9faf9cde9e
13 files changed
+312 -8

No files matched your search

+2
View File
@@ -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('![图](https://example.com/a.png)')).not.toContain('<img')
})
it('合法仓库图片路径会变成图片;问到之前只是路径', () => {
const path = 'frontend/src/components/overview/screenshots/OverviewSection.png'
const html = renderMarkdown(`![概览卡片](${path})`)
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('![图](https://example.com/a.png)')).not.toContain('<img')
expect(renderMarkdown('![图](https://example.com/a.png)')).not.toContain('cr-repo-image')
expect(renderMarkdown('![图](../secret.png)')).not.toContain('<img')
expect(renderMarkdown('![图](../secret.png)')).not.toContain('cr-repo-image')
expect(renderMarkdown('![图](./local.png)')).not.toContain('<img')
expect(renderMarkdown('![图](./local.png)')).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')
+16 -2
View File
@@ -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> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
}
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])
}