diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index e0bbfa9..3e9fcc1 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -27,7 +27,7 @@ | 跳到定义 | 和普通编辑器一样 ⌘ / Ctrl + 点击名字跳到定义,定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 | | 高亮名字的各处出现 | 和普通编辑器一样单击名字就高亮它在这个文件里的各处出现,由语言扩展按语义认(同名的别的东西不算),颜色用 VS Code 的单词高亮色,再用 focusBorder 色补一笔(当前那一处描框、别的画细线);看不见的(折叠、筛掉的)点的时候不自动展开,筛选栏上写「名字 · 2/5」;⌘↓ / ⌘↑(可改绑的命令)在各处之间跳,跳到折叠着的地方会展开 | | 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠,外加给目录 / 文件记注释、按审查工单只看 PR 改过的文件;不做新建 / 重命名 / 删除 | -| 审查工单 | 标题栏第一个按钮「选工单」列出看板「审查」列的工单,选中后树里只剩这张工单的 PR 改过的文件(目录层级照留、全部展开,按 M / A / D 着色,删掉的列出来但打不开);点开的是本机这张工单的工作副本里 PR 最新的代码,不标差异、不开 diff;本机没有工作副本就拉 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` → 捕获,`===` → 全等) | @@ -82,12 +82,15 @@ 看板「审查」列常压着好几张工单,逐张读 PR 改了什么时,侧栏只看这张工单的 PR 改过的文件(原型画板「审查 · 选工单」「审查 · 改动的文件」)。范围就是原型画的这些:不标差异、不开 diff、没有「改动」筛选。 -- 选工单:视图标题栏第一个按钮(`view/title` 的 `navigation@1`,`$(git-pull-request)`,命令 `superpowers.codeReader.pickReviewIssue`)。点开 VS Code 顶部的快速选择(`window.createQuickPick`):标题 `选工单 · 看板「审查」列`,占位 `输入工单号或标题筛选`(按第一行筛)。读数据期间转圈(`busy`),都读好了才列出来,不先摆假的数: - - 工单:走看板自己的加载(`loadIssues`,看板当前的范围 `superpowers.kanbanScope`,state JSON 定列),本机记录(worktree 路径)照看板叠上(`overlayLocalIssueState`),只留「审查」列的,次序同看板列(工单号大的在前)。只有 Gitea 工单:PR 在 Gitea 上。 - - 每项第一行 `#<工单号> <标题>`,后面淡色 `PR #`(`description`);第二行(`detail`)`改了 N 个文件 · 本机有工作副本` 或 `改了 N 个文件 · 本机没有工作副本,选中后拉取 PR 分支`。N 是 PR 改过的文件数(`listPullRequestFiles`,各项一起读);读不到时写 `读不到改了哪些文件:<原话>`。 +- 选工单:视图标题栏第一个按钮(`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` 的类型一致,按正整数认。 + - 看板还没加载过(两样都没有):占位换成 `看板还没加载过,先打开一次看板`,不转圈。「审查」列没有工单:占位换成 `看板「审查」列里没有工单`。 + - 快照可能落后于看板上后来的挪动:刚挪进「审查」列、看板还没重新加载过的工单要等看板刷新后才列出来;选中时会重读这张工单(见下),已经挪走的不会进审查。 + - 每项第一行 `#<工单号> <标题>`,后面淡色 `PR #`(`description`);第二行(`detail`)`<文件数> · 本机有工作副本` 或 `<文件数> · 本机没有工作副本,选中后拉取 PR 分支`。文件数先写 `正在读改动文件数…`,读到换成 `改了 N 个文件`(`listPullRequestFiles`,各项一起读),没读到写 `读不到改动文件数`、原话记进日志。每补一项整组换一次 `items`,光标按工单号留在原来那项上。本机有没有工作副本按盘上现在的看。 - state JSON 里没有 PR 号的工单也列出来,第二行只写 `没有关联 PR`;选中它只提示 `#<工单号> 没有关联 PR`,什么都不变。 - - 读不到看板(没有 Gitea 仓库、没配 token、网络):关掉弹框,报错。「审查」列没有工单:占位换成 `看板「审查」列里没有工单`。 -- 选中后进审查:先确认工单还在「审查」列、有 PR,PR 还开着(没了、合并了、关了都不进,提示原因),再读 PR 改过的文件、备好工作副本,都好了才换树。这期间视图上转进度(`withProgress` 的 `viewId`),标题下面那行写正在做哪一步(`正在读取 PR #447 改过的文件…`、`正在拉取 PR #447 的分支…`)。出错:报错,树不换(原来在审查别的工单就还是那张)。 + - 当前工作区没有 Gitea 仓库、没配 token:关掉弹框,报错。 + - 日志(Superpowers 输出通道,来源 `codeReader`):列了几张、数据来自看板这次加载的还是存下的快照;哪张的文件数没读到和原话;进审查的每一步、不进的原因、出错。 +- 选中后进审查:先重读这张工单(看板的单张加载 `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 分支,审查建的这个不该走那条路。 @@ -98,7 +101,7 @@ - 行上的按钮:「复制」照旧(相对路径相对工作副本的根,就是仓库里的路径);注释按钮审查时不出(`when` 带 `!superpowers.codeReader.reviewing`):注释记在工作区文件夹里,工作副本不是工作区文件夹。 - 刷新:审查时重读工单和 PR 改过的文件,工作副本照上面的规则更新;工单离开「审查」列、PR 不能看了就提示原因、回到工作区文件树。 - 记住:选中的工单号存 `workspaceState`(`superpowers.codeReader.reviewIssue`),重开窗口后视图第一次露出来时接着审查(没打开侧栏不去拉);工单离开了「审查」列、没了 PR、PR 没了 / 合并了 / 关了,悄悄回到工作区文件树并忘掉;读的时候出错,报错后同样回去。 -- 代码:`review/`。纯函数(改动状态、改动的文件树、弹框和标题的字、能不能审查)在 `changedFiles.ts`、`reviewRules.ts`;看板数据在 `reviewSource.ts`(仓库和 token 与看板「改动」tab 共用 `gitea/repoContext.ts`);工作副本在 `reviewWorktree.ts`(git 操作在 `git/worktree.ts`);着色在 `changeDecorations.ts`;弹框、进出审查、记住在 `reviewMode.ts`。文件树只多一个「换成这些文件」的入口(`showChanges`)。 +- 代码:`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`)。 ### 编辑器右上角切换 @@ -1217,8 +1220,10 @@ vscode/src/codeReader/ explorer/copyText.ts 复制菜单的命令和复制出什么(纯函数) explorer/copyCommands.ts 复制命令:写剪贴板、状态栏提示 review/changedFiles.ts 改动状态 → M / A / D、颜色,PR 改过的文件 → 按目录分好的树(纯函数) - review/reviewRules.ts 「审查」列的工单、PR 号、能不能审查、弹框和标题栏的字(纯函数) - review/reviewSource.ts 看板数据:「审查」列的工单、单张工单、PR、PR 改过的文件(走看板的加载和 Gitea 接口) + review/reviewRules.ts 「审查」列的工单、PR 号、能不能审查、标题栏的字(纯函数) + review/pickList.ts 选工单弹框的各项:先摆出来、文件数读到一个换一个、还转不转圈、每项的字(纯函数) + review/reviewSource.ts 看板数据:「审查」列的工单(看板上摆着的)、重读单张工单、PR、PR 改过的文件 + (panel/kanbanSnapshot.ts 的 boardIssues:看板上现在摆着的工单——这次加载的,否则存下的快照;KanbanWebviewPanel.issuesOnBoard 接上面板和 workspaceState) review/reviewWorktree.ts 本机这张工单的工作副本在哪、备好 PR 最新的代码(已有的快进、没有的建) review/changeDecorations.ts FileDecorationProvider:审查时给改过的文件上色、挂徽标 review/reviewMode.ts ReviewMode:选工单弹框、进出审查、刷新、按工作区记住 @@ -1290,7 +1295,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/reviewRules.test.ts`):弹框一项的三行(有 / 没有工作副本、读不到文件数写原话、没有 PR),标题栏的 description / message;「审查」列的筛选和次序,PR 号只认正整数;工单没了、离开「审查」列、没有 PR,PR 没了、合并了、关了的原因。 + - 选工单弹框(`review/pickList.test.ts`):一项的字(有 / 没有工作副本、正在读、读不到、没有 PR);一打开就摆出各项、有 PR 的在读、在转圈,读到一个只换那一项(别的项是同一个对象、次序不变),都读完(没读到也算)停止转圈,没 PR 的、没有工单的一开始就不转;工单从哪来:看板这次加载过用它、没加载过用存下的快照(JSON 里字符串的 PR 号认得,只留「审查」列的 Gitea 工单)、都没有或快照坏了是 undefined。 + - 规则和字(`review/reviewRules.test.ts`):标题栏的 description / message;「审查」列的筛选(只要 Gitea 的)和次序,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 new file mode 100644 index 0000000..13dbc26 --- /dev/null +++ b/vscode/src/codeReader/review/pickList.test.ts @@ -0,0 +1,108 @@ +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' + +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}` } +} + +const avatar = issue(446, '用户资料:上传头像,收紧非管理员改资料的权限', '447') +const settings = issue(452, '桌面端:照 Figma 原型重做设置页', '455') +const noPr = issue(451, '会话消息区 UI 优化') + +describe('选工单弹框的一项', () => { + it('本机有工作副本:工单号 + 标题,淡色 PR 号,第二行改了几个文件', () => { + expect(pickItemText({ issue: avatar, pr: { number: 447, files: { count: 6 }, hasWorktree: true } })).toEqual({ + label: '#446 用户资料:上传头像,收紧非管理员改资料的权限', + description: 'PR #447', + detail: '改了 6 个文件 · 本机有工作副本', + }) + }) + + it('本机没有工作副本:说选中后拉取 PR 分支', () => { + expect(pickItemText({ issue: settings, pr: { number: 455, files: { count: 12 }, hasWorktree: false } }).detail) + .toBe('改了 12 个文件 · 本机没有工作副本,选中后拉取 PR 分支') + }) + + it('文件数还在读、没读到:照实写,不编数', () => { + expect(pickItemText({ issue: avatar, pr: { number: 447, files: 'loading', hasWorktree: true } }).detail) + .toBe('正在读改动文件数… · 本机有工作副本') + expect(pickItemText({ issue: avatar, pr: { number: 447, files: 'failed', hasWorktree: true } }).detail) + .toBe('读不到改动文件数 · 本机有工作副本') + }) + + it('没有关联 PR:没有 PR 号,第二行只写「没有关联 PR」', () => { + expect(pickItemText({ issue: noPr })).toEqual({ label: '#451 会话消息区 UI 优化', detail: '没有关联 PR' }) + }) +}) + +describe('弹框的各项:先摆出来,文件数读到一个换一个', () => { + const opened = pickEntries([avatar, settings, noPr], item => item.number === 446) + + it('一打开:有 PR 的写「正在读」,本机有没有工作副本当场判断,没 PR 的没有文件数;在转圈', () => { + expect(opened).toEqual([ + { issue: avatar, pr: { number: 447, files: 'loading', hasWorktree: true } }, + { issue: settings, pr: { number: 455, files: 'loading', hasWorktree: false } }, + { issue: noPr }, + ]) + expect(countingFiles(opened)).toBe(true) + }) + + it('读到一个只换那一项,别的项是同一个对象、次序不变;还有没读完的接着转圈', () => { + const next = withFileCount(opened, 452, { count: 12 }) + expect(next.map(entry => entry.issue.number)).toEqual([446, 452, 451]) + expect(next[1].pr?.files).toEqual({ count: 12 }) + expect(next[0]).toBe(opened[0]) + expect(next[2]).toBe(opened[2]) + expect(opened[1].pr?.files).toBe('loading') + expect(countingFiles(next)).toBe(true) + }) + + it('都读完(没读到的也算完)就停止转圈', () => { + const done = withFileCount(withFileCount(opened, 452, { count: 12 }), 446, 'failed') + expect(done.map(entry => pickItemText(entry).detail)).toEqual([ + '读不到改动文件数 · 本机有工作副本', + '改了 12 个文件 · 本机没有工作副本,选中后拉取 PR 分支', + '没有关联 PR', + ]) + expect(countingFiles(done)).toBe(false) + }) + + it('没有 PR 的工单、没有工单:一开始就不转圈', () => { + expect(countingFiles(pickEntries([noPr], () => false))).toBe(false) + expect(countingFiles(pickEntries([], () => false))).toBe(false) + }) +}) + +describe('弹框列的工单从哪来', () => { + // 存下的看板快照是 JSON,PR 号是字符串 + const saved = JSON.parse(JSON.stringify({ + issues: [ + { ...issue(460, '已经合并', '461'), column: 'done' }, + { ...issue(454, '看板快照里的审查工单', '454'), worktreePath: '~/Sources/worktree/p/x' }, + { ...issue(12, 'YouTrack 的审查工单'), source: 'youtrack' }, + { ...issue(300, '进行中'), column: 'in-progress' }, + ], + scope: 'all', + globalAutoReview: false, + youtrackConfigured: true, + })) as unknown + + it('看板这次加载过就用它最近一次加载的,不看存下的快照', () => { + expect(boardIssues([avatar], saved)).toEqual({ issues: [avatar], from: 'loaded' }) + }) + + it('这次还没加载过:用存下的快照;「审查」列的 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]]) + }) + + it('都没有、快照是坏的:undefined(看板还没加载过)', () => { + expect(boardIssues(undefined, undefined)).toBeUndefined() + expect(boardIssues(undefined, { issues: 'x' })).toBeUndefined() + }) +}) diff --git a/vscode/src/codeReader/review/pickList.ts b/vscode/src/codeReader/review/pickList.ts new file mode 100644 index 0000000..8e0e6d4 --- /dev/null +++ b/vscode/src/codeReader/review/pickList.ts @@ -0,0 +1,51 @@ +import type { Issue } from '../../gitea/types' +import { prNumberOf } from './reviewRules' + +/** PR 改了几个文件:还在读、没读到、读到了。 */ +export type FileCount = 'loading' | 'failed' | { count: number } + +/** 选工单弹框里一项背后的数据;工单没有关联 PR 时没有 pr。 */ +export interface PickEntry> { + issue: T + pr?: { number: number, files: FileCount, hasWorktree: boolean } +} + +/** 弹框里一项的字(QuickPickItem 的 label / description / detail)。 */ +export interface PickText { + label: string + description?: string + detail: string +} + +/** 弹框一打开就摆出来的各项:有 PR 的文件数先写「正在读」,读到一个换一个(withFileCount)。 */ +export function pickEntries>(issues: readonly T[], hasWorktree: (issue: T) => boolean): PickEntry[] { + return issues.map((issue) => { + const pr = prNumberOf(issue) + return pr === undefined ? { issue } : { issue, pr: { number: pr, files: 'loading', hasWorktree: hasWorktree(issue) } } + }) +} + +/** 读到(或没读到)一张工单的 PR 改了几个文件:只换掉那一项,别的项原样(同一个对象),次序不变。 */ +export function withFileCount>(entries: readonly PickEntry[], issueNumber: number, files: FileCount): PickEntry[] { + return entries.map(entry => entry.issue.number === issueNumber && entry.pr ? { ...entry, pr: { ...entry.pr, files } } : entry) +} + +/** 还有 PR 的文件数没读完:弹框接着转圈。 */ +export function countingFiles(entries: readonly PickEntry>[]): boolean { + return entries.some(entry => entry.pr?.files === 'loading') +} + +/** + * 弹框里的一项:第一行工单号 + 标题,后面淡色 PR 号;第二行改了几个文件、本机有没有这张工单的工作副本。 + * 没有关联 PR 的第二行只写「没有关联 PR」。 + */ +export function pickItemText({ issue, pr }: PickEntry>): PickText { + const label = `#${issue.number} ${issue.title}` + if (!pr) + return { label, detail: '没有关联 PR' } + const files = pr.files === 'loading' + ? '正在读改动文件数…' + : pr.files === 'failed' ? '读不到改动文件数' : `改了 ${pr.files.count} 个文件` + const worktree = pr.hasWorktree ? '本机有工作副本' : '本机没有工作副本,选中后拉取 PR 分支' + return { label, description: `PR #${pr.number}`, detail: `${files} · ${worktree}` } +} diff --git a/vscode/src/codeReader/review/reviewMode.ts b/vscode/src/codeReader/review/reviewMode.ts index d5567e1..9c58286 100644 --- a/vscode/src/codeReader/review/reviewMode.ts +++ b/vscode/src/codeReader/review/reviewMode.ts @@ -3,12 +3,14 @@ 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 type { FileCount } from './pickList' import * as path from 'node:path' import { commands, Uri, window, workspace } from 'vscode' +import { logger } from '../../logging/logger' import { ChangeDecorations } from './changeDecorations' import { buildChangedTree, changeOf } from './changedFiles' -import { pickItemText, prNumberOf, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules' +import { countingFiles, pickEntries, pickItemText, withFileCount } from './pickList' +import { reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules' import { loadIssue, loadPullRequest, loadPullRequestFiles, requireRepo, reviewColumnIssues } from './reviewSource' import { localWorktree, prepareWorktree } from './reviewWorktree' @@ -79,6 +81,10 @@ export class ReviewMode implements Disposable { } /** 「选工单」:顶部弹出快速选择,读看板「审查」列的工单和各自改了几个文件时转圈。 */ + /** + * 「选工单」:顶部弹出快速选择,列出看板上现在摆着的「审查」列工单,马上就有; + * 各 PR 改了几个文件一起去读,读到一个换一项,都读完才停止转圈。 + */ async pick(): Promise { const picker = window.createQuickPick() picker.title = '选工单 · 看板「审查」列' @@ -94,26 +100,58 @@ export class ReviewMode implements Disposable { if (!chosen) return picker.hide() - void this.review(chosen.issue, 'pick') + void this.review(chosen.issue.number, 'pick') }) picker.show() + + let repo: RepoContext 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 + repo = await requireRepo(this.context) } catch (err) { + logger.add({ level: 'error', source: 'codeReader', message: '选工单:读不到当前工作区的 Gitea 仓库', details: errorText(err) }) + picker.hide() + void window.showErrorMessage(`没法选工单:${errorText(err)}`) + return + } + const board = reviewColumnIssues(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: `选工单:看板「审查」列 ${board.issues.length} 张(${board.from === 'loaded' ? '看板这次加载的' : '上次存下的看板快照'})`, + details: board.issues.map(issue => `#${issue.number} PR ${issue.pr ?? '无'}`).join('\n'), + }) + if (board.issues.length === 0) + picker.placeholder = '看板「审查」列里没有工单' + + let entries = pickEntries(board.issues, issue => localWorktree(this.context, repo.workspaceRoot, issue) !== undefined) + // 换 items 时 VS Code 会把光标挪回第一项:按工单号挪回用户停着的那一项 + const render = (): void => { if (!open) return - picker.hide() - void window.showErrorMessage(`读不到看板「审查」列:${errorText(err)}`) + const active = picker.activeItems[0]?.issue.number + const items = entries.map(entry => ({ issue: entry.issue, ...pickItemText(entry) })) + picker.items = items + const stay = items.find(item => 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() + })] + : [])) } /** 「显示全部」:回到工作区文件树,不再记着这张工单。 */ @@ -148,35 +186,36 @@ export class ReviewMode implements Disposable { } /** 弹框里的一项:有 PR 的工单去读 PR 改了几个文件(各项一起读),读不到就写读不到。 */ - private async pickItem(repo: RepoContext, issue: Issue): Promise { - 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 改了几个文件;没读到记进日志,这一项写读不到。 */ + private async countFiles(repo: RepoContext, issueNumber: number, pr: number): Promise { + try { + return { count: (await loadPullRequestFiles(repo, pr)).length } + } + catch (err) { + logger.add({ level: 'warn', source: 'codeReader', message: `选工单:读不到 #${issueNumber} 的 PR #${pr} 改了几个文件`, details: errorText(err) }) + return 'failed' + } } /** - * 进审查:读工单和 PR、备工作副本,都好了才换树;这期间视图上转进度,标题下面那行写正在做哪一步。 - * target 在选工单时是弹框里那张工单(刚读的),刷新、重开窗口时是工单号(重读)。 + * 进审查:重读这张工单(弹框里的是看板上摆着的,可能旧了)和它的 PR、备工作副本,都好了才换树; + * 这期间视图上转进度,标题下面那行写正在做哪一步。每一步、不进的原因、出错都记进日志。 */ - private async review(target: Issue | number, how: How): Promise { - const issueNumber = typeof target === 'number' ? target : target.number + 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(target, (text) => { + 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 @@ -188,6 +227,7 @@ export class ReviewMode implements Disposable { 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) @@ -195,15 +235,14 @@ export class ReviewMode implements Disposable { 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(target: Issue | number, step: (text: string) => void): Promise { + private async load(issueNumber: number, step: (text: string) => void): Promise { 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) + step(`正在读取 #${issueNumber}…`) + const reviewable = reviewableIssue(issueNumber, await loadIssue(this.context, repo, issueNumber)) if ('blocked' in reviewable) return reviewable const { issue, pr } = reviewable diff --git a/vscode/src/codeReader/review/reviewRules.test.ts b/vscode/src/codeReader/review/reviewRules.test.ts index f53ec11..9339175 100644 --- a/vscode/src/codeReader/review/reviewRules.test.ts +++ b/vscode/src/codeReader/review/reviewRules.test.ts @@ -1,32 +1,8 @@ import { describe, expect, it } from 'vitest' -import { inReviewColumn, pickItemText, prNumberOf, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules' +import { inReviewColumn, prNumberOf, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules' const issue = { number: 446, title: '用户资料:上传头像,收紧非管理员改资料的权限' } -describe('选工单弹框的一项', () => { - it('本机有工作副本:工单号 + 标题,淡色 PR 号,第二行改了几个文件', () => { - expect(pickItemText(issue, { number: 447, files: { count: 6 }, hasWorktree: true })).toEqual({ - label: '#446 用户资料:上传头像,收紧非管理员改资料的权限', - description: 'PR #447', - detail: '改了 6 个文件 · 本机有工作副本', - }) - }) - - it('本机没有工作副本:说选中后拉取 PR 分支', () => { - expect(pickItemText({ number: 452, title: '桌面端:照 Figma 原型重做设置页' }, { number: 455, files: { count: 12 }, hasWorktree: false }).detail) - .toBe('改了 12 个文件 · 本机没有工作副本,选中后拉取 PR 分支') - }) - - it('读不到改了哪些文件:写出错的原话,不编数', () => { - expect(pickItemText(issue, { number: 447, files: { error: 'Gitea 502' }, hasWorktree: true }).detail) - .toBe('读不到改了哪些文件:Gitea 502 · 本机有工作副本') - }) - - it('没有关联 PR:没有 PR 号,第二行只写「没有关联 PR」', () => { - expect(pickItemText(issue)).toEqual({ label: '#446 用户资料:上传头像,收紧非管理员改资料的权限', detail: '没有关联 PR' }) - }) -}) - describe('审查时的视图标题', () => { it('标题后面「审查 #446」,下面一行工单标题、PR、改了几个文件', () => { expect(reviewDescription(446)).toBe('审查 #446') @@ -46,7 +22,17 @@ describe('看板「审查」列', () => { expect(inReviewColumn(issues).map(item => item.number)).toEqual([452, 446, 438]) }) - it('工单的 PR 号取 state JSON 里的正整数', () => { + it('只要 Gitea 的工单(PR 在 Gitea 上),YouTrack 的不算,没写来源的算 Gitea 的', () => { + const issues = [ + { number: 12, column: 'review' as const, source: 'youtrack' as const }, + { 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]) + }) + + it('工单的 PR 号取 state JSON 里的正整数(看板快照里也是字符串)', () => { + expect(prNumberOf({ pr: '454' })).toBe(454) expect(prNumberOf({ pr: '447' })).toBe(447) expect(prNumberOf({})).toBeUndefined() expect(prNumberOf({ pr: '' })).toBeUndefined() diff --git a/vscode/src/codeReader/review/reviewRules.ts b/vscode/src/codeReader/review/reviewRules.ts index f85a13e..ead83f8 100644 --- a/vscode/src/codeReader/review/reviewRules.ts +++ b/vscode/src/codeReader/review/reviewRules.ts @@ -6,9 +6,11 @@ export function prNumberOf(issue: Pick): number | undefined { return Number.isInteger(pr) && pr > 0 ? pr : undefined } -/** 看板「审查」列里的工单,照看板列里的次序(工单号大的在前)。 */ -export function inReviewColumn>(issues: readonly T[]): T[] { - return issues.filter(issue => issue.column === 'review').sort((a, b) => b.number - a.number) +/** 看板「审查」列里的 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) } /** 这张工单(最新状态,工单已经不存在时 null)现在能不能审查:能时带上它和它的 PR 号,不能时给出原因(给人看的话)。 */ @@ -34,29 +36,6 @@ export function reviewablePullRequest, pr?: { number: number, files: FileCount, hasWorktree: boolean }): PickText { - const label = `#${issue.number} ${issue.title}` - if (!pr) - return { label, detail: '没有关联 PR' } - const files = 'count' in pr.files ? `改了 ${pr.files.count} 个文件` : `读不到改了哪些文件:${pr.files.error}` - const worktree = pr.hasWorktree ? '本机有工作副本' : '本机没有工作副本,选中后拉取 PR 分支' - return { label, description: `PR #${pr.number}`, detail: `${files} · ${worktree}` } -} - /** 审查时视图标题后面淡色的字(TreeView.description)。 */ export function reviewDescription(issueNumber: number): string { return `审查 #${issueNumber}` diff --git a/vscode/src/codeReader/review/reviewSource.ts b/vscode/src/codeReader/review/reviewSource.ts index ff2d252..de6d70f 100644 --- a/vscode/src/codeReader/review/reviewSource.ts +++ b/vscode/src/codeReader/review/reviewSource.ts @@ -1,18 +1,18 @@ /** - * 审查工单要的看板数据,全走看板自己的那套:工单和所在列用看板的加载(state JSON 定列、看板范围), - * 本机记录(worktree 路径)照看板叠上,PR 用 Gitea 的接口。 + * 审查工单要的看板数据,全走看板自己的那套:列工单用看板上现在摆着的,重读一张工单用看板的单张加载 + * (state JSON 定列),本机记录(worktree 路径)照看板叠上,PR 用 Gitea 的接口。 */ import type { ExtensionContext } from 'vscode' import type { GiteaPullRequest, PrFileDto } from '../../gitea/api' import type { RepoContext } from '../../gitea/repoContext' import type { Issue } from '../../gitea/types' -import type { KanbanScope } from '../../panel/kanbanScope' +import type { BoardIssues } from '../../panel/kanbanSnapshot' import { getPullRequest, GiteaApiError, listPullRequestFiles } from '../../gitea/api' -import { loadIssues, loadSingleIssue } from '../../gitea/issueLoader' +import { loadSingleIssue } from '../../gitea/issueLoader' import { resolveRepoContext } from '../../gitea/repoContext' import { overlayLocalIssueState } from '../../issues/localState' -import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from '../../panel/kanbanScope' +import { KanbanWebviewPanel } from '../../panel/KanbanPanel' import { inReviewColumn } from './reviewRules' /** 当前工作区的 Gitea 仓库和 token;没有时抛出给人看的错。 */ @@ -23,11 +23,13 @@ export async function requireRepo(context: ExtensionContext): Promise { - const scope = context.workspaceState.get(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE - const { issues } = await loadIssues({ ...repo, scope }) - return inReviewColumn(overlayLocalIssueState(context, issues, repo.workspaceRoot)) +/** + * 看板「审查」列里的工单:取看板上现在摆着的(用户看到的就是它),不再加载一遍; + * 看板还没加载过时 undefined。进审查时再单独重读选中的那张(loadIssue)。 + */ +export function reviewColumnIssues(context: ExtensionContext): BoardIssues | undefined { + const board = KanbanWebviewPanel.issuesOnBoard(context) + return board && { ...board, issues: inReviewColumn(board.issues) } } /** 一张工单的最新状态;工单已经不存在时 null。 */ diff --git a/vscode/src/codeReader/review/reviewWorktree.ts b/vscode/src/codeReader/review/reviewWorktree.ts index a3a672f..9703a66 100644 --- a/vscode/src/codeReader/review/reviewWorktree.ts +++ b/vscode/src/codeReader/review/reviewWorktree.ts @@ -14,10 +14,14 @@ function reviewWorktreePath(context: ExtensionContext, workspaceRoot: string, is return resolveWorktreeDir(template, workspaceRoot, reviewWorktreeSlug(issueNumber)) } -/** 本机这张工单的工作副本:看板记着的 worktree(盘上还在)优先,其次是之前审查它时建的;都没有时 undefined。 */ -export function localWorktree(context: ExtensionContext, workspaceRoot: string, issue: Pick): string | undefined { - if (issue.worktreePath && issue.worktreeExists) - return resolveWorktreePath(issue.worktreePath, workspaceRoot) +/** + * 本机这张工单的工作副本:看板记着的 worktree 优先,其次是之前审查它时建的;都没有时 undefined。 + * 在不在按盘上现在的看:看板快照里的 worktreeExists 是加载那会儿的。 + */ +export function localWorktree(context: ExtensionContext, workspaceRoot: string, issue: Pick): string | undefined { + const recorded = issue.worktreePath && resolveWorktreePath(issue.worktreePath, workspaceRoot) + if (recorded && existsSync(recorded)) + return recorded const review = reviewWorktreePath(context, workspaceRoot, issue.number) return existsSync(review) ? review : undefined } @@ -31,7 +35,7 @@ export function localWorktree(context: ExtensionContext, workspaceRoot: string, export async function prepareWorktree( context: ExtensionContext, repo: RepoContext, - issue: Pick, + issue: Pick, pull: { number: number, headSha: string }, step: (text: string) => void, ): Promise { diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 2a21bbf..fbaa31a 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -10,6 +10,7 @@ import type { IssueRef } from '../issues/stateRouter' import type { SessionTabKind } from './handlers/terminals' import type { IssueOwnershipFacts } from './issueOwnership' import type { KanbanScope } from './kanbanScope' +import type { BoardIssues } from './kanbanSnapshot' import type { ExtensionToWebview, WebviewToExtension } from './messages' import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode' import { deleteToken, getToken } from '../auth/secrets' @@ -44,7 +45,7 @@ import * as youtrackIssues from './handlers/youtrackIssues' import { PALETTE, resolveIssueColor, themeColorIdToIconUri } from './issueColor' import { isIssueMine } from './issueOwnership' import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from './kanbanScope' -import { KANBAN_SNAPSHOT_KEY, parseKanbanSnapshot, snapshotScriptTag } from './kanbanSnapshot' +import { boardIssues, KANBAN_SNAPSHOT_KEY, parseKanbanSnapshot, snapshotScriptTag } from './kanbanSnapshot' import { isOwnerOnlyMessage } from './readOnlyGuard' /** Resolved at runtime via getProfilesDir() — do not hardcode user paths. */ @@ -357,6 +358,11 @@ export class KanbanWebviewPanel { KanbanWebviewPanel.current?.loadAndPush().catch(() => {}) } + /** 看板上现在摆着的工单(见 boardIssues),给别处(代码阅读的「选工单」)直接用,不再加载一遍。 */ + static issuesOnBoard(context: ExtensionContext): BoardIssues | undefined { + return boardIssues(KanbanWebviewPanel.current?.snapshotIssues(), context.workspaceState.get(KANBAN_SNAPSHOT_KEY)) + } + /** * 同事工单的写操作最终拦截点。前端已按归属藏掉按钮,这里兜住快照过期 * 或工单刚被重新指派的竞态:不是我的工单就丢消息 + 提示,不落任何副作用。 diff --git a/vscode/src/panel/kanbanSnapshot.ts b/vscode/src/panel/kanbanSnapshot.ts index 8c16fca..f0d4ce8 100644 --- a/vscode/src/panel/kanbanSnapshot.ts +++ b/vscode/src/panel/kanbanSnapshot.ts @@ -34,6 +34,23 @@ export function parseKanbanSnapshot(raw: unknown): KanbanSnapshot | null { } } +/** 看板上现在摆着的工单,和它从哪来:面板这次会话里加载好的(loaded)、上次存下的快照(saved)。 */ +export interface BoardIssues { + issues: Issue[] + from: 'loaded' | 'saved' +} + +/** + * 看板上现在摆着的工单:面板这次会话里加载好了就用它最近一次加载的,否则用上次存下的快照 + * (重开窗口时看板先画的也是它);都没有时 undefined,看板还没加载过。 + */ +export function boardIssues(loaded: Issue[] | undefined, persisted: unknown): BoardIssues | undefined { + if (loaded) + return { issues: loaded, from: 'loaded' } + const snapshot = parseKanbanSnapshot(persisted) + return snapshot ? { issues: snapshot.issues, from: 'saved' } : undefined +} + /** 写入 HTML 的 JSON:小于号换成 \\u003c,避免 script 标签被提前关掉。 */ export function snapshotToScriptJson(snapshot: KanbanSnapshot): string { return JSON.stringify(snapshot).replace(/