🐛 fix(vscode): 代码阅读预览记住浏览位置,编辑 / 预览互切位置对齐

This commit is contained in:
cruldra committed 2026-09-25 03:21:14 +08:00
1 parent 6897954644
commit 37306bd23e
13 files changed
+409 -37

No files matched your search

+7 -1
View File
@@ -15,12 +15,18 @@ export interface DocumentMessage {
translators: TranslatorOption[]
translatorId: string
error?: string
/** 从文本编辑器切过来时它顶部那一行:新预览只在第一份 document 里带,webview 滚到包含这行的块 */
revealLine?: number
}
export type ExtensionToWebview = DocumentMessage
/** edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进) */
/**
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进)
* visibleLine:预览视口顶部那块的起始行,切回文本编辑器时把这行放到顶部
*/
export type WebviewToExtension
= | { type: 'ready' }
| { type: 'edit', startLine: number, endLine: number, text: string }
| { type: 'selectTranslator', id: string }
| { type: 'visibleLine', line: number }
+48 -6
View File
@@ -1,4 +1,4 @@
import type { CustomTextEditorProvider, Disposable, TextDocument, Uri, Webview, WebviewPanel } from 'vscode'
import type { CustomTextEditorProvider, Disposable, TextDocument, Uri, WebviewPanel } from 'vscode'
import type { CodeReaderRegistry } from '../registry'
import type { TreeSitterRuntime } from '../runtime'
import type { ExtensionToWebview, WebviewToExtension } from './messages'
@@ -19,6 +19,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>()
constructor(
private readonly extensionUri: Uri,
@@ -29,7 +31,9 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void {
panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri)] }
panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html')
const preview = new Preview(document, panel.webview, this.registry, this.runtime, languageId => this.refresh(languageId))
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)
this.previews.add(preview)
panel.onDidDispose(() => {
this.previews.delete(preview)
@@ -44,6 +48,26 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
void preview.push()
}
}
/** 用 open 打开 uri 的预览,新预览先滚到包含 line 的块。 */
async openAtLine(uri: Uri, line: number, open: () => Thenable<unknown>): Promise<void> {
const key = uri.toString()
this.revealLines.set(key, line)
try {
await open()
}
finally {
// 切换命令等新预览解析完才返回;切换没成时也不能把这行留给以后别处的打开
this.revealLines.delete(key)
}
}
/** uri 的预览视口顶部那块的起始行;几个编辑器组里都开着这个文件的预览时取活动的那个。 */
visibleLine(uri: Uri): number | undefined {
const key = uri.toString()
const previews = [...this.previews].filter(p => p.uri.toString() === key)
return (previews.find(p => p.active) ?? previews[0])?.visibleLine
}
}
class Preview {
@@ -53,16 +77,20 @@ class Preview {
private latest = 0
private timer: NodeJS.Timeout | undefined
private readonly disposables: Disposable[]
/** webview 报来的视口顶部那块的起始行 */
visibleLine: number | undefined
constructor(
private readonly document: TextDocument,
private readonly webview: Webview,
private readonly panel: WebviewPanel,
private readonly registry: CodeReaderRegistry,
private readonly runtime: TreeSitterRuntime,
/** 只随第一份真正发出去的文档走:之后页面因标签页切走重新加载,要按 webview 自己记的位置恢复 */
private revealLine: number | undefined,
private readonly onTranslatorSelected: (languageId: string) => void,
) {
this.disposables = [
webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)),
panel.webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)),
workspace.onDidChangeTextDocument((e) => {
if (e.document === document && e.contentChanges.length > 0)
this.schedulePush()
@@ -74,14 +102,25 @@ class Preview {
return this.document.languageId
}
get uri(): Uri {
return this.document.uri
}
get active(): boolean {
return this.panel.active
}
async push(): Promise<void> {
clearTimeout(this.timer)
if (!this.ready)
return
const request = ++this.latest
const message: ExtensionToWebview = await documentMessage(this.document, this.registry, this.runtime)
if (request === this.latest)
void this.webview.postMessage(message)
if (request !== this.latest)
return
const { revealLine } = this
this.revealLine = undefined
void this.panel.webview.postMessage(revealLine === undefined ? message : { ...message, revealLine })
}
dispose(): void {
@@ -107,6 +146,9 @@ class Preview {
case 'selectTranslator':
await this.registry.selectTranslator(this.languageId, msg.id)
this.onTranslatorSelected(this.languageId)
return
case 'visibleLine':
this.visibleLine = msg.line
}
}
@@ -0,0 +1,77 @@
import type { Disposable, TextEditor } from 'vscode'
import type { PreviewEditorProvider } from './previewEditor'
import { commands, Range, TabInputCustom, TextEditorRevealType, Uri, window } from 'vscode'
import { PREVIEW_VIEW_TYPE } from './previewEditor'
/** VS Code 自带普通文本编辑器的 editor id。 */
const TEXT_EDITOR = 'default'
/**
* VS Code 自带的「重新打开编辑器的方式」:在原标签页位置换成另一种编辑器,新编辑器打开后才返回。
* 原文件有未保存改动也不弹保存框——改动在 TextDocument 里,不跟着标签页走。
*/
const REOPEN_WITH = 'reopenActiveEditorWith'
/** 切换命令返回时文本编辑器还没同步到扩展这边的话,最多再等这么久 */
const EDITOR_WAIT_MS = 1000
function sameUri(a: Uri, b: Uri): boolean {
return a.toString() === b.toString()
}
/** 标题栏按钮传来的是 (文件 Uri, { groupId }),命令面板里调用时没有参数。 */
function resourceArg(args: unknown[]): Uri | undefined {
return args[0] instanceof Uri ? args[0] : undefined
}
function activeCustomEditorUri(): Uri | undefined {
const input = window.tabGroups.activeTabGroup.activeTab?.input
return input instanceof TabInputCustom ? input.uri : undefined
}
function textEditorOf(uri: Uri): TextEditor | undefined {
const active = window.activeTextEditor
if (active && sameUri(active.document.uri, uri))
return active
return window.visibleTextEditors.find(editor => sameUri(editor.document.uri, uri))
}
/** 编辑器变化和命令结果是分开通知到扩展这边的:没看到 uri 的文本编辑器就等它出现。 */
function waitForTextEditor(uri: Uri): Promise<TextEditor | undefined> {
const found = textEditorOf(uri)
if (found)
return Promise.resolve(found)
const disposables: Disposable[] = []
return new Promise<TextEditor | undefined>((resolve) => {
disposables.push(window.onDidChangeVisibleTextEditors(() => {
const editor = textEditorOf(uri)
if (editor)
resolve(editor)
}))
const timer = setTimeout(resolve, EDITOR_WAIT_MS, undefined)
disposables.push({ dispose: () => clearTimeout(timer) })
}).finally(() => disposables.forEach(d => d.dispose()))
}
/** 文本编辑器 → 预览(同一标签页原位替换):新预览先滚到文本编辑器顶部那一行所在的块。 */
export async function showPreview(previews: PreviewEditorProvider, args: unknown[]): Promise<void> {
const uri = resourceArg(args) ?? window.activeTextEditor?.document.uri
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)
else
await reopen()
}
/** 预览 → 文本编辑器(同一标签页原位替换):把预览视口顶部那块的起始行放到文本编辑器顶部。 */
export async function showSource(previews: PreviewEditorProvider, args: unknown[]): Promise<void> {
const uri = resourceArg(args) ?? activeCustomEditorUri()
const line = uri ? previews.visibleLine(uri) : undefined
await commands.executeCommand(REOPEN_WITH, TEXT_EDITOR, ...args)
if (!uri || line === undefined)
return
const editor = await waitForTextEditor(uri)
if (!editor)
return
const target = Math.min(line, editor.document.lineCount - 1)
editor.revealRange(new Range(target, 0, target, 0), TextEditorRevealType.AtTop)
}
+3 -11
View File
@@ -2,6 +2,7 @@ import type { ExtensionContext, Uri } from 'vscode'
import type { CodeReaderApi } from './contract'
import { commands, window, workspace } from 'vscode'
import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor'
import { showPreview, showSource } from './editor/switchEditor'
import { FileTreeProvider } from './explorer/fileTree'
import { createPythonAdapter } from './languages/python'
import { createTypeScriptAdapter } from './languages/typescript'
@@ -14,13 +15,6 @@ import { typescriptZhTranslator } from './translators/typescriptZh'
const LANGUAGE_IDS_CONTEXT = 'superpowers.codeReader.languageIds'
const FILES_VIEW = 'superpowers.codeReader.files'
const OPEN_COMMAND = 'superpowers.codeReader.open'
/** VS Code 自带普通文本编辑器的 editor id。 */
const TEXT_EDITOR = 'default'
/**
* VS Code 自带的「重新打开编辑器的方式」:在原标签页位置换成另一种编辑器。
* 原文件有未保存改动也不弹保存框——改动在 TextDocument 里,不跟着标签页走。
*/
const REOPEN_WITH = 'reopenActiveEditorWith'
/** 按 VS Code 的判断(含 files.associations)取文件的 languageId;图片等二进制文件打不开成文本,返回 undefined。 */
async function languageOf(uri: Uri): Promise<string | undefined> {
@@ -63,10 +57,8 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
commands.registerCommand('superpowers.codeReader.refresh', () => files.refresh()),
commands.registerCommand(OPEN_COMMAND, (uri: Uri) => openFile(uri, registry)),
// 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签
commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) =>
commands.executeCommand(REOPEN_WITH, PREVIEW_VIEW_TYPE, ...args)),
commands.registerCommand('superpowers.codeReader.showSource', (...args: unknown[]) =>
commands.executeCommand(REOPEN_WITH, TEXT_EDITOR, ...args)),
commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => showPreview(previews, args)),
commands.registerCommand('superpowers.codeReader.showSource', (...args: unknown[]) => showSource(previews, args)),
)
const api: CodeReaderApi = registry