✨ feat(vscode): 「未提交的更改」分暂存区两组、单文件暂存、隐藏测试文件

- 照源代码管理视图分「暂存的更改」「更改」两组(空的不摆,分组行写文件数),部分暂存的文件两组都有
- 「更改」组的文件行上加「暂存」按钮,走 git 扩展的 repository.add
- 标题栏「隐藏测试文件 / 显示测试文件」开关,按工作区记住,藏了几个写在标题后面;测试文件的判断集中在 isTestFile
- 以上只在「未提交的更改」里有:普通文件树、审查模式的行和菜单不变
This commit is contained in:
cruldra committed 2026-09-30 06:30:54 +08:00
1 parent a3b04a443b
commit 87d4360704
14 files changed
+528 -129

No files matched your search

@@ -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<FileNode>,
private readonly view: TreeView<TreeNode>,
private readonly files: FileTreeProvider,
private readonly filters: TreeFilters,
) {
this.hideTests = context.workspaceState.get<boolean>(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<void> {
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<void> {
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<GitRepository, WorkspaceFolder>, folder: WorkspaceFolder }[],
filesOf: (root: UncommittedRoot<GitRepository, WorkspaceFolder>) => 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)) })),
}
}