From 5b42231a7791dc89959f0b8126cbf09e91d5f139 Mon Sep 17 00:00:00 2001 From: 7mile Date: Tue, 25 Mar 2025 13:40:32 +0800 Subject: [PATCH] feat: eject preview panel to browser (#1575) * feat: eject preview panel to browser * refactor global state * fix typo for kind * await kill preview * add isNotPrimary launch option * fix isNotPrimary for eject * fix async syntax error in compat --- editors/vscode/package.json | 15 +++ editors/vscode/src/features/preview-compat.ts | 8 +- editors/vscode/src/features/preview.ts | 100 ++++++++++++++++-- editors/vscode/src/state.ts | 7 ++ locales/tinymist-vscode.toml | 38 +++++++ 5 files changed, 155 insertions(+), 13 deletions(-) diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 27d2b67b0..5cf94e94f 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -1204,6 +1204,12 @@ "icon": "$(open-preview)", "when": "resourceLangId == typst && editorTextFocus" }, + { + "command": "typst-preview.eject", + "title": "%extension.tinymist.command.typst-preview.eject%", + "description": "Eject the preview panel to browser to get better performance", + "icon": "$(link-external)" + }, { "command": "typst-preview.sync", "title": "%extension.tinymist.command.typst-preview.sync%", @@ -1255,6 +1261,10 @@ { "command": "tinymist.restartServer", "when": "ext.tinymistActivated" + }, + { + "command": "typst-preview.eject", + "when": "activeWebviewPanelId == 'typst-preview'" } ], "editor/title": [ @@ -1267,6 +1277,11 @@ "command": "typst-preview.preview", "when": "resourceLangId == typst", "group": "navigation" + }, + { + "command": "typst-preview.eject", + "when": "activeWebviewPanelId == 'typst-preview'", + "group": "navigation" } ], "editor/title/run": [ diff --git a/editors/vscode/src/features/preview-compat.ts b/editors/vscode/src/features/preview-compat.ts index 625c487f7..33fac54e3 100644 --- a/editors/vscode/src/features/preview-compat.ts +++ b/editors/vscode/src/features/preview-compat.ts @@ -329,10 +329,10 @@ export const launchPreviewCompat = async (task: LaunchInBrowserTask | LaunchInWe activeEditor, dataPlanePort, webviewPanel, - panelDispose() { + async panelDispose() { activeTask.delete(bindDocument); serverProcess.kill(); - contentPreviewProvider.then((p) => p.postDeactivate(connectUrl)); + await contentPreviewProvider.then((p) => p.postDeactivate(connectUrl)); }, }); } @@ -678,3 +678,7 @@ export const revealDocumentCompat = async (args: any) => { }); } }; + +export const ejectPreviewPanelCompat = async () => { + vscode.window.showWarningMessage("Eject is not supported in compat mode"); +} diff --git a/editors/vscode/src/features/preview.ts b/editors/vscode/src/features/preview.ts index e1ccde2d3..e21fb65a4 100644 --- a/editors/vscode/src/features/preview.ts +++ b/editors/vscode/src/features/preview.ts @@ -17,6 +17,7 @@ import { LaunchInWebViewTask, LaunchInBrowserTask, getPreviewHtml, + ejectPreviewPanelCompat, } from "./preview-compat"; import { PanelScrollOrCursorMoveRequest, @@ -26,12 +27,21 @@ import { } from "../lsp"; import { l10nMsg } from "../l10n"; import { IContext } from "../context"; +import { extensionState } from "../state"; /** * The launch preview implementation which depends on `isCompat` of previewActivate. */ let launchImpl: typeof launchPreviewLsp; +/** + * The state corresponding to the focusing preview panel. + */ +export interface PreviewPanelContext { + panel: vscode.WebviewPanel; + state: PersistPreviewState; +} + /** * Preload the preview resources to reduce the latency of the first preview. * @param context The extension context. @@ -94,6 +104,7 @@ export function previewActivate(context: vscode.ExtensionContext, isCompat: bool vscode.commands.registerCommand("typst-preview.browser", launch("browser", "doc")), vscode.commands.registerCommand("typst-preview.preview-slide", launch("webview", "slide")), vscode.commands.registerCommand("typst-preview.browser-slide", launch("browser", "slide")), + vscode.commands.registerCommand("typst-preview.eject", isCompat ? ejectPreviewPanelCompat : ejectPreviewPanelLsp), vscode.commands.registerCommand("tinymist.previewDev", launchDevPreview), vscode.commands.registerCommand( "typst-preview.revealDocument", @@ -142,7 +153,7 @@ export function previewActivate(context: vscode.ExtensionContext, isCompat: bool interface LaunchOpts { isBrowsing?: boolean; isDev?: boolean; - // isDev = false + isNotPrimary?: boolean; } /** @@ -168,11 +179,57 @@ export function previewActivate(context: vscode.ExtensionContext, isCompat: bool mode, isBrowsing: opts?.isBrowsing || false, isDev: opts?.isDev || false, + isNotPrimary: opts?.isNotPrimary || false, }).catch((e) => { vscode.window.showErrorMessage(`failed to launch preview: ${e}`); }); }; } + + async function launchForURI(uri: vscode.Uri, kind: "browser" | "webview", mode: "doc" | "slide", opts?: LaunchOpts) { + const doc = + vscode.workspace.textDocuments.find((doc) => { + return doc.uri.toString() === uri.toString(); + }) || (await vscode.workspace.openTextDocument(uri)); + const editor = await vscode.window.showTextDocument(doc, getSensibleTextEditorColumn(), true); + + const bindDocument = editor.document; + const isBrowsing = opts?.isBrowsing; + const isDev = opts?.isDev; + const isNotPrimary = opts?.isNotPrimary; + + await launchImpl({ + kind, + context, + editor, + bindDocument, + mode, + isBrowsing, + isDev, + isNotPrimary, + }); + } + + /** + * Ejects the preview panel to the external browser. + */ + async function ejectPreviewPanelLsp() { + const focusingContext = extensionState.getFocusingPreviewPanelContext(); + if (!focusingContext) { + vscode.window.showWarningMessage("No active preview panel"); + return; + } + const { panel, state } = focusingContext; + + // Close the preview panel, basically kill the previous preview task. + panel.dispose(); + + await launchForURI(vscode.Uri.parse(state.uri), "browser", state.mode, { + isBrowsing: state.isBrowsing, + isDev: state.isDev, + isNotPrimary: state.isNotPrimary, + }); + } } export function previewDeactivate() { @@ -220,7 +277,7 @@ interface OpenPreviewInWebViewArgs { /** * Additional cleanup routine when the webview panel is disposed. */ - panelDispose: () => void; + panelDispose: () => Promise; } /** @@ -253,20 +310,38 @@ export async function openPreviewInWebView({ }, ); + const previewState: PersistPreviewState = { + mode: task.mode, + isNotPrimary: !!task.isNotPrimary, + isBrowsing: !!task.isBrowsing, + isDev: !!task.isDev, + uri: activeEditor.document.uri.toString(), + }; + + const updateActivePanel =() => { + if (panel.active) { + extensionState.mut.focusingPreviewPanelContext = { + panel, + state: previewState, + }; + } + }; + + // NOTE: To avoid missing the auto revealing of webview initialization. + updateActivePanel(); + panel.onDidChangeViewState(updateActivePanel); + // todo: bind Document.onDidDispose, but we did not find a similar way. panel.onDidDispose(async () => { - panelDispose(); + if (extensionState.getFocusingPreviewPanelContext()?.panel === panel) { + extensionState.mut.focusingPreviewPanelContext = undefined; + } + await panelDispose(); console.log("killed preview services"); }); // Determines arguments for the preview HTML. const previewMode = task.mode === "doc" ? "Doc" : "Slide"; - const previewState: PersistPreviewState = { - mode: task.mode, - isNotPrimary: !!task.isNotPrimary, - isBrowsing: !!task.isBrowsing, - uri: activeEditor.document.uri.toString(), - }; const previewStateEncoded = Buffer.from(JSON.stringify(previewState), "utf-8").toString("base64"); // Substitutes arguments in the HTML content. @@ -359,9 +434,9 @@ async function launchPreviewLsp(task: LaunchInBrowserTask | LaunchInWebViewTask) activeEditor: editor, dataPlanePort, webviewPanel, - panelDispose() { + async panelDispose() { disposes.dispose(); - tinymist.killPreview(taskId); + await tinymist.killPreview(taskId); }, }); break; @@ -754,6 +829,7 @@ interface PersistPreviewState { mode: "doc" | "slide"; isNotPrimary: boolean; isBrowsing: boolean; + isDev: boolean; uri: string; } @@ -785,6 +861,7 @@ class TypstPreviewSerializer implements vscode.WebviewPanelSerializer