diff --git a/vscode/package.json b/vscode/package.json index aea5a1a..a1626a9 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -109,6 +109,12 @@ "category": "代码阅读", "icon": "$(git-pull-request)" }, + { + "command": "superpowers.codeReader.showUncommitted", + "title": "未提交的更改", + "category": "代码阅读", + "icon": "$(git-commit)" + }, { "command": "superpowers.codeReader.showAllFiles", "title": "显示全部", @@ -255,14 +261,19 @@ "group": "navigation@1" }, { - "command": "superpowers.codeReader.showAllFiles", - "when": "view == superpowers.codeReader.files && superpowers.codeReader.reviewing", + "command": "superpowers.codeReader.showUncommitted", + "when": "view == superpowers.codeReader.files", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.showAllFiles", + "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.refresh", "when": "view == superpowers.codeReader.files", - "group": "navigation@3" + "group": "navigation@4" } ], "view/item/context": [ @@ -273,12 +284,12 @@ }, { "command": "superpowers.codeReader.addNote", - "when": "view == superpowers.codeReader.files && !superpowers.codeReader.reviewing && viewItem =~ /^(file|folder)$/", + "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file|folder)$/", "group": "inline@2" }, { "command": "superpowers.codeReader.editNote", - "when": "view == superpowers.codeReader.files && !superpowers.codeReader.reviewing && viewItem =~ /^(file|folder) noted$/", + "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file|folder) noted$/", "group": "inline@2" }, { @@ -308,12 +319,12 @@ }, { "command": "superpowers.codeReader.addNote", - "when": "view == superpowers.codeReader.files && !superpowers.codeReader.reviewing && viewItem =~ /^(file|folder)$/", + "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file|folder)$/", "group": "notes" }, { "command": "superpowers.codeReader.editNote", - "when": "view == superpowers.codeReader.files && !superpowers.codeReader.reviewing && viewItem =~ /^(file|folder) noted$/", + "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file|folder) noted$/", "group": "notes" } ], @@ -336,7 +347,7 @@ }, { "command": "superpowers.codeReader.showAllFiles", - "when": "superpowers.codeReader.reviewing" + "when": "superpowers.codeReader.filter" }, { "command": "superpowers.codeReader.showPreview", diff --git a/vscode/src/codeReader/explorer/changedTree.test.ts b/vscode/src/codeReader/explorer/changedTree.test.ts new file mode 100644 index 0000000..a80d1c0 --- /dev/null +++ b/vscode/src/codeReader/explorer/changedTree.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { buildChangedTree } from './changedTree' + +describe('改动的文件树', () => { + // 原型「审查 · 改动的文件」里的 6 个文件(schemas.py 是改名的,算修改) + const files = [ + { path: 'backend/src/luffy_agent/user/routes.py', change: 'M' }, + { path: 'backend/tests/user/test_avatar.py', change: 'A' }, + { path: 'backend/src/luffy_agent/core/storage.py', change: 'M' }, + { path: 'backend/src/luffy_agent/user/legacy_profile.py', change: 'D' }, + { path: 'backend/src/luffy_agent/user/services.py', change: 'M' }, + { path: 'backend/src/luffy_agent/user/schemas.py', change: 'M' }, + ] as const + const tree = buildChangedTree(files) + const names = (dir: string): string[] => (tree.get(dir) ?? []).map(entry => entry.name) + + it('目录层级照留,每层只有通往改动的文件的目录', () => { + expect(names('')).toEqual(['backend']) + expect(names('backend')).toEqual(['src', 'tests']) + expect(names('backend/src')).toEqual(['luffy_agent']) + expect(names('backend/src/luffy_agent')).toEqual(['core', 'user']) + expect(names('backend/src/luffy_agent/core')).toEqual(['storage.py']) + expect(names('backend/tests/user')).toEqual(['test_avatar.py']) + }) + + it('同一层里的文件按名字排,带着各自的改动和相对根目录的路径', () => { + expect(tree.get('backend/src/luffy_agent/user')).toEqual([ + { name: 'legacy_profile.py', path: 'backend/src/luffy_agent/user/legacy_profile.py', isDirectory: false, change: 'D' }, + { name: 'routes.py', path: 'backend/src/luffy_agent/user/routes.py', isDirectory: false, change: 'M' }, + { name: 'schemas.py', path: 'backend/src/luffy_agent/user/schemas.py', isDirectory: false, change: 'M' }, + { name: 'services.py', path: 'backend/src/luffy_agent/user/services.py', isDirectory: false, change: 'M' }, + ]) + }) + + it('目录没有改动,路径相对根目录', () => { + expect(tree.get('backend')).toEqual([ + { name: 'src', path: 'backend/src', isDirectory: true }, + { name: 'tests', path: 'backend/tests', isDirectory: true }, + ]) + }) + + it('目录在前、文件在后,名字不区分大小写,和普通的树一样', () => { + const mixed = buildChangedTree([ + { path: 'README.md', change: 'M' }, + { path: 'src/a.ts', change: 'A' }, + { path: 'Docs/b.md', change: 'A' }, + { path: 'package.json', change: 'M' }, + ]) + expect(mixed.get('')?.map(entry => entry.name)).toEqual(['Docs', 'src', 'package.json', 'README.md']) + }) + + it('文件换成同名目录(删掉 a、新加 a/b)两项都留着', () => { + const replaced = buildChangedTree([{ path: 'a', change: 'D' }, { path: 'a/b', change: 'A' }]) + expect(replaced.get('')).toEqual([ + { name: 'a', path: 'a', isDirectory: true }, + { name: 'a', path: 'a', isDirectory: false, change: 'D' }, + ]) + expect(replaced.get('a')?.map(entry => entry.change)).toEqual(['A']) + }) + + it('没有改动的文件时根下面是空的', () => { + expect(buildChangedTree([]).get('')).toEqual([]) + }) +}) diff --git a/vscode/src/codeReader/explorer/changedTree.ts b/vscode/src/codeReader/explorer/changedTree.ts new file mode 100644 index 0000000..c48711a --- /dev/null +++ b/vscode/src/codeReader/explorer/changedTree.ts @@ -0,0 +1,51 @@ +import type { DirEntry } from './fileEntries' +import { sortEntries } from './fileEntries' + +/** 一个文件怎么改的,照 VS Code 源代码管理的写法:修改 M、新增 A、删除 D。 */ +export type FileChange = 'M' | 'A' | 'D' + +/** 改动的一个文件。 */ +export interface ChangedFile { + /** 相对根目录(仓库根、审查的工作副本),`/` 分隔 */ + path: string + change: FileChange +} + +/** 改动的文件树上的一项。 */ +export interface ChangedEntry extends DirEntry { + /** 相对根目录,`/` 分隔 */ + path: string + /** 文件怎么改的;目录没有 */ + change?: FileChange +} + +/** 按目录分好的改动:键是目录相对根目录的路径(根目录是 `''`),值是它下面一层的目录和文件。 */ +export type ChangedTree = ReadonlyMap + +/** + * 改动的文件 → 按目录分好的树:只有改动的文件和通往它们的目录,目录层级照留, + * 每层的排法和侧栏普通的树一样(目录在前、名字不区分大小写)。 + * 同一路径上一个文件换成了同名目录(删掉文件 `a`、新加 `a/b`)时两项都留着。 + */ +export function buildChangedTree(files: readonly ChangedFile[]): ChangedTree { + const levels = new Map>([['', new Map()]]) + const add = (dir: string, entry: ChangedEntry): void => { + let level = levels.get(dir) + if (!level) + levels.set(dir, level = new Map()) + const key = `${entry.isDirectory ? 'dir' : 'file'}:${entry.name}` + if (!level.has(key)) + level.set(key, entry) + } + for (const file of files) { + const segments = file.path.split('/') + segments.forEach((name, index) => { + const dir = segments.slice(0, index).join('/') + const path = segments.slice(0, index + 1).join('/') + add(dir, index === segments.length - 1 + ? { name, path, isDirectory: false, change: file.change } + : { name, path, isDirectory: true }) + }) + } + return new Map([...levels].map(([dir, level]) => [dir, sortEntries([...level.values()])])) +} diff --git a/vscode/src/codeReader/explorer/fileTree.ts b/vscode/src/codeReader/explorer/fileTree.ts index 364670c..0e19743 100644 --- a/vscode/src/codeReader/explorer/fileTree.ts +++ b/vscode/src/codeReader/explorer/fileTree.ts @@ -1,26 +1,32 @@ import type { Event, TreeDataProvider, WorkspaceFolder } from 'vscode' -import type { ChangedTree, FileChange } from '../review/changedFiles' +import type { ChangedTree, FileChange } from './changedTree' import type { ExcludeSetting } from './fileEntries' import * as path from 'node:path' import { EventEmitter, FileType, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, workspace } from 'vscode' -import { CHANGE_STYLES } from '../review/changedFiles' import { createExcludeMatcher, entryContext, sortEntries } from './fileEntries' export interface FileNode { uri: Uri + /** 这一行所在的根:工作区文件夹;只摆改动的文件时是那个根(仓库根、审查的工作副本) */ folder: WorkspaceFolder isDirectory: boolean isRoot: boolean - /** 审查工单时这个文件在 PR 里怎么改的;普通的树、目录没有 */ + /** 只摆改动的文件时这个文件怎么改的;普通的树、目录没有 */ change?: FileChange } -/** 审查工单时树上摆什么:工作副本当唯一的根(folder),下面只有 PR 改过的文件(tree)。 */ -export interface ChangedFilesView { +/** 只摆改动的文件时的一个根:folder 是根目录和根节点上的名字,tree 是它下面改动的文件。 */ +export interface ChangedRoot { folder: WorkspaceFolder tree: ChangedTree } +/** 只摆改动的文件时树上摆什么:每个根一个根节点,下面只有改动的文件和通往它们的目录。 */ +export type ChangedFilesView = readonly ChangedRoot[] + +/** 删掉的文件那一行的悬停提示:它已经不在了,点了打不开。 */ +const DELETED_TOOLTIP = '已删除,没有内容可打开' + /** 给树上的行添信息(比如文件注释):每次画行时调 decorate,onDidChange 报出哪些 Uri 的行要重画。 */ export interface FileTreeDecorator { readonly onDidChange: Event @@ -33,7 +39,7 @@ const REFRESH_DELAY_MS = 300 /** * 侧栏的工作区文件树:每个工作区文件夹一个根节点,只看和打开。 * 节点用 resourceUri,图标、git 改动颜色都由 VS Code 按文件图标主题和 SCM 装饰自动加上。 - * 审查工单时换成只摆 PR 改过的文件(showChanges):目录全部展开,删掉的文件列出来但打不开。 + * 筛选时换成只摆改动的文件(showChanges:审查工单时 PR 改过的,未提交的更改时各仓库里没提交的):目录全部展开,删掉的文件列出来但打不开。 */ export class FileTreeProvider implements TreeDataProvider { /** undefined 是整棵树重画,数组是只重画这几行 */ @@ -43,7 +49,7 @@ export class FileTreeProvider implements TreeDataProvider { /** 按 Uri 记下交给 VS Code 的节点:只重画几行时要交回同一个对象,VS Code 按对象认行。 */ private readonly nodes = new Map() private timer: NodeJS.Timeout | undefined - /** 审查工单时只摆的那些文件;undefined 是普通的工作区文件树 */ + /** 筛选时只摆的那些文件;undefined 是普通的工作区文件树 */ private changes: ChangedFilesView | undefined /** @@ -81,7 +87,7 @@ export class FileTreeProvider implements TreeDataProvider { return item } - /** 换成只摆 PR 改过的文件(undefined 回到工作区文件树),整棵树重画。 */ + /** 换成只摆改动的文件(undefined 回到工作区文件树),整棵树重画。 */ showChanges(view: ChangedFilesView | undefined): void { this.changes = view this.refresh() @@ -119,7 +125,7 @@ export class FileTreeProvider implements TreeDataProvider { const item = new TreeItem(node.uri, TreeItemCollapsibleState.None) item.iconPath = ThemeIcon.File if (node.change === 'D') - item.tooltip = CHANGE_STYLES.D.tooltip // 删掉的文件没有最新的版本可看,不挂打开的命令 + item.tooltip = DELETED_TOOLTIP // 删掉的文件没有内容可看,不挂打开的命令 else item.command = { command: this.openCommand, title: '打开', arguments: [node.uri] } return item @@ -142,12 +148,14 @@ export class FileTreeProvider implements TreeDataProvider { return node } - /** 审查工单时的一层:根下面是工作副本,再往下只有 PR 改过的文件和通往它们的目录。 */ - private changedChildren({ folder, tree }: ChangedFilesView, node?: FileNode): FileNode[] { + /** 只摆改动的文件时的一层:最上面是各个根,再往下只有改动的文件和通往它们的目录。 */ + private changedChildren(roots: ChangedFilesView, node?: FileNode): FileNode[] { if (!node) - return [this.nodeAt(folder.uri, folder, true, true)] + return roots.map(({ folder }) => this.nodeAt(folder.uri, folder, true, true)) + const { folder } = node + const tree = roots.find(root => root.folder === folder)?.tree const dir = path.posix.relative(folder.uri.path, node.uri.path) - return (tree.get(dir) ?? []).map(entry => this.nodeAt(Uri.joinPath(folder.uri, entry.path), folder, entry.isDirectory, false, entry.change)) + return (tree?.get(dir) ?? []).map(entry => this.nodeAt(Uri.joinPath(folder.uri, entry.path), folder, entry.isDirectory, false, entry.change)) } /** 还没展开过的行不用管,展开时自然按最新的画。 */ diff --git a/vscode/src/codeReader/explorer/rowMenus.test.ts b/vscode/src/codeReader/explorer/rowMenus.test.ts index 10dcef7..da8f443 100644 --- a/vscode/src/codeReader/explorer/rowMenus.test.ts +++ b/vscode/src/codeReader/explorer/rowMenus.test.ts @@ -103,35 +103,43 @@ describe('右键菜单的注释', () => { }) }) -describe('审查工单', () => { - const REVIEWING = 'superpowers.codeReader.reviewing' +describe('筛选(审查工单、未提交的更改)', () => { + const FILTER = 'superpowers.codeReader.filter' const PICK = 'superpowers.codeReader.pickReviewIssue' + const UNCOMMITTED = 'superpowers.codeReader.showUncommitted' const SHOW_ALL = 'superpowers.codeReader.showAllFiles' - it('标题栏:第一个「选工单」,审查时才有「显示全部」,然后「刷新」', () => { + it('标题栏:选工单、未提交的更改、开着筛选时才有的「显示全部」、刷新', () => { const title = menus['view/title'] .filter(item => item.when.startsWith('view == superpowers.codeReader.files')) .sort((a, b) => orderOf(a) - orderOf(b)) - expect(title.map(item => item.command)).toEqual([PICK, SHOW_ALL, 'superpowers.codeReader.refresh']) + expect(title.map(item => item.command)).toEqual([PICK, UNCOMMITTED, SHOW_ALL, 'superpowers.codeReader.refresh']) expect(title.every(item => item.group?.startsWith('navigation'))).toBe(true) - expect(title.find(item => item.command === SHOW_ALL)?.when).toContain(`&& ${REVIEWING}`) - expect(title.find(item => item.command === PICK)?.when).not.toContain(REVIEWING) + expect(title.find(item => item.command === SHOW_ALL)?.when).toBe(`view == superpowers.codeReader.files && ${FILTER}`) + for (const command of [PICK, UNCOMMITTED]) + expect(title.find(item => item.command === command)?.when).not.toContain(FILTER) }) - it('按钮的字和图标:选工单 git-pull-request,显示全部 close', () => { + it('按钮的字和图标:选工单 git-pull-request,未提交的更改 git-commit,显示全部 close', () => { expect(commands.find(item => item.command === PICK)).toMatchObject({ title: '选工单', icon: '$(git-pull-request)' }) + expect(commands.find(item => item.command === UNCOMMITTED)).toMatchObject({ title: '未提交的更改', icon: '$(git-commit)' }) expect(commands.find(item => item.command === SHOW_ALL)).toMatchObject({ title: '显示全部', icon: '$(close)' }) }) - it('审查时行上没有注释按钮(工作副本里不记注释),复制照旧', () => { + it('审查时行上没有注释按钮(工作副本里不记注释),未提交的更改时照常有;复制照旧', () => { const noteItems = menus['view/item/context'].filter(item => item.command === ADD_NOTE || item.command === EDIT_NOTE) expect(noteItems.length).toBeGreaterThan(0) - expect(noteItems.every(item => item.when.includes(`!${REVIEWING}`))).toBe(true) + expect(noteItems.every(item => item.when.includes(`${FILTER} != review`))).toBe(true) const copyItems = menus['view/item/context'].filter(item => item.submenu === COPY_MENU || item.command?.startsWith('superpowers.codeReader.copy')) - expect(copyItems.some(item => item.when.includes(REVIEWING))).toBe(false) + expect(copyItems.some(item => item.when.includes(FILTER))).toBe(false) }) - it('命令面板里「显示全部」只在审查时有', () => { - expect(menus.commandPalette.find(item => item.command === SHOW_ALL)?.when).toBe(REVIEWING) + it('命令面板里「显示全部」只在开着筛选时有', () => { + expect(menus.commandPalette.find(item => item.command === SHOW_ALL)?.when).toBe(FILTER) + }) + + it('只用一个上下文键,旧的 superpowers.codeReader.reviewing 不再出现', () => { + const whens = Object.values(menus).flat().map(item => item.when) + expect(whens.some(when => when.includes('superpowers.codeReader.reviewing'))).toBe(false) }) }) diff --git a/vscode/src/codeReader/explorer/treeFilters.ts b/vscode/src/codeReader/explorer/treeFilters.ts new file mode 100644 index 0000000..587793c --- /dev/null +++ b/vscode/src/codeReader/explorer/treeFilters.ts @@ -0,0 +1,76 @@ +import type { TreeView } from 'vscode' +import type { FileNode, FileTreeProvider } from './fileTree' +import { commands, Disposable } from 'vscode' + +/** 上下文键:侧栏树开着哪种筛选,没设是全部文件。标题栏的「显示全部」、行上的注释按钮按它显示 / 藏起来。 */ +const FILTER_CONTEXT = 'superpowers.codeReader.filter' + +/** 侧栏树的筛选:review 审查工单(只摆 PR 改过的文件),uncommitted 未提交的更改(只摆各仓库里没提交的文件)。 */ +export type TreeFilterKind = 'review' | 'uncommitted' + +/** 一种筛选。 */ +export interface TreeFilter { + /** 标题栏的「刷新」:重读这种筛选摆的文件 */ + reload: () => Promise + /** 「显示全部」或换成别的筛选:不再筛、不再记着,正在进的作废 */ + exit: () => Promise +} + +/** + * 侧栏树上的几种筛选,同时只开一种:开一种时别的都退出;上下文键跟着开着的那种。 + * 筛选开起来时自己换上树、标题后面的字(description)、标题下面的一行(message);退出时由这里回到工作区文件树。 + */ +export class TreeFilters { + private readonly filters: TreeFilter[] = [] + private current: TreeFilter | undefined + + constructor(private readonly files: FileTreeProvider, private readonly view: TreeView) {} + + /** 开着的那种筛选;没开是全部文件 */ + get active(): TreeFilter | undefined { + return this.current + } + + /** 登记一种筛选:别的筛选开起来时让它退出。 */ + add(filter: TreeFilter): void { + this.filters.push(filter) + } + + /** + * 视图第一次露出来时调 run(已经露着就马上调):筛选接着上次的,没打开侧栏就不去拉 PR、读 git。 + * @returns 还没露出来时盯着的监听 + */ + whenShown(run: () => void): Disposable { + if (this.view.visible) { + run() + return Disposable.from() + } + const shown = this.view.onDidChangeVisibility(({ visible }) => { + if (!visible) + return + shown.dispose() + run() + }) + return shown + } + + /** filter 换上侧栏树之前调:别的筛选都退出(同步的部分在这里就做完了,调用方紧接着换上自己的),上下文键设成 kind。 */ + async enter(filter: TreeFilter, kind: TreeFilterKind): Promise { + this.current = filter + await Promise.all([ + ...this.filters.filter(other => other !== filter).map(other => other.exit()), + commands.executeCommand('setContext', FILTER_CONTEXT, kind), + ]) + } + + /** filter 退出时调:它开着就回到工作区文件树,清掉标题后面的字、标题下面的一行和上下文键;没开着(还在进、或已经换成别的)什么都不动。 */ + async leave(filter: TreeFilter): Promise { + if (this.current !== filter) + return + this.current = undefined + this.files.showChanges(undefined) + this.view.description = undefined + this.view.message = undefined + await commands.executeCommand('setContext', FILTER_CONTEXT, undefined) + } +} diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 1a9eb5b..d0a4822 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -12,6 +12,7 @@ import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor import { showPreview, showSource } from './editor/switchEditor' import { registerCopyCommands } from './explorer/copyCommands' import { FileTreeProvider } from './explorer/fileTree' +import { TreeFilters } from './explorer/treeFilters' import { createBashAdapter } from './languages/bash' import { createDockerfileAdapter } from './languages/dockerfile' import { createPythonAdapter } from './languages/python' @@ -33,6 +34,7 @@ import { bashZhTranslator } from './translators/bashZh' import { dockerfileZhTranslator } from './translators/dockerfileZh' import { pythonZhTranslator } from './translators/pythonZh' import { typescriptZhTranslator } from './translators/typescriptZh' +import { UncommittedMode } from './uncommitted/uncommittedMode' import { workspaceIndexHost } from './workspaceFiles' import { WorkspaceIndexes } from './workspaceIndex' @@ -62,7 +64,7 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise { } /** - * 接入代码阅读:注册表、侧栏文件树(复制、文件注释、按审查工单只看 PR 改过的文件)、预览编辑器(含问 AI)、编辑器右上角的「预览 / 编辑」切换。 + * 接入代码阅读:注册表、侧栏文件树(复制、文件注释、筛选:按审查工单只看 PR 改过的文件、只看未提交的更改)、预览编辑器(含问 AI)、编辑器右上角的「预览 / 编辑」切换。 * 内置的语言插件、翻译插件、库规则和命令规则与其他扩展一样通过返回的 API 注册。 */ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { @@ -85,7 +87,9 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { const notes = new FileNotes() const files = new FileTreeProvider(OPEN_COMMAND, notes) const view = window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true }) - const review = new ReviewMode(context, FILES_VIEW, view, files) + const filters = new TreeFilters(files, view) + const review = new ReviewMode(context, FILES_VIEW, view, files, filters) + const uncommitted = new UncommittedMode(context, FILES_VIEW, view, files, filters) context.subscriptions.push( qa, @@ -101,10 +105,12 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { files, view, review, - // 审查工单时刷新是重读 PR 改过的文件,平时是重读工作区文件树 - commands.registerCommand('superpowers.codeReader.refresh', () => review.active ? review.reload() : files.refresh()), + uncommitted, + // 开着筛选时刷新是重读它摆的文件(PR 改过的、git 状态),平时是重读工作区文件树 + commands.registerCommand('superpowers.codeReader.refresh', () => filters.active ? filters.active.reload() : files.refresh()), commands.registerCommand('superpowers.codeReader.pickReviewIssue', () => review.pick()), - commands.registerCommand('superpowers.codeReader.showAllFiles', () => review.exit()), + commands.registerCommand('superpowers.codeReader.showUncommitted', () => uncommitted.enter()), + commands.registerCommand('superpowers.codeReader.showAllFiles', () => filters.active?.exit()), commands.registerCommand(OPEN_COMMAND, (uri: Uri) => openFile(uri, registry)), // 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签 commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => showPreview(previews, args)), diff --git a/vscode/src/codeReader/notes/fileNotes.ts b/vscode/src/codeReader/notes/fileNotes.ts index f7d2441..27d4cdc 100644 --- a/vscode/src/codeReader/notes/fileNotes.ts +++ b/vscode/src/codeReader/notes/fileNotes.ts @@ -1,8 +1,7 @@ import type { Disposable, TreeItem } from 'vscode' import type { FileNode, FileTreeDecorator } from '../explorer/fileTree' import { MarkdownString } from 'vscode' -import { FolderStore } from '../folderStore' -import { toPathKey } from '../pathMap' +import { FolderStore, locate } from '../folderStore' import { noteDecoration } from './noteItem' import { withNote } from './noteMap' import { NOTES_FILE } from './store' @@ -10,6 +9,7 @@ import { NOTES_FILE } from './store' /** * 读代码时给目录 / 文件记的注释。 * 盘上每个工作区文件夹一份 `.spx/code-notes.json`;内存里留一份给侧栏树画行时同步取用。 + * 注释记在文件所在的工作区文件夹里,和树从哪个根摆下来(工作区文件夹、仓库根)无关;工作区外面的文件(审查的工作副本)不记。 * 在 VS Code 里改名、挪位置,注释跟着走;删掉就一起删。外部改了文件(git pull、手改)按文件重读(见 FolderStore)。 */ export class FileNotes implements FileTreeDecorator, Disposable { @@ -17,15 +17,15 @@ export class FileNotes implements FileTreeDecorator, Disposable { /** 注释变了的那些行 */ readonly onDidChange = this.store.onDidChange - /** 这一行的注释;工作区根节点不记注释。 */ + /** 这一行的注释;工作区文件夹本身不记注释。 */ noteOf(node: FileNode): string | undefined { - const key = toPathKey(node.folder.uri.path, node.uri.path) - return key === undefined ? undefined : this.store.get(node.folder, key) + const at = locate(node.uri) + return at && this.store.get(at.folder, at.key) } decorate(item: TreeItem, node: FileNode): void { - const key = toPathKey(node.folder.uri.path, node.uri.path) - const decoration = key === undefined ? undefined : noteDecoration(item.contextValue ?? '', key, this.noteOf(node)) + const at = locate(node.uri) + const decoration = at && noteDecoration(item.contextValue ?? '', at.key, this.store.get(at.folder, at.key)) if (!decoration) return item.description = decoration.description @@ -35,9 +35,9 @@ export class FileNotes implements FileTreeDecorator, Disposable { /** 保存注释:去掉首尾空白,空的就删掉这条。 */ async save(node: FileNode, text: string): Promise { - const key = toPathKey(node.folder.uri.path, node.uri.path) - if (key !== undefined) - await this.store.update(node.folder, notes => withNote(notes, key, text)) + const at = locate(node.uri) + if (at) + await this.store.update(at.folder, notes => withNote(notes, at.key, text)) } dispose(): void { diff --git a/vscode/src/codeReader/review/changeDecorations.ts b/vscode/src/codeReader/review/changeDecorations.ts index d397078..124705f 100644 --- a/vscode/src/codeReader/review/changeDecorations.ts +++ b/vscode/src/codeReader/review/changeDecorations.ts @@ -1,5 +1,5 @@ import type { Disposable, Event, FileDecorationProvider, Uri } from 'vscode' -import type { FileChange } from './changedFiles' +import type { FileChange } from '../explorer/changedTree' import { EventEmitter, FileDecoration, ThemeColor } from 'vscode' import { CHANGE_STYLES } from './changedFiles' diff --git a/vscode/src/codeReader/review/changedFiles.test.ts b/vscode/src/codeReader/review/changedFiles.test.ts index 4bf2f78..9236116 100644 --- a/vscode/src/codeReader/review/changedFiles.test.ts +++ b/vscode/src/codeReader/review/changedFiles.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildChangedTree, CHANGE_STYLES, changeOf } from './changedFiles' +import { CHANGE_STYLES, changeOf } from './changedFiles' describe('改动状态', () => { it('新增 A、删除 D,别的(改名、复制、改了、没改)都算修改 M', () => { @@ -16,65 +16,3 @@ describe('改动状态', () => { expect(CHANGE_STYLES.D.tooltip).toBe('PR 里删掉了') }) }) - -describe('改动的文件树', () => { - // 原型「审查 · 改动的文件」里的 6 个文件,外加一个改名的 - const files = [ - { path: 'backend/src/luffy_agent/user/routes.py', status: 'changed' }, - { path: 'backend/tests/user/test_avatar.py', status: 'added' }, - { path: 'backend/src/luffy_agent/core/storage.py', status: 'changed' }, - { path: 'backend/src/luffy_agent/user/legacy_profile.py', status: 'deleted' }, - { path: 'backend/src/luffy_agent/user/services.py', status: 'changed' }, - { path: 'backend/src/luffy_agent/user/schemas.py', status: 'renamed' }, - ] - const tree = buildChangedTree(files) - const names = (dir: string): string[] => (tree.get(dir) ?? []).map(entry => entry.name) - - it('目录层级照留,每层只有通往改过的文件的目录', () => { - expect(names('')).toEqual(['backend']) - expect(names('backend')).toEqual(['src', 'tests']) - expect(names('backend/src')).toEqual(['luffy_agent']) - expect(names('backend/src/luffy_agent')).toEqual(['core', 'user']) - expect(names('backend/src/luffy_agent/core')).toEqual(['storage.py']) - expect(names('backend/tests/user')).toEqual(['test_avatar.py']) - }) - - it('同一层里的文件按名字排,带着各自的改动和相对仓库根的路径', () => { - expect(tree.get('backend/src/luffy_agent/user')).toEqual([ - { name: 'legacy_profile.py', path: 'backend/src/luffy_agent/user/legacy_profile.py', isDirectory: false, change: 'D' }, - { name: 'routes.py', path: 'backend/src/luffy_agent/user/routes.py', isDirectory: false, change: 'M' }, - { name: 'schemas.py', path: 'backend/src/luffy_agent/user/schemas.py', isDirectory: false, change: 'M' }, - { name: 'services.py', path: 'backend/src/luffy_agent/user/services.py', isDirectory: false, change: 'M' }, - ]) - }) - - it('目录没有改动,路径相对仓库根', () => { - expect(tree.get('backend')).toEqual([ - { name: 'src', path: 'backend/src', isDirectory: true }, - { name: 'tests', path: 'backend/tests', isDirectory: true }, - ]) - }) - - it('目录在前、文件在后,名字不区分大小写,和普通的树一样', () => { - const mixed = buildChangedTree([ - { path: 'README.md', status: 'changed' }, - { path: 'src/a.ts', status: 'added' }, - { path: 'Docs/b.md', status: 'added' }, - { path: 'package.json', status: 'changed' }, - ]) - expect(mixed.get('')?.map(entry => entry.name)).toEqual(['Docs', 'src', 'package.json', 'README.md']) - }) - - it('文件换成同名目录(删掉 a、新加 a/b)两项都留着', () => { - const replaced = buildChangedTree([{ path: 'a', status: 'deleted' }, { path: 'a/b', status: 'added' }]) - expect(replaced.get('')).toEqual([ - { name: 'a', path: 'a', isDirectory: true }, - { name: 'a', path: 'a', isDirectory: false, change: 'D' }, - ]) - expect(replaced.get('a')?.map(entry => entry.change)).toEqual(['A']) - }) - - it('没有改动的文件时根下面是空的', () => { - expect(buildChangedTree([]).get('')).toEqual([]) - }) -}) diff --git a/vscode/src/codeReader/review/changedFiles.ts b/vscode/src/codeReader/review/changedFiles.ts index f4a7665..c3a693c 100644 --- a/vscode/src/codeReader/review/changedFiles.ts +++ b/vscode/src/codeReader/review/changedFiles.ts @@ -1,8 +1,4 @@ -import type { DirEntry } from '../explorer/fileEntries' -import { sortEntries } from '../explorer/fileEntries' - -/** PR 里一个文件怎么改的,照 VS Code 源代码管理的写法:修改 M、新增 A、删除 D。 */ -export type FileChange = 'M' | 'A' | 'D' +import type { FileChange } from '../explorer/changedTree' /** * Gitea 给的改动状态 → M / A / D。Gitea 的状态有 added、deleted、changed、renamed、copied、unchanged: @@ -12,48 +8,9 @@ export function changeOf(status: string): FileChange { return status === 'added' ? 'A' : status === 'deleted' ? 'D' : 'M' } -/** 各种改动在侧栏上的样子:颜色用 VS Code 源代码管理的 git 装饰色(主题色 id),徽标就是字母本身。 */ +/** 审查时各种改动在侧栏上的样子:颜色用 VS Code 源代码管理的 git 装饰色(主题色 id),徽标就是字母本身。 */ export const CHANGE_STYLES: Record = { M: { color: 'gitDecoration.modifiedResourceForeground', tooltip: 'PR 里改了' }, A: { color: 'gitDecoration.addedResourceForeground', tooltip: 'PR 里新加的' }, D: { color: 'gitDecoration.deletedResourceForeground', tooltip: 'PR 里删掉了' }, } - -/** 改动的文件树上的一项。 */ -export interface ChangedEntry extends DirEntry { - /** 相对仓库根,`/` 分隔 */ - path: string - /** 文件怎么改的;目录没有 */ - change?: FileChange -} - -/** 按目录分好的改动:键是目录相对仓库根的路径(仓库根是 `''`),值是它下面一层的目录和文件。 */ -export type ChangedTree = ReadonlyMap - -/** - * PR 改过的文件 → 按目录分好的树:只有改过的文件和通往它们的目录,目录层级照留, - * 每层的排法和侧栏普通的树一样(目录在前、名字不区分大小写)。 - * 同一路径上一个文件换成了同名目录(删掉文件 `a`、新加 `a/b`)时两项都留着。 - */ -export function buildChangedTree(files: readonly { path: string, status: string }[]): ChangedTree { - const levels = new Map>([['', new Map()]]) - const add = (dir: string, entry: ChangedEntry): void => { - let level = levels.get(dir) - if (!level) - levels.set(dir, level = new Map()) - const key = `${entry.isDirectory ? 'dir' : 'file'}:${entry.name}` - if (!level.has(key)) - level.set(key, entry) - } - for (const file of files) { - const segments = file.path.split('/') - segments.forEach((name, index) => { - const dir = segments.slice(0, index).join('/') - const path = segments.slice(0, index + 1).join('/') - add(dir, index === segments.length - 1 - ? { name, path, isDirectory: false, change: changeOf(file.status) } - : { name, path, isDirectory: true }) - }) - } - return new Map([...levels].map(([dir, level]) => [dir, sortEntries([...level.values()])])) -} diff --git a/vscode/src/codeReader/review/reviewMode.ts b/vscode/src/codeReader/review/reviewMode.ts index a75bfc0..59461ee 100644 --- a/vscode/src/codeReader/review/reviewMode.ts +++ b/vscode/src/codeReader/review/reviewMode.ts @@ -3,12 +3,14 @@ import type { PrFileDto } from '../../gitea/api' import type { RepoContext } from '../../gitea/repoContext' import type { Issue } from '../../gitea/types' import type { FileNode, FileTreeProvider } from '../explorer/fileTree' +import type { TreeFilter, TreeFilters } from '../explorer/treeFilters' import type { FileCount } from './pickList' import * as path from 'node:path' -import { commands, QuickPickItemKind, Uri, window, workspace } from 'vscode' +import { QuickPickItemKind, Uri, window, workspace } from 'vscode' import { logger } from '../../logging/logger' +import { buildChangedTree } from '../explorer/changedTree' import { ChangeDecorations } from './changeDecorations' -import { buildChangedTree, changeOf } from './changedFiles' +import { changeOf } from './changedFiles' import { countingFiles, pickEntries, pickItemText, pickRows, withFileCount } from './pickList' import { columnLabel, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules' import { loadIssue, loadPullRequest, loadPullRequestFiles, requireRepo, reviewCandidateIssues } from './reviewSource' @@ -16,8 +18,6 @@ import { localWorktree, prepareWorktree } from './reviewWorktree' /** workspaceState:正在审查的工单号,重开窗口接着审查。 */ const REVIEW_ISSUE_KEY = 'superpowers.codeReader.reviewIssue' -/** 上下文键:正在审查工单。标题栏的「显示全部」、行上的注释按钮按它显示 / 藏起来。 */ -const REVIEWING_CONTEXT = 'superpowers.codeReader.reviewing' /** * 怎么进的审查,决定不能审查、出错时怎么收场: @@ -45,8 +45,9 @@ function errorText(err: unknown): string { /** * 侧栏按审查工单筛文件:「选工单」列出看板「审查」「进行中」列里能审查的工单,选中后树里只剩这张工单的 PR 改过的文件(按改动着色), * 点开的是本机这张工单的工作副本里 PR 最新的代码;「显示全部」回到工作区文件树。选的工单按工作区记住。 + * 和别的筛选(未提交的更改)互斥,见 TreeFilters。 */ -export class ReviewMode implements Disposable { +export class ReviewMode implements TreeFilter, Disposable { private readonly decorations = new ChangeDecorations() private readonly disposables: Disposable[] /** 正在审查的工单号 */ @@ -60,25 +61,14 @@ export class ReviewMode implements Disposable { private readonly viewId: string, private readonly view: TreeView, private readonly files: FileTreeProvider, + private readonly filters: TreeFilters, ) { - this.disposables = [this.decorations, window.registerFileDecorationProvider(this.decorations)] - // 视图第一次露出来时才接着上次的审查:没打开侧栏就不去拉 PR、动工作副本 - if (view.visible) { - void this.restore() - } - else { - const shown = view.onDidChangeVisibility(({ visible }) => { - if (!visible) - return - shown.dispose() - void this.restore() - }) - this.disposables.push(shown) - } - } - - get active(): boolean { - return this.reviewing !== undefined + filters.add(this) + this.disposables = [ + this.decorations, + window.registerFileDecorationProvider(this.decorations), + filters.whenShown(() => void this.restore()), + ] } /** @@ -156,16 +146,13 @@ export class ReviewMode implements Disposable { : [])) } - /** 「显示全部」:回到工作区文件树,不再记着这张工单。 */ + /** 「显示全部」或换成别的筛选:不再审查、不再记着这张工单,正在进的审查作废;正审查着的回到工作区文件树。 */ async exit(): Promise { this.generation++ this.reviewing = undefined - this.files.showChanges(undefined) this.decorations.show([]) - this.view.description = undefined - this.view.message = undefined await Promise.all([ - commands.executeCommand('setContext', REVIEWING_CONTEXT, false), + this.filters.leave(this), this.context.workspaceState.update(REVIEW_ISSUE_KEY, undefined), ]) } @@ -261,13 +248,15 @@ export class ReviewMode implements Disposable { const root = Uri.file(worktree) // 工作副本当根,名字照写工作区文件夹的:看的还是这个仓库,只是 PR 最新的代码 const folder: WorkspaceFolder = { uri: root, name: workspace.workspaceFolders?.[0]?.name ?? path.basename(worktree), index: 0 } + const changes = files.map(file => ({ path: file.path, change: changeOf(file.status) })) + const entering = this.filters.enter(this, 'review') this.reviewing = issue.number - this.files.showChanges({ folder, tree: buildChangedTree(files) }) - this.decorations.show(files.map(file => ({ uri: Uri.joinPath(root, file.path), change: changeOf(file.status) }))) + this.files.showChanges([{ folder, tree: buildChangedTree(changes) }]) + this.decorations.show(changes.map(file => ({ uri: Uri.joinPath(root, file.path), change: file.change }))) this.view.description = reviewDescription(issue.number) this.view.message = reviewMessage(issue, pr, files.length) await Promise.all([ - commands.executeCommand('setContext', REVIEWING_CONTEXT, true), + entering, this.context.workspaceState.update(REVIEW_ISSUE_KEY, issue.number), ]) } diff --git a/vscode/src/codeReader/uncommitted/uncommittedFiles.test.ts b/vscode/src/codeReader/uncommitted/uncommittedFiles.test.ts new file mode 100644 index 0000000..060c4d3 --- /dev/null +++ b/vscode/src/codeReader/uncommitted/uncommittedFiles.test.ts @@ -0,0 +1,143 @@ +import type { GitStatus } from '../../git/gitApi' +import type { ChangeGroups } from './uncommittedFiles' +import { describe, expect, it } from 'vitest' +import { GIT_STATUS } from '../../git/gitApi' +import { changeOfGitStatus, uncommittedFiles, uncommittedRoots, workspaceOrder } from './uncommittedFiles' + +const at = (path: string, status: GitStatus): { uri: { path: string }, status: GitStatus } => ({ uri: { path }, status }) + +function groups(parts: Partial): ChangeGroups { + return { mergeChanges: [], indexChanges: [], workingTreeChanges: [], untrackedChanges: [], ...parts } +} + +describe('git 状态 → 改动', () => { + it('未跟踪、新增(含 add -N、合并时新加的)算 A', () => { + for (const status of [GIT_STATUS.UNTRACKED, GIT_STATUS.INDEX_ADDED, GIT_STATUS.INTENT_TO_ADD, GIT_STATUS.ADDED_BY_US, GIT_STATUS.ADDED_BY_THEM, GIT_STATUS.BOTH_ADDED]) + expect(changeOfGitStatus(status)).toBe('A') + }) + + it('删除(暂存的、没暂存的、合并时双方都删的)算 D', () => { + for (const status of [GIT_STATUS.INDEX_DELETED, GIT_STATUS.DELETED, GIT_STATUS.BOTH_DELETED]) + expect(changeOfGitStatus(status)).toBe('D') + }) + + it('别的都算 M:改了、改名、复制、改类型、合并冲突(一方删一方改的文件还在工作区里)', () => { + for (const status of [ + GIT_STATUS.INDEX_MODIFIED, + GIT_STATUS.MODIFIED, + GIT_STATUS.INDEX_RENAMED, + GIT_STATUS.INTENT_TO_RENAME, + GIT_STATUS.INDEX_COPIED, + GIT_STATUS.TYPE_CHANGED, + GIT_STATUS.BOTH_MODIFIED, + GIT_STATUS.DELETED_BY_US, + GIT_STATUS.DELETED_BY_THEM, + ]) + expect(changeOfGitStatus(status)).toBe('M') + }) +}) + +describe('一个仓库里没提交的文件', () => { + it('合并冲突、暂存、没暂存、未跟踪的合在一起,路径相对仓库根', () => { + expect(uncommittedFiles('/repo', groups({ + mergeChanges: [at('/repo/conflict.ts', GIT_STATUS.BOTH_MODIFIED)], + indexChanges: [at('/repo/src/staged.ts', GIT_STATUS.INDEX_MODIFIED)], + workingTreeChanges: [at('/repo/src/gone.ts', GIT_STATUS.DELETED)], + untrackedChanges: [at('/repo/docs/new.md', GIT_STATUS.UNTRACKED)], + }))).toEqual([ + { path: 'conflict.ts', change: 'M' }, + { path: 'src/staged.ts', change: 'M' }, + { path: 'src/gone.ts', change: 'D' }, + { path: 'docs/new.md', change: 'A' }, + ]) + }) + + it('暂存了又改了的只留一项', () => { + expect(uncommittedFiles('/repo', groups({ + indexChanges: [at('/repo/a.ts', GIT_STATUS.INDEX_MODIFIED)], + workingTreeChanges: [at('/repo/a.ts', GIT_STATUS.MODIFIED)], + }))).toEqual([{ path: 'a.ts', change: 'M' }]) + }) + + it('新加的暂存后又改了,还是 A', () => { + expect(uncommittedFiles('/repo', groups({ + indexChanges: [at('/repo/a.ts', GIT_STATUS.INDEX_ADDED)], + workingTreeChanges: [at('/repo/a.ts', GIT_STATUS.MODIFIED)], + }))).toEqual([{ path: 'a.ts', change: 'A' }]) + }) + + it('新加的暂存后又在盘上删了,是 D(打不开)', () => { + expect(uncommittedFiles('/repo', groups({ + indexChanges: [at('/repo/a.ts', GIT_STATUS.INDEX_ADDED)], + workingTreeChanges: [at('/repo/a.ts', GIT_STATUS.DELETED)], + }))).toEqual([{ path: 'a.ts', change: 'D' }]) + }) + + it('从暂存区删掉、文件还在盘上(git rm --cached)的,照盘上算 A', () => { + expect(uncommittedFiles('/repo', groups({ + indexChanges: [at('/repo/a.ts', GIT_STATUS.INDEX_DELETED)], + untrackedChanges: [at('/repo/a.ts', GIT_STATUS.UNTRACKED)], + }))).toEqual([{ path: 'a.ts', change: 'A' }]) + }) + + it('仓库根本身、仓库外面的路径不要', () => { + expect(uncommittedFiles('/repo', groups({ + workingTreeChanges: [at('/repo', GIT_STATUS.MODIFIED), at('/other/a.ts', GIT_STATUS.MODIFIED), at('/repository/b.ts', GIT_STATUS.MODIFIED)], + }))).toEqual([]) + }) + + it('没有改动时是空的', () => { + expect(uncommittedFiles('/repo', groups({}))).toEqual([]) + }) +}) + +describe('工作区里有改动的仓库', () => { + const repo = (root: string, parts: Partial): { rootUri: { path: string }, state: ChangeGroups } => ({ rootUri: { path: root }, state: groups(parts) }) + const folder = (path: string, name: string): { uri: { path: string }, name: string } => ({ uri: { path }, name }) + const changed = (root: string): ReturnType => repo(root, { workingTreeChanges: [at(`${root}/a.ts`, GIT_STATUS.MODIFIED)] }) + + it('仓库根就是工作区文件夹时带着文件夹、用文件夹的名字', () => { + const app = folder('/work/app', '应用') + const [root] = uncommittedRoots([changed('/work/app')], [app]) + expect(root.folder).toBe(app) + expect(root.name).toBe('应用') + expect(root.files).toEqual([{ path: 'a.ts', change: 'M' }]) + }) + + it('嵌套在工作区文件夹里的、包着工作区文件夹的仓库也算,名字用仓库根目录的名字', () => { + const roots = uncommittedRoots([changed('/work/app/libs/sdk'), changed('/work')], [folder('/work/app', 'app')]) + expect(roots.map(root => [root.name, root.folder])).toEqual([['work', undefined], ['sdk', undefined]]) + }) + + it('跟工作区文件夹不沾边的仓库(审查用的工作副本)不算', () => { + expect(uncommittedRoots([changed('/worktrees/review-1'), changed('/work/application')], [folder('/work/app', 'app')])).toEqual([]) + }) + + it('没有改动的仓库不摆', () => { + expect(uncommittedRoots([repo('/work/app', {})], [folder('/work/app', 'app')])).toEqual([]) + }) + + it('按工作区文件夹的次序排,同一个文件夹里按仓库根的路径排', () => { + const roots = uncommittedRoots( + [changed('/b/zeta'), changed('/a'), changed('/b'), changed('/b/alpha')], + [folder('/b', 'B'), folder('/a', 'A')], + ) + expect(roots.map(root => root.repository.rootUri.path)).toEqual(['/b', '/b/alpha', '/b/zeta', '/a']) + }) +}) + +describe('算不算工作区里的仓库', () => { + const folders = [{ uri: { path: '/work/app' }, name: 'app' }, { uri: { path: '/lib' }, name: 'lib' }] + + it('仓库根就是、在里面、包着工作区文件夹的算,给出沾边的第一个工作区文件夹的次序', () => { + expect(workspaceOrder('/work/app', folders)).toBe(0) + expect(workspaceOrder('/work/app/vendor/sdk', folders)).toBe(0) + expect(workspaceOrder('/work', folders)).toBe(0) + expect(workspaceOrder('/lib', folders)).toBe(1) + }) + + it('不沾边的(名字只是前缀相同的、别处的工作副本)是 -1', () => { + expect(workspaceOrder('/work/application', folders)).toBe(-1) + expect(workspaceOrder('/worktrees/review-1', folders)).toBe(-1) + }) +}) diff --git a/vscode/src/codeReader/uncommitted/uncommittedFiles.ts b/vscode/src/codeReader/uncommitted/uncommittedFiles.ts new file mode 100644 index 0000000..7093c87 --- /dev/null +++ b/vscode/src/codeReader/uncommitted/uncommittedFiles.ts @@ -0,0 +1,114 @@ +import type { GitStatus } from '../../git/gitApi' +import type { ChangedFile, FileChange } from '../explorer/changedTree' +import { posix } from 'node:path' +import { GIT_STATUS } from '../../git/gitApi' +import { toPathKey } from '../pathMap' + +/** 一组改动里的一项:文件在哪(Uri.path)、git 状态。 */ +interface StatusEntry { + readonly uri: { readonly path: string } + readonly status: GitStatus +} + +/** 仓库状态里没提交的几组,照 git.d.ts 的 RepositoryState。 */ +export interface ChangeGroups { + readonly mergeChanges: readonly StatusEntry[] + readonly indexChanges: readonly StatusEntry[] + readonly workingTreeChanges: readonly StatusEntry[] + readonly untrackedChanges: readonly StatusEntry[] +} + +const ADDED = new Set([ + GIT_STATUS.INDEX_ADDED, + GIT_STATUS.UNTRACKED, + GIT_STATUS.INTENT_TO_ADD, + GIT_STATUS.ADDED_BY_US, + GIT_STATUS.ADDED_BY_THEM, + GIT_STATUS.BOTH_ADDED, +]) + +const DELETED = new Set([ + GIT_STATUS.INDEX_DELETED, + GIT_STATUS.DELETED, + GIT_STATUS.BOTH_DELETED, +]) + +/** + * git 的文件状态 → M / A / D:未跟踪、新增(含 `git add -N`、合并时一方或双方新加的)算 A, + * 删除(含合并时双方都删了的)算 D,别的(改了、改名、复制、改类型、合并冲突)都算 M。 + * 合并时一方删了、另一方改了的,冲突的文件还留在工作区里,也算 M。 + */ +export function changeOfGitStatus(status: GitStatus): FileChange { + return ADDED.has(status) ? 'A' : DELETED.has(status) ? 'D' : 'M' +} + +/** + * 一个仓库里没提交的文件:合并冲突的、暂存的、没暂存的、未跟踪的合在一起,路径相对仓库根、`/` 分隔。 + * 同一个文件在几组里都有(暂存了又改了)只留一项:照上面的次序越往后越接近盘上现在的样子,后面的盖掉前面的; + * 只是「改了」不盖掉前面的新增(新加的文件暂存后又改了,还是新加的)。 + */ +export function uncommittedFiles(rootPath: string, groups: ChangeGroups): ChangedFile[] { + const files = new Map() + for (const group of [groups.mergeChanges, groups.indexChanges, groups.workingTreeChanges, groups.untrackedChanges]) { + for (const { uri, status } of group) { + const key = toPathKey(rootPath, uri.path) + if (key === undefined) + continue + const change = changeOfGitStatus(status) + const before = files.get(key) + files.set(key, change === 'M' && before !== undefined ? before : change) + } + } + return [...files].map(([path, change]) => ({ path, change })) +} + +function sameOrUnder(child: string, parent: string): boolean { + return child === parent || toPathKey(parent, child) !== undefined +} + +/** 工作区文件夹里用到的:在哪(Uri.path)、叫什么。 */ +interface FolderLike { + readonly uri: { readonly path: string } + readonly name: string +} + +/** 仓库里用到的:仓库根(Uri.path)和没提交的几组改动。 */ +interface RepositoryLike { + readonly rootUri: { readonly path: string } + readonly state: ChangeGroups +} + +/** 有没提交的文件的一个仓库,侧栏上一个根节点。 */ +export interface UncommittedRoot { + repository: R + /** 仓库根就是这个工作区文件夹时是它 */ + folder: F | undefined + /** 根节点上的名字:仓库根就是工作区文件夹时用文件夹的名字,别的用仓库根目录的名字 */ + name: string + files: ChangedFile[] +} + +/** + * 仓库算不算工作区里的:仓库根就是某个工作区文件夹、在某个工作区文件夹里面(嵌套的仓库), + * 或者包着某个工作区文件夹(打开的是仓库里的子目录);编辑器打开别处的文件时 git 扩展顺带打开的仓库(比如审查用的工作副本)不算。 + * @returns 沾边的第一个工作区文件夹的次序;不算工作区里的是 -1 + */ +export function workspaceOrder(rootPath: string, folders: readonly FolderLike[]): number { + return folders.findIndex(folder => sameOrUnder(rootPath, folder.uri.path) || sameOrUnder(folder.uri.path, rootPath)) +} + +/** + * 工作区里(见 workspaceOrder)有没提交的文件的仓库,各一个根。 + * 按沾边的工作区文件夹的次序排,同一个文件夹沾边的按仓库根的路径排。 + */ +export function uncommittedRoots(repositories: readonly R[], folders: readonly F[]): UncommittedRoot[] { + return repositories + .map(repository => ({ repository, rootPath: repository.rootUri.path, order: workspaceOrder(repository.rootUri.path, folders) })) + .filter(({ order }) => order >= 0) + .sort((a, b) => a.order - b.order || (a.rootPath < b.rootPath ? -1 : a.rootPath > b.rootPath ? 1 : 0)) + .map(({ repository, rootPath }) => { + const folder = folders.find(item => item.uri.path === rootPath) + return { repository, folder, name: folder?.name ?? posix.basename(rootPath), files: uncommittedFiles(rootPath, repository.state) } + }) + .filter(root => root.files.length > 0) +} diff --git a/vscode/src/codeReader/uncommitted/uncommittedMode.ts b/vscode/src/codeReader/uncommitted/uncommittedMode.ts new file mode 100644 index 0000000..3b1c3e6 --- /dev/null +++ b/vscode/src/codeReader/uncommitted/uncommittedMode.ts @@ -0,0 +1,191 @@ +import type { ExtensionContext, TreeView } from 'vscode' +import type { GitApi, GitRepository } from '../../git/gitApi' +import type { FileNode, FileTreeProvider } from '../explorer/fileTree' +import type { TreeFilter, TreeFilters } from '../explorer/treeFilters' +import { Disposable, window, workspace } from 'vscode' +import { loadGitApi } from '../../git/gitExtension' +import { logger } from '../../logging/logger' +import { buildChangedTree } from '../explorer/changedTree' +import { uncommittedRoots, workspaceOrder } from './uncommittedFiles' + +/** workspaceState:开着「未提交的更改」,重开窗口接着显示。 */ +const UNCOMMITTED_KEY = 'superpowers.codeReader.uncommitted' +/** 开着时视图标题后面淡色的字(TreeView.description)。 */ +const UNCOMMITTED_DESCRIPTION = '未提交的更改' +/** git 状态连着变(保存、暂存、切分支)时合成一次重摆。 */ +const REBUILD_DELAY_MS = 300 + +/** 怎么进的:enter 点按钮(出错时说一声)、restore 重开窗口接着显示(出错只记日志)。 */ +type How = 'enter' | 'restore' + +function errorText(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +/** + * 侧栏只看未提交的更改:树里只剩工作区各 git 仓库里没提交的文件(暂存的、没暂存的、未跟踪的、合并冲突的),每个有改动的仓库一个根。 + * 文件就是工作区里的文件,颜色和 M / A / D 徽标由 VS Code 自带的 git 装饰加上。开着时盯着 git 状态自动重摆;「显示全部」回到工作区文件树。 + * 开着没开按工作区记住;和审查工单互斥,见 TreeFilters。 + */ +export class UncommittedMode implements TreeFilter, Disposable { + private readonly disposables: Disposable[] + /** 开着时的 git API;没开时 undefined */ + private api: GitApi | undefined + /** 开着时盯 git 状态的监听 */ + private watching: Disposable | undefined + private timer: NodeJS.Timeout | undefined + /** 树上现在摆的改动(序列化),没变就不重画 */ + private shown: string | undefined + /** 每进一次、退一次都加一:等 git 扩展回来时已经退出或又进了一次的,结果丢掉。 */ + private generation = 0 + + /** viewId 是侧栏视图的 id:刷新时在这个视图上转进度。 */ + constructor( + private readonly context: ExtensionContext, + private readonly viewId: string, + private readonly view: TreeView, + private readonly files: FileTreeProvider, + private readonly filters: TreeFilters, + ) { + filters.add(this) + this.disposables = [filters.whenShown(() => void this.restore())] + } + + /** 「未提交的更改」:别的筛选退出,树里换成未提交的文件,之后跟着 git 状态变。 */ + async enter(): Promise { + await this.open('enter') + } + + /** 「显示全部」或换成别的筛选:不再盯 git、不再记着,正在进的作废;开着的回到工作区文件树。 */ + async exit(): Promise { + this.generation++ + this.stopWatching() + await Promise.all([ + this.filters.leave(this), + this.context.workspaceState.update(UNCOMMITTED_KEY, undefined), + ]) + } + + /** 开着时的「刷新」:让 git 扩展重读工作区各仓库的状态,再整棵重摆。 */ + async reload(): Promise { + const api = this.api + if (!api) + return + const folders = workspace.workspaceFolders ?? [] + const repositories = api.repositories.filter(repository => workspaceOrder(repository.rootUri.path, folders) >= 0) + await window.withProgress({ location: { viewId: this.viewId } }, () => Promise.all(repositories.map(repository => this.status(repository)))) + if (this.api !== api) + return + this.shown = undefined + this.rebuild() + } + + dispose(): void { + this.stopWatching() + for (const disposable of this.disposables) + disposable.dispose() + } + + private async restore(): Promise { + if (this.context.workspaceState.get(UNCOMMITTED_KEY)) + await this.open('restore') + } + + private async open(how: How): Promise { + const generation = ++this.generation + let api: GitApi | undefined + try { + api = await loadGitApi() + } + catch (err) { + this.fail(how, '激活 vscode.git 扩展失败', errorText(err)) + return + } + if (generation !== this.generation) + return + if (!api) { + this.fail(how, 'vscode.git 扩展没装或被禁用了') + return + } + this.stopWatching() + this.api = api + this.watching = this.watch(api) + const entering = this.filters.enter(this, 'uncommitted') + this.view.description = UNCOMMITTED_DESCRIPTION + this.rebuild() + await Promise.all([entering, this.context.workspaceState.update(UNCOMMITTED_KEY, true)]) + } + + private fail(how: How, message: string, details?: string): void { + logger.add({ level: 'warn', source: 'codeReader', message: `未提交的更改:${message}`, ...(details ? { details } : {}) }) + if (how === 'enter') + void window.showErrorMessage(`没法显示未提交的更改:${message}`) + } + + private async status(repository: GitRepository): Promise { + try { + await repository.status() + } + catch (err) { + logger.add({ level: 'warn', source: 'codeReader', message: '未提交的更改:重读 git 状态失败', details: `${repository.rootUri.fsPath}\n${errorText(err)}` }) + } + } + + /** 盯着 git:各仓库的状态变了、开了 / 关了仓库、git 扩展找完仓库、工作区文件夹变了,都合成一次重摆。 */ + private watch(api: GitApi): Disposable { + const repositories = new Map() + const schedule = (): void => { + clearTimeout(this.timer) + this.timer = setTimeout(() => this.rebuild(), REBUILD_DELAY_MS) + } + const add = (repository: GitRepository): void => { + if (!repositories.has(repository)) + repositories.set(repository, repository.state.onDidChange(schedule)) + } + api.repositories.forEach(add) + return Disposable.from( + api.onDidOpenRepository((repository) => { + add(repository) + schedule() + }), + api.onDidCloseRepository((repository) => { + repositories.get(repository)?.dispose() + repositories.delete(repository) + schedule() + }), + api.onDidChangeState(schedule), + workspace.onDidChangeWorkspaceFolders(schedule), + { dispose: () => repositories.forEach(listener => listener.dispose()) }, + ) + } + + private stopWatching(): void { + clearTimeout(this.timer) + this.timer = undefined + this.watching?.dispose() + this.watching = undefined + this.api = undefined + this.shown = undefined + } + + /** 按 git 现在的状态重摆树;和树上摆着的一样就不动。没有改动时标题下面写一行说明。 */ + private rebuild(): void { + const api = this.api + if (!api) + return + const folders = workspace.workspaceFolders ?? [] + const roots = uncommittedRoots(api.repositories, folders) + this.view.message = roots.length > 0 + ? undefined + : api.state === 'initialized' ? '没有未提交的更改' : '正在读取 git 状态…' + const shown = JSON.stringify(roots.map(root => [root.repository.rootUri.toString(), root.name, root.files])) + if (shown === this.shown) + return + this.shown = shown + this.files.showChanges(roots.map(root => ({ + // 仓库根不是工作区文件夹时(嵌套的仓库、包着工作区文件夹的仓库)拿仓库根当根,名字用仓库根目录的名字 + folder: root.folder ?? { uri: root.repository.rootUri, name: root.name, index: folders.length }, + tree: buildChangedTree(root.files), + }))) + } +} diff --git a/vscode/src/git/gitApi.ts b/vscode/src/git/gitApi.ts new file mode 100644 index 0000000..2707558 --- /dev/null +++ b/vscode/src/git/gitApi.ts @@ -0,0 +1,74 @@ +import type { Event, Uri } from 'vscode' + +/** + * 内置 git 扩展(vscode.git)API 里用到的那一小部分,照 microsoft/vscode 的 extensions/git/src/api/git.d.ts 写的结构类型。 + * 这里只有类型和状态值,运行时不依赖 vscode,纯函数和单测可以直接用;取 API 见 gitExtension.ts。 + */ + +/** 文件在 git 里的状态,数值照 git.d.ts 的 Status 枚举。 */ +export const GIT_STATUS = { + INDEX_MODIFIED: 0, + INDEX_ADDED: 1, + INDEX_DELETED: 2, + INDEX_RENAMED: 3, + INDEX_COPIED: 4, + MODIFIED: 5, + DELETED: 6, + UNTRACKED: 7, + IGNORED: 8, + INTENT_TO_ADD: 9, + INTENT_TO_RENAME: 10, + TYPE_CHANGED: 11, + ADDED_BY_US: 12, + ADDED_BY_THEM: 13, + DELETED_BY_US: 14, + DELETED_BY_THEM: 15, + BOTH_ADDED: 16, + BOTH_DELETED: 17, + BOTH_MODIFIED: 18, +} as const + +export type GitStatus = (typeof GIT_STATUS)[keyof typeof GIT_STATUS] + +/** 一个文件的改动;改名的 uri 是新路径。 */ +export interface GitChange { + readonly uri: Uri + readonly status: GitStatus +} + +export interface GitRepositoryState { + /** 合并冲突的 */ + readonly mergeChanges: readonly GitChange[] + /** 暂存的 */ + readonly indexChanges: readonly GitChange[] + /** 没暂存的;设置 git.untrackedChanges 是 mixed(默认)时也含未跟踪的 */ + readonly workingTreeChanges: readonly GitChange[] + /** 未跟踪的;设置 git.untrackedChanges 是 separate 时才有 */ + readonly untrackedChanges: readonly GitChange[] + readonly onDidChange: Event +} + +export interface GitRepository { + readonly rootUri: Uri + readonly state: GitRepositoryState + /** 重读这个仓库的 git 状态 */ + status: () => Promise +} + +/** initialized 之前 git 扩展还在找工作区里的仓库,repositories 可能不全。 */ +export type GitApiState = 'uninitialized' | 'initialized' + +export interface GitApi { + readonly state: GitApiState + readonly onDidChangeState: Event + readonly repositories: readonly GitRepository[] + readonly onDidOpenRepository: Event + readonly onDidCloseRepository: Event +} + +/** vscode.git 扩展激活后导出的东西。 */ +export interface GitExtension { + /** 设置 git.enabled 关掉时是 false,这时 getAPI 会抛错 */ + readonly enabled: boolean + getAPI: (version: 1) => GitApi +} diff --git a/vscode/src/git/gitExtension.ts b/vscode/src/git/gitExtension.ts new file mode 100644 index 0000000..71fed98 --- /dev/null +++ b/vscode/src/git/gitExtension.ts @@ -0,0 +1,11 @@ +import type { GitApi, GitExtension } from './gitApi' +import { extensions } from 'vscode' + +/** 取内置 git 扩展的 API(还没激活就先激活);没装、被禁用(设置 git.enabled 关了)时是 undefined,激活失败时抛错。 */ +export async function loadGitApi(): Promise { + const extension = extensions.getExtension('vscode.git') + if (!extension) + return undefined + const git = extension.isActive ? extension.exports : await extension.activate() + return git.enabled ? git.getAPI(1) : undefined +} diff --git a/vscode/src/panel/handlers/toolbar.ts b/vscode/src/panel/handlers/toolbar.ts index 0fc411a..588480c 100644 --- a/vscode/src/panel/handlers/toolbar.ts +++ b/vscode/src/panel/handlers/toolbar.ts @@ -1,47 +1,19 @@ -import type { Uri } from 'vscode' +import type { GitRepository } from '../../git/gitApi' import type { KanbanWebviewPanel } from '../KanbanPanel' import { accessSync, constants } from 'node:fs' import * as path from 'node:path' -import { extensions, workspace } from 'vscode' +import { workspace } from 'vscode' import { resolveCommitProfileForRun } from '../../cc/commitProfile' import { resolveProfilePath } from '../../cc/profiles' import { spawnClaude } from '../../cc/spawnClaude' import { findEnvFiles, lockEnvFiles, unlockEnvFiles } from '../../files/envLock' +import { loadGitApi } from '../../git/gitExtension' import { missingGitIdentityKeys, readGitIdentity } from '../../git/identity' import { logger } from '../../logging/logger' import { getEffectiveCommitProfilePath } from '../../settings/store' import { makeNonce } from '../../webview/html' import { dismiss, toast } from './handoffFlow' -/** - * Minimal structural typing for the bits of the built-in `vscode.git` - * extension API we consume to observe working-tree state. - * Reference: microsoft/vscode → extensions/git/src/api/git.d.ts - */ -interface GitExtensionApiRepositoryStateChange { - (listener: () => unknown): { dispose: () => void } -} - -interface GitExtensionApiRepositoryState { - readonly workingTreeChanges: ReadonlyArray<{ uri: Uri }> - readonly indexChanges: ReadonlyArray<{ uri: Uri }> - readonly untrackedChanges?: ReadonlyArray<{ uri: Uri }> - readonly onDidChange: GitExtensionApiRepositoryStateChange -} - -interface GitExtensionApiRepository { - readonly rootUri: Uri - readonly state: GitExtensionApiRepositoryState -} - -interface GitExtensionApi { - readonly repositories: ReadonlyArray -} - -interface GitExtensionExports { - getAPI: (version: 1) => GitExtensionApi -} - export async function handleCommitRun(panel: KanbanWebviewPanel): Promise { if (panel.commitRunning) return @@ -266,26 +238,20 @@ export function setupGitWatcher(panel: KanbanWebviewPanel): void { if (!wsRoot) return - const gitExt = extensions.getExtension('vscode.git') - if (!gitExt) { - logger.add({ - level: 'warn', - source: 'panel', - message: 'vscode.git 扩展未找到,提交按钮将持续显示', - }) - // Fallback: keep the button visible so users can at least trigger - // commits manually even though we can't observe state. - updateHasChanges(panel, true) - return - } + void loadGitApi().then((api) => { + if (!api) { + logger.add({ + level: 'warn', + source: 'panel', + message: 'vscode.git 扩展没装或被禁用,提交按钮将持续显示', + }) + // Fallback: keep the button visible so users can at least trigger + // commits manually even though we can't observe state. + updateHasChanges(panel, true) + return + } - const activation = gitExt.isActive - ? Promise.resolve(gitExt.exports) - : Promise.resolve(gitExt.activate()) - - void activation.then((exports) => { - const api = exports.getAPI(1) - const findRepo = (): GitExtensionApiRepository | undefined => + const findRepo = (): GitRepository | undefined => api.repositories.find(r => r.rootUri.fsPath === wsRoot) const updateState = (): void => { @@ -297,11 +263,11 @@ export function setupGitWatcher(panel: KanbanWebviewPanel): void { const total = repo.state.workingTreeChanges.length + repo.state.indexChanges.length - + (repo.state.untrackedChanges?.length ?? 0) + + repo.state.untrackedChanges.length updateHasChanges(panel, total > 0) } - const subscribe = (repo: GitExtensionApiRepository): void => { + const subscribe = (repo: GitRepository): void => { panel.gitStateDisposable = repo.state.onDidChange(updateState) }