From ad35898716913f8da4c597b59dded2db7f88bdbd Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 14 Oct 2026 23:30:36 +1100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E9=98=85=E8=AF=BB=E9=A2=84=E8=A7=88=E8=B7=B3=E5=88=B0?= =?UTF-8?q?=E8=BF=98=E6=B2=A1=E6=89=93=E5=BC=80=E7=9A=84=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=97=B6=E5=81=9C=E5=9C=A8=E6=96=87=E4=BB=B6=E9=A1=B6=E4=B8=8A?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=8F=91=E7=94=9F=EF=BC=9A=E6=96=B0=E5=BC=80?= =?UTF-8?q?=E7=9A=84=E9=A2=84=E8=A7=88=E6=B3=A8=E9=87=8A=E9=87=8C=E6=9C=89?= =?UTF-8?q?=E4=BB=93=E5=BA=93=E5=9B=BE=E7=89=87=E6=97=B6=EF=BC=8C=E9=97=AE?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=89=8D=E6=8A=8A=E4=BB=93=E5=BA=93=E6=A0=B9?= =?UTF-8?q?=E5=8A=A0=E8=BF=9B=20webview=20=E8=83=BD=E8=AF=BB=E7=9A=84?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=EF=BC=8C=E6=94=B9=E4=BA=86=E9=80=89=E9=A1=B9?= =?UTF-8?q?=20VS=20Code=20=E9=87=8D=E6=96=B0=E5=8A=A0=E8=BD=BD=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=EF=BC=8C=E7=AC=AC=E4=BA=8C=E6=AC=A1=20ready=20?= =?UTF-8?q?=E5=8F=91=E7=9A=84=E6=96=87=E6=A1=A3=E4=B8=8D=E5=B8=A6=E8=A6=81?= =?UTF-8?q?=E6=BB=9A=E5=88=B0=E7=9A=84=E9=82=A3=E5=A4=84=EF=BC=9B=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E8=83=BD=E8=AF=BB=E7=9A=84=E7=9B=AE=E5=BD=95=EF=BC=88?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E8=B5=84=E6=BA=90=E3=80=81=E9=97=AE=E7=AD=94?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E3=80=81=E8=BF=99=E4=B8=80=E9=A1=B5=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E9=93=BE=E6=8E=A5=E7=9A=84=E5=90=84=E4=B8=AA=E6=A0=B9?= =?UTF-8?q?=E5=8F=8A=E5=85=B6=E7=9C=9F=E5=AE=9E=E4=BD=8D=E7=BD=AE=EF=BC=89?= =?UTF-8?q?=E6=94=B9=E5=9C=A8=E8=A3=85=E9=A1=B5=E9=9D=A2=E4=B9=8B=E5=89=8D?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E5=AE=9A=E5=A5=BD=EF=BC=8C=E4=B9=8B=E5=90=8E?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=94=B9=E9=80=89=E9=A1=B9=EF=BC=8CLinkBase?= =?UTF-8?q?=20=E5=B8=A6=E4=B8=8A=E5=AE=83=E7=AE=97=E5=BE=97=E5=87=BA?= =?UTF-8?q?=E7=9A=84=E6=A0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/codeReader/editor/pathPreview.ts | 11 ++-- .../codeReader/editor/previewEditor.test.ts | 35 +++++++++++- vscode/src/codeReader/editor/previewEditor.ts | 55 +++++++++---------- 3 files changed, 66 insertions(+), 35 deletions(-) diff --git a/vscode/src/codeReader/editor/pathPreview.ts b/vscode/src/codeReader/editor/pathPreview.ts index ea95a83..e11f561 100644 --- a/vscode/src/codeReader/editor/pathPreview.ts +++ b/vscode/src/codeReader/editor/pathPreview.ts @@ -43,8 +43,11 @@ export function featureFolders(): NamedFolder[] { return (workspace.workspaceFolders ?? []).map(folder => ({ name: folder.name, path: folder.uri.fsPath })) } -/** 把页面上一条仓库链接的路径换成磁盘上的位置;算不出来的没有。 */ -export type LinkBase = (rel: string) => RepoPlace | undefined +/** 一页上的仓库链接从哪儿算:`place` 把一条链接的路径换成磁盘上的位置,算不出来的没有;算出来的位置都在 `roots` 这几个根下面。 */ +export interface LinkBase { + place: (rel: string) => RepoPlace | undefined + roots: string[] +} /** * 预览这一页上仓库链接的路径从哪儿算:功能对话页从这个功能找代码用的工作区文件夹算(见 [featureFolders]、[folderLinkPlace]), @@ -53,10 +56,10 @@ export type LinkBase = (rel: string) => RepoPlace | undefined export async function linkBaseOf(uri: Uri): Promise { if (featureIdOf(uri) !== undefined) { const folders = featureFolders() - return rel => folderLinkPlace(folders, rel) + return { place: rel => folderLinkPlace(folders, rel), roots: folders.map(folder => folder.path) } } const root = await repoRootOf(uri.fsPath) - return rel => root === undefined ? undefined : { root, rel } + return root === undefined ? { place: () => undefined, roots: [] } : { place: rel => ({ root, rel }), roots: [root] } } export function pathPreviewUri(target: Uri, kind: PathSubject): Uri { diff --git a/vscode/src/codeReader/editor/previewEditor.test.ts b/vscode/src/codeReader/editor/previewEditor.test.ts index d304ebb..743147d 100644 --- a/vscode/src/codeReader/editor/previewEditor.test.ts +++ b/vscode/src/codeReader/editor/previewEditor.test.ts @@ -76,7 +76,7 @@ vi.mock('./repoPath', async importOriginal => ({ repoRootOf: async () => '/workspace', resolveRepoEntry: async (root: string, rel: string) => { const fsPath = `${root}/${rel}` - const files = ['/workspace/web/a.ts', '/workspace/web/b.ts', '/workspace/docs/notes.txt', '/ws/web/src/app/(main)/page.tsx', '/srv/app/app/routes.py'] + const files = ['/workspace/web/a.ts', '/workspace/web/b.ts', '/workspace/docs/notes.txt', '/workspace/docs/home.png', '/ws/web/src/app/(main)/page.tsx', '/srv/app/app/routes.py'] return files.includes(fsPath) ? { fsPath, isDirectory: false } : undefined }, })) @@ -377,6 +377,27 @@ describe('预览对比什么看这个标签页是从哪儿打开的', () => { }) }) +/** 页面眼下能读的本地目录 */ +function readableRoots(page: ReturnType): unknown[] { + return (page.panel.webview.options.localResourceRoots ?? []).map(root => root.fsPath) +} + +describe('页面能读哪些本地目录', () => { + it('新开的预览装页面之前就放行仓库根:注释里的图片问到时不再改页面选项,打开时要滚到的那处还在', async () => { + const { provider, document } = opening() + const page = tab() + await provider.open(document.uri as never, { reveal: { line: 1 } }, async () => provider.resolveCustomTextEditor(document as never, page.panel)) + expect(readableRoots(page)).toContain('/workspace') + + const options = page.panel.webview.options + await page.send({ type: 'ready' }) + await page.send({ type: 'checkRepoImages', id: 1, paths: ['docs/home.png'] }) + expect(page.panel.webview.options).toBe(options) + expect(page.posted()).toContainEqual(expect.objectContaining({ type: 'document', reveal: { line: 1 } })) + expect(page.posted()).toContainEqual({ type: 'repoImagesChecked', id: 1, images: [{ path: 'docs/home.png', src: 'file:///workspace/docs/home.png' }] }) + }) +}) + describe('标签页切走时页面留着', () => { it('注册时就让页面在标签页切走后留着', () => { const { provider } = opening() @@ -719,7 +740,7 @@ describe('「了解一块代码」对话页上的仓库链接按这个功能找 const { provider, document } = opening() Object.assign(document, { uri: FEATURE_PAGE, fileName: FEATURE_PAGE.fsPath, languageId: 'plaintext', getText: () => '' }) const page = tab() - provider.resolveCustomTextEditor(document as never, page.panel) + await provider.resolveCustomTextEditor(document as never, page.panel) await page.send({ type: 'ready' }) page.clear() return page @@ -752,6 +773,16 @@ describe('「了解一块代码」对话页上的仓库链接按这个功能找 } }) + it('页面装好之前就放行这个功能找代码用的各个工作区文件夹', async () => { + try { + const page = await featurePage([WEB, SERVER]) + expect(readableRoots(page)).toEqual(expect.arrayContaining(['/ws/web', '/srv/app'])) + } + finally { + reset() + } + }) + it('点链接按同一个算法打开文件;带符号的点了、悬停都在那个文件的文档符号里找', async () => { try { const page = await featurePage([WEB, SERVER]) diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 560953f..00c02cb 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -149,22 +149,41 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { [...this.previews].find(p => p.uri.toString() === key && p.active)?.reopen(request) } - resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void { - // 问答带的图片(工作区文件夹的 .spx/code-qa/)也要能读,历史里才画得出缩略图 - const imageRoot = this.qa.imageRoot(topSubjectOf(document.uri).uri) - panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri), ...(imageRoot ? [imageRoot] : [])] } - panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html') + /** + * 新开一个预览:先接下给它的打开请求、接上消息,再装页面。页面能读的本地目录(见 [readableRoots])装之前一次定好,之后不再改: + * 改了 webview 的选项 VS Code 会重新加载页面,打开时要滚到的那处就丢了。 + */ + async resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): Promise { const key = document.uri.toString() const request = this.requests.get(key) this.requests.delete(key) const preview = new Preview(document, panel, this, this.registry, this.runtime, this.filter, request) this.previews.add(preview) + let disposed = false panel.onDidDispose(() => { + disposed = true this.previews.delete(preview) this.navigation.update(preview, false) this.finding.update(preview, false) preview.dispose() }) + const roots = await this.readableRoots(document.uri) + // 找目录的工夫标签页已经关了 + if (disposed) + return + panel.webview.options = { enableScripts: true, localResourceRoots: roots } + panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html') + } + + /** + * 预览页面能读的本地目录:页面自己的脚本和样式;问答带的图片所在的目录(工作区文件夹的 .spx/code-qa/),历史里才画得出缩略图; + * 注释、回答里的仓库图片所在的各个根(见 [linkBaseOf]),根的真实位置和这个路径不一样时两个都放,读不到真实位置就只放这个路径。 + */ + private async readableRoots(uri: Uri): Promise { + const imageRoot = this.qa.imageRoot(topSubjectOf(uri).uri) + const { roots } = await linkBaseOf(uri) + const repoRoots = await Promise.all(roots.map(async root => [root, await realpath(root).catch(() => root)])) + return [webviewUiRoot(this.extensionUri), ...(imageRoot ? [imageRoot] : []), ...[...new Set(repoRoots.flat())].map(root => Uri.file(root))] } /** 「跳到下一处 / 上一处高亮」:发给能跳的那个预览,从命令面板运行时发给活动的预览。 */ @@ -967,35 +986,13 @@ class Preview { }) } - /** 仓库根放进这个预览能读的本地目录,图片地址才能加载。真实位置和 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 base = await linkBaseOf(this.document.uri) const images = await Promise.all(paths.map(async (rel) => { - const place = isRepoImagePath(rel) ? base(rel) : undefined + const place = isRepoImagePath(rel) ? base.place(rel) : undefined if (!place) return { path: rel } - await this.allowRepoRoot(place.root) const entry = await resolveRepoEntry(place.root, place.rel) if (!entry || entry.isDirectory) return { path: rel } @@ -1164,7 +1161,7 @@ class Preview { /** 一条仓库链接指到磁盘上的哪个文件或目录,路径按这一页的算法算(见 [linkBaseOf]);算不出来、不在、跑出根的没有。 */ async function linkEntry(base: LinkBase, rel: string): Promise { - const place = base(rel) + const place = base.place(rel) return place && resolveRepoEntry(place.root, place.rel) }