✨ feat(vscode): 代码阅读侧栏可以按审查工单只看 PR 改过的文件:标题栏「选工单」列出看板「审查」列,选中后树里只剩改过的文件(M / A / D 着色),点开的是本机工作副本里 PR 最新的代码,没有就拉 PR 分支建一个,「显示全部」退出
This commit is contained in:
1 parent
c009187f6c
commit
8e2cdfaf17
18 files changed
+1053
-40
No files matched your search
@@ -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),
|
||||
])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user