From ce5327dcd588f4edfcc2881659a9cd62340a3888 Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 12 Oct 2026 16:10:41 +1100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E8=B7=B3=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E3=80=81=E7=82=B9=E9=93=BE=E6=8E=A5=E3=80=81=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=88=87=E5=9B=9E=E9=A2=84=E8=A7=88=E4=B8=8D=E6=94=B9?= =?UTF-8?q?=E5=BC=80=E7=9D=80=E7=9A=84=E9=A2=84=E8=A7=88=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=E4=BB=80=E4=B9=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 10 +- vscode/src/codeReader/editor/messages.ts | 2 +- .../codeReader/editor/previewEditor.test.ts | 23 +++- vscode/src/codeReader/editor/previewEditor.ts | 42 +++++-- .../codeReader/editor/switchEditor.test.ts | 112 ++++++++++++++++++ vscode/src/codeReader/editor/switchEditor.ts | 83 +++++++++---- vscode/src/codeReader/index.ts | 12 +- vscode/webview-ui/src/codeReader/types.ts | 2 +- 8 files changed, 239 insertions(+), 47 deletions(-) create mode 100644 vscode/src/codeReader/editor/switchEditor.test.ts diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 66743d4..c6fff73 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -224,8 +224,10 @@ 审 AI 写的代码时,从只列改动的文件的地方(侧栏「未提交的更改」一栏、「选工单」审查)点开,预览的卡片标出这个文件相比改之前加了什么、改了什么、删了什么(原型插件菜单「代码差异」:画板「代码差异 · 全部 / 只看改动 / 标记说明」)。从主栏、收藏栏点开,点链接、跳定义这些别处打开的预览和平时一样,不标。 - 对比基准(`diff/diffBase.ts` 的 `DiffBase`,`previewDiffBases` 按文件在哪、这个标签页是从哪儿打开的给): - - 从侧栏「未提交的更改」一栏点开的、文件在工作区的仓库里有没提交的改动(`workspaceOrder`,审查的工作副本不算):对比上一次提交(HEAD),文案「对比:上一次提交」。从主栏、收藏栏点开,点仓库链接、⌘ / Ctrl + 点击跳到定义、点调用树、侧栏「问 AI」、编辑器右上角切回「预览」这些别处打开的不比。 - - 从哪儿打开的是打开请求自带的(`editor/previewEditor.ts` 的 `OpenRequest.from`:`uncommitted` / `other`):侧栏文件行的打开命令带上这一栏的 `from`(`explorer/fileTree.ts` 的 `FileOpenCommand`),`PreviewEditorProvider.open` 把请求交给新建的预览;打开的是已经开着的预览时交给切到的那个,以最后一次从哪儿点开为准(从「未提交的更改」点开就比,再从主栏点开同一个文件就不比);只切标签页不变。页面把它记进 webview 状态,VS Code 重启后恢复的标签页没有打开请求,随 `ready` 报回来,照关掉前的比;页面也没记过的(VS Code 自己换编辑器打开的)不比。 + - 从侧栏「未提交的更改」一栏点开的、文件在工作区的仓库里有没提交的改动(`workspaceOrder`,审查的工作副本不算):对比上一次提交(HEAD),文案「对比:上一次提交」。从主栏、收藏栏、「了解一块代码」栏点开的不比;点仓库链接、⌘ / Ctrl + 点击跳到定义、点调用树、侧栏「问 AI」新开的预览也不比。 + - 从哪儿打开的是打开请求自带的(`editor/previewEditor.ts` 的 `OpenRequest.from`:`uncommitted` / `other`):只有侧栏各栏点开文件带上,文件行的打开命令带上这一栏的 `from`(`explorer/fileTree.ts` 的 `FileOpenCommand`),`PreviewEditorProvider.open` 把请求交给新建的预览;打开的是已经开着的预览时交给切到的那个,以最后一次从侧栏哪一栏点开为准(从「未提交的更改」点开就比,再从主栏点开同一个文件就不比)。跳定义、点链接、点调用树、「问 AI」只是去看一眼,不带 `from`:新开的预览不比,打到已经开着的预览照旧;只切标签页也不变。 + - 编辑器右上角「编辑」再切回「预览」照切走前的比(`editor/switchEditor.ts` 的 `EditorSwitch`:切到文本编辑器时按编辑器组和文件记下预览是从哪儿打开的,切回时带上;那个文本编辑器标签页关了就忘掉,之后再点「预览」算新开的、不比)。 + - 页面把它记进 webview 状态,VS Code 重启后恢复的标签页没有打开请求,随 `ready` 报回来,照关掉前的比;页面也没记过的(新开的、VS Code 自己换编辑器打开的)不比。 - 审查工单、文件在这张工单的工作副本里(不管从哪儿打开):对比 PR 目标分支与 PR 头的合并基(Gitea PR 的 `merge_base`,没给时退回目标分支的提交,同看板「改动」tab)上的版本,改名的取改名前的路径(`reviewBaseFile`);文案「对比:PR 目标分支 」(PR 的 `base.ref`)。合并基是 PR 头的祖先,拉 PR 的提交时已经在本机,直接在工作副本里取。 - 取旧内容:`git/fileAt.ts` 的 `fileAtRef`(`git ls-tree` 看有没有这个文件、`git show <提交>:<路径>` 取内容,走 `runGit`);基准里没有这个文件(未跟踪、新加的、PR 里新加的、仓库还没有提交)不逐张标绿,顶部一条横幅「新文件,全部是新增」,后面淡色写原因(「还没被 git 跟踪,上一次提交里没有这个文件」「上一次提交里没有这个文件」「PR 目标分支 master 里没有这个文件」「仓库里还没有提交」)。 - 什么时候比(`diff/previewDiff.ts` 的 `PreviewDiff`,每个预览一个):每发出一份文档(编辑、保存、块编辑写回、换翻译)之后按眼下的基准重新比;基准挪了(盯着的 git 状态变了,比如提交后 HEAD 动了;审查进 / 重读工单)、开着的预览又被点开换了从哪儿打开的,打开着的预览也重新比。预览先照常画文档,差异算好了再补发(`diff` 消息),打开不变慢。取过的旧内容按基准的 key(提交 + 路径)记着,编辑时不用每次问 git;旧版本每次用同一个语言插件、翻译插件、库规则重新排成视图数据,规则查定义、调用的种类用这份文档眼下查到的答案,两边同样的写法翻得一样。取不到旧内容、旧版本解析失败时记日志、不标。 @@ -2080,7 +2082,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTab - `{ type: 'answerEnd', askId, status: 'done' | 'failed' | 'stopped', text?, error?, entryId? }`:答完了。`done` 带最终回答和存下的那条的 id(历史里不再重复显示它),`failed` 带给人看的原因,`stopped` 是被叫停、没存。 - `{ type: 'rewriteProgress', anchor, question, text, activity? }`:`anchor` 那块正在重写注释:`question` 是发给模型的那一句,`text` 是正文到现在为止写出来的,`activity` 是正在读什么。`{ type: 'rewriteEnd', anchor, status: 'done' | 'failed' | 'stopped', error? }`:重写完了(见「重写注释」)。 - `{ type: 'repoLinksChecked', id, missing, unresolved }`:回答第 `id` 次 `checkRepoLinks`,`missing` 是仓库里没有的路径,`unresolved` 是找不到的不带路径的引用。`{ type: 'repoLinkBroken' | 'repoLinkOpened', path?, symbol? }`:点了以后打不开 / 打开了;没有 `path` 的是不带路径的引用。见「注释的 Markdown 排版」。 - - `{ type: 'openedFrom', from: 'uncommitted' | 'other' }`:这个标签页是从哪儿打开的(见「代码差异」),页面记进 webview 状态。`ready` 后发一次,开着时又被点开再发。 + - `{ type: 'openedFrom', from: 'uncommitted' | 'other' }`:这个标签页是从哪儿打开的(见「代码差异」),页面记进 webview 状态。`ready` 后发一次,开着时又从侧栏点开、换了从哪儿打开的再发。 - webview → 扩展: - `{ type: 'probeDefinition', id, version, offset }`:按住修饰键指到带 `offset` 的名字上时问有没有定义,`id` 每问一次加一;`version` 不是当前文档版本就不答。 - `{ type: 'goToDefinition', version, offset }`:修饰键 + 点击,按「跳到定义」一节跳。这两条都只带下标:扩展端按发出去的文档认出规则标了引用的下标交给规则查(见「规则自己查定义」),其余问语言扩展。 @@ -2309,7 +2311,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 代码差异: - 块对齐和整棵树(`diff/blockDiff.test.ts`,真实的 Python 解析):原型的 `mutex.py`(改之前是编的)比出「新增 2 · 修改 3 · 删除 1」,新增块的子孙跟着算新增、只有最外面挂小标,`try_acquire` 自己改了、带属性表的标记,子块 `token` 改了、`ok` 和返回没变,`release` 只有子块改了(不算自己改了),删掉的 `_is_owner` 排在新增的 `renew` 后面、只带自己的内容、没有源码下标;没变的文件没有改动;子块里删掉的排在最前面时没有 after。对齐:有名字的按种类 + 名字配(挪了位置也配、同名照先后、种类不同不配、改名一删一增),没名字的按最长公共子序列和相似度配,种类不同不按相似度配。 - 卡片里的标记(`diff/viewDiff.test.ts`):只是源码下标不同不算改;大部分没变时逐截标、删掉的去掉下标,改得多整段旧值 → 新值;框按样式、框头、框里的词比;Markdown 按词、中文按字;一种文字换成另一种按字比;子行按第一列配(加行、删行放回原来的位置、改了的只标那一格、新加的行下面各层也是新加的);属性行按属性名配、子行改了不另标值;一行译文、摊开实参的表、说明各比各的;属性表和一行译文没法比。 - - 对比什么看从哪儿打开的(`diff/diffBase.test.ts`、`editor/previewEditor.test.ts`、`explorer/fileTree.test.ts`、`viewState.test.ts`):审查工作副本里的不管从哪儿打开都对比 PR 目标分支,别的只有从「未提交的更改」点开的对比上一次提交;新开的预览按打开请求、开着的再被点开换成这次的(不比了发 null)、只切标签页不变;没有打开请求的按页面记着的、页面没记过的不比,打开请求比页面记着的优先;「未提交的更改」一栏的文件行命令带 `uncommitted`、别的栏带 `other`;页面记着从哪儿打开的、认不得的值当没记过。 + - 对比什么看从哪儿打开的(`diff/diffBase.test.ts`、`editor/previewEditor.test.ts`、`explorer/fileTree.test.ts`、`viewState.test.ts`):审查工作副本里的不管从哪儿打开都对比 PR 目标分支,别的只有从「未提交的更改」点开的对比上一次提交;新开的预览按打开请求、开着的再从侧栏点开换成这次的(不比了发 null)、跳定义点链接打到开着的不变、新开的不比、只切标签页不变;「编辑」再切回「预览」照原来的,切过去的文本编辑器标签页关了就忘掉(`editor/switchEditor.test.ts`);没有打开请求的按页面记着的、页面没记过的不比,打开请求比页面记着的优先;「未提交的更改」一栏的文件行命令带 `uncommitted`、别的栏带 `other`;页面记着从哪儿打开的、认不得的值当没记过。 - 什么时候比(`diff/previewDiff.test.ts`):比完发出去(统计、删掉的块带种类名、一共几行);基准里没有这个文件是新文件;没有基准不发、发过后基准没了发一次 null;旧内容按 key 记着、key 变了重取;同一份文档同一个基准不再比;取不到旧内容记一笔、清掉、下次重取;慢的旧结果不盖掉新的。 - 取旧内容(`git/fileAt.test.ts`,真的 git):取的是提交里的、不是工作区改过的;提交里没有这个文件是 null;提交不存在抛错。对比基准的字(`uncommittedFiles.test.ts`、`reviewRules.test.ts`、`changedFiles.test.ts`):未跟踪的按状态认(两种 `git.untrackedChanges` 设置都认)、上一次提交里没有时的三种说明;PR 目标分支的文案;改名的取改名前的路径、PR 里新加的目标分支上没有。「只看改动」开关(`editor/blockFilter.test.ts`):记进 `workspaceState`、关了不记、改了发给所有预览带着眼下的块筛选。 - 页面(`diffView.test.ts`、`markdown.test.ts`、`viewState.test.ts`):块按记号认成页面上的 id、找不到的不标、不是这一版不画卡片里的标记,删掉的块按层挂、找不到挂处的和被筛掉的不画,新文件不逐块标,标了改动的默认展开;统计的字;删掉的块收起时的名字;只看改动折哪几段(改过的块下面按层折、删掉的块打断、被筛掉的不算、点开过的和要露出来的不折);各处改动的先后和行、被筛掉的不算;上一处 / 下一处接着走、按视口顶上的行找、到头绕回;带增删的 Markdown 照常排版、跨行逐行包、新加的列表项留住记号。 diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 9e80bf5..ff28c05 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -136,7 +136,7 @@ export interface StickyScrollSettings { * displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理 * diff:这份文件相比对比基准改了什么(审查工作副本里的、从「未提交的更改」点开的有改动的才有,见 [对比基准](vscode/src/codeReader/diff/diffBase.ts#DiffBase)):每发一份文档之后算好了补发, * 基准变了(提交后 HEAD 动了、换了审查的工单、文件有了改动、这个标签页换了从哪儿打开)再发;null 是不比了(退出了审查、改动提交了、从别处点开了) - * openedFrom:这个标签页是从哪儿打开的,页面记进自己的状态,VS Code 重启后恢复这个标签页时随 ready 报回来;ready 后发一次,开着时又被点开再发 + * openedFrom:这个标签页是从哪儿打开的,页面记进自己的状态,VS Code 重启后恢复这个标签页时随 ready 报回来;ready 后发一次,开着时又从侧栏点开、换了从哪儿打开的再发 * repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用 * repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到 * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了 diff --git a/vscode/src/codeReader/editor/previewEditor.test.ts b/vscode/src/codeReader/editor/previewEditor.test.ts index 4c8e499..5ff9cad 100644 --- a/vscode/src/codeReader/editor/previewEditor.test.ts +++ b/vscode/src/codeReader/editor/previewEditor.test.ts @@ -218,7 +218,28 @@ describe('预览对比什么看这个标签页是从哪儿打开的', () => { await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead)) }) - it('开着的预览再从别处点开就不比,再从「未提交的更改」点开又比;只切标签页不变', async () => { + it('跳定义、点链接打到开着的预览不改它对比什么、只滚过去;要新开的预览不比', async () => { + const { provider, document, diffBase } = opening() + const page = tab() + await provider.open(document.uri as never, { from: 'uncommitted' }, async () => provider.resolveCustomTextEditor(document as never, page.panel)) + await page.send({ type: 'ready' }) + await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead)) + + page.clear() + await provider.open(document.uri as never, { reveal: { line: 1 } }, async () => {}) + expect(page.posted()).toEqual([{ type: 'reveal', line: 1 }]) + expect(provider.openedFrom(document.uri as never)).toBe('uncommitted') + + diffBase.mockClear() + const fresh = tab() + await provider.open(document.uri as never, { reveal: { line: 1 } }, async () => provider.resolveCustomTextEditor(document as never, fresh.panel)) + await fresh.send({ type: 'ready' }) + expect(fresh.posted()).toContainEqual({ type: 'openedFrom', from: 'other' }) + expect(diffBase).toHaveBeenCalledWith(document.uri, 'other') + expect(diffBase).not.toHaveBeenCalledWith(document.uri, 'uncommitted') + }) + + it('开着的预览再从主栏点开就不比,再从「未提交的更改」点开又比;只切标签页不变', async () => { const { provider, document } = opening() const page = tab() await provider.open(document.uri as never, { from: 'uncommitted' }, async () => provider.resolveCustomTextEditor(document as never, page.panel)) diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index da87292..e59858f 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -54,9 +54,11 @@ const REFRESH_DELAY_MS = 150 /** * 一次打开预览带的话:这个标签页是从哪儿打开的(定对比什么);要滚到哪(从文本编辑器切过来时它顶部那一行,或跳到的定义); * 要不要把光标放进顶部「问 AI」输入框(侧栏「问 AI」打开的)。 + * + * 只有侧栏各栏点开文件、「编辑」切回「预览」带 `from`;跳定义、点链接、问 AI 这些只是去看一眼,不带:新开的预览不比,已经开着的照旧。 */ export interface OpenRequest { - from: OpenedFrom + from?: OpenedFrom reveal?: Reveal focusPath?: boolean } @@ -149,9 +151,19 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { /** uri 的预览视口顶部那块的起始行;几个编辑器组里都开着这个文件的预览时取活动的那个。 */ visibleLine(uri: Uri): number | undefined { + return this.shown(uri)?.visibleLine + } + + /** uri 的预览这个标签页是从哪儿打开的;几个编辑器组里都开着时取活动的那个,页面还没准备好、不知道的没有。 */ + openedFrom(uri: Uri): OpenedFrom | undefined { + return this.shown(uri)?.openedFrom + } + + /** uri 的预览:几个编辑器组里都开着时取活动的那个。 */ + private shown(uri: Uri): Preview | undefined { const key = uri.toString() const previews = [...this.previews].filter(p => p.uri.toString() === key) - return (previews.find(p => p.active) ?? previews[0])?.visibleLine + return previews.find(p => p.active) ?? previews[0] } } @@ -201,7 +213,7 @@ class Preview { * 之后页面因标签页切走重新加载,按 webview 自己记的位置恢复 */ private pendingReveal: Reveal | undefined - /** 这个标签页是从哪儿打开的,定对比什么;没带打开请求的(VS Code 重启后恢复的)先空着,页面 ready 时按它记着的定 */ + /** 这个标签页是从哪儿打开的,定对比什么;打开请求没说的(跳定义新开的、VS Code 重启后恢复的)先空着,页面 ready 时按它记着的定 */ private from: OpenedFrom | undefined constructor( @@ -334,6 +346,10 @@ class Preview { return this.panel.active } + get openedFrom(): OpenedFrom | undefined { + return this.from + } + async push(): Promise { clearTimeout(this.timer) if (!this.ready) @@ -470,13 +486,15 @@ class Preview { this.post({ type: 'moveOccurrence', step }) } - /** 开着时又被点开(见 [PreviewEditorProvider.open]):按这次是从哪儿打开的重新比,要滚到哪、要不要聚焦输入框照这次的。 */ + /** 开着时又被点开(见 [PreviewEditorProvider.open]):这次说了从哪儿打开的就按它重新比,没说的照旧;要滚到哪、要不要聚焦输入框照这次的。 */ reopen(request: OpenRequest): void { - this.from = request.from - if (this.ready) { - this.post({ type: 'openedFrom', from: request.from }) - if (this.sent) - void this.diff.update(this.sent) + if (request.from !== undefined) { + this.from = request.from + if (this.ready) { + this.post({ type: 'openedFrom', from: request.from }) + if (this.sent) + void this.diff.update(this.sent) + } } if (request.reveal) this.reveal(request.reveal) @@ -575,7 +593,7 @@ class Preview { switch (msg.type) { case 'ready': this.ready = true - // 打开请求说了从哪儿打开的就按它;没说的(VS Code 重启后恢复的)按页面记着的,页面也没记过的不算从「未提交的更改」点开的 + // 打开请求说了从哪儿打开的就按它;没说的按页面记着的(VS Code 重启后恢复的),页面也没记过的(新开的)不比 this.from ??= msg.openedFrom ?? 'other' this.post({ type: 'openedFrom', from: this.from }) // 先于文档发:第一份文档按筛选画,不会先整份露出来再藏 @@ -794,7 +812,7 @@ class Preview { } /** - * 在预览所在的编辑器组打开别的文件,和普通编辑器里 ⌘ + 点击一样:语言有代码阅读的用预览打开(算从别处打开的,不对比上一次提交),给了 spot 就滚过去、闪这处; + * 在预览所在的编辑器组打开别的文件,和普通编辑器里 ⌘ + 点击一样:语言有代码阅读的用预览打开(新开的不比,已经开着的照旧),给了 spot 就滚过去、闪这处; * 其余用 VS Code 默认的打开方式,给了 spot 就选中它(见 openingFor)。目标是否按「预览标签」打开听 VS Code 的设置, * workbench.editor.enablePreviewFromCodeNavigation 为假时先把当前这个预览标签固定住,免得被目标替掉。 */ @@ -807,7 +825,7 @@ class Preview { await commands.executeCommand('vscode.open', uri, { ...options, ...(opening.selection && { selection: opening.selection }) }) return } - await this.owner.open(uri, { from: 'other', ...(opening.reveal && { reveal: opening.reveal }) }, () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options)) + await this.owner.open(uri, opening.reveal ? { reveal: opening.reveal } : {}, () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options)) } /** 点了调用树上的一个节点:打开它在的文件,滚到这一处(同 ⌘ + 点击跳到别的文件)。 */ diff --git a/vscode/src/codeReader/editor/switchEditor.test.ts b/vscode/src/codeReader/editor/switchEditor.test.ts new file mode 100644 index 0000000..5ad0691 --- /dev/null +++ b/vscode/src/codeReader/editor/switchEditor.test.ts @@ -0,0 +1,112 @@ +import type { OpenedFrom } from '../diff/diffBase' +import type { OpenRequest } from './previewEditor' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { EditorSwitch } from './switchEditor' + +/** 假的 VS Code:文件、标签页的输入、活动的文本编辑器、标签页关掉的通知 */ +const fake = vi.hoisted(() => { + class Uri { + constructor(readonly path: string) {} + toString(): string { + return `file://${this.path}` + } + } + class TabInputText { + constructor(readonly uri: Uri) {} + } + class TabInputCustom { + constructor(readonly uri: Uri, readonly viewType: string) {} + } + const tabListeners = new Set<(event: { closed: unknown[] }) => void>() + const window = { + activeTextEditor: undefined as unknown, + visibleTextEditors: [] as unknown[], + onDidChangeVisibleTextEditors: () => ({ dispose: () => {} }), + tabGroups: { + activeTabGroup: { activeTab: undefined }, + onDidChangeTabs: (listener: (event: { closed: unknown[] }) => void) => { + tabListeners.add(listener) + return { dispose: () => tabListeners.delete(listener) } + }, + }, + } + return { Uri, TabInputText, TabInputCustom, window, tabListeners, executeCommand: vi.fn() } +}) + +vi.mock('vscode', () => ({ + Uri: fake.Uri, + TabInputText: fake.TabInputText, + TabInputCustom: fake.TabInputCustom, + window: fake.window, + commands: { executeCommand: fake.executeCommand }, + Range: class { + constructor(readonly startLine: number, readonly startCharacter: number, readonly endLine: number, readonly endCharacter: number) {} + }, + TextEditorRevealType: { AtTop: 3 }, +})) +vi.mock('./previewEditor', () => ({ PREVIEW_VIEW_TYPE: 'superpowers.codeReader.preview' })) + +const uri = new fake.Uri('/workspace/web/a.ts') +/** 第 1 个编辑器组里 a.ts 的文本编辑器,顶部是第 10 行 */ +const editor = { document: { uri, lineCount: 100 }, viewColumn: 1, visibleRanges: [{ start: { line: 10 } }], revealRange: vi.fn() } + +/** 开着的预览:从 from 打开的;open 照 VS Code 那样执行打开 */ +function previews(from: OpenedFrom | undefined) { + return { + visibleLine: () => 10, + openedFrom: () => from, + open: vi.fn(async (_uri: unknown, _request: OpenRequest, show: () => Thenable) => { + await show() + }), + } +} + +beforeEach(() => { + fake.window.activeTextEditor = undefined + fake.window.visibleTextEditors = [] + fake.tabListeners.clear() + // 换成文本编辑器后,a.ts 的文本编辑器就是活动的那个 + fake.executeCommand.mockImplementation(async (command: string, editorId: string) => { + if (command === 'reopenActiveEditorWith' && editorId === 'default') { + fake.window.activeTextEditor = editor + fake.window.visibleTextEditors = [editor] + } + }) +}) + +describe('编辑器右上角「预览 / 编辑」切换', () => { + it('预览切到文本编辑器再切回来:照切走前的从哪儿打开,滚到文本编辑器顶部那一行', async () => { + const opened = previews('uncommitted') + const switcher = new EditorSwitch(opened as never) + await switcher.showSource([uri, { groupId: 1 }]) + await switcher.showPreview([uri, { groupId: 1 }]) + + expect(opened.open).toHaveBeenLastCalledWith(uri, { from: 'uncommitted', reveal: { line: 10 } }, expect.any(Function)) + switcher.dispose() + }) + + it('文本编辑器不是从预览切过来的:切到预览不带从哪儿打开(新开的预览不比)', async () => { + const opened = previews('uncommitted') + const switcher = new EditorSwitch(opened as never) + fake.window.activeTextEditor = editor + fake.window.visibleTextEditors = [editor] + await switcher.showPreview([uri, { groupId: 1 }]) + + expect(opened.open).toHaveBeenLastCalledWith(uri, { reveal: { line: 10 } }, expect.any(Function)) + switcher.dispose() + }) + + it('切过去的那个文本编辑器标签页关了就忘掉;别的编辑器组里关的不算', async () => { + const opened = previews('uncommitted') + const switcher = new EditorSwitch(opened as never) + await switcher.showSource([uri, { groupId: 1 }]) + fake.tabListeners.forEach(listener => listener({ closed: [{ input: new fake.TabInputText(uri), group: { viewColumn: 2 } }] })) + await switcher.showPreview([uri, { groupId: 1 }]) + expect(opened.open).toHaveBeenLastCalledWith(uri, { from: 'uncommitted', reveal: { line: 10 } }, expect.any(Function)) + + fake.tabListeners.forEach(listener => listener({ closed: [{ input: new fake.TabInputText(uri), group: { viewColumn: 1 } }] })) + await switcher.showPreview([uri, { groupId: 1 }]) + expect(opened.open).toHaveBeenLastCalledWith(uri, { reveal: { line: 10 } }, expect.any(Function)) + switcher.dispose() + }) +}) diff --git a/vscode/src/codeReader/editor/switchEditor.ts b/vscode/src/codeReader/editor/switchEditor.ts index ee22e68..0cb0f73 100644 --- a/vscode/src/codeReader/editor/switchEditor.ts +++ b/vscode/src/codeReader/editor/switchEditor.ts @@ -1,6 +1,7 @@ -import type { Disposable, TextEditor } from 'vscode' +import type { Disposable, TextEditor, ViewColumn } from 'vscode' +import type { OpenedFrom } from '../diff/diffBase' import type { PreviewEditorProvider } from './previewEditor' -import { commands, Range, TabInputCustom, TextEditorRevealType, Uri, window } from 'vscode' +import { commands, Range, TabInputCustom, TabInputText, TextEditorRevealType, Uri, window } from 'vscode' import { PREVIEW_VIEW_TYPE } from './previewEditor' /** VS Code 自带普通文本编辑器的 editor id。 */ @@ -51,27 +52,63 @@ function waitForTextEditor(uri: Uri): Promise { }).finally(() => disposables.forEach(d => d.dispose())) } -/** 文本编辑器 → 预览(同一标签页原位替换,算从别处打开的):新预览先滚到文本编辑器顶部那一行所在的块。 */ -export async function showPreview(previews: PreviewEditorProvider, args: unknown[]): Promise { - const uri = resourceArg(args) ?? window.activeTextEditor?.document.uri - const topLine = uri ? textEditorOf(uri)?.visibleRanges[0]?.start.line : undefined - const reopen = (): Thenable => commands.executeCommand(REOPEN_WITH, PREVIEW_VIEW_TYPE, ...args) - if (uri) - await previews.open(uri, { from: 'other', ...(topLine !== undefined && { reveal: { line: topLine } }) }, reopen) - else - await reopen() +/** 一个编辑器组里的一个文件:同一个标签页在预览和文本编辑器之间换着看时按它认。 */ +function tabKey(viewColumn: ViewColumn | undefined, uri: Uri): string { + return `${viewColumn ?? ''}:${uri.toString()}` } -/** 预览 → 文本编辑器(同一标签页原位替换):把预览视口顶部那块的起始行放到文本编辑器顶部。 */ -export async function showSource(previews: PreviewEditorProvider, args: unknown[]): Promise { - const uri = resourceArg(args) ?? activeCustomEditorUri() - const line = uri ? previews.visibleLine(uri) : undefined - await commands.executeCommand(REOPEN_WITH, TEXT_EDITOR, ...args) - if (!uri || line === undefined) - return - const editor = await waitForTextEditor(uri) - if (!editor) - return - const target = Math.min(line, editor.document.lineCount - 1) - editor.revealRange(new Range(target, 0, target, 0), TextEditorRevealType.AtTop) +/** + * 编辑器右上角的「预览 / 编辑」:同一个标签页原位换成另一种编辑器,换回预览时照切走前的样子对比,不算从别处重新打开。 + * 切走的预览是从哪儿打开的按编辑器组和文件记着,换上的那个文本编辑器标签页关了就忘掉。 + */ +export class EditorSwitch implements Disposable { + /** 切到文本编辑器的预览原来是从哪儿打开的,按 [tabKey] 记 */ + private readonly sourceFrom = new Map() + private readonly closing: Disposable + + constructor(private readonly previews: Pick) { + this.closing = window.tabGroups.onDidChangeTabs(({ closed }) => { + for (const tab of closed) { + if (tab.input instanceof TabInputText) + this.sourceFrom.delete(tabKey(tab.group.viewColumn, tab.input.uri)) + } + }) + } + + /** 文本编辑器 → 预览:新预览先滚到文本编辑器顶部那一行所在的块;这个文本编辑器是从预览切过来的,照原来的从哪儿打开。 */ + async showPreview(args: unknown[]): Promise { + const uri = resourceArg(args) ?? window.activeTextEditor?.document.uri + const reopen = (): Thenable => commands.executeCommand(REOPEN_WITH, PREVIEW_VIEW_TYPE, ...args) + if (!uri) { + await reopen() + return + } + const editor = textEditorOf(uri) + const topLine = editor?.visibleRanges[0]?.start.line + const from = editor && this.sourceFrom.get(tabKey(editor.viewColumn, uri)) + await this.previews.open(uri, { ...(from && { from }), ...(topLine !== undefined && { reveal: { line: topLine } }) }, reopen) + } + + /** 预览 → 文本编辑器:把预览视口顶部那块的起始行放到文本编辑器顶部,记下这个预览是从哪儿打开的。 */ + async showSource(args: unknown[]): Promise { + const uri = resourceArg(args) ?? activeCustomEditorUri() + const line = uri ? this.previews.visibleLine(uri) : undefined + const from = uri ? this.previews.openedFrom(uri) : undefined + await commands.executeCommand(REOPEN_WITH, TEXT_EDITOR, ...args) + if (!uri || (line === undefined && from === undefined)) + return + const editor = await waitForTextEditor(uri) + if (!editor) + return + if (from) + this.sourceFrom.set(tabKey(editor.viewColumn, uri), from) + if (line === undefined) + return + const target = Math.min(line, editor.document.lineCount - 1) + editor.revealRange(new Range(target, 0, target, 0), TextEditorRevealType.AtTop) + } + + dispose(): void { + this.closing.dispose() + } } diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index ad32cee..9811583 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -16,7 +16,7 @@ import { NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigat import { languageOf, openingFor } from './editor/openTarget' import { PATH_PREVIEW_SCHEME } from './editor/pathPreview' import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor' -import { showPreview, showSource } from './editor/switchEditor' +import { EditorSwitch } from './editor/switchEditor' import { WorkspaceEntries } from './entries/workspaceEntries' import { registerCopyCommands } from './explorer/copyCommands' import { FileTreeProvider } from './explorer/fileTree' @@ -135,9 +135,10 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { uri => locator.reveal(uri), (subject, request) => features.ask(subject, request), ) + const switcher = new EditorSwitch(previews) const openChat = async (page: Uri | undefined): Promise => { if (page) - await previews.open(page, { from: 'other', focusPath: true }) + await previews.open(page, { focusPath: true }) } context.subscriptions.push( @@ -153,7 +154,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { const languageId = await languageOf(uri) return languageId !== undefined && registry.languageFor(languageId) !== undefined }, - uri => previews.open(uri, { from: 'other', focusPath: true }), + uri => previews.open(uri, { focusPath: true }), ), workspace.registerTextDocumentContentProvider(PATH_PREVIEW_SCHEME, { provideTextDocumentContent: () => '', @@ -171,6 +172,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { favorites, features, heads, + switcher, // 主栏审查时刷新是重读 PR 改过的文件,平时是重读工作区文件树 commands.registerCommand('superpowers.codeReader.refresh', () => review.reviewing ? review.reload() : files.refresh()), commands.registerCommand('superpowers.codeReader.pickReviewIssue', () => review.pick()), @@ -201,8 +203,8 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { await openFile(found.uri, 'other', registry, previews, found.spot) }), // 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签 - commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => showPreview(previews, args)), - commands.registerCommand('superpowers.codeReader.showSource', (...args: unknown[]) => showSource(previews, args)), + commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => switcher.showPreview(args)), + commands.registerCommand('superpowers.codeReader.showSource', (...args: unknown[]) => switcher.showSource(args)), commands.registerCommand(NEXT_OCCURRENCE, () => previews.moveOccurrence(1)), commands.registerCommand(PREVIOUS_OCCURRENCE, () => previews.moveOccurrence(-1)), ) diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 87e68f5..195d946 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -468,7 +468,7 @@ export type OpenedFrom = 'uncommitted' | 'other' * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现 * displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理 * diff:这份文件相比对比基准改了什么(审查工作副本里的、从「未提交的更改」点开的有改动的文件才有);每来一份文档之后补来,基准变了、换了从哪儿打开再来;null 是不比了 - * openedFrom:这个标签页是从哪儿打开的,记进页面状态;ready 后来一次,开着时又被点开再来 + * openedFrom:这个标签页是从哪儿打开的,记进页面状态;ready 后来一次,开着时又从侧栏点开、换了从哪儿打开的再来 * repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用 * repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到 * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了