✨ 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

@@ -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),
])
}