From 9c0f05362cc6330044940edc1b6d59dd86cb6a7a Mon Sep 17 00:00:00 2001 From: cruldra Date: Sat, 26 Sep 2026 15:51:52 +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=E9=A2=84=E8=A7=88=E5=8D=95=E5=87=BB=E5=90=8D?= =?UTF-8?q?=E5=AD=97=E9=AB=98=E4=BA=AE=E5=AE=83=E5=9C=A8=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E9=87=8C=E7=9A=84=E5=90=84=E5=A4=84=E5=87=BA=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 19 +- vscode/src/codeReader/editor/messages.ts | 14 ++ vscode/src/codeReader/editor/occurrences.ts | 31 +++ vscode/src/codeReader/editor/previewEditor.ts | 14 ++ vscode/webview-ui/src/codeReader/App.tsx | 7 +- .../webview-ui/src/codeReader/FilterBar.tsx | 17 +- .../webview-ui/src/codeReader/SegmentText.tsx | 18 +- .../webview-ui/src/codeReader/codeReader.css | 21 ++ .../src/codeReader/occurrences.test.ts | 224 ++++++++++++++++++ .../webview-ui/src/codeReader/occurrences.ts | 133 +++++++++++ vscode/webview-ui/src/codeReader/types.ts | 14 ++ .../src/codeReader/useOccurrences.ts | 55 +++++ 12 files changed, 558 insertions(+), 9 deletions(-) create mode 100644 vscode/src/codeReader/editor/occurrences.ts create mode 100644 vscode/webview-ui/src/codeReader/occurrences.test.ts create mode 100644 vscode/webview-ui/src/codeReader/occurrences.ts create mode 100644 vscode/webview-ui/src/codeReader/useOccurrences.ts diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index a3bcb60..d72b73b 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -24,6 +24,7 @@ | 函数、类的说明 | TypeScript 紧贴在函数、类前面的 JSDoc,Python 函数、类主体开头的 docstring 是它自己的说明:进属性表「说明」一行,不再单独成一块注释;它的源码行算在函数、类块的头部里,「编辑」时和签名一起改 | | 注释按 Markdown 显示 | 注释块和函数、类的「说明」按 Markdown 渲染,像 Kotlin 的 KDoc:去掉注释符号、引号和共同缩进后的原文原样交给 Markdown,不按语言转换(reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄,注释以后慢慢改成 Markdown 写法);段内单个换行是软换行,空行分段 | | 跳到定义 | 和普通编辑器一样 ⌘ / Ctrl + 点击名字跳到定义,定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 | +| 高亮名字的各处出现 | 和普通编辑器一样单击名字就高亮它在这个文件里的各处出现,由语言扩展按语义认(同名的别的东西不算),颜色用 VS Code 的单词高亮色;看不见的(折叠、筛掉的)不自动展开,筛选栏上写「名字 · N 处」 | | 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠,外加给目录 / 文件记注释;不做新建 / 重命名 / 删除 | | 文件注释 | 读代码时给目录 / 文件随手记一句:存在各工作区文件夹的 `.spx/code-notes.json`,跟着仓库走;在 VS Code 里改名、挪位置、删除时注释跟着变 | | 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) | @@ -148,7 +149,17 @@ 3. 其余(定义在一行译文里,例如循环变量;翻译插件没填 `defines`)→ 显示着定义处那个名字的块,名字在属性行的子行里时展开那一行(在嵌套的子行里时连它上面各层一起展开);页面上找不到这个名字就取包含定义那一行的块。 - 同一文件原地跳、跳进另一个文件的预览,规则一样。 - 滚过去:目标所在块的祖先块折叠着就展开,目标在折叠的属性行子行里(参数、导入项、字典 / 对象的项)就展开那一行,在嵌套的子行里就连它上面折叠着的各层子行一起展开。目标已在视口里不滚,否则滚到视口正中(比视口高就顶部对齐)。然后目标的边框和底色往聚焦色(`--vscode-focusBorder`)闪三下,共约 1.5 秒后复原;同一处再跳一次从头闪;系统开了「减少动态效果」时改成静止高亮 1.5 秒。目标是属性行 / 子行但块正在编辑(页面上没有属性表)时闪整块。 -- 不按修饰键的点击照旧;⌘ / Ctrl + 点击名字不会触发块的编辑切换、折叠。 +- 不按修饰键单击名字是高亮它的各处出现(见下一节);⌘ / Ctrl + 点击名字不会触发块的编辑切换、折叠,也不动高亮。 + +### 高亮名字的各处出现 + +- 能点的名字和「跳到定义」一样:带源码下标的段(属性表、一行译文、字典 / 对象各层子行的键和值,点分模块名每一节各算各的)。「编辑」状态的块里的源码、Markdown 里的名字不算,既不能点也不被高亮。 +- 单击名字:问扩展端(`vscode.executeDocumentHighlights`,文档 uri + 名字在源码里的位置),由语言扩展(Pylance、TypeScript 语言服务……)按语义答,同名的别的东西(例如同名的参数)不算。页面上源码范围和回答里任何一处重叠的段都高亮,一个名字被拆成几段(`luffy_agent.core.db`)时每段各按自己的范围认。 +- 颜色和编辑器里一样:写入(定义、赋值,`DocumentHighlightKind.Write`)用 `--vscode-editor-wordHighlightStrongBackground`,读取和按文字认出的用 `--vscode-editor-wordHighlightBackground`;主题给了 `wordHighlightStrongBorder` / `wordHighlightBorder`(高对比主题)才描边,描边画在字框里面不挤动排版。同一段碰上两种按写入画。 +- 看不见的出现(在折叠着的块、属性行、子行里,或块被筛掉)不自动展开;筛选栏右边、块数前面写「名字 · N 处」,名字取点中的那一段的文字,N 是回答里的处数(看不见的也算)。之后自己展开,里面的出现照样亮着。 +- 和编辑器一样每回点都现问,回答不留到下次用(语言扩展刚启动时答的「没有」或按文字认的结果不会一直留着);只认最近一次询问的回答,迟到的丢掉。点另一个名字:回答到之前旧高亮留着,到了就换成新的;回答是空的(没装语言扩展、没答)就什么都不亮,不按文字自己找。 +- 清掉(连同筛选栏上那句):点空白处或不是名字的字、按 Esc(输入框、下拉列表自己用掉的 Esc 不算)、文档换了版本、换了翻译。 +- 不抢别的点击:⌘ / Ctrl(以及 Alt、Shift)+ 点击、双击的第二下、拖着选字都不动高亮(双击的第一下照样算一次单击,和编辑器一样);点在按钮、链接、输入框、下拉列表、筛选栏、属性名上照旧,高亮不动——折叠箭头、「编辑」、Markdown 里的链接都照旧;名字本身在按钮里(带下一层的子行的键)时照旧折叠,同时高亮。 ## 通用契约 @@ -485,10 +496,12 @@ type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: P - `{ type: 'document', version, languageId, blocks: BlockView[], attributes: FileAttribute[], translators: { id, label }[], translatorId, error?: string, reveal?: Reveal }`,文档每次变化(防抖 150ms)重发。`attributes` 是这份文件里出现过的筛选属性,按筛选栏的先后排:`{ id, name, values: { id, name, count }[] }`,名字取翻译插件的 `attributeNames`,没有的用 id。`reveal = { line, definition? }` 只随新预览的第一份文档走:从文本编辑器切过来时是它顶部那一行(滚到包含这行的块、顶到顶部,折叠的不展开);跳到定义时还带 `definition`(定义处名字在源码里的下标),按「跳到定义」一节展开、滚动、闪。 - `{ type: 'reveal', line, definition? }`:同上,发给已经开着的预览。页面正在重新加载(标签页切回来)时收不到,扩展端改成随它 `ready` 后的第一份文档带过去。 - `{ type: 'definitionProbed', id, found }`:回答第 `id` 次 `probeDefinition`。 + - `{ type: 'occurrencesFound', id, occurrences: { start, end, kind: 'text' | 'read' | 'write' }[] }`:回答第 `id` 次 `findOccurrences`,`[start, end)` 是源码下标,`kind` 同 `DocumentHighlightKind`;语言扩展没答就是空数组。 - `{ type: 'filter', excluded: Record<属性 id, 值 id[]> }`:整个工作区共用的块筛选。webview 发 `ready` 后先于第一份文档发一次(第一份文档按筛选画),之后任何一个预览改了都发给所有开着的预览(包括改的那个)。 - webview → 扩展: - `{ type: 'probeDefinition', id, version, offset }`:按住修饰键指到带 `offset` 的名字上时问有没有定义,`id` 每问一次加一;`version` 不是当前文档版本就不答。 - `{ type: 'goToDefinition', version, offset }`:修饰键 + 点击,按「跳到定义」一节跳。 + - `{ type: 'findOccurrences', id, version, offset }`:单击带 `offset` 的名字,问它在这份文档里的各处出现;`id` 每问一次加一,`version` 不是当前文档版本就不答。 - `{ type: 'ready' }` - `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。 - `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。 @@ -541,8 +554,9 @@ vscode/src/codeReader/ editor/blockFilter.ts 块筛选:workspaceState 读写、通知所有预览 editor/definitions.ts 查定义(vscode.executeDefinitionProvider)、几处定义的挑选框 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,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts) +vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;属性表在 PropertyGrid.tsx,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts) ``` ## 测试 @@ -557,6 +571,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 注释的 Markdown:注释块、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、JSDoc 标签、HTML 都不转换),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / 相对路径的链接和图片不渲染;跳到定义不认 Markdown 里的名字。 - 行替换:多行块替换后缩进正确。 - 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。 +- 高亮各处出现:段的源码范围和出现重叠才亮、边界相接不算、没下标的不亮,点分模块名每一节各按自己的范围认(真实的 Python 译文和属性表:定义处按写入、用到处按读取);写入 → 强高亮色,读取、按文字 → 普通高亮色,同时碰上按写入;单击名字问、点空白处清、点按钮 / 链接 / 筛选栏不动、名字在按钮里照样问,拖选、双击第二下、带修饰键的点击不动,⌘ / Ctrl + 点击照旧发跳到定义、不问出现处;回答连同名字显示、空回答不亮、换名字时回答到前旧的留着、迟到的回答丢掉、答过「没有」再点照样再问、清掉后才到的回答丢掉、换文档版本或翻译清掉而同版本同翻译重发不清、没有文档时不问。 - 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中。 - 筛选:块的属性(TS、TSX 有抽象、仅类型,JavaScript / JSX 没有这两个属性、属性表也没有这两行,Python 有访问级别、异步);语言插件自己报的属性叠上去、盖过推出来的,kind 不变;文件里出现过的属性按契约顺序排、语言的排后面,值按第一次出现排、带所有层的块数,名字取翻译插件的、没有的显示 id,中文直译的名字和属性表一致;下拉框只给两种以上值的属性(或一种值正被去掉的),按钮上的字;同一属性内「或」、不同属性「且」、没有这个属性不受影响、藏整棵子树、「已隐藏」只数直接筛掉的;筛选记在 `workspaceState`、新实例读得回来、所有订阅的预览收到、形状不对的当没有;跳到被筛掉的块临时露出路径、目标的子块照常筛、文档刷新只留正在编辑的。 - 文件注释:键相对工作区文件夹、`/` 分隔,根和外面的路径没有键,手写的 `./`、结尾 `/` 规范掉;保存去空白、清空即删;文件改名、目录挪位置里面各层一起走、名字只是前缀相同的不动、跨工作区文件夹先取出再放进去;删目录连带里面的;读写:文件缺失 / 解析失败 / 不是对象返回空表、跳过不合法条目、建 `.spx`、键排序、删光留 `{}`;有注释 / 没注释的行的描述、悬停提示(Markdown 转义)、contextValue,和 `package.json` 里菜单的 when 子句对得上。 diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index ddcfade..0b58813 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -19,6 +19,16 @@ export interface Reveal { definition?: number } +/** + * 点中的名字在这份文档里的一处出现:源码下标 [start, end)。kind 同 VS Code 的 DocumentHighlightKind: + * write 是写入(定义、赋值),read 是读取,text 是语言扩展按文字认出来的。 + */ +export interface Occurrence { + start: number + end: number + kind: 'text' | 'read' | 'write' +} + export interface DocumentMessage { type: 'document' /** TextDocument.version:webview 靠它判断是不是新一份文档 */ @@ -37,12 +47,14 @@ export interface DocumentMessage { /** * reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal) * definitionProbed:id 那次 probeDefinition 的回答,found 表示 VS Code 查得到定义 + * occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的 * filter:整个工作区共用的块筛选;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览 */ export type ExtensionToWebview = | DocumentMessage | ({ type: 'reveal' } & Reveal) | { type: 'definitionProbed', id: number, found: boolean } + | { type: 'occurrencesFound', id: number, occurrences: Occurrence[] } | { type: 'filter', excluded: Excluded } /** @@ -51,6 +63,7 @@ export type ExtensionToWebview * probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问源码 offset 处有没有定义;id 标出是哪一次问,回答带回去; * version 是这份下标所属的文档版本 * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 + * findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition * setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览 */ export type WebviewToExtension @@ -61,3 +74,4 @@ export type WebviewToExtension | { type: 'visibleLine', line: number } | { type: 'probeDefinition', id: number, version: number, offset: number } | { type: 'goToDefinition', version: number, offset: number } + | { type: 'findOccurrences', id: number, version: number, offset: number } diff --git a/vscode/src/codeReader/editor/occurrences.ts b/vscode/src/codeReader/editor/occurrences.ts new file mode 100644 index 0000000..8ae6e48 --- /dev/null +++ b/vscode/src/codeReader/editor/occurrences.ts @@ -0,0 +1,31 @@ +import type { DocumentHighlight, TextDocument } from 'vscode' +import type { Occurrence } from './messages' +import { commands, DocumentHighlightKind } from 'vscode' +import { logger } from '../../logging/logger' + +const KINDS: Record = { + [DocumentHighlightKind.Text]: 'text', + [DocumentHighlightKind.Read]: 'read', + [DocumentHighlightKind.Write]: 'write', +} + +/** + * document 里 offset 处的名字出现在哪几处:交给 VS Code 注册的高亮提供者(Pylance、TypeScript 语言服务……), + * 和普通编辑器里单击名字高亮的是同一套,按语义认,同名的别的东西不算。 + */ +export async function occurrencesOf(document: TextDocument, offset: number): Promise { + const position = document.positionAt(offset) + let highlights: DocumentHighlight[] + try { + highlights = await commands.executeCommand('vscode.executeDocumentHighlights', document.uri, position) ?? [] + } + catch (err) { + logger.add({ level: 'warn', source: 'codeReader', message: `查名字的出现处失败 ${document.uri.toString()}:${position.line + 1}:${err instanceof Error ? err.message : String(err)}` }) + return [] + } + return highlights.map(({ range, kind }) => ({ + start: document.offsetAt(range.start), + end: document.offsetAt(range.end), + kind: KINDS[kind ?? DocumentHighlightKind.Text], + })) +} diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index a4346d9..b99143f 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -11,6 +11,7 @@ import { blockReplacement } from '../editText' import { definitionAction } from './definitionAction' import { findDefinitions, hasDefinition, pickDefinition } from './definitions' import { documentMessage } from './documentMessage' +import { occurrencesOf } from './occurrences' export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview' @@ -197,9 +198,22 @@ class Preview { return case 'goToDefinition': await this.goToDefinition(msg.version, msg.offset) + return + case 'findOccurrences': + await this.findOccurrences(msg.id, msg.version, msg.offset) } } + /** 回答 webview 第 id 次询问:offset 处的名字在这份文档里出现在哪几处。下标属于别的文档版本时不答,webview 换了文档版本会清掉高亮 */ + private async findOccurrences(id: number, version: number, offset: number): Promise { + const { document } = this + if (version !== document.version) + return + const occurrences = await occurrencesOf(document, offset) + if (version === document.version && this.ready) + this.post({ type: 'occurrencesFound', id, occurrences }) + } + /** 回答 webview 第 id 次询问:offset 处的名字有没有定义。下标属于别的文档版本时不答,webview 收到新文档后会重新问 */ private async probeDefinition(id: number, version: number, offset: number): Promise { const { document } = this diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 7e6cf20..1c2e9b8 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -8,6 +8,7 @@ import { BlockLevel } from './BlockTree' import { applyFilter, keptWhileEditing } from './filter' import { FilterBar } from './FilterBar' import { useDefinitionLinks } from './useDefinitionLinks' +import { HighlightContext, useOccurrences } from './useOccurrences' import { useReveal } from './useReveal' import { useScrollPosition } from './useScrollPosition' import { collapsedIds } from './viewState' @@ -51,6 +52,7 @@ export function App(): ReactElement | null { useScrollPosition(doc) useReveal(doc, reveal, collapsed, filter.hidden, show, expand, revealed) useDefinitionLinks(doc?.version) + const highlight = useOccurrences(doc?.version, doc?.translatorId) useEffect(() => { const off = onMessage((msg) => { @@ -100,11 +102,12 @@ export function App(): ReactElement | null { return null return ( - <> + postMessage({ type: 'setFilter', excluded: next })} translators={doc.translators} translatorId={doc.translatorId} @@ -129,6 +132,6 @@ export function App(): ReactElement | null { ? :
所有块都被筛选隐藏了。
} - +
) } diff --git a/vscode/webview-ui/src/codeReader/FilterBar.tsx b/vscode/webview-ui/src/codeReader/FilterBar.tsx index fca65a8..42e3240 100644 --- a/vscode/webview-ui/src/codeReader/FilterBar.tsx +++ b/vscode/webview-ui/src/codeReader/FilterBar.tsx @@ -1,5 +1,6 @@ import type { 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' @@ -10,6 +11,8 @@ export interface FilterBarProps { attributes: FileAttribute[] excluded: Excluded view: FilterView + /** 眼下高亮着的名字:汇总前面写「名字 · N 处」,折叠、筛掉的块里的出现也算在 N 里 */ + highlight: Highlight | undefined onChange: (excluded: Excluded) => void translators: TranslatorOption[] translatorId: string @@ -17,10 +20,10 @@ export interface FilterBarProps { } /** - * 预览顶部钉住的一条:「筛选」、这份文件里每个能筛的属性一个下拉框、右边汇总(共 N 块 / 已隐藏 N 块 · 清除), + * 预览顶部钉住的一条:「筛选」、这份文件里每个能筛的属性一个下拉框、右边汇总(高亮着名字时先是「名字 · N 处」,再是共 N 块 / 已隐藏 N 块 · 清除), * 多于一种翻译时最右边是翻译下拉框。内容和代码块同宽对齐,滚动时块从它下面过去。 */ -export function FilterBar({ attributes, excluded, view, onChange, translators, translatorId, onSelectTranslator }: FilterBarProps): ReactElement { +export function FilterBar({ attributes, excluded, view, highlight, onChange, translators, translatorId, onSelectTranslator }: FilterBarProps): ReactElement { return (
@@ -31,7 +34,15 @@ export function FilterBar({ attributes, excluded, view, onChange, translators, t {facetsOf(attributes, excluded).map(facet => ( onChange(toggleValue(excluded, facet.id, value))} /> ))} - + + {highlight && ( + <> + {highlight.name} + · + {`${highlight.occurrences.length} 处`} + + + )} {view.hiddenCount > 0 ? ( <> diff --git a/vscode/webview-ui/src/codeReader/SegmentText.tsx b/vscode/webview-ui/src/codeReader/SegmentText.tsx index a8505b5..3a30027 100644 --- a/vscode/webview-ui/src/codeReader/SegmentText.tsx +++ b/vscode/webview-ui/src/codeReader/SegmentText.tsx @@ -1,12 +1,26 @@ import type { ReactElement } from 'react' import type { Segment } from './types' +import { useContext } from 'react' +import { segmentOccurrence } from './occurrences' +import { HighlightContext } from './useOccurrences' -/** 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换;带源码下标的名字记在 data-offset 上,跳到定义用。 */ +/** + * 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换;带源码下标的名字记在 data-offset 上, + * 跳到定义、高亮各处出现用;是眼下高亮着的名字的一处出现时 data-occurrence 标出底色。 + */ export function SegmentText({ segments }: { segments: Segment[] }): ReactElement { + const highlight = useContext(HighlightContext) return ( <> {segments.map((segment, index) => ( - {segment.text} + + {segment.text} + ))} ) diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 3eb59ec..be53454 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -249,6 +249,27 @@ body.vscode-high-contrast-light { cursor: pointer; } +/* + * 单击名字高亮它在文件里的各处出现:颜色取 VS Code 编辑器的单词高亮,写入(定义、赋值)用强高亮色,读取和按文字认出的用普通高亮色; + * 主题给了边框色(高对比主题)才描边。筛选栏上「名字 · N 处」的名字也按普通高亮画。描边画在字框里面,不挤动排版 + */ +[data-occurrence] { + outline-offset: -1px; +} +[data-occurrence="normal"] { + background: var(--vscode-editor-wordHighlightBackground); + outline: 1px solid var(--vscode-editor-wordHighlightBorder, transparent); +} +[data-occurrence="strong"] { + background: var(--vscode-editor-wordHighlightStrongBackground); + outline: 1px solid var(--vscode-editor-wordHighlightStrongBorder, transparent); +} +.cr-occurrence-name { + padding: 0 3px; + color: var(--vscode-editor-foreground); + font-family: var(--vscode-editor-font-family, monospace); +} + /* 属性表的属性名:界面字体,和值一样的行高,两边逐行对齐 */ .cr-prop-label { font-size: 12.5px; diff --git a/vscode/webview-ui/src/codeReader/occurrences.test.ts b/vscode/webview-ui/src/codeReader/occurrences.test.ts new file mode 100644 index 0000000..6537f0b --- /dev/null +++ b/vscode/webview-ui/src/codeReader/occurrences.test.ts @@ -0,0 +1,224 @@ +import type { Click, Highlight } from './occurrences' +import type { BlockView, Occurrence, PreviewText, PropertyItem, Segment, WebviewToExtension } from './types' +import { describe, expect, it } from 'vitest' +import { toBlockViews } from '../../../src/codeReader/blockView' +import { createPythonAdapter } from '../../../src/codeReader/languages/python' +import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport' +import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh' +import { DefinitionLinks } from './definitionLinks' +import { clickAction, OccurrenceHighlight, occurrenceStyle, segmentOccurrence } from './occurrences' + +function name(text: string, offset: number): Segment { + return { text, role: 'variable', offset } +} + +function at(start: number, end: number, kind: Occurrence['kind'] = 'read'): Occurrence { + return { start, end, kind } +} + +function click(overrides: Partial = {}): Click { + return { modified: false, count: 1, selecting: false, control: false, ...overrides } +} + +function setup(version = 1, translatorId = 'zh'): { occ: OccurrenceHighlight, asked: () => { id: number, version: number, offset: number }[], changes: (Highlight | undefined)[] } { + const sent: WebviewToExtension[] = [] + const changes: (Highlight | undefined)[] = [] + const occ = new OccurrenceHighlight(msg => sent.push(msg), highlight => changes.push(highlight)) + occ.setDocument(version, translatorId) + const asked = (): { id: number, version: number, offset: number }[] => sent.flatMap(msg => msg.type === 'findOccurrences' ? [{ id: msg.id, version: msg.version, offset: msg.offset }] : []) + return { occ, asked, changes } +} + +function find(occ: OccurrenceHighlight, offset: number, text: string): void { + occ.click(clickAction(click({ name: { offset, text } }))) +} + +describe('occurrences:哪些段画高亮', () => { + it('段在源码里的范围和一处出现重叠就画,边界相接不算,没有下标的不画', () => { + const occurrences = [at(10, 18)] + expect(segmentOccurrence(name('ALPHABET', 10), occurrences)).toBe('normal') + expect(segmentOccurrence(name('x', 2), occurrences)).toBeUndefined() + expect(segmentOccurrence(name('ab', 8), occurrences)).toBeUndefined() + expect(segmentOccurrence(name('ab', 18), occurrences)).toBeUndefined() + expect(segmentOccurrence(name('ab', 17), occurrences)).toBe('normal') + expect(segmentOccurrence({ text: '常量', role: 'storage' }, occurrences)).toBeUndefined() + // 整段字符串比名字长:出现落在字符串里面也算 + expect(segmentOccurrence({ text: '\'./util\'', role: 'string', offset: 9 }, occurrences)).toBe('normal') + }) + + it('写入用强高亮色,读取和按文字认出的用普通高亮色;同一段碰上两种按写入画', () => { + expect(occurrenceStyle('write')).toBe('strong') + expect(occurrenceStyle('read')).toBe('normal') + expect(occurrenceStyle('text')).toBe('normal') + expect(segmentOccurrence(name('a', 0), [at(0, 1, 'text')])).toBe('normal') + expect(segmentOccurrence(name('a', 0), [at(0, 1, 'read'), at(0, 1, 'write')])).toBe('strong') + }) + + it('真实的 Python 译文和属性表:定义处按写入、用到的地方按读取画;点分模块名每一节各按自己的范围认', async () => { + const source = [ + 'import luffy_agent.core.db', + 'import secrets', + '', + 'ALPHABET = "abcdefgh"', + '', + 'def token():', + ' return "".join(secrets.choice(ALPHABET) for _ in range(8))', + ].join('\n') + const py = createPythonAdapter(WASM_DIR) + const views = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, source, py, pythonZhTranslator)) + const highlighted = (occurrences: Occurrence[]): string[] => allSegments(views).flatMap((segment) => { + const style = segmentOccurrence(segment, occurrences) + return style ? [`${segment.text}:${style}`] : [] + }) + const range = (needle: string, kind: Occurrence['kind'], from = 0): Occurrence => { + const start = source.indexOf(needle, from) + expect(start, needle).toBeGreaterThanOrEqual(0) + return at(start, start + needle.length, kind) + } + + const definition = range('ALPHABET', 'write') + expect(highlighted([definition, range('ALPHABET', 'read', definition.end)])).toEqual(['ALPHABET:strong', 'ALPHABET:normal']) + expect(highlighted([range('core', 'read')])).toEqual(['core:normal']) + expect(highlighted([range('luffy_agent.core.db', 'read')])).toEqual(['luffy_agent:normal', 'core:normal', 'db:normal']) + }) +}) + +describe('occurrences:点击', () => { + it('单击名字问它的各处出现,名字在折叠箭头这类按钮里也算;点空白处清掉,点在按钮、链接、筛选栏上不动', () => { + expect(clickAction(click({ name: { offset: 3, text: 'x' } }))).toEqual({ kind: 'find', offset: 3, name: 'x' }) + expect(clickAction(click({ name: { offset: 3, text: 'x' }, control: true }))).toEqual({ kind: 'find', offset: 3, name: 'x' }) + expect(clickAction(click())).toEqual({ kind: 'clear' }) + expect(clickAction(click({ control: true }))).toEqual({ kind: 'ignore' }) + }) + + it('拖着选字、双击、带修饰键的点击都不动高亮', () => { + const onName = { name: { offset: 3, text: 'x' } } + expect(clickAction(click({ ...onName, selecting: true }))).toEqual({ kind: 'ignore' }) + expect(clickAction(click({ selecting: true }))).toEqual({ kind: 'ignore' }) + expect(clickAction(click({ ...onName, count: 2 }))).toEqual({ kind: 'ignore' }) + expect(clickAction(click({ count: 2 }))).toEqual({ kind: 'ignore' }) + expect(clickAction(click({ ...onName, modified: true }))).toEqual({ kind: 'ignore' }) + expect(clickAction(click({ modified: true }))).toEqual({ kind: 'ignore' }) + }) + + it('⌘ / Ctrl + 点击名字照旧跳到定义,不问出现处', () => { + const sent: WebviewToExtension[] = [] + const links = new DefinitionLinks(msg => sent.push(msg)) + links.setVersion(1) + const occ = new OccurrenceHighlight(msg => sent.push(msg), () => {}) + occ.setDocument(1, 'zh') + + expect(links.click(42)).toBe(true) + occ.click(clickAction(click({ modified: true, name: { offset: 42, text: 'x' } }))) + expect(sent).toEqual([{ type: 'goToDefinition', version: 1, offset: 42 }]) + }) +}) + +describe('occurrences:高亮的状态', () => { + it('问到的各处出现连同点中的名字一起显示;回答是空的不高亮', () => { + const { occ, asked, changes } = setup() + find(occ, 20, 'ALPHABET') + expect(asked()).toEqual([{ id: asked()[0].id, version: 1, offset: 20 }]) + const occurrences = [at(20, 28, 'write'), at(60, 68)] + occ.answer(asked()[0].id, occurrences) + expect(occ.highlight).toEqual({ name: 'ALPHABET', occurrences }) + expect(changes).toEqual([{ name: 'ALPHABET', occurrences }]) + + find(occ, 5, 'secrets') + occ.answer(asked()[1].id, []) + expect(occ.highlight).toBeUndefined() + }) + + it('点另一个名字:回答到之前旧高亮留着,到了就换成新的', () => { + const { occ, asked } = setup() + find(occ, 20, 'ALPHABET') + occ.answer(asked()[0].id, [at(20, 28)]) + find(occ, 5, 'secrets') + expect(occ.highlight?.name).toBe('ALPHABET') + occ.answer(asked()[1].id, [at(5, 12), at(40, 47)]) + expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12), at(40, 47)] }) + }) + + it('只认最近一次询问的回答,迟到的丢掉', () => { + const { occ, asked } = setup() + find(occ, 20, 'ALPHABET') + find(occ, 5, 'secrets') + const [first, second] = asked() + occ.answer(second.id, [at(5, 12)]) + occ.answer(first.id, [at(20, 28)]) + expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12)] }) + occ.answer(second.id, [at(99, 100)]) + expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12)] }) + }) + + it('回答不留到下次用:答过「没有」后再点同一个名字照样再问', () => { + const { occ, asked } = setup() + find(occ, 20, 'ALPHABET') + occ.answer(asked()[0].id, []) + find(occ, 20, 'ALPHABET') + expect(asked()).toHaveLength(2) + occ.answer(asked()[1].id, [at(20, 28)]) + expect(occ.highlight?.name).toBe('ALPHABET') + }) + + it('点空白处、Esc 清掉高亮,清掉后才到的回答也丢掉', () => { + const { occ, asked } = setup() + find(occ, 20, 'ALPHABET') + occ.answer(asked()[0].id, [at(20, 28)]) + occ.click(clickAction(click())) + expect(occ.highlight).toBeUndefined() + + find(occ, 20, 'ALPHABET') + occ.answer(asked()[1].id, [at(20, 28)]) + find(occ, 5, 'secrets') + occ.clear() + occ.answer(asked()[2].id, [at(5, 12)]) + expect(occ.highlight).toBeUndefined() + }) + + it('换了文档版本或翻译就清掉,同一版本同一翻译重发不清;换版本前问的回答丢掉', () => { + const { occ, asked } = setup(1, 'zh') + find(occ, 20, 'ALPHABET') + occ.answer(asked()[0].id, [at(20, 28)]) + occ.setDocument(1, 'zh') + expect(occ.highlight?.name).toBe('ALPHABET') + occ.setDocument(1, 'ai') + expect(occ.highlight).toBeUndefined() + + find(occ, 20, 'ALPHABET') + occ.answer(asked()[1].id, [at(20, 28)]) + find(occ, 5, 'secrets') + occ.setDocument(2, 'ai') + expect(occ.highlight).toBeUndefined() + occ.answer(asked()[2].id, [at(5, 12)]) + expect(occ.highlight).toBeUndefined() + + find(occ, 20, 'ALPHABET') + expect(asked()[3].version).toBe(2) + }) + + it('还没有文档时点名字不问', () => { + const sent: WebviewToExtension[] = [] + const occ = new OccurrenceHighlight(msg => sent.push(msg), () => {}) + find(occ, 20, 'ALPHABET') + expect(sent).toEqual([]) + }) +}) + +function textSegments(text: PreviewText): Segment[] { + return 'markdown' in text ? [] : text +} + +function itemSegments(items: PropertyItem[] | undefined): Segment[] { + return (items ?? []).flatMap(item => [...item.cells.flat(), ...itemSegments(item.items)]) +} + +/** 预览里画出来的所有段,按页面上从上到下的顺序:每块的属性表或译文,再是它的子块。 */ +function allSegments(views: BlockView[]): Segment[] { + return views.flatMap(view => [ + ...('properties' in view + ? view.properties.flatMap(row => [...textSegments(row.value), ...itemSegments(row.items)]) + : textSegments(view.translation)), + ...allSegments(view.children), + ]) +} diff --git a/vscode/webview-ui/src/codeReader/occurrences.ts b/vscode/webview-ui/src/codeReader/occurrences.ts new file mode 100644 index 0000000..92e52f1 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/occurrences.ts @@ -0,0 +1,133 @@ +import type { Occurrence, Segment, WebviewToExtension } from './types' + +/** 高亮画成哪种底色,和 VS Code 编辑器一样:写入(定义、赋值)用强高亮色,读取和按文字认出的用普通高亮色。 */ +export type OccurrenceStyle = 'strong' | 'normal' + +export function occurrenceStyle(kind: Occurrence['kind']): OccurrenceStyle { + return kind === 'write' ? 'strong' : 'normal' +} + +/** + * 一段文字画不画高亮、画哪种:它照抄自源码,在源码里占 [offset, offset + 字数),和哪处出现重叠就画, + * 同时碰上写入和读取的按写入画。没有下标的(译词、提示词、编辑状态的源码)不画。 + */ +export function segmentOccurrence(segment: Segment, occurrences: readonly Occurrence[]): OccurrenceStyle | undefined { + const { offset } = segment + if (offset === undefined) + return undefined + const end = offset + segment.text.length + let style: OccurrenceStyle | undefined + for (const occurrence of occurrences) { + if (occurrence.start < end && offset < occurrence.end) { + style = occurrenceStyle(occurrence.kind) + if (style === 'strong') + return style + } + } + return style +} + +/** 一次点击的情况,useOccurrences 从事件和页面上读出来。 */ +export interface Click { + /** 按着 ⌘ / Ctrl / Alt / Shift:不是单纯的单击(⌘ / Ctrl + 点击归跳到定义) */ + modified: boolean + /** 连击的第几下(MouseEvent.detail),双击的第二下是 2 */ + count: number + /** 点完页面上有选中的文字:这是拖着选字,不是点 */ + selecting: boolean + /** 点中的带下标的名字 */ + name?: { offset: number, text: string } + /** 点在自己管点击的东西上:按钮、链接、输入框、下拉列表、筛选栏、属性名 */ + control: boolean +} + +/** find:问 offset 处名字的各处出现;clear:清掉高亮;ignore:不动 */ +export type ClickAction + = | { kind: 'find', offset: number, name: string } + | { kind: 'clear' } + | { kind: 'ignore' } + +/** + * 单击名字就高亮,名字在折叠箭头这类按钮里也算(按钮照旧折叠);点空白处清掉; + * 修饰键 + 点击、双击、拖着选字、点在按钮 / 链接 / 筛选栏这些自己管点击的东西上,高亮不动。 + */ +export function clickAction(click: Click): ClickAction { + if (click.modified || click.count > 1 || click.selecting) + return { kind: 'ignore' } + if (click.name) + return { kind: 'find', offset: click.name.offset, name: click.name.text } + return click.control ? { kind: 'ignore' } : { kind: 'clear' } +} + +/** 眼下高亮着的名字:点中时它的文字(筛选栏上显示)和它在文档里的各处出现。 */ +export interface Highlight { + name: string + occurrences: readonly Occurrence[] +} + +/** + * 单击名字高亮它在文档里的各处出现,和编辑器里一样每回点都现问扩展端,回答不留到下次用; + * 只认最近一次询问的回答。回答到之前旧高亮留着(点同一个名字的另一处不闪),回答是空的就不高亮。 + */ +export class OccurrenceHighlight { + private version: number | undefined + private translatorId: string | undefined + /** 最近一次询问的 id;别的回答(点了别处、清掉之后才到的)都丢掉 */ + private request = 0 + /** 最近一次询问点中的名字;答过或清掉后没有 */ + private asked: string | undefined + private current: Highlight | undefined + + constructor( + private readonly post: (msg: WebviewToExtension) => void, + private readonly changed: (highlight: Highlight | undefined) => void, + ) {} + + get highlight(): Highlight | undefined { + return this.current + } + + /** 换了文档版本或翻译:下标、页面上的名字都换了,高亮清掉 */ + setDocument(version: number | undefined, translatorId: string | undefined): void { + if (version === this.version && translatorId === this.translatorId) + return + this.version = version + this.translatorId = translatorId + this.clear() + } + + click(action: ClickAction): void { + if (action.kind === 'clear') + this.clear() + else if (action.kind === 'find') + this.find(action.offset, action.name) + } + + answer(id: number, occurrences: Occurrence[]): void { + if (id !== this.request || this.asked === undefined) + return + const name = this.asked + this.asked = undefined + this.show(occurrences.length > 0 ? { name, occurrences } : undefined) + } + + clear(): void { + this.request++ + this.asked = undefined + this.show(undefined) + } + + private find(offset: number, name: string): void { + if (this.version === undefined) + return + this.asked = name + this.post({ type: 'findOccurrences', id: ++this.request, version: this.version, offset }) + } + + private show(highlight: Highlight | undefined): void { + if (highlight === this.current) + return + this.current = highlight + this.changed(highlight) + } +} diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 815984f..d3c4ae2 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -95,6 +95,16 @@ export interface Reveal { definition?: number } +/** + * 点中的名字在这份文档里的一处出现:源码下标 [start, end)。kind 同 VS Code 的 DocumentHighlightKind: + * write 是写入(定义、赋值),read 是读取,text 是语言扩展按文字认出来的 + */ +export interface Occurrence { + start: number + end: number + kind: 'text' | 'read' | 'write' +} + export interface DocumentMessage { type: 'document' version: number @@ -112,12 +122,14 @@ export interface DocumentMessage { /** * reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal) * definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义 + * occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的 * filter:整个工作区共用的块筛选;ready 后先于第一份文档来一次,之后任何一个预览改了都会来 */ export type ExtensionToWebview = | DocumentMessage | ({ type: 'reveal' } & Reveal) | { type: 'definitionProbed', id: number, found: boolean } + | { type: 'occurrencesFound', id: number, occurrences: Occurrence[] } | { type: 'filter', excluded: Excluded } /** @@ -126,6 +138,7 @@ export type ExtensionToWebview * probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问 offset 处有没有定义;id 标出是哪一次问,回答带回来; * version 是这份下标所属的文档版本 * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 + * findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition * setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示 */ export type WebviewToExtension @@ -136,3 +149,4 @@ export type WebviewToExtension | { type: 'visibleLine', line: number } | { type: 'probeDefinition', id: number, version: number, offset: number } | { type: 'goToDefinition', version: number, offset: number } + | { type: 'findOccurrences', id: number, version: number, offset: number } diff --git a/vscode/webview-ui/src/codeReader/useOccurrences.ts b/vscode/webview-ui/src/codeReader/useOccurrences.ts new file mode 100644 index 0000000..2716772 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/useOccurrences.ts @@ -0,0 +1,55 @@ +import type { Click, Highlight } from './occurrences' +import { createContext, useEffect, useLayoutEffect, useState } from 'react' +import { clickAction, OccurrenceHighlight } from './occurrences' +import { onMessage, postMessage } from './vscode' + +/** 眼下高亮着的名字,SegmentText 按它给各处出现上底色。 */ +export const HighlightContext = createContext(undefined) + +/** 自己管点击的东西:点在上面(名字以外的地方)高亮不动 */ +const CONTROLS = 'a, button, input, select, textarea, [role="listbox"], .cr-filter-bar, .cr-prop-label' + +function clickOf(event: MouseEvent): Click { + const target = event.target instanceof Element ? event.target : null + const name = target?.closest('[data-offset]') + const selection = window.getSelection() + return { + modified: event.metaKey || event.ctrlKey || event.altKey || event.shiftKey, + count: event.detail, + selecting: selection !== null && !selection.isCollapsed, + name: name?.dataset.offset === undefined ? undefined : { offset: Number(name.dataset.offset), text: name.textContent ?? '' }, + control: target?.closest(CONTROLS) != null, + } +} + +/** 单击名字高亮它在文档里的各处出现,点空白处、Esc、换文档版本或翻译清掉;返回眼下高亮着的名字。 */ +export function useOccurrences(version: number | undefined, translatorId: string | undefined): Highlight | undefined { + const [highlight, setHighlight] = useState() + const [occurrences] = useState(() => new OccurrenceHighlight(postMessage, setHighlight)) + + // 赶在绘制前清掉:新文档的下标换了意思,旧高亮不能落在新页面的别的名字上 + useLayoutEffect(() => occurrences.setDocument(version, translatorId), [occurrences, version, translatorId]) + + useEffect(() => { + // 捕获阶段:赶在块里的按钮处理点击、折叠箭头换图标之前读出点在哪 + const onClick = (event: MouseEvent): void => occurrences.click(clickAction(clickOf(event))) + // 输入框、下拉列表自己用掉的 Esc 不算 + const onKey = (event: KeyboardEvent): void => { + if (event.key === 'Escape' && !event.defaultPrevented) + occurrences.clear() + } + const off = onMessage((msg) => { + if (msg.type === 'occurrencesFound') + occurrences.answer(msg.id, msg.occurrences) + }) + document.addEventListener('click', onClick, true) + window.addEventListener('keydown', onKey) + return () => { + off() + document.removeEventListener('click', onClick, true) + window.removeEventListener('keydown', onKey) + } + }, [occurrences]) + + return highlight +}