From c841ae678ac220f5bc29bf3cdfe99566f7bf3418 Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 7 Oct 2026 07:36:56 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E4=BE=A7=E6=A0=8F=E8=B7=9F=E7=9D=80=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E6=96=87=E4=BB=B6=E8=B5=B0=EF=BC=8C=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E5=9C=A8=E4=BE=A7=E6=A0=8F=E6=A0=91=E4=B8=8A?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 11 +- vscode/src/codeReader/editor/messages.ts | 2 +- vscode/src/codeReader/editor/previewEditor.ts | 7 +- .../src/codeReader/explorer/fileTree.test.ts | 204 ++++++++++++++++++ vscode/src/codeReader/explorer/fileTree.ts | 40 ++++ vscode/src/codeReader/explorer/treeLocator.ts | 107 +++++++++ .../codeReader/explorer/treeReveal.test.ts | 56 +++++ vscode/src/codeReader/explorer/treeReveal.ts | 37 ++++ vscode/src/codeReader/index.ts | 5 +- vscode/webview-ui/src/codeReader/types.ts | 2 +- 10 files changed, 464 insertions(+), 7 deletions(-) create mode 100644 vscode/src/codeReader/explorer/fileTree.test.ts create mode 100644 vscode/src/codeReader/explorer/treeLocator.ts create mode 100644 vscode/src/codeReader/explorer/treeReveal.test.ts create mode 100644 vscode/src/codeReader/explorer/treeReveal.ts diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 74059ff..cb6c391 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -59,6 +59,10 @@ - 树节点用 `resourceUri`,图标跟随用户的文件图标主题,git 改动颜色(M / U)由 VS Code 自动加上,外观与自带资源管理器一致。 - 视图标题栏:选工单(见「审查工单」)、刷新、全部折叠(`showCollapseAll`)。文件变化时自动刷新(防抖)。 - 点文件:语言受支持 → 用「代码阅读预览」自定义编辑器打开;否则 `vscode.open`。 +- 侧栏跟着当前文件走,规则同 VS Code 资源管理器:激活的标签页换成一个文件时,树展开到它并选中(`TreeView.reveal`,`select: true`、`focus: false`,焦点留在编辑器)。点仓库链接、⌘ / Ctrl + 点击跳到定义、在标签栏切换、侧栏点开都走这一条(`explorer/treeLocator.ts` 盯着 `window.tabGroups` 的变化)。文本编辑器、代码阅读预览取它开的文件,对比编辑器取改后那边,路径页(问文件夹的预览)取它问的文件夹(文件夹顺带展开);同一个标签页别的变化(改了没保存)不重新定位。 + - `explorer.autoReveal`:`false` 不跟;`focusNoScroll`(选中但不滚动)当作开着——插件的 `TreeView.reveal` 选中就会滚过去,做不到不滚动。`explorer.autoRevealExclude` 命中它自己或路上任何一层目录的(默认 `node_modules`、`bower_components`)不跟。 + - 只在代码阅读这棵树露着时跟,不把侧栏抢过来;树再露出来时补上当前文件。 + - 树上没有的不动:工作区外的、`files.exclude` 藏了的目录里的。开着「未提交的更改」「审查」时只在摆出来的文件里找:在就定位,同一个文件摆在两组里(暂存的、没暂存的)取最先摆的那行;不在就不动,不退出筛选。 - 鼠标停在目录 / 文件行上,行尾一排按钮(`view/item/context` 的 `inline` 组):先「复制」,再「添加注释 / 编辑注释」(见下两节)。工作区根节点没有。 ### 复制 @@ -462,7 +466,7 @@ - 原文:翻译插件去掉注释符号(`#`、`//`、`/*` `*/` 和每行开头的 `*`)、引号和中文名记号(`SourceDocument.markers`,见「中文名」),再去掉行尾空白、首尾空行和共同缩进,保留换行,不做别的转换。一个注释节点(块注释、docstring)的第一行紧跟在注释符号 / 引号后面,不参与算共同缩进(同 PEP 257);几条行注释连成的一块每行地位相同,第一行也算,缩进四格的代码块才留得住。 - 渲染:webview 用 markdown-it(`html: false`、`linkify: true`),CommonMark 加 GFM 的表格、删除线:段落、强调、行内代码(含双反引号)、代码块(围栏、缩进四格)、列表、链接、标题、引用、表格。段内单个换行是软换行,空行分段。同一段文字只渲染一次(缓存最近 500 段)。 - 安全:源码里的 HTML 当文字显示;链接认 http / https / mailto,带协议的网址和邮箱自动成链接(`sample.py` 这种不带协议的不算),别的写法(`javascript:`、`command:`、`./`、`..`、绝对路径、盘符)照原文显示。http / https / mailto 由 VS Code 的 webview 自己拦下、交给宿主在外部打开,不用另发消息。http / https 图片不渲染(webview 的 CSP 不放行外部图片),`![alt](url)` 显示成 `!` 加链接。仓库内图片按原图比例显示,点了打开原图,没有就写「找不到图片」和路径。 -- 仓库路径链接(`[名字](从仓库根起的路径)`,可带 `#类名` 或 `#类名.成员`)能点,点了打开文件并跳到符号。 +- 仓库路径链接(`[名字](从仓库根起的路径)`,可带 `#类名` 或 `#类名.成员`)能点,点了打开文件并跳到符号。指到目录的在代码阅读侧栏的树上定位(展开并选中,和侧栏跟着当前文件走是同一个定位;侧栏没露着时亮出来),不切到 VS Code 资源管理器;树上没有(开着筛选时没摆它)时提示「侧栏的树上没有 …」,不退出筛选。 - 样子:正文用界面字体(`--vscode-font-family`、`--vscode-font-size`)、comment 色、行高 20px,一行注释和一行译文一样高;行内代码用编辑器字体、`--vscode-textCodeBlock-background` 底;围栏代码块用编辑器字体和字号、同样的底,长行折行;段落、列表、代码块、表格之间 6px;标题只比正文稍大(h1 1.15em、h2 1.08em,其余同正文,加粗);链接 `--vscode-textLink-foreground`;引用左边 3px `--vscode-textBlockQuote-border` 竖线、`--vscode-textBlockQuote-background` 底;表格 1px 细线。 - 「编辑」状态照旧是源码,编辑写回不变;Markdown 里的名字不能点,不参与跳到定义。 @@ -2054,7 +2058,7 @@ vscode/src/codeReader/ translators/pythonZh.ts Python 表(含注解位置的判断) translators/bashZh.ts Shell 表:节点读法(展开、特殊变量、条件、通配、&& / ||、管道、遍历、分情况)、开关的叫法 translators/dockerfileZh.ts Dockerfile 表:各条指令的读法(RUN 问嵌着的 shell 读出来的样子)、并块的读法(COPY、LABEL)、解析指令的叫法 - explorer/fileTree.ts 侧栏文件树(FileTreeDecorator:给行添信息的接口;showChanges:审查工单时只摆 PR 改过的文件) + explorer/fileTree.ts 侧栏文件树(FileTreeDecorator:给行添信息的接口;showChanges:审查工单时只摆 PR 改过的文件;nodeOf / getParent:按 Uri 找树上那一行、找上一层) explorer/copyText.ts 复制菜单的命令和复制出什么(纯函数) explorer/copyCommands.ts 复制命令:写剪贴板、状态栏提示 review/changedFiles.ts 改动状态 → M / A / D、颜色,PR 改过的文件 → 按目录分好的树(纯函数) @@ -2067,6 +2071,8 @@ vscode/src/codeReader/ review/reviewMode.ts ReviewMode:选工单弹框、进出审查、刷新、按工作区记住,审查时预览对比什么(diffBase) uncommitted/uncommittedMode.ts UncommittedMode:「未提交的更改」进出、盯 git 状态重摆,开着时预览对比上一次提交(diffBase);纯函数在 uncommittedFiles.ts explorer/treeFilters.ts TreeFilters:侧栏的几种筛选同时只开一种,开着的那种定预览对比什么,换了筛选、基准变了通知预览 + explorer/treeLocator.ts TreeLocator:侧栏的树定位到某个文件 / 目录(跟着当前文件走、目录链接),按 FileTreeProvider 的 nodeOf、getParent 找行 + explorer/treeReveal.ts 定位用到的判断:树上一行和要找的路径什么关系、explorer.autoReveal / autoRevealExclude 怎么认(纯函数) diff/diffBase.ts DiffBase:对比基准(文案、旧内容怎么取、缓存用的 key、基准里没有这个文件时的说明) diff/blockDiff.ts 两棵块树比:兄弟块对齐(名字 / 最长公共子序列 / 相似度)、新增修改删除、删掉的块放哪、统计(纯函数) diff/viewDiff.ts 一块卡片里的内容比:属性表按属性名、子行按第一列、着色的词逐截、Markdown 逐词;删掉的块只留自己的内容、去掉源码下标(纯函数) @@ -2159,6 +2165,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 筛选:块的属性(TS、TSX 有抽象、仅类型,JavaScript / JSX 没有这两个属性、属性表也没有这两行,Python 有访问级别、异步);语言插件自己报的属性叠上去、盖过推出来的,kind 不变;文件里出现过的属性按契约顺序排、语言的排后面,值按第一次出现排、带所有层的块数,名字取翻译插件的、没有的显示 id,中文直译的名字和属性表一致;下拉框只给两种以上值的属性(或一种值正被去掉的),按钮上的字;全选 / 取消全选只动这份文件里的值、别的文件的去掉状态和别的属性不动、取消全选藏掉有这个属性的块(整棵子树)、再全选全回来;同一属性内「或」、不同属性「且」、没有这个属性不受影响、藏整棵子树、「已隐藏」只数直接筛掉的;筛选记在 `workspaceState`、新实例读得回来、所有订阅的预览收到、形状不对的当没有;跳到被筛掉的块临时露出路径、目标的子块照常筛、文档刷新只留正在编辑的。 - 文件注释:键相对工作区文件夹、`/` 分隔,根和外面的路径没有键,手写的 `./`、结尾 `/` 规范掉;保存去空白、清空即删;文件改名、目录挪位置里面各层一起走、名字只是前缀相同的不动、跨工作区文件夹先取出再放进去;删目录连带里面的;读写:文件缺失 / 解析失败 / 不是对象返回空表、跳过不合法条目、建 `.spx`、键排序、删光留 `{}`;有注释 / 没注释的行的描述、悬停提示(Markdown 转义)、contextValue,和 `package.json` 里菜单的 when 子句对得上。 - 侧栏行上的菜单(`explorer/rowMenus.test.ts`,读 `package.json`):行尾按钮先「复制」子菜单再注释,有没有注释换「添加 / 编辑注释」,工作区根都没有;「复制」子菜单带 `$(copy)`,注释图标 `$(note)` / `$(edit)`;子菜单和右键菜单里文件四项、目录三项(有没有注释一样)、菜单上的字;复制命令都登记了、和代码里的命令表一致、命令面板里藏起来。 +- 侧栏定位:树上一行和要找的路径的关系(自己、上层目录、名字开头相同的兄弟目录不算、别的协议不算);`explorer.autoReveal` 的 `true` / `false` / `focusNoScroll` / 没读到;`autoRevealExclude` 命中路上任何一层都不跟(`explorer/treeReveal.test.ts`)。树的数据源(`explorer/fileTree.test.ts`,假的 vscode 和磁盘):多层目录 `getParent` 交回 `getChildren` 给过的同一个对象、根的上一层没有;`nodeOf` 找到文件、目录,顺着 `getParent` 走回根;工作区外、`files.exclude` 藏了的、盘上没有的找不到;只摆改动的文件时在就找到、上一层一直走到分组行,没摆的找不到,同一文件在两组里取最先摆的,分组没名字时根在最上面。 - 审查工单: - 改动的文件树(`review/changedFiles.test.ts`):原型那 6 个文件加一个改名的,目录层级照留、每层只有通往改过的文件的目录,文件带改动和相对仓库根的路径,目录没有改动;排法同普通的树;文件换成同名目录两项都留着;没有文件时根下面是空的。状态 `added` → A、`deleted` → D、别的都是 M,三种颜色是 git 装饰色、删掉的提示「PR 里删掉了」。 - 选工单弹框(`review/pickList.test.ts`):一项的字(有 / 没有工作副本、正在读、读不到、没有 PR);一打开就摆出各项、有 PR 的在读、在转圈,读到一个只换那一项(别的项是同一个对象、次序不变),都读完(没读到也算)停止转圈,没 PR 的、没有工单的一开始就不转;按列分组:先「审查」再「进行中」、每组前一行列名、没有工单的列不出标题、换掉一项后分组不变;工单从哪来:看板这次加载过用它、没加载过用存下的快照(JSON 里字符串的 PR 号认得,只留「审查」列和有 PR 的「进行中」列的 Gitea 工单)、都没有或快照坏了是 undefined。 diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 54cd27b..67888bd 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -145,7 +145,7 @@ export type ExtensionToWebview * selectAskProfile:「问 AI」换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后发 askProfiles 给所有预览 * occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发; * 扩展端按它设 when 子句用的上下文键 - * openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在资源管理器里定位目录 + * openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录 * checkRepoLinks:预览里这批仓库路径,问哪些文件或目录没有。id 标出是哪一次问 * checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问 */ diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 01f4d89..ca8f72a 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -79,6 +79,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { readonly diffBases: DiffBases, /** 哪些文件是项目写明的入口、预览里哪些名字的块标成入口 */ readonly entries: Pick, + /** 在代码阅读侧栏的树上定位一个目录(仓库链接指到目录时);树上没有它时返回 false */ + readonly revealInTree: (uri: Uri) => Promise, ) {} /** 侧栏「问 AI」打开预览,并把光标放进顶部输入框。已经开着的就揭到前面再聚焦。 */ @@ -704,7 +706,7 @@ class Preview { } /** - * 打开注释里的仓库链接:目录在资源管理器里定位,文件用编辑器打开。 + * 打开注释里的仓库链接:目录在代码阅读侧栏的树上定位,文件用编辑器打开。 * 带了符号就按文档符号的名字跳过去;文件或符号没有就不打开。 */ private async openRepoLink(rel: string, symbol?: string): Promise { @@ -721,7 +723,8 @@ class Preview { this.markRepoLinkBroken(rel, symbol) return } - await commands.executeCommand('revealInExplorer', uri) + if (!await this.owner.revealInTree(uri)) + void window.showInformationMessage(`代码阅读:侧栏的树上没有 ${rel}`) this.markRepoLinkOpened(rel) return } diff --git a/vscode/src/codeReader/explorer/fileTree.test.ts b/vscode/src/codeReader/explorer/fileTree.test.ts new file mode 100644 index 0000000..88315db --- /dev/null +++ b/vscode/src/codeReader/explorer/fileTree.test.ts @@ -0,0 +1,204 @@ +import type { WorkspaceFolder } from 'vscode' +import type { ChangedSection, FileNode, FileTreeDecorator, TreeNode } from './fileTree' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Uri } from 'vscode' +import { buildChangedTree } from './changedTree' +import { FileTreeProvider, isGroupNode } from './fileTree' + +/** 假的工作区:目录路径 → 下面一层的 [名字, 类型];files.exclude 的值 */ +const disk = vi.hoisted(() => ({ + dirs: new Map(), + folders: [] as unknown[], + exclude: {} as Record, +})) + +vi.mock('vscode', async () => { + const { posix } = await import('node:path') + class FakeUri { + constructor(readonly scheme: string, readonly authority: string, readonly path: string) {} + static file(path: string): FakeUri { + return new FakeUri('file', '', path) + } + + static joinPath(base: FakeUri, ...segments: string[]): FakeUri { + return base.with({ path: posix.join(base.path, ...segments) }) + } + + with(change: { path?: string }): FakeUri { + return new FakeUri(this.scheme, this.authority, change.path ?? this.path) + } + + toString(): string { + return `${this.scheme}://${this.authority}${this.path}` + } + } + const listen = (): { dispose: () => void } => ({ dispose: () => {} }) + return { + EventEmitter: class { + event = listen + fire(): void {} + dispose(): void {} + }, + FileType: { File: 1, Directory: 2 }, + ThemeIcon: { File: {}, Folder: {} }, + TreeItem: class {}, + TreeItemCollapsibleState: { None: 0, Collapsed: 1, Expanded: 2 }, + Uri: FakeUri, + workspace: { + createFileSystemWatcher: () => ({ onDidCreate: listen, onDidDelete: listen, dispose: () => {} }), + onDidChangeWorkspaceFolders: listen, + onDidChangeConfiguration: listen, + get workspaceFolders() { + return disk.folders + }, + fs: { + readDirectory: async (uri: FakeUri) => { + const entries = disk.dirs.get(uri.path) + if (!entries) + throw new Error(`没有 ${uri.path}`) + return entries + }, + }, + getConfiguration: () => ({ get: () => disk.exclude }), + }, + } +}) + +const FILE = 1 +const DIR = 2 +const decorator = { onDidChange: () => ({ dispose: () => {} }), decorate: () => {} } as unknown as FileTreeDecorator +let folder: WorkspaceFolder + +beforeEach(() => { + folder = { uri: Uri.file('/ws'), name: 'ws', index: 0 } + disk.folders = [folder] + disk.exclude = {} + disk.dirs = new Map([ + ['/ws', [['backend', DIR], ['dist', DIR], ['README.md', FILE]]], + ['/ws/backend', [['src', DIR]]], + ['/ws/backend/src', [['luffy', DIR], ['gate.py', FILE]]], + ['/ws/backend/src/luffy', [['visibility.py', FILE]]], + ['/ws/dist', [['bundle.js', FILE]]], + ]) +}) + +async function childNamed(files: FileTreeProvider, parent: TreeNode, name: string): Promise { + const child = (await files.getChildren(parent)).find(node => !isGroupNode(node) && node.uri.path.endsWith(`/${name}`)) + if (!child || isGroupNode(child)) + throw new Error(`树上没有 ${name}`) + return child +} + +/** 从 node 顺着 getParent 走到最上面,每一层写成路径(分组行写它的名字) */ +function chainUp(files: FileTreeProvider, node: TreeNode | undefined): string[] { + const chain: string[] = [] + for (let current = node; current; current = files.getParent(current)) + chain.push(isGroupNode(current) ? `分组:${current.group.label}` : current.uri.path) + return chain +} + +/** 从 node 顺着 getParent 走到的最上面那一行 */ +function topOf(files: FileTreeProvider, node: TreeNode): TreeNode { + let top = node + for (let parent = files.getParent(top); parent; parent = files.getParent(top)) + top = parent + return top +} + +describe('工作区文件树:按 uri 找到那一行、一层层找上一层', () => { + it('多层目录:getParent 交回 getChildren 给过的同一个对象,根的上一层没有', async () => { + 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') + const luffy = await childNamed(files, src, 'luffy') + const visibility = await childNamed(files, luffy, 'visibility.py') + + expect(files.getParent(visibility)).toBe(luffy) + expect(files.getParent(luffy)).toBe(src) + expect(files.getParent(src)).toBe(backend) + expect(files.getParent(backend)).toBe(root) + expect(files.getParent(root)).toBeUndefined() + }) + + it('nodeOf 一层层读目录找到文件,就是树上摆的那一行', async () => { + const files = new FileTreeProvider('open', decorator) + const found = await files.nodeOf(Uri.file('/ws/backend/src/luffy/visibility.py')) + + expect(found?.isDirectory).toBe(false) + expect(chainUp(files, found)).toEqual(['/ws/backend/src/luffy/visibility.py', '/ws/backend/src/luffy', '/ws/backend/src', '/ws/backend', '/ws']) + expect(await files.getChildren(files.getParent(found!))).toContain(found) + expect(topOf(files, found!)).toBe((await files.getChildren())[0]) + }) + + it('目录也能找到', async () => { + const files = new FileTreeProvider('open', decorator) + const found = await files.nodeOf(Uri.file('/ws/backend/src')) + + expect(found?.isDirectory).toBe(true) + expect(found?.isRoot).toBe(false) + expect(chainUp(files, found)).toEqual(['/ws/backend/src', '/ws/backend', '/ws']) + }) + + it('树上没有的找不到:工作区外、被 files.exclude 藏了的目录里、盘上没有的', async () => { + disk.exclude = { '**/dist': true } + 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() + expect(await files.nodeOf(Uri.file('/ws/backend/missing.py'))).toBeUndefined() + }) +}) + +describe('只摆改动的文件时:在树上就找到,不在就没有', () => { + function sections(): { staged: ChangedSection, unstaged: ChangedSection } { + return { + staged: { label: '暂存的更改', roots: [{ folder, tree: buildChangedTree([{ path: 'backend/src/gate.py', change: 'M' }]) }] }, + unstaged: { label: '更改', roots: [{ folder, tree: buildChangedTree([ + { path: 'backend/src/luffy/visibility.py', change: 'M' }, + { path: 'backend/src/gate.py', change: 'M' }, + ]) }] }, + } + } + + it('找到那个分组里的那一行,上一层一直走到分组行', async () => { + const files = new FileTreeProvider('open', decorator) + const { staged, unstaged } = sections() + files.showChanges([staged, unstaged]) + const found = await files.nodeOf(Uri.file('/ws/backend/src/luffy/visibility.py')) + + expect(found?.section).toBe(unstaged) + expect(found?.change).toBe('M') + expect(chainUp(files, found)).toEqual(['/ws/backend/src/luffy/visibility.py', '/ws/backend/src/luffy', '/ws/backend/src', '/ws/backend', '/ws', '分组:更改']) + expect(topOf(files, found!)).toBe((await files.getChildren())[1]) + expect(await files.getChildren(files.getParent(found!))).toContain(found) + }) + + it('没摆出来的文件、目录找不到', async () => { + const files = new FileTreeProvider('open', decorator) + const { staged, unstaged } = sections() + files.showChanges([staged, unstaged]) + + expect(await files.nodeOf(Uri.file('/ws/README.md'))).toBeUndefined() + expect(await files.nodeOf(Uri.file('/ws/dist'))).toBeUndefined() + expect(await files.nodeOf(Uri.file('/ws/backend/src/other.py'))).toBeUndefined() + }) + + it('同一个文件摆在两个分组里时取最先摆的', async () => { + const files = new FileTreeProvider('open', decorator) + const { staged, unstaged } = sections() + files.showChanges([staged, unstaged]) + + expect((await files.nodeOf(Uri.file('/ws/backend/src/gate.py')))?.section).toBe(staged) + }) + + it('分组没有名字时根就在最上面一层,根的上一层没有', async () => { + const files = new FileTreeProvider('open', decorator) + files.showChanges([{ 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() + + expect(chainUp(files, found)).toEqual(['/ws/backend/src/gate.py', '/ws/backend/src', '/ws/backend', '/ws']) + expect(topOf(files, found!)).toBe(root) + }) +}) diff --git a/vscode/src/codeReader/explorer/fileTree.ts b/vscode/src/codeReader/explorer/fileTree.ts index 3682aad..3949d57 100644 --- a/vscode/src/codeReader/explorer/fileTree.ts +++ b/vscode/src/codeReader/explorer/fileTree.ts @@ -4,6 +4,7 @@ import type { ExcludeSetting } from './fileEntries' import * as path from 'node:path' import { EventEmitter, FileType, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, workspace } from 'vscode' import { createExcludeMatcher, entryContext, GROUP_CONTEXT, sortEntries } from './fileEntries' +import { pathRelation } from './treeReveal' export interface FileNode { uri: Uri @@ -64,6 +65,7 @@ const REFRESH_DELAY_MS = 300 * 节点用 resourceUri,图标、git 改动颜色都由 VS Code 按文件图标主题和 SCM 装饰自动加上。 * 筛选时换成只摆改动的文件(showChanges:审查工单时 PR 改过的,未提交的更改时各仓库里没提交的,分「暂存的更改」「更改」两组): * 目录全部展开,删掉的文件列出来但打不开。 + * 能按 Uri 找到树上那一行和它的上一层(nodeOf、getParent),侧栏定位到某个文件 / 目录(TreeLocator)靠它。 */ export class FileTreeProvider implements TreeDataProvider { /** undefined 是整棵树重画,数组是只重画这几行 */ @@ -145,6 +147,28 @@ export class FileTreeProvider implements TreeDataProvider { .map(entry => this.nodeAt(Uri.joinPath(node.uri, entry.name), node.folder, entry.isDirectory, false)) } + /** + * 一行的上一层,交回 getChildren 给过的同一个对象(`TreeView.reveal` 按对象认行): + * 文件、目录的上一层是所在目录,根节点的上一层是它所在的分组行(只摆改动的文件、分组有名字时),别的没有。 + */ + getParent(node: TreeNode): TreeNode | undefined { + if (isGroupNode(node)) + return undefined + if (node.isRoot) + return this.groups.find(({ group }) => group === node.section && group.label !== undefined) + const parentPath = path.posix.dirname(node.uri.path) + const isRoot = path.posix.relative(node.folder.uri.path, parentPath) === '' + return this.nodeAt(isRoot ? node.folder.uri : node.uri.with({ path: parentPath }), node.folder, true, isRoot, node.section) + } + + /** + * 树上 uri 这一行(文件或目录):从最上面一层层往下找,只进通往它的目录,路上的行按 getChildren 摆出来的记下。 + * 不在树上(工作区外、被 files.exclude 藏了、只摆改动的文件时没摆它)时没有;同一个文件摆在几个分组里时取最先摆的那行。 + */ + async nodeOf(uri: Uri): Promise { + return this.findBelow(undefined, uri) + } + dispose(): void { clearTimeout(this.timer) for (const disposable of this.disposables) @@ -209,6 +233,22 @@ export class FileTreeProvider implements TreeDataProvider { return section.roots.map(({ folder }) => this.nodeAt(folder.uri, folder, true, true, section)) } + private async findBelow(parent: TreeNode | undefined, uri: Uri): Promise { + for (const child of await this.getChildren(parent)) { + if (!isGroupNode(child)) { + const relation = pathRelation(child.uri, uri) + if (relation === 'self') + return child + if (relation !== 'ancestor' || !child.isDirectory) + continue + } + const found = await this.findBelow(child, uri) + if (found) + return found + } + return undefined + } + /** 还没展开过的行不用管,展开时自然按最新的画。 */ private redraw(uris: readonly Uri[]): void { const nodes = uris.flatMap(uri => [...this.nodes.values()].flatMap(known => known.get(uri.toString()) ?? [])) diff --git a/vscode/src/codeReader/explorer/treeLocator.ts b/vscode/src/codeReader/explorer/treeLocator.ts new file mode 100644 index 0000000..b1a2b2a --- /dev/null +++ b/vscode/src/codeReader/explorer/treeLocator.ts @@ -0,0 +1,107 @@ +import type { Disposable, TreeView, Uri } from 'vscode' +import type { ExcludeSetting } from './fileEntries' +import type { FileNode, FileTreeProvider, TreeNode } from './fileTree' +import * as path from 'node:path' +import { TabInputCustom, TabInputNotebook, TabInputNotebookDiff, TabInputText, TabInputTextDiff, window, workspace } from 'vscode' +import { logger } from '../../logging/logger' +import { pathPreviewOf } from '../editor/pathPreview' +import { createExcludeMatcher } from './fileEntries' +import { followsActiveFile, revealExcluded } from './treeReveal' + +/** + * 激活的标签页开着哪个文件:文本编辑器、代码阅读预览这种自定义编辑器、笔记本是它开的文件,对比编辑器取改后的那边(同 VS Code 资源管理器), + * 路径页(问文件夹的预览)还原成那个文件夹;终端、设置页这些没有。 + */ +function activeTabUri(): Uri | undefined { + const input = window.tabGroups.activeTabGroup.activeTab?.input + const uri = input instanceof TabInputText || input instanceof TabInputCustom || input instanceof TabInputNotebook + ? input.uri + : input instanceof TabInputTextDiff || input instanceof TabInputNotebookDiff ? input.modified : undefined + return uri && (pathPreviewOf(uri)?.uri ?? uri) +} + +/** + * 侧栏的树定位到某个文件 / 目录:展开路上的目录、选中那一行(目录顺带展开),焦点留在编辑器。 + * 跟着当前文件走:激活的标签页换成一个文件就定位过去,点链接、跳到定义、在标签栏切换、侧栏点开都走这一条; + * 规则同 VS Code 资源管理器:`explorer.autoReveal` 关掉不跟,`explorer.autoRevealExclude` 里的不跟; + * 树上没有的(工作区外、被 files.exclude 藏了、筛选时没摆它)不动,也不退出筛选。 + * 侧栏这棵树没露出来时不跟(reveal 会把它亮出来,抢了用户的侧栏),露出来时补上。 + */ +export class TreeLocator { + private readonly disposables: Disposable[] + /** 上一次跟过去的文件:同一个标签页别的变化(改了没保存、标题变了)不再定位,用户在树上折起来的不给展开回去 */ + private followed: string | undefined + /** 第几次定位:找那一行要读目录,连着切几个标签页、中间点了目录链接时,跟的只认最后一次 */ + private round = 0 + + constructor(private readonly files: FileTreeProvider, private readonly view: TreeView) { + this.disposables = [ + window.tabGroups.onDidChangeTabs(() => this.follow()), + window.tabGroups.onDidChangeTabGroups(() => this.follow()), + view.onDidChangeVisibility(({ visible }) => { + if (visible) + this.follow() + else + this.followed = undefined + }), + ] + this.follow() + } + + /** + * 在树上定位 uri(仓库链接指到的目录);侧栏这棵树没露出来时 VS Code 把它亮出来,点了链接就是要看。 + * @returns 树上没有它(工作区外、被藏了、筛选时没摆它)时 false + */ + async reveal(uri: Uri): Promise { + // 要看的是这一行:正在跟的作废,树因此亮出来时也不再跟回当前文件 + this.followed = activeTabUri()?.toString() + this.round++ + const node = await this.files.nodeOf(uri) + if (!node) + return false + await this.show(node) + return true + } + + dispose(): void { + for (const disposable of this.disposables) + disposable.dispose() + } + + private follow(): void { + if (!this.view.visible || !followsActiveFile(workspace.getConfiguration('explorer').get('autoReveal'))) + return + const uri = activeTabUri() + const key = uri?.toString() + if (key === this.followed) + return + this.followed = key + const round = ++this.round + if (uri) + void this.followTo(uri, round) + } + + private async followTo(uri: Uri, round: number): Promise { + const node = await this.files.nodeOf(uri) + if (!node || round !== this.round || !this.view.visible || this.excluded(node)) + return + try { + await this.show(node) + } + catch (err) { + // 找到行之后树刚好刷新了(文件增删、换了筛选),这一行已经不是树上的那个对象 + const message = err instanceof Error ? err.message : String(err) + logger.add({ level: 'warn', source: 'codeReader', message: `侧栏没能定位到 ${uri.fsPath}:${message}` }) + } + } + + /** `explorer.autoRevealExclude` 按这一行所在的根算相对路径 */ + private excluded(node: FileNode): boolean { + const setting = workspace.getConfiguration('explorer', node.folder.uri).get('autoRevealExclude') + return revealExcluded(createExcludeMatcher(setting), path.posix.relative(node.folder.uri.path, node.uri.path)) + } + + private async show(node: FileNode): Promise { + await this.view.reveal(node, { select: true, focus: false, expand: node.isDirectory }) + } +} diff --git a/vscode/src/codeReader/explorer/treeReveal.test.ts b/vscode/src/codeReader/explorer/treeReveal.test.ts new file mode 100644 index 0000000..ac542ea --- /dev/null +++ b/vscode/src/codeReader/explorer/treeReveal.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { createExcludeMatcher } from './fileEntries' +import { followsActiveFile, pathRelation, revealExcluded } from './treeReveal' + +const file = (path: string): { scheme: string, authority: string, path: string } => ({ scheme: 'file', authority: '', path }) + +describe('树上一行和要找的路径的关系', () => { + it('同一个路径是它自己', () => { + expect(pathRelation(file('/ws/backend/a.py'), file('/ws/backend/a.py'))).toBe('self') + }) + + it('上面任何一层目录都是往里找的那层', () => { + expect(pathRelation(file('/ws'), file('/ws/backend/src/a.py'))).toBe('ancestor') + expect(pathRelation(file('/ws/backend'), file('/ws/backend/src/a.py'))).toBe('ancestor') + expect(pathRelation(file('/'), file('/ws/a.py'))).toBe('ancestor') + }) + + it('名字开头相同的兄弟目录、下面的路径、别的协议都不相干', () => { + expect(pathRelation(file('/ws/back'), file('/ws/backend/a.py'))).toBeUndefined() + expect(pathRelation(file('/ws/backend/a.py'), file('/ws/backend'))).toBeUndefined() + expect(pathRelation(file('/ws/other'), file('/ws/backend/a.py'))).toBeUndefined() + expect(pathRelation({ scheme: 'git', authority: '', path: '/ws' }, file('/ws/a.py'))).toBeUndefined() + expect(pathRelation({ scheme: 'file', authority: 'host', path: '/ws' }, file('/ws/a.py'))).toBeUndefined() + }) +}) + +describe('explorer.autoReveal 的几种值', () => { + it('true 跟,false 不跟', () => { + expect(followsActiveFile(true)).toBe(true) + expect(followsActiveFile(false)).toBe(false) + }) + + it('focusNoScroll 当作开着(插件的 reveal 选中就会滚过去)', () => { + expect(followsActiveFile('focusNoScroll')).toBe(true) + }) + + it('没读到时照 VS Code 的默认,开着', () => { + expect(followsActiveFile(undefined)).toBe(true) + }) +}) + +describe('explorer.autoRevealExclude', () => { + const excluded = createExcludeMatcher({ '**/node_modules': true, '**/bower_components': true, 'build': false }) + + it('它自己或路上任何一层目录命中都不跟', () => { + expect(revealExcluded(excluded, 'node_modules/react/index.js')).toBe(true) + expect(revealExcluded(excluded, 'packages/web/node_modules/vue/dist/vue.js')).toBe(true) + expect(revealExcluded(excluded, 'node_modules')).toBe(true) + }) + + it('没命中、或那条关掉了的照跟', () => { + expect(revealExcluded(excluded, 'backend/src/visibility.py')).toBe(false) + expect(revealExcluded(excluded, 'build/out.js')).toBe(false) + expect(revealExcluded(excluded, 'src/node_modules_helper.ts')).toBe(false) + }) +}) diff --git a/vscode/src/codeReader/explorer/treeReveal.ts b/vscode/src/codeReader/explorer/treeReveal.ts new file mode 100644 index 0000000..583738b --- /dev/null +++ b/vscode/src/codeReader/explorer/treeReveal.ts @@ -0,0 +1,37 @@ +import type { ExcludeMatcher } from './fileEntries' + +/** Uri 里比路径用到的几样:vscode 的 Uri 就有这几样,测试里不用起 VS Code。 */ +export interface UriPath { + scheme: string + authority: string + path: string +} + +/** 树上一行和要找的 target 的关系:self 就是它,ancestor 是 target 上面的某层目录(往它里面找),undefined 不相干。 */ +export type PathRelation = 'self' | 'ancestor' | undefined + +export function pathRelation(row: UriPath, target: UriPath): PathRelation { + if (row.scheme !== target.scheme || row.authority !== target.authority) + return undefined + if (row.path === target.path) + return 'self' + const dir = row.path.endsWith('/') ? row.path : `${row.path}/` + return target.path.startsWith(dir) ? 'ancestor' : undefined +} + +/** + * `explorer.autoReveal` 的值 → 侧栏要不要跟着当前文件走,同 VS Code 资源管理器:只有 false 不跟。 + * `focusNoScroll`(选中但不滚动)当作开着:插件能用的 `TreeView.reveal` 没有不滚动的选项,选中就会滚过去。 + */ +export function followsActiveFile(autoReveal: unknown): boolean { + return autoReveal !== false +} + +/** + * 相对根的路径命中 `explorer.autoRevealExclude` 时不跟过去,同 VS Code 资源管理器:它自己或路上任何一层目录命中都算, + * 所以 `node_modules` 这一条管到它里面所有的文件。规则里看同级有没有某个文件的 `when` 不认(这里没有同级的名字)。 + */ +export function revealExcluded(excluded: ExcludeMatcher, relativePath: string): boolean { + const segments = relativePath.split('/') + return segments.some((_, index) => excluded(segments.slice(0, index + 1).join('/'), new Set())) +} diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index c58255c..b606216 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -18,6 +18,7 @@ import { FileTreeProvider } from './explorer/fileTree' import { registerPathAsk } from './explorer/pathAskPanel' import { RowDecorator } from './explorer/rowDecorator' import { TreeFilters } from './explorer/treeFilters' +import { TreeLocator } from './explorer/treeLocator' import { createBashAdapter } from './languages/bash' import { createDockerfileAdapter } from './languages/dockerfile' import { createPythonAdapter } from './languages/python' @@ -102,11 +103,12 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { const entries = new WorkspaceEntries() const files = new FileTreeProvider(OPEN_COMMAND, new RowDecorator(notes, entries)) const view = window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true }) + const locator = new TreeLocator(files, view) const filters = new TreeFilters(files, view, context.workspaceState) const review = new ReviewMode(context, FILES_VIEW, view, files, filters) const uncommitted = new UncommittedMode(context, FILES_VIEW, view, files, filters) // 侧栏开着的筛选定预览对比什么:未提交的更改对比上一次提交,审查工单对比 PR 的目标分支 - const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key), filters, entries) + const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key), filters, entries, uri => locator.reveal(uri)) context.subscriptions.push( qa, @@ -132,6 +134,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { window.registerCustomEditorProvider(PREVIEW_VIEW_TYPE, previews), files, view, + locator, filters, review, uncommitted, diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index be39f38..3d8a98f 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -447,7 +447,7 @@ export type ExtensionToWebview * deleteQa:删掉 anchor 那块下面的一次问答(已经理解了) * selectAskProfile:「问 AI」换 profile;不在本地先改,等扩展端发回 askProfiles 再按它显示 * occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发 - * openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在资源管理器里定位目录 + * openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录 * checkRepoLinks:预览里这批仓库路径,问哪些文件或目录没有。id 标出是哪一次问 * checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问 */