diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 723c8b1..6a64e3d 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -28,7 +28,7 @@ | 跳到定义 | 和普通编辑器一样 ⌘ / Ctrl + 点击名字跳到定义,定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 | | 高亮名字的各处出现 | 和普通编辑器一样单击名字就高亮它在这个文件里的各处出现,由语言扩展按语义认(同名的别的东西不算),颜色用 VS Code 的单词高亮色,再用 focusBorder 色补一笔(当前那一处描框、别的画细线);看不见的(折叠、筛掉的)点的时候不自动展开,筛选栏上写「名字 · 2/5」;⌘↓ / ⌘↑(可改绑的命令)在各处之间跳,跳到折叠着的地方会展开 | | 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠,外加给目录 / 文件记注释、按审查工单只看 PR 改过的文件;不做新建 / 重命名 / 删除 | -| 审查工单 | 标题栏第一个按钮「选工单」列出看板「审查」列的工单,选中后树里只剩这张工单的 PR 改过的文件(目录层级照留、全部展开,按 M / A / D 着色,删掉的列出来但打不开);点开的是本机这张工单的工作副本里 PR 最新的代码,不标差异、不开 diff;本机没有工作副本就拉 PR 分支建一个。弹框列的是看板上现在摆着的工单(不再加载一遍),选中后重读那张工单和它的 PR。见「审查工单」 | +| 审查工单 | 标题栏第一个按钮「选工单」列出看板「审查」列的工单和「进行中」列里已经有 PR 的工单(按列分组),选中后树里只剩这张工单的 PR 改过的文件(目录层级照留、全部展开,按 M / A / D 着色,删掉的列出来但打不开);点开的是本机这张工单的工作副本里 PR 最新的代码,不标差异、不开 diff;本机没有工作副本就拉 PR 分支建一个。弹框列的是看板上现在摆着的工单(不再加载一遍),选中后重读那张工单和它的 PR。见「审查工单」 | | 文件注释 | 读代码时给目录 / 文件随手记一句:存在各工作区文件夹的 `.spx/code-notes.json`,跟着仓库走;在 VS Code 里改名、挪位置、删除时注释跟着变 | | 问 AI | 块右上角「问 AI」,在块下面问 Claude Code(`claude -p`,只读,用输入框旁边选的 profile,记住上次选的,没选过用插件默认的)这块代码,回答逐字出来按 Markdown 显示、能追问(接着这块最近一次的会话,收起再展开、重启 VS Code 也接得上)、能停;输入框里能粘贴 / 拖进图片随问题发(存在 `.spx/code-qa/`,跟着问答删);问答存进各工作区文件夹的 `.spx/code-qa.json`,灰字挂在块下面免得重复问,理解了就删。问答按块的位置记号挂,记号只由和语言无关的块数据(种类、facts 里的名字、源码哈希)算出,文件别处改了照样认得;认不回来的不显示,留在文件里,代码改回来又挂回去。见「问 AI」 | | 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) | @@ -87,17 +87,20 @@ ### 审查工单 -看板「审查」列常压着好几张工单,逐张读 PR 改了什么时,侧栏只看这张工单的 PR 改过的文件(原型画板「审查 · 选工单」「审查 · 改动的文件」)。范围就是原型画的这些:不标差异、不开 diff、没有「改动」筛选。 +看板「审查」列常压着好几张工单,逐张读 PR 改了什么时,侧栏只看这张工单的 PR 改过的文件(原型画板「审查 · 选工单」「审查 · 改动的文件」)。开发完了却没走自动审查的工单会停在「进行中」列,它们的 PR 一样要看,所以「进行中」列里已经有 PR 的也能选。范围就是原型画的这些:不标差异、不开 diff、没有「改动」筛选。 -- 选工单:视图标题栏第一个按钮(`view/title` 的 `navigation@1`,`$(git-pull-request)`,命令 `superpowers.codeReader.pickReviewIssue`)。点开 VS Code 顶部的快速选择(`window.createQuickPick`):标题 `选工单 · 看板「审查」列`,占位 `输入工单号或标题筛选`(按第一行筛)。工单马上列出来,各 PR 改了几个文件随后一项项补上,都补完才停止转圈(`busy`): - - 工单:看板上现在摆着的,不再加载一遍(整个看板重新加载要翻每张工单的评论,几百张工单时很慢,哪个请求卡住就一直转)。面板这次会话里加载过就用它最近一次加载的(`KanbanWebviewPanel.issuesOnBoard` → `snapshotIssues()`),否则用上次存下的看板快照(`workspaceState` 的 `superpowers.kanbanSnapshot`,重开窗口时看板先画的也是它);二者都是看板加载时算好的(state JSON 定列、看板的范围、叠好本机记录)。只留「审查」列的 Gitea 工单(PR 在 Gitea 上),次序同看板列(工单号大的在前)。快照里 PR 号是字符串(`"454"`),和 `Issue.pr` 的类型一致,按正整数认。 - - 看板还没加载过(两样都没有):占位换成 `看板还没加载过,先打开一次看板`,不转圈。「审查」列没有工单:占位换成 `看板「审查」列里没有工单`。 - - 快照可能落后于看板上后来的挪动:刚挪进「审查」列、看板还没重新加载过的工单要等看板刷新后才列出来;选中时会重读这张工单(见下),已经挪走的不会进审查。 +- 能审查的列只有一条规则、写在一处(`reviewRules.ts` 的 `REVIEW_COLUMNS`:先「审查」、再「进行中」):弹框列哪些、选中 / 刷新 / 重开窗口时还能不能审查都按它。 +- 选工单:视图标题栏第一个按钮(`view/title` 的 `navigation@1`,`$(git-pull-request)`,命令 `superpowers.codeReader.pickReviewIssue`)。点开 VS Code 顶部的快速选择(`window.createQuickPick`):标题 `选工单 · 看板「审查」「进行中」列`,占位 `输入工单号或标题筛选`(按第一行筛)。工单马上列出来,各 PR 改了几个文件随后一项项补上,都补完才停止转圈(`busy`): + - 工单:看板上现在摆着的,不再加载一遍(整个看板重新加载要翻每张工单的评论,几百张工单时很慢,哪个请求卡住就一直转)。面板这次会话里加载过就用它最近一次加载的(`KanbanWebviewPanel.issuesOnBoard` → `snapshotIssues()`),否则用上次存下的看板快照(`workspaceState` 的 `superpowers.kanbanSnapshot`,重开窗口时看板先画的也是它);二者都是看板加载时算好的(state JSON 定列、看板的范围、叠好本机记录)。快照里 PR 号是字符串(`"454"`),和 `Issue.pr` 的类型一致,按正整数认。 + - 列哪些(`reviewCandidates`):只要 Gitea 工单(PR 在 Gitea 上);「审查」列的都列(没 PR 的也列,见下);「进行中」列的只列已经有 PR 的——没 PR 的还在开发,没东西可看,列出来只会添乱。 + - 分组:按列用快速选择的分隔行(`QuickPickItemKind.Separator`,写列名)分成两组,先「审查」再「进行中」,没有工单的列不出分隔行;组里次序同看板列(工单号大的在前)。 + - 看板还没加载过(两样都没有):占位换成 `看板还没加载过,先打开一次看板`,不转圈。两列都没有能审查的工单:占位换成 `看板「审查」「进行中」列里没有能审查的工单`。 + - 快照可能落后于看板上后来的挪动:刚挪进这两列、看板还没重新加载过的工单要等看板刷新后才列出来;选中时会重读这张工单(见下),已经挪走的不会进审查。 - 每项第一行 `#<工单号> <标题>`,后面淡色 `PR #`(`description`);第二行(`detail`)`<文件数> · 本机有工作副本` 或 `<文件数> · 本机没有工作副本,选中后拉取 PR 分支`。文件数先写 `正在读改动文件数…`,读到换成 `改了 N 个文件`(`listPullRequestFiles`,各项一起读),没读到写 `读不到改动文件数`、原话记进日志。每补一项整组换一次 `items`,光标按工单号留在原来那项上。本机有没有工作副本按盘上现在的看。 - - state JSON 里没有 PR 号的工单也列出来,第二行只写 `没有关联 PR`;选中它只提示 `#<工单号> 没有关联 PR`,什么都不变。 + - 「审查」列里 state JSON 没有 PR 号的工单也列出来,第二行只写 `没有关联 PR`;选中它只提示 `#<工单号> 没有关联 PR`,什么都不变。 - 当前工作区没有 Gitea 仓库、没配 token:关掉弹框,报错。 - 日志(Superpowers 输出通道,来源 `codeReader`):列了几张、数据来自看板这次加载的还是存下的快照;哪张的文件数没读到和原话;进审查的每一步、不进的原因、出错。 -- 选中后进审查:先重读这张工单(看板的单张加载 `loadSingleIssue`,弹框里的是看板上摆着的,可能旧了),确认它还在「审查」列、有 PR,PR 还开着(没了、合并了、关了都不进,提示原因),再读 PR 改过的文件、备好工作副本,都好了才换树。这期间视图上转进度(`withProgress` 的 `viewId`),标题下面那行写正在做哪一步(`正在读取 PR #447 改过的文件…`、`正在拉取 PR #447 的分支…`)。出错:报错,树不换(原来在审查别的工单就还是那张)。 +- 选中后进审查:先重读这张工单(看板的单张加载 `loadSingleIssue`,弹框里的是看板上摆着的,可能旧了),确认它还在「审查」或「进行中」列、有 PR,PR 还开着(没了、合并了、关了都不进,提示原因),再读 PR 改过的文件、备好工作副本,都好了才换树。这期间视图上转进度(`withProgress` 的 `viewId`),标题下面那行写正在做哪一步(`正在读取 PR #447 改过的文件…`、`正在拉取 PR #447 的分支…`)。出错:报错,树不换(原来在审查别的工单就还是那张)。 - 工作副本(PR 最新的代码): - 本机有这张工单的工作副本就用它:看板记着的 worktree(`worktreePath`,盘上还在)优先,其次是之前审查它时建的。用之前先 `git fetch origin refs/pull//head`(Gitea 给每个 PR 留的引用),能不丢东西时快进到 PR 最新的提交(`headSha`):有没提交的改动(未跟踪的文件不算)、本地另有 PR 之外的提交时照原样用;拉取、快进出错也照原样用,提醒一声。目录必须是工作副本的根,否则不动(`git -C` 会找到外面的仓库上去)。 - 没有就拉 PR 的分支(同上的 `refs/pull//head`)建一个:和实施的 worktree 同一个目录模板(插件设置里的 worktree 目录,`$feature_name` 换成 `review-<工单号>`),停在 PR 最新的提交上、不挂分支(`git worktree add --detach`),不和实施用的 `feature/…` 分支抢名字。不写进工单的本机记录:看板上删 worktree 会连带删工单的 feature 分支,审查建的这个不该走那条路。 @@ -106,8 +109,8 @@ - 删掉的文件:列出来但打不开(没有命令),悬停提示 `PR 里删掉了`。原型把名字划掉,VS Code 的树行没有删除线,这是和原型的有意差异,只靠红色和 `D`。 - 标题栏:视图 `description` 写 `审查 #<工单号>`(标题后面淡色),`message` 写 `#<工单号> <标题> · PR # · 改了 N 个文件`;上下文键 `superpowers.codeReader.reviewing` 为真时出「显示全部」(`$(close)`,`navigation@2`,命令 `superpowers.codeReader.showAllFiles`,命令面板里也只在审查时有),点了回到工作区文件树。真实的视图没有新建按钮,原型里被「显示全部」换掉的那两个不用管。 - 行上的按钮:「复制」照旧(相对路径相对工作副本的根,就是仓库里的路径);注释按钮审查时不出(`when` 带 `!superpowers.codeReader.reviewing`):注释记在工作区文件夹里,工作副本不是工作区文件夹。 -- 刷新:审查时重读工单和 PR 改过的文件,工作副本照上面的规则更新;工单离开「审查」列、PR 不能看了就提示原因、回到工作区文件树。 -- 记住:选中的工单号存 `workspaceState`(`superpowers.codeReader.reviewIssue`),重开窗口后视图第一次露出来时接着审查(没打开侧栏不去拉);工单离开了「审查」列、没了 PR、PR 没了 / 合并了 / 关了,悄悄回到工作区文件树并忘掉;读的时候出错,报错后同样回去。 +- 刷新:审查时重读工单和 PR 改过的文件,工作副本照上面的规则更新;工单两列都离开了(「审查」↔「进行中」之间挪动不算)、PR 不能看了就提示原因、回到工作区文件树。 +- 记住:选中的工单号存 `workspaceState`(`superpowers.codeReader.reviewIssue`),重开窗口后视图第一次露出来时接着审查(没打开侧栏不去拉);工单两列都离开了、没了 PR、PR 没了 / 合并了 / 关了,悄悄回到工作区文件树并忘掉;读的时候出错,报错后同样回去。 - 代码:`review/`。纯函数(改动状态、改动的文件树、能不能审查、标题的字、弹框的各项)在 `changedFiles.ts`、`reviewRules.ts`、`pickList.ts`;看板数据在 `reviewSource.ts`(看板上摆着的工单由看板给:`panel/kanbanSnapshot.ts` 的 `boardIssues`;仓库和 token 与看板「改动」tab 共用 `gitea/repoContext.ts`);工作副本在 `reviewWorktree.ts`(git 操作在 `git/worktree.ts`);着色在 `changeDecorations.ts`;弹框、进出审查、记住在 `reviewMode.ts`。文件树只多一个「换成这些文件」的入口(`showChanges`)。 ### 编辑器右上角切换 @@ -1732,9 +1735,9 @@ vscode/src/codeReader/ explorer/copyText.ts 复制菜单的命令和复制出什么(纯函数) explorer/copyCommands.ts 复制命令:写剪贴板、状态栏提示 review/changedFiles.ts 改动状态 → M / A / D、颜色,PR 改过的文件 → 按目录分好的树(纯函数) - review/reviewRules.ts 「审查」列的工单、PR 号、能不能审查、标题栏的字(纯函数) + review/reviewRules.ts 能审查的列(「审查」「进行中」)、弹框列哪些工单、PR 号、能不能审查、标题栏的字(纯函数) review/pickList.ts 选工单弹框的各项:先摆出来、文件数读到一个换一个、还转不转圈、每项的字(纯函数) - review/reviewSource.ts 看板数据:「审查」列的工单(看板上摆着的)、重读单张工单、PR、PR 改过的文件 + review/reviewSource.ts 看板数据:弹框列的工单(看板上摆着的)、重读单张工单、PR、PR 改过的文件 (panel/kanbanSnapshot.ts 的 boardIssues:看板上现在摆着的工单——这次加载的,否则存下的快照;KanbanWebviewPanel.issuesOnBoard 接上面板和 workspaceState) review/reviewWorktree.ts 本机这张工单的工作副本在哪、备好 PR 最新的代码(已有的快进、没有的建) review/changeDecorations.ts FileDecorationProvider:审查时给改过的文件上色、挂徽标 @@ -1821,8 +1824,8 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 侧栏行上的菜单(`explorer/rowMenus.test.ts`,读 `package.json`):行尾按钮先「复制」子菜单再注释,有没有注释换「添加 / 编辑注释」,工作区根都没有;「复制」子菜单带 `$(copy)`,注释图标 `$(note)` / `$(edit)`;子菜单和右键菜单里文件四项、目录三项(有没有注释一样)、菜单上的字;复制命令都登记了、和代码里的命令表一致、命令面板里藏起来。 - 审查工单: - 改动的文件树(`review/changedFiles.test.ts`):原型那 6 个文件加一个改名的,目录层级照留、每层只有通往改过的文件的目录,文件带改动和相对仓库根的路径,目录没有改动;排法同普通的树;文件换成同名目录两项都留着;没有文件时根下面是空的。状态 `added` → A、`deleted` → D、别的都是 M,三种颜色是 git 装饰色、删掉的提示「PR 里删掉了」。 - - 选工单弹框(`review/pickList.test.ts`):一项的字(有 / 没有工作副本、正在读、读不到、没有 PR);一打开就摆出各项、有 PR 的在读、在转圈,读到一个只换那一项(别的项是同一个对象、次序不变),都读完(没读到也算)停止转圈,没 PR 的、没有工单的一开始就不转;工单从哪来:看板这次加载过用它、没加载过用存下的快照(JSON 里字符串的 PR 号认得,只留「审查」列的 Gitea 工单)、都没有或快照坏了是 undefined。 - - 规则和字(`review/reviewRules.test.ts`):标题栏的 description / message;「审查」列的筛选(只要 Gitea 的)和次序,PR 号只认正整数;工单没了、离开「审查」列、没有 PR,PR 没了、合并了、关了的原因。 + - 选工单弹框(`review/pickList.test.ts`):一项的字(有 / 没有工作副本、正在读、读不到、没有 PR);一打开就摆出各项、有 PR 的在读、在转圈,读到一个只换那一项(别的项是同一个对象、次序不变),都读完(没读到也算)停止转圈,没 PR 的、没有工单的一开始就不转;按列分组:先「审查」再「进行中」、每组前一行列名、没有工单的列不出标题、换掉一项后分组不变;工单从哪来:看板这次加载过用它、没加载过用存下的快照(JSON 里字符串的 PR 号认得,只留「审查」列和有 PR 的「进行中」列的 Gitea 工单)、都没有或快照坏了是 undefined。 + - 规则和字(`review/reviewRules.test.ts`):标题栏的 description / message;弹框列哪些(「审查」列都列、「进行中」列只列有 PR 的、别的列不列、只要 Gitea 的)和次序(先「审查」组再「进行中」组,组里工单号大的在前),列名,PR 号只认正整数;「审查」「进行中」列有 PR 的能审查,工单没了、两列都离开了、没有 PR(两列都一样),PR 没了、合并了、关了的原因。 - 菜单(`explorer/rowMenus.test.ts`):标题栏先「选工单」、审查时才有「显示全部」、再「刷新」,字和图标;审查时注释按钮不出、复制照旧;命令面板里「显示全部」只在审查时有。 - 工作副本(`test/git/reviewWorktree.test.ts`,真的 git:bare 的 origin 上推 `refs/pull/7/head`):拉 PR 的提交建不挂分支的 worktree(父目录不在也建、本机不多出分支),目录手删后同一路径能再建;快进:PR 往前走了且干净 → 快进,已经最新 / 本地在它之上 → 不动,只有未跟踪的文件照样快进,有没提交的改动、本地另有提交 → 不动且改动留着,目录不是工作副本的根 → 报错、外面的仓库不动。 - 复制出什么(`explorer/copyText.test.ts`):文件、目录的名字 / 相对路径 / 绝对路径;不带后缀只去最后一个扩展名(`a.test.ts`、`archive.tar.gz`),`.env` 原样、`.env.local` → `.env`、没有扩展名的原样;相对路径总用 `/`,绝对路径照本机写法(Windows 反斜杠)。 diff --git a/vscode/src/codeReader/review/pickList.test.ts b/vscode/src/codeReader/review/pickList.test.ts index 13dbc26..5bacf07 100644 --- a/vscode/src/codeReader/review/pickList.test.ts +++ b/vscode/src/codeReader/review/pickList.test.ts @@ -1,8 +1,8 @@ import type { Issue } from '../../gitea/types' import { describe, expect, it } from 'vitest' import { boardIssues } from '../../panel/kanbanSnapshot' -import { countingFiles, pickEntries, pickItemText, withFileCount } from './pickList' -import { inReviewColumn } from './reviewRules' +import { countingFiles, pickEntries, pickItemText, pickRows, withFileCount } from './pickList' +import { reviewCandidates } from './reviewRules' function issue(number: number, title: string, pr?: string): Issue { return { id: `o/r#${number}`, number, title, column: 'review', ...(pr ? { pr } : {}), assignees: [], htmlUrl: `https://x/${number}` } @@ -76,6 +76,29 @@ describe('弹框的各项:先摆出来,文件数读到一个换一个', () = }) }) +describe('弹框按列分组', () => { + const idle = { ...issue(449, '开发完了没走自动审查', '450'), column: 'in-progress' as const } + const older = { ...issue(300, '也停在进行中', '301'), column: 'in-progress' as const } + const rows = (entries: ReturnType>): (string | number)[] => + pickRows(entries).map(row => 'separator' in row ? row.separator : row.entry.issue.number) + + it('先「审查」一组再「进行中」一组,每组前面一行列名', () => { + const entries = pickEntries(reviewCandidates([older, noPr, idle, avatar, settings]), () => false) + expect(rows(entries)).toEqual(['审查', 452, 451, 446, '进行中', 449, 300]) + }) + + it('没有工单的列不出标题', () => { + expect(rows(pickEntries([idle, older], () => false))).toEqual(['进行中', 449, 300]) + expect(rows(pickEntries([avatar], () => false))).toEqual(['审查', 446]) + expect(rows([])).toEqual([]) + }) + + it('读到文件数换掉一项后分组不变', () => { + const entries = pickEntries(reviewCandidates([idle, avatar]), () => true) + expect(rows(withFileCount(entries, 449, { count: 3 }))).toEqual(['审查', 446, '进行中', 449]) + }) +}) + describe('弹框列的工单从哪来', () => { // 存下的看板快照是 JSON,PR 号是字符串 const saved = JSON.parse(JSON.stringify({ @@ -83,7 +106,8 @@ describe('弹框列的工单从哪来', () => { { ...issue(460, '已经合并', '461'), column: 'done' }, { ...issue(454, '看板快照里的审查工单', '454'), worktreePath: '~/Sources/worktree/p/x' }, { ...issue(12, 'YouTrack 的审查工单'), source: 'youtrack' }, - { ...issue(300, '进行中'), column: 'in-progress' }, + { ...issue(300, '进行中、还没有 PR'), column: 'in-progress' }, + { ...issue(310, '进行中、已经有 PR', '311'), column: 'in-progress' }, ], scope: 'all', globalAutoReview: false, @@ -94,11 +118,11 @@ describe('弹框列的工单从哪来', () => { expect(boardIssues([avatar], saved)).toEqual({ issues: [avatar], from: 'loaded' }) }) - it('这次还没加载过:用存下的快照;「审查」列的 Gitea 工单照常列出,字符串的 PR 号认得', () => { + it('这次还没加载过:用存下的快照;「审查」列和有 PR 的「进行中」列的 Gitea 工单照常列出,字符串的 PR 号认得', () => { const board = boardIssues(undefined, saved) expect(board?.from).toBe('saved') - const entries = pickEntries(inReviewColumn(board!.issues), () => false) - expect(entries.map(entry => [entry.issue.number, entry.pr?.number])).toEqual([[454, 454]]) + const entries = pickEntries(reviewCandidates(board!.issues), () => false) + expect(entries.map(entry => [entry.issue.number, entry.pr?.number])).toEqual([[454, 454], [310, 311]]) }) it('都没有、快照是坏的:undefined(看板还没加载过)', () => { diff --git a/vscode/src/codeReader/review/pickList.ts b/vscode/src/codeReader/review/pickList.ts index 8e0e6d4..934327e 100644 --- a/vscode/src/codeReader/review/pickList.ts +++ b/vscode/src/codeReader/review/pickList.ts @@ -1,5 +1,5 @@ import type { Issue } from '../../gitea/types' -import { prNumberOf } from './reviewRules' +import { columnLabel, prNumberOf } from './reviewRules' /** PR 改了几个文件:还在读、没读到、读到了。 */ export type FileCount = 'loading' | 'failed' | { count: number } @@ -30,6 +30,16 @@ export function withFileCount>(entries return entries.map(entry => entry.issue.number === issueNumber && entry.pr ? { ...entry, pr: { ...entry.pr, files } } : entry) } +/** 弹框里的一行:分组标题(列名)或一项。 */ +export type PickRow> = { separator: string } | { entry: PickEntry } + +/** 给按列排好的各项插上分组标题:列一变先插一行列名(没有工单的列不出标题)。 */ +export function pickRows>(entries: readonly PickEntry[]): PickRow[] { + return entries.flatMap((entry, index) => index > 0 && entries[index - 1].issue.column === entry.issue.column + ? [{ entry }] + : [{ separator: columnLabel(entry.issue.column) }, { entry }]) +} + /** 还有 PR 的文件数没读完:弹框接着转圈。 */ export function countingFiles(entries: readonly PickEntry>[]): boolean { return entries.some(entry => entry.pr?.files === 'loading') diff --git a/vscode/src/codeReader/review/reviewMode.ts b/vscode/src/codeReader/review/reviewMode.ts index 9c58286..a75bfc0 100644 --- a/vscode/src/codeReader/review/reviewMode.ts +++ b/vscode/src/codeReader/review/reviewMode.ts @@ -5,13 +5,13 @@ import type { Issue } from '../../gitea/types' import type { FileNode, FileTreeProvider } from '../explorer/fileTree' import type { FileCount } from './pickList' import * as path from 'node:path' -import { commands, Uri, window, workspace } from 'vscode' +import { commands, QuickPickItemKind, Uri, window, workspace } from 'vscode' import { logger } from '../../logging/logger' import { ChangeDecorations } from './changeDecorations' import { buildChangedTree, changeOf } from './changedFiles' -import { countingFiles, pickEntries, pickItemText, withFileCount } from './pickList' -import { reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules' -import { loadIssue, loadPullRequest, loadPullRequestFiles, requireRepo, reviewColumnIssues } from './reviewSource' +import { countingFiles, pickEntries, pickItemText, pickRows, withFileCount } from './pickList' +import { columnLabel, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules' +import { loadIssue, loadPullRequest, loadPullRequestFiles, requireRepo, reviewCandidateIssues } from './reviewSource' import { localWorktree, prepareWorktree } from './reviewWorktree' /** workspaceState:正在审查的工单号,重开窗口接着审查。 */ @@ -33,8 +33,9 @@ interface Loaded { worktree: string } -interface IssuePick extends QuickPickItem { - issue: Issue +/** 弹框里的一行:工单的一项带着工单,分组标题(列名)没有。 */ +interface PickerItem extends QuickPickItem { + issue?: Issue } function errorText(err: unknown): string { @@ -42,7 +43,7 @@ function errorText(err: unknown): string { } /** - * 侧栏按审查工单筛文件:「选工单」列出看板「审查」列的工单,选中后树里只剩这张工单的 PR 改过的文件(按改动着色), + * 侧栏按审查工单筛文件:「选工单」列出看板「审查」「进行中」列里能审查的工单,选中后树里只剩这张工单的 PR 改过的文件(按改动着色), * 点开的是本机这张工单的工作副本里 PR 最新的代码;「显示全部」回到工作区文件树。选的工单按工作区记住。 */ export class ReviewMode implements Disposable { @@ -80,14 +81,13 @@ export class ReviewMode implements Disposable { return this.reviewing !== undefined } - /** 「选工单」:顶部弹出快速选择,读看板「审查」列的工单和各自改了几个文件时转圈。 */ /** - * 「选工单」:顶部弹出快速选择,列出看板上现在摆着的「审查」列工单,马上就有; + * 「选工单」:顶部弹出快速选择,列出看板上现在摆着的「审查」「进行中」列里能审查的工单(按列分组),马上就有; * 各 PR 改了几个文件一起去读,读到一个换一项,都读完才停止转圈。 */ async pick(): Promise { - const picker = window.createQuickPick() - picker.title = '选工单 · 看板「审查」列' + const picker = window.createQuickPick() + picker.title = `选工单 · 看板${REVIEW_COLUMNS_TEXT}列` picker.placeholder = '输入工单号或标题筛选' picker.busy = true let open = true @@ -96,11 +96,11 @@ export class ReviewMode implements Disposable { picker.dispose() }) picker.onDidAccept(() => { - const chosen = picker.selectedItems[0] + const chosen = picker.selectedItems[0]?.issue if (!chosen) return picker.hide() - void this.review(chosen.issue.number, 'pick') + void this.review(chosen.number, 'pick') }) picker.show() @@ -114,7 +114,7 @@ export class ReviewMode implements Disposable { void window.showErrorMessage(`没法选工单:${errorText(err)}`) return } - const board = reviewColumnIssues(this.context) + const board = reviewCandidateIssues(this.context) if (!open) return if (!board) { @@ -126,21 +126,23 @@ export class ReviewMode implements Disposable { logger.add({ level: 'info', source: 'codeReader', - message: `选工单:看板「审查」列 ${board.issues.length} 张(${board.from === 'loaded' ? '看板这次加载的' : '上次存下的看板快照'})`, - details: board.issues.map(issue => `#${issue.number} PR ${issue.pr ?? '无'}`).join('\n'), + 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 = '看板「审查」列里没有工单' + 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 = entries.map(entry => ({ issue: entry.issue, ...pickItemText(entry) })) + 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.number === active) + const stay = items.find(item => item.issue !== undefined && item.issue.number === active) if (stay) picker.activeItems = [stay] picker.busy = countingFiles(entries) diff --git a/vscode/src/codeReader/review/reviewRules.test.ts b/vscode/src/codeReader/review/reviewRules.test.ts index 9339175..01982c9 100644 --- a/vscode/src/codeReader/review/reviewRules.test.ts +++ b/vscode/src/codeReader/review/reviewRules.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { inReviewColumn, prNumberOf, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules' +import { columnLabel, prNumberOf, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewCandidates, reviewDescription, reviewMessage } from './reviewRules' const issue = { number: 446, title: '用户资料:上传头像,收紧非管理员改资料的权限' } @@ -10,25 +10,43 @@ describe('审查时的视图标题', () => { }) }) -describe('看板「审查」列', () => { - it('只要审查列的,工单号大的在前(和看板列里一样)', () => { +describe('选工单弹框列哪些工单', () => { + it('「审查」列的都列(没 PR 的也列),「进行中」列的只列有 PR 的,别的列不列', () => { const issues = [ { number: 438, column: 'review' as const }, - { number: 452, column: 'review' as const }, + { number: 452, column: 'review' as const, pr: '455' }, { number: 451, column: 'in-progress' as const }, - { number: 446, column: 'review' as const }, - { number: 460, column: 'done' as const }, + { number: 449, column: 'in-progress' as const, pr: '450' }, + { number: 460, column: 'done' as const, pr: '461' }, + { number: 470, column: 'todo' as const, pr: '471' }, ] - expect(inReviewColumn(issues).map(item => item.number)).toEqual([452, 446, 438]) + expect(reviewCandidates(issues).map(item => item.number)).toEqual([452, 438, 449]) + }) + + it('先「审查」一组再「进行中」一组,组里工单号大的在前(和看板列里一样)', () => { + const issues = [ + { number: 300, column: 'in-progress' as const, pr: '301' }, + { number: 438, column: 'review' as const, pr: '440' }, + { number: 500, column: 'in-progress' as const, pr: '501' }, + { number: 446, column: 'review' as const, pr: '447' }, + ] + expect(reviewCandidates(issues).map(item => item.number)).toEqual([446, 438, 500, 300]) }) it('只要 Gitea 的工单(PR 在 Gitea 上),YouTrack 的不算,没写来源的算 Gitea 的', () => { const issues = [ { number: 12, column: 'review' as const, source: 'youtrack' as const }, + { number: 13, column: 'in-progress' as const, source: 'youtrack' as const, pr: '14' }, { number: 446, column: 'review' as const, source: 'gitea' as const }, { number: 438, column: 'review' as const }, ] - expect(inReviewColumn(issues).map(item => item.number)).toEqual([446, 438]) + expect(reviewCandidates(issues).map(item => item.number)).toEqual([446, 438]) + }) + + it('列名:分组标题和连着写的几列', () => { + expect(columnLabel('review')).toBe('审查') + expect(columnLabel('in-progress')).toBe('进行中') + expect(REVIEW_COLUMNS_TEXT).toBe('「审查」「进行中」') }) it('工单的 PR 号取 state JSON 里的正整数(看板快照里也是字符串)', () => { @@ -42,16 +60,19 @@ describe('看板「审查」列', () => { }) describe('还能不能审查(选中、刷新、重开窗口时判断)', () => { - it('在审查列、有 PR:带上工单和 PR 号', () => { - const current = { column: 'review' as const, pr: '447' } - expect(reviewableIssue(446, current)).toEqual({ issue: current, pr: 447 }) + it('在「审查」或「进行中」列、有 PR:带上工单和 PR 号', () => { + const inReview = { column: 'review' as const, pr: '447' } + const inProgress = { column: 'in-progress' as const, pr: '450' } + expect(reviewableIssue(446, inReview)).toEqual({ issue: inReview, pr: 447 }) + expect(reviewableIssue(449, inProgress)).toEqual({ issue: inProgress, pr: 450 }) }) - it('工单没了、离开了审查列、没有 PR:给出原因', () => { + it('工单没了、两列都离开了、没有 PR:给出原因', () => { expect(reviewableIssue(446, null)).toEqual({ blocked: '#446 已经不存在' }) - expect(reviewableIssue(446, { column: 'done', pr: '447' })).toEqual({ blocked: '#446 已经不在看板「审查」列' }) - expect(reviewableIssue(446, { column: 'in-progress', pr: '447' })).toEqual({ blocked: '#446 已经不在看板「审查」列' }) + expect(reviewableIssue(446, { column: 'done', pr: '447' })).toEqual({ blocked: '#446 已经不在看板「审查」「进行中」列' }) + expect(reviewableIssue(446, { column: 'todo', pr: '447' })).toEqual({ blocked: '#446 已经不在看板「审查」「进行中」列' }) expect(reviewableIssue(446, { column: 'review' })).toEqual({ blocked: '#446 没有关联 PR' }) + expect(reviewableIssue(449, { column: 'in-progress' })).toEqual({ blocked: '#449 没有关联 PR' }) }) it('关联的 PR 开着:带上它;没了、合并了、关了:给出原因', () => { diff --git a/vscode/src/codeReader/review/reviewRules.ts b/vscode/src/codeReader/review/reviewRules.ts index ead83f8..e654b41 100644 --- a/vscode/src/codeReader/review/reviewRules.ts +++ b/vscode/src/codeReader/review/reviewRules.ts @@ -1,4 +1,4 @@ -import type { Issue } from '../../gitea/types' +import type { Issue, IssueColumn } from '../../gitea/types' /** 工单关联的 PR 号;state JSON 里没有、或不是正整数时没有。 */ export function prNumberOf(issue: Pick): number | undefined { @@ -6,19 +6,50 @@ export function prNumberOf(issue: Pick): number | undefined { return Number.isInteger(pr) && pr > 0 ? pr : undefined } -/** 看板「审查」列里的 Gitea 工单(PR 在 Gitea 上,YouTrack 的不算),照看板列里的次序(工单号大的在前)。 */ -export function inReviewColumn>(issues: readonly T[]): T[] { - return issues - .filter(issue => issue.column === 'review' && (issue.source ?? 'gitea') === 'gitea') - .sort((a, b) => b.number - a.number) +/** + * 能拿来审查的看板列,也是选工单弹框里分组的次序:先「审查」,再「进行中」 + * (开发完了、没走自动审查,停在「进行中」的工单)。 + */ +export const REVIEW_COLUMNS: readonly { column: IssueColumn, label: string }[] = [ + { column: 'review', label: '审查' }, + { column: 'in-progress', label: '进行中' }, +] + +/** 这几列连着写:「审查」「进行中」。 */ +export const REVIEW_COLUMNS_TEXT = REVIEW_COLUMNS.map(({ label }) => `「${label}」`).join('') + +/** 列的名字(选工单弹框的分组标题)。 */ +export function columnLabel(column: IssueColumn): string { + return REVIEW_COLUMNS.find(item => item.column === column)?.label ?? column } -/** 这张工单(最新状态,工单已经不存在时 null)现在能不能审查:能时带上它和它的 PR 号,不能时给出原因(给人看的话)。 */ +function inReviewColumns(column: IssueColumn): boolean { + return REVIEW_COLUMNS.some(item => item.column === column) +} + +/** + * 选工单弹框列哪些工单:只要 Gitea 的(PR 在 Gitea 上,YouTrack 的不算);「审查」列的都列(没 PR 的选中时说一声), + * 「进行中」列的只列已经有 PR 的(没 PR 的还在开发,列出来只会添乱)。按列分组(REVIEW_COLUMNS 的次序), + * 组里工单号大的在前(和看板列里一样)。 + */ +export function reviewCandidates>(issues: readonly T[]): T[] { + const order = (issue: T): number => REVIEW_COLUMNS.findIndex(item => item.column === issue.column) + return issues + .filter(issue => (issue.source ?? 'gitea') === 'gitea' + && inReviewColumns(issue.column) + && (issue.column === 'review' || prNumberOf(issue) !== undefined)) + .sort((a, b) => order(a) - order(b) || b.number - a.number) +} + +/** + * 这张工单(最新状态,工单已经不存在时 null)现在能不能审查:在「审查」「进行中」列、有 PR 才能, + * 能时带上它和它的 PR 号,不能时给出原因(给人看的话)。选中、刷新、重开窗口接着审查都按它判断。 + */ export function reviewableIssue>(issueNumber: number, issue: T | null): { issue: T, pr: number } | { blocked: string } { if (!issue) return { blocked: `#${issueNumber} 已经不存在` } - if (issue.column !== 'review') - return { blocked: `#${issueNumber} 已经不在看板「审查」列` } + if (!inReviewColumns(issue.column)) + return { blocked: `#${issueNumber} 已经不在看板${REVIEW_COLUMNS_TEXT}列` } const pr = prNumberOf(issue) if (pr === undefined) return { blocked: `#${issueNumber} 没有关联 PR` } diff --git a/vscode/src/codeReader/review/reviewSource.ts b/vscode/src/codeReader/review/reviewSource.ts index de6d70f..493364e 100644 --- a/vscode/src/codeReader/review/reviewSource.ts +++ b/vscode/src/codeReader/review/reviewSource.ts @@ -13,7 +13,7 @@ import { loadSingleIssue } from '../../gitea/issueLoader' import { resolveRepoContext } from '../../gitea/repoContext' import { overlayLocalIssueState } from '../../issues/localState' import { KanbanWebviewPanel } from '../../panel/KanbanPanel' -import { inReviewColumn } from './reviewRules' +import { reviewCandidates } from './reviewRules' /** 当前工作区的 Gitea 仓库和 token;没有时抛出给人看的错。 */ export async function requireRepo(context: ExtensionContext): Promise { @@ -24,12 +24,12 @@ export async function requireRepo(context: ExtensionContext): Promise