diff --git a/vscode/package.json b/vscode/package.json index a4f701c..044d7a9 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -115,6 +115,24 @@ "category": "代码阅读", "icon": "$(git-commit)" }, + { + "command": "superpowers.codeReader.hideTests", + "title": "隐藏测试文件", + "category": "代码阅读", + "icon": "$(beaker-stop)" + }, + { + "command": "superpowers.codeReader.showTests", + "title": "显示测试文件", + "category": "代码阅读", + "icon": "$(beaker)" + }, + { + "command": "superpowers.codeReader.stage", + "title": "暂存", + "category": "代码阅读", + "icon": "$(add)" + }, { "command": "superpowers.codeReader.showAllFiles", "title": "显示全部", @@ -215,7 +233,7 @@ "superpowers.codeReader.copy": [ { "command": "superpowers.codeReader.copyFileName", - "when": "view == superpowers.codeReader.files && viewItem =~ /^file( noted)?$/", + "when": "view == superpowers.codeReader.files && viewItem =~ /^file( unstaged)?( noted)?$/", "group": "copy@1" }, { @@ -225,17 +243,17 @@ }, { "command": "superpowers.codeReader.copyFileStem", - "when": "view == superpowers.codeReader.files && viewItem =~ /^file( noted)?$/", + "when": "view == superpowers.codeReader.files && viewItem =~ /^file( unstaged)?( noted)?$/", "group": "copy@2" }, { "command": "superpowers.codeReader.copyRelativePath", - "when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)( noted)?$/", + "when": "view == superpowers.codeReader.files && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", "group": "copy@3" }, { "command": "superpowers.codeReader.copyAbsolutePath", - "when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)( noted)?$/", + "when": "view == superpowers.codeReader.files && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", "group": "copy@4" } ], @@ -265,36 +283,51 @@ "when": "view == superpowers.codeReader.files", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.hideTests", + "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter == uncommitted && !superpowers.codeReader.testsHidden", + "group": "navigation@3" + }, + { + "command": "superpowers.codeReader.showTests", + "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter == uncommitted && superpowers.codeReader.testsHidden", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.showAllFiles", "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter", - "group": "navigation@3" + "group": "navigation@4" }, { "command": "superpowers.codeReader.refresh", "when": "view == superpowers.codeReader.files", - "group": "navigation@4" + "group": "navigation@5" } ], "view/item/context": [ { "submenu": "superpowers.codeReader.copy", - "when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)( noted)?$/", + "when": "view == superpowers.codeReader.files && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", "group": "inline@1" }, { "command": "superpowers.codeReader.addNote", - "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file|folder)$/", + "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file( unstaged)?|folder)$/", "group": "inline@2" }, { "command": "superpowers.codeReader.editNote", - "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file|folder) noted$/", + "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file( unstaged)?|folder) noted$/", "group": "inline@2" }, + { + "command": "superpowers.codeReader.stage", + "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter == uncommitted && viewItem =~ /^file unstaged( noted)?$/", + "group": "inline@3" + }, { "command": "superpowers.codeReader.copyFileName", - "when": "view == superpowers.codeReader.files && viewItem =~ /^file( noted)?$/", + "when": "view == superpowers.codeReader.files && viewItem =~ /^file( unstaged)?( noted)?$/", "group": "copy@1" }, { @@ -304,27 +337,27 @@ }, { "command": "superpowers.codeReader.copyFileStem", - "when": "view == superpowers.codeReader.files && viewItem =~ /^file( noted)?$/", + "when": "view == superpowers.codeReader.files && viewItem =~ /^file( unstaged)?( noted)?$/", "group": "copy@2" }, { "command": "superpowers.codeReader.copyRelativePath", - "when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)( noted)?$/", + "when": "view == superpowers.codeReader.files && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", "group": "copy@3" }, { "command": "superpowers.codeReader.copyAbsolutePath", - "when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)( noted)?$/", + "when": "view == superpowers.codeReader.files && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", "group": "copy@4" }, { "command": "superpowers.codeReader.addNote", - "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file|folder)$/", + "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file( unstaged)?|folder)$/", "group": "notes" }, { "command": "superpowers.codeReader.editNote", - "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file|folder) noted$/", + "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file( unstaged)?|folder) noted$/", "group": "notes" } ], @@ -349,6 +382,18 @@ "command": "superpowers.codeReader.showAllFiles", "when": "superpowers.codeReader.filter" }, + { + "command": "superpowers.codeReader.hideTests", + "when": "superpowers.codeReader.filter == uncommitted && !superpowers.codeReader.testsHidden" + }, + { + "command": "superpowers.codeReader.showTests", + "when": "superpowers.codeReader.filter == uncommitted && superpowers.codeReader.testsHidden" + }, + { + "command": "superpowers.codeReader.stage", + "when": "false" + }, { "command": "superpowers.codeReader.showPreview", "when": "activeEditor == workbench.editors.files.textFileEditor && (resourceLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact|python|shellscript|dockerfile)$/ || resourceLangId in superpowers.codeReader.languageIds)" diff --git a/vscode/src/codeReader/explorer/fileEntries.test.ts b/vscode/src/codeReader/explorer/fileEntries.test.ts index 37ab37f..61c4445 100644 --- a/vscode/src/codeReader/explorer/fileEntries.test.ts +++ b/vscode/src/codeReader/explorer/fileEntries.test.ts @@ -28,6 +28,12 @@ describe('行的 contextValue', () => { expect(entryContext({ isDirectory: true, isRoot: false })).toBe('folder') expect(entryContext({ isDirectory: false, isRoot: false })).toBe('file') }) + + it('文件行可以带标记(「更改」区能暂存的 file unstaged),目录、根不带', () => { + expect(entryContext({ isDirectory: false, isRoot: false }, 'unstaged')).toBe('file unstaged') + expect(entryContext({ isDirectory: true, isRoot: false }, 'unstaged')).toBe('folder') + expect(entryContext({ isDirectory: true, isRoot: true }, 'unstaged')).toBe('root') + }) }) describe('files.exclude', () => { diff --git a/vscode/src/codeReader/explorer/fileEntries.ts b/vscode/src/codeReader/explorer/fileEntries.ts index b601eb7..f089bac 100644 --- a/vscode/src/codeReader/explorer/fileEntries.ts +++ b/vscode/src/codeReader/explorer/fileEntries.ts @@ -12,9 +12,19 @@ export function sortEntries(entries: T[]): T[] { || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) } -/** 树上一行的 contextValue:菜单的 when 子句按它分工作区根、目录、文件。 */ -export function entryContext(entry: { isDirectory: boolean, isRoot: boolean }): 'root' | 'folder' | 'file' { - return entry.isRoot ? 'root' : entry.isDirectory ? 'folder' : 'file' +/** 分组行(「暂存的更改」「更改」)的 contextValue:不挂任何行菜单。 */ +export const GROUP_CONTEXT = 'group' + +/** + * 树上一行的 contextValue:菜单的 when 子句按它分工作区根、目录、文件。 + * 文件行可以带一个标记(「更改」区里能暂存的是 `file unstaged`),菜单按它给这些行多挂按钮;目录、根不带。 + */ +export function entryContext(entry: { isDirectory: boolean, isRoot: boolean }, fileMarker?: string): string { + if (entry.isRoot) + return 'root' + if (entry.isDirectory) + return 'folder' + return fileMarker ? `file ${fileMarker}` : 'file' } /** `files.exclude` 的值:true 排除;false 不排除;`{ when }` 只在有对应的兄弟文件时排除。 */ diff --git a/vscode/src/codeReader/explorer/fileTree.ts b/vscode/src/codeReader/explorer/fileTree.ts index 0e19743..3682aad 100644 --- a/vscode/src/codeReader/explorer/fileTree.ts +++ b/vscode/src/codeReader/explorer/fileTree.ts @@ -3,7 +3,7 @@ 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 { createExcludeMatcher, entryContext, sortEntries } from './fileEntries' +import { createExcludeMatcher, entryContext, GROUP_CONTEXT, sortEntries } from './fileEntries' export interface FileNode { uri: Uri @@ -11,6 +11,8 @@ export interface FileNode { folder: WorkspaceFolder isDirectory: boolean isRoot: boolean + /** 只摆改动的文件时所在的分组;普通的树没有 */ + section?: ChangedSection /** 只摆改动的文件时这个文件怎么改的;普通的树、目录没有 */ change?: FileChange } @@ -21,8 +23,29 @@ export interface ChangedRoot { tree: ChangedTree } -/** 只摆改动的文件时树上摆什么:每个根一个根节点,下面只有改动的文件和通往它们的目录。 */ -export type ChangedFilesView = readonly ChangedRoot[] +/** 只摆改动的文件时的一个分组:有 label 时画一行分组(名字后面淡色的 description),各个根摆在它下面;没有 label 时各个根直接摆在最上面。 */ +export interface ChangedSection { + label?: string + description?: string + /** 这一组里文件行的 contextValue 带的标记(见 entryContext),菜单按它给这些行多挂按钮 */ + fileMarker?: string + roots: readonly ChangedRoot[] +} + +/** 只摆改动的文件时树上摆什么:一个或几个分组,每组下面每个根一个根节点,再往下只有改动的文件和通往它们的目录。 */ +export type ChangedFilesView = readonly ChangedSection[] + +/** 分组行(「暂存的更改」「更改」)。 */ +export interface GroupNode { + group: ChangedSection +} + +/** 树上的一行:分组行,或者目录 / 文件。 */ +export type TreeNode = GroupNode | FileNode + +export function isGroupNode(node: TreeNode): node is GroupNode { + return 'group' in node +} /** 删掉的文件那一行的悬停提示:它已经不在了,点了打不开。 */ const DELETED_TOOLTIP = '已删除,没有内容可打开' @@ -39,18 +62,21 @@ const REFRESH_DELAY_MS = 300 /** * 侧栏的工作区文件树:每个工作区文件夹一个根节点,只看和打开。 * 节点用 resourceUri,图标、git 改动颜色都由 VS Code 按文件图标主题和 SCM 装饰自动加上。 - * 筛选时换成只摆改动的文件(showChanges:审查工单时 PR 改过的,未提交的更改时各仓库里没提交的):目录全部展开,删掉的文件列出来但打不开。 + * 筛选时换成只摆改动的文件(showChanges:审查工单时 PR 改过的,未提交的更改时各仓库里没提交的,分「暂存的更改」「更改」两组): + * 目录全部展开,删掉的文件列出来但打不开。 */ -export class FileTreeProvider implements TreeDataProvider { +export class FileTreeProvider implements TreeDataProvider { /** undefined 是整棵树重画,数组是只重画这几行 */ - private readonly changed = new EventEmitter() - readonly onDidChangeTreeData: Event = this.changed.event + private readonly changed = new EventEmitter() + readonly onDidChangeTreeData: Event = this.changed.event private readonly disposables: { dispose: () => unknown }[] - /** 按 Uri 记下交给 VS Code 的节点:只重画几行时要交回同一个对象,VS Code 按对象认行。 */ - private readonly nodes = new Map() + /** 按分组、Uri 记下交给 VS Code 的节点:只重画几行时要交回同一个对象,VS Code 按对象认行;同一个文件可以在几个分组里各有一行。 */ + private readonly nodes = new Map>() private timer: NodeJS.Timeout | undefined /** 筛选时只摆的那些文件;undefined 是普通的工作区文件树 */ private changes: ChangedFilesView | undefined + /** 筛选时每个分组的分组行 */ + private groups: GroupNode[] = [] /** * @param openCommand 点文件时执行的命令,参数是文件 Uri @@ -80,9 +106,11 @@ export class FileTreeProvider implements TreeDataProvider { this.changed.fire(undefined) } - getTreeItem(node: FileNode): TreeItem { + getTreeItem(node: TreeNode): TreeItem { + if (isGroupNode(node)) + return this.groupItem(node.group) const item = this.baseItem(node) - item.contextValue = entryContext(node) + item.contextValue = entryContext(node, node.section?.fileMarker) this.decorator.decorate(item, node) return item } @@ -90,14 +118,17 @@ export class FileTreeProvider implements TreeDataProvider { /** 换成只摆改动的文件(undefined 回到工作区文件树),整棵树重画。 */ showChanges(view: ChangedFilesView | undefined): void { this.changes = view + this.groups = (view ?? []).map(group => ({ group })) this.refresh() } - async getChildren(node?: FileNode): Promise { + async getChildren(node?: TreeNode): Promise { if (this.changes) - return this.changedChildren(this.changes, node) + return this.changedChildren(node) if (!node) return (workspace.workspaceFolders ?? []).map(folder => this.nodeAt(folder.uri, folder, true, true)) + if (isGroupNode(node)) + return [] // 分组行只在只摆改动的文件时有 let entries: [string, FileType][] try { @@ -138,29 +169,49 @@ export class FileTreeProvider implements TreeDataProvider { return item } - private nodeAt(uri: Uri, folder: WorkspaceFolder, isDirectory: boolean, isRoot: boolean, change?: FileChange): FileNode { + /** 分组行:名字、后面淡色的 description,一直展开;contextValue 是 GROUP_CONTEXT,不挂行菜单。 */ + private groupItem({ label, description }: ChangedSection): TreeItem { + const item = new TreeItem(label ?? '', TreeItemCollapsibleState.Expanded) + item.description = description + item.contextValue = GROUP_CONTEXT + return item + } + + private nodeAt(uri: Uri, folder: WorkspaceFolder, isDirectory: boolean, isRoot: boolean, section?: ChangedSection, change?: FileChange): FileNode { + let nodes = this.nodes.get(section) + if (!nodes) + this.nodes.set(section, nodes = new Map()) const id = uri.toString() - const known = this.nodes.get(id) + const known = nodes.get(id) if (known && known.folder === folder && known.isDirectory === isDirectory && known.isRoot === isRoot && known.change === change) return known - const node: FileNode = { uri, folder, isDirectory, isRoot, ...(change ? { change } : {}) } - this.nodes.set(id, node) + const node: FileNode = { uri, folder, isDirectory, isRoot, ...(section ? { section } : {}), ...(change ? { change } : {}) } + nodes.set(id, node) return node } - /** 只摆改动的文件时的一层:最上面是各个根,再往下只有改动的文件和通往它们的目录。 */ - private changedChildren(roots: ChangedFilesView, node?: FileNode): FileNode[] { + /** + * 只摆改动的文件时的一层:最上面是分组行(没有名字的分组直接摆它的各个根),分组下面是各个根, + * 再往下只有改动的文件和通往它们的目录。 + */ + private changedChildren(node?: TreeNode): TreeNode[] { if (!node) - return roots.map(({ folder }) => this.nodeAt(folder.uri, folder, true, true)) - const { folder } = node - const tree = roots.find(root => root.folder === folder)?.tree + return this.groups.flatMap((group): TreeNode[] => group.group.label !== undefined ? [group] : this.rootsOf(group.group)) + if (isGroupNode(node)) + return this.rootsOf(node.group) + const { folder, section } = node + const tree = section?.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, section, entry.change)) + } + + private rootsOf(section: ChangedSection): FileNode[] { + return section.roots.map(({ folder }) => this.nodeAt(folder.uri, folder, true, true, section)) } /** 还没展开过的行不用管,展开时自然按最新的画。 */ private redraw(uris: readonly Uri[]): void { - const nodes = uris.flatMap(uri => this.nodes.get(uri.toString()) ?? []) + const nodes = uris.flatMap(uri => [...this.nodes.values()].flatMap(known => known.get(uri.toString()) ?? [])) if (nodes.length > 0) this.changed.fire(nodes) } diff --git a/vscode/src/codeReader/explorer/rowMenus.test.ts b/vscode/src/codeReader/explorer/rowMenus.test.ts index da8f443..30a57ea 100644 --- a/vscode/src/codeReader/explorer/rowMenus.test.ts +++ b/vscode/src/codeReader/explorer/rowMenus.test.ts @@ -2,8 +2,9 @@ import { readFileSync } from 'node:fs' import * as path from 'node:path' import { describe, expect, it } from 'vitest' import { noteDecoration } from '../notes/noteItem' +import { UNSTAGED_MARKER } from '../uncommitted/uncommittedFiles' import { COPY_COMMANDS } from './copyText' -import { entryContext } from './fileEntries' +import { entryContext, GROUP_CONTEXT } from './fileEntries' interface MenuItem { command?: string, submenu?: string, when: string, group?: string } interface Manifest { @@ -23,6 +24,9 @@ const EDIT_NOTE = 'superpowers.codeReader.editNote' const file = entryContext({ isDirectory: false, isRoot: false }) const folder = entryContext({ isDirectory: true, isRoot: false }) const root = entryContext({ isDirectory: true, isRoot: true }) +/** 「未提交的更改」里「更改」组的文件行 */ +const unstaged = entryContext({ isDirectory: false, isRoot: false }, UNSTAGED_MARKER) +const STAGE = 'superpowers.codeReader.stage' const noted = (context: string): string => noteDecoration(context, 'x', '注释')!.contextValue const orderOf = (item: MenuItem): number => Number(item.group?.split('@')[1] ?? 0) @@ -60,6 +64,19 @@ describe('侧栏行尾按钮', () => { expect(itemsFor('view/item/context', 'inline', root)).toEqual([]) }) + it('「更改」组的文件行多一个「暂存」,排在最后;别的行都没有', () => { + expect(itemsFor('view/item/context', 'inline', unstaged)).toEqual([COPY_MENU, ADD_NOTE, STAGE]) + expect(itemsFor('view/item/context', 'inline', noted(unstaged))).toEqual([COPY_MENU, EDIT_NOTE, STAGE]) + for (const context of [file, folder, root, noted(file), noted(folder)]) + expect(itemsFor('view/item/context', 'inline', context)).not.toContain(STAGE) + }) + + it('分组行(「暂存的更改」「更改」)什么按钮、菜单都没有', () => { + for (const group of ['inline', 'copy', 'notes']) + expect(itemsFor('view/item/context', group, GROUP_CONTEXT)).toEqual([]) + expect(itemsFor(COPY_MENU, 'copy', GROUP_CONTEXT)).toEqual([]) + }) + it('「复制」是带 copy 图标的子菜单(VS Code 画成点开的下拉按钮),注释按钮的图标是 note / edit', () => { expect(submenus.find(item => item.id === COPY_MENU)).toEqual({ id: COPY_MENU, label: '复制', icon: '$(copy)' }) expect(commands.find(item => item.command === ADD_NOTE)?.icon).toBe('$(note)') @@ -69,7 +86,7 @@ describe('侧栏行尾按钮', () => { describe('复制菜单', () => { it.each([COPY_MENU, 'view/item/context'])('%s:文件四项、文件夹没有「不带后缀」,有没有注释都一样', (menu) => { - for (const context of [file, noted(file)]) + for (const context of [file, noted(file), unstaged, noted(unstaged)]) expect(itemsFor(menu, 'copy', context)).toEqual(FILE_COPIES) for (const context of [folder, noted(folder)]) expect(itemsFor(menu, 'copy', context)).toEqual(FOLDER_COPIES) @@ -95,7 +112,7 @@ describe('复制菜单', () => { describe('右键菜单的注释', () => { it('没注释的目录 / 文件只有「添加注释」,有注释的只有「编辑注释」,工作区根没有', () => { - for (const context of [file, folder]) { + for (const context of [file, folder, unstaged]) { expect(itemsFor('view/item/context', 'notes', context)).toEqual([ADD_NOTE]) expect(itemsFor('view/item/context', 'notes', noted(context))).toEqual([EDIT_NOTE]) } @@ -107,22 +124,44 @@ describe('筛选(审查工单、未提交的更改)', () => { const FILTER = 'superpowers.codeReader.filter' const PICK = 'superpowers.codeReader.pickReviewIssue' const UNCOMMITTED = 'superpowers.codeReader.showUncommitted' + const HIDE_TESTS = 'superpowers.codeReader.hideTests' + const SHOW_TESTS = 'superpowers.codeReader.showTests' const SHOW_ALL = 'superpowers.codeReader.showAllFiles' + const TESTS_HIDDEN = 'superpowers.codeReader.testsHidden' + const title = menus['view/title'] + .filter(item => item.when.startsWith('view == superpowers.codeReader.files')) + .sort((a, b) => orderOf(a) - orderOf(b)) + const whenOf = (command: string): string | undefined => title.find(item => item.command === command)?.when - 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, UNCOMMITTED, SHOW_ALL, 'superpowers.codeReader.refresh']) + it('标题栏:选工单、未提交的更改、隐藏 / 显示测试文件、开着筛选时才有的「显示全部」、刷新', () => { + expect(title.map(item => item.command)).toEqual([PICK, UNCOMMITTED, HIDE_TESTS, SHOW_TESTS, SHOW_ALL, 'superpowers.codeReader.refresh']) expect(title.every(item => item.group?.startsWith('navigation'))).toBe(true) - expect(title.find(item => item.command === SHOW_ALL)?.when).toBe(`view == superpowers.codeReader.files && ${FILTER}`) + expect(whenOf(SHOW_ALL)).toBe(`view == superpowers.codeReader.files && ${FILTER}`) for (const command of [PICK, UNCOMMITTED]) - expect(title.find(item => item.command === command)?.when).not.toContain(FILTER) + expect(whenOf(command)).not.toContain(FILTER) }) - it('按钮的字和图标:选工单 git-pull-request,未提交的更改 git-commit,显示全部 close', () => { + it('隐藏 / 显示测试文件只在未提交的更改时有,按是否隐藏着换着显示', () => { + expect(whenOf(HIDE_TESTS)).toBe(`view == superpowers.codeReader.files && ${FILTER} == uncommitted && !${TESTS_HIDDEN}`) + expect(whenOf(SHOW_TESTS)).toBe(`view == superpowers.codeReader.files && ${FILTER} == uncommitted && ${TESTS_HIDDEN}`) + const palette = new Map(menus.commandPalette.map(item => [item.command, item.when])) + expect(palette.get(HIDE_TESTS)).toBe(`${FILTER} == uncommitted && !${TESTS_HIDDEN}`) + expect(palette.get(SHOW_TESTS)).toBe(`${FILTER} == uncommitted && ${TESTS_HIDDEN}`) + }) + + it('「暂存」只在未提交的更改时挂在「更改」组的文件行上,命令面板里藏起来', () => { + const stage = menus['view/item/context'].filter(item => item.command === STAGE) + expect(stage).toHaveLength(1) + expect(stage[0].when).toContain(`${FILTER} == uncommitted`) + expect(menus.commandPalette.find(item => item.command === STAGE)?.when).toBe('false') + }) + + it('按钮的字和图标', () => { 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 === HIDE_TESTS)).toMatchObject({ title: '隐藏测试文件', icon: '$(beaker-stop)' }) + expect(commands.find(item => item.command === SHOW_TESTS)).toMatchObject({ title: '显示测试文件', icon: '$(beaker)' }) + expect(commands.find(item => item.command === STAGE)).toMatchObject({ title: '暂存', icon: '$(add)' }) expect(commands.find(item => item.command === SHOW_ALL)).toMatchObject({ title: '显示全部', icon: '$(close)' }) }) diff --git a/vscode/src/codeReader/explorer/treeFilters.ts b/vscode/src/codeReader/explorer/treeFilters.ts index 587793c..2dc7173 100644 --- a/vscode/src/codeReader/explorer/treeFilters.ts +++ b/vscode/src/codeReader/explorer/treeFilters.ts @@ -1,5 +1,5 @@ import type { TreeView } from 'vscode' -import type { FileNode, FileTreeProvider } from './fileTree' +import type { FileTreeProvider, TreeNode } from './fileTree' import { commands, Disposable } from 'vscode' /** 上下文键:侧栏树开着哪种筛选,没设是全部文件。标题栏的「显示全部」、行上的注释按钮按它显示 / 藏起来。 */ @@ -24,7 +24,7 @@ export class TreeFilters { private readonly filters: TreeFilter[] = [] private current: TreeFilter | undefined - constructor(private readonly files: FileTreeProvider, private readonly view: TreeView) {} + constructor(private readonly files: FileTreeProvider, private readonly view: TreeView) {} /** 开着的那种筛选;没开是全部文件 */ get active(): TreeFilter | undefined { diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index d0a4822..90d3fe0 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 { FileNode } from './explorer/fileTree' import type { IndexedName } from './nameResolution' import { commands, window, workspace } from 'vscode' import { logger } from '../logging/logger' @@ -110,6 +111,10 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { commands.registerCommand('superpowers.codeReader.refresh', () => filters.active ? filters.active.reload() : files.refresh()), commands.registerCommand('superpowers.codeReader.pickReviewIssue', () => review.pick()), commands.registerCommand('superpowers.codeReader.showUncommitted', () => uncommitted.enter()), + commands.registerCommand('superpowers.codeReader.hideTests', () => uncommitted.setTestsHidden(true)), + commands.registerCommand('superpowers.codeReader.showTests', () => uncommitted.setTestsHidden(false)), + // 「更改」组文件行上的按钮传来的是这一行的节点 + commands.registerCommand('superpowers.codeReader.stage', (node?: FileNode) => uncommitted.stage(node)), commands.registerCommand('superpowers.codeReader.showAllFiles', () => filters.active?.exit()), commands.registerCommand(OPEN_COMMAND, (uri: Uri) => openFile(uri, registry)), // 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签 diff --git a/vscode/src/codeReader/review/reviewMode.ts b/vscode/src/codeReader/review/reviewMode.ts index 59461ee..3f1be97 100644 --- a/vscode/src/codeReader/review/reviewMode.ts +++ b/vscode/src/codeReader/review/reviewMode.ts @@ -2,7 +2,7 @@ import type { Disposable, ExtensionContext, QuickPickItem, TreeView, WorkspaceFo 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 { FileTreeProvider, TreeNode } from '../explorer/fileTree' import type { TreeFilter, TreeFilters } from '../explorer/treeFilters' import type { FileCount } from './pickList' import * as path from 'node:path' @@ -59,7 +59,7 @@ export class ReviewMode implements TreeFilter, Disposable { constructor( private readonly context: ExtensionContext, private readonly viewId: string, - private readonly view: TreeView, + private readonly view: TreeView, private readonly files: FileTreeProvider, private readonly filters: TreeFilters, ) { @@ -251,7 +251,7 @@ export class ReviewMode implements TreeFilter, Disposable { 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(changes) }]) + this.files.showChanges([{ roots: [{ 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) diff --git a/vscode/src/codeReader/uncommitted/testFiles.test.ts b/vscode/src/codeReader/uncommitted/testFiles.test.ts new file mode 100644 index 0000000..080f215 --- /dev/null +++ b/vscode/src/codeReader/uncommitted/testFiles.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { isTestFile } from './testFiles' + +describe('测试文件', () => { + it('路径上有一层是测试目录的都算(不分大小写)', () => { + for (const path of [ + 'test/helpers.ts', + 'backend/tests/user/factories.py', + 'src/__tests__/App.tsx', + 'src/__mocks__/axios.ts', + 'src/components/__snapshots__/Button.test.tsx.snap', + 'pkg/parser/testdata/input.json', + 'Tests/AppTests/AppTests.swift', + 'jetbrains/src/test/kotlin/PluginTest.kt', + ]) + expect(isTestFile(path), path).toBe(true) + }) + + it('python:test_*.py、*_test.py、conftest.py', () => { + for (const path of ['backend/test_avatar.py', 'backend/avatar_test.py', 'backend/conftest.py', 'conftest.py']) + expect(isTestFile(path), path).toBe(true) + }) + + it('js / ts:*.test.*、*.spec.*', () => { + for (const path of ['src/a.test.ts', 'src/a.spec.tsx', 'src/a.test.js', 'src/a.spec.jsx', 'src/a.test.mjs', 'src/a.spec.cts', 'src/codeReader/review/changedFiles.test.ts']) + expect(isTestFile(path), path).toBe(true) + }) + + it('go:*_test.go', () => { + expect(isTestFile('pkg/server/handler_test.go')).toBe(true) + }) + + it('普通文件不算:名字里带 test 但不是约定的写法、目录名只是包含 test 的、别的扩展名的 spec', () => { + for (const path of [ + 'src/testing.ts', + 'src/latest.py', + 'src/contest.py', + 'src/test.ts', + 'src/attestation/verify.go', + 'testing/utils.ts', + 'src/tests.config.ts', + 'docs/openapi.spec.yaml', + 'docs/feature.spec.md', + 'src/handler_test.ts', + 'vitest.config.ts', + 'src/testFiles.ts', + ]) + expect(isTestFile(path), path).toBe(false) + }) + + it('测试目录只看目录,同名的文件不算', () => { + expect(isTestFile('scripts/test')).toBe(false) + expect(isTestFile('tests')).toBe(false) + }) +}) diff --git a/vscode/src/codeReader/uncommitted/testFiles.ts b/vscode/src/codeReader/uncommitted/testFiles.ts new file mode 100644 index 0000000..05ebbcd --- /dev/null +++ b/vscode/src/codeReader/uncommitted/testFiles.ts @@ -0,0 +1,27 @@ +/** + * 整个目录都算测试的目录名(不分大小写):测试目录(含 Swift 的 `Tests`)、Jest 的 mock 和快照、Go 的测试数据。 + */ +const TEST_DIRS = new Set(['test', 'tests', '__tests__', '__mocks__', '__snapshots__', 'testdata']) + +/** + * 按文件名认的测试文件:Python 的 `test_*.py`、`*_test.py`、`conftest.py`, + * JS / TS 的 `*.test.*`、`*.spec.*`(js、jsx、ts、tsx 和 m / c 开头的变体),Go 的 `*_test.go`。 + */ +const TEST_FILE_NAMES: readonly RegExp[] = [ + /^test_.+\.py$/, + /^.+_test\.py$/, + /^conftest\.py$/, + /^.+\.(?:test|spec)\.[cm]?[jt]sx?$/, + /^.+_test\.go$/, +] + +/** + * 是不是测试文件:路径上有一层目录是测试目录,或者文件名照测试文件的约定起。 + * 「未提交的更改」里隐藏测试文件时按它剔除。 + * @param path 相对仓库根,`/` 分隔 + */ +export function isTestFile(path: string): boolean { + const segments = path.split('/') + const name = segments.pop() ?? '' + return segments.some(dir => TEST_DIRS.has(dir.toLowerCase())) || TEST_FILE_NAMES.some(pattern => pattern.test(name)) +} diff --git a/vscode/src/codeReader/uncommitted/uncommittedFiles.test.ts b/vscode/src/codeReader/uncommitted/uncommittedFiles.test.ts index 060c4d3..c5fcb6c 100644 --- a/vscode/src/codeReader/uncommitted/uncommittedFiles.test.ts +++ b/vscode/src/codeReader/uncommitted/uncommittedFiles.test.ts @@ -2,7 +2,7 @@ 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' +import { changeOfGitStatus, uncommittedDescription, uncommittedFiles, uncommittedMessage, uncommittedRoots, withoutTestFiles, workspaceOrder } from './uncommittedFiles' const at = (path: string, status: GitStatus): { uri: { path: string }, status: GitStatus } => ({ uri: { path }, status }) @@ -37,57 +37,56 @@ describe('git 状态 → 改动', () => { }) }) -describe('一个仓库里没提交的文件', () => { - it('合并冲突、暂存、没暂存、未跟踪的合在一起,路径相对仓库根', () => { +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)], + indexChanges: [at('/repo/src/staged.ts', GIT_STATUS.INDEX_MODIFIED), at('/repo/src/added.ts', GIT_STATUS.INDEX_ADDED)], 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' }, - ]) + }))).toEqual({ + staged: [ + { path: 'src/staged.ts', change: 'M' }, + { path: 'src/added.ts', change: 'A' }, + ], + changes: [ + { path: 'conflict.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', () => { + it('暂存后又改了的两区都有,各带各的改动(和源代码管理视图一样)', () => { 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' }]) + }))).toEqual({ staged: [{ path: 'a.ts', change: 'A' }], changes: [{ path: 'a.ts', change: 'M' }] }) }) - 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', () => { + it('从暂存区删掉、文件还在盘上(git rm --cached)的:暂存区里是 D,更改里是 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' }]) + }))).toEqual({ staged: [{ path: 'a.ts', change: 'D' }], changes: [{ path: 'a.ts', change: 'A' }] }) + }) + + it('同一区里同一个文件只留一项,后面的组盖掉前面的', () => { + expect(uncommittedFiles('/repo', groups({ + mergeChanges: [at('/repo/a.ts', GIT_STATUS.BOTH_MODIFIED)], + workingTreeChanges: [at('/repo/a.ts', GIT_STATUS.DELETED)], + })).changes).toEqual([{ path: 'a.ts', change: 'D' }]) }) it('仓库根本身、仓库外面的路径不要', () => { expect(uncommittedFiles('/repo', groups({ + indexChanges: [at('/other/c.ts', GIT_STATUS.INDEX_MODIFIED)], workingTreeChanges: [at('/repo', GIT_STATUS.MODIFIED), at('/other/a.ts', GIT_STATUS.MODIFIED), at('/repository/b.ts', GIT_STATUS.MODIFIED)], - }))).toEqual([]) + }))).toEqual({ staged: [], changes: [] }) }) - it('没有改动时是空的', () => { - expect(uncommittedFiles('/repo', groups({}))).toEqual([]) + it('没有改动时两区都是空的', () => { + expect(uncommittedFiles('/repo', groups({}))).toEqual({ staged: [], changes: [] }) }) }) @@ -101,7 +100,13 @@ describe('工作区里有改动的仓库', () => { 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' }]) + expect(root.staged).toEqual([]) + expect(root.changes).toEqual([{ path: 'a.ts', change: 'M' }]) + }) + + it('只有暂存的更改的仓库也摆', () => { + const roots = uncommittedRoots([repo('/work/app', { indexChanges: [at('/work/app/a.ts', GIT_STATUS.INDEX_MODIFIED)] })], [folder('/work/app', 'app')]) + expect(roots.map(root => [root.staged.length, root.changes.length])).toEqual([[1, 0]]) }) it('嵌套在工作区文件夹里的、包着工作区文件夹的仓库也算,名字用仓库根目录的名字', () => { @@ -141,3 +146,45 @@ describe('算不算工作区里的仓库', () => { expect(workspaceOrder('/worktrees/review-1', folders)).toBe(-1) }) }) + +describe('隐藏测试文件', () => { + const file = (path: string, change: 'M' | 'A' | 'D' = 'M'): { path: string, change: 'M' | 'A' | 'D' } => ({ path, change }) + + it('两区都剔除测试文件,数出藏了几个(两区都有的算一个)', () => { + const { roots, hidden } = withoutTestFiles([{ + name: 'app', + staged: [file('src/a.ts'), file('src/a.test.ts', 'A')], + changes: [file('src/a.test.ts'), file('tests/test_api.py'), file('src/b.ts')], + }]) + expect(hidden).toBe(2) + expect(roots).toEqual([{ name: 'app', staged: [file('src/a.ts')], changes: [file('src/b.ts')] }]) + }) + + it('几个仓库的加在一起;剔完两区都空的仓库不摆', () => { + const { roots, hidden } = withoutTestFiles([ + { name: 'app', staged: [], changes: [file('handler_test.go')] }, + { name: 'lib', staged: [file('conftest.py')], changes: [file('lib.py')] }, + ]) + expect(hidden).toBe(2) + expect(roots.map(root => root.name)).toEqual(['lib']) + }) + + it('没有测试文件时原样、藏了 0 个', () => { + const only = { name: 'app', staged: [file('a.ts')], changes: [] } + expect(withoutTestFiles([only])).toEqual({ roots: [only], hidden: 0 }) + }) +}) + +describe('标题后面的字、标题下面的一行', () => { + it('藏了测试文件时标题后面注明藏了几个', () => { + expect(uncommittedDescription(0)).toBe('未提交的更改') + expect(uncommittedDescription(3)).toBe('未提交的更改 · 已隐藏 3 个测试文件') + }) + + it('树上有文件时没有提示;只剩藏起来的测试文件、什么都没有、git 还在找仓库时各说一句', () => { + expect(uncommittedMessage({ hasFiles: true, hiddenTests: 2, loading: false })).toBeUndefined() + expect(uncommittedMessage({ hasFiles: false, hiddenTests: 2, loading: false })).toBe('除了隐藏的测试文件,没有未提交的更改') + expect(uncommittedMessage({ hasFiles: false, hiddenTests: 0, loading: false })).toBe('没有未提交的更改') + expect(uncommittedMessage({ hasFiles: false, hiddenTests: 0, loading: true })).toBe('正在读取 git 状态…') + }) +}) diff --git a/vscode/src/codeReader/uncommitted/uncommittedFiles.ts b/vscode/src/codeReader/uncommitted/uncommittedFiles.ts index 7093c87..fe841fc 100644 --- a/vscode/src/codeReader/uncommitted/uncommittedFiles.ts +++ b/vscode/src/codeReader/uncommitted/uncommittedFiles.ts @@ -3,6 +3,7 @@ import type { ChangedFile, FileChange } from '../explorer/changedTree' import { posix } from 'node:path' import { GIT_STATUS } from '../../git/gitApi' import { toPathKey } from '../pathMap' +import { isTestFile } from './testFiles' /** 一组改动里的一项:文件在哪(Uri.path)、git 状态。 */ interface StatusEntry { @@ -42,26 +43,38 @@ export function changeOfGitStatus(status: GitStatus): FileChange { return ADDED.has(status) ? 'A' : DELETED.has(status) ? 'D' : 'M' } -/** - * 一个仓库里没提交的文件:合并冲突的、暂存的、没暂存的、未跟踪的合在一起,路径相对仓库根、`/` 分隔。 - * 同一个文件在几组里都有(暂存了又改了)只留一项:照上面的次序越往后越接近盘上现在的样子,后面的盖掉前面的; - * 只是「改了」不盖掉前面的新增(新加的文件暂存后又改了,还是新加的)。 - */ -export function uncommittedFiles(rootPath: string, groups: ChangeGroups): ChangedFile[] { +/** 源代码管理视图两个区里的文件,路径相对仓库根、`/` 分隔。 */ +export interface UncommittedFiles { + /** 暂存的更改:暂存区里的 */ + staged: ChangedFile[] + /** 更改:合并冲突的、没暂存的、未跟踪的 */ + changes: ChangedFile[] +} + +/** 几组改动合成一个区:同一个文件只留一项(后面的组盖掉前面的);仓库根本身、仓库外面的路径不要。 */ +function collect(rootPath: string, groups: readonly (readonly StatusEntry[])[]): ChangedFile[] { const files = new Map() - for (const group of [groups.mergeChanges, groups.indexChanges, groups.workingTreeChanges, groups.untrackedChanges]) { + for (const group of groups) { 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) + if (key !== undefined) + files.set(key, changeOfGitStatus(status)) } } return [...files].map(([path, change]) => ({ path, change })) } +/** + * 一个仓库里没提交的文件,照源代码管理视图分两区:暂存的更改(暂存区)、更改(合并冲突、没暂存、未跟踪)。 + * 暂存后又改了的文件两区都有。 + */ +export function uncommittedFiles(rootPath: string, groups: ChangeGroups): UncommittedFiles { + return { + staged: collect(rootPath, [groups.indexChanges]), + changes: collect(rootPath, [groups.mergeChanges, groups.workingTreeChanges, groups.untrackedChanges]), + } +} + function sameOrUnder(child: string, parent: string): boolean { return child === parent || toPathKey(parent, child) !== undefined } @@ -78,14 +91,13 @@ interface RepositoryLike { readonly state: ChangeGroups } -/** 有没提交的文件的一个仓库,侧栏上一个根节点。 */ -export interface UncommittedRoot { +/** 有没提交的文件的一个仓库,两个区里各是侧栏上一个根节点。 */ +export interface UncommittedRoot extends UncommittedFiles { repository: R /** 仓库根就是这个工作区文件夹时是它 */ folder: F | undefined /** 根节点上的名字:仓库根就是工作区文件夹时用文件夹的名字,别的用仓库根目录的名字 */ name: string - files: ChangedFile[] } /** @@ -108,7 +120,45 @@ export function uncommittedRoots .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) } + return { repository, folder, name: folder?.name ?? posix.basename(rootPath), ...uncommittedFiles(rootPath, repository.state) } }) - .filter(root => root.files.length > 0) + .filter(hasFiles) +} + +function hasFiles(files: UncommittedFiles): boolean { + return files.staged.length > 0 || files.changes.length > 0 +} + +/** + * 剔除测试文件(见 isTestFile):两个区都剔,剔完两区都空的仓库不摆。 + * @returns 剩下的仓库,和藏了几个测试文件(暂存后又改了、两区都有的算一个) + */ +export function withoutTestFiles(roots: readonly T[]): { roots: T[], hidden: number } { + let hidden = 0 + const kept = roots.map((root) => { + const tests = new Set([...root.staged, ...root.changes].map(file => file.path).filter(isTestFile)) + hidden += tests.size + const keep = (file: ChangedFile): boolean => !tests.has(file.path) + return { ...root, staged: root.staged.filter(keep), changes: root.changes.filter(keep) } + }) + return { roots: kept.filter(hasFiles), hidden } +} + +/** 「更改」区里文件行的 contextValue 带的标记(见 entryContext):这些行上挂「暂存」按钮。 */ +export const UNSTAGED_MARKER = 'unstaged' + +/** 开着时视图标题后面淡色的字(TreeView.description):藏了测试文件时注明藏了几个。 */ +export function uncommittedDescription(hiddenTests: number): string { + return hiddenTests > 0 ? `未提交的更改 · 已隐藏 ${hiddenTests} 个测试文件` : '未提交的更改' +} + +/** + * 标题下面的一行(TreeView.message):树上有文件时没有;只剩藏起来的测试文件、什么都没有、git 还在找仓库时各说一句。 + */ +export function uncommittedMessage(state: { hasFiles: boolean, hiddenTests: number, loading: boolean }): string | undefined { + if (state.hasFiles) + return undefined + if (state.hiddenTests > 0) + return '除了隐藏的测试文件,没有未提交的更改' + return state.loading ? '正在读取 git 状态…' : '没有未提交的更改' } diff --git a/vscode/src/codeReader/uncommitted/uncommittedMode.ts b/vscode/src/codeReader/uncommitted/uncommittedMode.ts index 3b1c3e6..0752074 100644 --- a/vscode/src/codeReader/uncommitted/uncommittedMode.ts +++ b/vscode/src/codeReader/uncommitted/uncommittedMode.ts @@ -1,17 +1,25 @@ -import type { ExtensionContext, TreeView } from 'vscode' +import type { ExtensionContext, TreeView, WorkspaceFolder } from 'vscode' import type { GitApi, GitRepository } from '../../git/gitApi' -import type { FileNode, FileTreeProvider } from '../explorer/fileTree' +import type { ChangedFile } from '../explorer/changedTree' +import type { ChangedSection, FileNode, FileTreeProvider, TreeNode } from '../explorer/fileTree' import type { TreeFilter, TreeFilters } from '../explorer/treeFilters' -import { Disposable, window, workspace } from 'vscode' +import type { UncommittedRoot } from './uncommittedFiles' +import * as path from 'node:path' +import { commands, 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' +import { uncommittedDescription, uncommittedMessage, uncommittedRoots, UNSTAGED_MARKER, withoutTestFiles, workspaceOrder } from './uncommittedFiles' /** workspaceState:开着「未提交的更改」,重开窗口接着显示。 */ const UNCOMMITTED_KEY = 'superpowers.codeReader.uncommitted' -/** 开着时视图标题后面淡色的字(TreeView.description)。 */ -const UNCOMMITTED_DESCRIPTION = '未提交的更改' +/** workspaceState:隐藏测试文件。 */ +const HIDE_TESTS_KEY = 'superpowers.codeReader.hideTests' +/** 上下文键:隐藏着测试文件。标题栏的「隐藏测试文件 / 显示测试文件」按它换着显示。 */ +const TESTS_HIDDEN_CONTEXT = 'superpowers.codeReader.testsHidden' +/** 两个分组的名字,照 VS Code 源代码管理视图的中文界面。 */ +const STAGED_LABEL = '暂存的更改' +const CHANGES_LABEL = '更改' /** git 状态连着变(保存、暂存、切分支)时合成一次重摆。 */ const REBUILD_DELAY_MS = 300 @@ -23,9 +31,10 @@ function errorText(err: unknown): string { } /** - * 侧栏只看未提交的更改:树里只剩工作区各 git 仓库里没提交的文件(暂存的、没暂存的、未跟踪的、合并冲突的),每个有改动的仓库一个根。 - * 文件就是工作区里的文件,颜色和 M / A / D 徽标由 VS Code 自带的 git 装饰加上。开着时盯着 git 状态自动重摆;「显示全部」回到工作区文件树。 - * 开着没开按工作区记住;和审查工单互斥,见 TreeFilters。 + * 侧栏只看未提交的更改:照源代码管理视图分「暂存的更改」「更改」两组(空的不摆),每组下面每个有改动的仓库一个根。 + * 文件就是工作区里的文件,颜色和 M / A / D 徽标由 VS Code 自带的 git 装饰加上;「更改」组里的文件行上有「暂存」按钮。 + * 可以隐藏测试文件。开着时盯着 git 状态自动重摆;「显示全部」回到工作区文件树。 + * 开着没开、隐不隐藏测试文件都按工作区记住;和审查工单互斥,见 TreeFilters。 */ export class UncommittedMode implements TreeFilter, Disposable { private readonly disposables: Disposable[] @@ -38,15 +47,18 @@ export class UncommittedMode implements TreeFilter, Disposable { private shown: string | undefined /** 每进一次、退一次都加一:等 git 扩展回来时已经退出或又进了一次的,结果丢掉。 */ private generation = 0 + private hideTests: boolean /** viewId 是侧栏视图的 id:刷新时在这个视图上转进度。 */ constructor( private readonly context: ExtensionContext, private readonly viewId: string, - private readonly view: TreeView, + private readonly view: TreeView, private readonly files: FileTreeProvider, private readonly filters: TreeFilters, ) { + this.hideTests = context.workspaceState.get(HIDE_TESTS_KEY) ?? false + void commands.executeCommand('setContext', TESTS_HIDDEN_CONTEXT, this.hideTests) filters.add(this) this.disposables = [filters.whenShown(() => void this.restore())] } @@ -80,6 +92,37 @@ export class UncommittedMode implements TreeFilter, Disposable { this.rebuild() } + /** 「隐藏测试文件 / 显示测试文件」:两组都剔除 / 放回测试文件,按工作区记住。 */ + async setTestsHidden(hidden: boolean): Promise { + this.hideTests = hidden + this.rebuild() + await Promise.all([ + commands.executeCommand('setContext', TESTS_HIDDEN_CONTEXT, hidden), + this.context.workspaceState.update(HIDE_TESTS_KEY, hidden || undefined), + ]) + } + + /** 「更改」组里文件行上的「暂存」:把这个文件放进暂存区(新文件、删掉的、冲突的都行),树跟着 git 状态变。 */ + async stage(node: FileNode | undefined): Promise { + const api = this.api + if (!node || !api) + return // 只挂在开着时「更改」组的文件行上 + const name = path.basename(node.uri.fsPath) + const repository = api.getRepository(node.uri) + if (!repository) { + logger.add({ level: 'warn', source: 'codeReader', message: `未提交的更改:${name} 不在打开的 git 仓库里,没法暂存`, details: node.uri.fsPath }) + void window.showErrorMessage(`没法暂存 ${name}:找不到它所在的 git 仓库`) + return + } + try { + await repository.add([node.uri.fsPath]) + } + catch (err) { + logger.add({ level: 'error', source: 'codeReader', message: `未提交的更改:暂存 ${name} 失败`, details: `${node.uri.fsPath}\n${errorText(err)}` }) + void window.showErrorMessage(`没能暂存 ${name}:${errorText(err)}`) + } + } + dispose(): void { this.stopWatching() for (const disposable of this.disposables) @@ -111,7 +154,6 @@ export class UncommittedMode implements TreeFilter, Disposable { 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)]) } @@ -168,24 +210,42 @@ export class UncommittedMode implements TreeFilter, Disposable { this.shown = undefined } - /** 按 git 现在的状态重摆树;和树上摆着的一样就不动。没有改动时标题下面写一行说明。 */ + /** 按 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])) + const all = uncommittedRoots(api.repositories, folders) + const { roots, hidden } = this.hideTests ? withoutTestFiles(all) : { roots: all, hidden: 0 } + this.view.description = uncommittedDescription(hidden) + this.view.message = uncommittedMessage({ hasFiles: roots.length > 0, hiddenTests: hidden, loading: api.state !== 'initialized' }) + const shown = JSON.stringify(roots.map(root => [root.repository.rootUri.toString(), root.name, root.staged, root.changes])) 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), - }))) + // 仓库根不是工作区文件夹时(嵌套的仓库、包着工作区文件夹的仓库)拿仓库根当根,名字用仓库根目录的名字 + const placed = roots.map(root => ({ root, folder: root.folder ?? { uri: root.repository.rootUri, name: root.name, index: folders.length } })) + this.files.showChanges([ + section(STAGED_LABEL, placed, root => root.staged), + section(CHANGES_LABEL, placed, root => root.changes, UNSTAGED_MARKER), + ].filter(group => group.roots.length > 0)) + } +} + +/** 一个分组:有这个区的文件的仓库各一个根,分组行后面写这个区一共几个文件。 */ +function section( + label: string, + placed: readonly { root: UncommittedRoot, folder: WorkspaceFolder }[], + filesOf: (root: UncommittedRoot) => ChangedFile[], + fileMarker?: string, +): ChangedSection { + const withFiles = placed.filter(({ root }) => filesOf(root).length > 0) + const count = withFiles.reduce((sum, { root }) => sum + filesOf(root).length, 0) + return { + label, + description: String(count), + ...(fileMarker ? { fileMarker } : {}), + roots: withFiles.map(({ root, folder }) => ({ folder, tree: buildChangedTree(filesOf(root)) })), } } diff --git a/vscode/src/git/gitApi.ts b/vscode/src/git/gitApi.ts index 2707558..eb0534d 100644 --- a/vscode/src/git/gitApi.ts +++ b/vscode/src/git/gitApi.ts @@ -53,6 +53,8 @@ export interface GitRepository { readonly state: GitRepositoryState /** 重读这个仓库的 git 状态 */ status: () => Promise + /** 把这些文件(绝对路径)放进暂存区,同 git add:新文件、删掉的、冲突的都行 */ + add: (paths: string[]) => Promise } /** initialized 之前 git 扩展还在找工作区里的仓库,repositories 可能不全。 */ @@ -64,6 +66,8 @@ export interface GitApi { readonly repositories: readonly GitRepository[] readonly onDidOpenRepository: Event readonly onDidCloseRepository: Event + /** 文件所在的仓库(按路径找,删掉的文件也行);不在任何打开的仓库里时 null */ + getRepository: (uri: Uri) => GitRepository | null } /** vscode.git 扩展激活后导出的东西。 */