✨ feat(vscode): 代码阅读侧栏加「未提交的更改」筛选

- 标题栏新按钮「未提交的更改」:树里只剩工作区各 git 仓库里没提交的文件(暂存、没暂存、未跟踪、合并冲突),每个有改动的仓库一个根,跟着 git 状态自动重摆;上色和徽标交给 VS Code 自带的 git 装饰
- 两种筛选互斥,由 TreeFilters 统一管,上下文键合并成 superpowers.codeReader.filter(review / uncommitted)
- 改动文件树改成多根,buildChangedTree 挪到 explorer/changedTree 并改吃 {path, change}
- 内置 git 扩展 API 的类型和加载抽到 src/git/gitApi、gitExtension,提交按钮的监听也改用它
- 文件注释按文件所在的工作区文件夹记,不看树从哪个根摆下来
This commit is contained in:
cruldra committed 2026-09-29 23:42:10 +08:00
1 parent 61c4728dce
commit 7f460d1257
18 files changed
+847 -240

No files matched your search

@@ -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<FileNode>,
private readonly files: FileTreeProvider,
private readonly filters: TreeFilters,
) {
filters.add(this)
this.disposables = [filters.whenShown(() => void this.restore())]
}
/** 「未提交的更改」:别的筛选退出,树里换成未提交的文件,之后跟着 git 状态变。 */
async enter(): Promise<void> {
await this.open('enter')
}
/** 「显示全部」或换成别的筛选:不再盯 git、不再记着,正在进的作废;开着的回到工作区文件树。 */
async exit(): Promise<void> {
this.generation++
this.stopWatching()
await Promise.all([
this.filters.leave(this),
this.context.workspaceState.update(UNCOMMITTED_KEY, undefined),
])
}
/** 开着时的「刷新」:让 git 扩展重读工作区各仓库的状态,再整棵重摆。 */
async reload(): Promise<void> {
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<void> {
if (this.context.workspaceState.get<boolean>(UNCOMMITTED_KEY))
await this.open('restore')
}
private async open(how: How): Promise<void> {
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<void> {
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<GitRepository, Disposable>()
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),
})))
}
}