Compare commits
9
Commits
3db44f84ae
...
1bb0850fa8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bb0850fa8 | ||
|
|
de05d13dab | ||
|
|
90268c7c67 | ||
|
|
7ac4e0fb21 | ||
|
|
39cde56de3 | ||
|
|
6120c85bc0 | ||
|
|
707d85c324 | ||
|
|
3b7ce69842 | ||
|
|
2a5806c6b5 |
No files matched your search
@@ -87,7 +87,7 @@
|
||||
|
||||
### 审查工单
|
||||
|
||||
看板「审查」列常压着好几张工单,逐张读 PR 改了什么时,侧栏只看这张工单的 PR 改过的文件(原型画板「审查 · 选工单」「审查 · 改动的文件」)。开发完了却没走自动审查的工单会停在「进行中」列,它们的 PR 一样要看,所以「进行中」列里已经有 PR 的也能选。范围就是原型画的这些:不标差异、不开 diff、没有「改动」筛选。
|
||||
看板「审查」列常压着好几张工单,逐张读 PR 改了什么时,侧栏只看这张工单的 PR 改过的文件(原型画板「审查 · 选工单」「审查 · 改动的文件」)。开发完了却没走自动审查的工单会停在「进行中」列,它们的 PR 一样要看,所以「进行中」列里已经有 PR 的也能选。不开 diff 编辑器、没有「改动」筛选;打开的预览标出这个文件相比 PR 目标分支改了什么,见「代码差异」。
|
||||
|
||||
- 能审查的列只有一条规则、写在一处(`reviewRules.ts` 的 `REVIEW_COLUMNS`:先「审查」、再「进行中」):弹框列哪些、选中 / 刷新 / 重开窗口时还能不能审查都按它。
|
||||
- 选工单:视图标题栏第一个按钮(`view/title` 的 `navigation@1`,`$(git-pull-request)`,命令 `superpowers.codeReader.pickReviewIssue`)。点开 VS Code 顶部的快速选择(`window.createQuickPick`):标题 `选工单 · 看板「审查」「进行中」列`,占位 `输入工单号或标题筛选`(按第一行筛)。工单马上列出来,各 PR 改了几个文件随后一项项补上,都补完才停止转圈(`busy`):
|
||||
@@ -176,6 +176,34 @@
|
||||
- 样子:筛选栏钉在预览顶部(`position: sticky`,块从它下面滚过去),横贯编辑区、下边一条 `--vscode-panel-border`、底色 `--vscode-editor-background`,里面的内容和代码块同宽对齐:「≡ 筛选」(宽 52px)、每个属性一个下拉框(宽 150px、高 24px,`--vscode-dropdown-background` / `--vscode-dropdown-border` / `--vscode-dropdown-foreground`)、右边汇总(`descriptionForeground`,「清除」`--vscode-textLink-foreground`)、再右边翻译下拉框;放不下就换行。下拉菜单盖在代码块上面:宽 200px,`--vscode-editorWidget-background`、`--vscode-editorWidget-border`、`--vscode-widget-shadow` 阴影;顶上「全选」「取消全选」两个链接(`--vscode-textLink-foreground`,间距 12px),下面 1px 分隔线(`--vscode-menu-separatorBackground`),列表太长时只滚列表、这一行不动;每项勾选框(勾着是 `--vscode-focusBorder` 底)、值、右边块数(`--vscode-editorLineNumber-foreground`);悬停、键盘高亮 `--vscode-list-hoverBackground`。
|
||||
- 键盘和读屏:下拉框按钮 `aria-haspopup="listbox"`、`aria-expanded`;菜单 `role="listbox"`、`aria-multiselectable`,每项 `role="option"`、`aria-selected` 表示勾着。按钮上 ↓ / 回车 / 空格打开(↑ 打开并停在最后一项),焦点落在列表上;列表里 ↑ ↓ Home End 移动,空格 / 回车勾选;Shift+Tab 回到「取消全选」「全选」(普通按钮,回车 / 空格触发),Tab 再回到列表;Esc 关掉并回到按钮;焦点离开下拉框(Tab 出去、点外面)就关。鼠标点选项、全选 / 取消全选时焦点留在列表上,方向键接着能用。
|
||||
|
||||
### 代码差异
|
||||
|
||||
审 AI 写的代码时,侧栏只列改动的文件(「未提交的更改」「选工单」审查),预览的卡片标出这个文件相比改之前加了什么、改了什么、删了什么(原型插件菜单「代码差异」:画板「代码差异 · 全部 / 只看改动 / 标记说明」)。普通文件树下预览和平时一样,不标。
|
||||
|
||||
- 对比基准(`diff/diffBase.ts` 的 `DiffBase`,由开着的侧栏筛选给:`TreeFilter.diffBase`,`TreeFilters` 转给预览):
|
||||
- 「未提交的更改」开着、文件在工作区的仓库里(`workspaceOrder`,审查的工作副本不算):对比上一次提交(HEAD),文案「对比:上一次提交」。
|
||||
- 审查工单、文件在这张工单的工作副本里:对比 PR 目标分支与 PR 头的合并基(Gitea PR 的 `merge_base`,没给时退回目标分支的提交,同看板「改动」tab)上的版本,改名的取改名前的路径(`reviewBaseFile`);文案「对比:PR 目标分支 <base 分支名>」(PR 的 `base.ref`)。合并基是 PR 头的祖先,拉 PR 的提交时已经在本机,直接在工作副本里取。
|
||||
- 取旧内容:`git/fileAt.ts` 的 `fileAtRef`(`git ls-tree` 看有没有这个文件、`git show <提交>:<路径>` 取内容,走 `runGit`);基准里没有这个文件(未跟踪、新加的、PR 里新加的、仓库还没有提交)不逐张标绿,顶部一条横幅「新文件,全部是新增」,后面淡色写原因(「还没被 git 跟踪,上一次提交里没有这个文件」「上一次提交里没有这个文件」「PR 目标分支 master 里没有这个文件」「仓库里还没有提交」)。
|
||||
- 什么时候比(`diff/previewDiff.ts` 的 `PreviewDiff`,每个预览一个):每发出一份文档(编辑、保存、块编辑写回、换翻译)之后按眼下的基准重新比;换了筛选、基准挪了(「未提交的更改」盯着的 git 状态变了,比如提交后 HEAD 动了;审查进 / 重读工单)打开着的预览也重新比。预览先照常画文档,差异算好了再补发(`diff` 消息),打开不变慢。取过的旧内容按基准的 key(提交 + 路径)记着,编辑时不用每次问 git;旧版本每次用同一个语言插件、翻译插件、库规则重新排成视图数据,规则查定义、调用的种类用这份文档眼下查到的答案,两边同样的写法翻得一样。取不到旧内容、旧版本解析失败时记日志、不标。
|
||||
- 块怎么对上(`diff/blockDiff.ts`,纯函数):同一父块下的兄弟块对齐——有名字的块(`defines`:函数名、类名、只声明一个变量时的变量名、规则卡片讲的名字)按「种类 + 名字」配,同名的照先后,挪了位置也配得上;没名字的块(语句、调用、注释……)以此为锚,最长公共子序列找出种类、源码都没变的,夹在相邻两个锚中间的按先后找种类相同、内容最像(按词比,相似度到一半)的配成修改。配不上的旧有新无算删除、旧无新有算新增;改名算一删一增。配上的块整段源码(去共同缩进)一样算没变,否则算修改,接着比子块;自己的内容改了、或源码变了子块却都没变的,是「自己改了」(上一处 / 下一处、总览条停在它上面)。
|
||||
- 卡片里标什么(`diff/viewDiff.ts`,纯函数):修改的卡片在渲染用的视图数据这一层比(翻译好的属性表、一行译文、摊开实参的表、说明),所有语言、库规则的卡片走同一条路:
|
||||
- 属性表按属性名配行;配上的行值变了:着色的词按词(一段着色的词一个)对齐,大部分没变时逐截标(新加的一截绿底、删掉的一截红底划掉),没变的不到四成时整段写成旧值(红底划掉)紧跟新值(绿底);说明这类 Markdown 按词比,中文按字、标点各算一个(`diffWordsWithSpace`)。带子行的行子行改了时只标子行,值(「3 个」「字典 2 项」这类汇总)跟着变、不另标——原型里参数多了一行时「输入参数」的个数也没标。
|
||||
- 子行(参数表、导入项、字典的各项、摊开实参的表)按第一列的字配对:多出来的(新参数)整行淡绿底、左边 +,没了的放回原来的位置、整行淡红底划掉、左边 −;配上的逐格比。有加行、删行的表格每行第一格前多一个记号位(+ / −,没变的空着),列照样对齐。
|
||||
- 多出来的属性行、没了的属性行同样整行淡绿 / 淡红底,+ / − 画在行左边外侧。删掉的内容取自旧版本,词去掉源码下标(不能跳定义、不参与高亮)。
|
||||
- 删掉的块:放回原来的位置——旧版本里它前面最近一个配上的兄弟所配的新块后面,再越过紧跟着的新增块(新增的在前、删掉的在后,原型里删掉的 `_is_owner` 在新增的 `renew` 和 `release` 之间);排在最前面的在这一层最前面。子块里删掉的挂在父块下面,整块删掉的子块不再单列。
|
||||
- 统计「新增 N · 修改 N · 删除 N」只数最外层带标记的块:新增块的子块、修改块里改动的子块不再数(原型的 2 / 3 / 1)。
|
||||
- 页面(原型组件 `Reader / DiffBar`、`Reader / UnchangedStrip`、`Reader / OverviewRuler`、`Reader / NewFileBanner`、`Diff / 已删除`):
|
||||
- 卡片左边外侧 3px 的改动色条(和卡片等高,间隔 5px):新增绿、修改蓝、删除红,没变的留一条透明的,同一层照样对齐;子卡片各带各的。悬停色条提示「新增 / 修改 / 删除」。
|
||||
- 新增的卡片连同子卡片底色淡绿(10%),最外面那张左上角压着「新增」小标(绿底白字 10px)。修改的卡片只有蓝条,里面按上面标。
|
||||
- 删除卡片:红虚线边、淡红底、半透明,没有编辑、问 AI 按钮;默认收成一行「已删除 · <种类> <名字>」(名字是属性表第一行的值,或译文的第一行;种类名取旧版本的筛选属性名),点箭头或这一行展开,下面是旧内容、整张划掉;子块不画。按块筛选同样藏。
|
||||
- 标了改动的块默认展开(函数、类本来默认收起子块),用户点过的照他的。
|
||||
- 筛选栏第二行(高 30px,只有有差异数据时才有):比较图标、「对比:<基准>」、「·」、统计(各按改动色,没有的那种不写,都没有写「没有改动」),右边「只看改动」开关、上一处 / 下一处(↑ ↓,悬停提示、`aria-label`)。基准里没有这个文件时只写基准。
|
||||
- 只看改动:每一层连着没变的块折成一条「… N 块没变 点开」(虚线框),被筛掉的块不算也不打断,删掉的块在哪儿就在哪儿断开;改过的块照常,它下面没变的子块同样折;点开一段就恢复这一段(筛选一变重新折)。开关按工作区记住(`BlockFilter`,`workspaceState` 的 `superpowers.codeReader.onlyChanges`),所有预览共用;跳到定义落在折起来的块上时临时露出来。
|
||||
- 上一处 / 下一处:在各处改动(新增的最外面那块、自己改了的修改块、删掉的块,照页面先后,被筛掉的不算)之间跳,展开折住它的块,滚到筛选栏下面、闪几下;跳完没滚过页面就接着刚跳到的那处走,自己滚过了按视口顶上那块的行找;到头绕回另一头。
|
||||
- 总览条:预览右边缘 14px 宽、从筛选栏下面贴到底,每处改动按它在新文件里的行画一段刻度(修改块只画它自己那几行,删掉的块一条 2px 细线),点刻度跳过去(同上一处 / 下一处)。
|
||||
- 文档刚改、差异还没重算时:块按记号认,没动过的块照样带色条;卡片里的标记按下标认,只在页面上正是差异算的那一版时画。
|
||||
- 颜色:取 VS Code 编辑器行号旁的改动色带 `--vscode-editorGutter-addedBackground` / `modifiedBackground` / `deletedBackground`(主题没给时用 Dark Modern 的默认值),行内增删的字用 `--vscode-diffEditor-insertedTextBackground` / `removedTextBackground`;整行底色是改动色的 14%,新增卡片底色 10%。
|
||||
|
||||
### 属性表(函数、类、枚举、异常、导入、常量、变量块)
|
||||
|
||||
卡片外观(折叠箭头、种类图标、底色、悬停高亮、「编辑 / 预览」切换)和其他块一样,只是内容换成一张属性表:左列属性名(界面字体、`descriptionForeground` 色,宽 76px,这张表里有更长的属性名时整列按最长的那个放宽,值照样对齐在同一处,属性名不折行:shell 的「管道里任一步失败都算失败」),右列是值(等宽字体,按词性着色,长了换行),两列间距 12px,行高 20px。「输入参数」「导入项」和值是字典 / 对象字面量的「值」「初始值」下面展开子行,属性名前带折叠箭头(图标、大小、颜色同块的折叠箭头;点箭头或属性名切换,默认展开,折叠后值「N 个」「字典 N 项」照常显示,只收起子行);子行往右缩进 5px,左边一条 1px 竖线(`editorIndentGuide-background1`);输入参数例外,是一张表格(见下)。字典 / 对象的子行分键 / 值两列,键列宽 150px(编辑区窄时按 28% 收),值列占剩下的。子行下面还能再展开一层(嵌套的字典 / 对象):带下一层的子行第一列前面有折叠箭头(点箭头或第一列切换,默认展开),同一层里其余子行在同样的位置留空、第一列对齐;下一层同样往右缩进 5px、左边一条竖线,竖线对着上一层的箭头,每层自己的两列对齐。同一层里没有带下一层的子行时不留箭头的位置(和导入项的子行一样)。
|
||||
@@ -499,7 +527,7 @@
|
||||
- profile 下拉(同筛选栏的下拉 `SelectMenu`,`ghost` 样子):只写选中的 profile 名和一个小箭头,灰字 12px、高 20px,最宽 140px,长名字截断,输入框还是主角;悬停 `--vscode-toolbar-hoverBackground`,打开时 `--vscode-toolbar-activeBackground`,键盘聚焦描 1px `--vscode-focusBorder`。点开(或聚焦后按 ↓ / ↑ / Enter / 空格)列出能用的 profile,↑ ↓ 挑、Enter 选、Esc 关;profile 多了弹层顶上有搜索框。悬停提示「用哪个 Claude Code 配置(profile)回答,下一个问题起用它」。
|
||||
- 选项是插件的 profile 目录(同看板、会话:`cc/profiles` 的 `listClaudeProfiles`,名字是文件名去掉 `.json`)。插件的默认 profile(`getDefaultProfilePath`,`offical.json`)在里面时,没选过就选它;不在(一个 profile 都没有,或没有 `offical.json`)时最前面多一项「默认」:不带 `--settings`,claude 用 `~/.claude` 的登录,和以前一样照问,没选过就选它。
|
||||
- 选了从下一个问题起用它(正在答的不受影响)。所有预览、所有块共用一份,记在扩展的 `globalState`(`superpowers.codeReader.askProfile`,记 profile 名,「默认」记 `''`)里:换个预览、重启 VS Code 都还是它;记着的那个没了(删了、目录换了)就回到默认那项。
|
||||
- 没展开、但问过的块:块下面只露一行灰字「问过 N 次」,读的时候一眼知道这里问过;点开看灰字历史(能展开、能删),没有输入框。
|
||||
- 没展开、但问过的块:块下面一行灰字「问过 N 次」,下面默认摊开灰字历史(能展开、能删),读的时候一眼看到这里问过什么;点这一行收起历史、再点摊开,箭头跟着换;没有输入框。
|
||||
- 收起就结束这一轮:这一轮答完的都回到灰字历史里(再问照样接着会话,见「追问」);还没答完的接着答,答完照样存。同一块同时只答一个问题(正在答时不能再发),不同块可以同时问,全插件同时最多 3 个,再多就在这一问下面说「同时最多问 3 个问题,等前面的答完再问」。
|
||||
- 追问:这块存过问答,再问就接着最近一次(按问的时间)存下的那条的会话(`--resume <会话 id>`,会话 id 取 stream-json 的 init 消息,和问答一起存在 `code-qa.json` 里),只发问题和「还是第几行那块」;收起再展开、预览重新加载、重启 VS Code 都一样接得上,前面问过的照旧灰字显示。换了 profile 也照样接着那次会话问(`--resume` 和这次选的 `--settings` 一起带,本机 claude 2.1 试过:换 profile 后会话 id 不变、记得前面说过的),线程里不另外提示。那条没记会话 id、或会话找不到了(过期、被清理)就按第一次那样带上代码上下文重新开会话。删掉最近那条,就接着剩下里最近的;一块下面删光了就是没问过。
|
||||
- 删除:点垃圾桶马上删,不用确认;一块下面删光了这块就不挂了,一份文件删光了文件里就没有这份文件。那条带的图片文件一起删。
|
||||
@@ -1643,7 +1671,8 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTab
|
||||
- `{ type: 'occurrencesFound', id, occurrences: { start, end, kind: 'text' | 'read' | 'write' }[] }`:回答第 `id` 次 `findOccurrences`,`[start, end)` 是源码下标,`kind` 同 `DocumentHighlightKind`;语言扩展没答就是空数组。
|
||||
- `{ type: 'moveOccurrence', step: 1 | -1 }`:两条命令发来,跳到下一处 / 上一处出现。
|
||||
- `{ type: 'displayNames', version, names: ShownName[] }`:version 这份文档里显示成中文名的名字,查到一步发一步;页面只收自己眼下这个版本的。
|
||||
- `{ type: 'filter', excluded: Record<属性 id, 值 id[]> }`:整个工作区共用的块筛选。webview 发 `ready` 后先于第一份文档发一次(第一份文档按筛选画),之后任何一个预览改了都发给所有开着的预览(包括改的那个)。
|
||||
- `{ type: 'filter', excluded: Record<属性 id, 值 id[]>, onlyChanges }`:整个工作区共用的块筛选和「只看改动」开关(见「代码差异」)。webview 发 `ready` 后先于第一份文档发一次(第一份文档按筛选画),之后任何一个预览改了都发给所有开着的预览(包括改的那个)。
|
||||
- `{ type: 'diff', diff: DocumentDiff | null }`:这份文件相比对比基准改了什么(见「代码差异」),每发一份文档之后算好了补发,换了筛选、基准挪了再发;`null` 是不比了。`DocumentDiff` 是 `{ base, version, newFile?, blocks: Record<块记号, { change: 'add' | 'mod', outermost?, own?, marks? }>, removed: { parent, after?, block, line, kindName }[], stats: { add, mod, del }, lineCount }`:块按记号认;`marks` 是卡片里要标的(属性表各行、子行、一行译文、摊开实参的表、说明的增删,行和子行按下标,只在页面上正是 `version` 这一版时画);删掉的块 `block` 是旧版本的视图数据(只带它自己的内容、词不带下标),`parent` / `after` 是它挂在哪块下面、排在哪块后面(记号),`line` 是它在新文件里的位置。
|
||||
- `{ type: 'qa', available, threads: { anchor, entries: { id, question, answer, createdAt, profile?, images? }[] }[], running: { askId, anchor, question, text, activity? }[] }`:这份文件的问答(见「问 AI」)。`ready` 后发一次,存的问答、正在答的问题变了再发给开着这份文件的所有预览;`available` 为假时文件不在工作区文件夹里,不给「问 AI」;`images` 是问题带的图片在预览里能直接用的地址(`webview.asWebviewUri`);`running` 让重新加载的页面接上还在答的问题(不带图片,接上的这一问不显示缩略图,答完回到历史里照常有)。
|
||||
- `{ type: 'askProfiles', options: { id, label }[], selected }`:「问 AI」的 profile 下拉(`id` 是 profile 名,「默认」的 `id` 是 `''`、`label` 是「默认」)。`ready` 后发一次(每次现读 profile 目录),任何一个预览选了都发给所有开着的预览(包括选的那个)。
|
||||
- `{ type: 'answer', askId, text, activity? }`:`askId` 那个问题的回答长了(`text` 是到现在为止的全文)或换了在做的事。webview 攒着、最多每 80ms 画一次。
|
||||
@@ -1657,6 +1686,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTab
|
||||
- `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。
|
||||
- `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。
|
||||
- `{ type: 'setFilter', excluded }`:改块筛选(整份替换);扩展端逐项核对后记进 `workspaceState`,再发 `filter` 给所有预览。webview 不在本地先改,只按扩展端发来的显示。
|
||||
- `{ type: 'setOnlyChanges', on }`:打开 / 关掉「只看改动」;扩展端记进 `workspaceState`(`superpowers.codeReader.onlyChanges`),再发 `filter` 给所有预览。
|
||||
- `{ type: 'ask', askId, anchor, question, images: { mediaType, base64 }[] }`:问 `anchor` 那块;`askId` 由 webview 起;`images` 是贴进输入框的图片(没贴是空的,`base64` 不带 `data:` 前缀)。扩展端按最近一次发出去的块树和源码找这块、拼问题,这块存过问答就接着最近那条的会话追问(见「问 AI」的追问),用扩展端记着的 profile;找不到(代码刚改过)、图片格式不认或问不了(这块正在答、同时答的太多)时直接回 `answerEnd` failed。
|
||||
- `{ type: 'stopAsk', askId }`:叫停,结束 claude 进程,这次不存。
|
||||
- `{ type: 'deleteQa', anchor, entryId }`:删掉一次问答。
|
||||
@@ -1743,7 +1773,14 @@ vscode/src/codeReader/
|
||||
(panel/kanbanSnapshot.ts 的 boardIssues:看板上现在摆着的工单——这次加载的,否则存下的快照;KanbanWebviewPanel.issuesOnBoard 接上面板和 workspaceState)
|
||||
review/reviewWorktree.ts 本机这张工单的工作副本在哪、备好 PR 最新的代码(已有的快进、没有的建)
|
||||
review/changeDecorations.ts FileDecorationProvider:审查时给改过的文件上色、挂徽标
|
||||
review/reviewMode.ts ReviewMode:选工单弹框、进出审查、刷新、按工作区记住
|
||||
review/reviewMode.ts ReviewMode:选工单弹框、进出审查、刷新、按工作区记住,审查时预览对比什么(diffBase)
|
||||
uncommitted/uncommittedMode.ts UncommittedMode:「未提交的更改」进出、盯 git 状态重摆,开着时预览对比上一次提交(diffBase);纯函数在 uncommittedFiles.ts
|
||||
explorer/treeFilters.ts TreeFilters:侧栏的几种筛选同时只开一种,开着的那种定预览对比什么,换了筛选、基准变了通知预览
|
||||
diff/diffBase.ts DiffBase:对比基准(文案、旧内容怎么取、缓存用的 key、基准里没有这个文件时的说明)
|
||||
diff/blockDiff.ts 两棵块树比:兄弟块对齐(名字 / 最长公共子序列 / 相似度)、新增修改删除、删掉的块放哪、统计(纯函数)
|
||||
diff/viewDiff.ts 一块卡片里的内容比:属性表按属性名、子行按第一列、着色的词逐截、Markdown 逐词;删掉的块只留自己的内容、去掉源码下标(纯函数)
|
||||
diff/previewDiff.ts PreviewDiff:一个预览的差异,发完文档再比、旧内容按 key 记着、只发最后一次的结果;DocumentDiff 消息的形状(不碰 VS Code)
|
||||
(git/fileAt.ts 的 fileAtRef:某个提交上某个文件的内容,没有这个文件是 null)
|
||||
(gitea/repoContext.ts 的 resolveRepoContext:当前工作区的 Gitea 仓库和 token,看板「改动」tab 共用;git/worktree.ts 的 fetchPullRequestHead / addDetachedWorktree / fastForwardWorktree:拉 PR 的提交、建不挂分支的 worktree、不丢东西地快进)
|
||||
pathMap.ts 按文件 / 目录记东西的表(文件注释、问答共用):路径的键、改名 / 挪位置 / 删除时怎么改、.spx 里 JSON 的读写、条目带着的文件哪些不再有人带、删文件(不碰 VS Code)
|
||||
folderStore.ts FolderStore:每个工作区文件夹一份表,内存里的一份、文件监听、跟着改名 / 删除走、读写排队、改表后删掉不再有条目带着的文件
|
||||
@@ -1764,14 +1801,14 @@ vscode/src/codeReader/
|
||||
(cc/spawnClaude.ts 的 runClaudeLines:跑 claude、给了 input 就写进 stdin、stdout 逐行交出、超时、叫停,问 AI 用它;buildStreamJsonLine 拼 stream-json 的一条用户消息)
|
||||
editor/previewEditor.ts CustomTextEditorProvider
|
||||
editor/messages.ts 消息类型
|
||||
editor/blockFilter.ts 块筛选:workspaceState 读写、通知所有预览
|
||||
editor/blockFilter.ts 块筛选和「只看改动」开关:workspaceState 读写、通知所有预览
|
||||
editor/definitions.ts 查定义(vscode.executeDefinitionProvider)、规则查到的位置换成 VS Code 的、几处定义的挑选框
|
||||
editor/definitionSources.ts 名字的定义去哪儿查:文档里规则标的引用问规则,其余问语言扩展
|
||||
editor/definitionAction.ts 查到的定义 → 怎么跳(纯函数)
|
||||
editor/occurrences.ts 查名字的各处出现(vscode.executeDocumentHighlights)
|
||||
editor/occurrenceNavigation.ts 跳到下一处 / 上一处高亮:命令、上下文键
|
||||
index.ts registerCodeReader(context): CodeReaderApi
|
||||
vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,悬停提示在 src/components/ui/tooltip.tsx(和看板共用,页面根上挂一次),块的种类图标(这组图标名 → 图标组件、内置种类 → 图标名,都只在这一处)在 kindIcons.tsx,属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,中文名换字在 displayNames.ts,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts,问 AI 的问答区在 AskThread.tsx、状态和规则在 qa.ts、贴图拖图的规则在 qaImages.ts、收发在 useQa.ts)
|
||||
vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,悬停提示在 src/components/ui/tooltip.tsx(和看板共用,页面根上挂一次),块的种类图标(这组图标名 → 图标组件、内置种类 → 图标名,都只在这一处)在 kindIcons.tsx,属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,中文名换字在 displayNames.ts,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts,问 AI 的问答区在 AskThread.tsx、状态和规则在 qa.ts、贴图拖图的规则在 qaImages.ts、收发在 useQa.ts;代码差异:页面上怎么标(块 id、删掉的块挂哪、只看改动折哪几段、各处改动、上一处 / 下一处跳到哪)在 diffView.ts,筛选栏第二行和新文件横幅在 DiffBar.tsx,卡片里的增删在 DiffText.tsx(Markdown 的增删在 markdown.ts 的 renderMarkdownDiff),总览条在 OverviewRuler.tsx,跳到一处改动在 useChangeNav.ts)
|
||||
```
|
||||
|
||||
## 测试
|
||||
@@ -1830,6 +1867,12 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit
|
||||
- 规则和字(`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 往前走了且干净 → 快进,已经最新 / 本地在它之上 → 不动,只有未跟踪的文件照样快进,有没提交的改动、本地另有提交 → 不动且改动留着,目录不是工作副本的根 → 报错、外面的仓库不动。
|
||||
- 代码差异:
|
||||
- 块对齐和整棵树(`diff/blockDiff.test.ts`,真实的 Python 解析):原型的 `mutex.py`(改之前是编的)比出「新增 2 · 修改 3 · 删除 1」,新增块的子孙跟着算新增、只有最外面挂小标,`try_acquire` 自己改了、带属性表的标记,子块 `token` 改了、`ok` 和返回没变,`release` 只有子块改了(不算自己改了),删掉的 `_is_owner` 排在新增的 `renew` 后面、只带自己的内容、没有源码下标;没变的文件没有改动;子块里删掉的排在最前面时没有 after。对齐:有名字的按种类 + 名字配(挪了位置也配、同名照先后、种类不同不配、改名一删一增),没名字的按最长公共子序列和相似度配,种类不同不按相似度配。
|
||||
- 卡片里的标记(`diff/viewDiff.test.ts`):只是源码下标不同不算改;大部分没变时逐截标、删掉的去掉下标,改得多整段旧值 → 新值;框按样式、框头、框里的词比;Markdown 按词、中文按字;一种文字换成另一种按字比;子行按第一列配(加行、删行放回原来的位置、改了的只标那一格、新加的行下面各层也是新加的);属性行按属性名配、子行改了不另标值;一行译文、摊开实参的表、说明各比各的;属性表和一行译文没法比。
|
||||
- 什么时候比(`diff/previewDiff.test.ts`):比完发出去(统计、删掉的块带种类名、一共几行);基准里没有这个文件是新文件;没有基准不发、发过后基准没了发一次 null;旧内容按 key 记着、key 变了重取;同一份文档同一个基准不再比;取不到旧内容记一笔、清掉、下次重取;慢的旧结果不盖掉新的。
|
||||
- 取旧内容(`git/fileAt.test.ts`,真的 git):取的是提交里的、不是工作区改过的;提交里没有这个文件是 null;提交不存在抛错。对比基准的字(`uncommittedFiles.test.ts`、`reviewRules.test.ts`、`changedFiles.test.ts`):未跟踪的按状态认(两种 `git.untrackedChanges` 设置都认)、上一次提交里没有时的三种说明;PR 目标分支的文案;改名的取改名前的路径、PR 里新加的目标分支上没有。「只看改动」开关(`editor/blockFilter.test.ts`):记进 `workspaceState`、关了不记、改了发给所有预览带着眼下的块筛选。
|
||||
- 页面(`diffView.test.ts`、`markdown.test.ts`、`viewState.test.ts`):块按记号认成页面上的 id、找不到的不标、不是这一版不画卡片里的标记,删掉的块按层挂、找不到挂处的和被筛掉的不画,新文件不逐块标,标了改动的默认展开;统计的字;删掉的块收起时的名字;只看改动折哪几段(改过的块下面按层折、删掉的块打断、被筛掉的不算、点开过的和要露出来的不折);各处改动的先后和行、被筛掉的不算;上一处 / 下一处接着走、按视口顶上的行找、到头绕回;带增删的 Markdown 照常排版、跨行逐行包、新加的列表项留住记号。
|
||||
- 复制出什么(`explorer/copyText.test.ts`):文件、目录的名字 / 相对路径 / 绝对路径;不带后缀只去最后一个扩展名(`a.test.ts`、`archive.tar.gz`),`.env` 原样、`.env.local` → `.env`、没有扩展名的原样;相对路径总用 `/`,绝对路径照本机写法(Windows 反斜杠)。
|
||||
- 块右上角的图标按钮(webview `BlockTools.test.tsx`,渲染成 HTML):一组一个按钮、没有字、悬停提示(`data-tooltip`,没有原生 `title`)和 `aria-label` 是「编辑」/「预览」、有图标、编辑时带按下标记;给了「问 AI」时它排在前面、只有图标、问答区展开时带按下标记和 `aria-expanded`。
|
||||
- 块的种类图标(webview `kindIcons.test.tsx`,渲染成 HTML):内置种类的图标和原型一一对应;这组里每个名字画的就是 lucide 里同名的图标;webview 配了图标的名字和契约的 `KIND_ICON_NAMES` 一样、没有多的(`kindIconNames.test.ts`);只有图标、没有字,`role="img"`,悬停提示(`data-tooltip`,没有原生 `title`) 和 `aria-label` 是种类名,20px 的框、16px 的图形;库规则的卡片用规则给的图标。各库规则的测试核对 `badge` 的名字、颜色、图标。
|
||||
@@ -1844,7 +1887,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit
|
||||
- stream-json(`qa/answerStream.test.ts`,照本机 claude 2.1 的输出截的):会话 id、逐字的回答、读文件、新回复换掉前面那句、没开逐字片段时一样、一条回复里两段空一行、result 没带回答时用眼下的、失败的原因、坏行和子代理的消息不算。
|
||||
- 正在答的问题(`qa/asks.test.ts`):进展、答完、叫停(结束进程、报 stopped、这块能再问)、出错、同一块只答一个、最多几个、全部叫停。
|
||||
- 跑 claude(`cc/spawnClaude.test.ts`,假的 claude 脚本):逐行交出、最后没换行的那段、参数和 cwd、给了 input 写进 stdin 再关上 / 不给时 stdin 是空的不卡住、退出码非零带原话、叫停马上结束进程、超时。
|
||||
- 页面(`qa.test.ts`、`AskThread.test.tsx`):问一次、答完记下存成的那条、收起结束这一轮、重新加载接上还在答的、停止和失败、不认识的消息不理;问的时间、回答的前两行;问答区没问过不画、没展开只露「问过 N 次」、展开的历史 / 当前这一轮 / 输入框、出错和停止;profile 下拉在输入框和发送按钮中间、写选中的名字、是能用键盘打开的弹出列表按钮、没收到选项时写「默认」,扩展端发来的下拉问答刷新不丢;历史每条的灰字写「时间 · profile 名」,没有 profile 的只写时间;带图的历史和当前这一轮在问题下面、回答前面一排缩略图(能点开看大图、不能去掉),没带图的不画;这一问带的图片答完、失败、停止都还在,重新加载接上的没有。贴图拖图的规则(`qaImages.test.ts`):剪贴板里只取文件、文字不算;四种格式、5 MB(正好 5 MB 的算)、连同已有最多 6 张,没加上的按原因合成一句提示;发出去的 base64 去掉 `data:` 前缀。(webview 的单元测试认 `@/`,靠 `vscode/vitest.config.mts` 里同 Vite 的别名。)
|
||||
- 页面(`qa.test.ts`、`AskThread.test.tsx`):问一次、答完记下存成的那条、收起结束这一轮、重新加载接上还在答的、停止和失败、不认识的消息不理;问的时间、回答的前两行;问答区没问过不画、没展开时「问过 N 次」下面默认摊开历史(没有输入框)、展开的历史 / 当前这一轮 / 输入框、出错和停止;profile 下拉在输入框和发送按钮中间、写选中的名字、是能用键盘打开的弹出列表按钮、没收到选项时写「默认」,扩展端发来的下拉问答刷新不丢;历史每条的灰字写「时间 · profile 名」,没有 profile 的只写时间;带图的历史和当前这一轮在问题下面、回答前面一排缩略图(能点开看大图、不能去掉),没带图的不画;这一问带的图片答完、失败、停止都还在,重新加载接上的没有。贴图拖图的规则(`qaImages.test.ts`):剪贴板里只取文件、文字不算;四种格式、5 MB(正好 5 MB 的算)、连同已有最多 6 张,没加上的按原因合成一句提示;发出去的 base64 去掉 `data:` 前缀。(webview 的单元测试认 `@/`,靠 `vscode/vitest.config.mts` 里同 Vite 的别名。)
|
||||
- 页面上贴图(无头 Chrome,模拟粘贴、拖放、按键):粘贴图片加缩略图且不拦文字、只粘文字不加图;太大、不是图片、超过张数不加并提示;✕ 去掉后光标回到输入框;点缩略图出大图、焦点在「关闭」、Esc 只关大图不收起问答区、焦点回到缩略图,点一下也关;拖进来的图加上;Shift+Enter 不发送、Enter 发送带上图片、发送后缩略图清空、这一问下面显示它带的图;输入框里 Esc 照旧收起。亮、暗两套颜色都看过。
|
||||
|
||||
## 收尾
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"publisher": "clurdra",
|
||||
"name": "superpowers-vscode-clurdra",
|
||||
"displayName": "Superpowers-clurdra",
|
||||
"version": "0.2.211",
|
||||
"version": "0.2.213",
|
||||
"packageManager": "pnpm@10.27.0",
|
||||
"description": "Superpowers specs and plans Kanban explorer",
|
||||
"author": "clurdra",
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { SourcedBlocks } from './blockDiff'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readBlockViews } from '../blockView'
|
||||
import { createPythonAdapter } from '../languages/python'
|
||||
import { runtime, WASM_DIR } from '../testSupport'
|
||||
import { pythonZhTranslator } from '../translators/pythonZh'
|
||||
import { alignSiblings, diffBlockTrees, similarity } from './blockDiff'
|
||||
|
||||
const py = createPythonAdapter(WASM_DIR)
|
||||
|
||||
async function python(source: string): Promise<SourcedBlocks> {
|
||||
const { blocks } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source)
|
||||
return { blocks, source }
|
||||
}
|
||||
|
||||
/** 手写的一块:只填比对用得到的几样。 */
|
||||
function view(anchor: string, kind: string, code: string, extra: Partial<BlockView> = {}): BlockView {
|
||||
return {
|
||||
id: anchor,
|
||||
anchor,
|
||||
kind,
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
headerEndLine: 0,
|
||||
code,
|
||||
codeTokens: [],
|
||||
attributes: { kind },
|
||||
children: [],
|
||||
translation: [{ text: code, role: 'plain' }],
|
||||
...extra,
|
||||
} as BlockView
|
||||
}
|
||||
|
||||
/** 名字写在源码开头:defines 指向它。 */
|
||||
function named(anchor: string, kind: string, name: string, code: string, source: { text: string }): BlockView {
|
||||
const startIndex = source.text.length
|
||||
source.text += name
|
||||
return view(anchor, kind, code, { defines: { startIndex, endIndex: startIndex + name.length } })
|
||||
}
|
||||
|
||||
const RELEASE_LUA = `_RELEASE_LUA = """
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('DEL', KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
"""`
|
||||
|
||||
const BEFORE = `"""Redis 互斥锁: SET NX EX 抢锁, Lua 比对 token 再删."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
${RELEASE_LUA}
|
||||
|
||||
|
||||
async def try_acquire(redis: Any, key: str, ttl_sec: int) -> bool:
|
||||
"""抢锁; 抢到返回持有者 token, 已被占用返回 None."""
|
||||
token = uuid.uuid4().hex
|
||||
ok = await redis.set(key, token, nx=True, ex=ttl_sec)
|
||||
return token if ok else None
|
||||
|
||||
|
||||
async def _is_owner(redis: Any, key: str, token: str) -> bool:
|
||||
"""锁仍归 token 时返回真."""
|
||||
return await redis.get(key) == token
|
||||
|
||||
|
||||
async def release(redis: Any, key: str, token: str) -> bool:
|
||||
"""只在锁仍归 token 时删掉; 返回是否真的删了."""
|
||||
result = await redis.eval(_RELEASE_LUA, 1, key)
|
||||
return int(result) == 1
|
||||
`
|
||||
|
||||
const AFTER = `"""Redis 互斥锁: SET NX EX 抢锁, Lua 比对 token 再续期 / 再删."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
${RELEASE_LUA}
|
||||
|
||||
_RENEW_LUA = """
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('EXPIRE', KEYS[1], ARGV[2])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
"""
|
||||
|
||||
|
||||
async def try_acquire(redis: Any, key: str, ttl_sec: int, token_prefix: str = "") -> str | None:
|
||||
"""抢锁; 抢到返回持有者 token, 已被占用返回 None."""
|
||||
token = f"{token_prefix}{uuid.uuid4().hex}"
|
||||
ok = await redis.set(key, token, nx=True, ex=ttl_sec)
|
||||
return token if ok else None
|
||||
|
||||
|
||||
async def renew(redis: Any, key: str, token: str, ttl_sec: int) -> bool:
|
||||
"""只在锁仍归 token 时把 TTL 续回 ttl_sec; 返回是否续上."""
|
||||
result = await redis.eval(_RENEW_LUA, 1, key, token, str(ttl_sec))
|
||||
return int(result) == 1
|
||||
|
||||
|
||||
async def release(redis: Any, key: str, token: str) -> bool:
|
||||
"""只在锁仍归 token 时删掉; 返回是否真的删了."""
|
||||
result = await redis.eval(_RELEASE_LUA, 1, key, token)
|
||||
return int(result) == 1
|
||||
`
|
||||
|
||||
describe('similarity', () => {
|
||||
it('一样是 1,毫不相干是 0,改了一个词在中间', () => {
|
||||
expect(similarity('a = 1', 'a = 1')).toBe(1)
|
||||
expect(similarity('foo(bar)', 'x')).toBe(0)
|
||||
const score = similarity('print(a, b)', 'print(a, c)')
|
||||
expect(score).toBeGreaterThan(0.5)
|
||||
expect(score).toBeLessThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('alignSiblings', () => {
|
||||
it('有名字的按种类 + 名字配,挪了位置也配得上;改名算一删一增', () => {
|
||||
const source = { text: '' }
|
||||
const before = [named('f', 'function', 'f', 'def f(): pass', source), named('g', 'function', 'g', 'def g(): pass', source)]
|
||||
const after = [named('g2', 'function', 'g', 'def g(): return 1', source), named('h', 'function', 'h', 'def f(): pass', source)]
|
||||
const { toOld, toNew } = alignSiblings(before, source.text, after, source.text)
|
||||
expect(toOld).toEqual([1, undefined])
|
||||
expect(toNew).toEqual([undefined, 0])
|
||||
})
|
||||
|
||||
it('同名的照先后配', () => {
|
||||
const source = { text: '' }
|
||||
const before = [named('a', 'function', 'f', 'one', source), named('b', 'function', 'f', 'two', source)]
|
||||
const after = [named('c', 'function', 'f', 'one', source), named('d', 'function', 'f', 'two!', source)]
|
||||
expect(alignSiblings(before, source.text, after, source.text).toOld).toEqual([0, 1])
|
||||
})
|
||||
|
||||
it('种类不同的同名块不配', () => {
|
||||
const source = { text: '' }
|
||||
const before = [named('a', 'const', 'x', 'const x = 1', source)]
|
||||
const after = [named('b', 'variable', 'x', 'let x = 1', source)]
|
||||
expect(alignSiblings(before, source.text, after, source.text).toOld).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('没名字的:源码没变的按最长公共子序列配,中间的按相似度配成修改,不像的算一删一增', () => {
|
||||
const before = [view('1', 'statement', 'print(a)'), view('2', 'statement', 'call(alpha, beta, gamma)'), view('3', 'statement', 'print(b)')]
|
||||
const after = [
|
||||
view('1', 'statement', 'print(a)'),
|
||||
view('x', 'statement', 'totally_new()'),
|
||||
view('2', 'statement', 'call(alpha, beta, delta)'),
|
||||
view('3', 'statement', 'print(b)'),
|
||||
]
|
||||
expect(alignSiblings(before, '', after, '').toOld).toEqual([0, undefined, 1, 2])
|
||||
})
|
||||
|
||||
it('没名字的块种类不同就不按相似度配', () => {
|
||||
const before = [view('1', 'statement', 'call(alpha, beta)')]
|
||||
const after = [view('2', 'return', 'call(alpha, beta, gamma)')]
|
||||
expect(alignSiblings(before, '', after, '').toOld).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('有名字的块不按相似度配:改名算一删一增', () => {
|
||||
const source = { text: '' }
|
||||
const before = [named('a', 'function', 'old_name', 'def old_name(x): return x + 1', source)]
|
||||
const after = [named('b', 'function', 'new_name', 'def new_name(x): return x + 1', source)]
|
||||
expect(alignSiblings(before, source.text, after, source.text).toOld).toEqual([undefined])
|
||||
})
|
||||
})
|
||||
|
||||
describe('diffBlockTrees', () => {
|
||||
it('原型的 mutex.py:新增 2 · 修改 3 · 删除 1,只数最外层', async () => {
|
||||
const diff = diffBlockTrees(await python(BEFORE), await python(AFTER))
|
||||
expect(diff.stats).toEqual({ add: 2, mod: 3, del: 1 })
|
||||
})
|
||||
|
||||
it('新增的块连同子孙都算新增,只有最外面那块挂小标', async () => {
|
||||
const after = await python(AFTER)
|
||||
const diff = diffBlockTrees(await python(BEFORE), after)
|
||||
const renew = after.blocks.find(block => block.anchor === 'function renew')!
|
||||
expect(diff.blocks[renew.anchor]).toEqual({ change: 'add', outermost: true })
|
||||
for (const child of renew.children)
|
||||
expect(diff.blocks[child.anchor]).toEqual({ change: 'add' })
|
||||
})
|
||||
|
||||
it('修改的块:自己的内容改了带标记,子块照样比;只有子块改了的父块不算自己改了', async () => {
|
||||
const after = await python(AFTER)
|
||||
const diff = diffBlockTrees(await python(BEFORE), after)
|
||||
const tryAcquire = after.blocks.find(block => block.anchor === 'function try_acquire')!
|
||||
expect(diff.blocks[tryAcquire.anchor]).toMatchObject({ change: 'mod', own: true })
|
||||
expect(diff.blocks[tryAcquire.anchor].marks?.rows).toBeDefined()
|
||||
const [token, ok, back] = tryAcquire.children
|
||||
expect(diff.blocks[token.anchor]).toMatchObject({ change: 'mod', own: true })
|
||||
expect(diff.blocks[ok.anchor]).toBeUndefined()
|
||||
expect(diff.blocks[back.anchor]).toBeUndefined()
|
||||
|
||||
const release = after.blocks.find(block => block.anchor === 'function release')!
|
||||
expect(diff.blocks[release.anchor]).toEqual({ change: 'mod' })
|
||||
expect(diff.blocks[release.children[0].anchor]).toMatchObject({ change: 'mod', own: true })
|
||||
})
|
||||
|
||||
it('删掉的块放回原来的位置:前面配上的兄弟之后,再越过紧跟着的新增块;只带它自己的内容', async () => {
|
||||
const after = await python(AFTER)
|
||||
const diff = diffBlockTrees(await python(BEFORE), after)
|
||||
expect(diff.removed).toHaveLength(1)
|
||||
const [removed] = diff.removed
|
||||
expect(removed).toMatchObject({ parent: '', after: 'function renew' })
|
||||
expect(removed.block.anchor).toBe('function _is_owner')
|
||||
expect(removed.block.children).toEqual([])
|
||||
expect(removed.block.defines).toBeUndefined()
|
||||
const renew = after.blocks.find(block => block.anchor === 'function renew')!
|
||||
expect(removed.line).toBe(renew.endLine + 1)
|
||||
const names = JSON.stringify(removed.block)
|
||||
expect(names).not.toContain('"offset"')
|
||||
})
|
||||
|
||||
it('没变的文件没有改动', async () => {
|
||||
const before = await python(BEFORE)
|
||||
expect(diffBlockTrees(before, await python(BEFORE))).toEqual({ blocks: {}, removed: [], stats: { add: 0, mod: 0, del: 0 } })
|
||||
})
|
||||
|
||||
it('子块里删掉的块挂在父块下面,排在最前面时没有 after', async () => {
|
||||
const before = await python('def f():\n a = 1\n b = 2\n')
|
||||
const after = await python('def f():\n b = 2\n')
|
||||
const diff = diffBlockTrees(before, after)
|
||||
expect(diff.stats).toEqual({ add: 0, mod: 1, del: 0 })
|
||||
expect(diff.removed).toHaveLength(1)
|
||||
expect(diff.removed[0]).toMatchObject({ parent: 'function f', line: 1 })
|
||||
expect(diff.removed[0].after).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { BlockMarks } from './viewDiff'
|
||||
import { diffArrays, diffWordsWithSpace } from 'diff'
|
||||
import { blockMarks, removedView } from './viewDiff'
|
||||
|
||||
/**
|
||||
* 一份文件改之前、改之后的两棵块树比出哪些块新增、修改、删除。同一父块下的兄弟块对齐:
|
||||
* 有名字的块按「种类 + 名字」配对,没名字的按位置和内容相似度配对(最长公共子序列找出没变的,夹在中间的按相似度配),
|
||||
* 配不上的旧有新无算删除、旧无新有算新增;改名算一删一增。配上的块整段源码一样算没变,否则算修改、接着比子块。
|
||||
* 这里不碰 VS Code。
|
||||
*/
|
||||
|
||||
/** 块的改动:新增、修改;删掉的块另列(RemovedBlock)。 */
|
||||
export type BlockChange = 'add' | 'mod'
|
||||
|
||||
export interface ChangedBlock {
|
||||
change: BlockChange
|
||||
/** 新增的最外面那块(挂「新增」小标);它的子孙跟着算新增、不挂 */
|
||||
outermost?: true
|
||||
/** 修改的块自己(不算子块)有变:卡片里的内容变了,或源码变了、子块却都没变 */
|
||||
own?: true
|
||||
/** 修改的块自己的内容改了哪些(见 blockMarks) */
|
||||
marks?: BlockMarks
|
||||
}
|
||||
|
||||
/**
|
||||
* 删掉的一块,放回它原来的位置:旧版本里它前面最近一个配上的兄弟所配的新块后面,
|
||||
* 再越过紧跟在那块后面的新增块(新增的在前、删掉的在后,都夹在原来前后两个邻居中间)。
|
||||
*/
|
||||
export interface RemovedBlock {
|
||||
/** 在哪一层:父块的记号(BlockView.anchor),顶层是 '' */
|
||||
parent: string
|
||||
/** 排在这一层哪块后面(新块的记号);排在最前面时没有 */
|
||||
after?: string
|
||||
/** 旧版本里的这块:只带它自己的内容,词不带源码下标(见 removedView) */
|
||||
block: BlockView
|
||||
/** 它在新文件里的位置(0 起的行号):总览条上的刻度画在这 */
|
||||
line: number
|
||||
}
|
||||
|
||||
/** 「新增 N · 修改 N · 删除 N」:只数最外层带标记的块,新增块的子块、修改块里改动的子块不再数。 */
|
||||
export interface DiffStats { add: number, mod: number, del: number }
|
||||
|
||||
export interface TreeDiff {
|
||||
/** 按新块的记号;没变的块不写 */
|
||||
blocks: Record<string, ChangedBlock>
|
||||
removed: RemovedBlock[]
|
||||
stats: DiffStats
|
||||
}
|
||||
|
||||
/** 一份源码的块树和源码本身(块的名字按 defines 从源码里取)。 */
|
||||
export interface SourcedBlocks {
|
||||
blocks: readonly BlockView[]
|
||||
source: string
|
||||
}
|
||||
|
||||
/** 没名字的两块内容相似度(0~1)到这么多才配成修改,否则算一删一增。 */
|
||||
const SIMILAR = 0.5
|
||||
/** 没名字的块往后最多找这么多块配相似的,改动很大的文件不至于两两比个遍。 */
|
||||
const SIMILAR_WINDOW = 40
|
||||
|
||||
/** 块自己的名字:defines 那一段源码(函数名、类名、只声明一个变量时的变量名、规则卡片讲的名字);没名字的块没有。 */
|
||||
function nameOf(view: BlockView, source: string): string | undefined {
|
||||
return view.defines && source.slice(view.defines.startIndex, view.defines.endIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* 两段源码有多像(0~1):2 × 没变的字数 ÷ 两边字数之和,按词比(中文按字、标点各算一个)。
|
||||
* 一边比另一边长三倍以上时没变的字不可能过半,直接当不像。
|
||||
*/
|
||||
export function similarity(a: string, b: string): number {
|
||||
if (a === b)
|
||||
return 1
|
||||
if (Math.max(a.length, b.length) > 3 * Math.min(a.length, b.length))
|
||||
return 0
|
||||
const kept = diffWordsWithSpace(a, b).filter(change => !change.added && !change.removed).reduce((sum, change) => sum + change.value.length, 0)
|
||||
return 2 * kept / (a.length + b.length)
|
||||
}
|
||||
|
||||
/** 同一父块下的兄弟块怎么对上:新的第几块配旧的第几块(没配上是 undefined),反过来也记一份。 */
|
||||
export interface Alignment {
|
||||
toOld: (number | undefined)[]
|
||||
toNew: (number | undefined)[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一父块下的兄弟块对齐:
|
||||
* ① 有名字的按「种类 + 名字」配,同名的照先后,挪了位置也配得上;
|
||||
* ② 以此为锚,最长公共子序列找出种类、源码都没变的没名字的块;
|
||||
* ③ 夹在相邻两个锚中间、还没配上的没名字的块,照先后找种类相同、内容最像且相似度到 SIMILAR 的配上(算修改)。
|
||||
* 有名字却没配上的(改了名、删了、新加的)不按相似度配:改名算一删一增。
|
||||
*/
|
||||
export function alignSiblings(before: readonly BlockView[], beforeSource: string, after: readonly BlockView[], afterSource: string): Alignment {
|
||||
const toOld: (number | undefined)[] = after.map(() => undefined)
|
||||
const toNew: (number | undefined)[] = before.map(() => undefined)
|
||||
const pair = (old: number, now: number): void => {
|
||||
toOld[now] = old
|
||||
toNew[old] = now
|
||||
}
|
||||
const oldNames = before.map(view => nameOf(view, beforeSource))
|
||||
const newNames = after.map(view => nameOf(view, afterSource))
|
||||
const waiting = new Map<string, number[]>()
|
||||
before.forEach((view, old) => {
|
||||
const name = oldNames[old]
|
||||
if (name !== undefined)
|
||||
waiting.set(`${view.kind}\u0000${name}`, [...waiting.get(`${view.kind}\u0000${name}`) ?? [], old])
|
||||
})
|
||||
after.forEach((view, now) => {
|
||||
const name = newNames[now]
|
||||
const old = name === undefined ? undefined : waiting.get(`${view.kind}\u0000${name}`)?.shift()
|
||||
if (old !== undefined)
|
||||
pair(old, now)
|
||||
})
|
||||
|
||||
const unnamedSame = (old: number, now: number): boolean => oldNames[old] === undefined && newNames[now] === undefined
|
||||
&& before[old].kind === after[now].kind && before[old].code === after[now].code
|
||||
const changes = diffArrays(before.map((_, i) => i), after.map((_, i) => i), { comparator: (old, now) => toNew[old] === now || unnamedSame(old, now) })
|
||||
const anchors: [number, number][] = []
|
||||
let old = 0
|
||||
let now = 0
|
||||
for (const change of changes) {
|
||||
if (!change.added && !change.removed) {
|
||||
for (let k = 0; k < change.count; k++) {
|
||||
if (toNew[old + k] === undefined)
|
||||
pair(old + k, now + k)
|
||||
anchors.push([old + k, now + k])
|
||||
}
|
||||
}
|
||||
if (!change.added)
|
||||
old += change.count
|
||||
if (!change.removed)
|
||||
now += change.count
|
||||
}
|
||||
|
||||
let lastOld = -1
|
||||
let lastNew = -1
|
||||
for (const [anchorOld, anchorNew] of [...anchors, [before.length, after.length]]) {
|
||||
let from = lastNew + 1
|
||||
for (let o = lastOld + 1; o < anchorOld; o++) {
|
||||
if (toNew[o] !== undefined || oldNames[o] !== undefined)
|
||||
continue
|
||||
let best: number | undefined
|
||||
let bestScore = 0
|
||||
for (let n = from; n < Math.min(anchorNew, from + SIMILAR_WINDOW); n++) {
|
||||
if (toOld[n] !== undefined || newNames[n] !== undefined || after[n].kind !== before[o].kind)
|
||||
continue
|
||||
const score = similarity(before[o].code, after[n].code)
|
||||
if (score >= SIMILAR && score > bestScore) {
|
||||
best = n
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
if (best !== undefined) {
|
||||
pair(o, best)
|
||||
from = best + 1
|
||||
}
|
||||
}
|
||||
lastOld = anchorOld
|
||||
lastNew = anchorNew
|
||||
}
|
||||
return { toOld, toNew }
|
||||
}
|
||||
|
||||
/** 新增的一块连同子孙:最外面那块挂「新增」小标,子孙跟着算新增。 */
|
||||
function markAdded(view: BlockView, outermost: boolean, blocks: Record<string, ChangedBlock>): void {
|
||||
blocks[view.anchor] = { change: 'add', ...(outermost && { outermost: true as const }) }
|
||||
for (const child of view.children)
|
||||
markAdded(child, false, blocks)
|
||||
}
|
||||
|
||||
/**
|
||||
* 两棵块树比:新增、修改的块按新块的记号记下,删掉的块放回原来的位置。
|
||||
* 统计只数最外层带标记的块(见 DiffStats)。
|
||||
*/
|
||||
export function diffBlockTrees(before: SourcedBlocks, after: SourcedBlocks): TreeDiff {
|
||||
const blocks: Record<string, ChangedBlock> = {}
|
||||
const removed: RemovedBlock[] = []
|
||||
const stats: DiffStats = { add: 0, mod: 0, del: 0 }
|
||||
|
||||
/**
|
||||
* 比一层兄弟块:parent 是这一层所属的新块(顶层没有),inside 表示外层已经带了标记(这一层的改动不再计数)。
|
||||
* @returns 这一层有没有块新增、修改、删除
|
||||
*/
|
||||
const level = (old: readonly BlockView[], now: readonly BlockView[], parent: BlockView | undefined, inside: boolean): boolean => {
|
||||
const { toOld, toNew } = alignSiblings(old, before.source, now, after.source)
|
||||
let changed = false
|
||||
now.forEach((view, index) => {
|
||||
const paired = toOld[index]
|
||||
if (paired === undefined) {
|
||||
markAdded(view, true, blocks)
|
||||
changed = true
|
||||
if (!inside)
|
||||
stats.add += 1
|
||||
return
|
||||
}
|
||||
const previous = old[paired]
|
||||
if (previous.code === view.code)
|
||||
return
|
||||
const marks = blockMarks(previous, view)
|
||||
const childrenChanged = level(previous.children, view.children, view, true)
|
||||
blocks[view.anchor] = { change: 'mod', ...((marks || !childrenChanged) && { own: true as const }), ...(marks && { marks }) }
|
||||
changed = true
|
||||
if (!inside)
|
||||
stats.mod += 1
|
||||
})
|
||||
// 删掉的块排在它前面最近一个配上的兄弟所配的新块后面,再越过紧跟着的新增块:落在原来前后两个邻居中间,新增的在前、删掉的在后
|
||||
const throughAdded = (from: number): BlockView | undefined => {
|
||||
let end = from
|
||||
while (end + 1 < now.length && toOld[end + 1] === undefined)
|
||||
end += 1
|
||||
return end >= 0 ? now[end] : undefined
|
||||
}
|
||||
let last = throughAdded(-1)
|
||||
old.forEach((view, index) => {
|
||||
const paired = toNew[index]
|
||||
if (paired !== undefined) {
|
||||
last = throughAdded(paired)
|
||||
return
|
||||
}
|
||||
removed.push({
|
||||
parent: parent?.anchor ?? '',
|
||||
...(last && { after: last.anchor }),
|
||||
block: removedView(view),
|
||||
line: last ? last.endLine + 1 : parent ? parent.headerEndLine + 1 : 0,
|
||||
})
|
||||
changed = true
|
||||
if (!inside)
|
||||
stats.del += 1
|
||||
})
|
||||
return changed
|
||||
}
|
||||
|
||||
level(before.blocks, after.blocks, undefined, false)
|
||||
return { blocks, removed, stats }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 对比基准:侧栏只列改动的文件时(「未提交的更改」对比上一次提交,「选工单」审查对比 PR 的目标分支),
|
||||
* 预览拿它取这个文件改之前的样子,和现在的比出差异。由开着的那种侧栏筛选给(TreeFilter.diffBase)。
|
||||
*/
|
||||
export interface DiffBase {
|
||||
/** 筛选栏上「对比:」后面的字:「上一次提交」「PR 目标分支 master」 */
|
||||
label: string
|
||||
/** 同一个基准里的同一个文件 key 一样:取过的旧内容按它记着,基准挪了(提交后 HEAD 动了)key 跟着变 */
|
||||
key: string
|
||||
/** 基准里这个文件的内容;基准里没有这个文件是 null */
|
||||
read: () => Promise<string | null>
|
||||
/** 基准里没有这个文件时横幅上的一句说明 */
|
||||
missing: string
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { DiffBase } from './diffBase'
|
||||
import type { DocumentDiff, OldDocument, SentDocument } from './previewDiff'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fileAttributes } from '../attributes'
|
||||
import { readBlockViews } from '../blockView'
|
||||
import { createPythonAdapter } from '../languages/python'
|
||||
import { runtime, WASM_DIR } from '../testSupport'
|
||||
import { pythonZhTranslator } from '../translators/pythonZh'
|
||||
import { PreviewDiff } from './previewDiff'
|
||||
|
||||
const py = createPythonAdapter(WASM_DIR)
|
||||
|
||||
async function parse(source: string): Promise<OldDocument> {
|
||||
const { blocks } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source)
|
||||
return { blocks, attributes: fileAttributes(blocks, pythonZhTranslator.attributeNames) }
|
||||
}
|
||||
|
||||
async function sent(source: string, version = 1): Promise<SentDocument> {
|
||||
return { version, source, blocks: (await parse(source)).blocks }
|
||||
}
|
||||
|
||||
function base(text: string | null, key = 'k'): DiffBase & { read: ReturnType<typeof vi.fn> } {
|
||||
return { label: '上一次提交', key, read: vi.fn(async () => text), missing: '上一次提交里没有这个文件' }
|
||||
}
|
||||
|
||||
function setup(current: { base: DiffBase | undefined }) {
|
||||
const posted: (DocumentDiff | null)[] = []
|
||||
const reports: string[] = []
|
||||
const diff = new PreviewDiff(() => current.base, parse, value => posted.push(value), message => reports.push(message))
|
||||
return { diff, posted, reports }
|
||||
}
|
||||
|
||||
describe('previewDiff', () => {
|
||||
it('比出差异发出去:统计、删掉的块带种类的名字、新文件一共几行', async () => {
|
||||
const current = { base: base('def f():\n return 1\n\n\ndef g():\n pass\n') as DiffBase | undefined }
|
||||
const { diff, posted } = setup(current)
|
||||
await diff.update(await sent('def f():\n return 2\n'))
|
||||
expect(posted).toHaveLength(1)
|
||||
expect(posted[0]).toMatchObject({ base: '上一次提交', version: 1, stats: { add: 0, mod: 1, del: 1 }, lineCount: 3 })
|
||||
expect(posted[0]?.removed[0]).toMatchObject({ kindName: '函数', after: 'function f' })
|
||||
})
|
||||
|
||||
it('基准里没有这个文件:整份是新文件,不逐块标', async () => {
|
||||
const { diff, posted } = setup({ base: base(null) })
|
||||
await diff.update(await sent('x = 1\n'))
|
||||
expect(posted).toEqual([{ base: '上一次提交', version: 1, lineCount: 2, newFile: '上一次提交里没有这个文件', blocks: {}, removed: [], stats: { add: 0, mod: 0, del: 0 } }])
|
||||
})
|
||||
|
||||
it('没有基准时不发;发过差异后基准没了发一次 null', async () => {
|
||||
const current: { base: DiffBase | undefined } = { base: undefined }
|
||||
const { diff, posted } = setup(current)
|
||||
const doc = await sent('x = 1\n')
|
||||
await diff.update(doc)
|
||||
expect(posted).toEqual([])
|
||||
current.base = base('x = 1\n')
|
||||
await diff.update(doc)
|
||||
expect(posted).toHaveLength(1)
|
||||
current.base = undefined
|
||||
await diff.update(doc)
|
||||
await diff.update(doc)
|
||||
expect(posted.at(-1)).toBeNull()
|
||||
expect(posted).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('旧内容按基准的 key 记着:文档改了不再取,key 变了重取', async () => {
|
||||
const first = base('x = 1\n', 'a')
|
||||
const current: { base: DiffBase | undefined } = { base: first }
|
||||
const { diff } = setup(current)
|
||||
await diff.update(await sent('x = 2\n', 1))
|
||||
await diff.update(await sent('x = 3\n', 2))
|
||||
expect(first.read).toHaveBeenCalledTimes(1)
|
||||
const second = base('x = 1\n', 'b')
|
||||
current.base = second
|
||||
await diff.update(await sent('x = 3\n', 2))
|
||||
expect(second.read).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('同一份文档、同一个基准不再比', async () => {
|
||||
const { diff, posted } = setup({ base: base('x = 1\n') })
|
||||
const doc = await sent('x = 2\n')
|
||||
await diff.update(doc)
|
||||
await diff.update(doc)
|
||||
expect(posted).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('取不到旧内容:记一笔,清掉发过的差异,下次重取', async () => {
|
||||
const failing: DiffBase = { label: '上一次提交', key: 'k', read: vi.fn().mockRejectedValueOnce(new Error('git 挂了')).mockResolvedValue('x = 1\n'), missing: '' }
|
||||
const current: { base: DiffBase | undefined } = { base: base('x = 1\n', 'ok') }
|
||||
const { diff, posted, reports } = setup(current)
|
||||
const doc = await sent('x = 2\n')
|
||||
await diff.update(doc)
|
||||
current.base = failing
|
||||
await diff.update(doc)
|
||||
expect(reports[0]).toContain('git 挂了')
|
||||
expect(posted.at(-1)).toBeNull()
|
||||
await diff.update(doc)
|
||||
expect(failing.read).toHaveBeenCalledTimes(2)
|
||||
expect(posted.at(-1)).toMatchObject({ stats: { mod: 1 } })
|
||||
})
|
||||
|
||||
it('慢的旧结果不盖掉新的', async () => {
|
||||
let release: (text: string) => void = () => {}
|
||||
const slow: DiffBase = { label: '上一次提交', key: 'slow', read: () => new Promise(resolve => release = resolve), missing: '' }
|
||||
const current: { base: DiffBase | undefined } = { base: slow }
|
||||
const { diff, posted } = setup(current)
|
||||
const pending = diff.update(await sent('x = 2\n', 1))
|
||||
current.base = base('x = 1\n', 'fast')
|
||||
await diff.update(await sent('x = 2\n', 2))
|
||||
release('x = 1\n')
|
||||
await pending
|
||||
expect(posted.map(value => value?.version)).toEqual([2])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { FileAttribute } from '../attributes'
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { ChangedBlock, DiffStats, RemovedBlock } from './blockDiff'
|
||||
import type { DiffBase } from './diffBase'
|
||||
import { splitLines } from '../editText'
|
||||
import { diffBlockTrees } from './blockDiff'
|
||||
|
||||
/** 删掉的一块(见 RemovedBlock),带上种类的名字:旧版本里才有的种类,新文件的筛选属性里查不到它叫什么。 */
|
||||
export interface RemovedView extends RemovedBlock {
|
||||
kindName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 发给预览的差异:这份文件相比对比基准(DiffBase)改了什么。块按记号(BlockView.anchor)认,文档刚改、差异还没重算时
|
||||
* 没动过的块照样对得上;卡片里的标记按下标认,只在页面上正是 version 这一版时画。
|
||||
*/
|
||||
export interface DocumentDiff {
|
||||
/** 「对比:」后面的字 */
|
||||
base: string
|
||||
/** 算的是哪一版文档 */
|
||||
version: number
|
||||
/** 基准里没有这个文件时横幅上的说明;这时整份都是新增,不逐块标 */
|
||||
newFile?: string
|
||||
/** 新增、修改的块,按新块的记号 */
|
||||
blocks: Record<string, ChangedBlock>
|
||||
removed: RemovedView[]
|
||||
stats: DiffStats
|
||||
/** 新文件一共几行:总览条按它排刻度 */
|
||||
lineCount: number
|
||||
}
|
||||
|
||||
/** 发出去的一份文档:版本、块树、源码。 */
|
||||
export interface SentDocument {
|
||||
version: number
|
||||
blocks: BlockView[]
|
||||
source: string
|
||||
}
|
||||
|
||||
/** 旧版本解析出来的块树和筛选属性(种类的名字从这取)。 */
|
||||
export interface OldDocument {
|
||||
blocks: BlockView[]
|
||||
attributes: FileAttribute[]
|
||||
}
|
||||
|
||||
const NO_CHANGES: DiffStats = { add: 0, mod: 0, del: 0 }
|
||||
|
||||
/** 旧版本里各种类叫什么:筛选属性 kind 的各个值的名字。 */
|
||||
function kindNames(attributes: readonly FileAttribute[]): Map<string, string> {
|
||||
return new Map(attributes.find(attribute => attribute.id === 'kind')?.values.map(value => [value.id, value.name]))
|
||||
}
|
||||
|
||||
/** 按基准比出来的差异:基准里没有这个文件时整份是新文件,否则两棵块树比(见 diffBlockTrees)。 */
|
||||
export function documentDiff(base: DiffBase, sent: SentDocument, old: { source: string, document: OldDocument } | null): DocumentDiff {
|
||||
const common = { base: base.label, version: sent.version, lineCount: splitLines(sent.source).length }
|
||||
if (!old)
|
||||
return { ...common, newFile: base.missing, blocks: {}, removed: [], stats: NO_CHANGES }
|
||||
const { blocks, removed, stats } = diffBlockTrees({ blocks: old.document.blocks, source: old.source }, sent)
|
||||
const kinds = kindNames(old.document.attributes)
|
||||
return { ...common, blocks, removed: removed.map(item => ({ ...item, kindName: kinds.get(item.block.kind) ?? item.block.kind })), stats }
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个打开着的预览的差异:每发出一份文档之后、对比基准变了之后按眼下的基准重新比,比完再发,预览先照常画、不等它。
|
||||
* 取过的旧内容按基准的 key 记着(编辑时不用每次去问 git),旧版本每次重新解析:规则查到的定义、调用的种类这些晚到的答案两边要一样。
|
||||
* 这里不碰 VS Code。
|
||||
*/
|
||||
export class PreviewDiff {
|
||||
/** 取过的旧内容;key 相同不再取,取失败了下次重取 */
|
||||
private old: { key: string, text: Promise<string | null> } | undefined
|
||||
/** 只发最后一次比的结果:慢的旧结果不能盖掉新的 */
|
||||
private latest = 0
|
||||
/** 上一次比的是哪份文档、哪个基准:都没变就不再比(git 状态变了、基准没挪) */
|
||||
private compared: { sent: SentDocument, base: string | undefined } | undefined
|
||||
/** 发过差异(不是 null):基准没了时要发一次 null 清掉 */
|
||||
private showing = false
|
||||
|
||||
constructor(
|
||||
/** 眼下对比的基准;没有是不比(普通文件树) */
|
||||
private readonly baseOf: () => DiffBase | undefined,
|
||||
/** 用这份文档的语言、翻译插件和库规则解析旧版本;解析失败是 undefined */
|
||||
private readonly readOld: (source: string) => Promise<OldDocument | undefined>,
|
||||
private readonly post: (diff: DocumentDiff | null) => void,
|
||||
private readonly report: (message: string) => void,
|
||||
) {}
|
||||
|
||||
/** 按眼下的基准比 sent 这份文档,比完发出去;取不到旧内容、旧版本解析失败时记一笔,发 null。 */
|
||||
async update(sent: SentDocument): Promise<void> {
|
||||
const base = this.baseOf()
|
||||
const signature = base && [base.key, base.label, base.missing].join('\u0000')
|
||||
if (this.compared?.sent === sent && this.compared.base === signature)
|
||||
return
|
||||
this.compared = { sent, base: signature }
|
||||
const request = ++this.latest
|
||||
if (!base) {
|
||||
this.clear()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const text = await this.oldText(base)
|
||||
const document = text === null ? undefined : await this.readOld(text)
|
||||
if (request !== this.latest)
|
||||
return
|
||||
if (text !== null && !document) {
|
||||
this.report(`没能解析${base.label}里的旧版本,不标差异`)
|
||||
this.clear()
|
||||
return
|
||||
}
|
||||
this.showing = true
|
||||
this.post(documentDiff(base, sent, text === null || !document ? null : { source: text, document }))
|
||||
}
|
||||
catch (err) {
|
||||
if (request !== this.latest)
|
||||
return
|
||||
this.report(`没能取到${base.label}里的旧版本,不标差异:${err instanceof Error ? err.message : String(err)}`)
|
||||
this.compared = undefined
|
||||
this.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private clear(): void {
|
||||
if (!this.showing)
|
||||
return
|
||||
this.showing = false
|
||||
this.post(null)
|
||||
}
|
||||
|
||||
private oldText(base: DiffBase): Promise<string | null> {
|
||||
if (this.old?.key !== base.key) {
|
||||
const text = base.read()
|
||||
this.old = { key: base.key, text }
|
||||
text.catch(() => {
|
||||
if (this.old?.text === text)
|
||||
this.old = undefined
|
||||
})
|
||||
}
|
||||
return this.old.text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { Inline, PropertyItem, PropertyRow, Segment, TokenRole } from '../contract'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { blockMarks, inlineDiff, itemsDiff, markdownDiff, pairByKey, removedItems, removedView, rowsDiff, textDiff, withoutSource } from './viewDiff'
|
||||
|
||||
function seg(text: string, role: TokenRole = 'plain', offset?: number): Segment {
|
||||
return { text, role, ...(offset !== undefined && { offset }) }
|
||||
}
|
||||
|
||||
function item(...cells: string[]): PropertyItem {
|
||||
return { cells: cells.map(cell => [seg(cell, 'variable')]) }
|
||||
}
|
||||
|
||||
function row(label: string, value: Inline[] | { markdown: string }, items?: PropertyItem[]): PropertyRow {
|
||||
return { label, value, ...(items && { items }) }
|
||||
}
|
||||
|
||||
function textView(translation: Inline[] | { markdown: string }, extra: Partial<BlockView> = {}): BlockView {
|
||||
return { id: '0', anchor: 'a', kind: 'statement', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], attributes: {}, children: [], translation, ...extra } as BlockView
|
||||
}
|
||||
|
||||
function propertyView(properties: PropertyRow[]): BlockView {
|
||||
return { id: '0', anchor: 'a', kind: 'function', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], attributes: {}, children: [], properties }
|
||||
}
|
||||
|
||||
describe('inlineDiff', () => {
|
||||
it('一样(只是源码下标不同)时没有改动', () => {
|
||||
expect(inlineDiff([seg('a', 'variable', 1)], [seg('a', 'variable', 9)])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('大部分没变时逐截标:没变的、新加的照新版本(带下标),删掉的取旧版本、去掉下标', () => {
|
||||
const before = [seg('call', 'function', 0), seg('('), seg('key', 'variable', 5), seg(')')]
|
||||
const after = [seg('call', 'function', 0), seg('('), seg('key', 'variable', 5), seg(', '), seg('token', 'variable', 10), seg(')')]
|
||||
expect(inlineDiff(before, after)).toEqual([
|
||||
{ segments: [seg('call', 'function', 0), seg('('), seg('key', 'variable', 5)] },
|
||||
{ segments: [seg(', '), seg('token', 'variable', 10)], change: 'add' },
|
||||
{ segments: [seg(')')] },
|
||||
])
|
||||
})
|
||||
|
||||
it('改得多时整段写成旧值 → 新值,旧值去掉下标', () => {
|
||||
expect(inlineDiff([seg('布尔', 'type', 3)], [seg('字符串', 'type'), seg(' | '), seg('空', 'storage')])).toEqual([
|
||||
{ segments: [seg('布尔', 'type')], change: 'del' },
|
||||
{ segments: [seg('字符串', 'type'), seg(' | '), seg('空', 'storage')], change: 'add' },
|
||||
])
|
||||
})
|
||||
|
||||
it('框按样式、框头和框里的词比', () => {
|
||||
const group = (label: string): Inline => ({ style: 'iteration', label, segments: [seg('x', 'variable', 2)] })
|
||||
expect(inlineDiff([group('列表')], [group('列表')])).toBeUndefined()
|
||||
expect(inlineDiff([group('列表')], [group('生成器')])).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('markdownDiff', () => {
|
||||
it('按词比,中文按字', () => {
|
||||
expect(markdownDiff('Lua 比对 token 再删.', 'Lua 比对 token 再续期 / 再删.')).toEqual([
|
||||
{ text: 'Lua 比对 token 再' },
|
||||
{ text: '续期 / 再', change: 'add' },
|
||||
{ text: '删.' },
|
||||
])
|
||||
})
|
||||
|
||||
it('删掉的字取旧版本', () => {
|
||||
expect(markdownDiff('放锁前必须比对', '放锁都必须比对')).toEqual([
|
||||
{ text: '放锁' },
|
||||
{ text: '前', change: 'del' },
|
||||
{ text: '都', change: 'add' },
|
||||
{ text: '必须比对' },
|
||||
])
|
||||
})
|
||||
|
||||
it('一样时没有改动', () => {
|
||||
expect(markdownDiff('同样', '同样')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('textDiff', () => {
|
||||
it('着色的词逐截比,Markdown 逐词比,一种换成另一种时按字逐词比', () => {
|
||||
expect(textDiff([seg('a')], [seg('b')])).toHaveProperty('inline')
|
||||
expect(textDiff({ markdown: 'a' }, { markdown: 'b' })).toHaveProperty('markdown')
|
||||
expect(textDiff([seg('无', 'hint')], { markdown: '说明' })).toEqual({ markdown: [{ text: '无', change: 'del' }, { text: '说明', change: 'add' }] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('pairByKey / removedItems', () => {
|
||||
it('同 key 照先后配;没配上的旧项排在它前面最近配上的那项所配的新项后面', () => {
|
||||
const before = ['a', 'x', 'b', 'y']
|
||||
const after = ['b', 'a', 'c']
|
||||
const { toOld, toNew } = pairByKey(before, after, value => value)
|
||||
expect(toOld).toEqual([2, 0, undefined])
|
||||
expect(toNew).toEqual([1, undefined, 0, undefined])
|
||||
expect(removedItems(before, toNew, value => value)).toEqual([{ after: 1, value: 'x' }, { after: 0, value: 'y' }])
|
||||
})
|
||||
|
||||
it('最前面没配上的排在最前面', () => {
|
||||
const { toNew } = pairByKey(['x', 'a'], ['a'], value => value)
|
||||
expect(removedItems(['x', 'a'], toNew, value => value)).toEqual([{ after: -1, value: 'x' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('itemsDiff', () => {
|
||||
it('按第一列配:新参数算加行,没了的参数算删行,配上的逐格比', () => {
|
||||
const before = [item('redis', 'Any'), item('ttl', '整数')]
|
||||
const after = [item('redis', 'Any'), item('ttl_sec', '整数'), item('prefix', '字符串')]
|
||||
const diff = itemsDiff(before, after)
|
||||
expect(diff?.changed).toEqual({ 1: { added: true }, 2: { added: true } })
|
||||
expect(diff?.removed).toEqual([{ after: 0, value: item('ttl', '整数') }])
|
||||
})
|
||||
|
||||
it('配上的行某一格变了只标那一格', () => {
|
||||
const diff = itemsDiff([item('key', '字符串')], [item('key', '字节串')])
|
||||
expect(Object.keys(diff?.changed[0].cells ?? {})).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('新加的行下面各层也是新加的', () => {
|
||||
const added: PropertyItem = { cells: [[seg('k')]], items: [item('inner')], table: { columns: ['名称'], items: [item('arg')] } }
|
||||
expect(itemsDiff([], [added])?.changed[0]).toEqual({
|
||||
added: true,
|
||||
items: { changed: { 0: { added: true } }, removed: [] },
|
||||
table: { changed: { 0: { added: true } }, removed: [] },
|
||||
})
|
||||
})
|
||||
|
||||
it('都没有、或一样时没有改动', () => {
|
||||
expect(itemsDiff(undefined, undefined)).toBeUndefined()
|
||||
expect(itemsDiff([item('a')], [item('a')])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rowsDiff', () => {
|
||||
it('按属性名配:值变了逐截标,多出来的行算加行,没了的行算删行', () => {
|
||||
const before = [row('名称', [seg('f', 'function')]), row('输出参数', [seg('无', 'hint')]), row('装饰器', [seg('@x')])]
|
||||
const after = [row('名称', [seg('f', 'function')]), row('异步', [seg('是')]), row('输出参数', [seg('布尔', 'type')])]
|
||||
const diff = rowsDiff(before, after)
|
||||
expect(diff?.changed[1]).toEqual({ added: true })
|
||||
expect(diff?.changed[2].value).toBeDefined()
|
||||
expect(diff?.removed).toEqual([{ after: 2, value: row('装饰器', [seg('@x')]) }])
|
||||
})
|
||||
|
||||
it('子行改了时只标子行,值(子行的汇总)不另标', () => {
|
||||
const before = [row('输入参数', [seg('1 个', 'hint')], [item('a')])]
|
||||
const after = [row('输入参数', [seg('2 个', 'hint')], [item('a'), item('b')])]
|
||||
expect(rowsDiff(before, after)?.changed[0]).toEqual({ items: { changed: { 1: { added: true } }, removed: [] } })
|
||||
})
|
||||
|
||||
it('新加的行带着子行时,子行也都是新加的', () => {
|
||||
const diff = rowsDiff([], [row('输入参数', [seg('1 个')], [item('a')])])
|
||||
expect(diff?.changed[0]).toEqual({ added: true, items: { changed: { 0: { added: true } }, removed: [] } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('blockMarks', () => {
|
||||
it('一行译文、摊开实参的表、说明各比各的', () => {
|
||||
const before = textView([seg('调用 f')], { doc: { markdown: '旧说明' } } as Partial<BlockView>)
|
||||
const after = textView([seg('调用 g')], { table: { columns: ['实参'], items: [item('x')] } } as Partial<BlockView>)
|
||||
const marks = blockMarks(before, after)
|
||||
expect(marks?.translation).toBeDefined()
|
||||
expect(marks?.table?.changed).toEqual({ 0: { added: true } })
|
||||
expect(marks?.doc).toEqual({ markdown: [{ text: '旧说明', change: 'del' }] })
|
||||
})
|
||||
|
||||
it('一块是属性表、一块是一行译文时没法逐行比', () => {
|
||||
expect(blockMarks(textView([seg('a')]), propertyView([row('名称', [seg('a')])]))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('内容一样时没有标记', () => {
|
||||
expect(blockMarks(propertyView([row('名称', [seg('a', 'variable', 1)])]), propertyView([row('名称', [seg('a', 'variable', 7)])]))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removedView / withoutSource', () => {
|
||||
it('删掉的块只带自己的内容,词不带源码下标,没有名字的范围', () => {
|
||||
const view = propertyView([row('名称', [seg('f', 'function', 4)], [{ cells: [[{ ...seg('x', 'variable', 9), refers: { startIndex: 1, endIndex: 2 } }]] }])])
|
||||
const removed = removedView({ ...view, defines: { startIndex: 4, endIndex: 5 }, children: [textView([seg('child', 'plain', 1)])] })
|
||||
expect(removed.children).toEqual([])
|
||||
expect(removed.defines).toBeUndefined()
|
||||
expect(JSON.stringify(removed)).not.toMatch(/"offset"|"refers"/)
|
||||
})
|
||||
|
||||
it('框里的词也去掉下标,颜色留着', () => {
|
||||
const color = { dark: '#fff', light: '#000' }
|
||||
expect(withoutSource([{ style: 'iteration', label: '列表', segments: [{ ...seg('x', 'variable', 3), color }] }])).toEqual([
|
||||
{ style: 'iteration', label: '列表', segments: [{ text: 'x', role: 'variable', color }] },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { Inline, PreviewTable, PreviewText, PropertyItem, PropertyRow } from '../contract'
|
||||
import { diffArrays, diffWordsWithSpace } from 'diff'
|
||||
|
||||
/**
|
||||
* 一块卡片里的内容改了哪些:在渲染用的视图数据这一层比(翻译好的属性表、一行译文、摊开实参的表、说明),
|
||||
* 所有语言、库规则的卡片走同一条路,不认识任何语言。这里不碰 VS Code。
|
||||
*/
|
||||
|
||||
/** 一截是新加的还是删掉的;没变的不写。 */
|
||||
export type PartChange = 'add' | 'del'
|
||||
|
||||
/** 着色的词里的一截:没变的、新加的(照新版本,带源码下标),或删掉的(取自旧版本,去掉了指向源码的下标)。 */
|
||||
export interface InlinePart { segments: Inline[], change?: PartChange }
|
||||
|
||||
/** Markdown 说明里的一截:按词切,中文按字。 */
|
||||
export interface MarkdownPart { text: string, change?: PartChange }
|
||||
|
||||
/** 一段预览文字改成了什么样:着色的词逐截标(改得多就是整段旧值 → 新值),Markdown 说明逐词 / 字标。 */
|
||||
export type TextDiff = { inline: InlinePart[] } | { markdown: MarkdownPart[] }
|
||||
|
||||
/** 删掉的一行 / 子行:旧的那行,和它排在新的第几行后面(-1 是最前面)。 */
|
||||
export interface Removed<T> { after: number, value: T }
|
||||
|
||||
/** 一层子行(参数表、导入项、字典的各项、摊开实参的表)的改动。 */
|
||||
export interface ItemsDiff {
|
||||
/** 按新子行的下标;没变的不写 */
|
||||
changed: Record<number, ItemDiff>
|
||||
removed: Removed<PropertyItem>[]
|
||||
}
|
||||
|
||||
export interface ItemDiff {
|
||||
/** 整行是新加的(它下面各层也都是) */
|
||||
added?: true
|
||||
/** 按列:这一格改成了什么样 */
|
||||
cells?: Record<number, InlinePart[]>
|
||||
items?: ItemsDiff
|
||||
table?: ItemsDiff
|
||||
}
|
||||
|
||||
/** 属性表各行的改动。 */
|
||||
export interface RowsDiff {
|
||||
/** 按新属性表的行下标;没变的不写 */
|
||||
changed: Record<number, RowDiff>
|
||||
removed: Removed<PropertyRow>[]
|
||||
}
|
||||
|
||||
export interface RowDiff {
|
||||
/** 整行是新加的(它下面的子行也都是) */
|
||||
added?: true
|
||||
value?: TextDiff
|
||||
items?: ItemsDiff
|
||||
}
|
||||
|
||||
/** 一块自己的内容(不算子块)改了哪些:属性表逐行,一行译文逐截,摊开实参的表逐行,说明逐词。 */
|
||||
export interface BlockMarks {
|
||||
rows?: RowsDiff
|
||||
translation?: TextDiff
|
||||
table?: ItemsDiff
|
||||
doc?: TextDiff
|
||||
}
|
||||
|
||||
/** 共同的部分少于长的那边的这么多,就不逐截标,整段写成旧值 → 新值。 */
|
||||
const PARTS_MIN_SHARE = 0.4
|
||||
|
||||
/** 旧版本里的词:去掉指向源码的下标(offset、refers、reference),旧的下标对不上现在的文件,不能拿去跳定义、高亮。 */
|
||||
export function withoutSource(pieces: readonly Inline[]): Inline[] {
|
||||
return pieces.map((piece) => {
|
||||
if (!('role' in piece))
|
||||
return { ...piece, segments: withoutSource(piece.segments) }
|
||||
const { text, role, color } = piece
|
||||
return { text, role, ...(color && { color }) }
|
||||
})
|
||||
}
|
||||
|
||||
function textWithoutSource(text: PreviewText): PreviewText {
|
||||
return 'markdown' in text ? text : withoutSource(text)
|
||||
}
|
||||
|
||||
function itemWithoutSource(item: PropertyItem): PropertyItem {
|
||||
return {
|
||||
cells: item.cells.map(withoutSource),
|
||||
...(item.items && { items: item.items.map(itemWithoutSource) }),
|
||||
...(item.table && { table: tableWithoutSource(item.table) }),
|
||||
}
|
||||
}
|
||||
|
||||
function tableWithoutSource(table: PreviewTable): PreviewTable {
|
||||
return { columns: table.columns, items: table.items.map(itemWithoutSource) }
|
||||
}
|
||||
|
||||
function rowWithoutSource(row: PropertyRow): PropertyRow {
|
||||
return {
|
||||
label: row.label,
|
||||
value: textWithoutSource(row.value),
|
||||
...(row.items && { items: row.items.map(itemWithoutSource) }),
|
||||
...(row.columns && { columns: row.columns }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧版本里的一块,拿来画删掉的块:只留它自己的内容(子块去掉),词去掉指向源码的下标,也不带名字的范围(defines)。
|
||||
*/
|
||||
export function removedView(view: BlockView): BlockView {
|
||||
const { defines: _defines, children: _children, ...rest } = view
|
||||
const base = { ...rest, children: [] }
|
||||
if ('properties' in view)
|
||||
return { ...base, properties: view.properties.map(rowWithoutSource) }
|
||||
return {
|
||||
...base,
|
||||
translation: textWithoutSource(view.translation),
|
||||
...(view.table && { table: tableWithoutSource(view.table) }),
|
||||
...(view.doc && { doc: view.doc }),
|
||||
}
|
||||
}
|
||||
|
||||
/** 一个词比较用的样子:词性和字(框是样式、框头和框里的词);源码下标不算,两个版本里同样的词下标不一样。 */
|
||||
function pieceKey(piece: Inline): string {
|
||||
return 'role' in piece ? `${piece.role}\u0000${piece.text}` : `${piece.style}\u0000${piece.label}\u0000[${piece.segments.map(pieceKey).join('\u0001')}]`
|
||||
}
|
||||
|
||||
/** 着色的词拼成的字(框只算框里的词)。 */
|
||||
export function inlineText(pieces: readonly Inline[]): string {
|
||||
return pieces.map(piece => 'role' in piece ? piece.text : inlineText(piece.segments)).join('')
|
||||
}
|
||||
|
||||
function sameInline(a: readonly Inline[], b: readonly Inline[]): boolean {
|
||||
return a.length === b.length && a.every((piece, i) => pieceKey(piece) === pieceKey(b[i]))
|
||||
}
|
||||
|
||||
/** 挨着的同一种截并成一截,空的截去掉。 */
|
||||
function mergeParts(parts: readonly InlinePart[]): InlinePart[] {
|
||||
const merged: InlinePart[] = []
|
||||
for (const part of parts) {
|
||||
if (part.segments.length === 0)
|
||||
continue
|
||||
const last = merged.at(-1)
|
||||
if (last && last.change === part.change)
|
||||
last.segments = [...last.segments, ...part.segments]
|
||||
else
|
||||
merged.push({ segments: [...part.segments], ...(part.change && { change: part.change }) })
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* 着色的词前后两版比:按词(一段着色的词是一个)对齐,没变的照新版本,删掉的取旧版本(去掉下标)、新加的照新版本;
|
||||
* 没变的字少于长的那边的 PARTS_MIN_SHARE 时不逐截标,整段写成旧值 → 新值。一样时是 undefined。
|
||||
*/
|
||||
export function inlineDiff(before: readonly Inline[], after: readonly Inline[]): InlinePart[] | undefined {
|
||||
if (sameInline(before, after))
|
||||
return undefined
|
||||
const changes = diffArrays([...before], [...after], { comparator: (a, b) => pieceKey(a) === pieceKey(b) })
|
||||
const kept = changes.filter(change => !change.added && !change.removed).reduce((sum, change) => sum + inlineText(change.value).length, 0)
|
||||
const longer = Math.max(inlineText(before).length, inlineText(after).length)
|
||||
if (kept < longer * PARTS_MIN_SHARE)
|
||||
return mergeParts([{ segments: withoutSource(before), change: 'del' }, { segments: [...after], change: 'add' }])
|
||||
return mergeParts(changes.map((change) => {
|
||||
if (change.removed)
|
||||
return { segments: withoutSource(change.value), change: 'del' }
|
||||
return change.added ? { segments: change.value, change: 'add' } : { segments: change.value }
|
||||
}))
|
||||
}
|
||||
|
||||
/** Markdown 说明前后两版比:按词切(中文按字、标点各算一个),删掉的取旧版本的字。一样时是 undefined。 */
|
||||
export function markdownDiff(before: string, after: string): MarkdownPart[] | undefined {
|
||||
if (before === after)
|
||||
return undefined
|
||||
return diffWordsWithSpace(before, after).map((change) => {
|
||||
if (change.removed)
|
||||
return { text: change.value, change: 'del' }
|
||||
return change.added ? { text: change.value, change: 'add' } : { text: change.value }
|
||||
})
|
||||
}
|
||||
|
||||
/** 一段预览文字前后两版比:都是着色的词逐截比,都是 Markdown 逐词比;一种换成了另一种时按字逐词比。一样时是 undefined。 */
|
||||
export function textDiff(before: PreviewText, after: PreviewText): TextDiff | undefined {
|
||||
if (!('markdown' in before) && !('markdown' in after)) {
|
||||
const inline = inlineDiff(before, after)
|
||||
return inline && { inline }
|
||||
}
|
||||
const plain = (text: PreviewText): string => 'markdown' in text ? text.markdown : inlineText(text)
|
||||
const markdown = markdownDiff(plain(before), plain(after))
|
||||
return markdown && { markdown }
|
||||
}
|
||||
|
||||
/** 两串东西配上的:新的第几个配旧的第几个(没配上是 undefined),反过来也记一份。 */
|
||||
export interface Pairing {
|
||||
toOld: (number | undefined)[]
|
||||
toNew: (number | undefined)[]
|
||||
}
|
||||
|
||||
/** 按 key 配对:key 一样的照先后一一配上(两个同名参数,第一个配第一个)。 */
|
||||
export function pairByKey<T>(before: readonly T[], after: readonly T[], key: (item: T) => string): Pairing {
|
||||
const waiting = new Map<string, number[]>()
|
||||
before.forEach((item, index) => waiting.set(key(item), [...waiting.get(key(item)) ?? [], index]))
|
||||
const toOld = after.map(item => waiting.get(key(item))?.shift())
|
||||
const toNew: (number | undefined)[] = before.map(() => undefined)
|
||||
toOld.forEach((old, index) => {
|
||||
if (old !== undefined)
|
||||
toNew[old] = index
|
||||
})
|
||||
return { toOld, toNew }
|
||||
}
|
||||
|
||||
/** 旧的里没配上的,各排在旧版本里它前面最近一个配上的那项所配的新项后面(前面没有配上的就排最前面,after 是 -1)。 */
|
||||
export function removedItems<T>(before: readonly T[], toNew: readonly (number | undefined)[], strip: (item: T) => T): Removed<T>[] {
|
||||
const removed: Removed<T>[] = []
|
||||
let after = -1
|
||||
before.forEach((item, index) => {
|
||||
const paired = toNew[index]
|
||||
if (paired === undefined)
|
||||
removed.push({ after, value: strip(item) })
|
||||
else
|
||||
after = paired
|
||||
})
|
||||
return removed
|
||||
}
|
||||
|
||||
/** 整层子行都是新加的(新加的行下面各层也是)。 */
|
||||
function allAdded(items: readonly PropertyItem[] | undefined): ItemsDiff | undefined {
|
||||
if (!items?.length)
|
||||
return undefined
|
||||
return { changed: Object.fromEntries(items.map((item, index) => [index, addedItem(item)])), removed: [] }
|
||||
}
|
||||
|
||||
function addedItem(item: PropertyItem): ItemDiff {
|
||||
const items = allAdded(item.items)
|
||||
const table = allAdded(item.table?.items)
|
||||
return { added: true, ...(items && { items }), ...(table && { table }) }
|
||||
}
|
||||
|
||||
/** 配上的两个子行比:各格逐截比,下一层、套着的表逐行比。一样时是 undefined。 */
|
||||
function itemDiff(before: PropertyItem, after: PropertyItem): ItemDiff | undefined {
|
||||
const cells: Record<number, InlinePart[]> = {}
|
||||
after.cells.forEach((cell, index) => {
|
||||
const parts = inlineDiff(before.cells[index] ?? [], cell)
|
||||
if (parts)
|
||||
cells[index] = parts
|
||||
})
|
||||
const items = itemsDiff(before.items, after.items)
|
||||
const table = itemsDiff(before.table?.items, after.table?.items)
|
||||
const diff: ItemDiff = {
|
||||
...(Object.keys(cells).length > 0 && { cells }),
|
||||
...(items && { items }),
|
||||
...(table && { table }),
|
||||
}
|
||||
return Object.keys(diff).length > 0 ? diff : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 一层子行前后两版比:按第一列的字配对(参数按名字、导入项按名称、字典按键),配上的逐格比,
|
||||
* 新版本多出来的算新加的行,旧版本里没配上的算删掉的行、排回原来的位置。一样时是 undefined。
|
||||
*/
|
||||
export function itemsDiff(before: readonly PropertyItem[] | undefined, after: readonly PropertyItem[] | undefined): ItemsDiff | undefined {
|
||||
const old = before ?? []
|
||||
const now = after ?? []
|
||||
const { toOld, toNew } = pairByKey(old, now, item => inlineText(item.cells[0] ?? []))
|
||||
const changed: Record<number, ItemDiff> = {}
|
||||
now.forEach((item, index) => {
|
||||
const paired = toOld[index]
|
||||
const diff = paired === undefined ? addedItem(item) : itemDiff(old[paired], item)
|
||||
if (diff)
|
||||
changed[index] = diff
|
||||
})
|
||||
const removed = removedItems(old, toNew, itemWithoutSource)
|
||||
return Object.keys(changed).length > 0 || removed.length > 0 ? { changed, removed } : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 配上的两行比:下面的子行逐行比,值逐截 / 逐词比。子行改了时只标子行:带子行的行,值是子行的汇总(「3 个」「字典 2 项」),
|
||||
* 跟着子行变,不另标。一样时是 undefined。
|
||||
*/
|
||||
function rowDiff(before: PropertyRow, after: PropertyRow): RowDiff | undefined {
|
||||
const items = itemsDiff(before.items, after.items)
|
||||
if (items)
|
||||
return { items }
|
||||
const value = textDiff(before.value, after.value)
|
||||
return value && { value }
|
||||
}
|
||||
|
||||
/** 属性表前后两版比:按属性名配对,配上的比值和子行,多出来的行算新加的,没了的行算删掉的、排回原来的位置。一样时是 undefined。 */
|
||||
export function rowsDiff(before: readonly PropertyRow[], after: readonly PropertyRow[]): RowsDiff | undefined {
|
||||
const { toOld, toNew } = pairByKey(before, after, row => row.label)
|
||||
const changed: Record<number, RowDiff> = {}
|
||||
after.forEach((row, index) => {
|
||||
const paired = toOld[index]
|
||||
const items = paired === undefined ? allAdded(row.items) : undefined
|
||||
const diff = paired === undefined ? { added: true as const, ...(items && { items }) } : rowDiff(before[paired], row)
|
||||
if (diff)
|
||||
changed[index] = diff
|
||||
})
|
||||
const removed = removedItems(before, toNew, rowWithoutSource)
|
||||
return Object.keys(changed).length > 0 || removed.length > 0 ? { changed, removed } : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 配上的两块自己的内容(不算子块)比:属性表逐行,一行译文逐截,摊开实参的表逐行,说明逐词。
|
||||
* 一样(或一块是属性表、一块是一行译文,没法逐行比)时是 undefined。
|
||||
*/
|
||||
export function blockMarks(before: BlockView, after: BlockView): BlockMarks | undefined {
|
||||
if ('properties' in before || 'properties' in after) {
|
||||
const rows = 'properties' in before && 'properties' in after ? rowsDiff(before.properties, after.properties) : undefined
|
||||
return rows && { rows }
|
||||
}
|
||||
const translation = textDiff(before.translation, after.translation)
|
||||
const table = itemsDiff(before.table?.items, after.table?.items)
|
||||
const doc = textDiff(before.doc ?? { markdown: '' }, after.doc ?? { markdown: '' })
|
||||
const marks: BlockMarks = { ...(translation && { translation }), ...(table && { table }), ...(doc && { doc }) }
|
||||
return Object.keys(marks).length > 0 ? marks : undefined
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Memento } from 'vscode'
|
||||
import type { Excluded } from './blockFilter'
|
||||
import type { FilterState } from './blockFilter'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BlockFilter, FILTER_KEY, normalizeExcluded } from './blockFilter'
|
||||
import { BlockFilter, FILTER_KEY, normalizeExcluded, ONLY_CHANGES_KEY } from './blockFilter'
|
||||
|
||||
function memento(initial: Record<string, unknown> = {}): Pick<Memento, 'get' | 'update'> & { store: Record<string, unknown> } {
|
||||
const store = { ...initial }
|
||||
@@ -19,26 +19,41 @@ describe('块筛选:整个工作区共用一份', () => {
|
||||
const state = memento()
|
||||
await new BlockFilter(state).set({ access: ['private'], kind: ['comment', 'import'] })
|
||||
expect(state.store[FILTER_KEY]).toEqual({ access: ['private'], kind: ['comment', 'import'] })
|
||||
expect(new BlockFilter(state).excluded).toEqual({ access: ['private'], kind: ['comment', 'import'] })
|
||||
expect(new BlockFilter(state).current.excluded).toEqual({ access: ['private'], kind: ['comment', 'import'] })
|
||||
})
|
||||
|
||||
it('一个预览改了,所有订阅的预览都收到新的筛选;取消订阅后不再收到', async () => {
|
||||
const filter = new BlockFilter(memento())
|
||||
const first: Excluded[] = []
|
||||
const second: Excluded[] = []
|
||||
filter.subscribe(excluded => first.push(excluded))
|
||||
const off = filter.subscribe(excluded => second.push(excluded))
|
||||
const first: FilterState[] = []
|
||||
const second: FilterState[] = []
|
||||
filter.subscribe(state => first.push(state))
|
||||
const off = filter.subscribe(state => second.push(state))
|
||||
await filter.set({ async: ['true'] })
|
||||
off.dispose()
|
||||
await filter.set({})
|
||||
expect(first).toEqual([{ async: ['true'] }, {}])
|
||||
expect(second).toEqual([{ async: ['true'] }])
|
||||
expect(first).toEqual([{ excluded: { async: ['true'] }, onlyChanges: false }, { excluded: {}, onlyChanges: false }])
|
||||
expect(second).toEqual([{ excluded: { async: ['true'] }, onlyChanges: false }])
|
||||
})
|
||||
|
||||
it('读回来的、webview 发来的逐项核对:形状不对的当没有,值去重排序,空的属性去掉', () => {
|
||||
expect(normalizeExcluded(undefined)).toEqual({})
|
||||
expect(normalizeExcluded(['access'])).toEqual({})
|
||||
expect(normalizeExcluded({ kind: ['if', 'comment', 'if', 3], access: 'private', async: [] })).toEqual({ kind: ['comment', 'if'] })
|
||||
expect(new BlockFilter(memento({ [FILTER_KEY]: 'broken' })).excluded).toEqual({})
|
||||
expect(new BlockFilter(memento({ [FILTER_KEY]: 'broken' })).current.excluded).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('只看改动:整个工作区共用一份', () => {
|
||||
it('开着的记进 workspaceState,关了就不记;改了发给所有订阅的预览,带着眼下的块筛选', async () => {
|
||||
const state = memento({ [FILTER_KEY]: { kind: ['comment'] } })
|
||||
const filter = new BlockFilter(state)
|
||||
const seen: FilterState[] = []
|
||||
filter.subscribe(value => seen.push(value))
|
||||
await filter.setOnlyChanges(true)
|
||||
expect(state.store[ONLY_CHANGES_KEY]).toBe(true)
|
||||
expect(new BlockFilter(state).current.onlyChanges).toBe(true)
|
||||
await filter.setOnlyChanges(false)
|
||||
expect(state.store[ONLY_CHANGES_KEY]).toBeUndefined()
|
||||
expect(seen).toEqual([{ excluded: { kind: ['comment'] }, onlyChanges: true }, { excluded: { kind: ['comment'] }, onlyChanges: false }])
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,8 @@ import type { Disposable, Memento } from 'vscode'
|
||||
|
||||
/** workspaceState 里记的块筛选:Excluded。 */
|
||||
export const FILTER_KEY = 'superpowers.codeReader.filter'
|
||||
/** workspaceState 里记的「只看改动」:标差异时连着没变的块折起来。 */
|
||||
export const ONLY_CHANGES_KEY = 'superpowers.codeReader.onlyChanges'
|
||||
|
||||
/**
|
||||
* 块筛选:属性 id → 不勾(藏起来)的值 id。记的是去掉的值而不是勾着的值:
|
||||
@@ -9,6 +11,12 @@ export const FILTER_KEY = 'superpowers.codeReader.filter'
|
||||
*/
|
||||
export type Excluded = Record<string, string[]>
|
||||
|
||||
/** 预览怎么藏块:块筛选(Excluded),和标差异时「只看改动」开没开(连着没变的块折成一条)。 */
|
||||
export interface FilterState {
|
||||
excluded: Excluded
|
||||
onlyChanges: boolean
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -25,29 +33,40 @@ export function normalizeExcluded(value: unknown): Excluded {
|
||||
}
|
||||
|
||||
/**
|
||||
* 整个工作区共用的一份块筛选:记在 workspaceState 里,重新加载窗口后还在;
|
||||
* 整个工作区共用的一份块筛选和「只看改动」开关:记在 workspaceState 里,重新加载窗口后还在;
|
||||
* 一个预览改了,所有订阅的预览都收到(包括改的那个,webview 只按扩展端发来的显示)。
|
||||
* 不依赖 vscode 运行时,便于单元测试。
|
||||
*/
|
||||
export class BlockFilter {
|
||||
private readonly listeners = new Set<(excluded: Excluded) => void>()
|
||||
private readonly listeners = new Set<(state: FilterState) => void>()
|
||||
|
||||
constructor(private readonly state: Pick<Memento, 'get' | 'update'>) {}
|
||||
|
||||
get excluded(): Excluded {
|
||||
return normalizeExcluded(this.state.get(FILTER_KEY))
|
||||
get current(): FilterState {
|
||||
return { excluded: normalizeExcluded(this.state.get(FILTER_KEY)), onlyChanges: this.state.get(ONLY_CHANGES_KEY) === true }
|
||||
}
|
||||
|
||||
/** 筛选变了就调 listener,带上新的筛选。 */
|
||||
subscribe(listener: (excluded: Excluded) => void): Disposable {
|
||||
/** 块筛选或「只看改动」变了就调 listener,带上整份新的。 */
|
||||
subscribe(listener: (state: FilterState) => void): Disposable {
|
||||
this.listeners.add(listener)
|
||||
return { dispose: () => this.listeners.delete(listener) }
|
||||
}
|
||||
|
||||
/** 换掉块筛选(整份)。 */
|
||||
async set(value: unknown): Promise<void> {
|
||||
const excluded = normalizeExcluded(value)
|
||||
await this.state.update(FILTER_KEY, excluded)
|
||||
await this.state.update(FILTER_KEY, normalizeExcluded(value))
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/** 打开 / 关掉「只看改动」。 */
|
||||
async setOnlyChanges(on: boolean): Promise<void> {
|
||||
await this.state.update(ONLY_CHANGES_KEY, on || undefined)
|
||||
this.notify()
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
const state = this.current
|
||||
for (const listener of [...this.listeners])
|
||||
listener(excluded)
|
||||
listener(state)
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,11 @@
|
||||
import type { ClaudeImage } from '../../cc/spawnClaude'
|
||||
import type { FileAttribute } from '../attributes'
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { DocumentDiff } from '../diff/previewDiff'
|
||||
import type { ShownName } from '../nameResolution'
|
||||
import type { ProfileChoice } from '../qa/askProfiles'
|
||||
import type { RunningAsk } from '../qa/asks'
|
||||
import type { Excluded } from './blockFilter'
|
||||
import type { Excluded, FilterState } from './blockFilter'
|
||||
|
||||
export type { Excluded, ShownName }
|
||||
|
||||
@@ -83,13 +84,15 @@ export interface QaMessage {
|
||||
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
|
||||
* definitionProbed:id 那次 probeDefinition 的回答,found 表示 VS Code 查得到定义
|
||||
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
|
||||
* filter:整个工作区共用的块筛选;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览
|
||||
* filter:整个工作区共用的块筛选和「只看改动」开关;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览
|
||||
* qa:这份文件的问答;ready 后发一次,问答、正在答的问题变了再发
|
||||
* askProfiles:「问 AI」输入框旁边的 profile 下拉:选项和选中的那项;ready 后发一次,任何一个预览改了都发给所有预览
|
||||
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
|
||||
* answerEnd:askId 那个问题答完了:done 带最终的回答和存下的那条的 id,failed 带给人看的原因,stopped 是被叫停
|
||||
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览
|
||||
* displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理
|
||||
* diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有,见 DiffBase):每发一份文档之后算好了补发,
|
||||
* 换了筛选、基准挪了(提交后 HEAD 动了)再发;null 是不比了(回到全部文件)
|
||||
*/
|
||||
export type ExtensionToWebview
|
||||
= | DocumentMessage
|
||||
@@ -98,11 +101,12 @@ export type ExtensionToWebview
|
||||
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
|
||||
| { type: 'moveOccurrence', step: 1 | -1 }
|
||||
| { type: 'displayNames', version: number, names: ShownName[] }
|
||||
| { type: 'filter', excluded: Excluded }
|
||||
| ({ type: 'filter' } & FilterState)
|
||||
| QaMessage
|
||||
| ({ type: 'askProfiles' } & ProfileChoice)
|
||||
| { type: 'answer', askId: string, text: string, activity?: string }
|
||||
| { type: 'answerEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string }
|
||||
| { type: 'diff', diff: DocumentDiff | null }
|
||||
|
||||
/**
|
||||
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进)
|
||||
@@ -112,6 +116,7 @@ export type ExtensionToWebview
|
||||
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
|
||||
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
|
||||
* setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览
|
||||
* setOnlyChanges:打开 / 关掉「只看改动」,扩展端记下后发 filter 给所有预览
|
||||
* ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;扩展端接着这块最近一次问答的会话问,用眼下选的 profile;
|
||||
* images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存
|
||||
* stopAsk:叫停 askId 那个问题,这次不存
|
||||
@@ -123,6 +128,7 @@ export type ExtensionToWebview
|
||||
export type WebviewToExtension
|
||||
= | { type: 'ready' }
|
||||
| { type: 'setFilter', excluded: Excluded }
|
||||
| { type: 'setOnlyChanges', on: boolean }
|
||||
| { type: 'edit', startLine: number, endLine: number, text: string }
|
||||
| { type: 'selectTranslator', id: string }
|
||||
| { type: 'visibleLine', line: number }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode'
|
||||
import type { CustomTextEditorProvider, Disposable, Event, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode'
|
||||
import type { ClaudeImage } from '../../cc/spawnClaude'
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, WorkspaceIndex } from '../contract'
|
||||
import type { DiffBase } from '../diff/diffBase'
|
||||
import type { OldDocument, SentDocument } from '../diff/previewDiff'
|
||||
import type { DefinitionStart, IndexedName, NameSources } from '../nameResolution'
|
||||
import type { CodeQa } from '../qa/codeQa'
|
||||
import type { CodeReaderRegistry } from '../registry'
|
||||
@@ -15,6 +16,7 @@ import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { webviewHtml, webviewUiRoot } from '../../webview/html'
|
||||
import { DocumentCallees } from '../calleeKinds'
|
||||
import { PreviewDiff } from '../diff/previewDiff'
|
||||
import { blockReplacement } from '../editText'
|
||||
import { locate } from '../folderStore'
|
||||
import { definitionFor, symbolAt, symbolOccurrences } from '../localSymbols'
|
||||
@@ -33,6 +35,12 @@ export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview'
|
||||
/** 连续输入时合并成一次重新解析。 */
|
||||
const REFRESH_DELAY_MS = 150
|
||||
|
||||
/** 预览对比什么:侧栏开着的筛选定(TreeFilters),换了筛选、基准变了发 onDidChangeDiffBase。 */
|
||||
export interface DiffBases {
|
||||
diffBase: (uri: Uri) => DiffBase | undefined
|
||||
onDidChangeDiffBase: Event<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 「代码阅读预览」自定义编辑器。预览和普通编辑器共用同一个 TextDocument:
|
||||
* 块内修改走 WorkspaceEdit,未保存标记、⌘S、⌘Z 都是 VS Code 原生行为。
|
||||
@@ -57,6 +65,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
readonly indexedNames: (languageId: string, name: string) => Promise<IndexedName[]>,
|
||||
/** 库规则的工作区索引里键是 key 的各项(规则查名字的定义时用,见 ruleLookups.ts) */
|
||||
readonly indexed: (index: WorkspaceIndex, key: string) => Promise<IndexedEntry[]>,
|
||||
/** 预览标差异时对比什么 */
|
||||
readonly diffBases: DiffBases,
|
||||
) {}
|
||||
|
||||
resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void {
|
||||
@@ -127,8 +137,8 @@ class Preview {
|
||||
private readonly definitions: DefinitionSources<FoundLocation>
|
||||
/** webview 报来的视口顶部那块的起始行 */
|
||||
visibleLine: number | undefined
|
||||
/** 最近一次发出去的块树和它的源码:问 AI 时按块的记号在里面找那块代码 */
|
||||
private sent: { blocks: BlockView[], source: string } | undefined
|
||||
/** 最近一次发出去的文档(版本、块树、源码):问 AI 时按块的记号在里面找那块代码,对比基准变了时拿它重新比 */
|
||||
private sent: SentDocument | undefined
|
||||
/** 最近一次发出去的文档里语言插件自己认得出的名字(LanguageAdapter.symbols):语言扩展查不到定义、各处出现时用 */
|
||||
private symbols: { version: number, list: LocalSymbol[] } | undefined
|
||||
/** 这份文档各版本显示成中文名的名字 */
|
||||
@@ -137,6 +147,8 @@ class Preview {
|
||||
private readonly lookups: DocumentLookups
|
||||
/** 调用的名字定义成哪种块(分新建还是调用):查到的变了就重发文档 */
|
||||
private readonly callees: DocumentCallees
|
||||
/** 这份文件相比对比基准改了什么:发完文档再算、补发 */
|
||||
private readonly diff: PreviewDiff
|
||||
|
||||
constructor(
|
||||
private readonly document: TextDocument,
|
||||
@@ -164,7 +176,20 @@ class Preview {
|
||||
})
|
||||
this.lookups = new DocumentLookups(() => this.schedulePush())
|
||||
this.callees = new DocumentCallees(() => this.schedulePush())
|
||||
this.diff = new PreviewDiff(
|
||||
() => owner.diffBases.diffBase(document.uri),
|
||||
source => this.readOld(source),
|
||||
(diff) => {
|
||||
if (this.ready)
|
||||
this.post({ type: 'diff', diff })
|
||||
},
|
||||
text => logger.add({ level: 'warn', source: 'codeReader', message: `${document.uri.fsPath}:${text}` }),
|
||||
)
|
||||
this.disposables = [
|
||||
owner.diffBases.onDidChangeDiffBase(() => {
|
||||
if (this.ready && this.sent)
|
||||
void this.diff.update(this.sent)
|
||||
}),
|
||||
panel.webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)),
|
||||
panel.onDidChangeViewState(() => {
|
||||
if (panel.visible)
|
||||
@@ -180,9 +205,9 @@ class Preview {
|
||||
this.schedulePush()
|
||||
}),
|
||||
// 页面没 ready 时收不到,等它 ready 再随第一份文档之前补发
|
||||
filter.subscribe((excluded) => {
|
||||
filter.subscribe((state) => {
|
||||
if (this.ready)
|
||||
this.post({ type: 'filter', excluded })
|
||||
this.post({ type: 'filter', ...state })
|
||||
}),
|
||||
owner.qa.onDidChange((uris) => {
|
||||
if (uris.some(uri => uri.toString() === document.uri.toString()))
|
||||
@@ -240,11 +265,14 @@ class Preview {
|
||||
if (request !== this.latest || !this.ready)
|
||||
return
|
||||
this.definitions.remember(message.version, message.blocks)
|
||||
this.sent = { blocks: message.blocks, source }
|
||||
// 解析失败的文档没有块:问 AI 找不到块,也不拿它比差异
|
||||
this.sent = message.error === undefined ? { version: message.version, blocks: message.blocks, source } : undefined
|
||||
this.symbols = { version, list: symbols }
|
||||
const reveal = this.pendingReveal
|
||||
this.pendingReveal = undefined
|
||||
this.post({ ...message, names: this.names.known(version), ...(reveal && { reveal }) })
|
||||
if (this.sent)
|
||||
void this.diff.update(this.sent)
|
||||
// 文档在解析期间又改了的,等下一次发文档再查
|
||||
if (version === this.document.version) {
|
||||
this.names.resolve(version, report => resolveNames(displayNames, message.blocks, this.nameSources(), report))
|
||||
@@ -253,6 +281,22 @@ class Preview {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对比基准里的旧版本排成视图数据:和这份文档同一种语言、同一个翻译插件和库规则;规则查定义、调用的种类用这份文档眼下查到的答案,
|
||||
* 两边同样的写法翻得一样,不会因为答案晚到被当成改了。旧版本里规则出的错不记(记的是现在这份的)。
|
||||
*/
|
||||
private async readOld(source: string): Promise<OldDocument | undefined> {
|
||||
const { message } = await documentMessage(
|
||||
{ languageId: this.document.languageId, version: this.document.version, getText: () => source },
|
||||
this.registry,
|
||||
this.runtime,
|
||||
() => {},
|
||||
this.lookups.session().lookup,
|
||||
this.callees.session().calleeKind,
|
||||
)
|
||||
return message.error === undefined ? { blocks: message.blocks, attributes: message.attributes } : undefined
|
||||
}
|
||||
|
||||
/** 这份文档里 offset 处名字的定义是哪种块:语言扩展查到的几处定义里第一处认得出的;语言扩展没答是 undefined。 */
|
||||
private async calleeKind(offset: number): Promise<BlockKind | null | undefined> {
|
||||
const { document } = this
|
||||
@@ -360,7 +404,7 @@ class Preview {
|
||||
*/
|
||||
private async ask(askId: string, anchor: string, question: string, images: ClaudeImage[]): Promise<void> {
|
||||
const at = locate(this.document.uri)
|
||||
const subject = at && this.sent && { path: at.key, languageId: this.document.languageId, ...this.sent, anchor }
|
||||
const subject = at && this.sent && { path: at.key, languageId: this.document.languageId, blocks: this.sent.blocks, source: this.sent.source, anchor }
|
||||
const prompt = subject && askPrompt(subject, question)
|
||||
const followUp = subject && followUpPrompt(subject, question)
|
||||
const refused = prompt === undefined || followUp === undefined
|
||||
@@ -383,7 +427,7 @@ class Preview {
|
||||
case 'ready':
|
||||
this.ready = true
|
||||
// 先于文档发:第一份文档按筛选画,不会先整份露出来再藏
|
||||
this.post({ type: 'filter', excluded: this.filter.excluded })
|
||||
this.post({ type: 'filter', ...this.filter.current })
|
||||
this.postQa()
|
||||
void this.postProfiles()
|
||||
await this.push()
|
||||
@@ -403,6 +447,9 @@ class Preview {
|
||||
case 'setFilter':
|
||||
await this.filter.set(msg.excluded)
|
||||
return
|
||||
case 'setOnlyChanges':
|
||||
await this.filter.setOnlyChanges(msg.on)
|
||||
return
|
||||
case 'edit':
|
||||
await this.applyEdit(msg.startLine, msg.endLine, msg.text)
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TreeView } from 'vscode'
|
||||
import type { Event, TreeView, Uri } from 'vscode'
|
||||
import type { DiffBase } from '../diff/diffBase'
|
||||
import type { FileTreeProvider, TreeNode } from './fileTree'
|
||||
import { commands, Disposable } from 'vscode'
|
||||
import { commands, Disposable, EventEmitter } from 'vscode'
|
||||
|
||||
/** 上下文键:侧栏树开着哪种筛选,没设是全部文件。标题栏的「显示全部」、行上的注释按钮按它显示 / 藏起来。 */
|
||||
const FILTER_CONTEXT = 'superpowers.codeReader.filter'
|
||||
@@ -14,18 +15,39 @@ export interface TreeFilter {
|
||||
reload: () => Promise<void>
|
||||
/** 「显示全部」或换成别的筛选:不再筛、不再记着,正在进的作废 */
|
||||
exit: () => Promise<void>
|
||||
/** 开着这种筛选时 uri 这个文件的预览对比什么(见 DiffBase);不在这种筛选管的范围里(仓库外、工作副本外)时没有 */
|
||||
diffBase: (uri: Uri) => DiffBase | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏树上的几种筛选,同时只开一种:开一种时别的都退出;上下文键跟着开着的那种。
|
||||
* 筛选开起来时自己换上树、标题后面的字(description)、标题下面的一行(message);退出时由这里回到工作区文件树。
|
||||
* 开着的筛选也定打开着的预览对比什么(diffBase),换了筛选、基准变了都通知预览重新比。
|
||||
*/
|
||||
export class TreeFilters {
|
||||
private readonly filters: TreeFilter[] = []
|
||||
private current: TreeFilter | undefined
|
||||
private readonly baseChange = new EventEmitter<void>()
|
||||
/** 开着的筛选换了(开了、换了、关了),或它的对比基准变了(git 状态变了、审查重读了 PR):打开着的预览照 diffBase 重新比 */
|
||||
readonly onDidChangeDiffBase: Event<void> = this.baseChange.event
|
||||
|
||||
constructor(private readonly files: FileTreeProvider, private readonly view: TreeView<TreeNode>) {}
|
||||
|
||||
/** 开着的筛选下 uri 这个文件的预览对比什么;没开筛选(全部文件)时没有,预览不标差异。 */
|
||||
diffBase(uri: Uri): DiffBase | undefined {
|
||||
return this.current?.diffBase(uri)
|
||||
}
|
||||
|
||||
/** filter 的对比基准变了时调;它没开着就不理。 */
|
||||
baseChanged(filter: TreeFilter): void {
|
||||
if (this.current === filter)
|
||||
this.baseChange.fire()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.baseChange.dispose()
|
||||
}
|
||||
|
||||
/** 开着的那种筛选;没开是全部文件 */
|
||||
get active(): TreeFilter | undefined {
|
||||
return this.current
|
||||
@@ -57,6 +79,7 @@ export class TreeFilters {
|
||||
/** filter 换上侧栏树之前调:别的筛选都退出(同步的部分在这里就做完了,调用方紧接着换上自己的),上下文键设成 kind。 */
|
||||
async enter(filter: TreeFilter, kind: TreeFilterKind): Promise<void> {
|
||||
this.current = filter
|
||||
this.baseChange.fire()
|
||||
await Promise.all([
|
||||
...this.filters.filter(other => other !== filter).map(other => other.exit()),
|
||||
commands.executeCommand('setContext', FILTER_CONTEXT, kind),
|
||||
@@ -68,6 +91,7 @@ export class TreeFilters {
|
||||
if (this.current !== filter)
|
||||
return
|
||||
this.current = undefined
|
||||
this.baseChange.fire()
|
||||
this.files.showChanges(undefined)
|
||||
this.view.description = undefined
|
||||
this.view.message = undefined
|
||||
|
||||
@@ -84,13 +84,14 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
const adapter = registry.languageFor(languageId)
|
||||
return adapter ? indexedNames((index, key) => indexes.find(index, key), adapter, name) : []
|
||||
}
|
||||
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key))
|
||||
const notes = new FileNotes()
|
||||
const files = new FileTreeProvider(OPEN_COMMAND, notes)
|
||||
const view = window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true })
|
||||
const filters = new TreeFilters(files, view)
|
||||
const review = new ReviewMode(context, FILES_VIEW, view, files, filters)
|
||||
const uncommitted = new UncommittedMode(context, FILES_VIEW, view, files, filters)
|
||||
// 侧栏开着的筛选定预览对比什么:未提交的更改对比上一次提交,审查工单对比 PR 的目标分支
|
||||
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key), filters)
|
||||
|
||||
context.subscriptions.push(
|
||||
qa,
|
||||
@@ -105,6 +106,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
window.registerCustomEditorProvider(PREVIEW_VIEW_TYPE, previews),
|
||||
files,
|
||||
view,
|
||||
filters,
|
||||
review,
|
||||
uncommitted,
|
||||
// 开着筛选时刷新是重读它摆的文件(PR 改过的、git 状态),平时是重读工作区文件树
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CHANGE_STYLES, changeOf } from './changedFiles'
|
||||
import { CHANGE_STYLES, changeOf, reviewBaseFile } from './changedFiles'
|
||||
|
||||
describe('改动状态', () => {
|
||||
it('新增 A、删除 D,别的(改名、复制、改了、没改)都算修改 M', () => {
|
||||
@@ -16,3 +16,18 @@ describe('改动状态', () => {
|
||||
expect(CHANGE_STYLES.D.tooltip).toBe('PR 里删掉了')
|
||||
})
|
||||
})
|
||||
|
||||
describe('审查时在 PR 目标分支上取哪个文件', () => {
|
||||
const files = [
|
||||
{ path: 'src/new.ts', status: 'added' },
|
||||
{ path: 'src/moved.ts', status: 'renamed', previousFilename: 'src/old.ts' },
|
||||
{ path: 'src/changed.ts', status: 'changed' },
|
||||
]
|
||||
|
||||
it('新加的目标分支上没有,改名的取改名前的路径,别的照原路径', () => {
|
||||
expect(reviewBaseFile(files, 'src/new.ts')).toEqual({ path: 'src/new.ts', added: true })
|
||||
expect(reviewBaseFile(files, 'src/moved.ts')).toEqual({ path: 'src/old.ts', added: false })
|
||||
expect(reviewBaseFile(files, 'src/changed.ts')).toEqual({ path: 'src/changed.ts', added: false })
|
||||
expect(reviewBaseFile(files, 'src/untouched.ts')).toEqual({ path: 'src/untouched.ts', added: false })
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,15 @@ export function changeOf(status: string): FileChange {
|
||||
return status === 'added' ? 'A' : status === 'deleted' ? 'D' : 'M'
|
||||
}
|
||||
|
||||
/**
|
||||
* 审查时工作副本里的一个文件(path 相对工作副本的根)在 PR 目标分支上叫什么、有没有:
|
||||
* 改名的取改名前的路径,PR 里新加的(Gitea 的 added)目标分支上没有。PR 没改过的文件照原路径取。
|
||||
*/
|
||||
export function reviewBaseFile(files: readonly { path: string, status: string, previousFilename?: string }[], path: string): { path: string, added: boolean } {
|
||||
const file = files.find(item => item.path === path)
|
||||
return { path: file?.previousFilename ?? path, added: file?.status === 'added' }
|
||||
}
|
||||
|
||||
/** 审查时各种改动在侧栏上的样子:颜色用 VS Code 源代码管理的 git 装饰色(主题色 id),徽标就是字母本身。 */
|
||||
export const CHANGE_STYLES: Record<FileChange, { color: string, tooltip: string }> = {
|
||||
M: { color: 'gitDecoration.modifiedResourceForeground', tooltip: 'PR 里改了' },
|
||||
|
||||
@@ -2,17 +2,20 @@ import type { Disposable, ExtensionContext, QuickPickItem, TreeView, WorkspaceFo
|
||||
import type { PrFileDto } from '../../gitea/api'
|
||||
import type { RepoContext } from '../../gitea/repoContext'
|
||||
import type { Issue } from '../../gitea/types'
|
||||
import type { DiffBase } from '../diff/diffBase'
|
||||
import type { FileTreeProvider, TreeNode } from '../explorer/fileTree'
|
||||
import type { TreeFilter, TreeFilters } from '../explorer/treeFilters'
|
||||
import type { FileCount } from './pickList'
|
||||
import * as path from 'node:path'
|
||||
import { QuickPickItemKind, Uri, window, workspace } from 'vscode'
|
||||
import { fileAtRef } from '../../git/fileAt'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { buildChangedTree } from '../explorer/changedTree'
|
||||
import { toPathKey } from '../pathMap'
|
||||
import { ChangeDecorations } from './changeDecorations'
|
||||
import { changeOf } from './changedFiles'
|
||||
import { changeOf, reviewBaseFile } from './changedFiles'
|
||||
import { countingFiles, pickEntries, pickItemText, pickRows, withFileCount } from './pickList'
|
||||
import { columnLabel, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules'
|
||||
import { columnLabel, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewBaseLabel, reviewBaseMissing, reviewDescription, reviewMessage } from './reviewRules'
|
||||
import { loadIssue, loadPullRequest, loadPullRequestFiles, requireRepo, reviewCandidateIssues } from './reviewSource'
|
||||
import { localWorktree, prepareWorktree } from './reviewWorktree'
|
||||
|
||||
@@ -25,12 +28,16 @@ const REVIEW_ISSUE_KEY = 'superpowers.codeReader.reviewIssue'
|
||||
*/
|
||||
type How = 'pick' | 'reload' | 'restore'
|
||||
|
||||
/** 进审查要的东西都备好了:工单、它的 PR、PR 改过的文件、装着 PR 最新代码的工作副本。 */
|
||||
/**
|
||||
* 进审查要的东西都备好了:工单、它的 PR、PR 改过的文件、装着 PR 最新代码的工作副本,
|
||||
* 和预览对比的基准:PR 的目标分支名、目标分支与 PR 头的合并基(Gitea 没给时退回目标分支的提交,同看板「改动」tab)。
|
||||
*/
|
||||
interface Loaded {
|
||||
issue: Issue
|
||||
pr: number
|
||||
files: PrFileDto[]
|
||||
worktree: string
|
||||
base: { ref: string, commit: string }
|
||||
}
|
||||
|
||||
/** 弹框里的一行:工单的一项带着工单,分组标题(列名)没有。 */
|
||||
@@ -44,14 +51,14 @@ function errorText(err: unknown): string {
|
||||
|
||||
/**
|
||||
* 侧栏按审查工单筛文件:「选工单」列出看板「审查」「进行中」列里能审查的工单,选中后树里只剩这张工单的 PR 改过的文件(按改动着色),
|
||||
* 点开的是本机这张工单的工作副本里 PR 最新的代码;「显示全部」回到工作区文件树。选的工单按工作区记住。
|
||||
* 点开的是本机这张工单的工作副本里 PR 最新的代码,预览对比 PR 的目标分支(diffBase);「显示全部」回到工作区文件树。选的工单按工作区记住。
|
||||
* 和别的筛选(未提交的更改)互斥,见 TreeFilters。
|
||||
*/
|
||||
export class ReviewMode implements TreeFilter, Disposable {
|
||||
private readonly decorations = new ChangeDecorations()
|
||||
private readonly disposables: Disposable[]
|
||||
/** 正在审查的工单号 */
|
||||
private reviewing: number | undefined
|
||||
/** 正在审查的:工单、PR、改过的文件、工作副本、对比基准 */
|
||||
private loaded: Loaded | undefined
|
||||
/** 每开始一次进审查、退出审查都加一:慢的那次回来时已经不是最新的,结果丢掉。 */
|
||||
private generation = 0
|
||||
|
||||
@@ -149,7 +156,7 @@ export class ReviewMode implements TreeFilter, Disposable {
|
||||
/** 「显示全部」或换成别的筛选:不再审查、不再记着这张工单,正在进的审查作废;正审查着的回到工作区文件树。 */
|
||||
async exit(): Promise<void> {
|
||||
this.generation++
|
||||
this.reviewing = undefined
|
||||
this.loaded = undefined
|
||||
this.decorations.show([])
|
||||
await Promise.all([
|
||||
this.filters.leave(this),
|
||||
@@ -159,8 +166,27 @@ export class ReviewMode implements TreeFilter, Disposable {
|
||||
|
||||
/** 审查时的「刷新」:重读工单和 PR 改过的文件,工作副本也更新到 PR 最新的提交。 */
|
||||
async reload(): Promise<void> {
|
||||
if (this.reviewing !== undefined)
|
||||
await this.review(this.reviewing, 'reload')
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
@@ -240,17 +266,20 @@ export class ReviewMode implements TreeFilter, Disposable {
|
||||
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 }
|
||||
const { headSha, baseRef, mergeBase, baseSha } = reviewablePull.pull
|
||||
const worktree = await prepareWorktree(this.context, repo, issue, { number: pr, headSha }, step)
|
||||
return { issue, pr, files, worktree, base: { ref: baseRef, commit: mergeBase || baseSha } }
|
||||
}
|
||||
|
||||
private async show({ issue, pr, files, worktree }: Loaded): Promise<void> {
|
||||
private async show(loaded: Loaded): Promise<void> {
|
||||
const { issue, pr, files, worktree } = loaded
|
||||
const root = Uri.file(worktree)
|
||||
// 工作副本当根,名字照写工作区文件夹的:看的还是这个仓库,只是 PR 最新的代码
|
||||
const folder: WorkspaceFolder = { uri: root, name: workspace.workspaceFolders?.[0]?.name ?? path.basename(worktree), index: 0 }
|
||||
const changes = files.map(file => ({ path: file.path, change: changeOf(file.status) }))
|
||||
// 先记下再进:进的时候打开着的预览就来问对比基准
|
||||
this.loaded = loaded
|
||||
const entering = this.filters.enter(this, 'review')
|
||||
this.reviewing = issue.number
|
||||
this.files.showChanges([{ roots: [{ folder, tree: buildChangedTree(changes) }] }])
|
||||
this.decorations.show(changes.map(file => ({ uri: Uri.joinPath(root, file.path), change: file.change })))
|
||||
this.view.description = reviewDescription(issue.number)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { columnLabel, prNumberOf, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewCandidates, reviewDescription, reviewMessage } from './reviewRules'
|
||||
import { columnLabel, prNumberOf, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewBaseLabel, reviewBaseMissing, reviewCandidates, reviewDescription, reviewMessage } from './reviewRules'
|
||||
|
||||
const issue = { number: 446, title: '用户资料:上传头像,收紧非管理员改资料的权限' }
|
||||
|
||||
@@ -83,3 +83,12 @@ describe('还能不能审查(选中、刷新、重开窗口时判断)', () =
|
||||
expect(reviewablePullRequest(447, { state: 'closed', merged: false })).toEqual({ blocked: 'PR #447 已经关闭' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('审查时预览对比的基准', () => {
|
||||
it('对比 PR 要合进去的目标分支;目标分支上没有的文件说一句', () => {
|
||||
expect(reviewBaseLabel('master')).toBe('PR 目标分支 master')
|
||||
expect(reviewBaseMissing('master')).toBe('PR 目标分支 master 里没有这个文件')
|
||||
expect(reviewBaseLabel('')).toBe('PR 目标分支')
|
||||
expect(reviewBaseMissing('')).toBe('PR 目标分支里没有这个文件')
|
||||
})
|
||||
})
|
||||
@@ -76,3 +76,13 @@ export function reviewDescription(issueNumber: number): string {
|
||||
export function reviewMessage(issue: Pick<Issue, 'number' | 'title'>, pr: number, fileCount: number): string {
|
||||
return `#${issue.number} ${issue.title} · PR #${pr} · 改了 ${fileCount} 个文件`
|
||||
}
|
||||
|
||||
/** 审查时预览对比的基准(筛选栏上「对比:」后面的字):PR 要合进去的目标分支。 */
|
||||
export function reviewBaseLabel(baseRef: string): string {
|
||||
return baseRef ? `PR 目标分支 ${baseRef}` : 'PR 目标分支'
|
||||
}
|
||||
|
||||
/** PR 目标分支上没有这个文件(PR 里新加的)时横幅上的说明。 */
|
||||
export function reviewBaseMissing(baseRef: string): string {
|
||||
return baseRef ? `PR 目标分支 ${baseRef} 里没有这个文件` : 'PR 目标分支里没有这个文件'
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { GitStatus } from '../../git/gitApi'
|
||||
import type { ChangeGroups } from './uncommittedFiles'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { GIT_STATUS } from '../../git/gitApi'
|
||||
import { changeOfGitStatus, uncommittedDescription, uncommittedFiles, uncommittedMessage, uncommittedRoots, withoutTestFiles, workspaceOrder } from './uncommittedFiles'
|
||||
import { changeOfGitStatus, headMissingText, isUntracked, uncommittedDescription, uncommittedFiles, uncommittedMessage, uncommittedRoots, withoutTestFiles, workspaceOrder } from './uncommittedFiles'
|
||||
|
||||
const at = (path: string, status: GitStatus): { uri: { path: string }, status: GitStatus } => ({ uri: { path }, status })
|
||||
|
||||
@@ -188,3 +188,18 @@ describe('标题后面的字、标题下面的一行', () => {
|
||||
expect(uncommittedMessage({ hasFiles: false, hiddenTests: 0, loading: true })).toBe('正在读取 git 状态…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('对比上一次提交', () => {
|
||||
it('未跟踪的按状态认,在没暂存的那组里(mixed)、单独一组里(separate)都算', () => {
|
||||
expect(isUntracked(groups({ workingTreeChanges: [at('/r/a.ts', GIT_STATUS.UNTRACKED)] }), '/r/a.ts')).toBe(true)
|
||||
expect(isUntracked(groups({ untrackedChanges: [at('/r/a.ts', GIT_STATUS.UNTRACKED)] }), '/r/a.ts')).toBe(true)
|
||||
expect(isUntracked(groups({ workingTreeChanges: [at('/r/a.ts', GIT_STATUS.MODIFIED)] }), '/r/a.ts')).toBe(false)
|
||||
expect(isUntracked(groups({ untrackedChanges: [at('/r/b.ts', GIT_STATUS.UNTRACKED)] }), '/r/a.ts')).toBe(false)
|
||||
})
|
||||
|
||||
it('上一次提交里没有这个文件时的说明', () => {
|
||||
expect(headMissingText({ hasCommit: true, untracked: true })).toBe('还没被 git 跟踪,上一次提交里没有这个文件')
|
||||
expect(headMissingText({ hasCommit: true, untracked: false })).toBe('上一次提交里没有这个文件')
|
||||
expect(headMissingText({ hasCommit: false, untracked: true })).toBe('仓库里还没有提交')
|
||||
})
|
||||
})
|
||||
@@ -162,3 +162,18 @@ export function uncommittedMessage(state: { hasFiles: boolean, hiddenTests: numb
|
||||
return '除了隐藏的测试文件,没有未提交的更改'
|
||||
return state.loading ? '正在读取 git 状态…' : '没有未提交的更改'
|
||||
}
|
||||
|
||||
/** 「未提交的更改」开着时预览对比的基准:上一次提交(HEAD)。 */
|
||||
export const HEAD_BASE_LABEL = '上一次提交'
|
||||
|
||||
/** 文件是不是未跟踪的:设置 git.untrackedChanges 是 mixed(默认)时未跟踪的混在没暂存的那组里,按状态认。 */
|
||||
export function isUntracked(groups: Pick<ChangeGroups, 'workingTreeChanges' | 'untrackedChanges'>, filePath: string): boolean {
|
||||
return [groups.workingTreeChanges, groups.untrackedChanges].some(group => group.some(entry => entry.uri.path === filePath && entry.status === GIT_STATUS.UNTRACKED))
|
||||
}
|
||||
|
||||
/** 上一次提交里没有这个文件时横幅上的说明:仓库还没有提交、文件还没被 git 跟踪各说一句。 */
|
||||
export function headMissingText(state: { hasCommit: boolean, untracked: boolean }): string {
|
||||
if (!state.hasCommit)
|
||||
return '仓库里还没有提交'
|
||||
return state.untracked ? '还没被 git 跟踪,上一次提交里没有这个文件' : '上一次提交里没有这个文件'
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
import type { ExtensionContext, TreeView, WorkspaceFolder } from 'vscode'
|
||||
import type { ExtensionContext, TreeView, Uri, WorkspaceFolder } from 'vscode'
|
||||
import type { GitApi, GitRepository } from '../../git/gitApi'
|
||||
import type { DiffBase } from '../diff/diffBase'
|
||||
import type { ChangedFile } from '../explorer/changedTree'
|
||||
import type { ChangedSection, FileNode, FileTreeProvider, TreeNode } from '../explorer/fileTree'
|
||||
import type { TreeFilter, TreeFilters } from '../explorer/treeFilters'
|
||||
import type { UncommittedRoot } from './uncommittedFiles'
|
||||
import * as path from 'node:path'
|
||||
import { commands, Disposable, window, workspace } from 'vscode'
|
||||
import { fileAtRef } from '../../git/fileAt'
|
||||
import { loadGitApi } from '../../git/gitExtension'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { buildChangedTree } from '../explorer/changedTree'
|
||||
import { uncommittedDescription, uncommittedMessage, uncommittedRoots, UNSTAGED_MARKER, withoutTestFiles, workspaceOrder } from './uncommittedFiles'
|
||||
import { toPathKey } from '../pathMap'
|
||||
import { HEAD_BASE_LABEL, headMissingText, isUntracked, uncommittedDescription, uncommittedMessage, uncommittedRoots, UNSTAGED_MARKER, withoutTestFiles, workspaceOrder } from './uncommittedFiles'
|
||||
|
||||
/** workspaceState:开着「未提交的更改」,重开窗口接着显示。 */
|
||||
const UNCOMMITTED_KEY = 'superpowers.codeReader.uncommitted'
|
||||
@@ -33,7 +36,7 @@ function errorText(err: unknown): string {
|
||||
/**
|
||||
* 侧栏只看未提交的更改:照源代码管理视图分「暂存的更改」「更改」两组(空的不摆),每组下面每个有改动的仓库一个根。
|
||||
* 文件就是工作区里的文件,颜色和 M / A / D 徽标由 VS Code 自带的 git 装饰加上;「更改」组里的文件行上有「暂存」按钮。
|
||||
* 可以隐藏测试文件。开着时盯着 git 状态自动重摆;「显示全部」回到工作区文件树。
|
||||
* 可以隐藏测试文件。开着时盯着 git 状态自动重摆,打开的预览对比上一次提交(diffBase);「显示全部」回到工作区文件树。
|
||||
* 开着没开、隐不隐藏测试文件都按工作区记住;和审查工单互斥,见 TreeFilters。
|
||||
*/
|
||||
export class UncommittedMode implements TreeFilter, Disposable {
|
||||
@@ -123,6 +126,24 @@ export class UncommittedMode implements TreeFilter, Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/** 开着时,工作区仓库里的文件对比上一次提交(HEAD);不在工作区的仓库里(审查的工作副本、别处的文件)的不比。 */
|
||||
diffBase(uri: Uri): DiffBase | undefined {
|
||||
const repository = this.api?.getRepository(uri)
|
||||
if (!repository || workspaceOrder(repository.rootUri.path, workspace.workspaceFolders ?? []) < 0)
|
||||
return undefined
|
||||
const relative = toPathKey(repository.rootUri.path, uri.path)
|
||||
if (relative === undefined)
|
||||
return undefined
|
||||
const head = repository.state.HEAD?.commit
|
||||
const root = repository.rootUri.fsPath
|
||||
return {
|
||||
label: HEAD_BASE_LABEL,
|
||||
key: `${head ?? ''}:${relative}`,
|
||||
read: () => head ? fileAtRef(root, head, relative) : Promise.resolve(null),
|
||||
missing: headMissingText({ hasCommit: head !== undefined, untracked: isUntracked(repository.state, uri.path) }),
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stopWatching()
|
||||
for (const disposable of this.disposables)
|
||||
@@ -173,12 +194,18 @@ export class UncommittedMode implements TreeFilter, Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/** 盯着 git:各仓库的状态变了、开了 / 关了仓库、git 扩展找完仓库、工作区文件夹变了,都合成一次重摆。 */
|
||||
/**
|
||||
* 盯着 git:各仓库的状态变了、开了 / 关了仓库、git 扩展找完仓库、工作区文件夹变了,都合成一次重摆;
|
||||
* 打开着的预览也跟着重新比(提交后 HEAD 动了,对比的上一次提交就换了)。
|
||||
*/
|
||||
private watch(api: GitApi): Disposable {
|
||||
const repositories = new Map<GitRepository, Disposable>()
|
||||
const schedule = (): void => {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => this.rebuild(), REBUILD_DELAY_MS)
|
||||
this.timer = setTimeout(() => {
|
||||
this.rebuild()
|
||||
this.filters.baseChanged(this)
|
||||
}, REBUILD_DELAY_MS)
|
||||
}
|
||||
const add = (repository: GitRepository): void => {
|
||||
if (!repositories.has(repository))
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { fileAtRef } from './fileAt'
|
||||
import { runGit } from './worktree'
|
||||
|
||||
describe('fileAtRef', () => {
|
||||
let repo: string
|
||||
let head: string
|
||||
|
||||
beforeAll(async () => {
|
||||
repo = mkdtempSync(path.join(os.tmpdir(), 'file-at-'))
|
||||
await runGit(repo, ['init', '-q'])
|
||||
mkdirSync(path.join(repo, 'src'))
|
||||
writeFileSync(path.join(repo, 'src', 'a.py'), 'x = 1\n')
|
||||
await runGit(repo, ['add', '.'])
|
||||
await runGit(repo, ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'init'])
|
||||
head = (await runGit(repo, ['rev-parse', 'HEAD'])).trim()
|
||||
writeFileSync(path.join(repo, 'src', 'a.py'), 'x = 2\n')
|
||||
writeFileSync(path.join(repo, 'src', 'b.py'), 'y = 1\n')
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(repo, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('取提交里的内容,不是工作区里改过的', async () => {
|
||||
expect(await fileAtRef(repo, head, 'src/a.py')).toBe('x = 1\n')
|
||||
})
|
||||
|
||||
it('提交里没有这个文件是 null', async () => {
|
||||
expect(await fileAtRef(repo, head, 'src/b.py')).toBeNull()
|
||||
})
|
||||
|
||||
it('提交不存在时抛错', async () => {
|
||||
await expect(fileAtRef(repo, '0123456789abcdef0123456789abcdef01234567', 'src/a.py')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { runGit } from './worktree'
|
||||
|
||||
/**
|
||||
* 仓库里 ref 这个提交上 path 这个文件的内容(path 相对仓库根、`/` 分隔);那个提交里没有这个文件时是 null。
|
||||
* 先 ls-tree 看有没有、再 show 取内容,这样「没有这个文件」和 git 出错分得开(出错照常抛)。
|
||||
*/
|
||||
export async function fileAtRef(repoRoot: string, ref: string, path: string): Promise<string | null> {
|
||||
const listed = await runGit(repoRoot, ['ls-tree', '--name-only', ref, '--', path], `查 ${ref} 上有没有 ${path}`)
|
||||
if (!listed.trim())
|
||||
return null
|
||||
return runGit(repoRoot, ['show', `${ref}:${path}`], `读 ${ref} 上的 ${path}`)
|
||||
}
|
||||
@@ -36,7 +36,14 @@ export interface GitChange {
|
||||
readonly status: GitStatus
|
||||
}
|
||||
|
||||
/** 当前检出的分支或提交;commit 是它指向的提交,仓库还没有提交时没有。 */
|
||||
export interface GitHead {
|
||||
readonly commit?: string
|
||||
}
|
||||
|
||||
export interface GitRepositoryState {
|
||||
/** 仓库还没打开完时没有 */
|
||||
readonly HEAD: GitHead | undefined
|
||||
/** 合并冲突的 */
|
||||
readonly mergeChanges: readonly GitChange[]
|
||||
/** 暂存的 */
|
||||
|
||||
@@ -324,6 +324,8 @@ export interface GiteaPullRequest {
|
||||
headRef: string
|
||||
/** base 分支 sha:mergeBase 取不到时的左侧 ref 兜底。 */
|
||||
baseSha: string
|
||||
/** base 分支名(PR 要合进去的目标分支),如 `master`。 */
|
||||
baseRef: string
|
||||
/** base 与 head 的合并基:PR diff 的左侧 ref(三点 diff,与 Gitea 网页口径一致)。 */
|
||||
mergeBase: string
|
||||
}
|
||||
@@ -353,7 +355,7 @@ export async function getPullRequest(opts: {
|
||||
html_url?: unknown
|
||||
body?: unknown
|
||||
head?: { sha?: unknown, ref?: unknown }
|
||||
base?: { sha?: unknown }
|
||||
base?: { sha?: unknown, ref?: unknown }
|
||||
merge_base?: unknown
|
||||
}
|
||||
return {
|
||||
@@ -366,6 +368,7 @@ export async function getPullRequest(opts: {
|
||||
headSha: typeof data.head?.sha === 'string' ? data.head.sha : '',
|
||||
headRef: typeof data.head?.ref === 'string' ? data.head.ref : '',
|
||||
baseSha: typeof data.base?.sha === 'string' ? data.base.sha : '',
|
||||
baseRef: typeof data.base?.ref === 'string' ? data.base.ref : '',
|
||||
mergeBase: typeof data.merge_base === 'string' ? data.merge_base : '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { BlockTreeState } from './BlockTree'
|
||||
import type { NameMap, VersionNames } from './displayNames'
|
||||
import type { DocumentMessage, Excluded, Reveal } from './types'
|
||||
import type { DocumentDiff, DocumentMessage, Excluded, Reveal } from './types'
|
||||
import type { OccurrencePage } from './useOccurrences'
|
||||
import type { ViewState } from './viewState'
|
||||
import { TriangleAlert } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BlockLevel } from './BlockTree'
|
||||
import { DiffBar, NewFileBanner } from './DiffBar'
|
||||
import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView'
|
||||
import { acceptNames, DisplayNameContext, documentNames } from './displayNames'
|
||||
import { coveredByEditing, startEditing } from './editing'
|
||||
import { applyFilter, keptWhileEditing } from './filter'
|
||||
import { applyFilter, excludedBy, keptWhileEditing } from './filter'
|
||||
import { FilterBar } from './FilterBar'
|
||||
import { OverviewRuler } from './OverviewRuler'
|
||||
import { useChangeNav } from './useChangeNav'
|
||||
import { useDefinitionLinks } from './useDefinitionLinks'
|
||||
import { HighlightContext, useOccurrences } from './useOccurrences'
|
||||
import { useQa } from './useQa'
|
||||
@@ -43,11 +47,27 @@ export function App(): ReactElement | null {
|
||||
const [keep, setKeep] = useState<ReadonlySet<string>>(NONE)
|
||||
/** 页面上这个文档版本的中文名:文档带来的,扩展端查到了再换 */
|
||||
const [names, setNames] = useState<VersionNames | undefined>()
|
||||
/** 这份文件相比对比基准改了什么:侧栏开着「未提交的更改」或审查工单时扩展端算好了补来,不比时是 null */
|
||||
const [diff, setDiff] = useState<DocumentDiff | null>(null)
|
||||
/** 收到消息时读眼下哪些块在编辑(消息回调只在挂载时注册一次) */
|
||||
const editing = useRef(view.editing)
|
||||
const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds), [doc, view.folds])
|
||||
// 解析失败的文档没有块,不标差异;删掉的块按块筛选同样藏
|
||||
const changes = useMemo(() => doc && doc.error === undefined
|
||||
? diffTree(doc.blocks, diff, doc.version, removed => !excludedBy(removed.block, excluded))
|
||||
: undefined, [doc, diff, excluded])
|
||||
const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds, openedByDiff(changes)), [doc, view.folds, changes])
|
||||
const filter = useMemo(() => applyFilter(doc?.blocks ?? [], excluded, keep), [doc, excluded, keep])
|
||||
const covered = useMemo(() => coveredByEditing(doc?.blocks ?? [], new Set(view.editing)), [doc, view.editing])
|
||||
/** 「只看改动」开着(整个工作区共用,扩展端记着);本地不改,改了发给扩展端、等它发回来 */
|
||||
const [onlyChanges, setOnlyChanges] = useState(false)
|
||||
/** 「只看改动」时点开过的几段没变的块(按第一块的 id),筛选一变就重新折 */
|
||||
const [openedRuns, setOpenedRuns] = useState<ReadonlySet<string>>(NONE)
|
||||
const folded = useMemo(() => doc && changes && onlyChanges
|
||||
? foldUnchanged(doc.blocks, changes, block => !filter.hidden.has(block.id) && !covered.has(block.id), openedRuns, keep)
|
||||
: undefined, [doc, changes, onlyChanges, filter, covered, openedRuns, keep])
|
||||
/** 页面上没画出来的块:被筛掉的,和「只看改动」折起来的 */
|
||||
const unshown = useMemo(() => folded ? new Set([...filter.hidden, ...folded.folded]) : filter.hidden, [filter, folded])
|
||||
const stops = useMemo(() => doc && changes ? changeStops(doc.blocks, changes, filter.hidden) : [], [doc, changes, filter])
|
||||
/** 记下这几块、属性行、子行折叠还是展开,从此不再按默认 */
|
||||
const setFolded = useCallback((ids: string[], folded: boolean) => setView(prev => ({
|
||||
...prev,
|
||||
@@ -57,16 +77,17 @@ export function App(): ReactElement | null {
|
||||
const show = useCallback((ids: string[]) => setKeep(prev => new Set([...prev, ...ids])), [])
|
||||
const revealed = useCallback(() => setReveal(null), [])
|
||||
useScrollPosition(doc)
|
||||
useReveal(doc, reveal, collapsed, filter.hidden, covered, show, expand, revealed)
|
||||
useReveal(doc, reveal, collapsed, unshown, covered, show, expand, revealed)
|
||||
useDefinitionLinks(doc?.version)
|
||||
const nav = useChangeNav(stops, collapsed, expand)
|
||||
const occurrencePage = useMemo((): OccurrencePage | undefined => doc
|
||||
? {
|
||||
blocks: doc.blocks,
|
||||
skip: id => filter.hidden.has(id) || covered.has(id) || view.editing.includes(id),
|
||||
skip: id => unshown.has(id) || covered.has(id) || view.editing.includes(id),
|
||||
collapsed,
|
||||
expand,
|
||||
}
|
||||
: undefined, [doc, filter, covered, view.editing, collapsed, expand])
|
||||
: undefined, [doc, unshown, covered, view.editing, collapsed, expand])
|
||||
const highlight = useOccurrences(doc?.version, doc?.translatorId, occurrencePage)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -87,7 +108,12 @@ export function App(): ReactElement | null {
|
||||
return
|
||||
case 'filter':
|
||||
setExcluded(msg.excluded)
|
||||
setOnlyChanges(msg.onlyChanges)
|
||||
setKeep(NONE)
|
||||
setOpenedRuns(NONE)
|
||||
return
|
||||
case 'diff':
|
||||
setDiff(msg.diff)
|
||||
}
|
||||
})
|
||||
postMessage({ type: 'ready' })
|
||||
@@ -121,7 +147,10 @@ export function App(): ReactElement | null {
|
||||
},
|
||||
onToggleFold: id => setFolded([id], !collapsed.has(id)),
|
||||
...(qa.state.available && { qa }),
|
||||
}, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, qa])
|
||||
...(changes && { diff: changes }),
|
||||
...(folded && { folded }),
|
||||
onOpenRun: id => setOpenedRuns(prev => new Set([...prev, id])),
|
||||
}, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, qa, changes, folded])
|
||||
|
||||
if (!doc || !tree)
|
||||
return null
|
||||
@@ -138,8 +167,19 @@ export function App(): ReactElement | null {
|
||||
translators={doc.translators}
|
||||
translatorId={doc.translatorId}
|
||||
onSelectTranslator={id => postMessage({ type: 'selectTranslator', id })}
|
||||
below={diff && doc.error === undefined && (
|
||||
<DiffBar
|
||||
diff={diff}
|
||||
onlyChanges={onlyChanges}
|
||||
onToggleOnly={() => postMessage({ type: 'setOnlyChanges', on: !onlyChanges })}
|
||||
canMove={stops.length > 0}
|
||||
onMove={nav.move}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{changes && diff && <OverviewRuler stops={stops} lineCount={diff.lineCount} onPick={nav.goTo} />}
|
||||
<div className="mx-auto box-content max-w-[880px] px-6 py-5">
|
||||
{diff?.newFile !== undefined && doc.error === undefined && <NewFileBanner detail={diff.newFile} />}
|
||||
{doc.error !== undefined && (
|
||||
<div className="mb-3 flex items-start gap-2 rounded-lg border border-[var(--vscode-inputValidation-errorBorder)] bg-[var(--vscode-inputValidation-errorBackground)] px-3 py-2">
|
||||
<TriangleAlert size={16} strokeWidth={1.8} className="mt-px shrink-0 text-[var(--vscode-errorForeground)]" />
|
||||
|
||||
@@ -29,11 +29,14 @@ describe('块下面的问答区', () => {
|
||||
expect(render({})).toBe('')
|
||||
})
|
||||
|
||||
it('问过、没展开:只露一行「问过 N 次」,点开才看历史', () => {
|
||||
it('问过、没展开:一行「问过 N 次」,下面默认摊开灰字历史,没有输入框', () => {
|
||||
const html = render({ history: HISTORY })
|
||||
expect(html).toContain('data-collapsed')
|
||||
expect(text(html)).toBe('问过 2 次')
|
||||
expect(html).toContain('aria-expanded="false"')
|
||||
expect(text(html)).toMatch(/^问过 2 次/)
|
||||
expect(html).toContain('aria-expanded="true"')
|
||||
expect(html.match(/data-history=""/g)).toHaveLength(2)
|
||||
expect(text(html)).toContain('为什么普通用户不能看别人的资料?3 天前')
|
||||
expect(text(html)).toContain('UserRole 还有哪些角色?昨天')
|
||||
expect(html).not.toContain('<textarea')
|
||||
})
|
||||
|
||||
|
||||
@@ -255,10 +255,10 @@ export interface AskThreadProps {
|
||||
|
||||
/**
|
||||
* 块下面的问答区(左边一条竖线):展开时是灰字的历史、当前这一轮、输入框;
|
||||
* 没展开但问过的块只露一行「问过 N 次」,点开看灰字历史,读代码时一眼知道这里问过什么。
|
||||
* 没展开但问过的块是一行「问过 N 次」、下面默认摊开灰字历史,点这一行收起 / 再摊开,读代码时一眼看到这里问过什么。
|
||||
*/
|
||||
export function AskThread({ history, asks, open, profiles, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showHistory, setShowHistory] = useState(true)
|
||||
/** 正在看大图的那张 */
|
||||
const [preview, setPreview] = useState<string>()
|
||||
const lightbox = preview !== undefined && <ImagePreview url={preview} onClose={() => setPreview(undefined)} />
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { CSSProperties, ReactElement } from 'react'
|
||||
import type { CSSProperties, ReactElement, ReactNode } from 'react'
|
||||
import type { DiffTree, FoldedRuns, PlacedRemoved } from './diffView'
|
||||
import type { QaState } from './qa'
|
||||
import type { PendingImage } from './qaImages'
|
||||
import type { BlockView } from './types'
|
||||
import { ChevronDown, ChevronRight, EyeOff } from 'lucide-react'
|
||||
import { Fragment } from 'react'
|
||||
import type { BlockMarks, BlockView } from './types'
|
||||
import { ChevronDown, ChevronRight, ChevronsUpDown, EyeOff } from 'lucide-react'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { AskThread } from './AskThread'
|
||||
import { BlockTools } from './BlockTools'
|
||||
import { CodeEditor } from './CodeEditor'
|
||||
import { DiffText } from './DiffText'
|
||||
import { removedTitle } from './diffView'
|
||||
import { KindIcon } from './kindIcons'
|
||||
import { PropertyGrid, PropertyTable } from './PropertyGrid'
|
||||
import { asksOf, historyOf } from './qa'
|
||||
@@ -33,6 +36,11 @@ export interface BlockTreeState {
|
||||
onToggleFold: (id: string) => void
|
||||
/** 问 AI;文件不在工作区文件夹里(问答没地方存)时没有 */
|
||||
qa?: QaTree
|
||||
/** 代码差异:新增、修改的块和删掉的块;侧栏开着「未提交的更改」或审查工单、基准里有这个文件时才有 */
|
||||
diff?: DiffTree
|
||||
/** 「只看改动」开着时折起来的连着没变的块(见 foldUnchanged);onOpenRun 点开一段(按第一块的 id) */
|
||||
folded?: FoldedRuns
|
||||
onOpenRun: (id: string) => void
|
||||
}
|
||||
|
||||
/** 问 AI 的状态和动作,块按自己的记号(anchor)取 */
|
||||
@@ -48,9 +56,36 @@ export interface QaTree {
|
||||
onSelectProfile: (id: string) => void
|
||||
}
|
||||
|
||||
/** 块下面有没有画出来的子块:子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。 */
|
||||
/** 块下面有没有画出来的子块(删掉的子块也算):子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。 */
|
||||
function hasShownChildren(block: BlockView, tree: BlockTreeState): boolean {
|
||||
return block.children.some(child => shown(child, tree))
|
||||
return block.children.some(child => shown(child, tree)) || (!tree.covered.has(block.id) && tree.diff?.removed.has(block.id) === true)
|
||||
}
|
||||
|
||||
const CHANGE_NAMES = { add: '新增', mod: '修改', del: '删除' } as const
|
||||
|
||||
/** 标差异时卡片左边外侧的改动色条(没变的留一条透明的,同一层照样对齐);不标差异时原样。 */
|
||||
function ChangeRow({ tree, change, children }: { tree: BlockTreeState, change: keyof typeof CHANGE_NAMES | undefined, children: ReactNode }): ReactElement {
|
||||
if (!tree.diff)
|
||||
return <>{children}</>
|
||||
return (
|
||||
<div className="cr-change-row flex gap-[5px]">
|
||||
<span className="cr-change-bar" data-change={change} data-tooltip={change && CHANGE_NAMES[change]} />
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 「只看改动」时连着没变的几块折成的一条「… N 块没变」,点开恢复这几块。 */
|
||||
function UnchangedStrip({ count, tree, onOpen }: { count: number, tree: BlockTreeState, onOpen: () => void }): ReactElement {
|
||||
return (
|
||||
<ChangeRow tree={tree} change={undefined}>
|
||||
<button type="button" className="cr-unchanged flex h-[22px] w-full items-center gap-1.5 rounded px-2.5 text-xs" onClick={onOpen}>
|
||||
<ChevronsUpDown size={12} strokeWidth={1.8} className="shrink-0" aria-hidden />
|
||||
{`… ${count} 块没变`}
|
||||
<span className="cr-link">点开</span>
|
||||
</button>
|
||||
</ChangeRow>
|
||||
)
|
||||
}
|
||||
|
||||
/** 块画不画:被筛掉的、被正在编辑的祖先盖住的都不画。 */
|
||||
@@ -74,21 +109,36 @@ function kindProps(block: BlockView | undefined): { 'data-kind'?: string, 'data-
|
||||
/**
|
||||
* 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉、被盖住的块不画,不留空位,竖线跟着变短。
|
||||
* owner 是这一层所属的块(顶层没有),悬停它时竖线换成它的种类色。
|
||||
* 标差异时每张卡片左边外侧一条改动色条,删掉的块收成一行放回原来的位置(见 RemovedCard);「只看改动」时连着没变的块折成一条(UnchangedStrip)。
|
||||
*/
|
||||
export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner?: BlockView, tree: BlockTreeState }): ReactElement {
|
||||
const removed = tree.diff?.removed.get(owner?.id ?? '') ?? []
|
||||
// 删掉的块排在它原来前面那块后面;那块被筛掉了也照排,不跟着消失
|
||||
const removedAfter = (id: string | undefined): ReactElement[] => removed
|
||||
.filter(item => item.after === id)
|
||||
.map(item => <RemovedCard key={item.id} placed={item} tree={tree} />)
|
||||
return (
|
||||
<div className="cr-level flex gap-2" {...kindProps(owner)}>
|
||||
<div className="flex w-3 shrink-0 justify-center">
|
||||
<div className="cr-guide w-px" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{blocks.filter(block => shown(block, tree)).map(block => (
|
||||
{removedAfter(undefined)}
|
||||
{blocks.map(block => (
|
||||
<Fragment key={block.id}>
|
||||
{tree.folded?.runs.has(block.id) && <UnchangedStrip count={tree.folded.runs.get(block.id) ?? 0} tree={tree} onOpen={() => tree.onOpenRun(block.id)} />}
|
||||
{shown(block, tree) && !tree.folded?.folded.has(block.id) && (
|
||||
<>
|
||||
<ChangeRow tree={tree} change={tree.diff?.changes.get(block.id)?.change}>
|
||||
<BlockCard block={block} tree={tree} />
|
||||
</ChangeRow>
|
||||
{tree.qa && <BlockQa key={block.anchor} anchor={block.anchor} qa={tree.qa} />}
|
||||
{hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && (
|
||||
<BlockLevel blocks={block.children} owner={block} tree={tree} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{removedAfter(block.id)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
@@ -115,20 +165,20 @@ function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement {
|
||||
|
||||
/**
|
||||
* 预览状态的内容:函数、类、枚举、导入、常量、变量块和库规则的卡片是属性表,其余是一行译文;
|
||||
* 译文下面依次接着摊开实参的表(跨几行写的调用)、说明。
|
||||
* 译文下面依次接着摊开实参的表(跨几行写的调用)、说明。标差异时 marks 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。
|
||||
*/
|
||||
function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement {
|
||||
function BlockPreview({ block, tree, marks }: { block: BlockView, tree: BlockTreeState, marks?: BlockMarks }): ReactElement {
|
||||
if ('properties' in block)
|
||||
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={tree.collapsed} onToggleFold={tree.onToggleFold} />
|
||||
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={tree.collapsed} onToggleFold={tree.onToggleFold} marks={marks?.rows} />
|
||||
return (
|
||||
<>
|
||||
<TextPreview text={block.translation} />
|
||||
{marks?.translation ? <DiffText diff={marks.translation} /> : <TextPreview text={block.translation} />}
|
||||
{block.table && (
|
||||
<div className="mt-[3px]">
|
||||
<PropertyTable parentId={tableId(block.id)} columns={block.table.columns} items={block.table.items} />
|
||||
<PropertyTable parentId={tableId(block.id)} columns={block.table.columns} items={block.table.items} marks={marks?.table} />
|
||||
</div>
|
||||
)}
|
||||
{block.doc && <TextPreview text={block.doc} className="mt-0.5" />}
|
||||
{marks?.doc ? <DiffText diff={marks.doc} className="mt-0.5" /> : block.doc && <TextPreview text={block.doc} className="mt-0.5" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -139,14 +189,17 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
|
||||
const collapsed = tree.collapsed.has(block.id)
|
||||
const hasChildren = hasShownChildren(block, tree)
|
||||
const Chevron = collapsed ? ChevronRight : ChevronDown
|
||||
const change = tree.diff?.changes.get(block.id)
|
||||
return (
|
||||
<div
|
||||
className="cr-block flex items-start gap-2.5 rounded-lg py-2 pr-2.5 pl-2"
|
||||
className="cr-block relative flex items-start gap-2.5 rounded-lg py-2 pr-2.5 pl-2"
|
||||
{...kindProps(block)}
|
||||
data-id={block.id}
|
||||
data-line={block.startLine}
|
||||
data-editing={editing ? '' : undefined}
|
||||
data-change={change?.change}
|
||||
>
|
||||
{change?.outermost && <span className="cr-added-badge">新增</span>}
|
||||
{hasChildren
|
||||
? (
|
||||
<button
|
||||
@@ -172,7 +225,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
|
||||
onExit={() => tree.onToggleEdit(block.id)}
|
||||
/>
|
||||
)
|
||||
: <BlockPreview block={block} tree={tree} />}
|
||||
: <BlockPreview block={block} tree={tree} marks={tree.diff?.current ? change?.marks : undefined} />}
|
||||
</div>
|
||||
{tree.temporary.has(block.id) && (
|
||||
<span className="cr-temporary flex h-5 shrink-0 items-center gap-1 rounded px-1.5 text-[11px] whitespace-nowrap">
|
||||
@@ -189,3 +242,44 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删掉的一块,放在原来的位置:红色虚线边、半透明,没有编辑、问 AI 按钮。默认收成一行「已删除 · 种类 名字」,
|
||||
* 点箭头或这一行展开,下面是旧内容(整张划掉,名字不能跳定义);子块不画。
|
||||
*/
|
||||
function RemovedCard({ placed, tree }: { placed: PlacedRemoved, tree: BlockTreeState }): ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { block, kindName } = placed.removed
|
||||
const title = removedTitle(block)
|
||||
const Chevron = open ? ChevronDown : ChevronRight
|
||||
const toggle = (): void => setOpen(value => !value)
|
||||
return (
|
||||
<ChangeRow tree={tree} change="del">
|
||||
<div className="cr-block cr-removed flex items-start gap-2.5 rounded-lg py-2 pr-2.5 pl-2" {...kindProps(block)} data-id={placed.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-5 w-3.5 shrink-0 items-center justify-center rounded-sm text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)]"
|
||||
aria-label={open ? '收起删掉的内容' : '展开删掉的内容'}
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<Chevron size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
<KindIcon block={block} name={kindName} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="cr-removed-head cursor-pointer truncate text-[12.5px] leading-5" onClick={toggle}>
|
||||
<span className="cr-removed-label">已删除 · </span>
|
||||
{kindName}
|
||||
{title && <span className="cr-removed-title">{` ${title}`}</span>}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="cr-removed-old mt-1">
|
||||
{/* 旧块的 id 是旧版本里的位置,换成页面上认删掉的块用的 id,属性行的 id 才不和现在的块撞上 */}
|
||||
<BlockPreview block={{ ...block, id: placed.id }} tree={tree} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ChangeRow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { DocumentDiff } from './types'
|
||||
import { ArrowDown, ArrowUp, FilePlus, GitCompare } from 'lucide-react'
|
||||
import { Fragment } from 'react'
|
||||
import { statsParts } from './diffView'
|
||||
|
||||
export interface DiffBarProps {
|
||||
diff: DocumentDiff
|
||||
/** 「只看改动」开着:连着没变的块折成一条 */
|
||||
onlyChanges: boolean
|
||||
onToggleOnly: () => void
|
||||
/** 有没有改动可以跳(筛掉的不算) */
|
||||
canMove: boolean
|
||||
/** 跳到上一处(-1)/ 下一处(1)改动 */
|
||||
onMove: (step: 1 | -1) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选栏第二行(标差异时才有):对比基准(「对比:上一次提交」),改动统计(「新增 2 · 修改 3 · 删除 1」,各按改动色,
|
||||
* 没有的那种不写,都没有写「没有改动」),右边「只看改动」开关、上一处 / 下一处。基准里没有这个文件时只写基准,看横幅。
|
||||
*/
|
||||
export function DiffBar({ diff, onlyChanges, onToggleOnly, canMove, onMove }: DiffBarProps): ReactElement {
|
||||
const parts = statsParts(diff.stats)
|
||||
return (
|
||||
<div className="mx-auto box-content flex h-[30px] max-w-[880px] items-center gap-2 px-6 text-xs whitespace-nowrap text-[var(--vscode-descriptionForeground)]">
|
||||
<GitCompare size={14} strokeWidth={1.8} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate">
|
||||
对比:
|
||||
<span className="text-[var(--vscode-foreground)]">{diff.base}</span>
|
||||
</span>
|
||||
{diff.newFile === undefined && (
|
||||
<>
|
||||
<span className="opacity-60" aria-hidden>·</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{parts.length === 0
|
||||
? '没有改动'
|
||||
: parts.map((part, index) => (
|
||||
<Fragment key={part.change}>
|
||||
{index > 0 && <span aria-hidden>·</span>}
|
||||
<span className="cr-diff-stat" data-change={part.change}>{part.text}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" className="cr-only-changes flex h-6 items-center gap-1.5 rounded px-1.5" aria-pressed={onlyChanges} onClick={onToggleOnly}>
|
||||
<span className="cr-switch" data-on={onlyChanges ? '' : undefined} aria-hidden>
|
||||
<span className="cr-switch-knob" />
|
||||
</span>
|
||||
只看改动
|
||||
</button>
|
||||
<span className="flex items-center gap-0.5" role="group" aria-label="改动之间跳">
|
||||
{([[-1, ArrowUp, '上一处改动'], [1, ArrowDown, '下一处改动']] as const).map(([step, Icon, label]) => (
|
||||
<button
|
||||
key={step}
|
||||
type="button"
|
||||
className="cr-tool flex h-[22px] w-6 items-center justify-center rounded disabled:opacity-40"
|
||||
data-tooltip={label}
|
||||
aria-label={label}
|
||||
disabled={!canMove}
|
||||
onClick={() => onMove(step)}
|
||||
>
|
||||
<Icon size={14} strokeWidth={1.8} className="text-[var(--vscode-foreground)]" aria-hidden />
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 基准里没有这个文件(新文件、未跟踪的):预览顶部一条横幅,卡片不再逐张标绿。 */
|
||||
export function NewFileBanner({ detail }: { detail: string }): ReactElement {
|
||||
return (
|
||||
<div className="cr-new-file mb-3 flex items-center gap-2 rounded-md px-3 py-[7px]" role="status">
|
||||
<FilePlus size={16} strokeWidth={1.8} className="shrink-0" aria-hidden />
|
||||
<span className="shrink-0 text-[12.5px] leading-[18px] font-medium text-[var(--vscode-foreground)]">新文件,全部是新增</span>
|
||||
<span className="min-w-0 text-xs leading-[18px] text-[var(--vscode-descriptionForeground)]">{detail}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { InlinePart, TextDiff } from './types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { renderMarkdownDiff } from './markdown'
|
||||
import { SegmentText } from './SegmentText'
|
||||
|
||||
/**
|
||||
* 着色的词里的增删(同一行的值改了):没变的照常,删掉的一截红底划掉(旧值,不能跳定义),新加的一截绿底,
|
||||
* 整段改了就是旧值紧跟新值。放在 .cr-code 的段落里。
|
||||
*/
|
||||
export function DiffInline({ parts }: { parts: InlinePart[] }): ReactElement {
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => part.change
|
||||
? (
|
||||
<span key={index} className="cr-diff-part" data-change={part.change}>
|
||||
<SegmentText segments={part.segments} />
|
||||
</span>
|
||||
)
|
||||
: <SegmentText key={index} segments={part.segments} />)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 预览里一段带增删的文字:着色的词逐截标(DiffInline),Markdown 说明照常排版、增删的字按词 / 字标(同 TextPreview 的排版)。 */
|
||||
export function DiffText({ diff, className }: { diff: TextDiff, className?: string }): ReactElement {
|
||||
if ('markdown' in diff)
|
||||
return <div className={cn('cr-markdown', className)} dangerouslySetInnerHTML={{ __html: renderMarkdownDiff(diff.markdown) }} />
|
||||
return (
|
||||
<p className={cn('cr-code', className)}>
|
||||
<DiffInline parts={diff.inline} />
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FocusEvent, KeyboardEvent, ReactElement } from 'react'
|
||||
import type { FocusEvent, KeyboardEvent, ReactElement, ReactNode } from 'react'
|
||||
import type { Facet, FilterView } from './filter'
|
||||
import type { Highlight } from './occurrences'
|
||||
import type { Excluded, FileAttribute, TranslatorOption } from './types'
|
||||
@@ -18,13 +18,15 @@ export interface FilterBarProps {
|
||||
translators: TranslatorOption[]
|
||||
translatorId: string
|
||||
onSelectTranslator: (id: string) => void
|
||||
/** 栏里接在这一行下面的(标差异时的第二行,见 DiffBar) */
|
||||
below?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览顶部钉住的一条:「筛选」、这份文件里每个能筛的属性一个下拉框、右边汇总(高亮着名字时先是「名字 · 2/5」,再是共 N 块 / 已隐藏 N 块 · 清除),
|
||||
* 多于一种翻译时最右边是翻译下拉框。内容和代码块同宽对齐,滚动时块从它下面过去。
|
||||
* 多于一种翻译时最右边是翻译下拉框;标差异时下面再接一行(below)。内容和代码块同宽对齐,滚动时块从它下面过去。
|
||||
*/
|
||||
export function FilterBar({ attributes, excluded, view, highlight, onChange, translators, translatorId, onSelectTranslator }: FilterBarProps): ReactElement {
|
||||
export function FilterBar({ attributes, excluded, view, highlight, onChange, translators, translatorId, onSelectTranslator, below }: FilterBarProps): ReactElement {
|
||||
return (
|
||||
<div className="cr-filter-bar">
|
||||
<div className="mx-auto box-content flex max-w-[880px] flex-wrap items-center gap-2 px-6 py-1.5">
|
||||
@@ -72,6 +74,7 @@ export function FilterBar({ attributes, excluded, view, highlight, onChange, tra
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{below}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { ChangeStop } from './diffView'
|
||||
import { useLayoutEffect, useState } from 'react'
|
||||
|
||||
const CHANGE_NAMES = { add: '新增', mod: '修改', del: '删除' } as const
|
||||
|
||||
/** 钉在预览顶部的筛选栏有多高(它换行、多出第二行时跟着变):总览条从它下面开始。 */
|
||||
function usePinnedHeight(): number {
|
||||
const [height, setHeight] = useState(0)
|
||||
useLayoutEffect(() => {
|
||||
const bar = document.querySelector<HTMLElement>('.cr-filter-bar')
|
||||
if (!bar)
|
||||
return
|
||||
setHeight(bar.offsetHeight)
|
||||
const observer = new ResizeObserver(() => setHeight(bar.offsetHeight))
|
||||
observer.observe(bar)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
return height
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览右边缘的总览条:每处改动(见 ChangeStop)按它在文件里的行画一段刻度,新增绿、修改蓝、删除红(删掉的块一条细线),
|
||||
* 点刻度跳过去。lineCount 是新文件一共几行。
|
||||
*/
|
||||
export function OverviewRuler({ stops, lineCount, onPick }: { stops: readonly ChangeStop[], lineCount: number, onPick: (stop: ChangeStop) => void }): ReactElement {
|
||||
const top = usePinnedHeight()
|
||||
const total = Math.max(lineCount, 1)
|
||||
const percent = (line: number): string => `${(Math.min(Math.max(line, 0), total) / total) * 100}%`
|
||||
return (
|
||||
<nav className="cr-ruler" style={{ top }} aria-label="改动总览">
|
||||
<div className="cr-ruler-track">
|
||||
{stops.map(stop => (
|
||||
<button
|
||||
key={stop.id}
|
||||
type="button"
|
||||
className="cr-ruler-tick"
|
||||
data-change={stop.change}
|
||||
style={{ top: percent(stop.from), ...(stop.change !== 'del' && { height: percent(stop.to - stop.from + 1) }) }}
|
||||
data-tooltip={`${CHANGE_NAMES[stop.change]} · 第 ${stop.from + 1} 行`}
|
||||
aria-label={`${CHANGE_NAMES[stop.change]},第 ${stop.from + 1} 行`}
|
||||
onClick={() => onPick(stop)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { Inline, PropertyItem, PropertyRow } from './types'
|
||||
import type { Inline, InlinePart, ItemDiff, ItemsDiff, PropertyItem, PropertyRow, Removed, RowsDiff } from './types'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { Fragment } from 'react'
|
||||
import { DiffInline, DiffText } from './DiffText'
|
||||
import { inlineText } from './segments'
|
||||
import { SegmentText } from './SegmentText'
|
||||
import { subRowsLayout, tableCells, tableTemplate } from './subRows'
|
||||
@@ -22,6 +23,22 @@ interface Folding {
|
||||
onToggleFold: (id: string) => void
|
||||
}
|
||||
|
||||
/** 删掉的行下面的子行:总是展开,点了也不收(旧内容只是看看)。 */
|
||||
const FIXED: Folding = { collapsed: new Set(), onToggleFold: () => {} }
|
||||
|
||||
/** 表格行第一格前的记号:加的 +、删的 −,没变的空一格,列照样对齐。 */
|
||||
const SIGNS = { add: '+', del: '−', none: ' ' } as const
|
||||
|
||||
/** 删掉的行(子行)里排在新的第 after 行后面的那些,带着它在删掉的行里的序号(页面上认它用)。 */
|
||||
function removedAt<T>(removed: readonly Removed<T>[] | undefined, after: number): { value: T, index: number }[] {
|
||||
return (removed ?? []).flatMap((entry, index) => entry.after === after ? [{ value: entry.value, index }] : [])
|
||||
}
|
||||
|
||||
/** 一格的字:这一格的值改了就逐截标(DiffInline),否则照常。 */
|
||||
function CellText({ cell, parts }: { cell: Inline[], parts: InlinePart[] | undefined }): ReactElement {
|
||||
return parts ? <DiffInline parts={parts} /> : <SegmentText segments={cell} />
|
||||
}
|
||||
|
||||
/**
|
||||
* 子行的第一列(字典的键):带下一层的子行前面是折叠箭头(图标、大小、颜色同属性行的),点箭头或这一列切换;
|
||||
* 同一层里不带下一层的子行在同样的位置留空,第一列才对得齐。
|
||||
@@ -60,32 +77,53 @@ function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Inline[],
|
||||
/**
|
||||
* 展开在一行或一个子行(parentId)下面的一层子行:左边一条竖线,每个子行几列对齐。
|
||||
* 带下一层的子行(字典 / 对象的值又是字典 / 对象),下一层展开在它下面、再往右缩进一层,默认展开。
|
||||
* 标差异时(marks)新加的子行淡绿底、左边 +,删掉的放回原来的位置、淡红底划掉、左边 −,改了的格子逐截标。
|
||||
*/
|
||||
function PropertyItems({ parentId, items, folding }: { parentId: string, items: PropertyItem[], folding: Folding }): ReactElement {
|
||||
function PropertyItems({ parentId, items, folding, marks }: { parentId: string, items: PropertyItem[], folding: Folding, marks?: ItemsDiff }): ReactElement {
|
||||
const nested = items.some(item => (item.items?.length ?? 0) > 0)
|
||||
/** 一个子行:删掉的(change 是 del)不带 id、下面的子行总是展开 */
|
||||
const itemRow = (item: PropertyItem, id: string, change: 'add' | 'del' | undefined, mark: ItemDiff | undefined): ReactElement => {
|
||||
const children = item.items ?? []
|
||||
const folds = change === 'del' ? FIXED : folding
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="cr-prop-item grid gap-x-3"
|
||||
data-id={change === 'del' ? undefined : id}
|
||||
data-diff={change}
|
||||
style={{ gridTemplateColumns: gridTemplate(item.cells.length) }}
|
||||
>
|
||||
{item.cells.map((cell, i) => i === 0 && nested
|
||||
? <ItemHead key={i} id={id} cell={cell} foldable={children.length > 0 && change !== 'del'} folding={folds} />
|
||||
: (
|
||||
<p key={i} className="cr-code min-w-0">
|
||||
<CellText cell={cell} parts={mark?.cells?.[i]} />
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
{children.length > 0 && !folds.collapsed.has(id) && (change === 'del'
|
||||
? (
|
||||
<div className="cr-diff-gone">
|
||||
<PropertyItems parentId={id} items={children} folding={folds} />
|
||||
</div>
|
||||
)
|
||||
: <PropertyItems parentId={id} items={children} folding={folds} marks={mark?.items} />)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
const removed = (after: number): ReactElement[] => removedAt(marks?.removed, after)
|
||||
.map(({ value, index }) => <Fragment key={`removed-${index}`}>{itemRow(value, `${parentId}/x${index}`, 'del', undefined)}</Fragment>)
|
||||
return (
|
||||
<div className="flex gap-2.5 pl-[5px]">
|
||||
<div className="cr-guide w-px shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
{removed(-1)}
|
||||
{items.map((item, index) => {
|
||||
const id = propertyItemId(parentId, index)
|
||||
const children = item.items ?? []
|
||||
const mark = marks?.changed[index]
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<div
|
||||
className="cr-prop-item grid gap-x-3"
|
||||
data-id={id}
|
||||
style={{ gridTemplateColumns: gridTemplate(item.cells.length) }}
|
||||
>
|
||||
{item.cells.map((cell, i) => i === 0 && nested
|
||||
? <ItemHead key={i} id={id} cell={cell} foldable={children.length > 0} folding={folding} />
|
||||
: (
|
||||
<p key={i} className="cr-code min-w-0">
|
||||
<SegmentText segments={cell} />
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
{children.length > 0 && !folding.collapsed.has(id) && <PropertyItems parentId={id} items={children} folding={folding} />}
|
||||
{itemRow(item, propertyItemId(parentId, index), mark?.added && 'add', mark)}
|
||||
{removed(index)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
@@ -98,34 +136,49 @@ function PropertyItems({ parentId, items, folding }: { parentId: string, items:
|
||||
* 给了列名的子行(输入参数、数据表的字段)排成表格:第一行是表头(列名),下面每个子行一行;外框圆角,格子之间、行之间一条分隔线。
|
||||
* 列宽按整列的内容定(见 tableTemplate),最后一列分剩下的,长了在格子里换行。一行译文下面摊开实参的表也用它;
|
||||
* 行带 table(实参的值里跨几行写的调用)时,那张表排在最后一格里、文字下面,往右缩一点,一层层往下套。
|
||||
* 标差异时(marks)有加行、删行的表每行第一格前多一个记号位(+ / −,没变的空着),加的行淡绿底,删的行放回原来的位置、淡红底划掉。
|
||||
*/
|
||||
export function PropertyTable({ parentId, columns, items }: { parentId: string, columns: string[], items: PropertyItem[] }): ReactElement {
|
||||
export function PropertyTable({ parentId, columns, items, marks }: { parentId: string, columns: string[], items: PropertyItem[], marks?: ItemsDiff }): ReactElement {
|
||||
// 有加行、删行的表每行第一格前留一个记号位
|
||||
const signed = marks !== undefined && (marks.removed.length > 0 || Object.values(marks.changed).some(mark => mark.added))
|
||||
/** 一行:删掉的(change 是 del)不带 id,套着的表不再标 */
|
||||
const tableRow = (item: PropertyItem, id: string, change: 'add' | 'del' | undefined, mark: ItemDiff | undefined): ReactElement => {
|
||||
const cells = tableCells(item, columns)
|
||||
const text = (cell: Inline[], i: number): ReactElement => (
|
||||
<>
|
||||
{signed && i === 0 && <span className="cr-diff-sign" data-change={change} aria-hidden>{SIGNS[change ?? 'none']}</span>}
|
||||
<CellText cell={cell} parts={mark?.cells?.[i]} />
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<div className="cr-table-row" role="row" data-id={change === 'del' ? undefined : id} data-diff={change}>
|
||||
{cells.map((cell, i) => item.table && i === cells.length - 1
|
||||
? (
|
||||
<div key={i} className="cr-table-cell flex min-w-0 flex-col gap-[3px]" role="cell">
|
||||
<p className="cr-code">{text(cell, i)}</p>
|
||||
<PropertyTable parentId={tableId(id)} columns={item.table.columns} items={item.table.items} marks={mark?.table} />
|
||||
</div>
|
||||
)
|
||||
: <p key={i} className="cr-table-cell cr-code min-w-0" role="cell">{text(cell, i)}</p>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const removed = (after: number): ReactElement[] => removedAt(marks?.removed, after)
|
||||
.map(({ value, index }) => <Fragment key={`removed-${index}`}>{tableRow(value, `${parentId}/x${index}`, 'del', undefined)}</Fragment>)
|
||||
return (
|
||||
<div className="pl-[5px]">
|
||||
<div className="cr-table" role="table" style={{ gridTemplateColumns: tableTemplate(columns.length) }}>
|
||||
<div className="cr-table-head" role="row">
|
||||
{columns.map((name, i) => <span key={i} className="cr-table-cell" role="columnheader">{name}</span>)}
|
||||
</div>
|
||||
{removed(-1)}
|
||||
{items.map((item, index) => {
|
||||
const id = propertyItemId(parentId, index)
|
||||
const cells = tableCells(item, columns)
|
||||
const mark = marks?.changed[index]
|
||||
return (
|
||||
<div key={index} className="cr-table-row" role="row" data-id={id}>
|
||||
{cells.map((cell, i) => item.table && i === cells.length - 1
|
||||
? (
|
||||
<div key={i} className="cr-table-cell flex min-w-0 flex-col gap-[3px]" role="cell">
|
||||
<p className="cr-code">
|
||||
<SegmentText segments={cell} />
|
||||
</p>
|
||||
<PropertyTable parentId={tableId(id)} columns={item.table.columns} items={item.table.items} />
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<p key={i} className="cr-table-cell cr-code min-w-0" role="cell">
|
||||
<SegmentText segments={cell} />
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<Fragment key={index}>
|
||||
{tableRow(item, propertyItemId(parentId, index), mark?.added && 'add', mark)}
|
||||
{removed(index)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -136,23 +189,30 @@ export function PropertyTable({ parentId, columns, items }: { parentId: string,
|
||||
/**
|
||||
* 函数、类、枚举、导入、常量、变量块和库规则的卡片的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行,
|
||||
* 给了列名的(输入参数、数据表的字段)排成表格,没给的排成列表。
|
||||
* 标差异时(marks)新加的行淡绿底、左边 +,删掉的行放回原来的位置、淡红底划掉、左边 −,值改了的按旧值 → 新值或逐词标。
|
||||
*/
|
||||
export function PropertyGrid({ blockId, rows, collapsed, onToggleFold }: {
|
||||
export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks }: {
|
||||
blockId: string
|
||||
rows: PropertyRow[]
|
||||
collapsed: ReadonlySet<string>
|
||||
onToggleFold: (id: string) => void
|
||||
/** 标差异时这张属性表改了哪些行 */
|
||||
marks?: RowsDiff
|
||||
}): ReactElement {
|
||||
const removed = (after: number): ReactElement[] => removedAt(marks?.removed, after)
|
||||
.map(({ value, index }) => <RemovedRow key={`removed-${index}`} id={`${blockId}/x${index}`} row={value} />)
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(76px,max-content)_minmax(0,1fr)] gap-x-3 gap-y-[3px] *:col-span-full">
|
||||
{removed(-1)}
|
||||
{rows.map((row, index) => {
|
||||
const layout = subRowsLayout(row)
|
||||
const id = propertyRowId(blockId, index)
|
||||
const folded = collapsed.has(id)
|
||||
const Chevron = folded ? ChevronRight : ChevronDown
|
||||
const mark = marks?.changed[index]
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<div className="cr-prop-row grid grid-cols-subgrid" data-id={id}>
|
||||
<div className="cr-prop-row grid grid-cols-subgrid" data-id={id} data-diff={mark?.added && 'add'}>
|
||||
{layout
|
||||
? (
|
||||
<button
|
||||
@@ -167,13 +227,34 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold }: {
|
||||
</button>
|
||||
)
|
||||
: <span className="cr-prop-label flex h-5 items-center">{row.label}</span>}
|
||||
<TextPreview text={row.value} className="min-w-0" />
|
||||
{mark?.value ? <DiffText diff={mark.value} className="min-w-0" /> : <TextPreview text={row.value} className="min-w-0" />}
|
||||
</div>
|
||||
{!folded && layout === 'table' && <PropertyTable parentId={id} columns={row.columns ?? []} items={row.items ?? []} />}
|
||||
{!folded && layout === 'list' && <PropertyItems parentId={id} items={row.items ?? []} folding={{ collapsed, onToggleFold }} />}
|
||||
{!folded && layout === 'table' && <PropertyTable parentId={id} columns={row.columns ?? []} items={row.items ?? []} marks={mark?.items} />}
|
||||
{!folded && layout === 'list' && <PropertyItems parentId={id} items={row.items ?? []} folding={{ collapsed, onToggleFold }} marks={mark?.items} />}
|
||||
{removed(index)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 属性表里删掉的一行:整行淡红底、划掉,左边 −;它下面的子行照样摊开、划掉。 */
|
||||
function RemovedRow({ id, row }: { id: string, row: PropertyRow }): ReactElement {
|
||||
const layout = subRowsLayout(row)
|
||||
return (
|
||||
<>
|
||||
<div className="cr-prop-row grid grid-cols-subgrid" data-diff="del">
|
||||
<span className="cr-prop-label flex h-5 items-center">{row.label}</span>
|
||||
<TextPreview text={row.value} className="min-w-0" />
|
||||
</div>
|
||||
{layout && (
|
||||
<div className="cr-diff-gone">
|
||||
{layout === 'table'
|
||||
? <PropertyTable parentId={id} columns={row.columns ?? []} items={row.items ?? []} />
|
||||
: <PropertyItems parentId={id} items={row.items ?? []} folding={FIXED} />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -839,3 +839,249 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-colored { color: var(--c
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 代码差异:配色取 VS Code 编辑器行号旁的改动色带(新增绿、修改蓝、删除红),主题没给时用 Dark Modern 的默认值;
|
||||
* 行内的字增删用 diff 编辑器的插入 / 删除文字底色
|
||||
*/
|
||||
body {
|
||||
--cr-diff-add: var(--vscode-editorGutter-addedBackground, #2ea043);
|
||||
--cr-diff-mod: var(--vscode-editorGutter-modifiedBackground, #0078d4);
|
||||
--cr-diff-del: var(--vscode-editorGutter-deletedBackground, #f85149);
|
||||
}
|
||||
|
||||
/* 卡片左边外侧的改动色条:3px 宽、和卡片等高;没变的卡片留一条透明的,同一层的卡片照样对齐 */
|
||||
.cr-change-bar {
|
||||
width: 3px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 1.5px;
|
||||
}
|
||||
.cr-change-bar[data-change="add"] { background: var(--cr-diff-add); }
|
||||
.cr-change-bar[data-change="mod"] { background: var(--cr-diff-mod); }
|
||||
.cr-change-bar[data-change="del"] { background: var(--cr-diff-del); }
|
||||
/* 卡片包了一层色条以后不再和子层相邻:悬停卡片时子层竖线换色改按外层认;问答区往右缩到和卡片对齐 */
|
||||
.cr-change-row:has(> div > .cr-block:hover) + .cr-level > * > .cr-guide,
|
||||
.cr-change-row:has(> div > .cr-block:hover) + .cr-qa + .cr-level > * > .cr-guide {
|
||||
background: var(--kind);
|
||||
}
|
||||
.cr-change-row + .cr-qa {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* 新增的卡片(连同它的子卡片):底色淡绿;最外面那张左上角压着「新增」小标 */
|
||||
.cr-block[data-change="add"] {
|
||||
background: color-mix(in srgb, var(--cr-diff-add) 10%, transparent);
|
||||
}
|
||||
.cr-block[data-change="add"]:hover {
|
||||
background: color-mix(in srgb, var(--cr-diff-add) 16%, transparent);
|
||||
}
|
||||
.cr-added-badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 6px;
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--cr-diff-add);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 删掉的块:红色虚线边、淡红底、半透明,没有编辑、问 AI 按钮;展开后下面是旧内容,整张划掉 */
|
||||
.cr-removed {
|
||||
border: 1px dashed color-mix(in srgb, var(--cr-diff-del) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--cr-diff-del) 6%, transparent);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.cr-block.cr-removed:hover {
|
||||
border-color: var(--cr-diff-del);
|
||||
background: color-mix(in srgb, var(--cr-diff-del) 10%, transparent);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.cr-removed-head {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
.cr-removed-head:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.cr-removed-label {
|
||||
color: var(--cr-diff-del);
|
||||
}
|
||||
.cr-removed-title {
|
||||
color: var(--cr-function);
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
}
|
||||
.cr-removed-old,
|
||||
.cr-removed-old * {
|
||||
text-decoration-line: line-through;
|
||||
}
|
||||
|
||||
/* 筛选栏第二行:对比基准、改动统计、「只看改动」、上一处 / 下一处 */
|
||||
.cr-diff-stat[data-change="add"] { color: var(--cr-diff-add); }
|
||||
.cr-diff-stat[data-change="mod"] { color: var(--cr-diff-mod); }
|
||||
.cr-diff-stat[data-change="del"] { color: var(--cr-diff-del); }
|
||||
|
||||
/* 基准里没有这个文件:顶部一条横幅,卡片不再逐张标绿 */
|
||||
.cr-new-file {
|
||||
border: 1px solid color-mix(in srgb, var(--cr-diff-add) 50%, transparent);
|
||||
background: color-mix(in srgb, var(--cr-diff-add) 14%, transparent);
|
||||
}
|
||||
.cr-new-file > svg {
|
||||
color: var(--cr-diff-add);
|
||||
}
|
||||
|
||||
/*
|
||||
* 卡片里的标记:改了的值旧值红底划掉、紧跟新值绿底(.cr-diff-part),说明里增删的字同样(.cr-diff-word,<ins> / <del>);
|
||||
* 新加的行、子行整行淡绿底、左边 +,删掉的整行淡红底、划掉、左边 −;表格的行记号写在第一格前(.cr-diff-sign)
|
||||
*/
|
||||
body {
|
||||
--cr-diff-add-text: var(--vscode-diffEditor-insertedTextBackground, color-mix(in srgb, var(--cr-diff-add) 32%, transparent));
|
||||
--cr-diff-del-text: var(--vscode-diffEditor-removedTextBackground, color-mix(in srgb, var(--cr-diff-del) 32%, transparent));
|
||||
}
|
||||
.cr-diff-part,
|
||||
.cr-diff-word {
|
||||
border-radius: 2px;
|
||||
-webkit-box-decoration-break: clone;
|
||||
box-decoration-break: clone;
|
||||
}
|
||||
:is(.cr-diff-part, .cr-diff-word)[data-change="add"],
|
||||
ins.cr-diff-word {
|
||||
background: var(--cr-diff-add-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
:is(.cr-diff-part, .cr-diff-word)[data-change="del"],
|
||||
del.cr-diff-word {
|
||||
background: var(--cr-diff-del-text);
|
||||
text-decoration-line: line-through;
|
||||
}
|
||||
.cr-diff-part[data-change="del"] * {
|
||||
text-decoration-line: line-through;
|
||||
}
|
||||
:is(.cr-prop-row, .cr-prop-item, .cr-table-row)[data-diff="add"] {
|
||||
background: color-mix(in srgb, var(--cr-diff-add) 14%, transparent);
|
||||
}
|
||||
:is(.cr-prop-row, .cr-prop-item, .cr-table-row)[data-diff="del"] {
|
||||
background: color-mix(in srgb, var(--cr-diff-del) 14%, transparent);
|
||||
}
|
||||
:is(.cr-prop-row, .cr-prop-item, .cr-table-row)[data-diff="del"] :not(.cr-diff-sign),
|
||||
.cr-diff-gone,
|
||||
.cr-diff-gone * {
|
||||
text-decoration-line: line-through;
|
||||
}
|
||||
:is(.cr-prop-row, .cr-prop-item)[data-diff] {
|
||||
position: relative;
|
||||
}
|
||||
:is(.cr-prop-row, .cr-prop-item)[data-diff]::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9px;
|
||||
width: 8px;
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
:is(.cr-prop-row, .cr-prop-item)[data-diff="add"]::before {
|
||||
content: "+";
|
||||
color: var(--cr-diff-add);
|
||||
}
|
||||
:is(.cr-prop-row, .cr-prop-item)[data-diff="del"]::before {
|
||||
content: "−";
|
||||
color: var(--cr-diff-del);
|
||||
}
|
||||
.cr-diff-sign {
|
||||
display: inline-block;
|
||||
width: 1.4ch;
|
||||
white-space: pre;
|
||||
}
|
||||
.cr-diff-sign[data-change="add"] { color: var(--cr-diff-add); }
|
||||
.cr-diff-sign[data-change="del"] { color: var(--cr-diff-del); }
|
||||
|
||||
/* 「只看改动」开关:一个小拨动开关加字,开着时底色同按下的工具按钮、开关换聚焦色 */
|
||||
.cr-only-changes {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
.cr-only-changes:hover {
|
||||
background: var(--vscode-toolbar-hoverBackground);
|
||||
}
|
||||
.cr-only-changes[aria-pressed="true"] {
|
||||
background: var(--vscode-toolbar-activeBackground);
|
||||
}
|
||||
.cr-only-changes:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.cr-switch {
|
||||
display: flex;
|
||||
width: 26px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
padding: 0 2px;
|
||||
border: 1px solid var(--vscode-checkbox-border, var(--vscode-editorWidget-border, var(--vscode-panel-border)));
|
||||
border-radius: 7px;
|
||||
background: var(--vscode-input-background, transparent);
|
||||
}
|
||||
.cr-switch-knob {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--vscode-descriptionForeground);
|
||||
}
|
||||
.cr-switch[data-on] {
|
||||
justify-content: flex-end;
|
||||
border-color: var(--vscode-focusBorder);
|
||||
background: var(--vscode-focusBorder);
|
||||
}
|
||||
.cr-switch[data-on] .cr-switch-knob {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 「只看改动」时连着没变的块折成的一条:虚线框,点开恢复 */
|
||||
.cr-unchanged {
|
||||
border: 1px dashed var(--vscode-editorWidget-border, var(--vscode-panel-border));
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
.cr-unchanged:hover {
|
||||
background: var(--vscode-toolbar-hoverBackground);
|
||||
}
|
||||
.cr-unchanged:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* 预览右边缘的总览条:从筛选栏下面贴到底,每处改动按它在文件里的行画一段刻度(删掉的块一条细线),点刻度跳过去 */
|
||||
.cr-ruler {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
width: 14px;
|
||||
border-left: 1px solid var(--vscode-editorOverviewRuler-border, var(--vscode-panel-border, transparent));
|
||||
background: var(--vscode-editor-background);
|
||||
}
|
||||
.cr-ruler-track {
|
||||
position: absolute;
|
||||
inset: 2px 0;
|
||||
}
|
||||
.cr-ruler-tick {
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
width: 8px;
|
||||
min-height: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cr-ruler-tick[data-change="add"] { background: var(--cr-diff-add); }
|
||||
.cr-ruler-tick[data-change="mod"] { background: var(--cr-diff-mod); }
|
||||
.cr-ruler-tick[data-change="del"] {
|
||||
min-height: 2px;
|
||||
height: 2px;
|
||||
background: var(--cr-diff-del);
|
||||
}
|
||||
.cr-ruler-tick:hover,
|
||||
.cr-ruler-tick:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { BlockView, DocumentDiff, RemovedView } from './types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { changeStops, diffTree, foldUnchanged, nextStop, openedByDiff, removedTitle, statsParts } from './diffView'
|
||||
|
||||
function block(id: string, anchor: string, children: BlockView[] = [], extra: Partial<BlockView> = {}): BlockView {
|
||||
return { id, anchor, kind: 'function', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'function' }, children, translation: [], ...extra } as BlockView
|
||||
}
|
||||
|
||||
function removed(parent: string, after: string | undefined, kind = 'function'): RemovedView {
|
||||
return { parent, ...(after !== undefined && { after }), block: block('9', `${kind} gone`, [], { kind }), line: 3, kindName: '函数' }
|
||||
}
|
||||
|
||||
function diff(parts: Partial<DocumentDiff>): DocumentDiff {
|
||||
return { base: '上一次提交', version: 2, blocks: {}, removed: [], stats: { add: 0, mod: 0, del: 0 }, lineCount: 10, ...parts }
|
||||
}
|
||||
|
||||
const BLOCKS = [
|
||||
block('0', 'function a', [block('0.0', 'function a > return @1')]),
|
||||
block('1', 'function b'),
|
||||
]
|
||||
|
||||
describe('diffTree', () => {
|
||||
it('块按记号认成页面上的 id;页面上找不到的记号(文档刚改)不标', () => {
|
||||
const tree = diffTree(BLOCKS, diff({ blocks: { 'function a': { change: 'mod' }, 'function a > return @1': { change: 'mod', own: true }, 'function gone': { change: 'add' } } }), 2, () => true)
|
||||
expect([...tree!.changes]).toEqual([['0', { change: 'mod' }], ['0.0', { change: 'mod', own: true }]])
|
||||
expect(tree!.current).toBe(true)
|
||||
})
|
||||
|
||||
it('页面上不是差异算的那一版时,卡片里的标记不画', () => {
|
||||
expect(diffTree(BLOCKS, diff({}), 3, () => true)?.current).toBe(false)
|
||||
})
|
||||
|
||||
it('删掉的块按层挂:父块、排在后面的那块换成页面上的 id,找不到的不画,被筛掉的不画', () => {
|
||||
const tree = diffTree(BLOCKS, diff({ removed: [
|
||||
removed('', 'function b'),
|
||||
removed('function a', undefined),
|
||||
removed('function missing', undefined),
|
||||
removed('', 'function missing'),
|
||||
removed('', undefined, 'import'),
|
||||
] }), 2, item => item.block.kind !== 'import')
|
||||
expect(tree!.removed.get('')).toEqual([{ id: 'removed-0', after: '1', removed: removed('', 'function b') }])
|
||||
expect(tree!.removed.get('0')).toEqual([{ id: 'removed-1', removed: removed('function a', undefined) }])
|
||||
expect(tree!.removed.size).toBe(2)
|
||||
})
|
||||
|
||||
it('不比、基准里没有这个文件时不逐块标', () => {
|
||||
expect(diffTree(BLOCKS, null, 2, () => true)).toBeUndefined()
|
||||
expect(diffTree(BLOCKS, diff({ newFile: '上一次提交里没有这个文件' }), 2, () => true)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('标了改动的块默认展开', () => {
|
||||
const tree = diffTree(BLOCKS, diff({ blocks: { 'function b': { change: 'add', outermost: true } } }), 2, () => true)
|
||||
expect([...openedByDiff(tree)]).toEqual(['1'])
|
||||
expect(openedByDiff(undefined).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('statsParts', () => {
|
||||
it('没有的那种不写', () => {
|
||||
expect(statsParts({ add: 2, mod: 0, del: 1 })).toEqual([{ change: 'add', text: '新增 2' }, { change: 'del', text: '删除 1' }])
|
||||
expect(statsParts({ add: 0, mod: 0, del: 0 })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('removedTitle', () => {
|
||||
it('属性表取第一行的值,一行译文取译文,只取第一行', () => {
|
||||
expect(removedTitle(block('0', 'x', [], { properties: [{ label: '名称', value: [{ text: '_is_owner', role: 'function' }] }] } as Partial<BlockView>))).toBe('_is_owner')
|
||||
expect(removedTitle(block('0', 'x', [], { translation: [{ text: '调用 f\n接着', role: 'plain' }] }))).toBe('调用 f')
|
||||
expect(removedTitle(block('0', 'x', [], { kind: 'comment', translation: { markdown: '说明第一行\n第二行' } }))).toBe('说明第一行')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 0 function a(修改)
|
||||
* 0.0 import(没变)
|
||||
* 0.1 return(修改,自己改了)
|
||||
* 0.2 return(没变)
|
||||
* 1 function b(没变)
|
||||
* 2 function c(没变)
|
||||
* 3 function d(新增,带子块 3.0)
|
||||
* 4 function e(没变)
|
||||
*/
|
||||
const TREE_BLOCKS = [
|
||||
block('0', 'a', [block('0.0', 'a0', [], { startLine: 1 }), block('0.1', 'a1', [], { startLine: 2, endLine: 2 }), block('0.2', 'a2', [], { startLine: 3 })], { startLine: 0, endLine: 3 }),
|
||||
block('1', 'b', [], { startLine: 5 }),
|
||||
block('2', 'c', [], { startLine: 7 }),
|
||||
block('3', 'd', [block('3.0', 'd0')], { startLine: 9, endLine: 11 }),
|
||||
block('4', 'e', [], { startLine: 13 }),
|
||||
]
|
||||
|
||||
function tree(removedList: RemovedView[] = []): NonNullable<ReturnType<typeof diffTree>> {
|
||||
return diffTree(TREE_BLOCKS, diff({
|
||||
blocks: { a: { change: 'mod' }, a1: { change: 'mod', own: true }, d: { change: 'add', outermost: true }, d0: { change: 'add' } },
|
||||
removed: removedList,
|
||||
}), 2, () => true)!
|
||||
}
|
||||
|
||||
describe('foldUnchanged', () => {
|
||||
it('每层连着没变的块折成一段,改过的块下面同样按层折', () => {
|
||||
const { runs, folded } = foldUnchanged(TREE_BLOCKS, tree(), () => true, new Set(), new Set())
|
||||
expect([...runs]).toEqual([['0.0', 1], ['0.2', 1], ['1', 2], ['4', 1]])
|
||||
expect([...folded].sort()).toEqual(['0.0', '0.2', '1', '2', '4'])
|
||||
})
|
||||
|
||||
it('删掉的块排在哪块后面,那一段就在哪儿断开', () => {
|
||||
const { runs } = foldUnchanged(TREE_BLOCKS, tree([removed('', 'b')]), () => true, new Set(), new Set())
|
||||
expect([...runs]).toContainEqual(['1', 1])
|
||||
expect([...runs]).toContainEqual(['2', 1])
|
||||
})
|
||||
|
||||
it('被筛掉的块不算也不打断;点开过的段、要露出来的块不折', () => {
|
||||
expect([...foldUnchanged(TREE_BLOCKS, tree(), block => block.id !== '2', new Set(), new Set()).runs]).toContainEqual(['1', 1])
|
||||
expect(foldUnchanged(TREE_BLOCKS, tree(), () => true, new Set(['1']), new Set()).runs.has('1')).toBe(false)
|
||||
expect(foldUnchanged(TREE_BLOCKS, tree(), () => true, new Set(), new Set(['2'])).folded.has('2')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('changeStops', () => {
|
||||
it('新增的最外面那块、自己改了的修改块、删掉的块,照页面上的先后;修改块只算自己那几行', () => {
|
||||
const stops = changeStops(TREE_BLOCKS, tree([removed('', 'c'), removed('a', undefined)]), new Set())
|
||||
expect(stops.map(stop => [stop.id, stop.change, stop.from, stop.to, stop.ancestors])).toEqual([
|
||||
['removed-1', 'del', 3, 3, ['0']],
|
||||
['0.1', 'mod', 2, 2, ['0']],
|
||||
['removed-0', 'del', 3, 3, []],
|
||||
['3', 'add', 9, 11, []],
|
||||
])
|
||||
})
|
||||
|
||||
it('被筛掉的块和它下面的不算', () => {
|
||||
expect(changeStops(TREE_BLOCKS, tree(), new Set(['3', '3.0'])).map(stop => stop.id)).toEqual(['0.1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextStop', () => {
|
||||
const stops = [{ from: 2 }, { from: 9 }, { from: 20 }].map((stop, index) => ({ id: String(index), change: 'mod' as const, to: stop.from, ancestors: [], ...stop }))
|
||||
|
||||
it('接着刚跳到的那处走,到头绕回另一头', () => {
|
||||
expect(nextStop(stops, { index: 0 }, 1)).toBe(1)
|
||||
expect(nextStop(stops, { index: 2 }, 1)).toBe(0)
|
||||
expect(nextStop(stops, { index: 0 }, -1)).toBe(2)
|
||||
})
|
||||
|
||||
it('按视口顶上那块的行找:下一处在它后面,上一处在它前面', () => {
|
||||
expect(nextStop(stops, { line: 9 }, 1)).toBe(2)
|
||||
expect(nextStop(stops, { line: 9 }, -1)).toBe(0)
|
||||
expect(nextStop(stops, { line: 30 }, 1)).toBe(0)
|
||||
expect(nextStop(stops, { line: 0 }, -1)).toBe(2)
|
||||
expect(nextStop(stops, {}, 1)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { BlockView, ChangedBlock, DiffStats, DocumentDiff, RemovedView } from './types'
|
||||
import { inlineText } from './segments'
|
||||
|
||||
/** 页面上怎么标差异:按块 id 取改动,按层取删掉的块。基准里没有这个文件(整份新增)时不逐块标,没有它。 */
|
||||
export interface DiffTree {
|
||||
/** 按块 id:新增、修改的块 */
|
||||
changes: ReadonlyMap<string, ChangedBlock>
|
||||
/** 按父块 id(顶层 ''):这一层删掉的块,照差异里的先后 */
|
||||
removed: ReadonlyMap<string, PlacedRemoved[]>
|
||||
/** 页面上正是差异算的那一版:卡片里的标记按下标认,只在这时画 */
|
||||
current: boolean
|
||||
}
|
||||
|
||||
/** 放在页面上的一块删掉的块:id 是页面上认它用的(`removed-<序号>`,不会和块 id、属性行 id 撞上),after 是排在哪块(id)后面,最前面时没有。 */
|
||||
export interface PlacedRemoved {
|
||||
id: string
|
||||
after?: string
|
||||
removed: RemovedView
|
||||
}
|
||||
|
||||
function* allBlocks(blocks: readonly BlockView[]): Generator<BlockView> {
|
||||
for (const block of blocks) {
|
||||
yield block
|
||||
yield* allBlocks(block.children)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 差异(块按记号认)→ 页面上的块 id:记号在页面上找不到的(文档刚改、差异还没重算)不标;删掉的块挂的父块、排在后面的那块找不到的也不画。
|
||||
* shown 挑删掉的块画不画(被筛选藏掉的种类不画)。
|
||||
*/
|
||||
export function diffTree(blocks: readonly BlockView[], diff: DocumentDiff | null, version: number, shown: (removed: RemovedView) => boolean): DiffTree | undefined {
|
||||
if (!diff || diff.newFile !== undefined)
|
||||
return undefined
|
||||
const ids = new Map([...allBlocks(blocks)].map(block => [block.anchor, block.id]))
|
||||
const changes = new Map<string, ChangedBlock>()
|
||||
for (const [anchor, change] of Object.entries(diff.blocks)) {
|
||||
const id = ids.get(anchor)
|
||||
if (id !== undefined)
|
||||
changes.set(id, change)
|
||||
}
|
||||
const removed = new Map<string, PlacedRemoved[]>()
|
||||
diff.removed.forEach((item, index) => {
|
||||
const parent = item.parent === '' ? '' : ids.get(item.parent)
|
||||
const after = item.after === undefined ? undefined : ids.get(item.after)
|
||||
if (parent === undefined || (item.after !== undefined && after === undefined) || !shown(item))
|
||||
return
|
||||
removed.set(parent, [...removed.get(parent) ?? [], { id: `removed-${index}`, ...(after !== undefined && { after }), removed: item }])
|
||||
})
|
||||
return { changes, removed, current: diff.version === version }
|
||||
}
|
||||
|
||||
/** 标了改动的块默认展开(函数、类本来默认收起子块):改了什么一眼能看到。 */
|
||||
export function openedByDiff(tree: DiffTree | undefined): ReadonlySet<string> {
|
||||
return new Set(tree?.changes.keys())
|
||||
}
|
||||
|
||||
/** 「新增 2 · 修改 3 · 删除 1」的各段:没有的那种不写。 */
|
||||
export function statsParts(stats: DiffStats): { change: keyof DiffStats, text: string }[] {
|
||||
const labels: Record<keyof DiffStats, string> = { add: '新增', mod: '修改', del: '删除' }
|
||||
return (['add', 'mod', 'del'] as const).filter(change => stats[change] > 0).map(change => ({ change, text: `${labels[change]} ${stats[change]}` }))
|
||||
}
|
||||
|
||||
/** 删掉的块收起时写的名字:属性表是第一行的值(函数名、变量名、导入的来源),一行译文是译文;只取第一行。 */
|
||||
export function removedTitle(block: BlockView): string {
|
||||
const text = 'properties' in block
|
||||
? block.properties[0] && ('markdown' in block.properties[0].value ? block.properties[0].value.markdown : inlineText(block.properties[0].value))
|
||||
: 'markdown' in block.translation ? block.translation.markdown : inlineText(block.translation)
|
||||
return (text ?? '').split('\n', 1)[0].trim()
|
||||
}
|
||||
|
||||
/** 「只看改动」时折起来的:各段连着没变的块(按第一块的 id 记这段几块),和折进去的所有块(连同子孙)。 */
|
||||
export interface FoldedRuns {
|
||||
runs: ReadonlyMap<string, number>
|
||||
folded: ReadonlySet<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* 「只看改动」:每一层连着没变的块折成一段(被筛掉的块不算、不打断),改过的块照常、它下面的子块同样按层折;
|
||||
* 删掉的块排在哪块后面,那一段就在哪儿断开。opened 里的段(按第一块的 id,点开过的)不折;keep 里的块(跳到定义要露出来的)
|
||||
* 和通向它的块不折。
|
||||
*/
|
||||
export function foldUnchanged(blocks: readonly BlockView[], tree: DiffTree, shown: (block: BlockView) => boolean, opened: ReadonlySet<string>, keep: ReadonlySet<string>): FoldedRuns {
|
||||
const runs = new Map<string, number>()
|
||||
const folded = new Set<string>()
|
||||
const fold = (block: BlockView): void => {
|
||||
folded.add(block.id)
|
||||
block.children.forEach(fold)
|
||||
}
|
||||
const kept = (block: BlockView): boolean => keep.has(block.id) || block.children.some(kept)
|
||||
const visit = (list: readonly BlockView[], owner: string): void => {
|
||||
const breaks = new Set((tree.removed.get(owner) ?? []).map(item => item.after))
|
||||
let run: BlockView[] = []
|
||||
const close = (): void => {
|
||||
if (run.length > 0 && !opened.has(run[0].id)) {
|
||||
runs.set(run[0].id, run.length)
|
||||
run.forEach(fold)
|
||||
}
|
||||
run = []
|
||||
}
|
||||
for (const block of list) {
|
||||
if (shown(block)) {
|
||||
if (tree.changes.has(block.id) || kept(block)) {
|
||||
close()
|
||||
visit(block.children, block.id)
|
||||
}
|
||||
else {
|
||||
run.push(block)
|
||||
}
|
||||
}
|
||||
if (breaks.has(block.id))
|
||||
close()
|
||||
}
|
||||
close()
|
||||
}
|
||||
visit(blocks, '')
|
||||
return { runs, folded }
|
||||
}
|
||||
|
||||
/**
|
||||
* 「上一处 / 下一处」和总览条上的一处改动:新增的最外面那块、自己改了的修改块、删掉的块,照页面上的先后。
|
||||
* id 是页面上的块 id(删掉的块是 PlacedRemoved.id);from / to 是它在新文件里的行(0 起),修改块只算它自己那几行(到第一个子块之前);
|
||||
* ancestors 是要展开才看得见它的各层块。
|
||||
*/
|
||||
export interface ChangeStop {
|
||||
id: string
|
||||
change: 'add' | 'mod' | 'del'
|
||||
from: number
|
||||
to: number
|
||||
ancestors: string[]
|
||||
}
|
||||
|
||||
/** 页面上的各处改动(见 ChangeStop);hidden 里的块(被筛掉的)连同子孙不算。 */
|
||||
export function changeStops(blocks: readonly BlockView[], tree: DiffTree, hidden: ReadonlySet<string>): ChangeStop[] {
|
||||
const stops: ChangeStop[] = []
|
||||
const visit = (list: readonly BlockView[], owner: string, ancestors: string[]): void => {
|
||||
const removed = tree.removed.get(owner) ?? []
|
||||
const removedAfter = (id: string | undefined): void => {
|
||||
for (const item of removed.filter(entry => entry.after === id))
|
||||
stops.push({ id: item.id, change: 'del', from: item.removed.line, to: item.removed.line, ancestors })
|
||||
}
|
||||
removedAfter(undefined)
|
||||
for (const block of list) {
|
||||
const change = hidden.has(block.id) ? undefined : tree.changes.get(block.id)
|
||||
if (change?.change === 'add' && change.outermost)
|
||||
stops.push({ id: block.id, change: 'add', from: block.startLine, to: block.endLine, ancestors })
|
||||
if (change?.change === 'mod' && change.own) {
|
||||
const own = block.children[0] ? Math.max(block.startLine, block.children[0].startLine - 1) : block.endLine
|
||||
stops.push({ id: block.id, change: 'mod', from: block.startLine, to: own, ancestors })
|
||||
}
|
||||
if (change?.change === 'mod')
|
||||
visit(block.children, block.id, [...ancestors, block.id])
|
||||
removedAfter(block.id)
|
||||
}
|
||||
}
|
||||
visit(blocks, '', [])
|
||||
return stops
|
||||
}
|
||||
|
||||
/**
|
||||
* 「上一处 / 下一处」跳到第几处:at.index 是接着走的那处(刚跳到的那处还在视口顶上时),否则按视口顶上那块的行 at.line 找:
|
||||
* 下一处是起始行在它后面的第一处,上一处是在它前面的最后一处;走到头绕回另一头。
|
||||
*/
|
||||
export function nextStop(stops: readonly ChangeStop[], at: { index?: number, line?: number }, step: 1 | -1): number {
|
||||
const count = stops.length
|
||||
if (at.index !== undefined)
|
||||
return (at.index + step + count) % count
|
||||
const line = at.line
|
||||
if (line === undefined)
|
||||
return step === 1 ? 0 : count - 1
|
||||
if (step === 1) {
|
||||
const index = stops.findIndex(stop => stop.from > line)
|
||||
return index >= 0 ? index : 0
|
||||
}
|
||||
let last = -1
|
||||
stops.forEach((stop, index) => {
|
||||
if (stop.from < line)
|
||||
last = index
|
||||
})
|
||||
return last >= 0 ? last : count - 1
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import { renderMarkdown, renderMarkdownDiff } from './markdown'
|
||||
|
||||
describe('renderMarkdown', () => {
|
||||
it('一行文字是一个段落;段内换行是软换行,空行分段', () => {
|
||||
@@ -45,3 +45,19 @@ describe('renderMarkdown', () => {
|
||||
expect(renderMarkdown('[文档](https://example.com "")')).toBe('<p><a href="https://example.com">文档</a></p>\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderMarkdownDiff', () => {
|
||||
it('照常排版,新加的字包进 <ins>、删掉的包进 <del>', () => {
|
||||
expect(renderMarkdownDiff([{ text: '放锁' }, { text: '前', change: 'del' }, { text: '都', change: 'add' }, { text: '必须比对 `token`' }]))
|
||||
.toBe('<p>放锁<del class="cr-diff-word">前</del><ins class="cr-diff-word">都</ins>必须比对 <code>token</code></p>\n')
|
||||
})
|
||||
|
||||
it('跨行的一截逐行包,新加的列表项留住列表记号', () => {
|
||||
expect(renderMarkdownDiff([{ text: '- a\n' }, { text: '- b\n- c', change: 'add' }]))
|
||||
.toBe('<ul>\n<li>a</li>\n<li><ins class="cr-diff-word">b</ins></li>\n<li><ins class="cr-diff-word">c</ins></li>\n</ul>\n')
|
||||
})
|
||||
|
||||
it('行中间的一截不把前面的字当块记号', () => {
|
||||
expect(renderMarkdownDiff([{ text: '共 ' }, { text: '# 3', change: 'add' }])).toBe('<p>共 <ins class="cr-diff-word"># 3</ins></p>\n')
|
||||
})
|
||||
})
|
||||
@@ -40,3 +40,37 @@ export function renderMarkdown(text: string): string {
|
||||
cache.delete(cache.keys().next().value!)
|
||||
return html
|
||||
}
|
||||
|
||||
/** 渲染带增删的 Markdown 时,新加 / 删掉的字前后夹的记号(Unicode 私用区的字,正文里不会有),渲染完换成 <ins> / <del>。 */
|
||||
const MARKS = {
|
||||
add: ['', '', '<ins class="cr-diff-word">', '</ins>'],
|
||||
del: ['', '', '<del class="cr-diff-word">', '</del>'],
|
||||
} as const
|
||||
|
||||
/** 一行开头的块记号(列表、标题、引用):新加的一行要把它留在记号外面,不然就不认成列表、标题了。 */
|
||||
const BLOCK_LEAD = /^\s*(?:(?:[\-*+>]|\d+[.)]|#{1,6})\s+)*/
|
||||
|
||||
/**
|
||||
* 带增删的 Markdown 说明 → HTML:照常排版,新加的字包进 <ins>、删掉的包进 <del>(样式见 .cr-diff-word)。
|
||||
* 一截跨了几行时逐行包,记号不跨段落、列表项。
|
||||
*/
|
||||
export function renderMarkdownDiff(parts: readonly { text: string, change?: 'add' | 'del' }[]): string {
|
||||
let text = ''
|
||||
for (const { text: piece, change } of parts) {
|
||||
if (!change) {
|
||||
text += piece
|
||||
continue
|
||||
}
|
||||
const [open, close] = MARKS[change]
|
||||
text += piece.split('\n').map((line, index) => {
|
||||
const atLineStart = index > 0 || text === '' || text.endsWith('\n')
|
||||
const lead = atLineStart ? BLOCK_LEAD.exec(line)?.[0] ?? '' : ''
|
||||
const rest = line.slice(lead.length)
|
||||
return rest ? `${lead}${open}${rest}${close}` : line
|
||||
}).join('\n')
|
||||
}
|
||||
let html = md.render(text)
|
||||
for (const [open, close, openTag, closeTag] of Object.values(MARKS))
|
||||
html = html.replaceAll(open, openTag).replaceAll(close, closeTag)
|
||||
return html
|
||||
}
|
||||
@@ -258,17 +258,108 @@ export interface AnswerEnd {
|
||||
entryId?: string
|
||||
}
|
||||
|
||||
/** 一截是新加的还是删掉的;没变的不写 */
|
||||
export type PartChange = 'add' | 'del'
|
||||
|
||||
/** 着色的词里的一截:没变的、新加的(带源码下标),或删掉的(取自旧版本,不带下标) */
|
||||
export interface InlinePart { segments: Inline[], change?: PartChange }
|
||||
|
||||
/** Markdown 说明里的一截:按词切,中文按字 */
|
||||
export interface MarkdownPart { text: string, change?: PartChange }
|
||||
|
||||
/** 一段预览文字改成了什么样:着色的词逐截标(改得多就是整段旧值 → 新值),Markdown 说明逐词 / 字标 */
|
||||
export type TextDiff = { inline: InlinePart[] } | { markdown: MarkdownPart[] }
|
||||
|
||||
/** 删掉的一行 / 子行:旧的那行,和它排在新的第几行后面(-1 是最前面) */
|
||||
export interface Removed<T> { after: number, value: T }
|
||||
|
||||
/** 一层子行(参数表、导入项、字典的各项、摊开实参的表)的改动:changed 按新子行的下标,没变的不写 */
|
||||
export interface ItemsDiff {
|
||||
changed: Record<number, ItemDiff>
|
||||
removed: Removed<PropertyItem>[]
|
||||
}
|
||||
|
||||
/** 一个子行的改动:整行新加的(它下面各层也都是),或按列标的格子、下一层、套着的表 */
|
||||
export interface ItemDiff {
|
||||
added?: true
|
||||
cells?: Record<number, InlinePart[]>
|
||||
items?: ItemsDiff
|
||||
table?: ItemsDiff
|
||||
}
|
||||
|
||||
/** 属性表各行的改动:changed 按新属性表的行下标,没变的不写 */
|
||||
export interface RowsDiff {
|
||||
changed: Record<number, RowDiff>
|
||||
removed: Removed<PropertyRow>[]
|
||||
}
|
||||
|
||||
/** 属性表一行的改动:整行新加的(它下面的子行也都是),或值、子行改了 */
|
||||
export interface RowDiff {
|
||||
added?: true
|
||||
value?: TextDiff
|
||||
items?: ItemsDiff
|
||||
}
|
||||
|
||||
/** 一块自己的内容(不算子块)改了哪些:属性表逐行,一行译文逐截,摊开实参的表逐行,说明逐词 */
|
||||
export interface BlockMarks {
|
||||
rows?: RowsDiff
|
||||
translation?: TextDiff
|
||||
table?: ItemsDiff
|
||||
doc?: TextDiff
|
||||
}
|
||||
|
||||
/**
|
||||
* 一块的改动:add 新增(outermost 是新增的最外面那块,挂「新增」小标;子孙跟着算新增),mod 修改(own 是这块自己的内容或头部变了,
|
||||
* 不只是子块变了;marks 是卡片里要标的地方)
|
||||
*/
|
||||
export interface ChangedBlock {
|
||||
change: 'add' | 'mod'
|
||||
outermost?: true
|
||||
own?: true
|
||||
marks?: BlockMarks
|
||||
}
|
||||
|
||||
/**
|
||||
* 删掉的一块,放回原来的位置:parent 是父块的记号(顶层 ''),after 是排在这一层哪块后面(新块的记号,最前面时没有);
|
||||
* block 是旧版本里的这块(只带它自己的内容,词不带源码下标),line 是它在新文件里的位置(0 起),kindName 是种类的名字
|
||||
*/
|
||||
export interface RemovedView {
|
||||
parent: string
|
||||
after?: string
|
||||
block: BlockView
|
||||
line: number
|
||||
kindName: string
|
||||
}
|
||||
|
||||
/** 「新增 N · 修改 N · 删除 N」:只数最外层带标记的块 */
|
||||
export interface DiffStats { add: number, mod: number, del: number }
|
||||
|
||||
/**
|
||||
* 这份文件相比对比基准改了什么:base 是「对比:」后面的字;块按记号(anchor)认,卡片里的标记只在页面上正是 version 这一版时画;
|
||||
* newFile 是基准里没有这个文件时横幅上的说明(这时不逐块标);lineCount 是新文件一共几行,总览条按它排刻度
|
||||
*/
|
||||
export interface DocumentDiff {
|
||||
base: string
|
||||
version: number
|
||||
newFile?: string
|
||||
blocks: Record<string, ChangedBlock>
|
||||
removed: RemovedView[]
|
||||
stats: DiffStats
|
||||
lineCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
|
||||
* definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义
|
||||
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
|
||||
* filter:整个工作区共用的块筛选;ready 后先于第一份文档来一次,之后任何一个预览改了都会来
|
||||
* filter:整个工作区共用的块筛选和「只看改动」开关;ready 后先于第一份文档来一次,之后任何一个预览改了都会来
|
||||
* qa:这份文件的问答;ready 后来一次,问答、正在答的问题变了再来
|
||||
* askProfiles:「问 AI」的 profile 下拉;ready 后来一次,任何一个预览改了都会来
|
||||
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
|
||||
* answerEnd:askId 那个问题答完了,见 AnswerEnd
|
||||
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现
|
||||
* displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理
|
||||
* diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有);每来一份文档之后补来,基准变了再来;null 是不比了
|
||||
*/
|
||||
export type ExtensionToWebview
|
||||
= | DocumentMessage
|
||||
@@ -277,11 +368,12 @@ export type ExtensionToWebview
|
||||
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
|
||||
| { type: 'moveOccurrence', step: 1 | -1 }
|
||||
| { type: 'displayNames', version: number, names: ShownName[] }
|
||||
| { type: 'filter', excluded: Excluded }
|
||||
| { type: 'filter', excluded: Excluded, onlyChanges: boolean }
|
||||
| QaMessage
|
||||
| ({ type: 'askProfiles' } & ProfileChoice)
|
||||
| { type: 'answer', askId: string, text: string, activity?: string }
|
||||
| AnswerEnd
|
||||
| { type: 'diff', diff: DocumentDiff | null }
|
||||
|
||||
/**
|
||||
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进)
|
||||
@@ -291,6 +383,7 @@ export type ExtensionToWebview
|
||||
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
|
||||
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
|
||||
* setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示
|
||||
* setOnlyChanges:打开 / 关掉「只看改动」;同样等扩展端发回 filter 再按它显示
|
||||
* ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;扩展端接着这块最近一次问答的会话问,用眼下选的 profile;
|
||||
* images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存
|
||||
* stopAsk:叫停 askId 那个问题,这次不存
|
||||
@@ -301,6 +394,7 @@ export type ExtensionToWebview
|
||||
export type WebviewToExtension
|
||||
= | { type: 'ready' }
|
||||
| { type: 'setFilter', excluded: Excluded }
|
||||
| { type: 'setOnlyChanges', on: boolean }
|
||||
| { type: 'edit', startLine: number, endLine: number, text: string }
|
||||
| { type: 'selectTranslator', id: string }
|
||||
| { type: 'visibleLine', line: number }
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ChangeStop } from './diffView'
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { nextStop } from './diffView'
|
||||
import { anchorAt } from './scrollPosition'
|
||||
import { flash } from './useReveal'
|
||||
import { measureBlocks, pinnedHeight } from './useScrollPosition'
|
||||
|
||||
/** 跳到的那处离筛选栏下沿留多少像素。 */
|
||||
const MARGIN = 8
|
||||
|
||||
/**
|
||||
* 「上一处 / 下一处」和点总览条的刻度:跳到一处改动。它在折叠着的块里时先展开那几层(expand),渲染好了再把它滚到筛选栏下面、闪几下。
|
||||
* 接着走从哪算:跳完之后页面没滚过就从刚跳到的那处接着走,用户自己滚过了就按视口顶上那块的行找(见 nextStop)。
|
||||
*/
|
||||
export function useChangeNav(stops: readonly ChangeStop[], collapsed: ReadonlySet<string>, expand: (ids: string[]) => void): {
|
||||
move: (step: 1 | -1) => void
|
||||
goTo: (stop: ChangeStop) => void
|
||||
} {
|
||||
const [target, setTarget] = useState<ChangeStop | null>(null)
|
||||
/** 最近一次跳到的那处,和跳完时页面滚到哪 */
|
||||
const jumped = useRef<{ id: string, scrollY: number } | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!target)
|
||||
return
|
||||
const hidden = target.ancestors.filter(id => collapsed.has(id))
|
||||
if (hidden.length > 0) {
|
||||
expand(hidden)
|
||||
return
|
||||
}
|
||||
const card = document.querySelector<HTMLElement>(`.cr-block[data-id="${target.id}"]`)
|
||||
if (card) {
|
||||
window.scrollTo(0, card.getBoundingClientRect().top + window.scrollY - pinnedHeight() - MARGIN)
|
||||
flash(card)
|
||||
}
|
||||
jumped.current = { id: target.id, scrollY: window.scrollY }
|
||||
setTarget(null)
|
||||
}, [target, collapsed, expand])
|
||||
|
||||
const move = useCallback((step: 1 | -1) => {
|
||||
if (stops.length === 0)
|
||||
return
|
||||
const from = jumped.current && Math.abs(window.scrollY - jumped.current.scrollY) < 2 ? jumped.current.id : undefined
|
||||
const index = from === undefined ? -1 : stops.findIndex(stop => stop.id === from)
|
||||
const at = index >= 0 ? { index } : { line: anchorAt(measureBlocks(), window.scrollY + pinnedHeight())?.line }
|
||||
setTarget(stops[nextStop(stops, at, step)])
|
||||
}, [stops])
|
||||
|
||||
return { move, goTo: setTarget }
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { blockForLine } from './scrollPosition'
|
||||
import { measureBlocks, pinnedHeight } from './useScrollPosition'
|
||||
|
||||
/** 让一块或属性表里的一行闪几下(codeReader.css 的 [data-flash]);正在闪的同一处再跳一次时从头闪。 */
|
||||
function flash(el: HTMLElement): void {
|
||||
export function flash(el: HTMLElement): void {
|
||||
const running = el.getAnimations().filter(animation => animation instanceof CSSAnimation)
|
||||
if (running.length > 0) {
|
||||
for (const animation of running)
|
||||
|
||||
@@ -119,6 +119,11 @@ describe('眼下折叠着哪些', () => {
|
||||
expect([...collapsedIds([block('0', 'exception', [block('0.0', 'function')]), block('1', 'if')], {})]).toEqual(['0', '0.0'])
|
||||
})
|
||||
|
||||
it('标了改动的块默认展开,用户折叠过的照样折叠', () => {
|
||||
expect([...collapsedIds(BLOCKS, {}, new Set(['1', '2']))].sort()).toEqual(['1.0.0', '2.0'])
|
||||
expect([...collapsedIds(BLOCKS, { 1: true }, new Set(['1']))].sort()).toEqual(['1', '1.0.0', '2', '2.0'])
|
||||
})
|
||||
|
||||
it('点过的按用户的:展开的函数、类保持展开,折叠的其他块、属性行保持折叠', () => {
|
||||
const folds = { '1': false, '2': false, '1.0': true, '1.0.0': false, [propertyRowId('0', 2)]: true, [propertyRowId('2.0', 1)]: false }
|
||||
expect([...collapsedIds(BLOCKS, folds)].sort()).toEqual(['0/p2', '1.0', '2.0'])
|
||||
|
||||
@@ -59,12 +59,12 @@ export function foldedByDefault(block: Pick<BlockView, 'kind' | 'badge'>): boole
|
||||
return block.badge !== undefined || ['function', 'class', 'enum', 'exception'].includes(block.kind)
|
||||
}
|
||||
|
||||
/** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault。 */
|
||||
export function collapsedIds(blocks: readonly BlockView[], folds: Readonly<Record<string, boolean>>): Set<string> {
|
||||
/** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault;opened 里的块(标了改动的)默认展开。 */
|
||||
export function collapsedIds(blocks: readonly BlockView[], folds: Readonly<Record<string, boolean>>, opened: ReadonlySet<string> = new Set()): Set<string> {
|
||||
const collapsed = new Set(Object.keys(folds).filter(id => folds[id]))
|
||||
const visit = (list: readonly BlockView[]): void => {
|
||||
for (const block of list) {
|
||||
if (folds[block.id] === undefined && foldedByDefault(block))
|
||||
if (folds[block.id] === undefined && foldedByDefault(block) && !opened.has(block.id))
|
||||
collapsed.add(block.id)
|
||||
visit(block.children)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user