✨ feat(vscode): 代码阅读侧栏可以按审查工单只看 PR 改过的文件:标题栏「选工单」列出看板「审查」列,选中后树里只剩改过的文件(M / A / D 着色),点开的是本机工作副本里 PR 最新的代码,没有就拉 PR 分支建一个,「显示全部」退出

This commit is contained in:
cruldra committed 2026-09-27 17:36:02 +08:00
1 parent c009187f6c
commit 8e2cdfaf17
18 files changed
+1053 -40

No files matched your search

+233
View File
@@ -0,0 +1,233 @@
import type { Disposable, ExtensionContext, QuickPickItem, TreeView, WorkspaceFolder } from 'vscode'
import type { PrFileDto } from '../../gitea/api'
import type { RepoContext } from '../../gitea/repoContext'
import type { Issue } from '../../gitea/types'
import type { FileNode, FileTreeProvider } from '../explorer/fileTree'
import type { FileCount } from './reviewRules'
import * as path from 'node:path'
import { commands, Uri, window, workspace } from 'vscode'
import { ChangeDecorations } from './changeDecorations'
import { buildChangedTree, changeOf } from './changedFiles'
import { pickItemText, prNumberOf, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules'
import { loadIssue, loadPullRequest, loadPullRequestFiles, requireRepo, reviewColumnIssues } from './reviewSource'
import { localWorktree, prepareWorktree } from './reviewWorktree'
/** workspaceState:正在审查的工单号,重开窗口接着审查。 */
const REVIEW_ISSUE_KEY = 'superpowers.codeReader.reviewIssue'
/** 上下文键:正在审查工单。标题栏的「显示全部」、行上的注释按钮按它显示 / 藏起来。 */
const REVIEWING_CONTEXT = 'superpowers.codeReader.reviewing'
/**
* 怎么进的审查,决定不能审查、出错时怎么收场:
* pick 选工单(说一声,什么都不变)、reload 刷新(说一声,回到全部文件)、restore 重开窗口接着审查(悄悄回到全部文件)。
*/
type How = 'pick' | 'reload' | 'restore'
/** 进审查要的东西都备好了:工单、它的 PR、PR 改过的文件、装着 PR 最新代码的工作副本。 */
interface Loaded {
issue: Issue
pr: number
files: PrFileDto[]
worktree: string
}
interface IssuePick extends QuickPickItem {
issue: Issue
}
function errorText(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
/**
* 侧栏按审查工单筛文件:「选工单」列出看板「审查」列的工单,选中后树里只剩这张工单的 PR 改过的文件(按改动着色),
* 点开的是本机这张工单的工作副本里 PR 最新的代码;「显示全部」回到工作区文件树。选的工单按工作区记住。
*/
export class ReviewMode implements Disposable {
private readonly decorations = new ChangeDecorations()
private readonly disposables: Disposable[]
/** 正在审查的工单号 */
private reviewing: number | undefined
/** 每开始一次进审查、退出审查都加一:慢的那次回来时已经不是最新的,结果丢掉。 */
private generation = 0
/** viewId 是侧栏视图的 id:进审查时在这个视图上转进度。 */
constructor(
private readonly context: ExtensionContext,
private readonly viewId: string,
private readonly view: TreeView<FileNode>,
private readonly files: FileTreeProvider,
) {
this.disposables = [this.decorations, window.registerFileDecorationProvider(this.decorations)]
// 视图第一次露出来时才接着上次的审查:没打开侧栏就不去拉 PR、动工作副本
if (view.visible) {
void this.restore()
}
else {
const shown = view.onDidChangeVisibility(({ visible }) => {
if (!visible)
return
shown.dispose()
void this.restore()
})
this.disposables.push(shown)
}
}
get active(): boolean {
return this.reviewing !== undefined
}
/** 「选工单」:顶部弹出快速选择,读看板「审查」列的工单和各自改了几个文件时转圈。 */
async pick(): Promise<void> {
const picker = window.createQuickPick<IssuePick>()
picker.title = '选工单 · 看板「审查」列'
picker.placeholder = '输入工单号或标题筛选'
picker.busy = true
let open = true
picker.onDidHide(() => {
open = false
picker.dispose()
})
picker.onDidAccept(() => {
const chosen = picker.selectedItems[0]
if (!chosen)
return
picker.hide()
void this.review(chosen.issue, 'pick')
})
picker.show()
try {
const repo = await requireRepo(this.context)
const issues = await reviewColumnIssues(this.context, repo)
const items = await Promise.all(issues.map(issue => this.pickItem(repo, issue)))
if (!open)
return
picker.items = items
if (items.length === 0)
picker.placeholder = '看板「审查」列里没有工单'
picker.busy = false
}
catch (err) {
if (!open)
return
picker.hide()
void window.showErrorMessage(`读不到看板「审查」列:${errorText(err)}`)
}
}
/** 「显示全部」:回到工作区文件树,不再记着这张工单。 */
async exit(): Promise<void> {
this.generation++
this.reviewing = undefined
this.files.showChanges(undefined)
this.decorations.show([])
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<void> {
if (this.reviewing !== undefined)
await this.review(this.reviewing, 'reload')
}
dispose(): void {
for (const disposable of this.disposables)
disposable.dispose()
}
private async restore(): Promise<void> {
const issueNumber = this.context.workspaceState.get<number>(REVIEW_ISSUE_KEY)
if (issueNumber !== undefined)
await this.review(issueNumber, 'restore')
}
/** 弹框里的一项:有 PR 的工单去读 PR 改了几个文件(各项一起读),读不到就写读不到。 */
private async pickItem(repo: RepoContext, issue: Issue): Promise<IssuePick> {
const pr = prNumberOf(issue)
if (pr === undefined)
return { issue, ...pickItemText(issue) }
const files: FileCount = await loadPullRequestFiles(repo, pr).then(
list => ({ count: list.length }),
(err: unknown) => ({ error: errorText(err) }),
)
const hasWorktree = localWorktree(this.context, repo.workspaceRoot, issue) !== undefined
return { issue, ...pickItemText(issue, { number: pr, files, hasWorktree }) }
}
/**
* 进审查:读工单和 PR、备工作副本,都好了才换树;这期间视图上转进度,标题下面那行写正在做哪一步。
* target 在选工单时是弹框里那张工单(刚读的),刷新、重开窗口时是工单号(重读)。
*/
private async review(target: Issue | number, how: How): Promise<void> {
const issueNumber = typeof target === 'number' ? target : target.number
const generation = ++this.generation
const latest = (): boolean => generation === this.generation
const before = this.view.message
let result: Loaded | { blocked: string }
try {
result = await window.withProgress({ location: { viewId: this.viewId } }, () => this.load(target, (text) => {
if (latest())
this.view.message = text
}))
}
catch (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) {
this.view.message = before
if (how !== 'restore')
void window.showInformationMessage(result.blocked)
if (how !== 'pick')
await this.exit()
return
}
await this.show(result)
}
private async load(target: Issue | number, step: (text: string) => void): Promise<Loaded | { blocked: string }> {
const repo = await requireRepo(this.context)
const issueNumber = typeof target === 'number' ? target : target.number
if (typeof target === 'number')
step(`正在读取 #${issueNumber}…`)
const reviewable = reviewableIssue(issueNumber, typeof target === 'number' ? await loadIssue(this.context, repo, target) : target)
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 loadPullRequestFiles(repo, pr)
const worktree = await prepareWorktree(this.context, repo, issue, { number: pr, headSha: reviewablePull.pull.headSha }, step)
return { issue, pr, files, worktree }
}
private async show({ issue, pr, files, worktree }: Loaded): Promise<void> {
const root = Uri.file(worktree)
// 工作副本当根,名字照写工作区文件夹的:看的还是这个仓库,只是 PR 最新的代码
const folder: WorkspaceFolder = { uri: root, name: workspace.workspaceFolders?.[0]?.name ?? path.basename(worktree), index: 0 }
this.reviewing = issue.number
this.files.showChanges({ folder, tree: buildChangedTree(files) })
this.decorations.show(files.map(file => ({ uri: Uri.joinPath(root, file.path), change: changeOf(file.status) })))
this.view.description = reviewDescription(issue.number)
this.view.message = reviewMessage(issue, pr, files.length)
await Promise.all([
commands.executeCommand('setContext', REVIEWING_CONTEXT, true),
this.context.workspaceState.update(REVIEW_ISSUE_KEY, issue.number),
])
}
}