diff --git a/package.json b/package.json index e1beb37d..e41f3da8 100644 --- a/package.json +++ b/package.json @@ -169,7 +169,8 @@ "onDebug", "onDebugDynamicConfigurations:brightscript", "onTaskType:brightscript", - "onWebviewPanel:rceVideoView" + "onWebviewPanel:rceVideoView", + "onWebviewPanel:sceneGraphInspectorView" ], "contributes": { "taskDefinitions": [ diff --git a/src/mockVscode.spec.ts b/src/mockVscode.spec.ts index ca9d89de..f16eb2f7 100644 --- a/src/mockVscode.spec.ts +++ b/src/mockVscode.spec.ts @@ -234,6 +234,7 @@ export let vscode = { }, window: { registerCustomEditorProvider: () => { }, + registerWebviewPanelSerializer: () => { }, withProgress: (options, action) => { return action(); }, diff --git a/src/viewProviders/BaseWebviewViewProvider.ts b/src/viewProviders/BaseWebviewViewProvider.ts index b7b43900..1c8e91cb 100644 --- a/src/viewProviders/BaseWebviewViewProvider.ts +++ b/src/viewProviders/BaseWebviewViewProvider.ts @@ -111,11 +111,21 @@ export abstract class BaseWebviewViewProvider implements vscode.WebviewViewProvi } protected postMessage(message) { - this.view?.webview.postMessage(message).then(null, (reason) => { - console.log('postMessage failed: ', reason); - }); + // resolve `.webview` lazily inside the guard: accessing it on a DISPOSED view or + // panel throws synchronously ("Webview is disposed") — e.g. after the pop-out + // editor is closed, or while the sidebar view is hidden behind the panel. + this.tryPostMessage(() => this.view?.webview, message); + this.tryPostMessage(() => this.panel?.webview, message); + } - this.panel?.webview.postMessage(message).then(null, (reason) => { + private tryPostMessage(resolveWebview: () => vscode.Webview | undefined, message) { + let webview: vscode.Webview | undefined; + try { + webview = resolveWebview(); + } catch { + return; // the view/panel was disposed + } + webview?.postMessage(message).then(null, (reason) => { console.log('postMessage failed: ', reason); }); } @@ -207,7 +217,7 @@ export abstract class BaseWebviewViewProvider implements vscode.WebviewViewProvi return []; } - protected async getHtmlForWebview() { + protected async getHtmlForWebview(webviewContext: 'sidebar' | 'panel' = 'sidebar') { try { let watcher; try { @@ -224,22 +234,30 @@ export abstract class BaseWebviewViewProvider implements vscode.WebviewViewProvi if ( events.find(x => (x.type === 'create' || x.type === 'update') && x.path?.toLowerCase()?.endsWith('index.html')) ) { - this.view.webview.html = ''; - this.view.webview.html = this.getIndexHtml(); + const webview = (this.view ?? this.panel)?.webview; + if (webview) { + webview.html = ''; + // the sidebar view takes precedence in (this.view ?? this.panel), + // so reload with the matching context + webview.html = this.getIndexHtml(this.view ? 'sidebar' : 'panel'); + } } }); } } catch (e) { console.error(e); } - return this.getIndexHtml(); + return this.getIndexHtml(webviewContext); } - private getIndexHtml() { + private getIndexHtml(webviewContext: 'sidebar' | 'panel' = 'sidebar') { return buildWebviewIndexHtml({ webview: this.view?.webview ?? this.panel?.webview, webviewBasePath: this.webviewBasePath, viewName: this.id, + //lets a view persist/behave differently in the sidebar vs the popped-out + //editor panel (the same view component runs in both) + webviewContext: webviewContext, additionalScriptContents: this.additionalScriptContents() }); } @@ -277,7 +295,7 @@ export abstract class BaseWebviewViewProvider implements vscode.WebviewViewProvi vscode.Uri.file(this.webviewBasePath) ] }; - webview.html = await this.getHtmlForWebview(); + webview.html = await this.getHtmlForWebview('sidebar'); } protected async createOrRevealWebviewPanel() { @@ -297,24 +315,68 @@ export abstract class BaseWebviewViewProvider implements vscode.WebviewViewProvi } if (createPanel) { - this.panel = vscode.window.createWebviewPanel( + const panel = vscode.window.createWebviewPanel( this.id, await this.getViewNameById(this.id), vscode.ViewColumn.Active, { // Enable javascript in the webview enableScripts: true, + retainContextWhenHidden: this.retainPanelContextWhenHidden, localResourceRoots: [ vscode.Uri.file(this.webviewBasePath) ] } ); + await this.attachPanel(panel); + } + } + + /** Subclasses may set this to keep the panel's webview state alive while its tab + * is in the background (more memory, but no reset on every tab switch). */ + protected retainPanelContextWhenHidden = false; + + /** Adopt an editor panel (newly created, or restored after a window reload): + * wire messaging, (re-)assert webview options, and set the html. */ + private async attachPanel(panel: vscode.WebviewPanel) { + this.panel = panel; + // when the pop-out editor is closed, drop the reference so postMessage (and the + // dev-watcher reload) stop targeting the disposed panel + panel.onDidDispose?.(() => { + if (this.panel === panel) { + this.panel = undefined; + } + }); + this.setupViewMessageObserver(panel.webview); + panel.webview.options = { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.file(this.webviewBasePath) + ] + }; + panel.webview.html = await this.getHtmlForWebview('panel'); + this.onPanelAttached(panel); + } - this.setupViewMessageObserver(this.panel.webview); + /** Called exactly once per editor panel, when it's created or restored after a + * window reload (not on reveal) — e.g. to track the panel's lifetime. */ + protected onPanelAttached(panel: vscode.WebviewPanel) { } - const html = await this.getHtmlForWebview(); - this.panel.webview.html = html; - } + /** + * Restore this provider's editor panel across window reloads — without a + * registered serializer VS Code destroys the panel (the tab is forgotten). + * Call from the subclass constructor (i.e. during activation) and pair it with + * an `onWebviewPanel:` activation event in package.json so the extension + * wakes up to revive the panel. + */ + protected enablePanelRestore() { + this.extensionContext.subscriptions.push( + vscode.window.registerWebviewPanelSerializer(this.id, { + deserializeWebviewPanel: async (panel: vscode.WebviewPanel) => { + await this.attachPanel(panel); + } + }) + ); } private async getViewNameById(viewId) { diff --git a/src/viewProviders/SceneGraphInspectorViewProvider.ts b/src/viewProviders/SceneGraphInspectorViewProvider.ts index 18c37bc5..742fffda 100644 --- a/src/viewProviders/SceneGraphInspectorViewProvider.ts +++ b/src/viewProviders/SceneGraphInspectorViewProvider.ts @@ -12,5 +12,8 @@ export class SceneGraphInspectorViewProvider extends BaseRdbViewProvider { this.registerCommand(VscodeCommand.openSceneGraphInspectorInPanel, async () => { await this.createOrRevealWebviewPanel(); }); + // bring the panel back after a window reload (paired with the + // onWebviewPanel:sceneGraphInspectorView activation event) + this.enablePanelRestore(); } } diff --git a/src/viewProviders/webviewHtml.ts b/src/viewProviders/webviewHtml.ts index 02e65157..a6f18c45 100644 --- a/src/viewProviders/webviewHtml.ts +++ b/src/viewProviders/webviewHtml.ts @@ -23,6 +23,7 @@ export function buildWebviewIndexHtml(options: BuildWebviewIndexHtmlOptions): st //the data that will be replaced in the index.html const data = { viewName: options.viewName, + webviewContext: options.webviewContext ?? 'sidebar', baseHref: `${baseUri}/`, additionalScriptContents: (options.additionalScriptContents ?? []).join('\n ') }; @@ -50,6 +51,8 @@ export interface BuildWebviewIndexHtmlOptions { webviewBasePath: string; /** The client-side view name the bundle should boot (a key in webviews/src/main.ts's views map) */ viewName: string; + /** Where the view is rendering, so it can persist/behave differently in the sidebar vs a popped-out editor panel */ + webviewContext?: 'sidebar' | 'panel'; /** Extra script lines injected into the page */ additionalScriptContents?: string[]; } diff --git a/webviews/index.html b/webviews/index.html index e7858035..88e18fe4 100644 --- a/webviews/index.html +++ b/webviews/index.html @@ -7,6 +7,9 @@