✨ feat(vscode): 代码阅读鼠标停在名字和注释链接上弹出定义所在那块的卡片,别的文件的也行
This commit is contained in:
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 }))
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user