'use strict'; import { TextDecoder } from 'util'; import { commands, ConfigurationChangeEvent, ConfigurationTarget, Disposable, Uri, ViewColumn, Webview, WebviewPanel, WebviewPanelOnDidChangeViewStateEvent, window, workspace, } from 'vscode'; import { configuration } from '../configuration'; import { Container } from '../container'; import { Logger } from '../logger'; import { DidChangeConfigurationNotificationType, IpcMessage, IpcNotificationParamsOf, IpcNotificationType, onIpcCommand, UpdateConfigurationCommandType, } from './protocol'; import { Commands } from '../commands'; let ipcSequence = 0; function nextIpcId() { if (ipcSequence === Number.MAX_SAFE_INTEGER) { ipcSequence = 1; } else { ipcSequence++; } return `host:${ipcSequence}`; } const emptyCommands: Disposable[] = [ { dispose: function () { /* noop */ }, }, ]; export abstract class WebviewBase implements Disposable { protected disposable: Disposable; private _disposablePanel: Disposable | undefined; private _panel: WebviewPanel | undefined; constructor(showCommand: Commands, private readonly _column?: ViewColumn) { this.disposable = Disposable.from( configuration.onDidChange(this.onConfigurationChanged, this), commands.registerCommand(showCommand, this.onShowCommand, this), ); } abstract get filename(): string; abstract get id(): string; abstract get title(): string; registerCommands(): Disposable[] { return emptyCommands; } renderHead?(): string | Promise; renderBody?(): string | Promise; renderEndOfBody?(): string | Promise; dispose() { this.disposable.dispose(); this._disposablePanel?.dispose(); } protected onShowCommand() { void this.show(this._column); } private onConfigurationChanged(_e: ConfigurationChangeEvent) { void this.notifyDidChangeConfiguration(); } private onPanelDisposed() { this._disposablePanel?.dispose(); this._panel = undefined; } private onViewStateChanged(e: WebviewPanelOnDidChangeViewStateEvent) { Logger.log( `Webview(${this.id}).onViewStateChanged`, `active=${e.webviewPanel.active}, visible=${e.webviewPanel.visible}`, ); // Anytime the webview becomes active, make sure it has the most up-to-date config if (e.webviewPanel.active) { void this.notifyDidChangeConfiguration(); } } protected onMessageReceived(e: IpcMessage) { switch (e.method) { case UpdateConfigurationCommandType.method: onIpcCommand(UpdateConfigurationCommandType, e, async params => { const target = params.scope === 'workspace' ? ConfigurationTarget.Workspace : ConfigurationTarget.Global; for (const key in params.changes) { const inspect = configuration.inspect(key as any)!; let value = params.changes[key]; if (value != null) { if (params.scope === 'workspace') { if (value === inspect.workspaceValue) continue; } else { if (value === inspect.globalValue && value !== inspect.defaultValue) continue; if (value === inspect.defaultValue) { value = undefined; } } } void (await configuration.update(key as any, value, target)); } for (const key of params.removes) { void (await configuration.update(key as any, undefined, target)); } }); break; default: break; } } private onMessageReceivedCore(e: IpcMessage) { if (e == null) return; Logger.log(`Webview(${this.id}).onMessageReceived: method=${e.method}, data=${JSON.stringify(e)}`); this.onMessageReceived(e); } get visible() { return this._panel?.visible ?? false; } hide() { this._panel?.dispose(); } setTitle(title: string) { if (this._panel == null) return; this._panel.title = title; } async show(column: ViewColumn = ViewColumn.Beside): Promise { if (this._panel == null) { this._panel = window.createWebviewPanel( this.id, this.title, { viewColumn: column, preserveFocus: false }, { retainContextWhenHidden: true, enableFindWidget: true, enableCommandUris: true, enableScripts: true, }, ); this._panel.iconPath = Uri.file(Container.context.asAbsolutePath('images/gitlens-icon.png')); this._disposablePanel = Disposable.from( this._panel, this._panel.onDidDispose(this.onPanelDisposed, this), this._panel.onDidChangeViewState(this.onViewStateChanged, this), this._panel.webview.onDidReceiveMessage(this.onMessageReceivedCore, this), ...this.registerCommands(), ); this._panel.webview.html = await this.getHtml(this._panel.webview); } else { const html = await this.getHtml(this._panel.webview); // Reset the html to get the webview to reload this._panel.webview.html = ''; this._panel.webview.html = html; this._panel.reveal(this._panel.viewColumn ?? ViewColumn.Active, false); } } private async getHtml(webview: Webview): Promise { const uri = Uri.joinPath(Container.context.extensionUri, 'dist', 'webviews', this.filename); const content = new TextDecoder('utf8').decode(await workspace.fs.readFile(uri)); let html = content .replace(/#{cspSource}/g, webview.cspSource) .replace(/#{root}/g, webview.asWebviewUri(Container.context.extensionUri).toString()); if (this.renderHead != null) { html = html.replace(/#{head}/i, await this.renderHead()); } if (this.renderBody != null) { html = html.replace(/#{body}/i, await this.renderBody()); } if (this.renderEndOfBody != null) { html = html.replace(/#{endOfBody}/i, await this.renderEndOfBody()); } return html; } protected notify(type: NT, params: IpcNotificationParamsOf): Thenable { return this.postMessage({ id: nextIpcId(), method: type.method, params: params }); } private notifyDidChangeConfiguration() { // Make sure to get the raw config, not from the container which has the modes mixed in return this.notify(DidChangeConfigurationNotificationType, { config: configuration.get() }); } private postMessage(message: IpcMessage) { if (this._panel == null) return Promise.resolve(false); return this._panel.webview.postMessage(message); } }