Files
superwork/vscode/src/codeReader/uncommitted/uncommittedMode.ts
T

279 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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())]
}
/** 「未提交的更改」:别的筛选退出,树里换成未提交的文件,之后跟着 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()
}
/** 「隐藏测试文件 / 显示测试文件」:两组都剔除 / 放回测试文件,按工作区记住。 */
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)}`)
}
}
/** 开着时,工作区仓库里的文件对比上一次提交(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<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.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 扩展找完仓库、工作区文件夹变了,都合成一次重摆;
* 打开着的预览也跟着重新比(提交后 HEAD 动了,对比的上一次提交就换了)。
*/
private watch(api: GitApi): Disposable {
const repositories = new Map<GitRepository, Disposable>()
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<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)) })),
}
}