From 3c7513b7cd32f945b08ca5bccda48c5ebb632f4f Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 12 Oct 2026 15:50:30 +1100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E5=8F=AA=E6=9C=89?= =?UTF-8?q?=E4=BB=8E=E3=80=8C=E6=9C=AA=E6=8F=90=E4=BA=A4=E7=9A=84=E6=9B=B4?= =?UTF-8?q?=E6=94=B9=E3=80=8D=E7=82=B9=E5=BC=80=E7=9A=84=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=89=8D=E5=AF=B9=E6=AF=94=E4=B8=8A=E4=B8=80=E6=AC=A1=E6=8F=90?= =?UTF-8?q?=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 23 +-- vscode/src/codeReader/diff/diffBase.test.ts | 24 +-- vscode/src/codeReader/diff/diffBase.ts | 30 ++-- vscode/src/codeReader/diff/previewDiff.ts | 2 +- vscode/src/codeReader/editor/messages.ts | 10 +- .../codeReader/editor/previewEditor.test.ts | 144 ++++++++++++++++++ vscode/src/codeReader/editor/previewEditor.ts | 124 ++++++++------- vscode/src/codeReader/editor/switchEditor.ts | 6 +- .../src/codeReader/explorer/fileTree.test.ts | 49 ++++-- vscode/src/codeReader/explorer/fileTree.ts | 13 +- .../src/codeReader/favorites/favoriteViews.ts | 4 +- vscode/src/codeReader/index.ts | 33 ++-- .../src/codeReader/uncommitted/headBases.ts | 2 +- .../codeReader/uncommitted/uncommittedView.ts | 4 +- vscode/webview-ui/src/codeReader/App.tsx | 9 +- vscode/webview-ui/src/codeReader/types.ts | 10 +- .../src/codeReader/viewState.test.ts | 8 + vscode/webview-ui/src/codeReader/viewState.ts | 14 +- 18 files changed, 366 insertions(+), 143 deletions(-) diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index a322f8d..66743d4 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -221,13 +221,14 @@ ### 代码差异 -审 AI 写的代码时,侧栏只列改动的文件(「未提交的更改」「选工单」审查),预览的卡片标出这个文件相比改之前加了什么、改了什么、删了什么(原型插件菜单「代码差异」:画板「代码差异 · 全部 / 只看改动 / 标记说明」)。普通文件树下预览和平时一样,不标。 +审 AI 写的代码时,从只列改动的文件的地方(侧栏「未提交的更改」一栏、「选工单」审查)点开,预览的卡片标出这个文件相比改之前加了什么、改了什么、删了什么(原型插件菜单「代码差异」:画板「代码差异 · 全部 / 只看改动 / 标记说明」)。从主栏、收藏栏点开,点链接、跳定义这些别处打开的预览和平时一样,不标。 -- 对比基准(`diff/diffBase.ts` 的 `DiffBase`,由开着的侧栏筛选给:`TreeFilter.diffBase`,`TreeFilters` 转给预览): - - 「未提交的更改」开着、文件在工作区的仓库里(`workspaceOrder`,审查的工作副本不算):对比上一次提交(HEAD),文案「对比:上一次提交」。 - - 审查工单、文件在这张工单的工作副本里:对比 PR 目标分支与 PR 头的合并基(Gitea PR 的 `merge_base`,没给时退回目标分支的提交,同看板「改动」tab)上的版本,改名的取改名前的路径(`reviewBaseFile`);文案「对比:PR 目标分支 」(PR 的 `base.ref`)。合并基是 PR 头的祖先,拉 PR 的提交时已经在本机,直接在工作副本里取。 +- 对比基准(`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 自己换编辑器打开的)不比。 + - 审查工单、文件在这张工单的工作副本里(不管从哪儿打开):对比 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;旧版本每次用同一个语言插件、翻译插件、库规则重新排成视图数据,规则查定义、调用的种类用这份文档眼下查到的答案,两边同样的写法翻得一样。取不到旧内容、旧版本解析失败时记日志、不标。 +- 什么时候比(`diff/previewDiff.ts` 的 `PreviewDiff`,每个预览一个):每发出一份文档(编辑、保存、块编辑写回、换翻译)之后按眼下的基准重新比;基准挪了(盯着的 git 状态变了,比如提交后 HEAD 动了;审查进 / 重读工单)、开着的预览又被点开换了从哪儿打开的,打开着的预览也重新比。预览先照常画文档,差异算好了再补发(`diff` 消息),打开不变慢。取过的旧内容按基准的 key(提交 + 路径)记着,编辑时不用每次问 git;旧版本每次用同一个语言插件、翻译插件、库规则重新排成视图数据,规则查定义、调用的种类用这份文档眼下查到的答案,两边同样的写法翻得一样。取不到旧内容、旧版本解析失败时记日志、不标。 - 块怎么对上(`diff/blockDiff.ts`,纯函数):同一父块下的兄弟块对齐——有名字的块(`defines`:函数名、类名、只声明一个变量时的变量名、规则卡片讲的名字)按「种类 + 名字」配,同名的照先后,挪了位置也配得上;没名字的块(语句、调用、注释……)以此为锚,最长公共子序列找出种类、源码都没变的,夹在相邻两个锚中间的按先后找种类相同、内容最像(按词比,相似度到一半)的配成修改。配不上的旧有新无算删除、旧无新有算新增;改名算一删一增。配上的块整段源码(去共同缩进)一样算没变,否则算修改,接着比子块;自己的内容改了、或源码变了子块却都没变的,是「自己改了」(上一处 / 下一处、总览条停在它上面)。 - 卡片里标什么(`diff/viewDiff.ts`,纯函数):修改的卡片在渲染用的视图数据这一层比(翻译好的属性表、一行译文、摊开实参的表、说明),所有语言、库规则的卡片走同一条路: - 属性表按属性名配行;配上的行值变了:着色的词按词(一段着色的词一个)对齐,大部分没变时逐截标(新加的一截绿底、删掉的一截红底划掉),没变的不到四成时整段写成旧值(红底划掉)紧跟新值(绿底);说明这类 Markdown 按词比,中文按字、标点各算一个(`diffWordsWithSpace`)。带子行的行子行改了时只标子行,值(「3 个」「字典 2 项」这类汇总)跟着变、不另标——原型里参数多了一行时「输入参数」的个数也没标。 @@ -2072,13 +2073,14 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTab - `{ type: 'moveOccurrence', step: 1 | -1 }`:两条命令发来,跳到下一处 / 上一处出现。 - `{ type: 'displayNames', version, names: ShownName[] }`:version 这份文档里显示成中文名的名字,查到一步发一步;页面只收自己眼下这个版本的。 - `{ type: 'filter', excluded: Record<属性 id, 值 id[]>, onlyChanges }`:整个工作区共用的块筛选和「只看改动」开关(见「代码差异」)。webview 发 `ready` 后先于第一份文档发一次(第一份文档按筛选画),之后任何一个预览改了都发给所有开着的预览(包括改的那个)。 - - `{ type: 'diff', diff: DocumentDiff | null }`:这份文件相比对比基准改了什么(见「代码差异」),每发一份文档之后算好了补发,换了筛选、基准挪了再发;`null` 是不比了。`DocumentDiff` 是 `{ base, version, newFile?, blocks: Record<块记号, { change: 'add' | 'mod', outermost?, own?, marks? }>, removed: { parent, after?, block, line, kindName }[], stats: { add, mod, del }, lineCount }`:块按记号认;`marks` 是卡片里要标的(属性表各行、子行、一行译文、摊开实参的表、说明的增删,行和子行按下标,只在页面上正是 `version` 这一版时画);删掉的块 `block` 是旧版本的视图数据(只带它自己的内容、词不带下标),`parent` / `after` 是它挂在哪块下面、排在哪块后面(记号),`line` 是它在新文件里的位置。 + - `{ type: 'diff', diff: DocumentDiff | null }`:这份文件相比对比基准改了什么(见「代码差异」),每发一份文档之后算好了补发,基准挪了、换了从哪儿打开的再发;`null` 是不比了。`DocumentDiff` 是 `{ base, version, newFile?, blocks: Record<块记号, { change: 'add' | 'mod', outermost?, own?, marks? }>, removed: { parent, after?, block, line, kindName }[], stats: { add, mod, del }, lineCount }`:块按记号认;`marks` 是卡片里要标的(属性表各行、子行、一行译文、摊开实参的表、说明的增删,行和子行按下标,只在页面上正是 `version` 这一版时画);删掉的块 `block` 是旧版本的视图数据(只带它自己的内容、词不带下标),`parent` / `after` 是它挂在哪块下面、排在哪块后面(记号),`line` 是它在新文件里的位置。 - `{ type: 'qa', available, threads: { anchor, entries: { id, question, answer, createdAt, profile?, images? }[] }[], running: { askId, anchor, question, text, activity? }[] }`:这份文件的问答(见「问 AI」)。`ready` 后发一次,存的问答、正在答的问题变了再发给开着这份文件的所有预览;`available` 为假时文件不在工作区文件夹里,不给「问 AI」;`images` 是问题带的图片在预览里能直接用的地址(`webview.asWebviewUri`);`running` 让重新加载的页面接上还在答的问题(不带图片,接上的这一问不显示缩略图,答完回到历史里照常有)。 - `{ type: 'askProfiles', options: { id, label }[], selected }`:「问 AI」的 profile 下拉(`id` 是 profile 名,「默认」的 `id` 是 `''`、`label` 是「默认」)。`ready` 后发一次(每次现读 profile 目录),任何一个预览选了都发给所有开着的预览(包括选的那个)。 - `{ type: 'answer', askId, text, activity? }`:`askId` 那个问题的回答长了(`text` 是到现在为止的全文)或换了在做的事。webview 攒着、最多每 80ms 画一次。 - `{ 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` 后发一次,开着时又被点开再发。 - webview → 扩展: - `{ type: 'probeDefinition', id, version, offset }`:按住修饰键指到带 `offset` 的名字上时问有没有定义,`id` 每问一次加一;`version` 不是当前文档版本就不答。 - `{ type: 'goToDefinition', version, offset }`:修饰键 + 点击,按「跳到定义」一节跳。这两条都只带下标:扩展端按发出去的文档认出规则标了引用的下标交给规则查(见「规则自己查定义」),其余问语言扩展。 @@ -2086,7 +2088,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTab - `{ type: 'findOccurrences', id, version, offset }`:单击带 `offset` 的名字,问它在这份文档里的各处出现;`id` 每问一次加一,`version` 不是当前文档版本就不答。 - `{ type: 'occurrenceNavigation', available }`:眼下能不能用键盘在各处出现之间跳,页面加载后报一次、之后变了才报;扩展端按它设 `superpowers.codeReader.hasOccurrences`。 - `{ type: 'checkRepoLinks', id, paths, symbols }`:页面上的仓库路径问哪些没有,不带路径的引用问哪些找不到(`id` 每问一次加一)。`{ type: 'openRepoLink', path?, symbol? }`:点了注释里的仓库链接(带 `path`)或不带路径的引用(只带 `symbol`)。见「注释的 Markdown 排版」。 - - `{ type: 'ready' }` + - `{ type: 'ready', openedFrom? }`:页面准备好了;`openedFrom` 是 webview 状态里记着的这个标签页是从哪儿打开的,新开的页面没记过就不带。 - `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。 - `{ type: 'rewriteComment', anchor }`:给 `anchor` 那块重写注释(见「重写注释」);`{ type: 'stopRewrite', anchor }`:叫停,不写文件。 - `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。 @@ -2195,11 +2197,11 @@ vscode/src/codeReader/ review/reviewWorktree.ts 本机这张工单的工作副本在哪、备好 PR 最新的代码(已有的快进、没有的建) review/changeDecorations.ts FileDecorationProvider:审查时给改过的文件上色、挂徽标 review/reviewMode.ts ReviewMode:选工单弹框、进出审查、刷新、按工作区记住,审查时预览对比什么(diffBase) - uncommitted/uncommittedMode.ts UncommittedMode:「未提交的更改」进出、盯 git 状态重摆,开着时预览对比上一次提交(diffBase);纯函数在 uncommittedFiles.ts - explorer/treeFilters.ts TreeFilters:侧栏的几种筛选同时只开一种,开着的那种定预览对比什么,换了筛选、基准变了通知预览 + uncommitted/uncommittedView.ts UncommittedView:侧栏「未提交的更改」一栏,盯 git 状态重摆,点开的文件带 `from: uncommitted`;纯函数在 uncommittedFiles.ts + uncommitted/headBases.ts HeadBases:从「未提交的更改」点开的预览对比上一次提交(diffBase),git 状态变了通知预览重新比 explorer/treeLocator.ts TreeLocator:侧栏的树定位到某个文件 / 目录(跟着当前文件走、目录链接),按 FileTreeProvider 的 nodeOf、getParent 找行 explorer/treeReveal.ts 定位用到的判断:树上一行和要找的路径什么关系、explorer.autoReveal / autoRevealExclude 怎么认(纯函数) - diff/diffBase.ts DiffBase:对比基准(文案、旧内容怎么取、缓存用的 key、基准里没有这个文件时的说明) + diff/diffBase.ts DiffBase:对比基准(文案、旧内容怎么取、缓存用的 key、基准里没有这个文件时的说明);OpenedFrom、previewDiffBases:预览对比什么(审查按位置,上一次提交只给从「未提交的更改」点开的) diff/blockDiff.ts 两棵块树比:兄弟块对齐(名字 / 最长公共子序列 / 相似度)、新增修改删除、删掉的块放哪、统计(纯函数) diff/viewDiff.ts 一块卡片里的内容比:属性表按属性名、子行按第一列、着色的词逐截、Markdown 逐词;删掉的块只留自己的内容、去掉源码下标(纯函数) diff/previewDiff.ts PreviewDiff:一个预览的差异,发完文档再比、旧内容按 key 记着、只发最后一次的结果;DocumentDiff 消息的形状(不碰 VS Code) @@ -2307,6 +2309,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/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/diff/diffBase.test.ts b/vscode/src/codeReader/diff/diffBase.test.ts index 608d7e0..ac710fc 100644 --- a/vscode/src/codeReader/diff/diffBase.test.ts +++ b/vscode/src/codeReader/diff/diffBase.test.ts @@ -1,7 +1,7 @@ import type { Uri } from 'vscode' import type { DiffBase, DiffBaseSource } from './diffBase' import { describe, expect, it, vi } from 'vitest' -import { locatedDiffBases } from './diffBase' +import { previewDiffBases } from './diffBase' vi.mock('vscode', () => ({ Disposable: { @@ -25,20 +25,24 @@ function source(root: string, label: string): DiffBaseSource & { fire: () => voi } } -describe('预览按文件所在的位置取对比基准', () => { +describe('预览对比什么:审查按位置,上一次提交看是不是从「未提交的更改」点开的', () => { const review = source('/worktrees/issue-446', 'PR 目标分支 master') const head = source('/ws', '上一次提交') - const bases = locatedDiffBases([review, head]) + const bases = previewDiffBases(review, head) - it('审查工作副本里的对比 PR 目标分支,工作区仓库里的对比上一次提交,都不管的不比', () => { - expect(bases.diffBase(uri('/worktrees/issue-446/src/a.ts'))?.label).toBe('PR 目标分支 master') - expect(bases.diffBase(uri('/ws/src/a.ts'))?.label).toBe('上一次提交') - expect(bases.diffBase(uri('/elsewhere/a.ts'))).toBeUndefined() + it('审查工作副本里的文件对比 PR 目标分支,从哪儿打开的都一样', () => { + expect(bases.diffBase(uri('/worktrees/issue-446/src/a.ts'), 'uncommitted')?.label).toBe('PR 目标分支 master') + expect(bases.diffBase(uri('/worktrees/issue-446/src/a.ts'), 'other')?.label).toBe('PR 目标分支 master') }) - it('两方都管的文件按顺序取前面那方', () => { - const both = locatedDiffBases([source('/ws', '前面'), source('/ws', '后面')]) - expect(both.diffBase(uri('/ws/a.ts'))?.label).toBe('前面') + it('工作区仓库里有改动的文件:从「未提交的更改」点开的对比上一次提交,别处打开的不比', () => { + expect(bases.diffBase(uri('/ws/src/a.ts'), 'uncommitted')?.label).toBe('上一次提交') + expect(bases.diffBase(uri('/ws/src/a.ts'), 'other')).toBeUndefined() + }) + + it('两方都不管的不比', () => { + expect(bases.diffBase(uri('/elsewhere/a.ts'), 'uncommitted')).toBeUndefined() + expect(bases.diffBase(uri('/elsewhere/a.ts'), 'other')).toBeUndefined() }) it('哪一方的基准变了都通知预览重新问;不听了就不再通知', () => { diff --git a/vscode/src/codeReader/diff/diffBase.ts b/vscode/src/codeReader/diff/diffBase.ts index d86c7e6..0710750 100644 --- a/vscode/src/codeReader/diff/diffBase.ts +++ b/vscode/src/codeReader/diff/diffBase.ts @@ -3,7 +3,7 @@ import { Disposable } from 'vscode' /** * 对比基准:预览拿它取这个文件改之前的样子,和现在的比出差异。 - * 按文件所在的位置给(见 locatedDiffBases):审查工作副本里的对比 PR 的目标分支,工作区仓库里有改动的对比上一次提交。 + * 给哪个基准见 [previewDiffBases]:审查工作副本里的对比 PR 的目标分支,从「未提交的更改」一栏点开的有改动的文件对比上一次提交。 */ export interface DiffBase { /** 筛选栏上「对比:」后面的字:「上一次提交」「PR 目标分支 master」 */ @@ -22,22 +22,26 @@ export interface DiffBaseSource { readonly onDidChange: Event } +/** 一个预览标签页是从哪儿打开的:从侧栏「未提交的更改」一栏点开的是 `uncommitted`,别处(主栏、收藏栏、点链接、跳定义……)打开的是 `other`。 */ +export type OpenedFrom = 'uncommitted' | 'other' + +/** 预览问对比基准的地方:同一个文件从哪儿打开的不一样,对比的也可能不一样;基准变了发 onDidChange,打开着的预览重新问。 */ +export interface PreviewDiffBases { + diffBase: (uri: Uri, from: OpenedFrom) => DiffBase | undefined + readonly onDidChange: Event +} + /** - * 预览对比什么按文件所在的位置定,不看是从哪一栏、点链接还是切标签打开的: - * 按顺序问各方(审查工作副本在前、工作区仓库在后),第一个管这个文件的说了算,都不管的不比;哪一方的基准变了都通知。 + * 预览对比什么:审查工作副本里的文件不管从哪儿打开都对比 PR 的目标分支,其余的只有从「未提交的更改」一栏点开的才对比上一次提交,别处打开的不比。 + * + * @param review 审查:工作副本里的文件给 PR 目标分支 + * @param uncommitted 工作区仓库里有改动的文件给上一次提交 */ -export function locatedDiffBases(sources: readonly DiffBaseSource[]): DiffBaseSource { +export function previewDiffBases(review: DiffBaseSource, uncommitted: DiffBaseSource): PreviewDiffBases { return { - diffBase: (uri) => { - for (const source of sources) { - const base = source.diffBase(uri) - if (base) - return base - } - return undefined - }, + diffBase: (uri, from) => review.diffBase(uri) ?? (from === 'uncommitted' ? uncommitted.diffBase(uri) : undefined), onDidChange: (listener, thisArgs, disposables) => { - const all = Disposable.from(...sources.map(source => source.onDidChange(listener, thisArgs))) + const all = Disposable.from(review.onDidChange(listener, thisArgs), uncommitted.onDidChange(listener, thisArgs)) disposables?.push(all) return all }, diff --git a/vscode/src/codeReader/diff/previewDiff.ts b/vscode/src/codeReader/diff/previewDiff.ts index 60cc522..4f574bb 100644 --- a/vscode/src/codeReader/diff/previewDiff.ts +++ b/vscode/src/codeReader/diff/previewDiff.ts @@ -75,7 +75,7 @@ export class PreviewDiff { private showing = false constructor( - /** 眼下对比的基准;没有是不比(普通文件树) */ + /** 眼下对比的基准;没有是不比(别处打开的、没有改动的) */ private readonly baseOf: () => DiffBase | undefined, /** 用这份文档的语言、翻译插件和库规则解析旧版本;解析失败是 undefined */ private readonly readOld: (source: string) => Promise, diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 8ddcb2b..9e80bf5 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -7,6 +7,7 @@ import type { FileAttribute } from '../attributes' import type { BlockView } from '../blockView' import type { ContractMiss } from '../callTree/endpoint' import type { CallTree } from '../callTree/model' +import type { OpenedFrom } from '../diff/diffBase' import type { DocumentDiff } from '../diff/previewDiff' import type { ShownName } from '../nameResolution' import type { ProfileChoice } from '../qa/askProfiles' @@ -133,8 +134,9 @@ export interface StickyScrollSettings { * failed 带给人看的原因,stopped 是被叫停,这两种都没记、文件没改 * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览 * displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理 - * diff:这份文件相比对比基准改了什么(审查工作副本里的、工作区仓库里有改动的才有,见 DiffBase):每发一份文档之后算好了补发, - * 基准变了(提交后 HEAD 动了、换了审查的工单、文件有了改动)再发;null 是不比了(退出了审查、改动提交了) + * diff:这份文件相比对比基准改了什么(审查工作副本里的、从「未提交的更改」点开的有改动的才有,见 [对比基准](vscode/src/codeReader/diff/diffBase.ts#DiffBase)):每发一份文档之后算好了补发, + * 基准变了(提交后 HEAD 动了、换了审查的工单、文件有了改动、这个标签页换了从哪儿打开)再发;null 是不比了(退出了审查、改动提交了、从别处点开了) + * openedFrom:这个标签页是从哪儿打开的,页面记进自己的状态,VS Code 重启后恢复这个标签页时随 ready 报回来;ready 后发一次,开着时又被点开再发 * repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用 * repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到 * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了 @@ -161,6 +163,7 @@ export type ExtensionToWebview | { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string, queued?: true } | { type: 'rewriteEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string } | { type: 'diff', diff: DocumentDiff | null } + | { type: 'openedFrom', from: OpenedFrom } | { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] } | { type: 'repoLinkBroken', path?: string, symbol?: string } | { type: 'repoLinkOpened', path?: string, symbol?: string } @@ -170,6 +173,7 @@ export type ExtensionToWebview | { type: 'callTree', version: number, anchor: string, name: string, where: string, trees: CallTree[], contractMiss?: ContractMiss } /** + * ready:页面准备好了,可以收消息;openedFrom 是页面记着的这个标签页是从哪儿打开的,新开的页面没记过就没有 * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进) * visibleLine:预览视口顶部那块的起始行,切回文本编辑器时把这行放到顶部 * probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问源码 offset 处有没有定义;id 标出是哪一次问,回答带回去; @@ -200,7 +204,7 @@ export type ExtensionToWebview * goToCallNode:点了调用树上的一个节点,打开 uri 那个文件,跳到 line 行 character 列(都从 0 起,见 CallTreeNode.jump) */ export type WebviewToExtension - = | { type: 'ready' } + = | { type: 'ready', openedFrom?: OpenedFrom } | { type: 'setFilter', excluded: Excluded } | { type: 'setOnlyChanges', on: boolean } | { type: 'edit', startLine: number, endLine: number, text: string } diff --git a/vscode/src/codeReader/editor/previewEditor.test.ts b/vscode/src/codeReader/editor/previewEditor.test.ts index 453b8eb..4c8e499 100644 --- a/vscode/src/codeReader/editor/previewEditor.test.ts +++ b/vscode/src/codeReader/editor/previewEditor.test.ts @@ -1,3 +1,4 @@ +import type { DiffBase, OpenedFrom } from '../diff/diffBase' import type { RewriteRequest } from '../qa/codeQa' import type { ExtensionToWebview, WebviewToExtension } from './messages' import { describe, expect, it, vi } from 'vitest' @@ -10,6 +11,7 @@ vi.mock('vscode', () => ({ workspace: { onDidChangeTextDocument: () => ({ dispose: () => {} }), onDidChangeConfiguration: () => ({ dispose: () => {} }), + getConfiguration: () => ({ get: (_key: string, fallback: unknown) => fallback }), openTextDocument: vi.fn(), applyEdit: vi.fn(async () => true), }, @@ -28,6 +30,15 @@ vi.mock('../../webview/html', () => ({ webviewHtml: () => '', webviewUiRoot: () vi.mock('../folderStore', () => ({ locate: () => ({ folder: { uri: { fsPath: '/workspace' } }, key: 'web/a.ts' }) })) vi.mock('./pathPreview', () => ({ PATH_PREVIEW_SCHEME: 'code-reader-path', topSubjectOf: (uri: unknown) => ({ uri, kind: 'file' }) })) vi.mock('./occurrences', () => ({ occurrencesOf: vi.fn() })) +/** 排视图数据不真的解析:一份没有块的文档 */ +vi.mock('./documentMessage', () => ({ + documentMessage: vi.fn(async (input: { languageId: string, version: number }) => ({ + message: { type: 'document', version: input.version, languageId: input.languageId, blocks: [], attributes: [], translators: [], translatorId: '', names: [] }, + displayNames: [], + symbols: [], + imports: [], + })), +})) const SOURCE = ['// 旧说明', 'export function load() {}'] @@ -125,3 +136,136 @@ describe('预览里重写注释', () => { } }) }) + +const HEAD_BASE: DiffBase = { label: '上一次提交', key: 'head:web/a.ts', read: async () => '// 改之前', missing: '' } + +/** 对比基准只认从「未提交的更改」点开的:从那儿点开的对比上一次提交,别处打开的不比 */ +function opening() { + const subscribe = () => ({ dispose: () => {} }) + const diffBase = vi.fn((_uri: unknown, from: OpenedFrom): DiffBase | undefined => from === 'uncommitted' ? HEAD_BASE : undefined) + const qa = { + imageRoot: () => undefined, + onDidChange: subscribe, + onDidAnswer: subscribe, + onDidRewrite: subscribe, + profiles: { subscribe, choice: async () => ({ profiles: [], selected: '' }) }, + view: () => ({ type: 'qa' }), + } + const provider = new PreviewEditorProvider(...[ + {}, + {}, + {}, + { subscribe, current: { excluded: {}, onlyChanges: false } }, + async () => [], + qa, + async () => [], + async () => [], + { diffBase, onDidChange: subscribe }, + { namesOf: () => [], onDidChange: subscribe }, + async () => false, + ] as unknown as ConstructorParameters) + const uri = { fsPath: '/workspace/web/a.ts', scheme: 'file', toString: () => 'file:///workspace/web/a.ts' } + const document = { uri, version: 1, languageId: 'typescript', fileName: '/workspace/web/a.ts', getText: () => SOURCE.join('\n') } + return { provider, document, diffBase } +} + +/** 一个标签页:收发消息、切走(页面被销毁,切回来重新加载、再发 ready) */ +function tab() { + let receive: ((message: WebviewToExtension) => Promise) | undefined + let viewStateChanged: (() => void) | undefined + const post = vi.fn<(message: ExtensionToWebview) => void>() + const panel = { + active: true, + visible: true, + webview: { + options: {}, + postMessage: post, + asWebviewUri: (file: unknown) => file, + onDidReceiveMessage: (listener: typeof receive) => { + receive = listener + return { dispose: () => {} } + }, + }, + onDidChangeViewState: (listener: typeof viewStateChanged) => { + viewStateChanged = listener + return { dispose: () => {} } + }, + onDidDispose: () => ({ dispose: () => {} }), + } + return { + panel: panel as unknown as Parameters[1], + send: (message: WebviewToExtension) => receive!(message), + hide: () => { + panel.visible = false + viewStateChanged!() + panel.visible = true + }, + posted: () => post.mock.calls.map(([message]) => message), + clear: () => post.mockClear(), + } +} + +const comparedWithHead = expect.objectContaining({ type: 'diff', diff: expect.objectContaining({ base: '上一次提交' }) }) + +describe('预览对比什么看这个标签页是从哪儿打开的', () => { + 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)) + await page.send({ type: 'ready' }) + + expect(page.posted()).toContainEqual({ type: 'openedFrom', from: 'uncommitted' }) + await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead)) + }) + + 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)) + await page.send({ type: 'ready' }) + await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead)) + + page.clear() + await provider.open(document.uri as never, { from: 'other' }, async () => {}) + expect(page.posted()).toContainEqual({ type: 'openedFrom', from: 'other' }) + await vi.waitFor(() => expect(page.posted()).toContainEqual({ type: 'diff', diff: null })) + + page.clear() + await provider.open(document.uri as never, { from: 'uncommitted' }, async () => {}) + expect(page.posted()).toContainEqual({ type: 'openedFrom', from: 'uncommitted' }) + await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead)) + + page.hide() + page.clear() + await page.send({ type: 'ready', openedFrom: 'uncommitted' }) + expect(page.posted()).toContainEqual({ type: 'openedFrom', from: 'uncommitted' }) + await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead)) + }) + + it('没带打开请求的(VS Code 重启后恢复的)按页面记着的来,页面没记过的不比', async () => { + const { provider, document, diffBase } = opening() + const restored = tab() + provider.resolveCustomTextEditor(document as never, restored.panel) + await restored.send({ type: 'ready', openedFrom: 'uncommitted' }) + await vi.waitFor(() => expect(restored.posted()).toContainEqual(comparedWithHead)) + + diffBase.mockClear() + const fresh = tab() + 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, diffBase } = opening() + const page = tab() + await provider.open(document.uri as never, { from: 'other' }, async () => provider.resolveCustomTextEditor(document as never, page.panel)) + await page.send({ type: 'ready', openedFrom: 'uncommitted' }) + + expect(page.posted()).toContainEqual({ type: 'openedFrom', from: 'other' }) + expect(diffBase).toHaveBeenCalledWith(document.uri, 'other') + expect(diffBase).not.toHaveBeenCalledWith(document.uri, 'uncommitted') + }) +}) diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index cbdd3a7..da87292 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -3,7 +3,7 @@ import type { ClaudeImage } from '../../cc/spawnClaude' import type { IndexFind } from '../callTree/endpoint' import type { CommentEdit, CommentJob, WriteResult } from '../commentRewrite' import type { BlockKind, LocalSymbol, RuleLocation, RuleReference } from '../contract' -import type { DiffBaseSource } from '../diff/diffBase' +import type { OpenedFrom, PreviewDiffBases } from '../diff/diffBase' import type { OldDocument, SentDocument } from '../diff/previewDiff' import type { WorkspaceEntries } from '../entries/workspaceEntries' import type { DefinitionStart, IndexedName, NameSources } from '../nameResolution' @@ -51,16 +51,24 @@ export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview' /** 连续输入时合并成一次重新解析。 */ const REFRESH_DELAY_MS = 150 +/** + * 一次打开预览带的话:这个标签页是从哪儿打开的(定对比什么);要滚到哪(从文本编辑器切过来时它顶部那一行,或跳到的定义); + * 要不要把光标放进顶部「问 AI」输入框(侧栏「问 AI」打开的)。 + */ +export interface OpenRequest { + from: OpenedFrom + reveal?: Reveal + focusPath?: boolean +} + /** * 「代码阅读预览」自定义编辑器。预览和普通编辑器共用同一个 TextDocument: * 块内修改走 WorkspaceEdit,未保存标记、⌘S、⌘Z 都是 VS Code 原生行为。 */ export class PreviewEditorProvider implements CustomTextEditorProvider { private readonly previews = new Set() - /** 侧栏「问 AI」打开的预览,解析时把光标放进顶部输入框;已经开着的由 openAsking 自己聚焦 */ - private readonly pathFocus = new Set() - /** 按文档 uri:等着交给马上要打开的新预览的位置(从文本编辑器切过来时它顶部那一行,或跳到的定义) */ - private readonly reveals = new Map() + /** 按文档 uri:等着交给马上要打开的新预览的打开请求(见 [open]) */ + private readonly requests = new Map() /** 哪个预览眼下能用键盘在高亮的各处出现之间跳,跟着它设快捷键 when 子句用的上下文键 */ readonly navigation = new NavigationContext(value => void commands.executeCommand('setContext', OCCURRENCES_CONTEXT, value)) @@ -77,8 +85,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { readonly indexedNames: (languageId: string, name: string) => Promise, /** 工作区索引里键是 key 的各项(规则查名字的定义时用,见 ruleLookups.ts;接口的调用树查契约和前端请求时用) */ readonly indexed: IndexFind, - /** 预览标差异时对比什么:按文件所在的位置定,基准变了发 onDidChange(见 locatedDiffBases) */ - readonly diffBases: DiffBaseSource, + /** 预览标差异时对比什么:看文件在哪、这个标签页是从哪儿打开的,基准变了发 onDidChange */ + readonly diffBases: PreviewDiffBases, /** 哪些文件是项目写明的入口、预览里哪些名字的块标成入口 */ readonly entries: Pick, /** 在代码阅读侧栏的树上定位一个目录(仓库链接指到目录时);树上没有它时返回 false */ @@ -87,22 +95,26 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { readonly askFeature: (subject: Uri, request: TopRequest) => Promise, ) {} - /** 侧栏「问 AI」打开预览,并把光标放进顶部输入框。已经开着的就揭到前面再聚焦。 */ - async openAsking(uri: Uri): Promise { + /** + * 用 `show` 打开 `uri` 的预览,把 `request` 交给它:新建的预览解析时带走;`show` 只是切到了已经开着的预览(没有新建)时,交给切到的那个。 + * + * `show` 默认按「预览标签」打开,同 `vscode.open`(用户关了 `workbench.editor.enablePreview` 时 VS Code 自会固定)。 + */ + async open(uri: Uri, request: OpenRequest, show: () => Thenable = () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, { preview: true })): Promise { const key = uri.toString() - this.pathFocus.add(key) + this.requests.set(key, request) + let claimed: boolean try { - await commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, { preview: true }) + await show() + claimed = this.requests.get(key) !== request } finally { - // 新开的预览在解析时已经拿走标记;还在的是本来就开着的那页,这里聚焦 - if (this.pathFocus.delete(key)) { - for (const preview of this.previews) { - if (preview.uri.toString() === key) - preview.focusPathAsk() - } - } + // 打开命令等新预览解析完才返回;没打开成时也不能把这次请求留给以后别处的打开 + if (this.requests.get(key) === request) + this.requests.delete(key) } + if (!claimed) + [...this.previews].find(p => p.uri.toString() === key && p.active)?.reopen(request) } resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void { @@ -111,9 +123,9 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri), ...(imageRoot ? [imageRoot] : [])] } panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html') const key = document.uri.toString() - const focusPath = this.pathFocus.delete(key) - const preview = new Preview(document, panel, this, this.registry, this.runtime, this.filter, this.reveals.get(key), focusPath) - this.reveals.delete(key) + 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) panel.onDidDispose(() => { this.previews.delete(preview) @@ -135,26 +147,6 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { } } - /** - * 用 open 打开 uri 的预览并滚到 reveal:新建的预览随第一份文档带过去; - * open 只是切到了已经开着的预览(没有新建)时,直接发给这个预览,不重新打开。 - */ - async openAt(uri: Uri, reveal: Reveal, open: () => Thenable): Promise { - const key = uri.toString() - this.reveals.set(key, reveal) - let claimed: boolean - try { - await open() - claimed = this.reveals.get(key) !== reveal - } - finally { - // 打开命令等新预览解析完才返回;没打开成时也不能把这一处留给以后别处的打开 - this.reveals.delete(key) - } - if (!claimed) - [...this.previews].find(p => p.uri.toString() === key && p.active)?.reveal(reveal) - } - /** uri 的预览视口顶部那块的起始行;几个编辑器组里都开着这个文件的预览时取活动的那个。 */ visibleLine(uri: Uri): number | undefined { const key = uri.toString() @@ -204,6 +196,13 @@ class Preview { private readonly callees: DocumentCallees /** 这份文件相比对比基准改了什么:发完文档再算、补发 */ private readonly diff: PreviewDiff + /** + * 随下一份真正发出去的文档走、只走一次:新预览第一份文档要滚到的位置,或页面正在重新加载时收不到的 reveal。 + * 之后页面因标签页切走重新加载,按 webview 自己记的位置恢复 + */ + private pendingReveal: Reveal | undefined + /** 这个标签页是从哪儿打开的,定对比什么;没带打开请求的(VS Code 重启后恢复的)先空着,页面 ready 时按它记着的定 */ + private from: OpenedFrom | undefined constructor( private readonly document: TextDocument, @@ -212,14 +211,12 @@ class Preview { private readonly registry: CodeReaderRegistry, private readonly runtime: TreeSitterRuntime, private readonly filter: BlockFilter, - /** - * 随下一份真正发出去的文档走、只走一次:新预览第一份文档要滚到的位置,或页面正在重新加载时收不到的 reveal。 - * 之后页面因标签页切走重新加载,按 webview 自己记的位置恢复 - */ - private pendingReveal: Reveal | undefined, - focusPath: boolean, + /** 打开它的那次请求;VS Code 重启后恢复的、VS Code 自己换编辑器打开的没有 */ + request: OpenRequest | undefined, ) { - this.pendingPathFocus = focusPath + this.from = request?.from + this.pendingReveal = request?.reveal + this.pendingPathFocus = request?.focusPath ?? false this.definitions = new DefinitionSources({ rule: async reference => ruleDefinitions(await owner.resolveReference(reference)), language: async (offset) => { @@ -234,7 +231,7 @@ class Preview { this.lookups = new DocumentLookups(() => this.schedulePush()) this.callees = new DocumentCallees(() => this.schedulePush()) this.diff = new PreviewDiff( - () => owner.diffBases.diffBase(document.uri), + () => this.from === undefined ? undefined : owner.diffBases.diffBase(document.uri, this.from), source => this.readOld(source), (diff) => { if (this.ready) @@ -473,8 +470,22 @@ class Preview { this.post({ type: 'moveOccurrence', step }) } + /** 开着时又被点开(见 [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.reveal) + this.reveal(request.reveal) + if (request.focusPath) + this.focusPathAsk() + } + /** 滚到 reveal:页面在就直接发,正在重新加载就等它发 ready 后随文档带过去。 */ - reveal(reveal: Reveal): void { + private reveal(reveal: Reveal): void { if (this.ready) this.post({ type: 'reveal', ...reveal }) else @@ -508,7 +519,7 @@ class Preview { } /** 把光标放进顶部输入框;页面还没准备好就等 ready。 */ - focusPathAsk(): void { + private focusPathAsk(): void { if (!this.ready) { this.pendingPathFocus = true return @@ -564,6 +575,9 @@ class Preview { switch (msg.type) { case 'ready': this.ready = true + // 打开请求说了从哪儿打开的就按它;没说的(VS Code 重启后恢复的)按页面记着的,页面也没记过的不算从「未提交的更改」点开的 + this.from ??= msg.openedFrom ?? 'other' + this.post({ type: 'openedFrom', from: this.from }) // 先于文档发:第一份文档按筛选画,不会先整份露出来再藏 this.post({ type: 'filter', ...this.filter.current }) this.postHoverDelay() @@ -780,7 +794,7 @@ class Preview { } /** - * 在预览所在的编辑器组打开别的文件,和普通编辑器里 ⌘ + 点击一样:语言有代码阅读的用预览打开,给了 spot 就滚过去、闪这处; + * 在预览所在的编辑器组打开别的文件,和普通编辑器里 ⌘ + 点击一样:语言有代码阅读的用预览打开(算从别处打开的,不对比上一次提交),给了 spot 就滚过去、闪这处; * 其余用 VS Code 默认的打开方式,给了 spot 就选中它(见 openingFor)。目标是否按「预览标签」打开听 VS Code 的设置, * workbench.editor.enablePreviewFromCodeNavigation 为假时先把当前这个预览标签固定住,免得被目标替掉。 */ @@ -793,11 +807,7 @@ class Preview { await commands.executeCommand('vscode.open', uri, { ...options, ...(opening.selection && { selection: opening.selection }) }) return } - const open = (): Thenable => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options) - if (opening.reveal) - await this.owner.openAt(uri, opening.reveal, open) - else - await open() + await this.owner.open(uri, { from: 'other', ...(opening.reveal && { reveal: opening.reveal }) }, () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options)) } /** 点了调用树上的一个节点:打开它在的文件,滚到这一处(同 ⌘ + 点击跳到别的文件)。 */ diff --git a/vscode/src/codeReader/editor/switchEditor.ts b/vscode/src/codeReader/editor/switchEditor.ts index 22b94c9..ee22e68 100644 --- a/vscode/src/codeReader/editor/switchEditor.ts +++ b/vscode/src/codeReader/editor/switchEditor.ts @@ -51,13 +51,13 @@ 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 && topLine !== undefined) - await previews.openAt(uri, { line: topLine }, reopen) + if (uri) + await previews.open(uri, { from: 'other', ...(topLine !== undefined && { reveal: { line: topLine } }) }, reopen) else await reopen() } diff --git a/vscode/src/codeReader/explorer/fileTree.test.ts b/vscode/src/codeReader/explorer/fileTree.test.ts index 9a69c16..93b9a09 100644 --- a/vscode/src/codeReader/explorer/fileTree.test.ts +++ b/vscode/src/codeReader/explorer/fileTree.test.ts @@ -1,5 +1,5 @@ import type { WorkspaceFolder } from 'vscode' -import type { ChangedSection, FileNode, FileTreeDecorator, TreeNode } from './fileTree' +import type { ChangedSection, FileNode, FileOpenCommand, FileTreeDecorator, TreeNode } from './fileTree' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Uri } from 'vscode' import { buildChangedTree } from './changedTree' @@ -68,6 +68,7 @@ vi.mock('vscode', async () => { const FILE = 1 const DIR = 2 const decorator = { onDidChange: () => ({ dispose: () => {} }), decorate: () => {} } as unknown as FileTreeDecorator +const OPEN: FileOpenCommand = { command: 'open', from: 'other' } let folder: WorkspaceFolder beforeEach(() => { @@ -108,7 +109,7 @@ function topOf(files: FileTreeProvider, node: TreeNode): TreeNode { describe('工作区文件树:按 uri 找到那一行、一层层找上一层', () => { it('多层目录:getParent 交回 getChildren 给过的同一个对象,根的上一层没有', async () => { - const files = new FileTreeProvider('open', decorator) + const files = new FileTreeProvider(OPEN, decorator) const [root] = await files.getChildren() const backend = await childNamed(files, root, 'backend') const src = await childNamed(files, backend, 'src') @@ -123,7 +124,7 @@ describe('工作区文件树:按 uri 找到那一行、一层层找上一层', }) it('nodeOf 一层层读目录找到文件,就是树上摆的那一行', async () => { - const files = new FileTreeProvider('open', decorator) + const files = new FileTreeProvider(OPEN, decorator) const found = await files.nodeOf(Uri.file('/ws/backend/src/luffy/visibility.py')) expect(found?.isDirectory).toBe(false) @@ -133,7 +134,7 @@ describe('工作区文件树:按 uri 找到那一行、一层层找上一层', }) it('目录也能找到', async () => { - const files = new FileTreeProvider('open', decorator) + const files = new FileTreeProvider(OPEN, decorator) const found = await files.nodeOf(Uri.file('/ws/backend/src')) expect(found?.isDirectory).toBe(true) @@ -143,7 +144,7 @@ describe('工作区文件树:按 uri 找到那一行、一层层找上一层', it('树上没有的找不到:工作区外、被 files.exclude 藏了的目录里、盘上没有的', async () => { disk.exclude = { '**/dist': true } - const files = new FileTreeProvider('open', decorator) + const files = new FileTreeProvider(OPEN, decorator) expect(await files.nodeOf(Uri.file('/elsewhere/a.py'))).toBeUndefined() expect(await files.nodeOf(Uri.file('/ws/dist/bundle.js'))).toBeUndefined() @@ -163,7 +164,7 @@ describe('只摆改动的文件时:在树上就找到,不在就没有', () = } it('找到那个分组里的那一行,上一层一直走到分组行', async () => { - const files = new FileTreeProvider('open', decorator) + const files = new FileTreeProvider(OPEN, decorator) const { staged, unstaged } = sections() files.show({ kind: 'changes', view: [staged, unstaged] }) const found = await files.nodeOf(Uri.file('/ws/backend/src/luffy/visibility.py')) @@ -176,7 +177,7 @@ describe('只摆改动的文件时:在树上就找到,不在就没有', () = }) it('没摆出来的文件、目录找不到', async () => { - const files = new FileTreeProvider('open', decorator) + const files = new FileTreeProvider(OPEN, decorator) const { staged, unstaged } = sections() files.show({ kind: 'changes', view: [staged, unstaged] }) @@ -186,7 +187,7 @@ describe('只摆改动的文件时:在树上就找到,不在就没有', () = }) it('同一个文件摆在两个分组里时取最先摆的', async () => { - const files = new FileTreeProvider('open', decorator) + const files = new FileTreeProvider(OPEN, decorator) const { staged, unstaged } = sections() files.show({ kind: 'changes', view: [staged, unstaged] }) @@ -194,7 +195,7 @@ describe('只摆改动的文件时:在树上就找到,不在就没有', () = }) it('分组没有名字时根就在最上面一层,根的上一层没有', async () => { - const files = new FileTreeProvider('open', decorator) + const files = new FileTreeProvider(OPEN, decorator) files.show({ kind: 'changes', view: [{ roots: [{ folder, tree: buildChangedTree([{ path: 'backend/src/gate.py', change: 'A' }]) }] }] }) const found = await files.nodeOf(Uri.file('/ws/backend/src/gate.py')) const [root] = await files.getChildren() @@ -208,7 +209,7 @@ describe('收藏栏:一个目录下的完整文件树', () => { it('直接摆目录下的各项(不套一层根),规则同工作区文件树:目录在前、files.exclude 藏掉的不摆', async () => { disk.dirs.set('/ws/backend', [['src', DIR], ['main.py', FILE], ['build', DIR]]) disk.exclude = { '**/build': true } - const files = new FileTreeProvider('open', decorator, { kind: 'directory', uri: Uri.file('/ws/backend') }) + const files = new FileTreeProvider(OPEN, decorator, { kind: 'directory', uri: Uri.file('/ws/backend') }) const top = await files.getChildren() expect(top.map(node => !isGroupNode(node) && [node.uri.path, node.isDirectory, node.isRoot])).toEqual([ @@ -220,7 +221,7 @@ describe('收藏栏:一个目录下的完整文件树', () => { }) it('最上面一层没有上一层;往下找得到,上一层走到收藏的目录下面为止', async () => { - const files = new FileTreeProvider('open', decorator, { kind: 'directory', uri: Uri.file('/ws/backend') }) + const files = new FileTreeProvider(OPEN, decorator, { kind: 'directory', uri: Uri.file('/ws/backend') }) const found = await files.nodeOf(Uri.file('/ws/backend/src/luffy/visibility.py')) expect(chainUp(files, found)).toEqual(['/ws/backend/src/luffy/visibility.py', '/ws/backend/src/luffy', '/ws/backend/src']) @@ -228,7 +229,7 @@ describe('收藏栏:一个目录下的完整文件树', () => { }) it('目录外面的找不到;目录被删了时树是空的', async () => { - const files = new FileTreeProvider('open', decorator, { kind: 'directory', uri: Uri.file('/ws/backend') }) + const files = new FileTreeProvider(OPEN, decorator, { kind: 'directory', uri: Uri.file('/ws/backend') }) expect(await files.nodeOf(Uri.file('/ws/README.md'))).toBeUndefined() expect(await files.nodeOf(Uri.file('/ws/backend'))).toBeUndefined() @@ -239,10 +240,32 @@ describe('收藏栏:一个目录下的完整文件树', () => { it('目录不在任何工作区文件夹里(文件夹被移出工作区):拿目录自己当根', async () => { disk.folders = [] - const files = new FileTreeProvider('open', decorator, { kind: 'directory', uri: Uri.file('/ws/backend') }) + const files = new FileTreeProvider(OPEN, decorator, { kind: 'directory', uri: Uri.file('/ws/backend') }) const [src] = await files.getChildren() expect(!isGroupNode(src) && src.folder.uri.path).toBe('/ws/backend') expect(files.getParent(src)).toBeUndefined() }) }) + +describe('点文件打开', () => { + it('命令带上文件和这一栏的 from:「未提交的更改」那一栏的是 uncommitted;删掉的文件不挂命令', async () => { + const files = new FileTreeProvider({ command: 'open', from: 'uncommitted' }, decorator) + files.show({ kind: 'changes', view: [{ label: '更改', roots: [{ folder, tree: buildChangedTree([ + { path: 'backend/src/gate.py', change: 'M' }, + { path: 'README.md', change: 'D' }, + ]) }] }] }) + const gate = await files.nodeOf(Uri.file('/ws/backend/src/gate.py')) + const readme = await files.nodeOf(Uri.file('/ws/README.md')) + + expect(files.getTreeItem(gate!).command).toEqual({ command: 'open', title: '打开', arguments: [gate!.uri, 'uncommitted'] }) + expect(files.getTreeItem(readme!).command).toBeUndefined() + }) + + it('别的栏的是 other', async () => { + const files = new FileTreeProvider(OPEN, decorator) + const readme = await files.nodeOf(Uri.file('/ws/README.md')) + + expect(files.getTreeItem(readme!).command?.arguments).toEqual([readme!.uri, 'other']) + }) +}) diff --git a/vscode/src/codeReader/explorer/fileTree.ts b/vscode/src/codeReader/explorer/fileTree.ts index 617443f..fbdf2b6 100644 --- a/vscode/src/codeReader/explorer/fileTree.ts +++ b/vscode/src/codeReader/explorer/fileTree.ts @@ -1,4 +1,5 @@ import type { Event, TreeDataProvider, WorkspaceFolder } from 'vscode' +import type { OpenedFrom } from '../diff/diffBase' import type { ChangedTree, FileChange } from './changedTree' import type { ExcludeSetting } from './fileEntries' import * as path from 'node:path' @@ -70,6 +71,12 @@ export interface FileTreeDecorator { decorate: (item: TreeItem, node: FileNode) => void } +/** 点文件时执行的命令:参数是文件 Uri 和 `from`,预览按它知道这个标签页是从哪儿打开的(见 [OpenedFrom])。 */ +export interface FileOpenCommand { + command: string + from: OpenedFrom +} + /** 批量改文件(git checkout、构建输出)时合并成一次刷新。 */ const REFRESH_DELAY_MS = 300 @@ -92,11 +99,11 @@ export class FileTreeProvider implements TreeDataProvider { private directoryFolder: WorkspaceFolder | undefined /** - * @param openCommand 点文件时执行的命令,参数是文件 Uri + * @param open 点文件时执行的命令和这一栏的 `from` * @param decorator 给行添信息(文件注释) * @param content 一开始摆什么,默认是工作区文件树 */ - constructor(private readonly openCommand: string, private readonly decorator: FileTreeDecorator, private content: TreeContent = WORKSPACE_TREE) { + constructor(private readonly open: FileOpenCommand, private readonly decorator: FileTreeDecorator, private content: TreeContent = WORKSPACE_TREE) { this.groups = groupsOf(content) // 只关心增删:文件内容变化不影响树的结构 const watcher = workspace.createFileSystemWatcher('**/*', false, true, false) @@ -190,7 +197,7 @@ export class FileTreeProvider implements TreeDataProvider { if (node.change === 'D') item.tooltip = DELETED_TOOLTIP // 删掉的文件没有内容可看,不挂打开的命令 else - item.command = { command: this.openCommand, title: '打开', arguments: [node.uri] } + item.command = { command: this.open.command, title: '打开', arguments: [node.uri, this.open.from] } return item } const expanded = node.isRoot || this.content.kind === 'changes' diff --git a/vscode/src/codeReader/favorites/favoriteViews.ts b/vscode/src/codeReader/favorites/favoriteViews.ts index 01692a2..64ac884 100644 --- a/vscode/src/codeReader/favorites/favoriteViews.ts +++ b/vscode/src/codeReader/favorites/favoriteViews.ts @@ -26,7 +26,7 @@ class FavoritePane { constructor(favorite: Favorite, openCommand: string, decorator: FileTreeDecorator) { this.uri = Uri.parse(favorite.uri) - const files = new FileTreeProvider(openCommand, decorator, { kind: 'directory', uri: this.uri }) + const files = new FileTreeProvider({ command: openCommand, from: 'other' }, decorator, { kind: 'directory', uri: this.uri }) this.view = window.createTreeView(slotViewId(favorite.slot), { treeDataProvider: files, showCollapseAll: true }) this.view.title = slotTitle('favorite', favorite.name) this.view.description = workspace.asRelativePath(this.uri) @@ -72,7 +72,7 @@ export class FavoriteViews { /** * @param state workspaceState * @param pool 和「了解一块代码」共用的栏位 - * @param openCommand 点文件时执行的命令,参数是文件 Uri + * @param openCommand 点文件时执行的命令,参数是文件 Uri 和 `other`(不是从「未提交的更改」点开的) * @param decorator 给行添信息(文件注释),和别的栏共用 */ constructor(private readonly state: Memento, private readonly pool: SlotPool, private readonly openCommand: string, private readonly decorator: FileTreeDecorator) { diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index d51b1f5..ad32cee 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -1,5 +1,6 @@ import type { ExtensionContext, Uri } from 'vscode' import type { CodeReaderApi, RuleLocation, RuleReference } from './contract' +import type { OpenedFrom } from './diff/diffBase' import type { TargetSpot } from './editor/openTarget' import type { FileNode } from './explorer/fileTree' import type { FeatureRow } from './features/featureViews' @@ -9,7 +10,7 @@ import { logger } from '../logging/logger' import { packageCommandRules } from './commands/packageCommands' import { shellCommandRules } from './commands/shellCommands' import { systemCommandRules } from './commands/systemCommands' -import { locatedDiffBases } from './diff/diffBase' +import { previewDiffBases } from './diff/diffBase' import { BlockFilter } from './editor/blockFilter' import { NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigation' import { languageOf, openingFor } from './editor/openTarget' @@ -71,26 +72,21 @@ const FILES_VIEW = 'superpowers.codeReader.files' const OPEN_COMMAND = 'superpowers.codeReader.open' /** - * 侧栏点文件、点「了解一块代码」栏里的一条:语言受支持就用预览打开,否则用 VS Code 默认的打开方式(见 openingFor)。 - * 给了 spot(条目的符号在哪)就跳过去:预览滚到那一块、闪一下,普通编辑器选中那个名字。 + * 侧栏点文件、点「了解一块代码」栏里的一条:语言受支持就用预览打开、告诉它是从哪一栏点开的(`from`),否则用 VS Code 默认的打开方式(见 [openingFor])。 + * 给了 `spot`(条目的符号在哪)就跳过去:预览滚到那一块、闪一下,普通编辑器选中那个名字。 */ -async function openFile(uri: Uri, registry: CodeReaderRegistry, previews: PreviewEditorProvider, spot?: TargetSpot): Promise { +async function openFile(uri: Uri, from: OpenedFrom, registry: CodeReaderRegistry, previews: PreviewEditorProvider, spot?: TargetSpot): Promise { const opening = openingFor(await languageOf(uri), languageId => registry.languageFor(languageId) !== undefined, spot) if (opening.kind === 'default') { await commands.executeCommand('vscode.open', uri, opening.selection && { selection: opening.selection }) return } - // 和 vscode.open 一样按「预览标签」打开(用户关了 workbench.editor.enablePreview 时 VS Code 自会固定) - const open = (): Thenable => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, { preview: true }) - if (opening.reveal) - await previews.openAt(uri, opening.reveal, open) - else - await open() + await previews.open(uri, { from, ...(opening.reveal && { reveal: opening.reveal }) }) } /** * 接入代码阅读:注册表、侧栏的几栏(主栏的文件树可以按审查工单只看 PR 改过的文件,常驻的「未提交的更改」,每个收藏目录、每个「了解一块代码」找的功能各一栏; - * 行上有问路径、复制、在系统文件管理器里显示、文件注释、收藏目录)、预览编辑器(含问 AI,按文件所在位置标差异)、编辑器右上角的「预览 / 编辑」切换。 + * 行上有问路径、复制、在系统文件管理器里显示、文件注释、收藏目录)、预览编辑器(含问 AI,审查工作副本里的和从「未提交的更改」点开的标差异)、编辑器右上角的「预览 / 编辑」切换。 * 内置的语言插件、翻译插件、库规则和命令规则与其他扩展一样通过返回的 API 注册。 */ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { @@ -113,7 +109,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { const notes = new FileNotes() const entries = new WorkspaceEntries() const decorator = new RowDecorator(notes, entries) - const files = new FileTreeProvider(OPEN_COMMAND, decorator) + const files = new FileTreeProvider({ command: OPEN_COMMAND, from: 'other' }, decorator) const view = window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true }) const locator = new TreeLocator(files, view) const review = new ReviewMode(context, FILES_VIEW, view, files) @@ -123,8 +119,8 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { const favorites = new FavoriteViews(context.workspaceState, slots, OPEN_COMMAND, decorator) const features = new FeatureViews(context.workspaceState, slots, qa, item => locateItem(item, { adapterFor: languageId => registry.languageFor(languageId), runtime })) const heads = new HeadBases() - // 预览对比什么按文件所在的位置定:审查工作副本里的对比 PR 的目标分支,工作区仓库里有改动的对比上一次提交 - const diffBases = locatedDiffBases([review, heads]) + // 预览对比什么:审查工作副本里的对比 PR 的目标分支,从「未提交的更改」一栏点开的有改动的文件对比上一次提交,别处打开的不比 + const diffBases = previewDiffBases(review, heads) const previews = new PreviewEditorProvider( context.extensionUri, registry, @@ -141,7 +137,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { ) const openChat = async (page: Uri | undefined): Promise => { if (page) - await previews.openAsking(page) + await previews.open(page, { from: 'other', focusPath: true }) } context.subscriptions.push( @@ -157,7 +153,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { const languageId = await languageOf(uri) return languageId !== undefined && registry.languageFor(languageId) !== undefined }, - uri => previews.openAsking(uri), + uri => previews.open(uri, { from: 'other', focusPath: true }), ), workspace.registerTextDocumentContentProvider(PATH_PREVIEW_SCHEME, { provideTextDocumentContent: () => '', @@ -196,12 +192,13 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { commands.registerCommand(featureCommand('rename', slot), () => features.rename(slot)), commands.registerCommand(featureCommand('remove', slot), () => features.remove(slot)), ]), - commands.registerCommand(OPEN_COMMAND, (uri: Uri) => openFile(uri, registry, previews)), + // 侧栏各栏的文件行传来的是 (文件 Uri, 从哪一栏点开的) + commands.registerCommand(OPEN_COMMAND, (uri: Uri, from: OpenedFrom) => openFile(uri, from, registry, previews)), // 「了解一块代码」栏里的条目行传来的是这一行:按眼下的代码找到它再打开 commands.registerCommand(OPEN_FEATURE_ITEM, async (row?: FeatureRow) => { const found = await features.target(row) if (found) - await openFile(found.uri, registry, previews, found.spot) + await openFile(found.uri, 'other', registry, previews, found.spot) }), // 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签 commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => showPreview(previews, args)), diff --git a/vscode/src/codeReader/uncommitted/headBases.ts b/vscode/src/codeReader/uncommitted/headBases.ts index 4bef6b8..76ca5fa 100644 --- a/vscode/src/codeReader/uncommitted/headBases.ts +++ b/vscode/src/codeReader/uncommitted/headBases.ts @@ -13,7 +13,7 @@ function errorText(err: unknown): string { } /** - * 工作区仓库里有没提交改动的文件,预览对比上一次提交(见 headDiffBase),不管是从哪一栏、点链接还是切标签打开的。 + * 工作区仓库里有没提交改动的文件,对比上一次提交(见 [headDiffBase]);只有从「未提交的更改」一栏点开的预览来问(见 [previewDiffBases](vscode/src/codeReader/diff/diffBase.ts#previewDiffBases))。 * 第一次有预览来问时才去读 git,读到了通知预览重新问;之后盯着 git 状态,改动、HEAD 变了都通知打开着的预览重新比。 */ export class HeadBases implements DiffBaseSource, Disposable { diff --git a/vscode/src/codeReader/uncommitted/uncommittedView.ts b/vscode/src/codeReader/uncommitted/uncommittedView.ts index c9c6433..863c1db 100644 --- a/vscode/src/codeReader/uncommitted/uncommittedView.ts +++ b/vscode/src/codeReader/uncommitted/uncommittedView.ts @@ -43,11 +43,11 @@ export class UncommittedView implements Disposable { /** * @param context 隐藏测试文件的开关记在它的 workspaceState 里 - * @param openCommand 点文件时执行的命令,参数是文件 Uri + * @param openCommand 点文件时执行的命令,参数是文件 Uri 和 `uncommitted`:从这一栏点开的预览对比上一次提交 * @param decorator 给行添信息(文件注释),和别的栏共用 */ constructor(context: ExtensionContext, openCommand: string, decorator: FileTreeDecorator) { - this.files = new FileTreeProvider(openCommand, decorator, { kind: 'changes', view: [] }) + this.files = new FileTreeProvider({ command: openCommand, from: 'uncommitted' }, decorator, { kind: 'changes', view: [] }) this.view = window.createTreeView(UNCOMMITTED_VIEW, { treeDataProvider: this.files, showCollapseAll: true }) this.tests = new TestsToggle(context.workspaceState, UNCOMMITTED_TESTS, () => this.rebuild()) this.disposables = [this.files, this.view, whenShown(this.view, () => void this.open())] diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 7545558..ed8181a 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -72,7 +72,7 @@ export function App(): ReactElement | null { const [keep, setKeep] = useState>(NONE) /** 页面上这个文档版本的中文名:文档带来的,扩展端查到了再换 */ const [names, setNames] = useState() - /** 这份文件相比对比基准改了什么:审查工作副本里的、工作区仓库里有改动的文件扩展端算好了补来,不比时是 null */ + /** 这份文件相比对比基准改了什么:审查工作副本里的、从「未提交的更改」点开的有改动的文件扩展端算好了补来,不比时是 null */ const [diff, setDiff] = useState(null) /** 收到消息时读眼下哪些块在编辑(消息回调只在挂载时注册一次) */ const editing = useRef(view.editing) @@ -177,11 +177,16 @@ export function App(): ReactElement | null { case 'diff': setDiff(msg.diff) return + case 'openedFrom': + viewState.update({ openedFrom: msg.from }) + return case 'focusPathAsk': setPathFocus(n => n + 1) } }) - postMessage({ type: 'ready' }) + // 重启 VS Code 后恢复的标签页,扩展端不知道它是从哪儿打开的,按页面记着的 + const { openedFrom } = viewState.get() + postMessage({ type: 'ready', ...(openedFrom && { openedFrom }) }) return off }, []) diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 75a5db8..87e68f5 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -448,6 +448,9 @@ export interface StickyScrollSettings { maxLineCount: number } +/** 这个标签页是从哪儿打开的:从侧栏「未提交的更改」一栏点开的是 `uncommitted`,别处打开的是 `other`;页面只记着,重启 VS Code 后恢复时报给扩展端。 */ +export type OpenedFrom = 'uncommitted' | 'other' + /** * reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal) * definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义 @@ -464,7 +467,8 @@ export interface StickyScrollSettings { * rewriteEnd:askId 那一轮重写结束了,见 RewriteEnd:done 是模型答完、记下了(没写进文件时带原因);failed、stopped 没记,文件没改 * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现 * displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理 - * diff:这份文件相比对比基准改了什么(审查工作副本里的、工作区仓库里有改动的文件才有);每来一份文档之后补来,基准变了再来;null 是不比了 + * diff:这份文件相比对比基准改了什么(审查工作副本里的、从「未提交的更改」点开的有改动的文件才有);每来一份文档之后补来,基准变了、换了从哪儿打开再来;null 是不比了 + * openedFrom:这个标签页是从哪儿打开的,记进页面状态;ready 后来一次,开着时又被点开再来 * repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用 * repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到 * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了 @@ -530,6 +534,7 @@ export type ExtensionToWebview | { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string, queued?: true } | RewriteEnd | { type: 'diff', diff: DocumentDiff | null } + | { type: 'openedFrom', from: OpenedFrom } | { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] } | { type: 'repoLinkBroken', path?: string, symbol?: string } | { type: 'repoLinkOpened', path?: string, symbol?: string } @@ -539,6 +544,7 @@ export type ExtensionToWebview | { type: 'callTree', version: number, anchor: string, name: string, where: string, trees: CallTree[], contractMiss?: ContractMiss } /** + * ready:页面准备好了,可以收消息;openedFrom 是页面状态里记着的这个标签页是从哪儿打开的,新开的页面没记过就不带 * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进) * visibleLine:视口顶部那块的起始行,切回文本编辑器时扩展端把这行放到顶部 * probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问 offset 处有没有定义;id 标出是哪一次问,回答带回来; @@ -568,7 +574,7 @@ export type ExtensionToWebview * goToCallNode:点了调用树上的一个节点,打开 uri 那个文件,跳到 line 行 character 列(见 CallTreeNode.jump) */ export type WebviewToExtension - = | { type: 'ready' } + = | { type: 'ready', openedFrom?: OpenedFrom } | { type: 'setFilter', excluded: Excluded } | { type: 'setOnlyChanges', on: boolean } | { type: 'edit', startLine: number, endLine: number, text: string } diff --git a/vscode/webview-ui/src/codeReader/viewState.test.ts b/vscode/webview-ui/src/codeReader/viewState.test.ts index 5673d69..4683a6c 100644 --- a/vscode/webview-ui/src/codeReader/viewState.test.ts +++ b/vscode/webview-ui/src/codeReader/viewState.test.ts @@ -62,6 +62,14 @@ describe('webview 状态', () => { expect(createViewStateStore(memoryStorage(null)).get()).toEqual({ editing: [], folds: {} }) }) + it('记着这个标签页是从哪儿打开的,页面重新加载后取回;认不得的值当没记过', () => { + const storage = memoryStorage() + createViewStateStore(storage).update({ openedFrom: 'uncommitted' }) + expect(createViewStateStore(storage).get()).toEqual({ editing: [], folds: {}, openedFrom: 'uncommitted' }) + expect(createViewStateStore(memoryStorage({ openedFrom: 'other' })).get()).toEqual({ editing: [], folds: {}, openedFrom: 'other' }) + expect(createViewStateStore(memoryStorage({ openedFrom: 'files' })).get()).toEqual({ editing: [], folds: {} }) + }) + it('属性行的折叠 id 是块 id 加行序号,不会和块 id 撞上', () => { expect(propertyRowId('6.1.0', 3)).toBe('6.1.0/p3') expect(propertyRowId('6', 1)).not.toBe('6.1') diff --git a/vscode/webview-ui/src/codeReader/viewState.ts b/vscode/webview-ui/src/codeReader/viewState.ts index da149d0..705e6a2 100644 --- a/vscode/webview-ui/src/codeReader/viewState.ts +++ b/vscode/webview-ui/src/codeReader/viewState.ts @@ -1,15 +1,18 @@ import type { ScrollAnchor } from './scrollPosition' -import type { BlockView } from './types' +import type { BlockView, OpenedFrom } from './types' /** * 标签页切走时 VS Code 会销毁 webview、切回来重新加载,靠它恢复:哪些块停在「编辑」、 - * 用户把哪些块、属性行和子行折叠 / 展开了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行、子行 id 见 propertyRowId、propertyItemId),以及滚到了哪。 + * 用户把哪些块、属性行和子行折叠 / 展开了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行、子行 id 见 [propertyRowId]、[propertyItemId]),以及滚到了哪。 + * VS Code 重启后恢复标签页时也带着它,这个标签页是从哪儿打开的靠它报回扩展端。 */ export interface ViewState { editing: string[] /** 用户自己点过折叠箭头的块、属性行和子行:id → 折叠着没有。没点过的按默认,见 collapsedIds */ folds: Record scroll?: ScrollAnchor + /** 这个标签页是从哪儿打开的,扩展端告诉的;新开的页面还没记 */ + openedFrom?: OpenedFrom } export interface ViewStateStorage { @@ -51,7 +54,12 @@ function readViewState(saved: unknown): ViewState { const folds = isRecord(state.folds) ? Object.fromEntries(Object.entries(state.folds).filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean')) : {} - return isScrollAnchor(state.scroll) ? { editing, folds, scroll: state.scroll } : { editing, folds } + return { + editing, + folds, + ...(isScrollAnchor(state.scroll) && { scroll: state.scroll }), + ...((state.openedFrom === 'uncommitted' || state.openedFrom === 'other') && { openedFrom: state.openedFrom }), + } } /**