✨ feat(vscode): 代码阅读预览单击名字高亮它在文件里的各处出现

This commit is contained in:
cruldra committed 2026-09-26 15:51:52 +08:00
1 parent 5872cf7958
commit 9c0f05362c
12 files changed
+558 -9

No files matched your search

+17 -2
View File
@@ -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 子句对得上。
+14
View File
@@ -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 }
@@ -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, Occurrence['kind']> = {
[DocumentHighlightKind.Text]: 'text',
[DocumentHighlightKind.Read]: 'read',
[DocumentHighlightKind.Write]: 'write',
}
/**
* document 里 offset 处的名字出现在哪几处:交给 VS Code 注册的高亮提供者(Pylance、TypeScript 语言服务……),
* 和普通编辑器里单击名字高亮的是同一套,按语义认,同名的别的东西不算。
*/
export async function occurrencesOf(document: TextDocument, offset: number): Promise<Occurrence[]> {
const position = document.positionAt(offset)
let highlights: DocumentHighlight[]
try {
highlights = await commands.executeCommand<DocumentHighlight[] | undefined>('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],
}))
}
@@ -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<void> {
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<void> {
const { document } = this
+5 -2
View File
@@ -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 (
<>
<HighlightContext value={highlight}>
<FilterBar
attributes={doc.attributes}
excluded={excluded}
view={filter}
highlight={highlight}
onChange={next => postMessage({ type: 'setFilter', excluded: next })}
translators={doc.translators}
translatorId={doc.translatorId}
@@ -129,6 +132,6 @@ export function App(): ReactElement | null {
? <BlockLevel blocks={doc.blocks} tree={tree} />
: <div className="text-[var(--vscode-descriptionForeground)]">所有块都被筛选隐藏了。</div>}
</div>
</>
</HighlightContext>
)
}
+14 -3
View File
@@ -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 (
<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">
@@ -31,7 +34,15 @@ export function FilterBar({ attributes, excluded, view, onChange, translators, t
{facetsOf(attributes, excluded).map(facet => (
<FacetMenu key={facet.id} facet={facet} onToggle={value => onChange(toggleValue(excluded, facet.id, value))} />
))}
<span className="ml-auto flex h-6 items-center gap-1.5 pl-2 text-xs whitespace-nowrap text-[var(--vscode-descriptionForeground)]">
<span className="ml-auto flex h-6 min-w-0 items-center gap-1.5 pl-2 text-xs whitespace-nowrap text-[var(--vscode-descriptionForeground)]">
{highlight && (
<>
<span className="cr-occurrence-name max-w-[240px] min-w-0 truncate" data-occurrence="normal" title={highlight.name}>{highlight.name}</span>
<span aria-hidden>·</span>
{`${highlight.occurrences.length} 处`}
<span className="mx-1.5 h-3 border-l border-[var(--vscode-panel-border)]" aria-hidden />
</>
)}
{view.hiddenCount > 0
? (
<>
@@ -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) => (
<span key={index} className={`cr-role-${segment.role}`} data-offset={segment.offset}>{segment.text}</span>
<span
key={index}
className={`cr-role-${segment.role}`}
data-offset={segment.offset}
data-occurrence={highlight && segmentOccurrence(segment, highlight.occurrences)}
>
{segment.text}
</span>
))}
</>
)
@@ -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;
@@ -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> = {}): 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),
])
}
@@ -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)
}
}
+14
View File
@@ -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 }
@@ -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<Highlight | undefined>(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<HTMLElement>('[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<Highlight | undefined>()
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
}