From c84e21ff571660951eb81b243947fc01e5a9d1c6 Mon Sep 17 00:00:00 2001 From: cruldra Date: Sat, 26 Sep 2026 20:39:21 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E4=BE=A7=E6=A0=8F=E8=A1=8C=E5=8A=A0=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E8=8F=9C=E5=8D=95=EF=BC=8C=E5=9D=97=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E5=8F=AA=E7=95=99=E5=9B=BE=E6=A0=87=EF=BC=8C=E7=AD=9B=E9=80=89?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E5=8A=A0=E5=85=A8=E9=80=89=20/=20=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E5=85=A8=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 35 ++++- vscode/package.json | 115 +++++++++++++- .../src/codeReader/explorer/copyCommands.ts | 27 ++++ .../src/codeReader/explorer/copyText.test.ts | 42 ++++++ vscode/src/codeReader/explorer/copyText.ts | 41 +++++ vscode/src/codeReader/explorer/fileEntries.ts | 7 +- .../src/codeReader/explorer/rowMenus.test.ts | 104 +++++++++++++ vscode/src/codeReader/index.ts | 4 +- vscode/src/codeReader/notes/noteItem.test.ts | 31 ---- .../src/codeReader/BlockTools.test.tsx | 20 +++ .../webview-ui/src/codeReader/BlockTools.tsx | 25 ++++ .../webview-ui/src/codeReader/BlockTree.tsx | 18 +-- .../webview-ui/src/codeReader/FilterBar.tsx | 140 +++++++++++------- .../webview-ui/src/codeReader/codeReader.css | 22 ++- .../webview-ui/src/codeReader/filter.test.ts | 26 +++- vscode/webview-ui/src/codeReader/filter.ts | 17 ++- 16 files changed, 555 insertions(+), 119 deletions(-) create mode 100644 vscode/src/codeReader/explorer/copyCommands.ts create mode 100644 vscode/src/codeReader/explorer/copyText.test.ts create mode 100644 vscode/src/codeReader/explorer/copyText.ts create mode 100644 vscode/src/codeReader/explorer/rowMenus.test.ts create mode 100644 vscode/webview-ui/src/codeReader/BlockTools.test.tsx create mode 100644 vscode/webview-ui/src/codeReader/BlockTools.tsx diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 18153a2..5f7a794 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -46,12 +46,24 @@ - 树节点用 `resourceUri`,图标跟随用户的文件图标主题,git 改动颜色(M / U)由 VS Code 自动加上,外观与自带资源管理器一致。 - 视图标题栏:刷新、全部折叠(`showCollapseAll`)。文件变化时自动刷新(防抖)。 - 点文件:语言受支持 → 用「代码阅读预览」自定义编辑器打开;否则 `vscode.open`。 +- 鼠标停在目录 / 文件行上,行尾一排按钮(`view/item/context` 的 `inline` 组):先「复制」,再「添加注释 / 编辑注释」(见下两节)。工作区根节点没有。 + +### 复制 + +原型画板「侧栏 · 复制菜单」。 + +- 行尾第一个按钮「复制」(`$(copy)`):点开一组复制方式——文件:复制文件名(带后缀)、复制文件名(不带后缀)、复制相对路径、复制绝对路径;目录:复制文件夹名、复制相对路径、复制绝对路径(没有「不带后缀」)。右键菜单里也有同样几项,排在注释前面。有没有注释都一样;工作区根节点没有。 +- 做法:`contributes.submenus` 里一个带 `$(copy)` 图标的子菜单(`superpowers.codeReader.copy`),挂在 `view/item/context` 的 `inline@1`。VS Code 的树把行内子菜单画成点开的下拉按钮(`treeView.ts` 行尾按钮走 `createActionViewItem`,子菜单是 `SubmenuEntryActionViewItem`;子菜单的项用同一份带 `viewItem` 的上下文算 `when`、同样把这一行传给命令),1.97 起就支持。原型里按钮旁的小箭头、菜单右边淡色的「会复制出什么」VS Code 的菜单做不出,是和原型的有意差异;复制后状态栏说一声。 +- 已知的 VS Code 问题:菜单用 VS Code 自己画的样式时(`window.menuStyle` 不是 native;macOS 默认跟着自定义标题栏,也是这种),在下拉菜单里点了一项,命令照常执行,但菜单不会自己收起,要点别处或按 Esc。原因在 VS Code:树给行尾按钮换执行器时把旧的执行器销毁了,下拉菜单还拿着旧的,「执行了就收起」的通知收不到。右键菜单不受影响。 +- 复制出什么(`explorer/copyText.ts`,纯函数):名字取 `Uri.path` 的最后一段;不带后缀只去掉最后一个扩展名(`a.test.ts` → `a.test`),点开头、后面没有别的点的名字(`.env`)没有扩展名、原样,`.env.local` → `.env`(和 `files.exclude` 的 `$(basename)` 同一个函数);相对路径相对所在的工作区文件夹、`/` 分隔;绝对路径是 `Uri.fsPath`(本机写法)。 +- 写剪贴板用 `vscode.env.clipboard.writeText`,状态栏 `已复制:<内容>` 停 3 秒,连着复制只留最新一条。 +- 命令(`copyFileName` / `copyFolderName` / `copyFileStem` / `copyRelativePath` / `copyAbsolutePath`)只挂在侧栏行上,命令面板里藏起来(`when: false`)。文件名、文件夹名复制的是同一样东西,分两个命令只为菜单上写对字,按 `viewItem =~ /^file( noted)?$/`、`/^folder( noted)?$/` 挑。 ### 文件注释 读代码时给目录或文件随手记一句(原型画板「文件注释 · 悬停出按钮 / 填写 / 已保存」)。 -- 鼠标停在目录 / 文件行上,行尾出现按钮(`view/item/context` 的 `inline` 组):没注释时「添加注释」(`$(comment)`),有注释时「编辑注释」(`$(edit)`)。右键菜单里也有同一个命令。工作区根节点没有。 +- 鼠标停在目录 / 文件行上,行尾「复制」后面的按钮(`inline@2`):没注释时「添加注释」(`$(note)`),有注释时「编辑注释」(`$(edit)`)。右键菜单里也有同一个命令。工作区根节点没有。 - 两个命令只挂在侧栏行上,命令面板里藏起来(`when: false`):面板里没有「哪一行」可言,按当前文件记还得另判断它有没有注释,没有必要。 - 点按钮弹 VS Code 顶部的单行输入框(`window.showInputBox`):标题 `添加注释 · <名字>` / `编辑注释 · <名字>`,编辑时预填原注释(整段选中),提示 `按 Enter 保存,按 Esc 取消;清空后保存即删除注释`。回车保存(去掉首尾空白),Esc 或点别处不改,清空后保存即删除。VS Code 会在提示后面自己再补一句「(Press 'Enter' to confirm or 'Escape' to cancel)」,关不掉。 - 显示:有注释的行名字后面灰字是注释(`TreeItem.description`,放不下 VS Code 自己截断);悬停提示是 Markdown,第一行粗体的相对路径,下一行注释全文,注释里的 Markdown 符号原样显示。没注释的行和原来一样。 @@ -72,11 +84,12 @@ - 每一句是一块:导入、函数、类、枚举、类型、字段、常量、变量、条件、否则、循环、尝试、捕获、最终、返回、语句、注释。 - 嵌套的块放在父块下面、往右缩进;每一层左边一条竖线(颜色 `--vscode-editorIndentGuide-background`)。 -- 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类标签(字取翻译插件给 `kind` 值起的名字,见「筛选」)、内容、右侧切换按钮。 +- 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类标签(字取翻译插件给 `kind` 值起的名字,见「筛选」)、内容、右上角一排图标按钮。 +- 右上角的图标按钮(原型组件 `Reader / BlockTools`,普通块、属性表块、库规则的卡片共用):只有图标、不带字,名字写在悬停提示(`title`)和 `aria-label` 里;整排是 `role="group"`(`aria-label="块操作"`),以后的按钮(问 AI)加在这一排里。眼下只有「编辑 / 预览」切换:预览时铅笔「编辑」,编辑时眼睛「预览」、带按下的底色(`--vscode-toolbar-activeBackground`)。按钮宽 24px、高 20px(和种类标签同高),图标 14px、`descriptionForeground` 色;悬停 `--vscode-toolbar-hoverBackground`,键盘聚焦描 1px `--vscode-focusBorder`;Tab 能走到,回车 / 空格切换。 - 种类标签和底色按种类区分(颜色见下表);注释块虚线框。 - 内容两种状态,每块单独切: - 预览:函数、类、枚举、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。一行译文的块带说明(Python 字段的属性 docstring)时,说明按 Markdown 排在译文下面。 - - 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮文字「预览」;Esc 也回到预览。 + - 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮换成眼睛「预览」;Esc 也回到预览。 - 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数、类的说明(JSDoc / docstring)也算在函数、类块的头部里;Python 常量、变量、字段的属性 docstring 算在它那一块里(中间隔着的空行也是)。 - 库规则认领的块(数据表卡片等,见「库规则」):种类标签的字和颜色由规则给,内容是规则给的属性表;它的编辑范围是原块整条语句(类就是整个类),「编辑」时整段一起改。规则没并进卡片的子块(方法、别的成员)不管写在类主体哪儿(两个字段中间也算),都照源码顺序是卡片的子块,各自的「编辑」照旧只改自己那几行。 - 同一行不会同时在两处能改(能同时编辑的几段互不重叠):一块在编辑时,编辑范围和它重叠的子孙块不画(卡片编辑时它下面的子块全收起来,一行写完的 `if x: return 1` 编辑时同一行的子块也不画),回到预览就照原样出来;点一块的「编辑」时,编辑范围和它重叠的别的块退出编辑。跳到定义的目标块正被盖住时闪盖住它的那块。 @@ -92,15 +105,16 @@ 1. 语言插件给每块报和语言无关的属性「属性 = 值」,都是稳定的英文 id,不放显示文字。通用词表由契约定(`COMMON_ATTRIBUTES`):`kind`(块的种类)、`access`(`public` / `private` / `protected`,语言可以加自己的值)、`async`、`abstract`、`typeOnly`(`'true'` / `'false'`)。通用属性由宿主从块的 kind 和 facts 推出来(`FunctionFacts.access` / `async`、`ClassFacts.access` / `abstract`、`EnumFacts.access`、`ImportFacts.typeOnly`、`VariableFacts.access`),只有这一条路,语言插件不用另报;facts 里没填的(语言没有这个概念,例如 JavaScript 没有抽象类、仅类型导入)块就没有这个属性,不是填「否」。语言自己的属性(Rust 的 `unsafe = 'true'`)、facts 表达不了的通用属性值(Kotlin 的 `access = 'internal'`)填在块的 `attributes` 里,和推出来的合在一起,同一属性以它为准;`kind` 总是块的种类。 2. 翻译插件给属性和值起名(`Translator.attributeNames`:`access` → 访问级别、`public` → 公共、`kind` 的 `function` → 函数、布尔值 → 是 / 否),和属性表里的属性名、值是同一套叫法(中文直译里属性表的「访问级别」「异步」「抽象」「仅类型」行和筛选共用一份表);种类标签上的字也取它。没起名的显示 id。 3. webview 只按属性 id / 值 id 筛,显示翻译插件给的名字。 -- 下拉框:这份文件里有两种以上值的属性各一个(只有一种值的不给——筛它等于按种类筛);只有一种值、但这个值正被去掉的也给,不然藏起来的块在栏上找不到原因。排列:通用属性按契约的顺序(种类、访问级别、异步、抽象、仅类型),语言自己的属性按在文件里第一次出现的先后排在后面。下拉菜单是勾选列表,列出这份文件里出现的值(按第一次出现的先后)和各有几块(所有层的块都算,例如「私有 3 / 公共 3」)。 +- 下拉框:这份文件里有两种以上值的属性各一个(只有一种值的不给——筛它等于按种类筛);只有一种值、但这个值正被去掉的也给,不然藏起来的块在栏上找不到原因。排列:通用属性按契约的顺序(种类、访问级别、异步、抽象、仅类型),语言自己的属性按在文件里第一次出现的先后排在后面。下拉菜单顶上一行「全选 · 取消全选」,分隔线下面是勾选列表,列出这份文件里出现的值(按第一次出现的先后)和各有几块(所有层的块都算,例如「私有 3 / 公共 3」)。 +- 全选 / 取消全选:把这份文件里这个属性出现的值一起勾上 / 去掉,和逐个勾一样记下来;别的文件才有的值记着的去掉状态不动(筛选整个工作区共用)。取消全选后有这个属性的块全藏起来,没有这个属性的照常显示;菜单不关,可以接着勾。 - 规则:同一属性里勾着的值满足一个就显示,不同属性要同时满足;块没有某个属性就不受那个属性的筛选影响(条件、返回块没有访问级别,按访问级别筛时照常显示);块被筛掉,连同它的子块一起藏,不留空位,层级竖线跟着变短;子块都被筛掉的块不给折叠箭头。 - 汇总:什么都没筛掉时右边写「共 N 块」(N 是整份文件所有层的块数);筛掉了写「已隐藏 N 块 · 清除」,N 只数直接被筛掉的块,跟着藏起来的子块不算;点「清除」整份筛选清空。页面上一块都不剩时写「所有块都被筛选隐藏了。」 - 下拉框上的字:没去掉值时是属性名;去掉了(边框换 `--vscode-focusBorder`)勾着一种写「访问级别:公共」,几种写「种类:已选 11 种」,一种都不勾写「种类:未选」。 - 记住:记的是每个属性**去掉的值**(`{ 属性 id: 值 id[] }`),所以文件里第一次出现的值默认显示。整个工作区共用一份(不按文件),存在扩展端的 `workspaceState`(`superpowers.codeReader.filter`),重新加载窗口后还在;一个预览改了,所有开着的预览跟着变。某个属性在这份文件里没出现就没有它的下拉框,记着的去掉的值照样对它出现的文件起作用。 - 跳到定义到被筛掉的块:临时显示目标和通向它的路径(被筛掉的祖先块下面只露出这条路径,目标自己的子块照常按筛选显示,函数照样能展开看主体),本来被筛掉的块右边带小提示「已被筛选隐藏,临时显示」,照常展开、滚过去、闪。筛选一变,或文档刷新(正在编辑的块和它的祖先除外,不能打着字就消失)就回到筛选状态。 - 折叠、跳到定义、属性行 id、滚动位置恢复照旧;被筛掉的块不在页面上,按行滚动(从文本编辑器切过来)落到它前面最近的一块。视口顶部按筛选栏下沿算:记位置、恢复、滚过去都让开筛选栏。 -- 样子:筛选栏钉在预览顶部(`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-focusBorder` 底)、值、右边块数(`--vscode-editorLineNumber-foreground`);悬停、键盘高亮 `--vscode-list-hoverBackground`。 -- 键盘和读屏:下拉框按钮 `aria-haspopup="listbox"`、`aria-expanded`;菜单 `role="listbox"`、`aria-multiselectable`,每项 `role="option"`、`aria-selected` 表示勾着。按钮上 ↓ / 回车 / 空格打开(↑ 打开并停在最后一项),列表里 ↑ ↓ Home End 移动,空格 / 回车勾选,Esc 关掉并回到按钮,Tab、点外面关掉。 +- 样子:筛选栏钉在预览顶部(`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 出去、点外面)就关。鼠标点选项、全选 / 取消全选时焦点留在列表上,方向键接着能用。 ### 属性表(函数、类、枚举、导入、常量、变量块) @@ -944,6 +958,8 @@ vscode/src/codeReader/ translators/typescriptZh.ts TS 表 translators/pythonZh.ts Python 表(含注解位置的判断) explorer/fileTree.ts 侧栏文件树(FileTreeDecorator:给行添信息的接口) + explorer/copyText.ts 复制菜单的命令和复制出什么(纯函数) + explorer/copyCommands.ts 复制命令:写剪贴板、状态栏提示 notes/noteMap.ts 注释的键、保存 / 改名 / 删除时怎么改(纯函数) notes/store.ts .spx/code-notes.json 读写 notes/noteItem.ts 有注释的行怎么显示:描述、悬停提示、contextValue(纯函数) @@ -957,7 +973,7 @@ vscode/src/codeReader/ editor/definitionAction.ts 查到的定义 → 怎么跳(纯函数) editor/occurrences.ts 查名字的各处出现(vscode.executeDocumentHighlights) index.ts registerCodeReader(context): CodeReaderApi -vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts) +vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts) ``` ## 测试 @@ -984,8 +1000,11 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 工作区索引(内存里的工作区,`MemoryIndexHost`):第一次查才建、同一个 id 只建一份;查到的是名字的行列(列按 UTF-16)、照文件和行排好;预筛不过的不解析;改了只重看那一份、新建加进来、删目录连里面的去掉;新建 / 删掉问过在不在的文件(`__init__.py`)时问过它的文件重看;建索引期间的改动建好后补上;读不了、规则出错的记下来、别的照常;dispose 后不再盯着。还要跳过的文件(`workspaceScope.test.ts`,内存里的文件夹):子目录里 `.git` 是文件的 git worktree、`.git` 是目录的子仓库整个跳过,工作区自己的 `.git` 不算;`.gitignore` 忽略的目录跳过,每层规则相对那一层、取反照 gitignore 写法,`.ignore` 也读;`search.useIgnoreFiles` 关着时不读忽略文件、嵌套的 checkout 照样跳过;同一个目录只查一次。 - Celery 规则:`fixtures/celeryApp.py` 的应用卡片和定时任务卡片逐字同上面的样例(列名、「12 条,另有展开 1 处」、行尾注释、条目里单独成行的注释、跨行的 `10⏎ * 60`、`每 get_settings().… 秒`、展开一行);任务名是引用、下标指回源码原文;应用卡片的位置参数按参数顺序认、别的参数各一行、展开、属性 docstring、别名;没导入 celery、不是 `Celery(...)` 的不认;频率换算(秒数算式、`timedelta`、`crontab` 各种写法、`schedule`、换算不了的「每 X 秒」和照写法);说明取哪些注释;条目名和任务名不同、没写任务名、任务名不是字符串、值不是字典、选项一列有才有;`conf.update(beat_schedule=…)`、不带 `beat_schedule` 的 `conf.update` 和 `autodiscover_tasks` 照常。任务名索引:显式 `name=`、默认「模块路径.函数名」(包、`src` 下的命名空间包、没有包)、`@shared_task` / `@shared_task()` / `@app.task` 不带括号 / 老写法 `@task`、`async def`、函数里的只认显式名字、`name=` 不是字符串的不认、同名几处都给、文件改了和新建 `__init__.py` 后跟着变、没有任务装饰器的文件不解析。 - 枚举(`rules/enum.test.ts`、`languages/typescript.test.ts`):`fixtures/enumModels.py` 的 `CommissionStatus` 逐字同上面的样例,`SecretScope` 的 docstring 当说明、没有说明列;种类 enum、叫「枚举」、访问级别同原来的类、没有标签(badge),编辑范围是整个类;Python 成员规则(dunder、sunder、私有名、`_ignore_`、lambda、`property`、`nonmember`、只写注解的不是成员,`auto()` 写「自动」,`member(x)` 取 x,说明取上面的注释块、行尾注释、属性 docstring,装饰器);方法、嵌套类、不是成员的属性照源码顺序是子块,成员前面的注释并进来、最后面的留下,「编辑」写回整个类;类名、成员名的定义范围和带下标的词指回源码原文;没导入 enum、SQLAlchemy 的 `Enum`、不是枚举类型的父类不认,`import enum as e` 的 `e.IntEnum` 认。TypeScript:`enums.ts` 里 export / const / declare / 函数里的 enum 都是枚举块、JSDoc 进头部,写的值、自增算出的值(接不上数字就没有)、说明(上面的注释和 JSDoc、行尾注释,同一行的 JSDoc 讲下一个),访问级别;自增的值和「自动」按 hint。TypeScript 和 Python 同样的成员得到一样的种类、属性表行、列名和格子着色(除了 Python 多的「继承」)。宿主:规则认成现成属性表块时照语言插件报的这种块显示、并进来的不是子块时照常显示;webview 枚举块默认折叠。 -- 筛选:块的属性(TS、TSX 有抽象、仅类型,JavaScript / JSX 没有这两个属性、属性表也没有这两行,Python 有访问级别、异步);语言插件自己报的属性叠上去、盖过推出来的,kind 不变;文件里出现过的属性按契约顺序排、语言的排后面,值按第一次出现排、带所有层的块数,名字取翻译插件的、没有的显示 id,中文直译的名字和属性表一致;下拉框只给两种以上值的属性(或一种值正被去掉的),按钮上的字;同一属性内「或」、不同属性「且」、没有这个属性不受影响、藏整棵子树、「已隐藏」只数直接筛掉的;筛选记在 `workspaceState`、新实例读得回来、所有订阅的预览收到、形状不对的当没有;跳到被筛掉的块临时露出路径、目标的子块照常筛、文档刷新只留正在编辑的。 +- 筛选:块的属性(TS、TSX 有抽象、仅类型,JavaScript / JSX 没有这两个属性、属性表也没有这两行,Python 有访问级别、异步);语言插件自己报的属性叠上去、盖过推出来的,kind 不变;文件里出现过的属性按契约顺序排、语言的排后面,值按第一次出现排、带所有层的块数,名字取翻译插件的、没有的显示 id,中文直译的名字和属性表一致;下拉框只给两种以上值的属性(或一种值正被去掉的),按钮上的字;全选 / 取消全选只动这份文件里的值、别的文件的去掉状态和别的属性不动、取消全选藏掉有这个属性的块(整棵子树)、再全选全回来;同一属性内「或」、不同属性「且」、没有这个属性不受影响、藏整棵子树、「已隐藏」只数直接筛掉的;筛选记在 `workspaceState`、新实例读得回来、所有订阅的预览收到、形状不对的当没有;跳到被筛掉的块临时露出路径、目标的子块照常筛、文档刷新只留正在编辑的。 - 文件注释:键相对工作区文件夹、`/` 分隔,根和外面的路径没有键,手写的 `./`、结尾 `/` 规范掉;保存去空白、清空即删;文件改名、目录挪位置里面各层一起走、名字只是前缀相同的不动、跨工作区文件夹先取出再放进去;删目录连带里面的;读写:文件缺失 / 解析失败 / 不是对象返回空表、跳过不合法条目、建 `.spx`、键排序、删光留 `{}`;有注释 / 没注释的行的描述、悬停提示(Markdown 转义)、contextValue,和 `package.json` 里菜单的 when 子句对得上。 +- 侧栏行上的菜单(`explorer/rowMenus.test.ts`,读 `package.json`):行尾按钮先「复制」子菜单再注释,有没有注释换「添加 / 编辑注释」,工作区根都没有;「复制」子菜单带 `$(copy)`,注释图标 `$(note)` / `$(edit)`;子菜单和右键菜单里文件四项、目录三项(有没有注释一样)、菜单上的字;复制命令都登记了、和代码里的命令表一致、命令面板里藏起来。 +- 复制出什么(`explorer/copyText.test.ts`):文件、目录的名字 / 相对路径 / 绝对路径;不带后缀只去最后一个扩展名(`a.test.ts`、`archive.tar.gz`),`.env` 原样、`.env.local` → `.env`、没有扩展名的原样;相对路径总用 `/`,绝对路径照本机写法(Windows 反斜杠)。 +- 块右上角的图标按钮(webview `BlockTools.test.tsx`,渲染成 HTML):一组一个按钮、没有字、`title` 和 `aria-label` 是「编辑」/「预览」、有图标、编辑时带按下标记。 ## 收尾 diff --git a/vscode/package.json b/vscode/package.json index cf3028c..86145e0 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -124,11 +124,36 @@ "title": "编辑", "category": "代码阅读" }, + { + "command": "superpowers.codeReader.copyFileName", + "title": "复制文件名", + "category": "代码阅读" + }, + { + "command": "superpowers.codeReader.copyFolderName", + "title": "复制文件夹名", + "category": "代码阅读" + }, + { + "command": "superpowers.codeReader.copyFileStem", + "title": "复制文件名(不带后缀)", + "category": "代码阅读" + }, + { + "command": "superpowers.codeReader.copyRelativePath", + "title": "复制相对路径", + "category": "代码阅读" + }, + { + "command": "superpowers.codeReader.copyAbsolutePath", + "title": "复制绝对路径", + "category": "代码阅读" + }, { "command": "superpowers.codeReader.addNote", "title": "添加注释", "category": "代码阅读", - "icon": "$(comment)" + "icon": "$(note)" }, { "command": "superpowers.codeReader.editNote", @@ -137,7 +162,41 @@ "icon": "$(edit)" } ], + "submenus": [ + { + "id": "superpowers.codeReader.copy", + "label": "复制", + "icon": "$(copy)" + } + ], "menus": { + "superpowers.codeReader.copy": [ + { + "command": "superpowers.codeReader.copyFileName", + "when": "view == superpowers.codeReader.files && viewItem =~ /^file( noted)?$/", + "group": "copy@1" + }, + { + "command": "superpowers.codeReader.copyFolderName", + "when": "view == superpowers.codeReader.files && viewItem =~ /^folder( noted)?$/", + "group": "copy@1" + }, + { + "command": "superpowers.codeReader.copyFileStem", + "when": "view == superpowers.codeReader.files && viewItem =~ /^file( noted)?$/", + "group": "copy@2" + }, + { + "command": "superpowers.codeReader.copyRelativePath", + "when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)( noted)?$/", + "group": "copy@3" + }, + { + "command": "superpowers.codeReader.copyAbsolutePath", + "when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)( noted)?$/", + "group": "copy@4" + } + ], "view/title": [ { "command": "superpowers.openKanban", @@ -161,15 +220,45 @@ } ], "view/item/context": [ + { + "submenu": "superpowers.codeReader.copy", + "when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)( noted)?$/", + "group": "inline@1" + }, { "command": "superpowers.codeReader.addNote", "when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)$/", - "group": "inline" + "group": "inline@2" }, { "command": "superpowers.codeReader.editNote", "when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder) noted$/", - "group": "inline" + "group": "inline@2" + }, + { + "command": "superpowers.codeReader.copyFileName", + "when": "view == superpowers.codeReader.files && viewItem =~ /^file( noted)?$/", + "group": "copy@1" + }, + { + "command": "superpowers.codeReader.copyFolderName", + "when": "view == superpowers.codeReader.files && viewItem =~ /^folder( noted)?$/", + "group": "copy@1" + }, + { + "command": "superpowers.codeReader.copyFileStem", + "when": "view == superpowers.codeReader.files && viewItem =~ /^file( noted)?$/", + "group": "copy@2" + }, + { + "command": "superpowers.codeReader.copyRelativePath", + "when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)( noted)?$/", + "group": "copy@3" + }, + { + "command": "superpowers.codeReader.copyAbsolutePath", + "when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)( noted)?$/", + "group": "copy@4" }, { "command": "superpowers.codeReader.addNote", @@ -207,6 +296,26 @@ "command": "superpowers.codeReader.showSource", "when": "activeCustomEditorId == superpowers.codeReader.preview" }, + { + "command": "superpowers.codeReader.copyFileName", + "when": "false" + }, + { + "command": "superpowers.codeReader.copyFolderName", + "when": "false" + }, + { + "command": "superpowers.codeReader.copyFileStem", + "when": "false" + }, + { + "command": "superpowers.codeReader.copyRelativePath", + "when": "false" + }, + { + "command": "superpowers.codeReader.copyAbsolutePath", + "when": "false" + }, { "command": "superpowers.codeReader.addNote", "when": "false" diff --git a/vscode/src/codeReader/explorer/copyCommands.ts b/vscode/src/codeReader/explorer/copyCommands.ts new file mode 100644 index 0000000..c751659 --- /dev/null +++ b/vscode/src/codeReader/explorer/copyCommands.ts @@ -0,0 +1,27 @@ +import type { CopyKind } from './copyText' +import type { FileNode } from './fileTree' +import { commands, Disposable, env, window } from 'vscode' +import { COPY_COMMANDS, copyText } from './copyText' + +/** 状态栏里「已复制:…」停多久 */ +const STATUS_MS = 3000 + +/** + * 侧栏行上「复制」的几个命令:行尾「复制」按钮点开的菜单和右键菜单里都是它们(见 package.json 的 submenus)。 + * 复制完在状态栏说一声复制了什么,连着复制时只留最新的一条。 + */ +export function registerCopyCommands(): Disposable { + let status: Disposable | undefined + const copy = async (kind: CopyKind, node: FileNode | undefined): Promise => { + if (!node) + return // 只挂在侧栏的行上,别处调用没有目标 + const text = copyText(kind, { path: node.uri.path, folderPath: node.folder.uri.path, fsPath: node.uri.fsPath }) + await env.clipboard.writeText(text) + status?.dispose() + status = window.setStatusBarMessage(`已复制:${text}`, STATUS_MS) + } + return Disposable.from( + ...Object.entries(COPY_COMMANDS).map(([id, kind]) => commands.registerCommand(id, (node?: FileNode) => copy(kind, node))), + { dispose: () => status?.dispose() }, + ) +} diff --git a/vscode/src/codeReader/explorer/copyText.test.ts b/vscode/src/codeReader/explorer/copyText.test.ts new file mode 100644 index 0000000..8cb54e1 --- /dev/null +++ b/vscode/src/codeReader/explorer/copyText.test.ts @@ -0,0 +1,42 @@ +import type { CopyKind, CopyTarget } from './copyText' +import { describe, expect, it } from 'vitest' +import { copyText } from './copyText' + +/** 工作区文件夹 /work/app 下的一项 */ +function at(relative: string): CopyTarget { + return { path: `/work/app/${relative}`, folderPath: '/work/app', fsPath: `/work/app/${relative}` } +} + +const ALL: CopyKind[] = ['name', 'stem', 'relativePath', 'absolutePath'] +const copies = (target: CopyTarget): string[] => ALL.map(kind => copyText(kind, target)) + +describe('复制出什么', () => { + it('文件:带后缀的名字、不带后缀的名字、相对工作区文件夹的路径、绝对路径', () => { + expect(copies(at('backend/campaign/order_bonus.py'))) + .toEqual(['order_bonus.py', 'order_bonus', 'backend/campaign/order_bonus.py', '/work/app/backend/campaign/order_bonus.py']) + }) + + it('文件夹:名字和路径', () => { + const folder = at('backend/campaign') + expect(copyText('name', folder)).toBe('campaign') + expect(copyText('relativePath', folder)).toBe('backend/campaign') + expect(copyText('absolutePath', folder)).toBe('/work/app/backend/campaign') + }) + + it('不带后缀只去掉最后一个扩展名', () => { + expect(copyText('stem', at('src/a.test.ts'))).toBe('a.test') + expect(copyText('stem', at('archive.tar.gz'))).toBe('archive.tar') + }) + + it('点开头的名字:只有一个点的整个是名字,后面还有点的去掉最后一段', () => { + expect(copyText('stem', at('.env'))).toBe('.env') + expect(copyText('stem', at('.env.local'))).toBe('.env') + expect(copyText('stem', at('Makefile'))).toBe('Makefile') + }) + + it('相对路径总用 / 分隔,绝对路径照本机写法', () => { + const windows: CopyTarget = { path: '/c:/work/app/src/a.ts', folderPath: '/c:/work/app', fsPath: 'c:\\work\\app\\src\\a.ts' } + expect(copyText('relativePath', windows)).toBe('src/a.ts') + expect(copyText('absolutePath', windows)).toBe('c:\\work\\app\\src\\a.ts') + }) +}) diff --git a/vscode/src/codeReader/explorer/copyText.ts b/vscode/src/codeReader/explorer/copyText.ts new file mode 100644 index 0000000..94367d9 --- /dev/null +++ b/vscode/src/codeReader/explorer/copyText.ts @@ -0,0 +1,41 @@ +import * as path from 'node:path' +import { stem } from './fileEntries' + +/** 复制菜单里的一种:名字、不带后缀的文件名、相对路径、绝对路径。 */ +export type CopyKind = 'name' | 'stem' | 'relativePath' | 'absolutePath' + +/** + * 复制菜单的命令 → 复制什么。文件名和文件夹名复制的是同一样东西,分成两个命令只为菜单上写对字 + * (package.json 按行的 contextValue 挑);「不带后缀」只给文件。 + */ +export const COPY_COMMANDS = { + 'superpowers.codeReader.copyFileName': 'name', + 'superpowers.codeReader.copyFolderName': 'name', + 'superpowers.codeReader.copyFileStem': 'stem', + 'superpowers.codeReader.copyRelativePath': 'relativePath', + 'superpowers.codeReader.copyAbsolutePath': 'absolutePath', +} as const satisfies Record + +export interface CopyTarget { + /** 这一行的 Uri.path(`/` 分隔) */ + path: string + /** 所在工作区文件夹的 Uri.path */ + folderPath: string + /** 这一行的 Uri.fsPath:本机写法的绝对路径(Windows 上是反斜杠) */ + fsPath: string +} + +/** 相对路径相对所在的工作区文件夹、用 `/` 分隔(和文件注释的键一样);绝对路径照本机写法。 */ +export function copyText(kind: CopyKind, target: CopyTarget): string { + const name = path.posix.basename(target.path) + switch (kind) { + case 'name': + return name + case 'stem': + return stem(name) + case 'relativePath': + return path.posix.relative(target.folderPath, target.path) + case 'absolutePath': + return target.fsPath + } +} diff --git a/vscode/src/codeReader/explorer/fileEntries.ts b/vscode/src/codeReader/explorer/fileEntries.ts index b08573d..b601eb7 100644 --- a/vscode/src/codeReader/explorer/fileEntries.ts +++ b/vscode/src/codeReader/explorer/fileEntries.ts @@ -26,8 +26,11 @@ export type ExcludeSetting = Record) => boolean -/** 去掉最后一个扩展名:`when` 里的 `$(basename)` 指的就是它。 */ -function stem(name: string): string { +/** + * 去掉最后一个扩展名(`a.test.ts` → `a.test`);点开头、后面没有别的点的名字(`.env`)没有扩展名,原样。 + * `files.exclude` 的 `when` 里的 `$(basename)` 和复制「文件名(不带后缀)」都是它。 + */ +export function stem(name: string): string { const dot = name.lastIndexOf('.') return dot > 0 ? name.slice(0, dot) : name } diff --git a/vscode/src/codeReader/explorer/rowMenus.test.ts b/vscode/src/codeReader/explorer/rowMenus.test.ts new file mode 100644 index 0000000..ef04ba4 --- /dev/null +++ b/vscode/src/codeReader/explorer/rowMenus.test.ts @@ -0,0 +1,104 @@ +import { readFileSync } from 'node:fs' +import * as path from 'node:path' +import { describe, expect, it } from 'vitest' +import { noteDecoration } from '../notes/noteItem' +import { COPY_COMMANDS } from './copyText' +import { entryContext } from './fileEntries' + +interface MenuItem { command?: string, submenu?: string, when: string, group?: string } +interface Manifest { + contributes: { + commands: { command: string, title: string, icon?: string }[] + submenus: { id: string, label: string, icon?: string }[] + menus: Record + } +} + +const { commands, submenus, menus } = (JSON.parse(readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8')) as Manifest).contributes + +const COPY_MENU = 'superpowers.codeReader.copy' +const ADD_NOTE = 'superpowers.codeReader.addNote' +const EDIT_NOTE = 'superpowers.codeReader.editNote' + +const file = entryContext({ isDirectory: false, isRoot: false }) +const folder = entryContext({ isDirectory: true, isRoot: false }) +const root = entryContext({ isDirectory: true, isRoot: true }) +const noted = (context: string): string => noteDecoration(context, 'x', '注释')!.contextValue + +const orderOf = (item: MenuItem): number => Number(item.group?.split('@')[1] ?? 0) + +/** 某个菜单的某一组(行尾按钮 inline / 右键菜单的 copy、notes)对这一行显示哪些命令或子菜单,照 `@序号` 排 */ +function itemsFor(menu: string, group: string, contextValue: string): string[] { + return menus[menu] + .filter(item => item.group?.split('@')[0] === group && item.when.includes('view == superpowers.codeReader.files')) + .filter((item) => { + const pattern = /viewItem =~ \/(.+)\/$/.exec(item.when)?.[1] + return pattern !== undefined && new RegExp(pattern).test(contextValue) + }) + .sort((a, b) => orderOf(a) - orderOf(b)) + .map(item => item.command ?? item.submenu ?? '') +} + +const FILE_COPIES = [ + 'superpowers.codeReader.copyFileName', + 'superpowers.codeReader.copyFileStem', + 'superpowers.codeReader.copyRelativePath', + 'superpowers.codeReader.copyAbsolutePath', +] +const FOLDER_COPIES = [ + 'superpowers.codeReader.copyFolderName', + 'superpowers.codeReader.copyRelativePath', + 'superpowers.codeReader.copyAbsolutePath', +] + +describe('侧栏行尾按钮', () => { + it('先「复制」再注释:没注释的是「添加注释」,有注释的是「编辑注释」;工作区根都没有', () => { + for (const context of [file, folder]) { + expect(itemsFor('view/item/context', 'inline', context)).toEqual([COPY_MENU, ADD_NOTE]) + expect(itemsFor('view/item/context', 'inline', noted(context))).toEqual([COPY_MENU, EDIT_NOTE]) + } + expect(itemsFor('view/item/context', 'inline', root)).toEqual([]) + }) + + it('「复制」是带 copy 图标的子菜单(VS Code 画成点开的下拉按钮),注释按钮的图标是 note / edit', () => { + expect(submenus.find(item => item.id === COPY_MENU)).toEqual({ id: COPY_MENU, label: '复制', icon: '$(copy)' }) + expect(commands.find(item => item.command === ADD_NOTE)?.icon).toBe('$(note)') + expect(commands.find(item => item.command === EDIT_NOTE)?.icon).toBe('$(edit)') + }) +}) + +describe('复制菜单', () => { + it.each([COPY_MENU, 'view/item/context'])('%s:文件四项、文件夹没有「不带后缀」,有没有注释都一样', (menu) => { + for (const context of [file, noted(file)]) + expect(itemsFor(menu, 'copy', context)).toEqual(FILE_COPIES) + for (const context of [folder, noted(folder)]) + expect(itemsFor(menu, 'copy', context)).toEqual(FOLDER_COPIES) + expect(itemsFor(menu, 'copy', root)).toEqual([]) + }) + + it('菜单上的字', () => { + const title = (id: string): string | undefined => commands.find(item => item.command === id)?.title + expect(FILE_COPIES.map(title)).toEqual(['复制文件名', '复制文件名(不带后缀)', '复制相对路径', '复制绝对路径']) + expect(title('superpowers.codeReader.copyFolderName')).toBe('复制文件夹名') + }) + + it('命令都登记了、都有注册,命令面板里藏起来', () => { + const palette = new Map(menus.commandPalette.map(item => [item.command, item.when])) + for (const id of [...new Set([...FILE_COPIES, ...FOLDER_COPIES])]) { + expect(id in COPY_COMMANDS).toBe(true) + expect(commands.some(item => item.command === id)).toBe(true) + expect(palette.get(id)).toBe('false') + } + expect(Object.keys(COPY_COMMANDS).sort()).toEqual([...new Set([...FILE_COPIES, ...FOLDER_COPIES])].sort()) + }) +}) + +describe('右键菜单的注释', () => { + it('没注释的目录 / 文件只有「添加注释」,有注释的只有「编辑注释」,工作区根没有', () => { + for (const context of [file, folder]) { + expect(itemsFor('view/item/context', 'notes', context)).toEqual([ADD_NOTE]) + expect(itemsFor('view/item/context', 'notes', noted(context))).toEqual([EDIT_NOTE]) + } + expect(itemsFor('view/item/context', 'notes', root)).toEqual([]) + }) +}) diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index aea259f..1354b74 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -5,6 +5,7 @@ import { logger } from '../logging/logger' import { BlockFilter } from './editor/blockFilter' import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor' import { showPreview, showSource } from './editor/switchEditor' +import { registerCopyCommands } from './explorer/copyCommands' import { FileTreeProvider } from './explorer/fileTree' import { createPythonAdapter } from './languages/python' import { createTypeScriptAdapter } from './languages/typescript' @@ -47,7 +48,7 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise { } /** - * 接入代码阅读:注册表、侧栏文件树和文件注释、预览编辑器、编辑器右上角的「预览 / 编辑」切换。 + * 接入代码阅读:注册表、侧栏文件树(复制、文件注释)、预览编辑器、编辑器右上角的「预览 / 编辑」切换。 * 内置的语言插件、翻译插件和库规则与其他扩展一样通过返回的 API 注册。 */ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { @@ -69,6 +70,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { notes, indexes, registerNoteCommands(notes), + registerCopyCommands(), registry.onDidChange(() => { void commands.executeCommand('setContext', LANGUAGE_IDS_CONTEXT, registry.languageIds()) previews.refresh() diff --git a/vscode/src/codeReader/notes/noteItem.test.ts b/vscode/src/codeReader/notes/noteItem.test.ts index 047eb31..132c25f 100644 --- a/vscode/src/codeReader/notes/noteItem.test.ts +++ b/vscode/src/codeReader/notes/noteItem.test.ts @@ -1,7 +1,4 @@ -import { readFileSync } from 'node:fs' -import * as path from 'node:path' import { describe, expect, it } from 'vitest' -import { entryContext } from '../explorer/fileEntries' import { escapeMarkdown, noteDecoration } from './noteItem' describe('树上的一行', () => { @@ -23,31 +20,3 @@ describe('树上的一行', () => { .toBe('\\*\\*别加粗\\*\\* \\x\\<\\/b\\> \\[链接\\]\\(command\\:x\\) \\`代码\\` \\# 标题 \\$\\(icon\\)') }) }) - -describe('菜单按 contextValue 挂按钮', () => { - interface MenuItem { command: string, when: string, group: string } - const menus = (JSON.parse(readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8')) as { - contributes: { menus: Record } - }).contributes.menus['view/item/context'] - - /** 这一行在某个组(行尾按钮 inline / 右键菜单)里有哪些注释命令 */ - function commandsFor(contextValue: string, group: string): string[] { - return menus - .filter(item => item.group === group && item.when.includes('view == superpowers.codeReader.files')) - .filter((item) => { - const pattern = /viewItem =~ \/(.+)\/$/.exec(item.when)?.[1] - return pattern !== undefined && new RegExp(pattern).test(contextValue) - }) - .map(item => item.command) - } - - const noted = (context: string): string => noteDecoration(context, 'x', '注释')!.contextValue - - it.each(['inline', 'notes'])('%s:没注释的目录 / 文件只有「添加注释」,有注释的只有「编辑注释」,工作区根都没有', (group) => { - for (const context of [entryContext({ isDirectory: false, isRoot: false }), entryContext({ isDirectory: true, isRoot: false })]) { - expect(commandsFor(context, group)).toEqual(['superpowers.codeReader.addNote']) - expect(commandsFor(noted(context), group)).toEqual(['superpowers.codeReader.editNote']) - } - expect(commandsFor(entryContext({ isDirectory: true, isRoot: true }), group)).toEqual([]) - }) -}) diff --git a/vscode/webview-ui/src/codeReader/BlockTools.test.tsx b/vscode/webview-ui/src/codeReader/BlockTools.test.tsx new file mode 100644 index 0000000..99509c6 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/BlockTools.test.tsx @@ -0,0 +1,20 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { BlockTools } from './BlockTools' + +const render = (editing: boolean): string => renderToStaticMarkup( {}} />) + +/** 标签之间的字:图标(svg)没有字,按钮上有字就会出现在这里 */ +const visibleText = (html: string): string => html.replace(/<[^>]*>/g, '').trim() + +describe('块右上角的图标按钮', () => { + it.each([[false, '编辑'], [true, '预览']] as const)('编辑中 = %s:只有图标不带字,名字在悬停提示和 aria-label 里', (editing, label) => { + const html = render(editing) + expect(html).toMatch(/^]* role="group" aria-label="块操作"/) + expect(visibleText(html)).toBe('') + expect(html.match(/ + + ) +} diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index ccd9b3a..e912a97 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -1,8 +1,8 @@ import type { CSSProperties, ReactElement } from 'react' import type { BlockView } from './types' -import { ChevronDown, ChevronRight, Eye, EyeOff, Pencil } from 'lucide-react' +import { ChevronDown, ChevronRight, EyeOff } from 'lucide-react' import { Fragment } from 'react' -import { cn } from '@/lib/utils' +import { BlockTools } from './BlockTools' import { CodeEditor } from './CodeEditor' import { PropertyGrid } from './PropertyGrid' import { TextPreview } from './TextPreview' @@ -86,7 +86,7 @@ function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState ) } -/** 一块:折叠箭头、种类标签、预览内容或源码、右边的「编辑 / 预览」切换。 */ +/** 一块:折叠箭头、种类标签、预览内容或源码、右上角的一排图标按钮(BlockTools)。 */ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement { const editing = tree.editing.has(block.id) const collapsed = tree.collapsed.has(block.id) @@ -135,17 +135,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): 已被筛选隐藏,临时显示 )} - + tree.onToggleEdit(block.id)} /> ) } diff --git a/vscode/webview-ui/src/codeReader/FilterBar.tsx b/vscode/webview-ui/src/codeReader/FilterBar.tsx index 42e3240..94d142d 100644 --- a/vscode/webview-ui/src/codeReader/FilterBar.tsx +++ b/vscode/webview-ui/src/codeReader/FilterBar.tsx @@ -1,11 +1,11 @@ -import type { KeyboardEvent, ReactElement } from 'react' +import type { FocusEvent, KeyboardEvent, ReactElement } from 'react' import type { Facet, FilterView } from './filter' import type { Highlight } from './occurrences' import type { Excluded, FileAttribute, TranslatorOption } from './types' import { Check, ChevronDown, ListFilter } from 'lucide-react' -import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react' +import { useId, useLayoutEffect, useRef, useState } from 'react' import { SelectMenu } from '@/components/ui/select-menu' -import { facetLabel, facetsOf, toggleValue } from './filter' +import { facetLabel, facetsOf, setChecked, toggleValue } from './filter' export interface FilterBarProps { attributes: FileAttribute[] @@ -32,7 +32,12 @@ export function FilterBar({ attributes, excluded, view, highlight, onChange, tra 筛选 {facetsOf(attributes, excluded).map(facet => ( - onChange(toggleValue(excluded, facet.id, value))} /> + onChange(toggleValue(excluded, facet.id, value))} + onSetAll={checked => onChange(setChecked(excluded, facet.id, facet.values.map(value => value.id), checked))} + /> ))} {highlight && ( @@ -71,10 +76,16 @@ export function FilterBar({ attributes, excluded, view, highlight, onChange, tra } /** - * 一个属性的下拉框:按钮上是属性名(去掉了值时写勾着哪些,边框换聚焦色),点开是勾选列表,每个值后面是块数。 - * 列表盖在代码块上面;点外面、Esc、Tab 关掉。键盘:按钮上 ↓ ↑ 回车 空格打开,列表里 ↑ ↓ Home End 移动,空格 / 回车勾选。 + * 一个属性的下拉框:按钮上是属性名(去掉了值时写勾着哪些,边框换聚焦色),点开的菜单盖在代码块上面: + * 顶上一行「全选 · 取消全选」,分隔线下面是勾选列表,每个值后面是块数。点外面、Tab 出去、Esc 关掉。 + * 键盘:按钮上 ↓ ↑ 回车 空格打开、焦点落在列表上,列表里 ↑ ↓ Home End 移动,空格 / 回车勾选,Shift+Tab 回到「取消全选」「全选」。 */ -function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string) => void }): ReactElement { +function FacetMenu({ facet, onToggle, onSetAll }: { + facet: Facet + onToggle: (value: string) => void + /** 全选(true)/ 取消全选(false) */ + onSetAll: (checked: boolean) => void +}): ReactElement { const [open, setOpen] = useState(false) const [activeIndex, setActive] = useState(0) const rootRef = useRef(null) @@ -86,17 +97,6 @@ function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string // 列表开着时文档刷新、值变少了,高亮落回最后一项 const active = Math.min(activeIndex, count - 1) - useEffect(() => { - if (!open) - return - const onPointerDown = (event: MouseEvent): void => { - if (event.target instanceof Node && !rootRef.current?.contains(event.target)) - setOpen(false) - } - document.addEventListener('mousedown', onPointerDown, true) - return () => document.removeEventListener('mousedown', onPointerDown, true) - }, [open]) - useLayoutEffect(() => { if (open) listRef.current?.focus() @@ -117,6 +117,12 @@ function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string buttonRef.current?.focus() } + // 焦点离开按钮和菜单(点外面、Tab 出去、切到别的窗口)就关 + const onBlur = (event: FocusEvent): void => { + if (!(event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget))) + setOpen(false) + } + const onButtonKeyDown = (event: KeyboardEvent): void => { if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') { event.preventDefault() @@ -128,6 +134,14 @@ function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string } } + const onMenuKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + close() + } + } + const onListKeyDown = (event: KeyboardEvent): void => { const move: Record = { ArrowDown: (active + 1) % count, ArrowUp: (active - 1 + count) % count, Home: 0, End: count - 1 } if (event.key in move) { @@ -138,18 +152,10 @@ function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string event.preventDefault() onToggle(facet.values[active].id) } - else if (event.key === 'Escape') { - event.preventDefault() - event.stopPropagation() - close() - } - else if (event.key === 'Tab') { - setOpen(false) - } } return ( -
+
+ ))} +
+
+
+ {facet.values.map((value, index) => ( +
event.preventDefault()} + onMouseMove={() => setActive(index)} + onClick={() => onToggle(value.id)} + > + + {value.checked && } + + {value.name} + {value.count} +
+ ))} +
)}
diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 3c41d76..28abcf4 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -91,6 +91,21 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(- background: color-mix(in srgb, var(--kind) 7%, var(--vscode-editor-background)); } +/* 块右上角的图标按钮:悬停、按下(这一块在编辑)有底色,键盘聚焦描一圈聚焦色 */ +.cr-tool { + color: var(--vscode-descriptionForeground); +} +.cr-tool:hover { + background: var(--vscode-toolbar-hoverBackground); +} +.cr-tool[data-active] { + background: var(--vscode-toolbar-activeBackground); +} +.cr-tool:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + /* 层级竖线:悬停一个块时,它子层的竖线换成它的种类色,标出这个块管到哪里为止 */ .cr-guide { background: var(--vscode-editorIndentGuide-background1); @@ -424,13 +439,18 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(- background: var(--vscode-editorWidget-background); border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border, transparent)); box-shadow: 0 2px 8px var(--vscode-widget-shadow, rgb(0 0 0 / 36%)); +} +.cr-menu-divider { + background: var(--vscode-menu-separatorBackground, var(--vscode-editorWidget-border, var(--vscode-panel-border))); +} +.cr-menu-list { outline: none; } .cr-option[data-active] { background: var(--vscode-list-hoverBackground); } /* 键盘在列表里走时,高亮项再描一圈聚焦色 */ -.cr-menu:focus-visible .cr-option[data-active] { +.cr-menu-list:focus-visible .cr-option[data-active] { outline: 1px solid var(--vscode-list-focusOutline, var(--vscode-focusBorder)); outline-offset: -1px; } diff --git a/vscode/webview-ui/src/codeReader/filter.test.ts b/vscode/webview-ui/src/codeReader/filter.test.ts index 2e24eeb..f73422b 100644 --- a/vscode/webview-ui/src/codeReader/filter.test.ts +++ b/vscode/webview-ui/src/codeReader/filter.test.ts @@ -1,6 +1,6 @@ import type { BlockKind, BlockView, FileAttribute } from './types' import { describe, expect, it } from 'vitest' -import { applyFilter, blockPath, excludedBy, facetLabel, facetsOf, keptWhileEditing, toggleValue } from './filter' +import { applyFilter, blockPath, excludedBy, facetLabel, facetsOf, keptWhileEditing, setChecked, toggleValue } from './filter' function block(id: string, kind: BlockKind, extra: Record = {}, children: BlockView[] = []): BlockView { return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind, ...extra }, children, translation: [] } @@ -111,6 +111,30 @@ describe('筛选栏上的下拉框', () => { expect(toggleValue({ access: ['private'], kind: ['if'] }, 'access', 'public')).toEqual({ access: ['private', 'public'], kind: ['if'] }) expect(toggleValue({ access: ['private'], kind: ['if'] }, 'access', 'private')).toEqual({ kind: ['if'] }) }) + + it('全选:这份文件里的值都勾上,别的文件才有的值照旧去掉,别的属性不动', () => { + expect(setChecked({ access: ['private', 'protected'], kind: ['if'] }, 'access', ['private', 'public'], true)) + .toEqual({ access: ['protected'], kind: ['if'] }) + expect(setChecked({ access: ['private'], kind: ['if'] }, 'access', ['private', 'public'], true)).toEqual({ kind: ['if'] }) + expect(setChecked({}, 'access', ['private', 'public'], true)).toEqual({}) + }) + + it('取消全选:这份文件里的值都去掉,已经去掉的不重复记', () => { + expect(setChecked({ access: ['protected'], kind: ['if'] }, 'access', ['private', 'public', 'protected'], false)) + .toEqual({ access: ['protected', 'private', 'public'], kind: ['if'] }) + const excluded = setChecked({}, 'access', ['private', 'public'], false) + const [, access] = facetsOf(ATTRIBUTES, excluded) + expect(access.values.every(value => !value.checked)).toBe(true) + expect(facetLabel(access)).toEqual({ text: '访问级别:未选', active: true }) + }) + + it('取消全选后有这个属性的块全藏起来,没有这个属性的照常显示;再全选全回来', () => { + const none = setChecked({}, 'access', ['private', 'public'], false) + const view = applyFilter(BLOCKS, none, NONE) + expect([...view.hidden].sort()).toEqual(['0', '1', '1.0', '1.0.0', '1.1', '2', '2.0']) + expect(view.hiddenCount).toBe(3) + expect(applyFilter(BLOCKS, setChecked(none, 'access', ['private', 'public'], true), NONE).hidden).toEqual(NONE) + }) }) describe('临时显示的块', () => { diff --git a/vscode/webview-ui/src/codeReader/filter.ts b/vscode/webview-ui/src/codeReader/filter.ts index 51b4860..e4e0d4b 100644 --- a/vscode/webview-ui/src/codeReader/filter.ts +++ b/vscode/webview-ui/src/codeReader/filter.ts @@ -79,7 +79,22 @@ export function facetLabel(facet: Facet): { text: string, active: boolean } { /** 勾上 / 去掉属性的一个值后的筛选;一个值都没去掉的属性不留。 */ export function toggleValue(excluded: Excluded, attribute: string, value: string): Excluded { const current = excluded[attribute] ?? [] - const next = current.includes(value) ? current.filter(item => item !== value) : [...current, value] + return withExcluded(excluded, attribute, current.includes(value) ? current.filter(item => item !== value) : [...current, value]) +} + +/** + * 下拉菜单的「全选 / 取消全选」:把 values(这份文件里这个属性出现的值)一起勾上或去掉。 + * 筛选整个工作区共用,别的文件才有的值记着的去掉状态不动;取消全选后有这个属性的块全藏起来。 + */ +export function setChecked(excluded: Excluded, attribute: string, values: readonly string[], checked: boolean): Excluded { + const current = excluded[attribute] ?? [] + return withExcluded(excluded, attribute, checked + ? current.filter(item => !values.includes(item)) + : [...current, ...values.filter(item => !current.includes(item))]) +} + +/** 换掉一个属性去掉的值;一个值都没去掉的属性不留。 */ +function withExcluded(excluded: Excluded, attribute: string, next: string[]): Excluded { const rest = Object.fromEntries(Object.entries(excluded).filter(([key]) => key !== attribute)) return next.length > 0 ? { ...rest, [attribute]: next } : rest }