import type { ExtensionContext, TreeView, Uri, WorkspaceFolder } from 'vscode' import type { GitApi, GitRepository } from '../../git/gitApi' import type { DiffBase } from '../diff/diffBase' import type { ChangedFile } from '../explorer/changedTree' import type { ChangedSection, FileNode, FileTreeProvider, TreeNode } from '../explorer/fileTree' import type { TreeFilter, TreeFilters } from '../explorer/treeFilters' import type { UncommittedRoot } from './uncommittedFiles' import * as path from 'node:path' import { commands, Disposable, window, workspace } from 'vscode' import { fileAtRef } from '../../git/fileAt' import { loadGitApi } from '../../git/gitExtension' import { logger } from '../../logging/logger' import { buildChangedTree } from '../explorer/changedTree' import { toPathKey } from '../pathMap' import { HEAD_BASE_LABEL, headMissingText, isUntracked, uncommittedDescription, uncommittedMessage, uncommittedRoots, UNSTAGED_MARKER, withoutTestFiles, workspaceOrder } from './uncommittedFiles' /** workspaceState:开着「未提交的更改」,重开窗口接着显示。 */ const UNCOMMITTED_KEY = 'superpowers.codeReader.uncommitted' /** 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 /** 怎么进的:enter 点按钮(出错时说一声)、restore 重开窗口接着显示(出错只记日志)。 */ type How = 'enter' | 'restore' function errorText(err: unknown): string { return err instanceof Error ? err.message : String(err) } /** * 侧栏只看未提交的更改:照源代码管理视图分「暂存的更改」「更改」两组(空的不摆),每组下面每个有改动的仓库一个根。 * 文件就是工作区里的文件,颜色和 M / A / D 徽标由 VS Code 自带的 git 装饰加上;「更改」组里的文件行上有「暂存」按钮。 * 可以隐藏测试文件。开着时盯着 git 状态自动重摆,打开的预览对比上一次提交(diffBase);「显示全部」回到工作区文件树。 * 开着没开、隐不隐藏测试文件都按工作区记住;和审查工单互斥,见 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 private hideTests: boolean /** viewId 是侧栏视图的 id:刷新时在这个视图上转进度。 */ constructor( private readonly context: ExtensionContext, private readonly viewId: string, 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())] } /** 「未提交的更改」:别的筛选退出,树里换成未提交的文件,之后跟着 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() } /** 「隐藏测试文件 / 显示测试文件」:两组都剔除 / 放回测试文件,按工作区记住。 */ 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)}`) } } /** 开着时,工作区仓库里的文件对比上一次提交(HEAD);不在工作区的仓库里(审查的工作副本、别处的文件)的不比。 */ diffBase(uri: Uri): DiffBase | undefined { const repository = this.api?.getRepository(uri) if (!repository || workspaceOrder(repository.rootUri.path, workspace.workspaceFolders ?? []) < 0) return undefined const relative = toPathKey(repository.rootUri.path, uri.path) if (relative === undefined) return undefined const head = repository.state.HEAD?.commit const root = repository.rootUri.fsPath return { label: HEAD_BASE_LABEL, key: `${head ?? ''}:${relative}`, read: () => head ? fileAtRef(root, head, relative) : Promise.resolve(null), missing: headMissingText({ hasCommit: head !== undefined, untracked: isUntracked(repository.state, uri.path) }), } } 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.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 扩展找完仓库、工作区文件夹变了,都合成一次重摆; * 打开着的预览也跟着重新比(提交后 HEAD 动了,对比的上一次提交就换了)。 */ private watch(api: GitApi): Disposable { const repositories = new Map() const schedule = (): void => { clearTimeout(this.timer) this.timer = setTimeout(() => { this.rebuild() this.filters.baseChanged(this) }, 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 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 // 仓库根不是工作区文件夹时(嵌套的仓库、包着工作区文件夹的仓库)拿仓库根当根,名字用仓库根目录的名字 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)) })), } }