✨ feat(vscode): 代码阅读预览按住 ⌘ / Ctrl 点名字跳到定义
This commit is contained in:
1 parent
e5db1a2e65
commit
4e146eb48f
20 files changed
+870
-60
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user