✨ feat(vscode): 注释里的仓库图片按比例显示
This commit is contained in:
1 parent
57b2f2a115
commit
9faf9cde9e
13 files changed
+312
-8
No files matched your search
@@ -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 不放行外部图片),`` 显示成 `!` 加链接。
|
||||
- 安全:源码里的 HTML 当文字显示;链接认 http / https / mailto,带协议的网址和邮箱自动成链接(`sample.py` 这种不带协议的不算),别的写法(`javascript:`、`command:`、`./`、`..`、绝对路径、盘符)照原文显示。http / https / mailto 由 VS Code 的 webview 自己拦下、交给宿主在外部打开,不用另发消息。http / https 图片不渲染(webview 的 CSP 不放行外部图片),`` 显示成 `!` 加链接。仓库内图片按原图比例显示,点了打开原图,没有就写「找不到图片」和路径。
|
||||
- 仓库路径链接(`[名字](从仓库根起的路径)`,可带 `#类名` 或 `#类名.成员`)能点,点了打开文件并跳到符号。
|
||||
- 样子:正文用界面字体(`--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` 或又按键、点鼠标不守。
|
||||
|
||||
@@ -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[] }
|
||||
@@ -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 }) })
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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