import type { Disposable, Event, ExtensionContext, QuickPickItem, TreeView, WorkspaceFolder } from 'vscode' import type { ForgeContext } from '../../forge/context' import type { ForgePrFile } from '../../forge/types' import type { Issue } from '../../issues/types' import type { DiffBase, DiffBaseSource } from '../diff/diffBase' import type { FileTreeProvider, TreeNode } from '../explorer/fileTree' import type { FileCount } from './pickList' import * as path from 'node:path' import { commands, EventEmitter, QuickPickItemKind, Uri, window, workspace } from 'vscode' import { requireForge } from '../../forge/context' import { fileAtRef } from '../../git/fileAt' import { logger } from '../../logging/logger' import { WORKSPACE_TREE } from '../explorer/fileTree' import { TestsToggle } from '../explorer/testsToggle' import { whenShown } from '../explorer/viewShown' import { toPathKey } from '../pathMap' import { ChangeDecorations } from './changeDecorations' import { changeOf, reviewBaseFile, reviewTree } from './changedFiles' import { countingFiles, pickEntries, pickItemText, pickRows, withFileCount } from './pickList' import { columnLabel, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewBaseLabel, reviewBaseMissing, reviewDescription, reviewMessage } from './reviewRules' import { loadIssue, loadPullRequest, reviewCandidateIssues } from './reviewSource' import { localWorktree, prepareWorktree } from './reviewWorktree' /** workspaceState:正在审查的工单号,重开窗口接着审查。 */ const REVIEW_ISSUE_KEY = 'superpowers.codeReader.reviewIssue' /** 上下文键:主栏正审查着工单。标题栏的「显示全部」、隐藏 / 显示测试文件按它显示,主栏行上的注释、收藏按钮按它藏起来(那些行是工作副本里的)。 */ const REVIEWING_CONTEXT = 'superpowers.codeReader.reviewing' /** 审查时隐藏测试文件的开关:记在 workspaceState 的键、标题栏按它换按钮的上下文键 */ const REVIEW_TESTS = { state: 'superpowers.codeReader.reviewHideTests', context: 'superpowers.codeReader.reviewTestsHidden' } /** * 怎么进的审查,决定不能审查、出错时怎么收场: * pick 选工单(说一声,什么都不变)、reload 刷新(说一声,回到全部文件)、restore 重开窗口接着审查(悄悄回到全部文件)。 */ type How = 'pick' | 'reload' | 'restore' /** * 进审查要的东西都备好了:工单、它的 PR、PR 改过的文件、装着 PR 最新代码的工作副本, * 和预览对比的基准:PR 的目标分支名、目标分支与 PR 头的合并基(取不到时退回目标分支的提交,同看板「改动」tab)。 */ interface Loaded { issue: Issue pr: number files: ForgePrFile[] worktree: string base: { ref: string, commit: string } } /** 弹框里的一行:工单的一项带着工单,分组标题(列名)没有。 */ interface PickerItem extends QuickPickItem { issue?: Issue } function errorText(err: unknown): string { return err instanceof Error ? err.message : String(err) } /** * 主栏按审查工单筛文件:「选工单」列出看板「审查」「进行中」列里能审查的工单,选中后主栏只剩这张工单的 PR 改过的文件(按改动着色), * 点开的是本机这张工单的工作副本里 PR 最新的代码,工作副本里的文件预览对比 PR 的目标分支(diffBase);「显示全部」回到工作区文件树。 * 选的工单按工作区记住,主栏第一次露出来时接着审查;隐藏测试文件的开关是审查自己的,也按工作区记住。 */ export class ReviewMode implements DiffBaseSource, Disposable { private readonly decorations = new ChangeDecorations() private readonly baseChange = new EventEmitter() /** 审查的对比基准变了(进了审查、重读了 PR、换了工单、退出了):打开着的预览重新问 */ readonly onDidChange: Event = this.baseChange.event private readonly tests: TestsToggle private readonly disposables: Disposable[] /** 正在审查的(主栏摆着它):工单、PR、改过的文件、工作副本、对比基准 */ private loaded: Loaded | undefined /** 每开始一次进审查、退出审查都加一:慢的那次回来时已经不是最新的,结果丢掉。 */ private generation = 0 /** viewId、view、files 是主栏的视图 id、视图、文件树:进审查时在主栏上转进度、换上 PR 改过的文件。 */ constructor( private readonly context: ExtensionContext, private readonly viewId: string, private readonly view: TreeView, private readonly files: FileTreeProvider, ) { this.tests = new TestsToggle(context.workspaceState, REVIEW_TESTS, () => this.place()) this.disposables = [ this.decorations, this.baseChange, window.registerFileDecorationProvider(this.decorations), whenShown(view, () => void this.restore()), ] } /** 正审查着工单:主栏只摆着 PR 改过的文件 */ get reviewing(): boolean { return this.loaded !== undefined } /** * 「选工单」:顶部弹出快速选择,列出看板上现在摆着的「审查」「进行中」列里能审查的工单(按列分组),马上就有; * 各 PR 改了几个文件一起去读,读到一个换一项,都读完才停止转圈。 */ async pick(): Promise { const picker = window.createQuickPick() picker.title = `选工单 · 看板${REVIEW_COLUMNS_TEXT}列` picker.placeholder = '输入工单号或标题筛选' picker.busy = true let open = true picker.onDidHide(() => { open = false picker.dispose() }) picker.onDidAccept(() => { const chosen = picker.selectedItems[0]?.issue if (!chosen) return picker.hide() void this.review(chosen.number, 'pick') }) picker.show() let repo: ForgeContext try { repo = await requireForge(this.context) } catch (err) { logger.add({ level: 'error', source: 'codeReader', message: '选工单:读不到当前工作区的代码托管仓库', details: errorText(err) }) picker.hide() void window.showErrorMessage(`没法选工单:${errorText(err)}`) return } const board = reviewCandidateIssues(this.context) if (!open) return if (!board) { logger.add({ level: 'info', source: 'codeReader', message: '选工单:看板还没加载过,没有工单可列' }) picker.placeholder = '看板还没加载过,先打开一次看板' picker.busy = false return } logger.add({ level: 'info', source: 'codeReader', message: `选工单:看板${REVIEW_COLUMNS_TEXT}列能审查的 ${board.issues.length} 张(${board.from === 'loaded' ? '看板这次加载的' : '上次存下的看板快照'})`, details: board.issues.map(issue => `#${issue.number} ${columnLabel(issue.column)} PR ${issue.pr ?? '无'}`).join('\n'), }) if (board.issues.length === 0) picker.placeholder = `看板${REVIEW_COLUMNS_TEXT}列里没有能审查的工单` let entries = pickEntries(board.issues, issue => localWorktree(this.context, repo.workspaceRoot, issue) !== undefined) // 换 items 时 VS Code 会把光标挪回第一项:按工单号挪回用户停着的那一项 const render = (): void => { if (!open) return const active = picker.activeItems[0]?.issue?.number const items: PickerItem[] = pickRows(entries).map(row => 'separator' in row ? { label: row.separator, kind: QuickPickItemKind.Separator } : { issue: row.entry.issue, ...pickItemText(row.entry) }) picker.items = items const stay = items.find(item => item.issue !== undefined && item.issue.number === active) if (stay) picker.activeItems = [stay] picker.busy = countingFiles(entries) } render() await Promise.all(entries.flatMap(({ issue, pr }) => pr ? [this.countFiles(repo, issue.number, pr.number).then((files) => { entries = withFileCount(entries, issue.number, files) render() })] : [])) } /** 「显示全部」:不再审查、不再记着这张工单,正在进的审查作废;正审查着的主栏回到工作区文件树,清掉标题后面的字和标题下面的一行。 */ async exit(): Promise { this.generation++ const shown = this.loaded !== undefined this.loaded = undefined this.decorations.show([]) if (shown) { this.baseChange.fire() this.files.show(WORKSPACE_TREE) this.view.description = undefined this.view.message = undefined } await Promise.all([ commands.executeCommand('setContext', REVIEWING_CONTEXT, false), this.context.workspaceState.update(REVIEW_ISSUE_KEY, undefined), ]) } /** 审查时的「刷新」:重读工单和 PR 改过的文件,工作副本也更新到 PR 最新的提交。 */ async reload(): Promise { if (this.loaded) await this.review(this.loaded.issue.number, 'reload') } /** * 审查时,工作副本里的文件对比 PR 目标分支与 PR 头的合并基上的版本(改名的取改名前的路径);工作副本外面的文件不比。 * 合并基是 PR 头的祖先,拉 PR 的提交时已经在本机了,直接在工作副本里取。 */ diffBase(uri: Uri): DiffBase | undefined { const loaded = this.loaded const relative = loaded && toPathKey(Uri.file(loaded.worktree).path, uri.path) if (!loaded || relative === undefined || !loaded.base.commit) return undefined const { worktree, base } = loaded const file = reviewBaseFile(loaded.files, relative) return { label: reviewBaseLabel(base.ref), key: `${base.commit}:${file.path}`, read: () => file.added ? Promise.resolve(null) : fileAtRef(worktree, base.commit, file.path), missing: reviewBaseMissing(base.ref), } } /** 审查时标题栏的「隐藏 / 显示测试文件」:正审查着的文件按新开关重摆。 */ setTestsHidden(hidden: boolean): Promise { return this.tests.set(hidden) } dispose(): void { for (const disposable of this.disposables) disposable.dispose() } private async restore(): Promise { const issueNumber = this.context.workspaceState.get(REVIEW_ISSUE_KEY) if (issueNumber !== undefined) await this.review(issueNumber, 'restore') } /** 弹框里的一项:有 PR 的工单去读 PR 改了几个文件(各项一起读),读不到就写读不到。 */ /** 读一张工单的 PR 改了几个文件;没读到记进日志,这一项写读不到。 */ private async countFiles(repo: ForgeContext, issueNumber: number, pr: number): Promise { try { return { count: (await repo.provider.listPullRequestFiles(pr)).length } } catch (err) { logger.add({ level: 'warn', source: 'codeReader', message: `选工单:读不到 #${issueNumber} 的 PR #${pr} 改了几个文件`, details: errorText(err) }) return 'failed' } } /** * 进审查:重读这张工单(弹框里的是看板上摆着的,可能旧了)和它的 PR、备工作副本,都好了才换树; * 这期间视图上转进度,标题下面那行写正在做哪一步。每一步、不进的原因、出错都记进日志。 */ private async review(issueNumber: number, how: How): Promise { const generation = ++this.generation const latest = (): boolean => generation === this.generation const before = this.view.message logger.add({ level: 'info', source: 'codeReader', message: `审查 #${issueNumber}:开始(${how})` }) let result: Loaded | { blocked: string } try { result = await window.withProgress({ location: { viewId: this.viewId } }, () => this.load(issueNumber, (text) => { logger.add({ level: 'info', source: 'codeReader', message: `审查 #${issueNumber}:${text}` }) if (latest()) this.view.message = text })) } catch (err) { logger.add({ level: 'error', source: 'codeReader', message: `审查 #${issueNumber} 失败`, details: errorText(err) }) if (!latest()) return this.view.message = before void window.showErrorMessage(`没能审查 #${issueNumber}:${errorText(err)}`) if (how === 'restore') await this.exit() return } if (!latest()) return if ('blocked' in result) { logger.add({ level: 'info', source: 'codeReader', message: `审查 #${issueNumber} 不进:${result.blocked}` }) this.view.message = before if (how !== 'restore') void window.showInformationMessage(result.blocked) if (how !== 'pick') await this.exit() return } logger.add({ level: 'info', source: 'codeReader', message: `审查 #${issueNumber}:PR #${result.pr} 改了 ${result.files.length} 个文件`, details: result.worktree }) await this.show(result) } private async load(issueNumber: number, step: (text: string) => void): Promise { const repo = await requireForge(this.context) step(`正在读取 #${issueNumber}…`) const reviewable = reviewableIssue(issueNumber, await loadIssue(this.context, repo, issueNumber)) if ('blocked' in reviewable) return reviewable const { issue, pr } = reviewable step(`正在读取 PR #${pr} 改过的文件…`) const reviewablePull = reviewablePullRequest(pr, await loadPullRequest(repo, pr)) if ('blocked' in reviewablePull) return reviewablePull const files = await repo.provider.listPullRequestFiles(pr) const { headSha, baseRef } = reviewablePull.pull const baseCommit = await repo.provider.pullRequestDiffBase(reviewablePull.pull) const worktree = await prepareWorktree(this.context, repo, issue, { number: pr, headSha }, step) return { issue, pr, files, worktree, base: { ref: baseRef, commit: baseCommit } } } private async show(loaded: Loaded): Promise { const root = Uri.file(loaded.worktree) // 先记下再通知:打开着的预览马上来问对比基准 this.loaded = loaded this.baseChange.fire() this.place() this.decorations.show(loaded.files.map(file => ({ uri: Uri.joinPath(root, file.path), change: changeOf(file.status) }))) await Promise.all([ commands.executeCommand('setContext', REVIEWING_CONTEXT, true), this.context.workspaceState.update(REVIEW_ISSUE_KEY, loaded.issue.number), ]) } /** 把正审查着的 PR 改过的文件摆上树(隐藏测试文件时剔掉),标题后面写上藏了几个。 */ private place(): void { const loaded = this.loaded if (!loaded) return const { issue, pr, files, worktree } = loaded // 工作副本当根,名字照写工作区文件夹的:看的还是这个仓库,只是 PR 最新的代码 const folder: WorkspaceFolder = { uri: Uri.file(worktree), name: workspace.workspaceFolders?.[0]?.name ?? path.basename(worktree), index: 0 } const { tree, hidden } = reviewTree(files, this.tests.hidden) this.files.show({ kind: 'changes', view: [{ roots: [{ folder, tree }] }] }) this.view.description = reviewDescription(issue.number, hidden) this.view.message = reviewMessage(issue, pr, files.length) } }