diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 31a4988..ce0ef54 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -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` 或又按键、点鼠标不守。 diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index cae646c..7866189 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -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[] } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index c0b6d3b..d5a07da 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -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 { + 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 { + 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 { if (this.panel.active && !workspace.getConfiguration('workbench.editor').get('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 }) }) } diff --git a/vscode/src/codeReader/editor/repoPath.test.ts b/vscode/src/codeReader/editor/repoPath.test.ts index 0624565..633ae63 100644 --- a/vscode/src/codeReader/editor/repoPath.test.ts +++ b/vscode/src/codeReader/editor/repoPath.test.ts @@ -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 = [ { diff --git a/vscode/src/codeReader/editor/repoPath.ts b/vscode/src/codeReader/editor/repoPath.ts index b08d6fb..ab47aaa 100644 --- a/vscode/src/codeReader/editor/repoPath.ts +++ b/vscode/src/codeReader/editor/repoPath.ts @@ -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)) diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index b596d40..436f791 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -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 ? { diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index d1bd854..56ad74e 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -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); diff --git a/vscode/webview-ui/src/codeReader/markdown.test.ts b/vscode/webview-ui/src/codeReader/markdown.test.ts index 8905d3e..e2bf453 100644 --- a/vscode/webview-ui/src/codeReader/markdown.test.ts +++ b/vscode/webview-ui/src/codeReader/markdown.test.ts @@ -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(' { + 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('概览卡片`) + }) + + it('http、..、./ 的图片不渲染', () => { + expect(renderMarkdown('![图](https://example.com/a.png)')).not.toContain(' { + const shown = repoImageInner('frontend/missing.png', '图', 'missing') + expect(shown).toBe('找不到图片 frontend/missing.png') + expect(shown).not.toContain(' { expect(renderMarkdown('[智能体定义](vendor/agents)')).toBe('

智能体定义

\n') expect(renderMarkdown('[智能体表](backend/src/luffy_agent/agent/models.py#Agent)')).toBe('

智能体表

\n') diff --git a/vscode/webview-ui/src/codeReader/markdown.ts b/vscode/webview-ui/src/codeReader/markdown.ts index f0be7cd..cdfaaa1 100644 --- a/vscode/webview-ui/src/codeReader/markdown.ts +++ b/vscode/webview-ui/src/codeReader/markdown.ts @@ -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 `!${esc(alt)}` +} + /** 链接的标题(`[文字](网址 "标题")`)写成 data-tooltip,和页面上别的悬停提示走同一个提示框(components/ui/tooltip.tsx),不用原生 title。 */ md.core.ruler.push('link_title_tooltip', (state) => { for (const block of state.tokens) { diff --git a/vscode/webview-ui/src/codeReader/repoImage.test.ts b/vscode/webview-ui/src/codeReader/repoImage.test.ts new file mode 100644 index 0000000..7f15590 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/repoImage.test.ts @@ -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(' = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', +} + +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 `${escapeHtml(alt)}` +} + +/** 渲染进注释的占位。先是路径文字,问完再换成图片或「找不到图片」。 */ +export function repoImagePlaceholder(path: string, alt: string): string { + const shown = repoImageInner(path, alt, 'pending') + return `${shown}` +} + +/** 扩展端对一张仓库图片的回答。src 是预览里能直接用的地址;没有就是文件不在。 */ +export interface RepoImageAnswer { + path: string + src?: string +} + +/** + * 注释、说明、问 AI 的回答里的仓库图片。 + * 页面上的路径变了才问一次;还没回的不算不在。不是最近一次的回答丢掉。 + */ +export class RepoImages { + private request = 0 + /** 最近一次问过的路径,没变就不再问 */ + private sent = '' + /** 问到的结果。值没有(undefined)是文件不在 */ + private known = new Map() + + 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' + } +} diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 713deba..8120405 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -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[] } diff --git a/vscode/webview-ui/src/codeReader/useRepoImages.ts b/vscode/webview-ui/src/codeReader/useRepoImages.ts new file mode 100644 index 0000000..92b788c --- /dev/null +++ b/vscode/webview-ui/src/codeReader/useRepoImages.ts @@ -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('.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('.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]) +}