✨ feat(vscode): 代码阅读预览按住 ⌘ / Ctrl 点名字跳到定义

This commit is contained in:
cruldra committed 2026-09-25 12:54:45 +08:00
1 parent e5db1a2e65
commit 4e146eb48f
20 files changed
+870 -60

No files matched your search

@@ -0,0 +1,44 @@
import type { DefinitionTarget } from './definitionAction'
import { describe, expect, it } from 'vitest'
import { definitionAction } from './definitionAction'
const CURRENT = 'file:///ws/app/routes.py'
function at(uri: string, line: number, supported = true, character = 4): DefinitionTarget {
return { uri, line, character, supported }
}
describe('⌘ + 点击查到定义之后怎么跳', () => {
it('没有定义:不动', () => {
expect(definitionAction([], CURRENT)).toEqual({ kind: 'none' })
})
it('定义在当前文件:预览原地滚过去', () => {
const target = at(CURRENT, 12)
expect(definitionAction([target], CURRENT)).toEqual({ kind: 'here', target })
})
it('另一个文件、语言有代码阅读:用预览打开', () => {
const target = at('file:///ws/luffy_agent/core/db.py', 30)
expect(definitionAction([target], CURRENT)).toEqual({ kind: 'preview', target })
})
it('另一个文件、语言没有代码阅读:用普通文本编辑器打开', () => {
const target = at('file:///ws/native/ext.c', 7, false)
expect(definitionAction([target], CURRENT)).toEqual({ kind: 'text', target })
})
it('几处定义:让用户挑,挑好的一处再按上面的规则跳', () => {
const stub = at('file:///ws/typings/db.pyi', 3)
const source = at('file:///ws/luffy_agent/core/db.py', 30)
expect(definitionAction([stub, source], CURRENT)).toEqual({ kind: 'pick', targets: [stub, source] })
expect(definitionAction([source], CURRENT)).toEqual({ kind: 'preview', target: source })
})
it('同一处报了几遍算一处;同一行不同列算两处', () => {
const target = at('file:///ws/luffy_agent/core/db.py', 30)
expect(definitionAction([target, { ...target }], CURRENT)).toEqual({ kind: 'preview', target })
const other = at(target.uri, 30, true, 20)
expect(definitionAction([target, other], CURRENT)).toEqual({ kind: 'pick', targets: [target, other] })
})
})
@@ -0,0 +1,31 @@
/** 一处定义。uri 是 Uri.toString(),用来和当前文件比、去重;supported:目标文件的语言有代码阅读。 */
export interface DefinitionTarget {
uri: string
line: number
character: number
supported: boolean
}
/**
* ⌘ / Ctrl + 点击查到定义之后怎么做:
* none 不动;pick 让用户从几处里挑一处,挑好的那处再交给 definitionAction 决定;
* here 定义就在当前文件,预览原地滚过去;preview 用代码阅读预览打开目标文件;text 目标语言没有代码阅读,用普通文本编辑器打开。
*/
export type DefinitionAction<T extends DefinitionTarget>
= | { kind: 'none' }
| { kind: 'pick', targets: T[] }
| { kind: 'here' | 'preview' | 'text', target: T }
/** 定义提供者查到的几处(可能重复)→ 怎么跳。current 是当前文件的 Uri.toString()。 */
export function definitionAction<T extends DefinitionTarget>(targets: readonly T[], current: string): DefinitionAction<T> {
const unique = targets.filter((target, i) =>
targets.findIndex(other => other.uri === target.uri && other.line === target.line && other.character === target.character) === i)
if (unique.length === 0)
return { kind: 'none' }
if (unique.length > 1)
return { kind: 'pick', targets: unique }
const [target] = unique
if (target.uri === current)
return { kind: 'here', target }
return { kind: target.supported ? 'preview' : 'text', target }
}
@@ -0,0 +1,68 @@
import type { Location, LocationLink, Position, Range, Uri } from 'vscode'
import type { DefinitionTarget } from './definitionAction'
import { commands, window, workspace } from 'vscode'
import { logger } from '../../logging/logger'
/** VS Code 查到的一处定义,加上打开目标文件才知道的信息。 */
export interface Definition extends DefinitionTarget {
location: { uri: Uri, range: Range }
/** 定义处在目标文件里的下标,预览靠它找到是哪块、哪一行属性 */
offset: number
/** 定义所在那一行的源码,列在挑选框里 */
lineText: string
}
/** 交给 VS Code 注册的定义提供者(Pylance、TypeScript 语言服务……),和普通编辑器里 ⌘ + 点击查的是同一套。 */
async function provided(uri: Uri, position: Position): Promise<(Location | LocationLink)[]> {
try {
return await commands.executeCommand<(Location | LocationLink)[] | undefined>('vscode.executeDefinitionProvider', uri, position) ?? []
}
catch (err) {
logger.add({ level: 'warn', source: 'codeReader', message: `查定义失败 ${uri.toString()}:${position.line + 1}:${err instanceof Error ? err.message : String(err)}` })
return []
}
}
export async function hasDefinition(uri: Uri, position: Position): Promise<boolean> {
return (await provided(uri, position)).length > 0
}
/** uri 里 position 处名字的所有定义;目标打不开成文本的(例如 Python 命名空间包指向的目录)略过。 */
export async function findDefinitions(uri: Uri, position: Position, supports: (languageId: string) => boolean): Promise<Definition[]> {
const found = await Promise.all((await provided(uri, position)).map(async (item): Promise<Definition[]> => {
const location = 'targetUri' in item
? { uri: item.targetUri, range: item.targetSelectionRange ?? item.targetRange }
: { uri: item.uri, range: item.range }
const { start } = location.range
try {
const document = await workspace.openTextDocument(location.uri)
return [{
uri: location.uri.toString(),
line: start.line,
character: start.character,
supported: supports(document.languageId),
location,
offset: document.offsetAt(start),
lineText: document.lineAt(start.line).text,
}]
}
catch (err) {
logger.add({ level: 'warn', source: 'codeReader', message: `定义所在的 ${location.uri.toString()} 打不开:${err instanceof Error ? err.message : String(err)}` })
return []
}
}))
return found.flat()
}
/** 几处定义里挑一处:每项是「相对路径:行号」,下面一行是那行源码。 */
export async function pickDefinition<T extends Definition>(definitions: T[]): Promise<T | undefined> {
const picked = await window.showQuickPick(
definitions.map(definition => ({
label: `${workspace.asRelativePath(definition.location.uri)}:${definition.line + 1}`,
detail: definition.lineText.trim(),
definition,
})),
{ placeHolder: '跳到哪一处定义', matchOnDetail: true },
)
return picked?.definition
}
+23 -3
View File
@@ -6,6 +6,15 @@ import type { BlockView } from '../blockView'
export interface TranslatorOption { id: string, label: string }
/**
* 让预览滚到包含 line 的块。definition 只在跳到定义时有:定义处那个名字在源码里的下标(Segment.offset),
* 预览展开把它折叠住的块和属性行,再让那块闪几下。
*/
export interface Reveal {
line: number
definition?: number
}
export interface DocumentMessage {
type: 'document'
/** TextDocument.version:webview 靠它判断是不是新一份文档 */
@@ -15,18 +24,29 @@ export interface DocumentMessage {
translators: TranslatorOption[]
translatorId: string
error?: string
/** 从文本编辑器切过来时它顶部那一行:新预览只在第一份 document 里带,webview 滚到包含这行的块 */
revealLine?: number
/** 新预览只在第一份 document 里带:从文本编辑器切过来时是它顶部那一行,跳到定义时是定义处 */
reveal?: Reveal
}
export type ExtensionToWebview = DocumentMessage
/**
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
* definitionProbed:probeDefinition 的回答,found 表示 VS Code 查得到定义
*/
export type ExtensionToWebview
= | DocumentMessage
| ({ type: 'reveal' } & Reveal)
| { type: 'definitionProbed', version: number, offset: number, found: boolean }
/**
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进)
* visibleLine:预览视口顶部那块的起始行,切回文本编辑器时把这行放到顶部
* probeDefinition:按住 ⌘ / Ctrl 悬停到带下标的名字上,问源码 offset 处有没有定义;version 是这份下标所属的文档版本
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
*/
export type WebviewToExtension
= | { type: 'ready' }
| { type: 'edit', startLine: number, endLine: number, text: string }
| { type: 'selectTranslator', id: string }
| { type: 'visibleLine', line: number }
| { type: 'probeDefinition', version: number, offset: number }
| { type: 'goToDefinition', version: number, offset: number }
+111 -21
View File
@@ -1,11 +1,14 @@
import type { CustomTextEditorProvider, Disposable, TextDocument, Uri, WebviewPanel } from 'vscode'
import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode'
import type { CodeReaderRegistry } from '../registry'
import type { TreeSitterRuntime } from '../runtime'
import type { ExtensionToWebview, WebviewToExtension } from './messages'
import { Range, window, workspace, WorkspaceEdit } from 'vscode'
import type { Definition } from './definitions'
import type { ExtensionToWebview, Reveal, WebviewToExtension } from './messages'
import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode'
import { logger } from '../../logging/logger'
import { webviewHtml, webviewUiRoot } from '../../webview/html'
import { blockReplacement } from '../editText'
import { definitionAction } from './definitionAction'
import { findDefinitions, hasDefinition, pickDefinition } from './definitions'
import { documentMessage } from './documentMessage'
export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview'
@@ -19,8 +22,8 @@ const REFRESH_DELAY_MS = 150
*/
export class PreviewEditorProvider implements CustomTextEditorProvider {
private readonly previews = new Set<Preview>()
/** 按文档 uri:从文本编辑器切过来时它顶部那一行,等着交给马上要打开的新预览 */
private readonly revealLines = new Map<string, number>()
/** 按文档 uri:等着交给马上要打开的新预览的位置(从文本编辑器切过来时它顶部那一行,或跳到的定义) */
private readonly reveals = new Map<string, Reveal>()
constructor(
private readonly extensionUri: Uri,
@@ -32,8 +35,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri)] }
panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html')
const key = document.uri.toString()
const preview = new Preview(document, panel, this.registry, this.runtime, this.revealLines.get(key), languageId => this.refresh(languageId))
this.revealLines.delete(key)
const preview = new Preview(document, panel, this, this.registry, this.runtime, this.reveals.get(key))
this.reveals.delete(key)
this.previews.add(preview)
panel.onDidDispose(() => {
this.previews.delete(preview)
@@ -49,17 +52,24 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
}
}
/** 用 open 打开 uri 的预览,新预览先滚到包含 line 的块。 */
async openAtLine(uri: Uri, line: number, open: () => Thenable<unknown>): Promise<void> {
/**
* 用 open 打开 uri 的预览并滚到 reveal:新建的预览随第一份文档带过去;
* open 只是切到了已经开着的预览(没有新建)时,直接发给这个预览,不重新打开。
*/
async openAt(uri: Uri, reveal: Reveal, open: () => Thenable<unknown>): Promise<void> {
const key = uri.toString()
this.revealLines.set(key, line)
this.reveals.set(key, reveal)
let claimed: boolean
try {
await open()
claimed = this.reveals.get(key) !== reveal
}
finally {
// 切换命令等新预览解析完才返回;切换没成时也不能把这行留给以后别处的打开
this.revealLines.delete(key)
// 打开命令等新预览解析完才返回;没打开成时也不能把这一处留给以后别处的打开
this.reveals.delete(key)
}
if (!claimed)
[...this.previews].find(p => p.uri.toString() === key && p.active)?.reveal(reveal)
}
/** uri 的预览视口顶部那块的起始行;几个编辑器组里都开着这个文件的预览时取活动的那个。 */
@@ -71,7 +81,7 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
}
class Preview {
/** webview 发过 ready 之后才发文档,避免脚本还没起来时消息丢掉 */
/** webview 发过 ready 之后才发消息;标签页切走时页面被销毁,切回来重新加载、再发 ready,这期间也不发 */
private ready = false
/** 解析是异步的:只发最后一次请求的结果,慢的旧结果不能盖掉新的 */
private latest = 0
@@ -83,14 +93,21 @@ class Preview {
constructor(
private readonly document: TextDocument,
private readonly panel: WebviewPanel,
private readonly owner: PreviewEditorProvider,
private readonly registry: CodeReaderRegistry,
private readonly runtime: TreeSitterRuntime,
/** 只随第一份真正发出去的文档走:之后页面因标签页切走重新加载,要按 webview 自己记的位置恢复 */
private revealLine: number | undefined,
private readonly onTranslatorSelected: (languageId: string) => void,
/**
* 随下一份真正发出去的文档走、只走一次:新预览第一份文档要滚到的位置,或页面正在重新加载时收不到的 reveal。
* 之后页面因标签页切走重新加载,按 webview 自己记的位置恢复
*/
private pendingReveal: Reveal | undefined,
) {
this.disposables = [
panel.webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)),
panel.onDidChangeViewState(() => {
if (!panel.visible)
this.ready = false
}),
workspace.onDidChangeTextDocument((e) => {
if (e.document === document && e.contentChanges.length > 0)
this.schedulePush()
@@ -116,11 +133,19 @@ class Preview {
return
const request = ++this.latest
const message: ExtensionToWebview = await documentMessage(this.document, this.registry, this.runtime)
if (request !== this.latest)
if (request !== this.latest || !this.ready)
return
const { revealLine } = this
this.revealLine = undefined
void this.panel.webview.postMessage(revealLine === undefined ? message : { ...message, revealLine })
const reveal = this.pendingReveal
this.pendingReveal = undefined
this.post(reveal === undefined ? message : { ...message, reveal })
}
/** 滚到 reveal:页面在就直接发,正在重新加载就等它发 ready 后随文档带过去。 */
reveal(reveal: Reveal): void {
if (this.ready)
this.post({ type: 'reveal', ...reveal })
else
this.pendingReveal = reveal
}
dispose(): void {
@@ -129,6 +154,10 @@ class Preview {
disposable.dispose()
}
private post(message: ExtensionToWebview): void {
void this.panel.webview.postMessage(message)
}
private schedulePush(): void {
clearTimeout(this.timer)
this.timer = setTimeout(() => void this.push(), REFRESH_DELAY_MS)
@@ -145,13 +174,74 @@ class Preview {
return
case 'selectTranslator':
await this.registry.selectTranslator(this.languageId, msg.id)
this.onTranslatorSelected(this.languageId)
this.owner.refresh(this.languageId)
return
case 'visibleLine':
this.visibleLine = msg.line
return
case 'probeDefinition':
await this.probeDefinition(msg.version, msg.offset)
return
case 'goToDefinition':
await this.goToDefinition(msg.version, msg.offset)
}
}
/** 回答 webview:offset 处的名字有没有定义。下标属于别的文档版本时不答,webview 收到新文档后会重新问 */
private async probeDefinition(version: number, offset: number): Promise<void> {
const { document } = this
if (version !== document.version)
return
const found = await hasDefinition(document.uri, document.positionAt(offset))
if (version === document.version && this.ready)
this.post({ type: 'definitionProbed', version, offset, found })
}
/** ⌘ / Ctrl + 点击:跳到 offset 处名字的定义,几处时先让用户挑。 */
private async goToDefinition(version: number, offset: number): Promise<void> {
const { document } = this
if (version !== document.version)
return
const current = document.uri.toString()
const supports = (languageId: string): boolean => this.registry.languageFor(languageId) !== undefined
let action = definitionAction(await findDefinitions(document.uri, document.positionAt(offset), supports), current)
if (action.kind === 'pick') {
const picked = await pickDefinition(action.targets)
action = definitionAction(picked ? [picked] : [], current)
}
if (action.kind === 'none' || action.kind === 'pick')
return
const { target } = action
const reveal: Reveal = { line: target.line, definition: target.offset }
if (action.kind === 'here') {
this.reveal(reveal)
return
}
try {
await this.openDefinition(action.kind, target, reveal)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'warn', source: 'codeReader', message: `没能打开定义 ${target.uri}:${message}` })
void window.showErrorMessage(`代码阅读:没能打开定义所在的 ${workspace.asRelativePath(target.location.uri)}`)
}
}
/**
* 和普通编辑器里 ⌘ + 点击一样:在同一个编辑器组打开目标(是否按「预览标签」打开听 VS Code 的设置),
* 并按 workbench.editor.enablePreviewFromCodeNavigation 把当前这个预览标签固定住,免得被目标替掉。
*/
private async openDefinition(kind: 'preview' | 'text', target: Definition, reveal: Reveal): Promise<void> {
if (this.panel.active && !workspace.getConfiguration('workbench.editor').get<boolean>('enablePreviewFromCodeNavigation', false))
await commands.executeCommand('workbench.action.keepEditor')
const options: TextDocumentShowOptions = { viewColumn: this.panel.viewColumn }
const { uri, range } = target.location
if (kind === 'text')
await window.showTextDocument(uri, { ...options, selection: range })
else
await this.owner.openAt(uri, reveal, () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options))
}
private async applyEdit(startLine: number, endLine: number, text: string): Promise<void> {
const { document } = this
const replacement = blockReplacement(line => document.lineAt(line).text, document.lineCount, startLine, endLine, text)
+1 -1
View File
@@ -57,7 +57,7 @@ export async function showPreview(previews: PreviewEditorProvider, args: unknown
const topLine = uri ? textEditorOf(uri)?.visibleRanges[0]?.start.line : undefined
const reopen = (): Thenable<unknown> => commands.executeCommand(REOPEN_WITH, PREVIEW_VIEW_TYPE, ...args)
if (uri && topLine !== undefined)
await previews.openAtLine(uri, topLine, reopen)
await previews.openAt(uri, { line: topLine }, reopen)
else
await reopen()
}