279 lines
12 KiB
TypeScript
279 lines
12 KiB
TypeScript
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)) })),
|
||
}
|
||
}
|