✨ feat(vscode): 代码阅读鼠标停在名字和注释链接上弹出定义所在那块的卡片,别的文件的也行

This commit is contained in:
cruldra committed 2026-10-08 07:17:26 +08:00
1 parent bc7380c716
commit a48b016d73
20 files changed
+1145 -89

No files matched your search

@@ -0,0 +1,89 @@
import type { Memento } from 'vscode'
import type { BlockView } from '../blockView'
import { describe, expect, it } from 'vitest'
import { createPythonAdapter } from '../languages/python'
import { CodeReaderRegistry } from '../registry'
import { runtime, WASM_DIR } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { definitionSiteNames, namesOnCard, peekBlock } from './definitionPeek'
import { documentMessage } from './documentMessage'
const SOURCE = [
'from typing import Annotated',
'',
'class PairedDeviceRepository:',
' """配对设备的仓库"""',
'',
' owner: Annotated[str, Comment("所有者")] = ""',
'',
' def find(self, device_id: str):',
' return device_id',
'',
'',
'repo = PairedDeviceRepository()',
'',
].join('\n')
async function read(): Promise<Awaited<ReturnType<typeof documentMessage>>> {
const registry = new CodeReaderRegistry({ get: () => undefined, update: async () => {} } as Pick<Memento, 'get' | 'update'>)
registry.registerLanguage(createPythonAdapter(WASM_DIR))
registry.registerTranslator(pythonZhTranslator)
return documentMessage({ languageId: 'python', version: 1, getText: () => SOURCE }, registry, runtime, () => {})
}
function at(name: string, from = 0): { offset: number, line: number } {
const offset = SOURCE.indexOf(name, from)
return { offset, line: SOURCE.slice(0, offset).split('\n').length - 1 }
}
describe('定义卡片取哪块', () => {
it('定义是块自己的名字:取那块,连同子块', async () => {
const { message } = await read()
const { offset, line } = at('PairedDeviceRepository')
const block = peekBlock(message.blocks, offset, line)
expect(block?.kind).toBe('class')
expect(block?.children.map(child => child.kind)).toContain('function')
})
it('定义是参数:取参数所在的函数那块', async () => {
const { message } = await read()
const { offset, line } = at('device_id')
const block = peekBlock(message.blocks, offset, line)
expect(block?.kind).toBe('function')
expect(block?.defines?.startIndex).toBe(at('find').offset)
})
it('定义是变量:取变量那块;定义在块外面:没有', async () => {
const { message } = await read()
const repo = at('repo =')
expect(peekBlock(message.blocks, repo.offset, repo.line)?.kind).toBe('variable')
expect(peekBlock(message.blocks, SOURCE.length, 99)).toBeUndefined()
})
it('挂在父卡片子行上的子块:浮窗里是最外层的卡片,不带 slot', () => {
const child = { id: '0.0', startLine: 2, endLine: 3, defines: { startIndex: 30, endIndex: 34 }, slot: { row: 1, item: 0 }, children: [] } as unknown as BlockView
const parent = { id: '0', startLine: 0, endLine: 5, children: [child] } as unknown as BlockView
const block = peekBlock([parent], 31, 2)
expect(block?.id).toBe('0.0')
expect(block && 'slot' in block).toBe(false)
})
})
describe('卡片上的中文名', () => {
it('别的文件:起了中文名的定义本身,标成定义处;只带卡片上画出来的', async () => {
const { message, displayNames } = await read()
const names = definitionSiteNames(displayNames)
const { offset, line } = at('PairedDeviceRepository')
const owner = { offset: at('owner').offset, length: 'owner'.length, name: '所有者', definition: true }
expect(namesOnCard(peekBlock(message.blocks, offset, line)!, names)).toEqual([owner])
const repo = at('repo =')
expect(namesOnCard(peekBlock(message.blocks, repo.offset, repo.line)!, names)).toEqual([])
})
it('下标对上、长度对不上的不带', async () => {
const { message } = await read()
const { offset, line } = at('PairedDeviceRepository')
const owner = at('owner').offset
expect(namesOnCard(peekBlock(message.blocks, offset, line)!, [{ offset: owner, length: 3, name: '所有者' }])).toEqual([])
})
})
@@ -0,0 +1,50 @@
import type { BlockView } from '../blockView'
import type { DisplayName } from '../contract'
import type { ShownName } from '../nameResolution'
import { renderedNames } from '../nameResolution'
function covers(block: BlockView, offset: number): boolean {
return block.defines !== undefined && block.defines.startIndex <= offset && offset < block.defines.endIndex
}
/** 自己的名字(defines)盖住 offset 的最里层那块。 */
function namedAt(blocks: readonly BlockView[], offset: number): BlockView | undefined {
for (const block of blocks) {
const inner = namedAt(block.children, offset)
if (inner)
return inner
if (covers(block, offset))
return block
}
return undefined
}
/** 编辑范围盖住 line 的最里层那块;同一行上并排的兄弟块取第一块。 */
function innermostAt(blocks: readonly BlockView[], line: number): BlockView | undefined {
const outer = blocks.find(block => block.startLine <= line && line <= block.endLine)
return outer && (innermostAt(outer.children, line) ?? outer)
}
/**
* 定义卡片取哪块:定义处(下标 offset,在第 line 行)是哪块自己的名字(函数名、类名、单个变量名)就取那块,
* 否则取编辑范围盖住定义那一行的最里层那块(参数、导入项、字段落在它们所在的块里)。都没有(定义在块外面)就没有。
* 挂在父卡片子行上的子块(slot)在浮窗里是最外层那张卡片,去掉 slot 照常画。
*/
export function peekBlock(blocks: readonly BlockView[], offset: number, line: number): BlockView | undefined {
const found = namedAt(blocks, offset) ?? innermostAt(blocks, line)
if (!found?.slot)
return found
const { slot: _slot, ...card } = found
return card
}
/** 卡片上画出来的名字(照抄自源码,下标和长度都对得上)显示成什么中文名;别的不带过去。 */
export function namesOnCard(block: BlockView, names: readonly ShownName[]): ShownName[] {
const rendered = renderedNames([block])
return names.filter(name => rendered.get(name.offset)?.length === name.length)
}
/** 别的文件里起了中文名的定义本身显示成中文名(定义处标 definition,同预览);用到它们的各处要问语言扩展,浮窗不等。 */
export function definitionSiteNames(names: readonly DisplayName[]): ShownName[] {
return names.map(({ name, text }) => ({ offset: name.startIndex, length: name.endIndex - name.startIndex, name: text, definition: true as const }))
}
+20
View File
@@ -34,6 +34,17 @@ export interface Occurrence {
kind: 'text' | 'read' | 'write'
}
/**
* 悬停名字、注释里的链接时浮窗里的定义卡片:定义所在的文件(从工作区文件夹算起的相对路径,同挑选框)、定义处那一行(0 起),
* 盖住定义处的那块(连同子块,和那个文件的预览画的是同一张卡片),显示成中文名的名字(下标是那个文件里的)。
*/
export interface DefinitionPeek {
path: string
line: number
block: BlockView
names: ShownName[]
}
export interface DocumentMessage {
type: 'document'
/** TextDocument.version:webview 靠它判断是不是新一份文档 */
@@ -89,6 +100,9 @@ export interface QaMessage {
/**
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
* definitionProbed:id 那次 probeDefinition 的回答,found 表示 VS Code 查得到定义
* definitionPeeked:id 那次 peekDefinition / peekLink 的回答。查不到定义、定义所在的语言没有代码阅读、没有块盖住定义处、
* 指着的名字就是定义本身时没有 peek,页面不弹浮窗
* hoverDelay:VS Code 的 editor.hover.delay / editor.hover.hidingDelay(毫秒),鼠标停多久弹定义卡片、移开后多久收;ready 后发一次,设置改了再发
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
* filter:整个工作区共用的块筛选和「只看改动」开关;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览
* qa:这份文件的问答;ready 后发一次,问答、正在答的问题变了再发
@@ -110,6 +124,8 @@ export type ExtensionToWebview
= | DocumentMessage
| ({ type: 'reveal' } & Reveal)
| { type: 'definitionProbed', id: number, found: boolean }
| { type: 'definitionPeeked', id: number, peek?: DefinitionPeek }
| { type: 'hoverDelay', delay: number, hidingDelay: number }
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
| { type: 'moveOccurrence', step: 1 | -1 }
| { type: 'displayNames', version: number, names: ShownName[] }
@@ -133,6 +149,8 @@ export type ExtensionToWebview
* probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问源码 offset 处有没有定义;id 标出是哪一次问,回答带回去;
* version 是这份下标所属的文档版本
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
* peekDefinition:鼠标停在带下标的名字上,要 offset 处名字的定义卡片(几处定义取第一处);id、version 同 probeDefinition
* peekLink:鼠标停在注释里指到符号的链接上(带 #符号 的仓库链接、不带路径的引用),要那个符号的定义卡片;path、symbol 同 openRepoLink
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
* setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览
* setOnlyChanges:打开 / 关掉「只看改动」,扩展端记下后发 filter 给所有预览
@@ -161,6 +179,8 @@ export type WebviewToExtension
| { type: 'visibleLine', line: number }
| { type: 'probeDefinition', id: number, version: number, offset: number }
| { type: 'goToDefinition', version: number, offset: number }
| { type: 'peekDefinition', id: number, version: number, offset: number }
| { type: 'peekLink', id: number, path?: string, symbol: string }
| { type: 'findOccurrences', id: number, version: number, offset: number }
| { type: 'ask', askId: string, anchor: string, question: string, images: ClaudeImage[] }
| { type: 'stopAsk', askId: string }
+101 -22
View File
@@ -13,7 +13,7 @@ import type { ImportedName, LinkTarget, SymbolNode } from '../symbolLinks'
import type { IndexedEntry } from '../workspaceIndex'
import type { BlockFilter } from './blockFilter'
import type { FoundLocation } from './definitions'
import type { ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages'
import type { DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages'
import type { TargetSpot } from './openTarget'
import { realpath } from 'node:fs/promises'
import { dirname } from 'node:path'
@@ -33,6 +33,7 @@ import { askPrompt, followUpPrompt } from '../qa/prompt'
import { DocumentLookups } from '../ruleLookups'
import { resolveReference } from '../symbolLinks'
import { definitionAction } from './definitionAction'
import { definitionSiteNames, namesOnCard, peekBlock } from './definitionPeek'
import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions'
import { DefinitionSources } from './definitionSources'
import { documentMessage } from './documentMessage'
@@ -259,6 +260,10 @@ class Preview {
this.names.edited(document.version, e.contentChanges.map(change => ({ offset: change.rangeOffset, length: change.rangeLength, text: change.text })))
this.schedulePush()
}),
workspace.onDidChangeConfiguration((e) => {
if (this.ready && e.affectsConfiguration('editor.hover', document))
this.postHoverDelay()
}),
// 页面没 ready 时收不到,等它 ready 再随第一份文档之前补发
filter.subscribe((state) => {
if (this.ready)
@@ -533,6 +538,7 @@ class Preview {
this.ready = true
// 先于文档发:第一份文档按筛选画,不会先整份露出来再藏
this.post({ type: 'filter', ...this.filter.current })
this.postHoverDelay()
this.postQa()
void this.postProfiles()
await this.push()
@@ -579,6 +585,12 @@ class Preview {
case 'goToDefinition':
await this.goToDefinition(msg.version, msg.offset)
return
case 'peekDefinition':
await this.peekDefinition(msg.id, msg.version, msg.offset)
return
case 'peekLink':
await this.peekLink(msg.id, msg.path, msg.symbol)
return
case 'findOccurrences':
await this.findOccurrences(msg.id, msg.version, msg.offset)
return
@@ -644,6 +656,73 @@ class Preview {
}
}
/** VS Code 的悬停延迟(按这份文件取,语言自己的设置优先):页面按它弹、收定义卡片。 */
private postHoverDelay(): void {
const hover = workspace.getConfiguration('editor.hover', this.document)
this.post({ type: 'hoverDelay', delay: hover.get<number>('delay', 300), hidingDelay: hover.get<number>('hidingDelay', 300) })
}
/**
* 回答 webview 第 id 次要定义卡片:offset 处名字的第一处定义所在那块(见 peekAt);指着的名字就是定义本身时没有。
* 下标属于别的文档版本时不答,webview 换了文档版本会收起浮窗
*/
private async peekDefinition(id: number, version: number, offset: number): Promise<void> {
const { document } = this
if (version !== document.version)
return
const [definition] = await findDefinitions(await this.definitions.locate(version, offset))
const own = definition?.uri === document.uri.toString() && definition.offset === offset
const peek = definition && !own ? await this.peekAt(definition.location.uri, definition.offset) : undefined
if (version === document.version && this.ready)
this.post({ type: 'definitionPeeked', id, ...(peek && { peek }) })
}
/** 回答 webview 第 id 次要注释里的链接指的符号的卡片:符号同点这个链接时找的那个(symbolTarget),找不到没有。 */
private async peekLink(id: number, rel: string | undefined, symbol: string): Promise<void> {
let peek: DefinitionPeek | undefined
try {
const target = await this.symbolTarget(rel, symbol)
peek = target && await this.peekAt(Uri.parse(target.uri), target.selection.startIndex)
}
catch (err) {
const link = rel === undefined ? `[${symbol}]` : `${rel}#${symbol}`
logger.add({ level: 'warn', source: 'codeReader', message: `没能找 ${link} 指的符号:${err instanceof Error ? err.message : String(err)}` })
}
if (this.ready)
this.post({ type: 'definitionPeeked', id, ...(peek && { peek }) })
}
/**
* uri 里 offset 处的定义的卡片:盖住它的那块(peekBlock)。同一份文件用最近发出去的那份文档的块和中文名;
* 别的文件打开它、按它的语言排视图数据(同它自己的预览:翻译插件、库规则、项目入口;规则查名字的定义、调用的种类不等),
* 中文名只认起了中文名的定义本身。语言没有代码阅读、解析失败、没有块盖住定义处时没有。
*/
private async peekAt(uri: Uri, offset: number): Promise<DefinitionPeek | undefined> {
try {
const target = await workspace.openTextDocument(uri)
const line = target.positionAt(offset).line
const path = workspace.asRelativePath(uri)
if (uri.toString() === this.document.uri.toString()) {
const { sent } = this
const block = sent?.version === target.version ? peekBlock(sent.blocks, offset, line) : undefined
return block && { path, line, block, names: namesOnCard(block, this.names.known(target.version)) }
}
const { message, displayNames } = await documentMessage(
{ languageId: target.languageId, version: target.version, getText: () => target.getText(), fileName: target.fileName, entryNames: this.owner.entries.namesOf(uri) },
this.registry,
this.runtime,
// 那个文件里规则出的错由它自己的预览记
() => {},
)
const block = message.error === undefined ? peekBlock(message.blocks, offset, line) : undefined
return block && { path, line, block, names: namesOnCard(block, definitionSiteNames(displayNames)) }
}
catch (err) {
logger.add({ level: 'warn', source: 'codeReader', message: `没能读出 ${uri.toString()} 里定义的那块:${err instanceof Error ? err.message : String(err)}` })
return undefined
}
}
/**
* 在预览所在的编辑器组打开别的文件,和普通编辑器里 ⌘ + 点击一样:语言有代码阅读的用预览打开,给了 spot 就滚过去、闪这处;
* 其余用 VS Code 默认的打开方式,给了 spot 就选中它(见 openingFor)。目标是否按「预览标签」打开听 VS Code 的设置,
@@ -732,45 +811,33 @@ class Preview {
private async openRepoLink(rel: string | undefined, symbol?: string): Promise<void> {
const link = rel ?? `[${symbol}]`
try {
if (rel === undefined) {
const target = symbol === undefined ? undefined : await this.resolveSymbolLink(symbol)
if (symbol !== undefined) {
const target = await this.symbolTarget(rel, symbol)
if (!target) {
this.markRepoLinkBroken(undefined, symbol)
this.markRepoLinkBroken(rel, symbol)
return
}
await this.showLinkTarget(target)
this.markRepoLinkOpened(undefined, symbol)
this.markRepoLinkOpened(rel, symbol)
return
}
if (rel === undefined)
return
const root = await repoRootOf(this.document.uri.fsPath)
const entry = root === undefined ? undefined : await resolveRepoEntry(root, rel)
if (!entry) {
this.markRepoLinkBroken(rel, symbol)
this.markRepoLinkBroken(rel)
return
}
const uri = Uri.file(entry.fsPath)
if (entry.isDirectory) {
if (symbol !== undefined) {
this.markRepoLinkBroken(rel, symbol)
return
}
if (!await this.owner.revealInTree(uri))
void window.showInformationMessage(`代码阅读:侧栏的树上没有 ${rel}`)
this.markRepoLinkOpened(rel)
return
}
if (symbol === undefined) {
await this.openElsewhere(uri)
this.markRepoLinkOpened(rel)
return
}
const found = await this.locateRepoSymbol(uri, symbol)
if (!found) {
this.markRepoLinkBroken(rel, symbol)
return
}
await this.showLinkTarget(found)
this.markRepoLinkOpened(rel, symbol)
await this.openElsewhere(uri)
this.markRepoLinkOpened(rel)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
@@ -779,6 +846,18 @@ class Preview {
}
}
/**
* 链接指的符号在哪:没有 path 的是不带路径的引用,按这份文件找(resolveSymbolLink);有 path 的在那个文件的文档符号里找(locateRepoSymbol)。
* 文件没有、是目录、符号没有时没有。点链接、悬停链接看定义卡片都按它找。
*/
private async symbolTarget(rel: string | undefined, symbol: string): Promise<LinkTarget | undefined> {
if (rel === undefined)
return this.resolveSymbolLink(symbol)
const root = await repoRootOf(this.document.uri.fsPath)
const entry = root === undefined ? undefined : await resolveRepoEntry(root, rel)
return entry && !entry.isDirectory ? this.locateRepoSymbol(Uri.file(entry.fsPath), symbol) : undefined
}
/** 用文档符号按名字找(`Agent`、`Agent.owner_id`);没有这个名字就没有,不打开到文件顶上。 */
private async locateRepoSymbol(uri: Uri, symbol: string): Promise<LinkTarget | undefined> {
const found = findRepoSymbol(await this.symbolNodes(uri.toString()), symbol)