✨ 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

+3 -3
View File
@@ -24,7 +24,7 @@
| 折叠 | 有子块的块、属性表里带子行的行、带下一层的子行(字典 / 对象里嵌套的字典 / 对象)能折叠;函数块(含方法、嵌套函数)、类块、枚举块、异常块和库规则的卡片默认收起主体、属性表照常显示,其余块、属性行和子行默认展开;用户点过的按他点的记住 |
| 函数、类的说明 | TypeScript 紧贴在函数、类前面的 JSDoc,Python 函数、类主体开头的 docstring 是它自己的说明:进属性表「说明」一行,不再单独成一块注释;它的源码行算在函数、类块的头部里,「编辑」时和签名一起改 |
| 变量、字段的说明 | Python 模块、类主体里紧跟在一条赋值后面的字符串(属性 docstring,PEP 257 / Sphinx 的写法)是这个变量的说明:常量、变量块进属性表「说明」一行(紧跟名称),字段块排在一行译文下面;不再单独成块,它的源码行算在这一块里,「编辑」时和赋值一起改 |
| 注释按 Markdown 显示 | 注释块和函数、类、变量、字段的「说明」按 Markdown 渲染,像 Kotlin 的 KDoc:去掉注释符号、引号和共同缩进后的原文原样交给 Markdown,不按语言转换(reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄,注释以后慢慢改成 Markdown 写法);段内单个换行是软换行,空行分段 |
| 注释按 Markdown 显示 | 注释块和函数、类、变量、字段的「说明」按 Markdown 渲染,像 Kotlin 的 KDoc:去掉注释符号、引号和共同缩进后的原文原样交给 Markdown,不按语言转换(reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄,注释以后慢慢改成 Markdown 写法);段内单个换行是软换行,空行分段;仓库内图片按原图比例显示,点了打开原图,没有就写「找不到图片」 |
| 跳到定义 | 和普通编辑器一样 ⌘ / Ctrl + 点击名字跳到定义,定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 |
| 高亮名字的各处出现 | 和普通编辑器一样单击名字就高亮它在这个文件里的各处出现,由语言扩展按语义认(同名的别的东西不算),颜色用 VS Code 的单词高亮色,再用 focusBorder 色补一笔(当前那一处描框、别的画细线);看不见的(折叠、筛掉的)点的时候不自动展开,筛选栏上写「名字 · 2/5」;⌘↓ / ⌘↑(可改绑的命令)在各处之间跳,跳到折叠着的地方会展开 |
| 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠,外加给目录 / 文件记注释、按审查工单只看 PR 改过的文件;不做新建 / 重命名 / 删除 |
@@ -441,7 +441,7 @@
- 用在:注释块(`#`、`//`、`/* */`、不挂在函数、类上的 JSDoc、Python 模块开头的 docstring 和别的当注释用的单独字符串)的预览,函数、类、常量、变量属性表的「说明」,字段译文下面的说明。视图数据里它们是 `{ markdown }`(见「通用契约」的 `Markdown`),webview 按数据决定怎么渲染,不猜。
- 原文:翻译插件去掉注释符号(`#`、`//`、`/*` `*/` 和每行开头的 `*`)、引号和中文名记号(`SourceDocument.markers`,见「中文名」),再去掉行尾空白、首尾空行和共同缩进,保留换行,不做别的转换。一个注释节点(块注释、docstring)的第一行紧跟在注释符号 / 引号后面,不参与算共同缩进(同 PEP 257);几条行注释连成的一块每行地位相同,第一行也算,缩进四格的代码块才留得住。
- 渲染:webview 用 markdown-it(`html: false`、`linkify: true`),CommonMark 加 GFM 的表格、删除线:段落、强调、行内代码(含双反引号)、代码块(围栏、缩进四格)、列表、链接、标题、引用、表格。段内单个换行是软换行,空行分段。同一段文字只渲染一次(缓存最近 500 段)。
- 安全:源码里的 HTML 当文字显示;链接认 http / https / mailto,带协议的网址和邮箱自动成链接(`sample.py` 这种不带协议的不算),别的写法(`javascript:`、`command:`、`./`、`..`、绝对路径、盘符)照原文显示。http / https / mailto 由 VS Code 的 webview 自己拦下、交给宿主在外部打开,不用另发消息。图片不渲染(webview 的 CSP 不放行外部图片),`![alt](url)` 显示成 `!` 加链接。
- 安全:源码里的 HTML 当文字显示;链接认 http / https / mailto,带协议的网址和邮箱自动成链接(`sample.py` 这种不带协议的不算),别的写法(`javascript:`、`command:`、`./`、`..`、绝对路径、盘符)照原文显示。http / https / mailto 由 VS Code 的 webview 自己拦下、交给宿主在外部打开,不用另发消息。http / https 图片不渲染(webview 的 CSP 不放行外部图片),`![alt](url)` 显示成 `!` 加链接。仓库内图片按原图比例显示,点了打开原图,没有就写「找不到图片」和路径。
- 仓库路径链接(`[名字](从仓库根起的路径)`,可带 `#类名` 或 `#类名.成员`)能点,点了打开文件并跳到符号。
- 样子:正文用界面字体(`--vscode-font-family`、`--vscode-font-size`)、comment 色、行高 20px,一行注释和一行译文一样高;行内代码用编辑器字体、`--vscode-textCodeBlock-background` 底;围栏代码块用编辑器字体和字号、同样的底,长行折行;段落、列表、代码块、表格之间 6px;标题只比正文稍大(h1 1.15em、h2 1.08em,其余同正文,加粗);链接 `--vscode-textLink-foreground`;引用左边 3px `--vscode-textBlockQuote-border` 竖线、`--vscode-textBlockQuote-background` 底;表格 1px 细线。
- 「编辑」状态照旧是源码,编辑写回不变;Markdown 里的名字不能点,不参与跳到定义。
@@ -2001,7 +2001,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit
- 输入参数表格:函数的「输入参数」行带列名 名称 / 类型 / 默认值(TS、Python 一样),默认值格子只有译文、没有就是空格子,别的行没有列名;webview 按有没有列名选表格还是列表,表格的格子按列数取,列宽模板(`tableTemplate`)只看列数。
- 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。
- `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、类、导入、常量、变量块只带属性表,其余块只带译文。
- 注释的 Markdown:注释块、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、JSDoc 标签、HTML 都不转换),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / `./` / `..` / 绝对路径的链接和图片不渲染,仓库路径链接能点、点了打开文件并跳到符号;跳到定义不认 Markdown 里的名字。
- 注释的 Markdown:注释块、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、JSDoc 标签、HTML 都不转换),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / `./` / `..` / 绝对路径的链接和 http 图片不渲染,仓库内图片按原图比例显示,点了打开原图,没有就写「找不到图片」,仓库路径链接能点、点了打开文件并跳到符号;跳到定义不认 Markdown 里的名字。
- 行替换:多行块替换后缩进正确。
- 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。
- 高亮各处出现:段的源码范围和出现重叠才亮、边界相接不算、没下标的不亮,讲别处名字的段(`refers`)按那个名字算、自己照抄的位置不算(`occurrences.test.ts` 的 `sourceOf`),点分模块名每一节各按自己的范围认(真实的 Python 译文和属性表:定义处按写入、用到处按读取);写入 → 强高亮色,读取、按文字 → 普通高亮色,同时碰上按写入;单击名字问、点空白处清、点按钮 / 链接 / 筛选栏不动、名字在按钮里照样问,拖选、双击第二下、带修饰键的点击不动,⌘ / Ctrl + 点击照旧发跳到定义、不问出现处;回答连同名字显示、空回答不亮、换名字时回答到前旧的留着、迟到的回答丢掉、答过「没有」再点照样再问、清掉后才到的回答丢掉、换文档版本或翻译清掉而同版本同翻译重发不清、没有文档时不问。键盘跳(`occurrences.test.ts`、`reveal.test.ts`、`editor/occurrenceNavigation.test.ts`):回答照源码先后排、点中的那处是当前那一处、「2/5」和「N 处」;往后 / 往前、到头接着跳,找不到的跳过、总数照旧、一处都找不到不动,点中的不在回答里从点中的地方数起;只有当前那一处描框;能不能跳(高亮、焦点、输入框)第一次必报、之后变了才报;页面上找第一处显示着它的地方、要展开的块 / 属性行 / 子行、跳过筛掉 / 编辑 / 盖住的块、Markdown 不算;`package.json` 的两条命令、默认键、`when`、命令面板,扩展和页面的代码里不写键、不拦键,上下文键有一个预览能跳就真、变了才设,命令发给能跳的预览、没有就发给活动的;按键那一刻的位置跳转时回一次、隔太久 / 点过鼠标 / 没按过键不回,跳完守住跳到的位置、过了 `KEY_SCROLL_MS` 或又按键、点鼠标不守。
+4
View File
@@ -98,6 +98,7 @@ export interface QaMessage {
* repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径
* repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在
* repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在
*/
export type ExtensionToWebview
= | DocumentMessage
@@ -117,6 +118,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(扩展端给每行补回原来的缩进)
@@ -138,6 +140,7 @@ export type ExtensionToWebview
* 扩展端按它设 when 子句用的上下文键
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在资源管理器里定位目录
* checkRepoLinks:预览里这批仓库路径,问哪些文件或目录没有。id 标出是哪一次问
* checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问
*/
export type WebviewToExtension
= | { type: 'ready' }
@@ -158,3 +161,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[] }
+49 -2
View File
@@ -12,6 +12,7 @@ import type { IndexedEntry } from '../workspaceIndex'
import type { BlockFilter } from './blockFilter'
import type { Definition, FoundLocation } from './definitions'
import type { ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages'
import { realpath } from 'node:fs/promises'
import { dirname } from 'node:path'
import { commands, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode'
import { logger } from '../../logging/logger'
@@ -32,7 +33,7 @@ import { DefinitionSources } from './definitionSources'
import { documentMessage } from './documentMessage'
import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation'
import { occurrencesOf } from './occurrences'
import { findRepoSymbol, repoRootOf, resolveRepoEntry } from './repoPath'
import { findRepoSymbol, isImageFile, isRepoImagePath, repoRootOf, resolveRepoEntry } from './repoPath'
export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview'
@@ -498,6 +499,9 @@ class Preview {
case 'checkRepoLinks':
await this.checkRepoLinks(msg.id, msg.paths)
return
case 'checkRepoImages':
await this.checkRepoImages(msg.id, msg.paths)
return
case 'occurrenceNavigation':
this.owner.navigation.update(this, msg.available)
}
@@ -580,6 +584,45 @@ class Preview {
this.post({ type: 'repoLinksChecked', id, missing })
}
/** 仓库根放进这个预览能读的本地目录,图片地址才能加载。真实位置和 git 给的路径不一样时两个都放。 */
private addLocalRoot(fsPath: string): void {
const uri = Uri.file(fsPath)
const roots = this.panel.webview.options.localResourceRoots ?? []
if (roots.some(root => root.fsPath === uri.fsPath))
return
this.panel.webview.options = { ...this.panel.webview.options, localResourceRoots: [...roots, uri] }
}
private async allowRepoRoot(root: string): Promise<void> {
this.addLocalRoot(root)
try {
const real = await realpath(root)
if (real !== root)
this.addLocalRoot(real)
}
catch {
// 仓库根这会儿读不到真实位置,就按 git 给的路径放行
}
}
/** 这批仓库图片哪些在。在的带回预览里能直接用的地址;不在、不是图片、跑出仓库的没有地址。 */
private async checkRepoImages(id: number, paths: string[]): Promise<void> {
const root = await repoRootOf(this.document.uri.fsPath)
if (root)
await this.allowRepoRoot(root)
const images = await Promise.all(paths.map(async (rel) => {
if (root === undefined || !isRepoImagePath(rel))
return { path: rel }
const entry = await resolveRepoEntry(root, rel)
if (!entry || entry.isDirectory)
return { path: rel }
const src = this.panel.webview.asWebviewUri(Uri.file(entry.fsPath)).toString()
return { path: rel, src }
}))
if (this.ready)
this.post({ type: 'repoImagesChecked', id, images })
}
/**
* 打开注释里的仓库链接:目录在资源管理器里定位,文件用编辑器打开。
* 带了符号就按文档符号的名字跳过去;文件或符号没有就不打开。
@@ -629,10 +672,14 @@ class Preview {
return Array.isArray(symbols) ? findRepoSymbol(symbols, symbol) : undefined
}
/** 在当前这个预览所在的编辑器组打开文件,并把预览标签固定住,免得被它替掉。 */
/** 在预览所在的编辑器组打开文件,并把预览标签固定住,免得被它替掉。图片用默认打开方式,才看得到图。 */
private async showRepoFile(uri: Uri, selection?: Range): Promise<void> {
if (this.panel.active && !workspace.getConfiguration('workbench.editor').get<boolean>('enablePreviewFromCodeNavigation', false))
await commands.executeCommand('workbench.action.keepEditor')
if (selection === undefined && isImageFile(uri.fsPath)) {
await commands.executeCommand('vscode.open', uri, { viewColumn: this.panel.viewColumn })
return
}
await window.showTextDocument(uri, { viewColumn: this.panel.viewColumn, ...(selection && { selection }) })
}
+13 -1
View File
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promis
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { findRepoSymbol, pathInsideRoot, resolveRepoEntry } from './repoPath'
import { findRepoSymbol, isRepoImagePath, pathInsideRoot, resolveRepoEntry } from './repoPath'
describe('pathInsideRoot', () => {
const root = '/repo'
@@ -21,6 +21,18 @@ describe('pathInsideRoot', () => {
})
})
describe('isRepoImagePath', () => {
it('仓库里的图片算,跑出去的和不是图片的不算', () => {
expect(isRepoImagePath('frontend/a.png')).toBe(true)
expect(isRepoImagePath('frontend/a.PNG')).toBe(true)
expect(isRepoImagePath('../a.png')).toBe(false)
expect(isRepoImagePath('./a.png')).toBe(false)
expect(isRepoImagePath('/a.png')).toBe(false)
expect(isRepoImagePath('C:/a.png')).toBe(false)
expect(isRepoImagePath('README.md')).toBe(false)
})
})
describe('findRepoSymbol', () => {
const tree = [
{
+13
View File
@@ -30,6 +30,19 @@ export function isSafeRepoPath(rel: string): boolean {
return segments.length > 0 && segments.every(segment => segment !== '' && segment !== '.' && segment !== '..')
}
/** 注释里当图片显示的文件后缀。webview 的 repoImage.ts 用同一份。 */
const IMAGE_FILE = /\.(?:avif|bmp|gif|ico|jpe?g|png|svg|webp)$/i
/** 文件名是图片。绝对路径也能用来判断,不检查有没有跑出仓库。 */
export function isImageFile(filePath: string): boolean {
return IMAGE_FILE.test(filePath)
}
/** 从仓库根起、能在注释里当图片显示的路径。别的仓库文件不给预览地址。 */
export function isRepoImagePath(rel: string): boolean {
return isSafeRepoPath(rel) && isImageFile(rel)
}
/** target 解析之后还在 root 里面(root 自己不算)。 */
export function staysInside(root: string, target: string): boolean {
const back = path.relative(path.resolve(root), path.resolve(target))
+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])
}