From 15c06a87f37d6858a5ced88866c4f6b973720ef4 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Mon, 28 Oct 2019 07:54:50 +0000 Subject: [PATCH 01/21] [webview] delete 'vscode.previewHtml' command This command is removed from VS Code because of security issues https://code.visualstudio.com/updates/v1_33#_removing-the-vscodepreviewhtml-command. Extensions should not rely on it anymore. Signed-off-by: Anton Kosyakov --- .../plugin-vscode-commands-contribution.ts | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts b/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts index 94661977e454e..865f3b1c41975 100644 --- a/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts +++ b/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts @@ -24,7 +24,6 @@ import { EditorManager } from '@theia/editor/lib/browser'; import { TextDocumentShowOptions } from '@theia/plugin-ext/lib/common/plugin-api-rpc-model'; import { DocumentsMainImpl } from '@theia/plugin-ext/lib/main/browser/documents-main'; import { createUntitledResource } from '@theia/plugin-ext/lib/main/browser/editor/untitled-resource'; -import { WebviewWidget } from '@theia/plugin-ext/lib/main/browser/webview/webview'; import { fromViewColumn, toDocumentSymbol } from '@theia/plugin-ext/lib/plugin/type-converters'; import { ViewColumn } from '@theia/plugin-ext/lib/plugin/types-impl'; import { WorkspaceCommands } from '@theia/workspace/lib/browser'; @@ -44,10 +43,6 @@ export namespace VscodeCommands { export const SET_CONTEXT: Command = { id: 'setContext' }; - - export const PREVIEW_HTML: Command = { - id: 'vscode.previewHtml' - }; } @injectable() @@ -123,26 +118,6 @@ export class PluginVscodeCommandsContribution implements CommandContribution { this.contextKeyService.createKey(String(contextKey), contextValue); } }); - commands.registerCommand(VscodeCommands.PREVIEW_HTML, { - isVisible: () => false, - // tslint:disable-next-line: no-any - execute: async (resource: URI, position?: any, label?: string, options?: any) => { - label = label || resource.fsPath; - const view = new WebviewWidget(label, { allowScripts: true }, {}, this.mouseTracker); - const res = await this.resources(new TheiaURI(resource)); - const str = await res.readContents(); - const html = this.getHtml(str); - this.shell.addWidget(view, { area: 'main', mode: 'split-right' }); - this.shell.activateWidget(view.id); - view.setHTML(html); - - const editorWidget = await this.editorManager.getOrCreateByUri(new TheiaURI(resource)); - editorWidget.editor.onDocumentContentChanged(listener => { - view.setHTML(this.getHtml(editorWidget.editor.document.getText())); - }); - - } - }); // https://code.visualstudio.com/docs/getstarted/keybindings#_navigation /* @@ -337,8 +312,4 @@ export class PluginVscodeCommandsContribution implements CommandContribution { // see https://github.com/microsoft/vscode/blob/master/src/vs/workbench/api/common/extHostApiCommands.ts } - private getHtml(body: String): string { - return `${body}`; - } - } From 95b9691c634139a03ffa60015209209c8b48fb4f Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Mon, 28 Oct 2019 07:56:32 +0000 Subject: [PATCH 02/21] [webview] fix #5648: integrate webviews with the application shell It requires to preserve webviews on reload and reconnection. Signed-off-by: Anton Kosyakov --- .../browser/plugin-ext-frontend-module.ts | 11 ++ .../src/main/browser/webview/webview.ts | 46 ++++++-- .../src/main/browser/webviews-main.ts | 106 +++++++++--------- packages/plugin-ext/src/plugin/webviews.ts | 5 +- 4 files changed, 102 insertions(+), 66 deletions(-) diff --git a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts index 5c6dfc110ea9d..71c295e5f52fe 100644 --- a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts +++ b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts @@ -63,6 +63,7 @@ import { LanguagesMainFactory, OutputChannelRegistryFactory } from '../../common import { LanguagesMainImpl } from './languages-main'; import { OutputChannelRegistryMainImpl } from './output-channel-registry-main'; import { InPluginFileSystemWatcherManager } from './in-plugin-filesystem-watcher-manager'; +import { WebviewWidget, WebviewWidgetIdentifier } from './webview/webview'; export default new ContainerModule((bind, unbind, isBound, rebind) => { @@ -146,6 +147,16 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { } })).inSingletonScope(); + bind(WebviewWidget).toSelf(); + bind(WidgetFactory).toDynamicValue(({ container }) => ({ + id: WebviewWidget.FACTORY_ID, + createWidget: (identifier: WebviewWidgetIdentifier) => { + const child = container.createChild(); + child.bind(WebviewWidgetIdentifier).toConstantValue(identifier); + return child.get(WebviewWidget); + } + })).inSingletonScope(); + bind(PluginViewWidget).toSelf(); bind(WidgetFactory).toDynamicValue(({ container }) => ({ id: PLUGIN_VIEW_FACTORY_ID, diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index 704b9e5d4c682..93bc5a065f788 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -13,9 +13,11 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ + +import { injectable, inject, postConstruct } from 'inversify'; import { BaseWidget, Message } from '@theia/core/lib/browser/widgets/widget'; -import { IdGenerator } from '../../../common/id-generator'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; +// TODO: get rid of dependencies to the mini browser import { MiniBrowserContentStyle } from '@theia/mini-browser/lib/browser/mini-browser-content-style'; import { ApplicationShellMouseTracker } from '@theia/core/lib/browser/shell/application-shell-mouse-tracker'; @@ -31,8 +33,16 @@ export interface WebviewEvents { onLoad?(contentDocument: Document): void; } +@injectable() +export class WebviewWidgetIdentifier { + id: string; +} + +@injectable() export class WebviewWidget extends BaseWidget { - private static readonly ID = new IdGenerator('webview-widget-'); + + static FACTORY_ID = 'plugin-webview'; + private iframe: HTMLIFrameElement; private state: { [key: string]: any } | undefined = undefined; private loadTimeout: number | undefined; @@ -42,15 +52,19 @@ export class WebviewWidget extends BaseWidget { // XXX This is a hack to be able to tack the mouse events when drag and dropping the widgets. On `mousedown` we put a transparent div over the `iframe` to avoid losing the mouse tacking. protected readonly transparentOverlay: HTMLElement; - constructor(title: string, - private options: WebviewWidgetOptions, - private eventDelegate: WebviewEvents, - protected readonly mouseTracker: ApplicationShellMouseTracker) { + @inject(WebviewWidgetIdentifier) + protected readonly identifier: WebviewWidgetIdentifier; + + @inject(ApplicationShellMouseTracker) + protected readonly mouseTracker: ApplicationShellMouseTracker; + + private options: WebviewWidgetOptions = {}; + eventDelegate: WebviewEvents = {}; + + constructor() { super(); this.node.tabIndex = 0; - this.id = WebviewWidget.ID.nextId(); this.title.closable = true; - this.title.label = title; this.addClass(WebviewWidget.Styles.WEBVIEW); this.scrollY = 0; @@ -59,18 +73,23 @@ export class WebviewWidget extends BaseWidget { this.transparentOverlay.style.display = 'none'; this.node.appendChild(this.transparentOverlay); - this.toDispose.push(this.mouseTracker.onMousedown(e => { + this.toDispose.push(this.mouseTracker.onMousedown(() => { if (this.iframe.style.display !== 'none') { this.transparentOverlay.style.display = 'block'; } })); - this.toDispose.push(this.mouseTracker.onMouseup(e => { + this.toDispose.push(this.mouseTracker.onMouseup(() => { if (this.iframe.style.display !== 'none') { this.transparentOverlay.style.display = 'none'; } })); } + @postConstruct() + protected init(): void { + this.id = WebviewWidget.FACTORY_ID + ':' + this.identifier.id; + } + protected handleMessage(message: any): void { switch (message.command) { case 'onmessage': @@ -88,11 +107,14 @@ export class WebviewWidget extends BaseWidget { } setOptions(options: WebviewWidgetOptions): void { - if (!this.iframe || this.options.allowScripts === options.allowScripts) { + if (this.options.allowScripts === options.allowScripts) { return; } - this.updateSandboxAttribute(this.iframe, options.allowScripts); this.options = options; + if (!this.iframe) { + return; + } + this.updateSandboxAttribute(this.iframe, options.allowScripts); this.reloadFrame(); } diff --git a/packages/plugin-ext/src/main/browser/webviews-main.ts b/packages/plugin-ext/src/main/browser/webviews-main.ts index 8c0909b17f28d..1746fd1b751e1 100644 --- a/packages/plugin-ext/src/main/browser/webviews-main.ts +++ b/packages/plugin-ext/src/main/browser/webviews-main.ts @@ -14,6 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import debounce = require('lodash.debounce'); import { WebviewsMain, MAIN_RPC_CONTEXT, WebviewsExt } from '../../common/plugin-api-rpc'; import { interfaces } from 'inversify'; import { RPCProtocol } from '../../common/rpc-protocol'; @@ -21,26 +22,25 @@ import { UriComponents } from '../../common/uri-components'; import { WebviewOptions, WebviewPanelOptions, WebviewPanelShowOptions } from '@theia/plugin'; import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding'; -import { WebviewWidget } from './webview/webview'; +import { WebviewWidget, WebviewWidgetIdentifier } from './webview/webview'; import { ThemeService } from '@theia/core/lib/browser/theming'; import { ThemeRulesService } from './webview/theme-rules-service'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; import { ViewColumnService } from './view-column-service'; -import { ApplicationShellMouseTracker } from '@theia/core/lib/browser/shell/application-shell-mouse-tracker'; - -import debounce = require('lodash.debounce'); +import { WidgetManager } from '@theia/core/lib/browser/widget-manager'; export class WebviewsMainImpl implements WebviewsMain, Disposable { + private readonly revivers = new Set(); private readonly proxy: WebviewsExt; protected readonly shell: ApplicationShell; + protected readonly widgets: WidgetManager; protected readonly viewColumnService: ViewColumnService; protected readonly keybindingRegistry: KeybindingRegistry; protected readonly themeService = ThemeService.get(); protected readonly themeRulesService = ThemeRulesService.get(); protected readonly updateViewOptions: () => void; - private readonly views = new Map(); private readonly viewsOptions = new Map(); - protected readonly mouseTracker: ApplicationShellMouseTracker; - private readonly toDispose = new DisposableCollection(); constructor(rpc: RPCProtocol, container: interfaces.Container) { this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.WEBVIEWS_EXT); this.shell = container.get(ApplicationShell); - this.mouseTracker = container.get(ApplicationShellMouseTracker); this.keybindingRegistry = container.get(KeybindingRegistry); this.viewColumnService = container.get(ViewColumnService); + this.widgets = container.get(WidgetManager); this.updateViewOptions = debounce<() => void>(() => { for (const key of this.viewsOptions.keys()) { this.checkViewOptions(key); @@ -73,50 +71,54 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { this.toDispose.dispose(); } - $createWebviewPanel( + async $createWebviewPanel( panelId: string, + // TODO check webview API completness, implement or get rid of missing APIs viewType: string, title: string, showOptions: WebviewPanelShowOptions, options: (WebviewPanelOptions & WebviewOptions) | undefined, + // TODO check webview API completness, implement or get rid of missing APIs extensionLocation: UriComponents - ): void { + ): Promise { const toDisposeOnClose = new DisposableCollection(); const toDisposeOnLoad = new DisposableCollection(); - const view = new WebviewWidget(title, { + const view = await this.widgets.getOrCreateWidget(WebviewWidget.FACTORY_ID, { id: panelId }); + view.title.label = title; + view.setOptions({ allowScripts: options ? options.enableScripts : false - }, { - onMessage: m => { - this.proxy.$onMessage(panelId, m); - }, - onKeyboardEvent: e => { - this.keybindingRegistry.run(e); - }, - onLoad: contentDocument => { - const styleId = 'webview-widget-theme'; - let styleElement: HTMLStyleElement | null | undefined; - if (!toDisposeOnLoad.disposed) { - // if reload the frame - toDisposeOnLoad.dispose(); - styleElement = contentDocument.getElementById(styleId); - } - toDisposeOnClose.push(toDisposeOnLoad); - if (!styleElement) { - const parent = contentDocument.head ? contentDocument.head : contentDocument.body; - styleElement = this.themeRulesService.createStyleSheet(parent); - styleElement.id = styleId; - parent.appendChild(styleElement); - } + }); + view.eventDelegate = { + onMessage: m => { + this.proxy.$onMessage(panelId, m); + }, + onKeyboardEvent: e => { + this.keybindingRegistry.run(e); + }, + onLoad: contentDocument => { + const styleId = 'webview-widget-theme'; + let styleElement: HTMLStyleElement | null | undefined; + if (!toDisposeOnLoad.disposed) { + // if reload the frame + toDisposeOnLoad.dispose(); + styleElement = contentDocument.getElementById(styleId); + } + toDisposeOnClose.push(toDisposeOnLoad); + if (!styleElement) { + const parent = contentDocument.head ? contentDocument.head : contentDocument.body; + styleElement = this.themeRulesService.createStyleSheet(parent); + styleElement.id = styleId; + parent.appendChild(styleElement); + } - this.themeRulesService.setRules(styleElement, this.themeRulesService.getCurrentThemeRules()); + this.themeRulesService.setRules(styleElement, this.themeRulesService.getCurrentThemeRules()); + contentDocument.body.className = `vscode-${ThemeService.get().getCurrentTheme().id}`; + toDisposeOnLoad.push(this.themeService.onThemeChange(() => { + this.themeRulesService.setRules(styleElement, this.themeRulesService.getCurrentThemeRules()); contentDocument.body.className = `vscode-${ThemeService.get().getCurrentTheme().id}`; - toDisposeOnLoad.push(this.themeService.onThemeChange(() => { - this.themeRulesService.setRules(styleElement, this.themeRulesService.getCurrentThemeRules()); - contentDocument.body.className = `vscode-${ThemeService.get().getCurrentTheme().id}`; - })); - } - }, - this.mouseTracker); + })); + } + }; view.disposed.connect(() => { toDisposeOnClose.dispose(); this.proxy.$onDidDisposeWebviewPanel(panelId); @@ -126,16 +128,14 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { const viewId = view.id; toDisposeOnClose.push(Disposable.create(() => this.themeRulesService.setIconPath(viewId, undefined))); - this.views.set(panelId, view); - toDisposeOnClose.push(Disposable.create(() => this.views.delete(panelId))); - this.viewsOptions.set(viewId, { panelOptions: showOptions, options: options, panelId, visible: false, active: false }); toDisposeOnClose.push(Disposable.create(() => this.viewsOptions.delete(viewId))); this.addOrReattachWidget(panelId, showOptions); } - private addOrReattachWidget(handler: string, showOptions: WebviewPanelShowOptions): void { - const view = this.views.get(handler); + + private addOrReattachWidget(handle: string, showOptions: WebviewPanelShowOptions): void { + const view = this.tryGetWebview(handle); if (!view) { return; } @@ -184,7 +184,7 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { options.active = active; } $disposeWebview(handle: string): void { - const view = this.views.get(handle); + const view = this.tryGetWebview(handle); if (view) { view.dispose(); } @@ -256,12 +256,12 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { this.revivers.delete(viewType); } - private async checkViewOptions(handler: string, viewColumn?: number | undefined): Promise { - const options = this.viewsOptions.get(handler); + private async checkViewOptions(handle: string, viewColumn?: number | undefined): Promise { + const options = this.viewsOptions.get(handle); if (!options || !options.panelOptions) { return; } - const view = this.views.get(options.panelId); + const view = this.tryGetWebview(handle); if (!view) { return; } @@ -281,11 +281,15 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { } private getWebview(viewId: string): WebviewWidget { - const webview = this.views.get(viewId); + const webview = this.tryGetWebview(viewId); if (!webview) { throw new Error(`Unknown Webview: ${viewId}`); } return webview; } + private tryGetWebview(id: string): WebviewWidget | undefined { + return this.widgets.tryGetWidget(WebviewWidget.FACTORY_ID, { id }); + } + } diff --git a/packages/plugin-ext/src/plugin/webviews.ts b/packages/plugin-ext/src/plugin/webviews.ts index 7611998af941f..5a98442659f39 100644 --- a/packages/plugin-ext/src/plugin/webviews.ts +++ b/packages/plugin-ext/src/plugin/webviews.ts @@ -14,18 +14,17 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import { v4 } from 'uuid'; import { WebviewsExt, WebviewPanelViewState, WebviewsMain, PLUGIN_RPC_CONTEXT, /* WebviewsMain, PLUGIN_RPC_CONTEXT */ } from '../common/plugin-api-rpc'; import * as theia from '@theia/plugin'; import { RPCProtocol } from '../common/rpc-protocol'; import URI from 'vscode-uri/lib/umd'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { fromViewColumn, toViewColumn, toWebviewPanelShowOptions } from './type-converters'; -import { IdGenerator } from '../common/id-generator'; import { Disposable, WebviewPanelTargetArea } from './types-impl'; export class WebviewsExtImpl implements WebviewsExt { private readonly proxy: WebviewsMain; - private readonly idGenerator = new IdGenerator('v'); private readonly webviewPanels = new Map(); private readonly serializers = new Map(); @@ -85,7 +84,7 @@ export class WebviewsExtImpl implements WebviewsExt { extensionLocation: URI): theia.WebviewPanel { const webviewShowOptions = toWebviewPanelShowOptions(showOptions); - const viewId = this.idGenerator.nextId(); + const viewId = v4(); this.proxy.$createWebviewPanel(viewId, viewType, title, webviewShowOptions, options, extensionLocation); const webview = new WebviewImpl(viewId, this.proxy, options); From 5a1b0627fff186fca21c3d2f88e416aa4661d277 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Tue, 29 Oct 2019 08:17:18 +0000 Subject: [PATCH 03/21] [webview] secure webviews Align with VS Code browser implementation of webviews to secure them: - secure resource fetching via service worker - serve each webview from own origin to isolate access to share state like cookies and local storage - plus use nested iframes to inject JS tracking focus, scrolling, key and mouse events within webview even if a webview does not allow javascript See for details: https://blog.mattbierner.com/vscode-webview-web-learnings Signed-off-by: Anton Kosyakov --- .gitpod.yml | 2 +- .vscode/launch.json | 9 +- packages/core/package.json | 2 +- packages/core/src/browser/endpoint.ts | 6 + .../src/node/plugin-vscode-init.ts | 4 +- packages/plugin-ext/package.json | 5 + .../plugin-ext/src/common/plugin-api-rpc.ts | 9 +- .../src/hosted/browser/hosted-plugin.ts | 16 +- .../src/hosted/browser/worker/worker-main.ts | 8 +- .../src/hosted/node/plugin-host-rpc.ts | 16 +- .../browser/plugin-ext-frontend-module.ts | 13 +- .../src/main/browser/webview/pre/fake.html | 14 + .../src/main/browser/webview/pre/host.js | 115 ++++ .../src/main/browser/webview/pre/index.html | 17 + .../src/main/browser/webview/pre/main.js | 577 ++++++++++++++++++ .../browser/webview/pre/service-worker.js | 292 +++++++++ .../browser/webview/theme-rules-service.ts | 4 +- .../browser/webview/webview-environment.ts | 63 ++ .../src/main/browser/webview/webview.ts | 436 ++++++------- .../src/main/browser/webviews-main.ts | 256 ++++---- .../src/main/common/webview-protocol.ts | 27 + .../src/main/node/plugin-service.ts | 22 +- .../plugin-ext/src/plugin/plugin-context.ts | 10 +- .../plugin-ext/src/plugin/plugin-manager.ts | 7 +- packages/plugin-ext/src/plugin/webviews.ts | 134 ++-- packages/plugin/src/theia.d.ts | 78 ++- yarn.lock | 25 +- 27 files changed, 1706 insertions(+), 461 deletions(-) create mode 100644 packages/plugin-ext/src/main/browser/webview/pre/fake.html create mode 100644 packages/plugin-ext/src/main/browser/webview/pre/host.js create mode 100644 packages/plugin-ext/src/main/browser/webview/pre/index.html create mode 100644 packages/plugin-ext/src/main/browser/webview/pre/main.js create mode 100644 packages/plugin-ext/src/main/browser/webview/pre/service-worker.js create mode 100644 packages/plugin-ext/src/main/browser/webview/webview-environment.ts create mode 100644 packages/plugin-ext/src/main/common/webview-protocol.ts diff --git a/.gitpod.yml b/.gitpod.yml index 26a74f0df7af5..b6d3ac3377ef8 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -10,7 +10,7 @@ tasks: - init: yarn command: > jwm & - yarn --cwd examples/browser start ../.. + yarn --cwd examples/browser start ../.. --hostname=0.0.0.0 github: prebuilds: pullRequestsFromForks: true diff --git a/.vscode/launch.json b/.vscode/launch.json index 80a9d380a0b9b..a8ce94844a108 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -35,7 +35,8 @@ "--no-app-auto-install" ], "env": { - "NODE_ENV": "development" + "NODE_ENV": "development", + "THEIA_WEBVIEW_EXTERNAL_ENDPOINT": "${env:THEIA_WEBVIEW_EXTERNAL_ENDPOINT}" }, "sourceMaps": true, "outFiles": [ @@ -63,7 +64,8 @@ "--hosted-plugin-inspect=9339" ], "env": { - "NODE_ENV": "development" + "NODE_ENV": "development", + "THEIA_WEBVIEW_EXTERNAL_ENDPOINT": "${env:THEIA_WEBVIEW_EXTERNAL_ENDPOINT}" }, "sourceMaps": true, "outFiles": [ @@ -104,7 +106,8 @@ "--no-app-auto-install" ], "env": { - "NODE_ENV": "development" + "NODE_ENV": "development", + "THEIA_WEBVIEW_EXTERNAL_ENDPOINT": "${env:THEIA_WEBVIEW_EXTERNAL_ENDPOINT}" }, "sourceMaps": true, "outFiles": [ diff --git a/packages/core/package.json b/packages/core/package.json index 03ed5acae678e..37e9fb5acc091 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,7 +6,7 @@ "typings": "lib/common/index.d.ts", "dependencies": { "@babel/runtime": "^7.5.5", - "@phosphor/widgets": "^1.5.0", + "@phosphor/widgets": "^1.9.3", "@primer/octicons-react": "^9.0.0", "@theia/application-package": "^0.12.0", "@types/body-parser": "^1.16.4", diff --git a/packages/core/src/browser/endpoint.ts b/packages/core/src/browser/endpoint.ts index ac26658255ec2..8d2f95da51aef 100644 --- a/packages/core/src/browser/endpoint.ts +++ b/packages/core/src/browser/endpoint.ts @@ -53,6 +53,9 @@ export class Endpoint { } protected get host(): string { + if (this.options.host) { + return this.options.host; + } if (this.location.host) { return this.location.host; } @@ -77,6 +80,9 @@ export class Endpoint { } protected get wsScheme(): string { + if (this.options.wsScheme) { + return this.options.wsScheme; + } return this.httpScheme === Endpoint.PROTO_HTTPS ? Endpoint.PROTO_WSS : Endpoint.PROTO_WS; } diff --git a/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts b/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts index 97cb8684b9be1..993fb3f59aea9 100644 --- a/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts +++ b/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts @@ -61,7 +61,7 @@ export const doInitialization: BackendInitializationFn = (apiFactory: PluginAPIF // redefine property Object.defineProperty(panel.webview, 'html', { set: function (html: string): void { - const newHtml = html.replace(new RegExp('vscode-resource:/', 'g'), '/webview/'); + const newHtml = html.replace(new RegExp('vscode-resource:/', 'g'), 'theia-resource:/'); this.checkIsDisposed(); if (this._html !== newHtml) { this._html = newHtml; @@ -74,7 +74,7 @@ export const doInitialization: BackendInitializationFn = (apiFactory: PluginAPIF const originalPostMessage = panel.webview.postMessage; panel.webview.postMessage = (message: any): PromiseLike => { const decoded = JSON.stringify(message); - const newMessage = decoded.replace(new RegExp('vscode-resource:/', 'g'), '/webview/'); + const newMessage = decoded.replace(new RegExp('vscode-resource:/', 'g'), 'theia-resource:/'); return originalPostMessage.call(panel.webview, JSON.parse(newMessage)); }; diff --git a/packages/plugin-ext/package.json b/packages/plugin-ext/package.json index 52b63f9b21154..50cf8539f5dd4 100644 --- a/packages/plugin-ext/package.json +++ b/packages/plugin-ext/package.json @@ -24,6 +24,9 @@ "@theia/task": "^0.12.0", "@theia/terminal": "^0.12.0", "@theia/workspace": "^0.12.0", + "@types/connect": "^3.4.32", + "@types/serve-static": "^1.13.3", + "connect": "^3.7.0", "decompress": "^4.2.0", "escape-html": "^1.0.3", "jsonc-parser": "^2.0.2", @@ -31,7 +34,9 @@ "macaddress": "^0.2.9", "ps-tree": "^1.2.0", "request": "^2.82.0", + "serve-static": "^1.14.1", "uuid": "^3.2.1", + "vhost": "^3.0.2", "vscode-debugprotocol": "^1.32.0", "vscode-textmate": "^4.0.1" }, diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index a958e0fa5db9b..2ca91122f8433 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -162,6 +162,7 @@ export interface PluginManagerInitializeParams { workspaceState: KeysToKeysToAnyValue env: EnvInit extApi?: ExtPluginApi[] + webview: WebviewInitData } export interface PluginManagerStartParams { @@ -1218,6 +1219,11 @@ export interface LanguagesMain { $registerRenameProvider(handle: number, pluginInfo: PluginInfo, selector: SerializedDocumentFilter[], supportsResoveInitialValues: boolean): void; } +export interface WebviewInitData { + webviewResourceRoot: string + webviewCspSource: string +} + export interface WebviewPanelViewState { readonly active: boolean; readonly visible: boolean; @@ -1241,8 +1247,7 @@ export interface WebviewsMain { viewType: string, title: string, showOptions: theia.WebviewPanelShowOptions, - options: theia.WebviewPanelOptions & theia.WebviewOptions | undefined, - pluginLocation: UriComponents): void; + options: theia.WebviewPanelOptions & theia.WebviewOptions): void; $disposeWebview(handle: string): void; $reveal(handle: string, showOptions: theia.WebviewPanelShowOptions): void; $setTitle(handle: string, value: string): void; diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts index e9c554f23cc88..6cf5551625e5a 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts @@ -53,6 +53,7 @@ import { Emitter, isCancelled } from '@theia/core'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; import { PluginViewRegistry } from '../../main/browser/view/plugin-view-registry'; import { TaskProviderRegistry, TaskResolverRegistry } from '@theia/task/lib/browser/task-contribution'; +import { WebviewEnvironment } from '../../main/browser/webview/webview-environment'; export type PluginHost = 'frontend' | string; export type DebugActivationEvent = 'onDebugResolve' | 'onDebugInitialConfigurations' | 'onDebugAdapterProtocolTracker'; @@ -127,6 +128,9 @@ export class HostedPluginSupport { @inject(ProgressService) protected readonly progressService: ProgressService; + @inject(WebviewEnvironment) + protected readonly webviewEnvironment: WebviewEnvironment; + private theiaReadyPromise: Promise; protected readonly managers = new Map(); @@ -355,13 +359,15 @@ export class HostedPluginSupport { this.managers.set(host, manager); toDisconnect.push(Disposable.create(() => this.managers.delete(host))); - const [extApi, globalState, workspaceState] = await Promise.all([ + const [extApi, globalState, workspaceState, webviewResourceRoot, webviewCspSource] = await Promise.all([ this.server.getExtPluginAPI(), this.pluginServer.getAllStorageValues(undefined), this.pluginServer.getAllStorageValues({ workspace: this.workspaceService.workspace, roots: this.workspaceService.tryGetRoots() - }) + }), + this.webviewEnvironment.resourceRoot(), + this.webviewEnvironment.cspSource() ]); if (toDisconnect.disposed) { return undefined; @@ -372,7 +378,11 @@ export class HostedPluginSupport { globalState, workspaceState, env: { queryParams: getQueryParameters(), language: navigator.language }, - extApi + extApi, + webview: { + webviewResourceRoot, + webviewCspSource + } }); if (toDisconnect.disposed) { return undefined; diff --git a/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts b/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts index d582cbaf27329..3271f319cbb60 100644 --- a/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts +++ b/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts @@ -30,6 +30,7 @@ import { MessageRegistryExt } from '../../../plugin/message-registry'; import { WorkerEnvExtImpl } from './worker-env-ext'; import { ClipboardExt } from '../../../plugin/clipboard-ext'; import { KeyValueStorageProxy } from '../../../plugin/plugin-storage'; +import { WebviewsExtImpl } from '../../../plugin/webviews'; // tslint:disable-next-line:no-any const ctx = self as any; @@ -59,6 +60,7 @@ const workspaceExt = new WorkspaceExtImpl(rpc, editorsAndDocuments, messageRegis const preferenceRegistryExt = new PreferenceRegistryExtImpl(rpc, workspaceExt); const debugExt = createDebugExtStub(rpc); const clipboardExt = new ClipboardExt(rpc); +const webviewExt = new WebviewsExtImpl(rpc, workspaceExt); const pluginManager = new PluginManagerExtImpl({ // tslint:disable-next-line:no-any @@ -131,7 +133,7 @@ const pluginManager = new PluginManagerExtImpl({ } } } -}, envExt, storageProxy, preferenceRegistryExt, rpc); +}, envExt, storageProxy, preferenceRegistryExt, webviewExt, rpc); const apiFactory = createAPIFactory( rpc, @@ -142,7 +144,8 @@ const apiFactory = createAPIFactory( editorsAndDocuments, workspaceExt, messageRegistryExt, - clipboardExt + clipboardExt, + webviewExt ); let defaultApi: typeof theia; @@ -169,6 +172,7 @@ rpc.set(MAIN_RPC_CONTEXT.HOSTED_PLUGIN_MANAGER_EXT, pluginManager); rpc.set(MAIN_RPC_CONTEXT.EDITORS_AND_DOCUMENTS_EXT, editorsAndDocuments); rpc.set(MAIN_RPC_CONTEXT.WORKSPACE_EXT, workspaceExt); rpc.set(MAIN_RPC_CONTEXT.PREFERENCE_REGISTRY_EXT, preferenceRegistryExt); +rpc.set(MAIN_RPC_CONTEXT.WEBVIEWS_EXT, webviewExt); function isElectron(): boolean { if (typeof navigator === 'object' && typeof navigator.userAgent === 'string' && navigator.userAgent.indexOf('Electron') >= 0) { diff --git a/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts b/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts index 94d68e15acbcd..e0c05570c856f 100644 --- a/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts +++ b/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts @@ -29,6 +29,7 @@ import { EnvNodeExtImpl } from '../../plugin/node/env-node-ext'; import { ClipboardExt } from '../../plugin/clipboard-ext'; import { loadManifest } from './plugin-manifest-loader'; import { KeyValueStorageProxy } from '../../plugin/plugin-storage'; +import { WebviewsExtImpl } from '../../plugin/webviews'; /** * Handle the RPC calls. @@ -52,12 +53,14 @@ export class PluginHostRPC { const workspaceExt = new WorkspaceExtImpl(this.rpc, editorsAndDocumentsExt, messageRegistryExt); const preferenceRegistryExt = new PreferenceRegistryExtImpl(this.rpc, workspaceExt); const clipboardExt = new ClipboardExt(this.rpc); - this.pluginManager = this.createPluginManager(envExt, storageProxy, preferenceRegistryExt, this.rpc); + const webviewExt = new WebviewsExtImpl(this.rpc, workspaceExt); + this.pluginManager = this.createPluginManager(envExt, storageProxy, preferenceRegistryExt, webviewExt, this.rpc); this.rpc.set(MAIN_RPC_CONTEXT.HOSTED_PLUGIN_MANAGER_EXT, this.pluginManager); this.rpc.set(MAIN_RPC_CONTEXT.EDITORS_AND_DOCUMENTS_EXT, editorsAndDocumentsExt); this.rpc.set(MAIN_RPC_CONTEXT.WORKSPACE_EXT, workspaceExt); this.rpc.set(MAIN_RPC_CONTEXT.PREFERENCE_REGISTRY_EXT, preferenceRegistryExt); this.rpc.set(MAIN_RPC_CONTEXT.STORAGE_EXT, storageProxy); + this.rpc.set(MAIN_RPC_CONTEXT.WEBVIEWS_EXT, webviewExt); this.apiFactory = createAPIFactory( this.rpc, @@ -68,7 +71,8 @@ export class PluginHostRPC { editorsAndDocumentsExt, workspaceExt, messageRegistryExt, - clipboardExt + clipboardExt, + webviewExt ); } @@ -84,8 +88,10 @@ export class PluginHostRPC { } } - // tslint:disable-next-line:no-any - createPluginManager(envExt: EnvExtImpl, storageProxy: KeyValueStorageProxy, preferencesManager: PreferenceRegistryExtImpl, rpc: any): PluginManagerExtImpl { + createPluginManager( + envExt: EnvExtImpl, storageProxy: KeyValueStorageProxy, preferencesManager: PreferenceRegistryExtImpl, webview: WebviewsExtImpl, + // tslint:disable-next-line:no-any + rpc: any): PluginManagerExtImpl { const { extensionTestsPath } = process.env; const self = this; const pluginManager = new PluginManagerExtImpl({ @@ -216,7 +222,7 @@ export class PluginHostRPC { `Path ${extensionTestsPath} does not point to a valid extension test runner.` ); } : undefined - }, envExt, storageProxy, preferencesManager, rpc); + }, envExt, storageProxy, preferencesManager, webview, rpc); return pluginManager; } } diff --git a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts index 71c295e5f52fe..863f1b775aa45 100644 --- a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts +++ b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts @@ -63,7 +63,8 @@ import { LanguagesMainFactory, OutputChannelRegistryFactory } from '../../common import { LanguagesMainImpl } from './languages-main'; import { OutputChannelRegistryMainImpl } from './output-channel-registry-main'; import { InPluginFileSystemWatcherManager } from './in-plugin-filesystem-watcher-manager'; -import { WebviewWidget, WebviewWidgetIdentifier } from './webview/webview'; +import { WebviewWidget, WebviewWidgetIdentifier, WebviewWidgetExternalEndpoint } from './webview/webview'; +import { WebviewEnvironment } from './webview/webview-environment'; export default new ContainerModule((bind, unbind, isBound, rebind) => { @@ -147,12 +148,20 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { } })).inSingletonScope(); + bind(WebviewEnvironment).toSelf().inSingletonScope(); bind(WebviewWidget).toSelf(); bind(WidgetFactory).toDynamicValue(({ container }) => ({ id: WebviewWidget.FACTORY_ID, - createWidget: (identifier: WebviewWidgetIdentifier) => { + createWidget: async (identifier: WebviewWidgetIdentifier) => { + const externalEndpoint = await container.get(WebviewEnvironment).externalEndpoint(); + let endpoint = externalEndpoint.replace('{{uuid}}', identifier.id); + if (endpoint[endpoint.length - 1] === '/') { + endpoint = endpoint.slice(0, endpoint.length - 1); + } + const child = container.createChild(); child.bind(WebviewWidgetIdentifier).toConstantValue(identifier); + child.bind(WebviewWidgetExternalEndpoint).toConstantValue(endpoint); return child.get(WebviewWidget); } })).inSingletonScope(); diff --git a/packages/plugin-ext/src/main/browser/webview/pre/fake.html b/packages/plugin-ext/src/main/browser/webview/pre/fake.html new file mode 100644 index 0000000000000..18c40421e34bc --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview/pre/fake.html @@ -0,0 +1,14 @@ + + + + + + + + Fake + + + + + + diff --git a/packages/plugin-ext/src/main/browser/webview/pre/host.js b/packages/plugin-ext/src/main/browser/webview/pre/host.js new file mode 100644 index 0000000000000..b5fa2e9cd01b3 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview/pre/host.js @@ -0,0 +1,115 @@ +/******************************************************************************** + * Copyright (C) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ +// copied and modified from https://github.com/microsoft/vscode/blob/ba40bd16433d5a817bfae15f3b4350e18f144af4/src/vs/workbench/contrib/webview/browser/pre/host.js +// @ts-check +(function () { + const id = document.location.search.match(/\bid=([\w-]+)/)[1]; + + const hostMessaging = new class HostMessaging { + constructor() { + this.handlers = new Map(); + window.addEventListener('message', (e) => { + if (e.data && (e.data.command === 'onmessage' || e.data.command === 'do-update-state')) { + // Came from inner iframe + this.postMessage(e.data.command, e.data.data); + return; + } + + const channel = e.data.channel; + const handler = this.handlers.get(channel); + if (handler) { + handler(e, e.data.args); + } else { + console.error('no handler for ', e); + } + }); + } + + postMessage(channel, data) { + window.parent.postMessage({ target: id, channel, data }, '*'); + } + + onMessage(channel, handler) { + this.handlers.set(channel, handler); + } + }(); + + const workerReady = new Promise(async (resolveWorkerReady) => { + if (!areServiceWorkersEnabled()) { + console.error('Service Workers are not enabled. Webviews will not work properly'); + return resolveWorkerReady(); + } + + const expectedWorkerVersion = 1; + + navigator.serviceWorker.register('service-worker.js').then(async registration => { + await navigator.serviceWorker.ready; + + const versionHandler = (event) => { + if (event.data.channel !== 'version') { + return; + } + + navigator.serviceWorker.removeEventListener('message', versionHandler); + if (event.data.version === expectedWorkerVersion) { + return resolveWorkerReady(); + } else { + // If we have the wrong version, try once to unregister and re-register + return registration.update() + .then(() => navigator.serviceWorker.ready) + .finally(resolveWorkerReady); + } + }; + navigator.serviceWorker.addEventListener('message', versionHandler); + registration.active.postMessage({ channel: 'version' }); + }); + + const forwardFromHostToWorker = (channel) => { + hostMessaging.onMessage(channel, event => { + navigator.serviceWorker.ready.then(registration => { + registration.active.postMessage({ channel: channel, data: event.data.args }); + }); + }); + }; + forwardFromHostToWorker('did-load-resource'); + forwardFromHostToWorker('did-load-localhost'); + + navigator.serviceWorker.addEventListener('message', event => { + if (['load-resource', 'load-localhost'].includes(event.data.channel)) { + hostMessaging.postMessage(event.data.channel, event.data); + } + }); + }); + + function areServiceWorkersEnabled() { + try { + return !!navigator.serviceWorker; + } catch (e) { + return false; + } + } + + window.createWebviewManager({ + postMessage: hostMessaging.postMessage.bind(hostMessaging), + onMessage: hostMessaging.onMessage.bind(hostMessaging), + ready: workerReady, + fakeLoad: true + }); +}()); diff --git a/packages/plugin-ext/src/main/browser/webview/pre/index.html b/packages/plugin-ext/src/main/browser/webview/pre/index.html new file mode 100644 index 0000000000000..f4ed42759569d --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview/pre/index.html @@ -0,0 +1,17 @@ + + + + + + + + Virtual Document + + + + + + + + diff --git a/packages/plugin-ext/src/main/browser/webview/pre/main.js b/packages/plugin-ext/src/main/browser/webview/pre/main.js new file mode 100644 index 0000000000000..3560e6e6e816e --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview/pre/main.js @@ -0,0 +1,577 @@ +/******************************************************************************** + * Copyright (C) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// copied and modified from https://github.com/microsoft/vscode/blob/ba40bd16433d5a817bfae15f3b4350e18f144af4/src/vs/workbench/contrib/webview/browser/pre/main.js +// @ts-check + +/** + * @typedef {{ + * postMessage: (channel: string, data?: any) => void, + * onMessage: (channel: string, handler: any) => void, + * focusIframeOnCreate?: boolean, + * ready?: Promise, + * onIframeLoaded?: (iframe: HTMLIFrameElement) => void, + * fakeLoad: boolean + * }} WebviewHost + */ + +(function () { + 'use strict'; + + /** + * Use polling to track focus of main webview and iframes within the webview + * + * @param {Object} handlers + * @param {() => void} handlers.onFocus + * @param {() => void} handlers.onBlur + */ + const trackFocus = ({ onFocus, onBlur }) => { + const interval = 50; + let isFocused = document.hasFocus(); + setInterval(() => { + const isCurrentlyFocused = document.hasFocus(); + if (isCurrentlyFocused === isFocused) { + return; + } + isFocused = isCurrentlyFocused; + if (isCurrentlyFocused) { + onFocus(); + } else { + onBlur(); + } + }, interval); + }; + + const getActiveFrame = () => { + return /** @type {HTMLIFrameElement} */ (document.getElementById('active-frame')); + }; + + const getPendingFrame = () => { + return /** @type {HTMLIFrameElement} */ (document.getElementById('pending-frame')); + }; + + const defaultCssRules = ` + body { + background-color: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + font-family: var(--vscode-font-family); + font-weight: var(--vscode-font-weight); + font-size: var(--vscode-font-size); + margin: 0; + padding: 0 20px; + } + + img { + max-width: 100%; + max-height: 100%; + } + + a { + color: var(--vscode-textLink-foreground); + } + + a:hover { + color: var(--vscode-textLink-activeForeground); + } + + a:focus, + input:focus, + select:focus, + textarea:focus { + outline: 1px solid -webkit-focus-ring-color; + outline-offset: -1px; + } + + code { + color: var(--vscode-textPreformat-foreground); + } + + blockquote { + background: var(--vscode-textBlockQuote-background); + border-color: var(--vscode-textBlockQuote-border); + } + + kbd { + color: var(--vscode-editor-foreground); + border-radius: 3px; + vertical-align: middle; + padding: 1px 3px; + + background-color: hsla(0,0%,50%,.17); + border: 1px solid rgba(71,71,71,.4); + border-bottom-color: rgba(88,88,88,.4); + box-shadow: inset 0 -1px 0 rgba(88,88,88,.4); + } + .vscode-light kbd { + background-color: hsla(0,0%,87%,.5); + border: 1px solid hsla(0,0%,80%,.7); + border-bottom-color: hsla(0,0%,73%,.7); + box-shadow: inset 0 -1px 0 hsla(0,0%,73%,.7); + } + + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + ::-webkit-scrollbar-thumb { + background-color: var(--vscode-scrollbarSlider-background); + } + ::-webkit-scrollbar-thumb:hover { + background-color: var(--vscode-scrollbarSlider-hoverBackground); + } + ::-webkit-scrollbar-thumb:active { + background-color: var(--vscode-scrollbarSlider-activeBackground); + }`; + + /** + * @param {*} [state] + * @return {string} + */ + function getVsCodeApiScript(state) { + return ` + const acquireVsCodeApi = (function() { + const originalPostMessage = window.parent.postMessage.bind(window.parent); + const targetOrigin = '*'; + let acquired = false; + + let state = ${state ? `JSON.parse(${JSON.stringify(state)})` : undefined}; + + return () => { + if (acquired) { + throw new Error('An instance of the VS Code API has already been acquired'); + } + acquired = true; + return Object.freeze({ + postMessage: function(msg) { + return originalPostMessage({ command: 'onmessage', data: msg }, targetOrigin); + }, + setState: function(newState) { + state = newState; + originalPostMessage({ command: 'do-update-state', data: JSON.stringify(newState) }, targetOrigin); + return newState; + }, + getState: function() { + return state; + } + }); + }; + })(); + const acquireTheiaApi = acquireVsCodeApi; + delete window.parent; + delete window.top; + delete window.frameElement; + `; + } + + /** + * @param {WebviewHost} host + */ + function createWebviewManager(host) { + // state + let firstLoad = true; + let loadTimeout; + let pendingMessages = []; + + const initData = { + initialScrollProgress: undefined + }; + + + /** + * @param {HTMLDocument?} document + * @param {HTMLElement?} body + */ + const applyStyles = (document, body) => { + if (!document) { + return; + } + + if (body) { + body.classList.remove('vscode-light', 'vscode-dark', 'vscode-high-contrast'); + body.classList.add(initData.activeTheme); + } + + if (initData.styles) { + for (const variable of Object.keys(initData.styles)) { + document.documentElement.style.setProperty(`--${variable}`, initData.styles[variable]); + } + } + }; + + /** + * @param {MouseEvent} event + */ + const handleInnerClick = (event) => { + if (!event || !event.view || !event.view.document) { + return; + } + + let baseElement = event.view.document.getElementsByTagName('base')[0]; + /** @type {any} */ + let node = event.target; + while (node) { + if (node.tagName && node.tagName.toLowerCase() === 'a' && node.href) { + if (node.getAttribute('href') === '#') { + event.view.scrollTo(0, 0); + } else if (node.hash && (node.getAttribute('href') === node.hash || (baseElement && node.href.indexOf(baseElement.href) >= 0))) { + let scrollTarget = event.view.document.getElementById(node.hash.substr(1, node.hash.length - 1)); + if (scrollTarget) { + scrollTarget.scrollIntoView(); + } + } else { + host.postMessage('did-click-link', node.href.baseVal || node.href); + } + event.preventDefault(); + break; + } + node = node.parentNode; + } + }; + + /** + * @param {MouseEvent} event + */ + const handleAuxClick = + (event) => { + // Prevent middle clicks opening a broken link in the browser + if (!event.view || !event.view.document) { + return; + } + + if (event.button === 1) { + let node = /** @type {any} */ (event.target); + while (node) { + if (node.tagName && node.tagName.toLowerCase() === 'a' && node.href) { + event.preventDefault(); + break; + } + node = node.parentNode; + } + } + }; + + /** + * @param {KeyboardEvent} e + */ + const handleInnerKeydown = (e) => { + host.postMessage('did-keydown', { + key: e.key, + keyCode: e.keyCode, + code: e.code, + shiftKey: e.shiftKey, + altKey: e.altKey, + ctrlKey: e.ctrlKey, + metaKey: e.metaKey, + repeat: e.repeat + }); + }; + + let isHandlingScroll = false; + const handleInnerScroll = (event) => { + if (!event.target || !event.target.body) { + return; + } + if (isHandlingScroll) { + return; + } + + const progress = event.currentTarget.scrollY / event.target.body.clientHeight; + if (isNaN(progress)) { + return; + } + + isHandlingScroll = true; + window.requestAnimationFrame(() => { + try { + host.postMessage('did-scroll', progress); + } catch (e) { + // noop + } + isHandlingScroll = false; + }); + }; + + /** + * @return {string} + */ + function toContentHtml(data) { + const options = data.options; + const text = data.contents; + const newDocument = new DOMParser().parseFromString(text, 'text/html'); + + newDocument.querySelectorAll('a').forEach(a => { + if (!a.title) { + a.title = a.getAttribute('href'); + } + }); + + // apply default script + if (options.allowScripts) { + const defaultScript = newDocument.createElement('script'); + defaultScript.textContent = getVsCodeApiScript(data.state); + newDocument.head.prepend(defaultScript); + } + + // apply default styles + const defaultStyles = newDocument.createElement('style'); + defaultStyles.id = '_defaultStyles'; + defaultStyles.innerHTML = defaultCssRules; + newDocument.head.prepend(defaultStyles); + + applyStyles(newDocument, newDocument.body); + + // Check for CSP + const csp = newDocument.querySelector('meta[http-equiv="Content-Security-Policy"]'); + if (!csp) { + host.postMessage('no-csp-found'); + } else { + // Rewrite theia-resource in csp + if (data.endpoint) { + try { + const endpointUrl = new URL(data.endpoint); + csp.setAttribute('content', csp.getAttribute('content').replace(/theia-resource:(?=(\s|;|$))/g, endpointUrl.origin)); + } catch (e) { + console.error('Could not rewrite csp'); + } + } + } + + // set DOCTYPE for newDocument explicitly as DOMParser.parseFromString strips it off + // and DOCTYPE is needed in the iframe to ensure that the user agent stylesheet is correctly overridden + return '\n' + newDocument.documentElement.outerHTML; + } + + document.addEventListener('DOMContentLoaded', () => { + const idMatch = document.location.search.match(/\bid=([\w-]+)/); + const ID = idMatch ? idMatch[1] : undefined; + if (!document.body) { + return; + } + + host.onMessage('styles', (_event, data) => { + initData.styles = data.styles; + initData.activeTheme = data.activeTheme; + + const target = getActiveFrame(); + if (!target) { + return; + } + + if (target.contentDocument) { + applyStyles(target.contentDocument, target.contentDocument.body); + } + }); + + // propagate focus + host.onMessage('focus', () => { + const target = getActiveFrame(); + if (target) { + target.contentWindow.focus(); + } + }); + + // update iframe-contents + let updateId = 0; + host.onMessage('content', async (_event, data) => { + const currentUpdateId = ++updateId; + await host.ready; + if (currentUpdateId !== updateId) { + return; + } + + const options = data.options; + const newDocument = toContentHtml(data); + + const frame = getActiveFrame(); + const wasFirstLoad = firstLoad; + // keep current scrollY around and use later + let setInitialScrollPosition; + if (firstLoad) { + firstLoad = false; + setInitialScrollPosition = (body, window) => { + if (!isNaN(initData.initialScrollProgress)) { + if (window.scrollY === 0) { + window.scroll(0, body.clientHeight * initData.initialScrollProgress); + } + } + }; + } else { + const scrollY = frame && frame.contentDocument && frame.contentDocument.body ? frame.contentWindow.scrollY : 0; + setInitialScrollPosition = (body, window) => { + if (window.scrollY === 0) { + window.scroll(0, scrollY); + } + }; + } + + // Clean up old pending frames and set current one as new one + const previousPendingFrame = getPendingFrame(); + if (previousPendingFrame) { + previousPendingFrame.setAttribute('id', ''); + document.body.removeChild(previousPendingFrame); + } + if (!wasFirstLoad) { + pendingMessages = []; + } + + const newFrame = document.createElement('iframe'); + newFrame.setAttribute('id', 'pending-frame'); + newFrame.setAttribute('frameborder', '0'); + newFrame.setAttribute('sandbox', options.allowScripts ? 'allow-scripts allow-forms allow-same-origin' : 'allow-same-origin'); + if (host.fakeLoad) { + // We should just be able to use srcdoc, but I wasn't + // seeing the service worker applying properly. + // Fake load an empty on the correct origin and then write real html + // into it to get around this. + newFrame.src = `./fake.html?id=${ID}`; + } + newFrame.style.cssText = 'display: block; margin: 0; overflow: hidden; position: absolute; width: 100%; height: 100%; visibility: hidden'; + document.body.appendChild(newFrame); + + if (!host.fakeLoad) { + // write new content onto iframe + newFrame.contentDocument.open(); + } + + newFrame.contentWindow.addEventListener('DOMContentLoaded', e => { + // Workaround for https://bugs.chromium.org/p/chromium/issues/detail?id=978325 + setTimeout(() => { + if (host.fakeLoad) { + newFrame.contentDocument.open(); + newFrame.contentDocument.write(newDocument); + newFrame.contentDocument.close(); + hookupOnLoadHandlers(newFrame); + } + const contentDocument = e.target ? (/** @type {HTMLDocument} */ (e.target)) : undefined; + if (contentDocument) { + applyStyles(contentDocument, contentDocument.body); + } + }, 0); + }); + + const onLoad = (contentDocument, contentWindow) => { + if (contentDocument && contentDocument.body) { + // Workaround for https://github.com/Microsoft/vscode/issues/12865 + // check new scrollY and reset if neccessary + setInitialScrollPosition(contentDocument.body, contentWindow); + } + + const newFrame = getPendingFrame(); + if (newFrame && newFrame.contentDocument && newFrame.contentDocument === contentDocument) { + const oldActiveFrame = getActiveFrame(); + if (oldActiveFrame) { + document.body.removeChild(oldActiveFrame); + } + // Styles may have changed since we created the element. Make sure we re-style + applyStyles(newFrame.contentDocument, newFrame.contentDocument.body); + newFrame.setAttribute('id', 'active-frame'); + newFrame.style.visibility = 'visible'; + if (host.focusIframeOnCreate) { + newFrame.contentWindow.focus(); + } + + contentWindow.addEventListener('scroll', handleInnerScroll); + + pendingMessages.forEach((data) => { + contentWindow.postMessage(data, '*'); + }); + pendingMessages = []; + } + }; + + /** + * @param {HTMLIFrameElement} newFrame + */ + function hookupOnLoadHandlers(newFrame) { + const timeoutDelay = 5000; + clearTimeout(loadTimeout); + loadTimeout = undefined; + loadTimeout = setTimeout(() => { + clearTimeout(loadTimeout); + loadTimeout = undefined; + console.warn('Loading webview is slow, took: ' + timeoutDelay + 'ms'); + onLoad(newFrame.contentDocument, newFrame.contentWindow); + }, timeoutDelay); + + newFrame.contentWindow.addEventListener('load', function (e) { + if (loadTimeout) { + clearTimeout(loadTimeout); + loadTimeout = undefined; + onLoad(e.target, this); + } + }); + + // Bubble out various events + newFrame.contentWindow.addEventListener('click', handleInnerClick); + newFrame.contentWindow.addEventListener('auxclick', handleAuxClick); + newFrame.contentWindow.addEventListener('keydown', handleInnerKeydown); + newFrame.contentWindow.addEventListener('contextmenu', e => e.preventDefault()); + + if (host.onIframeLoaded) { + host.onIframeLoaded(newFrame); + } + } + + if (!host.fakeLoad) { + hookupOnLoadHandlers(newFrame); + } + + if (!host.fakeLoad) { + newFrame.contentDocument.write(newDocument); + newFrame.contentDocument.close(); + } + + host.postMessage('did-set-content', undefined); + }); + + // Forward message to the embedded iframe + host.onMessage('message', (_event, data) => { + const pending = getPendingFrame(); + if (!pending) { + const target = getActiveFrame(); + if (target) { + target.contentWindow.postMessage(data, '*'); + return; + } + } + pendingMessages.push(data); + }); + + host.onMessage('initial-scroll-position', (_event, progress) => { + initData.initialScrollProgress = progress; + }); + + + trackFocus({ + onFocus: () => host.postMessage('did-focus'), + onBlur: () => host.postMessage('did-blur') + }); + + // signal ready + host.postMessage('webview-ready', {}); + }); + } + + if (typeof module !== 'undefined') { + module.exports = createWebviewManager; + } else { + window.createWebviewManager = createWebviewManager; + } +}()); diff --git a/packages/plugin-ext/src/main/browser/webview/pre/service-worker.js b/packages/plugin-ext/src/main/browser/webview/pre/service-worker.js new file mode 100644 index 0000000000000..a6de65764b6d0 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview/pre/service-worker.js @@ -0,0 +1,292 @@ +/******************************************************************************** + * Copyright (C) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ +// copied and modified from https://github.com/microsoft/vscode/blob/ba40bd16433d5a817bfae15f3b4350e18f144af4/src/vs/workbench/contrib/webview/browser/pre/service-worker.js +const VERSION = 1; + +const rootPath = self.location.pathname.replace(/\/service-worker.js$/, ''); + +/** + * Root path for resources + */ +const resourceRoot = rootPath + '/theia-resource'; + +const resolveTimeout = 30000; + +/** + * @template T + * @typedef {{ + * resolve: (x: T) => void, + * promise: Promise + * }} RequestStoreEntry + */ + +/** + * @template T + */ +class RequestStore { + constructor() { + /** @type {Map>} */ + this.map = new Map(); + } + + /** + * @param {string} webviewId + * @param {string} path + * @return {Promise | undefined} + */ + get(webviewId, path) { + const entry = this.map.get(this._key(webviewId, path)); + return entry && entry.promise; + } + + /** + * @param {string} webviewId + * @param {string} path + * @returns {Promise} + */ + create(webviewId, path) { + const existing = this.get(webviewId, path); + if (existing) { + return existing; + } + let resolve; + const promise = new Promise(r => resolve = r); + const entry = { resolve, promise }; + const key = this._key(webviewId, path); + this.map.set(key, entry); + + const dispose = () => { + clearTimeout(timeout); + const existingEntry = this.map.get(key); + if (existingEntry === entry) { + return this.map.delete(key); + } + }; + const timeout = setTimeout(dispose, resolveTimeout); + return promise; + } + + /** + * @param {string} webviewId + * @param {string} path + * @param {T} result + * @return {boolean} + */ + resolve(webviewId, path, result) { + const entry = this.map.get(this._key(webviewId, path)); + if (!entry) { + return false; + } + entry.resolve(result); + return true; + } + + /** + * @param {string} webviewId + * @param {string} path + * @return {string} + */ + _key(webviewId, path) { + return `${webviewId}@@@${path}`; + } +} + +/** + * Map of requested paths to responses. + * + * @type {RequestStore<{ body: any, mime: string } | undefined>} + */ +const resourceRequestStore = new RequestStore(); + +/** + * Map of requested localhost origins to optional redirects. + * + * @type {RequestStore} + */ +const localhostRequestStore = new RequestStore(); + +const notFound = () => + new Response('Not Found', { status: 404, }); + +self.addEventListener('message', async (event) => { + switch (event.data.channel) { + case 'version': + { + self.clients.get(event.source.id).then(client => { + if (client) { + client.postMessage({ + channel: 'version', + version: VERSION + }); + } + }); + return; + } + case 'did-load-resource': + { + const webviewId = getWebviewIdForClient(event.source); + const data = event.data.data; + const response = data.status === 200 + ? { body: data.data, mime: data.mime } + : undefined; + + if (!resourceRequestStore.resolve(webviewId, data.path, response)) { + console.error('Could not resolve unknown resource', data.path); + } + return; + } + + case 'did-load-localhost': + { + const webviewId = getWebviewIdForClient(event.source); + const data = event.data.data; + if (!localhostRequestStore.resolve(webviewId, data.origin, data.location)) { + console.error('Could not resolve unknown localhost', data.origin); + } + return; + } + } + + console.error('Unknown message'); +}); + +self.addEventListener('fetch', (event) => { + const requestUrl = new URL(event.request.url); + + // See if it's a resource request + if (requestUrl.origin === self.origin && requestUrl.pathname.startsWith(resourceRoot + '/')) { + return event.respondWith(processResourceRequest(event, requestUrl)); + } + + // See if it's a localhost request + if (requestUrl.origin !== self.origin && requestUrl.host.match(/^localhost:(\d+)$/)) { + return event.respondWith(processLocalhostRequest(event, requestUrl)); + } +}); + +self.addEventListener('install', (event) => { + event.waitUntil(self.skipWaiting()); // Activate worker immediately +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); // Become available to all pages +}); + +async function processResourceRequest(event, requestUrl) { + const client = await self.clients.get(event.clientId); + if (!client) { + console.error('Could not find inner client for request'); + return notFound(); + } + + const webviewId = getWebviewIdForClient(client); + const resourcePath = requestUrl.pathname.startsWith(resourceRoot + '/') ? requestUrl.pathname.slice(resourceRoot.length) : requestUrl.pathname; + + function resolveResourceEntry(entry) { + if (!entry) { + return notFound(); + } + return new Response(entry.body, { + status: 200, + headers: { 'Content-Type': entry.mime } + }); + } + + const parentClient = await getOuterIframeClient(webviewId); + if (!parentClient) { + console.error('Could not find parent client for request'); + return notFound(); + } + + // Check if we've already resolved this request + const existing = resourceRequestStore.get(webviewId, resourcePath); + if (existing) { + return existing.then(resolveResourceEntry); + } + + parentClient.postMessage({ + channel: 'load-resource', + path: resourcePath + }); + + return resourceRequestStore.create(webviewId, resourcePath) + .then(resolveResourceEntry); +} + +/** + * @param {*} event + * @param {URL} requestUrl + */ +async function processLocalhostRequest(event, requestUrl) { + const client = await self.clients.get(event.clientId); + if (!client) { + // This is expected when requesting resources on other localhost ports + // that are not spawned by vs code + return undefined; + } + const webviewId = getWebviewIdForClient(client); + const origin = requestUrl.origin; + + const resolveRedirect = redirectOrigin => { + if (!redirectOrigin) { + return fetch(event.request); + } + const location = event.request.url.replace(new RegExp(`^${requestUrl.origin}(/|$)`), `${redirectOrigin}$1`); + return new Response(null, { + status: 302, + headers: { + Location: location + } + }); + }; + + const parentClient = await getOuterIframeClient(webviewId); + if (!parentClient) { + console.error('Could not find parent client for request'); + return notFound(); + } + + // Check if we've already resolved this request + const existing = localhostRequestStore.get(webviewId, origin); + if (existing) { + return existing.then(resolveRedirect); + } + + parentClient.postMessage({ + channel: 'load-localhost', + origin: origin + }); + + return localhostRequestStore.create(webviewId, origin) + .then(resolveRedirect); +} + +function getWebviewIdForClient(client) { + const requesterClientUrl = new URL(client.url); + return requesterClientUrl.search.match(/\bid=([a-z0-9-]+)/i)[1]; +} + +async function getOuterIframeClient(webviewId) { + const allClients = await self.clients.matchAll({ includeUncontrolled: true }); + return allClients.find(client => { + const clientUrl = new URL(client.url); + return (clientUrl.pathname === `${rootPath}/` || clientUrl.pathname === `${rootPath}/index.html`) && clientUrl.search.match(new RegExp('\\bid=' + webviewId)); + }); +} diff --git a/packages/plugin-ext/src/main/browser/webview/theme-rules-service.ts b/packages/plugin-ext/src/main/browser/webview/theme-rules-service.ts index 23eba55151663..01d5dec1e9f11 100644 --- a/packages/plugin-ext/src/main/browser/webview/theme-rules-service.ts +++ b/packages/plugin-ext/src/main/browser/webview/theme-rules-service.ts @@ -69,7 +69,7 @@ export class ThemeRulesService { insertRule: (rule: string, index: number) => void, removeRule: (index: number) => void, rules: CSSRuleList - // tslint:disable-next-line:no-any + // tslint:disable-next-line:no-any } | undefined = (styleElement).sheet; if (!sheet || !sheet.rules || !sheet.rules.length) { return cssText; @@ -94,7 +94,7 @@ export class ThemeRulesService { insertRule: (rule: string, index: number) => void; removeRule: (index: number) => void; rules: CSSRuleList; - // tslint:disable-next-line:no-any + // tslint:disable-next-line:no-any } | undefined = (styleSheet).sheet; if (!sheet) { diff --git a/packages/plugin-ext/src/main/browser/webview/webview-environment.ts b/packages/plugin-ext/src/main/browser/webview/webview-environment.ts new file mode 100644 index 0000000000000..520134bdf5f48 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview/webview-environment.ts @@ -0,0 +1,63 @@ +/******************************************************************************** + * Copyright (C) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable, inject, postConstruct } from 'inversify'; +import { Endpoint } from '@theia/core/lib/browser/endpoint'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; +import URI from '@theia/core/lib/common/uri'; +import { WebviewExternalEndpoint } from '../../common/webview-protocol'; + +@injectable() +export class WebviewEnvironment { + + @inject(EnvVariablesServer) + protected readonly environments: EnvVariablesServer; + + protected readonly externalEndpointHost = new Deferred(); + + @postConstruct() + protected async init(): Promise { + try { + const variable = await this.environments.getValue(WebviewExternalEndpoint.pattern); + const value = variable && variable.value || WebviewExternalEndpoint.defaultPattern; + this.externalEndpointHost.resolve(value.replace('{{hostname}}', window.location.host || 'localhost')); + } catch (e) { + this.externalEndpointHost.reject(e); + } + } + + async externalEndpointUrl(): Promise { + const host = await this.externalEndpointHost.promise; + return new Endpoint({ + host, + path: '/webview' + }).getRestUrl(); + } + + async externalEndpoint(): Promise { + return (await this.externalEndpointUrl()).toString(true); + } + + async resourceRoot(): Promise { + return (await this.externalEndpointUrl()).resolve('theia-resource/{{resource}}').toString(true); + } + + async cspSource(): Promise { + return (await this.externalEndpointUrl()).withPath('').withQuery('').withFragment('').toString(true).replace('{{uuid}}', '*'); + } + +} diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index 93bc5a065f788..0c79436d18adf 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -15,16 +15,32 @@ ********************************************************************************/ import { injectable, inject, postConstruct } from 'inversify'; +import { ArrayExt } from '@phosphor/algorithm/lib/array'; +import { WebviewPanelOptions, WebviewPortMapping, Uri } from '@theia/plugin'; import { BaseWidget, Message } from '@theia/core/lib/browser/widgets/widget'; -import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; +import { Disposable } from '@theia/core/lib/common/disposable'; // TODO: get rid of dependencies to the mini browser import { MiniBrowserContentStyle } from '@theia/mini-browser/lib/browser/mini-browser-content-style'; import { ApplicationShellMouseTracker } from '@theia/core/lib/browser/shell/application-shell-mouse-tracker'; +import { StatefulWidget } from '@theia/core/lib/browser/shell/shell-layout-restorer'; +import { WebviewPanelViewState } from '../../../common/plugin-api-rpc'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { WebviewEnvironment } from './webview-environment'; +import URI from '@theia/core/lib/common/uri'; +import { FileSystem } from '@theia/filesystem/lib/common/filesystem'; // tslint:disable:no-any -export interface WebviewWidgetOptions { +export const enum WebviewMessageChannels { + loadResource = 'load-resource', + webviewReady = 'webview-ready' +} + +export interface WebviewContentOptions { readonly allowScripts?: boolean; + readonly localResourceRoots?: ReadonlyArray; + readonly portMapping?: ReadonlyArray; + readonly enableCommandUris?: boolean; } export interface WebviewEvents { @@ -38,35 +54,57 @@ export class WebviewWidgetIdentifier { id: string; } +export const WebviewWidgetExternalEndpoint = Symbol('WebviewWidgetExternalEndpoint'); + @injectable() -export class WebviewWidget extends BaseWidget { +export class WebviewWidget extends BaseWidget implements StatefulWidget { static FACTORY_ID = 'plugin-webview'; - private iframe: HTMLIFrameElement; - private state: { [key: string]: any } | undefined = undefined; - private loadTimeout: number | undefined; - private scrollY: number; - private readyToReceiveMessage: boolean = false; + protected element: HTMLIFrameElement; + // tslint:disable-next-line:max-line-length - // XXX This is a hack to be able to tack the mouse events when drag and dropping the widgets. On `mousedown` we put a transparent div over the `iframe` to avoid losing the mouse tacking. - protected readonly transparentOverlay: HTMLElement; + // XXX This is a hack to be able to tack the mouse events when drag and dropping the widgets. + // On `mousedown` we put a transparent div over the `iframe` to avoid losing the mouse tacking. + protected transparentOverlay: HTMLElement; @inject(WebviewWidgetIdentifier) - protected readonly identifier: WebviewWidgetIdentifier; + readonly identifier: WebviewWidgetIdentifier; + + @inject(WebviewWidgetExternalEndpoint) + readonly externalEndpoint: string; @inject(ApplicationShellMouseTracker) protected readonly mouseTracker: ApplicationShellMouseTracker; - private options: WebviewWidgetOptions = {}; + @inject(WebviewEnvironment) + protected readonly environment: WebviewEnvironment; + + @inject(FileSystem) + protected readonly fileSystem: FileSystem; + + viewState: WebviewPanelViewState = { + visible: false, + active: false, + position: 0 + }; + + protected html = ''; + protected contentOptions: WebviewContentOptions = {}; + state: any; + + viewType: string; + options: WebviewPanelOptions = {}; eventDelegate: WebviewEvents = {}; - constructor() { - super(); + protected readonly ready = new Deferred(); + + @postConstruct() + protected init(): void { this.node.tabIndex = 0; + this.id = WebviewWidget.FACTORY_ID + ':' + this.identifier.id; this.title.closable = true; this.addClass(WebviewWidget.Styles.WEBVIEW); - this.scrollY = 0; this.transparentOverlay = document.createElement('div'); this.transparentOverlay.classList.add(MiniBrowserContentStyle.TRANSPARENT_OVERLAY); @@ -74,277 +112,205 @@ export class WebviewWidget extends BaseWidget { this.node.appendChild(this.transparentOverlay); this.toDispose.push(this.mouseTracker.onMousedown(() => { - if (this.iframe.style.display !== 'none') { + if (this.element.style.display !== 'none') { this.transparentOverlay.style.display = 'block'; } })); this.toDispose.push(this.mouseTracker.onMouseup(() => { - if (this.iframe.style.display !== 'none') { + if (this.element.style.display !== 'none') { this.transparentOverlay.style.display = 'none'; } })); - } - - @postConstruct() - protected init(): void { - this.id = WebviewWidget.FACTORY_ID + ':' + this.identifier.id; - } - - protected handleMessage(message: any): void { - switch (message.command) { - case 'onmessage': - this.eventDelegate.onMessage!(message.data); - break; - case 'do-update-state': - this.state = message.data; - } - } - async postMessage(message: any): Promise { - // wait message can be delivered - await this.waitReadyToReceiveMessage(); - this.iframe.contentWindow!.postMessage(message, '*'); + const element = document.createElement('iframe'); + element.className = 'webview'; + element.sandbox.add('allow-scripts', 'allow-same-origin'); + element.setAttribute('src', `${this.externalEndpoint}/index.html?id=${this.identifier.id}`); + element.style.border = 'none'; + element.style.width = '100%'; + element.style.height = '100%'; + this.element = element; + this.node.appendChild(this.element); + + const subscription = this.on(WebviewMessageChannels.webviewReady, () => { + subscription.dispose(); + this.ready.resolve(); + }); + this.toDispose.push(subscription); + this.toDispose.push(this.on(WebviewMessageChannels.loadResource, (entry: any) => { + const rawPath = entry.path; + const normalizedPath = decodeURIComponent(rawPath); + const uri = new URI(normalizedPath.replace(/^\/(\w+)\/(.+)$/, (_, scheme, path) => scheme + ':/' + path)); + this.loadResource(rawPath, uri); + })); } - setOptions(options: WebviewWidgetOptions): void { - if (this.options.allowScripts === options.allowScripts) { - return; - } - this.options = options; - if (!this.iframe) { + setContentOptions(contentOptions: WebviewContentOptions): void { + if (WebviewWidget.compareWebviewContentOptions(this.contentOptions, contentOptions)) { return; } - this.updateSandboxAttribute(this.iframe, options.allowScripts); - this.reloadFrame(); + this.contentOptions = contentOptions; + this.doUpdateContent(); } setIconClass(iconClass: string): void { this.title.iconClass = iconClass; } - protected readonly toDisposeOnHTML = new DisposableCollection(); + setHTML(value: string): void { + this.html = this.preprocessHtml(value); + this.doUpdateContent(); + } - setHTML(html: string): void { - const newDocument = new DOMParser().parseFromString(html, 'text/html'); - if (!newDocument || !newDocument.body) { - return; - } + protected preprocessHtml(value: string): string { + return value + .replace(/(["'])theia-resource:(\/\/([^\s\/'"]+?)(?=\/))?([^\s'"]+?)(["'])/gi, (_, startQuote, _1, scheme, path, endQuote) => { + if (scheme) { + return `${startQuote}${this.externalEndpoint}/theia-resource/${scheme}${path}${endQuote}`; + } + return `${startQuote}${this.externalEndpoint}/theia-resource/file${path}${endQuote}`; + }); + } - this.toDisposeOnHTML.dispose(); - this.toDispose.push(this.toDisposeOnHTML); + protected onActivateRequest(msg: Message): void { + super.onActivateRequest(msg); + this.focus(); + } - (newDocument.querySelectorAll('a')).forEach((a: any) => { - if (!a.title) { - a.title = a.href; - } - }); + focus(): void { + if (this.element) { + this.doSend('focus'); + } + } - (window as any)[`postMessageExt${this.id}`] = (e: any) => { - this.handleMessage(e); - }; - this.toDisposeOnHTML.push(Disposable.create(() => - delete (window as any)[`postMessageExt${this.id}`] - )); - this.updateApiScript(newDocument); - - const newFrame = document.createElement('iframe'); - newFrame.setAttribute('id', 'pending-frame'); - newFrame.setAttribute('frameborder', '0'); - newFrame.style.cssText = 'display: block; margin: 0; overflow: hidden; position: absolute; width: 100%; height: 100%; visibility: hidden'; - this.node.appendChild(newFrame); - this.iframe = newFrame; - this.toDisposeOnHTML.push(Disposable.create(() => { - newFrame.setAttribute('id', ''); - this.node.removeChild(newFrame); - })); + reload(): void { + this.doUpdateContent(); + } - newFrame.contentDocument!.open('text/html', 'replace'); + protected async loadResource(requestPath: string, uri: URI): Promise { + try { + const normalizedUri = this.normalizeRequestUri(uri); - const onLoad = (contentDocument: any, contentWindow: any) => { - if (newFrame && newFrame.contentDocument === contentDocument) { - newFrame.style.visibility = 'visible'; - } - if (contentDocument.body) { - if (this.eventDelegate && this.eventDelegate.onKeyboardEvent) { - const eventNames = ['keydown', 'keypress', 'click']; - // Delegate events from the `iframe` to the application. - eventNames.forEach((eventName: string) => { - contentDocument.addEventListener(eventName, this.eventDelegate.onKeyboardEvent!, true); - this.toDispose.push(Disposable.create(() => contentDocument.removeEventListener(eventName, this.eventDelegate.onKeyboardEvent!))); + if (this.contentOptions.localResourceRoots) { + for (const root of this.contentOptions.localResourceRoots) { + if (!new URI(root).path.isEqualOrParent(normalizedUri.path)) { + continue; + } + const { content } = await this.fileSystem.resolveContent(normalizedUri.toString()); + return this.doSend('did-load-resource', { + status: 200, + path: requestPath, + mime: 'text/plain', // TODO detect mimeType from URI extension + data: content }); } - if (this.eventDelegate && this.eventDelegate.onLoad) { - this.eventDelegate.onLoad(contentDocument); - } - } - }; - - this.loadTimeout = window.setTimeout(() => { - clearTimeout(this.loadTimeout); - this.loadTimeout = undefined; - onLoad(newFrame.contentDocument, newFrame.contentWindow); - }, 200); - this.toDisposeOnHTML.push(Disposable.create(() => { - if (typeof this.loadTimeout === 'number') { - clearTimeout(this.loadTimeout); - this.loadTimeout = undefined; } - })); - - newFrame.contentWindow!.addEventListener('load', e => { - if (this.loadTimeout) { - clearTimeout(this.loadTimeout); - this.loadTimeout = undefined; - onLoad(e.target, newFrame.contentWindow); - } - }, { once: true }); - newFrame.contentDocument!.write(newDocument!.documentElement!.innerHTML); - newFrame.contentDocument!.close(); + } catch { + // no-op + } - this.updateSandboxAttribute(newFrame); + return this.doSend('did-load-resource', { + status: 404, + path: requestPath + }); } - protected onActivateRequest(msg: Message): void { - super.onActivateRequest(msg); - // restore scrolling if there was one - if (this.scrollY > 0) { - this.iframe.contentWindow!.scrollTo({ top: this.scrollY }); + protected normalizeRequestUri(requestUri: URI): URI { + if (requestUri.scheme !== 'theia-resource') { + return requestUri; + } + + // Modern vscode-resources uris put the scheme of the requested resource as the authority + if (requestUri.authority) { + return new URI(requestUri.authority + ':' + requestUri.path); } - this.node.focus(); - // unblock messages - this.readyToReceiveMessage = true; - } - // block messages - protected onBeforeShow(msg: Message): void { - this.readyToReceiveMessage = false; + // Old style vscode-resource uris lose the scheme of the resource which means they are unable to + // load a mix of local and remote content properly. + return requestUri.withScheme('file'); } - protected onBeforeHide(msg: Message): void { - // persist scrolling - if (this.iframe.contentWindow) { - this.scrollY = this.iframe.contentWindow.scrollY; - } - super.onBeforeHide(msg); + sendMessage(data: any): void { + this.doSend('message', data); } - public reloadFrame(): void { - if (!this.iframe || !this.iframe.contentDocument || !this.iframe.contentDocument.documentElement) { - return; - } - this.setHTML(this.iframe.contentDocument.documentElement.innerHTML); + protected doUpdateContent(): void { + this.doSend('content', { + contents: this.html, + options: this.contentOptions, + state: this.state + }); } - private updateSandboxAttribute(element: HTMLElement, isAllowScript?: boolean): void { - if (!element) { - return; - } - const allowScripts = isAllowScript !== undefined ? isAllowScript : this.options.allowScripts; - element.setAttribute('sandbox', allowScripts ? 'allow-scripts allow-forms allow-same-origin' : 'allow-same-origin'); + storeState(): WebviewWidget.State { + return { + viewType: this.viewType, + title: this.title.label, + options: this.options, + contentOptions: this.contentOptions, + state: this.state + }; } - private updateApiScript(contentDocument: Document, isAllowScript?: boolean): void { - if (!contentDocument) { - return; - } - const allowScripts = isAllowScript !== undefined ? isAllowScript : this.options.allowScripts; - const scriptId = 'webview-widget-codeApi'; - if (!allowScripts) { - const script = contentDocument.getElementById(scriptId); - if (!script) { - return; - } - script!.parentElement!.removeChild(script!); - return; - } + restoreState(oldState: WebviewWidget.State): void { + const { viewType, title, options, contentOptions, state } = oldState; + this.viewType = viewType; + this.title.label = title; + this.options = options; + this.contentOptions = contentOptions; + this.state = state; + } - const codeApiScript = contentDocument.createElement('script'); - codeApiScript.id = scriptId; - codeApiScript.textContent = ` - window.postMessageExt = window.parent['postMessageExt${this.id}']; - const acquireVsCodeApi = (function() { - let acquired = false; - let state = ${this.state ? `JSON.parse(${JSON.stringify(this.state)})` : undefined}; - return () => { - if (acquired) { - throw new Error('An instance of the VS Code API has already been acquired'); - } - acquired = true; - return Object.freeze({ - postMessage: function(msg) { - return window.postMessageExt({ command: 'onmessage', data: msg }, '*'); - }, - setState: function(newState) { - state = newState; - window.postMessageExt({ command: 'do-update-state', data: JSON.stringify(newState) }, '*'); - return newState; - }, - getState: function() { - return state; - } - }); - }; - })(); - const acquireTheiaApi = (function() { - let acquired = false; - let state = ${this.state ? `JSON.parse(${JSON.stringify(this.state)})` : undefined}; - return () => { - if (acquired) { - throw new Error('An instance of the VS Code API has already been acquired'); - } - acquired = true; - return Object.freeze({ - postMessage: function(msg) { - return window.postMessageExt({ command: 'onmessage', data: msg }, '*'); - }, - setState: function(newState) { - state = newState; - window.postMessageExt({ command: 'do-update-state', data: JSON.stringify(newState) }, '*'); - return newState; - }, - getState: function() { - return state; - } - }); - }; - })(); - delete window.parent; - delete window.top; - delete window.frameElement; - `; - const parent = contentDocument.head ? contentDocument.head : contentDocument.body; - if (parent.hasChildNodes()) { - parent.insertBefore(codeApiScript, parent.firstChild); - } else { - parent.appendChild(codeApiScript); + protected async doSend(channel: string, data?: any): Promise { + try { + await this.ready.promise; + this.postMessage(channel, data); + } catch (e) { + console.error(e); } } - /** - * Check if given object is ready to receive message and if it is ready, resolve promise - */ - waitReceiveMessage(object: WebviewWidget, resolve: any): void { - if (object.readyToReceiveMessage) { - resolve(true); - } else { - setTimeout(this.waitReceiveMessage, 100, object, resolve); + protected postMessage(channel: string, data?: any): void { + if (this.element) { + this.element.contentWindow!.postMessage({ channel, args: data }, '*'); } } - /** - * Block until we're able to receive message - */ - public async waitReadyToReceiveMessage(): Promise { - return new Promise((resolve, reject) => { - this.waitReceiveMessage(this, resolve); - }); + protected on(channel: WebviewMessageChannels, handler: (data: T) => void): Disposable { + const listener = (e: any) => { + if (!e || !e.data || e.data.target !== this.identifier.id) { + return; + } + if (e.data.channel === channel) { + handler(e.data.data); + } + }; + window.addEventListener('message', listener); + return Disposable.create(() => + window.removeEventListener('message', listener) + ); } -} +} export namespace WebviewWidget { export namespace Styles { - export const WEBVIEW = 'theia-webview'; - + } + export interface State { + viewType: string + title: string + options: WebviewPanelOptions + // TODO serialize/revive URIs + contentOptions: WebviewContentOptions + state: any + // TODO: preserve icon class + } + export function compareWebviewContentOptions(a: WebviewContentOptions, b: WebviewContentOptions): boolean { + return a.enableCommandUris === b.enableCommandUris + && a.allowScripts === b.allowScripts && + ArrayExt.shallowEqual(a.localResourceRoots || [], b.localResourceRoots || [], (uri, uri2) => uri.toString() === uri2.toString()) && + ArrayExt.shallowEqual(a.portMapping || [], b.portMapping || [], (m, m2) => + m.extensionHostPort === m2.extensionHostPort && m.webviewPort === m2.webviewPort + ); } } diff --git a/packages/plugin-ext/src/main/browser/webviews-main.ts b/packages/plugin-ext/src/main/browser/webviews-main.ts index 1746fd1b751e1..37bf98fe17fd3 100644 --- a/packages/plugin-ext/src/main/browser/webviews-main.ts +++ b/packages/plugin-ext/src/main/browser/webviews-main.ts @@ -15,10 +15,9 @@ ********************************************************************************/ import debounce = require('lodash.debounce'); -import { WebviewsMain, MAIN_RPC_CONTEXT, WebviewsExt } from '../../common/plugin-api-rpc'; +import { WebviewsMain, MAIN_RPC_CONTEXT, WebviewsExt, WebviewPanelViewState } from '../../common/plugin-api-rpc'; import { interfaces } from 'inversify'; import { RPCProtocol } from '../../common/rpc-protocol'; -import { UriComponents } from '../../common/uri-components'; import { WebviewOptions, WebviewPanelOptions, WebviewPanelShowOptions } from '@theia/plugin'; import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding'; @@ -28,6 +27,9 @@ import { ThemeRulesService } from './webview/theme-rules-service'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; import { ViewColumnService } from './view-column-service'; import { WidgetManager } from '@theia/core/lib/browser/widget-manager'; +import { HostedPluginSupport } from '../../hosted/browser/hosted-plugin'; +import { JSONExt } from '@phosphor/coreutils/lib/json'; +import { Mutable } from '@theia/core/lib/common/types'; export class WebviewsMainImpl implements WebviewsMain, Disposable { @@ -39,16 +41,6 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { protected readonly keybindingRegistry: KeybindingRegistry; protected readonly themeService = ThemeService.get(); protected readonly themeRulesService = ThemeRulesService.get(); - protected readonly updateViewOptions: () => void; - - private readonly viewsOptions = new Map(); - private readonly toDispose = new DisposableCollection(); constructor(rpc: RPCProtocol, container: interfaces.Container) { @@ -57,14 +49,20 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { this.keybindingRegistry = container.get(KeybindingRegistry); this.viewColumnService = container.get(ViewColumnService); this.widgets = container.get(WidgetManager); - this.updateViewOptions = debounce<() => void>(() => { - for (const key of this.viewsOptions.keys()) { - this.checkViewOptions(key); + const pluginService = container.get(HostedPluginSupport); + this.toDispose.push(this.shell.onDidChangeActiveWidget(() => this.updateViewStates())); + this.toDispose.push(this.shell.onDidChangeCurrentWidget(() => this.updateViewStates())); + this.toDispose.push(this.viewColumnService.onViewColumnChanged(() => this.updateViewStates())); + this.toDispose.push(this.widgets.onDidCreateWidget(({ factoryId, widget }) => { + if (factoryId === WebviewWidget.FACTORY_ID && widget instanceof WebviewWidget) { + const restoreState = widget.restoreState.bind(widget); + widget.restoreState = async oldState => { + restoreState(oldState); + await pluginService.activateByEvent(`onWebviewPanel:${widget.viewType}`); + this.restoreWidget(widget); + }; } - }, 100); - this.toDispose.push(this.shell.onDidChangeActiveWidget(() => this.updateViewOptions())); - this.toDispose.push(this.shell.onDidChangeCurrentWidget(() => this.updateViewOptions())); - this.toDispose.push(this.viewColumnService.onViewColumnChanged(() => this.updateViewOptions())); + })); } dispose(): void { @@ -73,24 +71,28 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { async $createWebviewPanel( panelId: string, - // TODO check webview API completness, implement or get rid of missing APIs viewType: string, title: string, showOptions: WebviewPanelShowOptions, - options: (WebviewPanelOptions & WebviewOptions) | undefined, - // TODO check webview API completness, implement or get rid of missing APIs - extensionLocation: UriComponents + options: WebviewPanelOptions & WebviewOptions ): Promise { - const toDisposeOnClose = new DisposableCollection(); - const toDisposeOnLoad = new DisposableCollection(); const view = await this.widgets.getOrCreateWidget(WebviewWidget.FACTORY_ID, { id: panelId }); + this.hookWebview(view); + view.viewType = viewType; view.title.label = title; - view.setOptions({ - allowScripts: options ? options.enableScripts : false - }); + const { enableFindWidget, retainContextWhenHidden, enableScripts, ...contentOptions } = options; + view.options = { enableFindWidget, retainContextWhenHidden }; + view.setContentOptions({ allowScripts: enableScripts, ...contentOptions }); + this.addOrReattachWidget(panelId, showOptions); + } + + protected hookWebview(view: WebviewWidget): void { + const handle = view.identifier.id; + const toDisposeOnClose = new DisposableCollection(); + const toDisposeOnLoad = new DisposableCollection(); view.eventDelegate = { onMessage: m => { - this.proxy.$onMessage(panelId, m); + this.proxy.$onMessage(handle, m); }, onKeyboardEvent: e => { this.keybindingRegistry.run(e); @@ -121,22 +123,16 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { }; view.disposed.connect(() => { toDisposeOnClose.dispose(); - this.proxy.$onDidDisposeWebviewPanel(panelId); + this.proxy.$onDidDisposeWebviewPanel(handle); }); - this.toDispose.push(view); - - const viewId = view.id; - toDisposeOnClose.push(Disposable.create(() => this.themeRulesService.setIconPath(viewId, undefined))); - this.viewsOptions.set(viewId, { panelOptions: showOptions, options: options, panelId, visible: false, active: false }); - toDisposeOnClose.push(Disposable.create(() => this.viewsOptions.delete(viewId))); - - this.addOrReattachWidget(panelId, showOptions); + this.toDispose.push(view); + toDisposeOnClose.push(Disposable.create(() => this.themeRulesService.setIconPath(handle, undefined))); } - private addOrReattachWidget(handle: string, showOptions: WebviewPanelShowOptions): void { - const view = this.tryGetWebview(handle); - if (!view) { + private async addOrReattachWidget(handle: string, showOptions: WebviewPanelShowOptions): Promise { + const widget = await this.tryGetWebview(handle); + if (!widget) { return; } const widgetOptions: ApplicationShell.WidgetOptions = { area: showOptions.area ? showOptions.area : 'main' }; @@ -159,137 +155,163 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { widgetIds = this.viewColumnService.getViewColumnIds(showOptions.viewColumn); } } - const ref = this.shell.getWidgets(widgetOptions.area).find(widget => widget.isVisible && widgetIds.indexOf(widget.id) !== -1); + const ref = this.shell.getWidgets(widgetOptions.area).find(w => !w.isHidden && widgetIds.indexOf(w.id) !== -1); if (ref) { Object.assign(widgetOptions, { ref, mode }); } } - this.shell.addWidget(view, widgetOptions); - const visible = true; - let active: boolean; + this.shell.addWidget(widget, widgetOptions); if (showOptions.preserveFocus) { - this.shell.revealWidget(view.id); - active = false; + this.shell.revealWidget(widget.id); } else { - this.shell.activateWidget(view.id); - active = true; + this.shell.activateWidget(widget.id); } - const options = this.viewsOptions.get(view.id); - if (!options) { - return; - } - options.panelOptions = showOptions; - options.visible = visible; - options.active = active; + this.updateViewState(widget, showOptions.viewColumn); + this.updateViewStates(); } - $disposeWebview(handle: string): void { - const view = this.tryGetWebview(handle); + + async $disposeWebview(handle: string): Promise { + const view = await this.tryGetWebview(handle); if (view) { view.dispose(); } } - $reveal(handle: string, showOptions: WebviewPanelShowOptions): void { - const view = this.getWebview(handle); - if (view.isDisposed) { + + async $reveal(handle: string, showOptions: WebviewPanelShowOptions): Promise { + const widget = await this.getWebview(handle); + if (widget.isDisposed) { return; } - const options = this.viewsOptions.get(view.id); - let retain = false; - if (options && options.options && options.options.retainContextWhenHidden) { - retain = options.options.retainContextWhenHidden; - } - if ((showOptions.viewColumn !== undefined && showOptions.viewColumn !== options!.panelOptions.viewColumn) || showOptions.area !== undefined) { + if ((showOptions.viewColumn !== undefined && showOptions.viewColumn !== widget.viewState.position) || showOptions.area !== undefined) { this.viewColumnService.updateViewColumns(); - if (!options) { - return; - } const columnIds = showOptions.viewColumn ? this.viewColumnService.getViewColumnIds(showOptions.viewColumn) : []; - if (columnIds.indexOf(view.id) === -1 || options.panelOptions.area !== showOptions.area) { - this.addOrReattachWidget(options.panelId, showOptions); - options.panelOptions = showOptions; - this.checkViewOptions(view.id, options.panelOptions.viewColumn); - this.updateViewOptions(); + const area = this.shell.getAreaFor(widget); + if (columnIds.indexOf(widget.id) === -1 || area !== showOptions.area) { + this.addOrReattachWidget(widget.identifier.id, showOptions); return; } - } else if (!retain) { + } else if (!widget.options.retainContextWhenHidden) { // reload content when revealing - view.reloadFrame(); + widget.reload(); } if (showOptions.preserveFocus) { - this.shell.revealWidget(view.id); + this.shell.revealWidget(widget.id); } else { - this.shell.activateWidget(view.id); + this.shell.activateWidget(widget.id); } } - $setTitle(handle: string, value: string): void { - const webview = this.getWebview(handle); + + async $setTitle(handle: string, value: string): Promise { + const webview = await this.getWebview(handle); webview.title.label = value; } - $setIconPath(handle: string, iconPath: { light: string; dark: string; } | string | undefined): void { - const webview = this.getWebview(handle); - webview.setIconClass(iconPath ? `webview-icon ${webview.id}-file-icon` : ''); - this.themeRulesService.setIconPath(webview.id, iconPath); + + async $setIconPath(handle: string, iconPath: { light: string; dark: string; } | string | undefined): Promise { + const webview = await this.getWebview(handle); + webview.setIconClass(iconPath ? `webview-icon ${handle}-file-icon` : ''); + this.themeRulesService.setIconPath(handle, iconPath); } - $setHtml(handle: string, value: string): void { - const webview = this.getWebview(handle); + + async $setHtml(handle: string, value: string): Promise { + const webview = await this.getWebview(handle); webview.setHTML(value); } - $setOptions(handle: string, options: WebviewOptions): void { - const webview = this.getWebview(handle); - webview.setOptions({ allowScripts: options ? options.enableScripts : false }); + + async $setOptions(handle: string, options: WebviewOptions): Promise { + const webview = await this.getWebview(handle); + const { enableScripts, ...contentOptions } = options; + webview.setContentOptions({ allowScripts: enableScripts, ...contentOptions }); } + // tslint:disable-next-line:no-any - $postMessage(handle: string, value: any): Thenable { - const webview = this.getWebview(handle); - if (webview) { - webview.postMessage(value); - } - return Promise.resolve(webview !== undefined); + async $postMessage(handle: string, value: any): Promise { + const webview = await this.getWebview(handle); + webview.sendMessage(value); + return true; } + $registerSerializer(viewType: string): void { + if (this.revivers.has(viewType)) { + throw new Error(`Reviver for ${viewType} already registered`); + } this.revivers.add(viewType); this.toDispose.push(Disposable.create(() => this.$unregisterSerializer(viewType))); } + $unregisterSerializer(viewType: string): void { this.revivers.delete(viewType); } - private async checkViewOptions(handle: string, viewColumn?: number | undefined): Promise { - const options = this.viewsOptions.get(handle); - if (!options || !options.panelOptions) { + protected async restoreWidget(widget: WebviewWidget): Promise { + const viewType = widget.viewType; + if (!this.revivers.has(viewType)) { + widget.setHTML(this.getDeserializationFailedContents(viewType)); return; } - const view = this.tryGetWebview(handle); - if (!view) { - return; - } - const active = !!this.shell.activeWidget ? this.shell.activeWidget.id === view!.id : false; - const visible = view!.isVisible; - if (viewColumn === undefined) { + try { + this.hookWebview(widget); + const handle = widget.identifier.id; + const title = widget.title.label; + const state = widget.state; + const options = widget.options; this.viewColumnService.updateViewColumns(); - viewColumn = this.viewColumnService.hasViewColumn(view.id) ? this.viewColumnService.getViewColumn(view.id)! : 0; - if (options.panelOptions.viewColumn === viewColumn && options.visible === visible && options.active === active) { - return; + const position = this.viewColumnService.getViewColumn(widget.id) || 0; + await this.proxy.$deserializeWebviewPanel(handle, viewType, title, state, position, options); + } catch (e) { + widget.setHTML(this.getDeserializationFailedContents(viewType)); + console.error('Failed to restore the webview', e); + } + } + + protected getDeserializationFailedContents(viewType: string): string { + return ` + + + + + + An error occurred while restoring view:${viewType} + `; + } + + protected readonly updateViewStates = debounce(() => { + for (const widget of this.widgets.getWidgets(WebviewWidget.FACTORY_ID)) { + if (widget instanceof WebviewWidget) { + this.updateViewState(widget); } } - options.active = active; - options.visible = visible; - options.panelOptions.viewColumn = viewColumn; - this.proxy.$onDidChangeWebviewPanelViewState(options.panelId, { active, visible, position: options.panelOptions.viewColumn! }); + }, 100); + + private async updateViewState(widget: WebviewWidget, viewColumn?: number | undefined): Promise { + const viewState: Mutable = { + active: this.shell.activeWidget === widget, + visible: !widget.isHidden, + position: viewColumn || 0 + }; + if (typeof viewColumn !== 'number') { + this.viewColumnService.updateViewColumns(); + viewState.position = this.viewColumnService.getViewColumn(widget.id) || 0; + } + // tslint:disable-next-line:no-any + if (JSONExt.deepEqual(viewState, widget.viewState)) { + return; + } + widget.viewState = viewState; + this.proxy.$onDidChangeWebviewPanelViewState(widget.identifier.id, widget.viewState); } - private getWebview(viewId: string): WebviewWidget { - const webview = this.tryGetWebview(viewId); + private async getWebview(viewId: string): Promise { + const webview = await this.tryGetWebview(viewId); if (!webview) { throw new Error(`Unknown Webview: ${viewId}`); } return webview; } - private tryGetWebview(id: string): WebviewWidget | undefined { - return this.widgets.tryGetWidget(WebviewWidget.FACTORY_ID, { id }); + private async tryGetWebview(id: string): Promise { + return this.widgets.getWidget(WebviewWidget.FACTORY_ID, { id }); } } diff --git a/packages/plugin-ext/src/main/common/webview-protocol.ts b/packages/plugin-ext/src/main/common/webview-protocol.ts new file mode 100644 index 0000000000000..ee1d194132685 --- /dev/null +++ b/packages/plugin-ext/src/main/common/webview-protocol.ts @@ -0,0 +1,27 @@ +/******************************************************************************** + * Copyright (C) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +/** + * Each webview should be deployed on a unique origin (https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) + * to ensure isolation from browser shared state as cookies, local storage and so on. + * + * Use `THEIA_WEBVIEW_EXTERNAL_ENDPOINT` to customize the hostname pattern of a origin. + * By default is `{{uuid}}.webview.{{hostname}}`. Where `{{uuid}}` is a placeholder for a webview global id. + */ +export namespace WebviewExternalEndpoint { + export const pattern = 'THEIA_WEBVIEW_EXTERNAL_ENDPOINT'; + export const defaultPattern = '{{uuid}}.webview.{{hostname}}'; +} diff --git a/packages/plugin-ext/src/main/node/plugin-service.ts b/packages/plugin-ext/src/main/node/plugin-service.ts index 7715448592973..1f2572f5672bb 100644 --- a/packages/plugin-ext/src/main/node/plugin-service.ts +++ b/packages/plugin-ext/src/main/node/plugin-service.ts @@ -13,23 +13,37 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ + +import * as path from 'path'; +import connect = require('connect'); +import serveStatic = require('serve-static'); +const vhost = require('vhost'); import * as express from 'express'; import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application'; import { injectable } from 'inversify'; -import { FileUri } from '@theia/core/lib/node'; +import { WebviewExternalEndpoint } from '../common/webview-protocol'; const pluginPath = (process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE) + './theia/plugins/'; @injectable() export class PluginApiContribution implements BackendApplicationContribution { + configure(app: express.Application): void { app.get('/plugin/:path(*)', (req, res) => { const filePath: string = req.params.path; res.sendFile(pluginPath + filePath); }); - app.get('/webview/:path(*)', (req, res) => { - res.sendFile(FileUri.fsPath('file:/' + req.params.path)); - }); + const webviewApp = connect(); + webviewApp.use('/webview', serveStatic(path.join(__dirname, '../../../src/main/browser/webview/pre'))); + const webviewExternalEndpoint = this.webviewExternalEndpoint(); + console.log(`Configuring to accept webviews on '${webviewExternalEndpoint}' hostname.`); + app.use(vhost(new RegExp(webviewExternalEndpoint, 'i'), webviewApp)); + } + + protected webviewExternalEndpoint(): string { + return (process.env[WebviewExternalEndpoint.pattern] || WebviewExternalEndpoint.defaultPattern) + .replace('{{uuid}}', '.+') + .replace('{{hostname}}', '.+'); } } diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 2937388b309fd..6fea4ba46766f 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -130,7 +130,6 @@ import { MarkdownString } from './markdown-string'; import { TreeViewsExtImpl } from './tree/tree-views'; import { LanguagesContributionExtImpl } from './languages-contribution-ext'; import { ConnectionExtImpl } from './connection-ext'; -import { WebviewsExtImpl } from './webviews'; import { TasksExtImpl } from './tasks/tasks'; import { DebugExtImpl } from './node/debug/debug'; import { FileSystemExtImpl } from './file-system'; @@ -140,6 +139,7 @@ import { DecorationProvider, LineChange } from '@theia/plugin'; import { DecorationsExtImpl } from './decorations'; import { TextEditorExt } from './text-editor'; import { ClipboardExt } from './clipboard-ext'; +import { WebviewsExtImpl } from './webviews'; export function createAPIFactory( rpc: RPCProtocol, @@ -150,7 +150,8 @@ export function createAPIFactory( editorsAndDocumentsExt: EditorsAndDocumentsExtImpl, workspaceExt: WorkspaceExtImpl, messageRegistryExt: MessageRegistryExt, - clipboard: ClipboardExt + clipboard: ClipboardExt, + webviewExt: WebviewsExtImpl ): PluginAPIFactory { const commandRegistry = rpc.set(MAIN_RPC_CONTEXT.COMMAND_REGISTRY_EXT, new CommandRegistryImpl(rpc)); @@ -165,7 +166,6 @@ export function createAPIFactory( const outputChannelRegistryExt = rpc.set(MAIN_RPC_CONTEXT.OUTPUT_CHANNEL_REGISTRY_EXT, new OutputChannelRegistryExtImpl(rpc)); const languagesExt = rpc.set(MAIN_RPC_CONTEXT.LANGUAGES_EXT, new LanguagesExtImpl(rpc, documents, commandRegistry)); const treeViewsExt = rpc.set(MAIN_RPC_CONTEXT.TREE_VIEWS_EXT, new TreeViewsExtImpl(rpc, commandRegistry)); - const webviewExt = rpc.set(MAIN_RPC_CONTEXT.WEBVIEWS_EXT, new WebviewsExtImpl(rpc)); const tasksExt = rpc.set(MAIN_RPC_CONTEXT.TASKS_EXT, new TasksExtImpl(rpc)); const connectionExt = rpc.set(MAIN_RPC_CONTEXT.CONNECTION_EXT, new ConnectionExtImpl(rpc)); const languagesContributionExt = rpc.set(MAIN_RPC_CONTEXT.LANGUAGES_CONTRIBUTION_EXT, new LanguagesContributionExtImpl(rpc, connectionExt)); @@ -338,11 +338,11 @@ export function createAPIFactory( createWebviewPanel(viewType: string, title: string, showOptions: theia.ViewColumn | theia.WebviewPanelShowOptions, - options: theia.WebviewPanelOptions & theia.WebviewOptions): theia.WebviewPanel { + options: theia.WebviewPanelOptions & theia.WebviewOptions = {}): theia.WebviewPanel { return webviewExt.createWebview(viewType, title, showOptions, options, Uri.file(plugin.pluginPath)); }, registerWebviewPanelSerializer(viewType: string, serializer: theia.WebviewPanelSerializer): theia.Disposable { - return webviewExt.registerWebviewPanelSerializer(viewType, serializer); + return webviewExt.registerWebviewPanelSerializer(viewType, serializer, Uri.file(plugin.pluginPath)); }, get state(): theia.WindowState { return windowStateExt.getWindowState(); diff --git a/packages/plugin-ext/src/plugin/plugin-manager.ts b/packages/plugin-ext/src/plugin/plugin-manager.ts index 4ccd8c4b6b0f5..4f794a2e725d1 100644 --- a/packages/plugin-ext/src/plugin/plugin-manager.ts +++ b/packages/plugin-ext/src/plugin/plugin-manager.ts @@ -39,6 +39,7 @@ import { RPCProtocol } from '../common/rpc-protocol'; import { Emitter } from '@theia/core/lib/common/event'; import * as os from 'os'; import * as fs from 'fs-extra'; +import { WebviewsExtImpl } from './webviews'; export interface PluginHost { @@ -72,7 +73,8 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { 'onDebug', 'onDebugInitialConfigurations', 'onDebugResolve', 'onDebugAdapterProtocolTracker', 'workspaceContains', 'onView', - 'onUri' + 'onUri', + 'onWebviewPanel' ]); private readonly registry = new Map(); @@ -94,6 +96,7 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { private readonly envExt: EnvExtImpl, private readonly storageProxy: KeyValueStorageProxy, private readonly preferencesManager: PreferenceRegistryExtImpl, + private readonly webview: WebviewsExtImpl, private readonly rpc: RPCProtocol ) { this.messageRegistryProxy = this.rpc.getProxy(PLUGIN_RPC_CONTEXT.MESSAGE_REGISTRY_MAIN); @@ -149,6 +152,8 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { if (params.extApi) { this.host.initExtApi(params.extApi); } + + this.webview.init(params.webview); } async $start(params: PluginManagerStartParams): Promise { diff --git a/packages/plugin-ext/src/plugin/webviews.ts b/packages/plugin-ext/src/plugin/webviews.ts index 5a98442659f39..d6bacec72a1ce 100644 --- a/packages/plugin-ext/src/plugin/webviews.ts +++ b/packages/plugin-ext/src/plugin/webviews.ts @@ -15,23 +15,35 @@ ********************************************************************************/ import { v4 } from 'uuid'; -import { WebviewsExt, WebviewPanelViewState, WebviewsMain, PLUGIN_RPC_CONTEXT, /* WebviewsMain, PLUGIN_RPC_CONTEXT */ } from '../common/plugin-api-rpc'; +import { WebviewsExt, WebviewPanelViewState, WebviewsMain, PLUGIN_RPC_CONTEXT, WebviewInitData, /* WebviewsMain, PLUGIN_RPC_CONTEXT */ } from '../common/plugin-api-rpc'; import * as theia from '@theia/plugin'; import { RPCProtocol } from '../common/rpc-protocol'; -import URI from 'vscode-uri/lib/umd'; +import URI from 'vscode-uri'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { fromViewColumn, toViewColumn, toWebviewPanelShowOptions } from './type-converters'; import { Disposable, WebviewPanelTargetArea } from './types-impl'; +import { WorkspaceExtImpl } from './workspace'; export class WebviewsExtImpl implements WebviewsExt { private readonly proxy: WebviewsMain; private readonly webviewPanels = new Map(); - private readonly serializers = new Map(); - - constructor(rpc: RPCProtocol) { + private readonly serializers = new Map(); + private initData: WebviewInitData | undefined; + + constructor( + rpc: RPCProtocol, + private readonly workspace: WorkspaceExtImpl, + ) { this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.WEBVIEWS_MAIN); } + init(initData: WebviewInitData): void { + this.initData = initData; + } + // tslint:disable-next-line:no-any $onMessage(handle: string, message: any): void { const panel = this.getWebviewPanel(handle); @@ -66,43 +78,51 @@ export class WebviewsExtImpl implements WebviewsExt { state: any, position: number, options: theia.WebviewOptions & theia.WebviewPanelOptions): PromiseLike { - const serializer = this.serializers.get(viewType); - if (!serializer) { + if (!this.initData) { + return Promise.reject(new Error('Webviews are not initialized')); + } + const entry = this.serializers.get(viewType); + if (!entry) { return Promise.reject(new Error(`No serializer found for '${viewType}'`)); } + const { serializer, pluginLocation } = entry; - const webview = new WebviewImpl(viewId, this.proxy, options); + const webview = new WebviewImpl(viewId, this.proxy, options, this.initData, this.workspace, pluginLocation); const revivedPanel = new WebviewPanelImpl(viewId, this.proxy, viewType, title, toViewColumn(position)!, options, webview); this.webviewPanels.set(viewId, revivedPanel); return serializer.deserializeWebviewPanel(revivedPanel, state); } - createWebview(viewType: string, + createWebview( + viewType: string, title: string, showOptions: theia.ViewColumn | theia.WebviewPanelShowOptions, - options: (theia.WebviewPanelOptions & theia.WebviewOptions) | undefined, - extensionLocation: URI): theia.WebviewPanel { - + options: theia.WebviewPanelOptions & theia.WebviewOptions, + pluginLocation: URI + ): theia.WebviewPanel { + if (!this.initData) { + throw new Error('Webviews are not initialized'); + } const webviewShowOptions = toWebviewPanelShowOptions(showOptions); const viewId = v4(); - this.proxy.$createWebviewPanel(viewId, viewType, title, webviewShowOptions, options, extensionLocation); + this.proxy.$createWebviewPanel(viewId, viewType, title, webviewShowOptions, options); - const webview = new WebviewImpl(viewId, this.proxy, options); + const webview = new WebviewImpl(viewId, this.proxy, options, this.initData, this.workspace, pluginLocation); const panel = new WebviewPanelImpl(viewId, this.proxy, viewType, title, webviewShowOptions, options, webview); this.webviewPanels.set(viewId, panel); return panel; - } registerWebviewPanelSerializer( viewType: string, - serializer: theia.WebviewPanelSerializer + serializer: theia.WebviewPanelSerializer, + pluginLocation: URI ): theia.Disposable { if (this.serializers.has(viewType)) { throw new Error(`Serializer for '${viewType}' already registered`); } - this.serializers.set(viewType, serializer); + this.serializers.set(viewType, { serializer, pluginLocation }); this.proxy.$registerSerializer(viewType); return new Disposable(() => { @@ -130,10 +150,15 @@ export class WebviewImpl implements theia.Webview { // tslint:disable-next-line:no-any public readonly onDidReceiveMessage: Event = this.onMessageEmitter.event; - constructor(private readonly viewId: string, + constructor( + private readonly viewId: string, private readonly proxy: WebviewsMain, - options: theia.WebviewOptions | undefined) { - this._options = options!; + options: theia.WebviewOptions, + private readonly initData: WebviewInitData, + private readonly workspace: WorkspaceExtImpl, + private readonly pluginLocation: URI + ) { + this._options = options; } dispose(): void { @@ -144,33 +169,30 @@ export class WebviewImpl implements theia.Webview { this.onMessageEmitter.dispose(); } - // tslint:disable-next-line:no-any - postMessage(message: any): PromiseLike { + asWebviewUri(resource: theia.Uri): theia.Uri { + const uri = this.initData.webviewResourceRoot + // Make sure we preserve the scheme of the resource but convert it into a normal path segment + // The scheme is important as we need to know if we are requesting a local or a remote resource. + .replace('{{resource}}', resource.scheme + resource.toString().replace(/^\S+?:/, '')) + .replace('{{uuid}}', this.viewId); + return URI.parse(uri); + } + + get cspSource(): string { + return this.initData.webviewCspSource.replace('{{uuid}}', this.viewId); + } + + get html(): string { this.checkIsDisposed(); - // replace theia-resource: content in the given message - const decoded = JSON.stringify(message); - let newMessage = decoded.replace(new RegExp('theia-resource:/', 'g'), '/webview/'); - if (this._options && this._options.localResourceRoots) { - newMessage = this.filterLocalRoots(newMessage, this._options.localResourceRoots); - } - return this.proxy.$postMessage(this.viewId, JSON.parse(newMessage)); + return this._html; } - protected filterLocalRoots(content: string, localResourceRoots: ReadonlyArray): string { - const webViewsRegExp = /"(\/webview\/.*?)\"/g; - let m; - while ((m = webViewsRegExp.exec(content)) !== null) { - if (m.index === webViewsRegExp.lastIndex) { - webViewsRegExp.lastIndex++; - } - // take group 1 which is webview URL - const url = m[1]; - const isIncluded = localResourceRoots.some((uri): boolean => url.substring('/webview'.length).startsWith(uri.fsPath)); - if (!isIncluded) { - content = content.replace(url, url.replace('/webview', '/webview-disallowed-localroot')); - } + set html(value: string) { + this.checkIsDisposed(); + if (this._html !== value) { + this._html = value; + this.proxy.$setHtml(this.viewId, value); } - return content; } get options(): theia.WebviewOptions { @@ -180,26 +202,20 @@ export class WebviewImpl implements theia.Webview { set options(newOptions: theia.WebviewOptions) { this.checkIsDisposed(); - this.proxy.$setOptions(this.viewId, newOptions); + this.proxy.$setOptions(this.viewId, { + ...newOptions, + localResourceRoots: newOptions.localResourceRoots || [ + ...(this.workspace.workspaceFolders || []).map(x => x.uri), + this.pluginLocation, + ] + }); this._options = newOptions; } - get html(): string { - this.checkIsDisposed(); - return this._html; - } - - set html(html: string) { - let newHtml = html.replace(new RegExp('theia-resource:/', 'g'), '/webview/'); - if (this._options && this._options.localResourceRoots) { - newHtml = this.filterLocalRoots(newHtml, this._options.localResourceRoots); - } - + // tslint:disable-next-line:no-any + postMessage(message: any): PromiseLike { this.checkIsDisposed(); - if (this._html !== newHtml) { - this._html = newHtml; - this.proxy.$setHtml(this.viewId, newHtml); - } + return this.proxy.$postMessage(this.viewId, message); } private checkIsDisposed(): void { diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 562c9e4926f0d..dc51f9439939c 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -2718,6 +2718,21 @@ declare module '@theia/plugin' { update(key: string, value: any): PromiseLike; } + /** + * Defines a port mapping used for localhost inside the webview. + */ + export interface WebviewPortMapping { + /** + * Localhost port to remap inside the webview. + */ + readonly webviewPort: number; + + /** + * Destination port. The `webviewPort` is resolved to this port. + */ + readonly extensionHostPort: number; + } + /** * Content settings for a webview. */ @@ -2744,6 +2759,21 @@ declare module '@theia/plugin' { * Pass in an empty array to disallow access to any local resources. */ readonly localResourceRoots?: ReadonlyArray; + + /** + * Mappings of localhost ports used inside the webview. + * + * Port mapping allow webviews to transparently define how localhost ports are resolved. This can be used + * to allow using a static localhost port inside the webview that is resolved to random port that a service is + * running on. + * + * If a webview accesses localhost content, we recommend that you specify port mappings even if + * the `webviewPort` and `extensionHostPort` ports are the same. + * + * *Note* that port mappings only work for `http` or `https` urls. Websocket urls (e.g. `ws://localhost:3000`) + * cannot be mapped to another port. + */ + readonly portMapping?: ReadonlyArray; } /** @@ -2775,6 +2805,30 @@ declare module '@theia/plugin' { * @param message Body of the message. */ postMessage(message: any): PromiseLike; + + /** + * Convert a uri for the local file system to one that can be used inside webviews. + * + * Webviews cannot directly load resources from the workspace or local file system using `file:` uris. The + * `asWebviewUri` function takes a local `file:` uri and converts it into a uri that can be used inside of + * a webview to load the same resource: + * + * ```ts + * webview.html = `` + * ``` + */ + asWebviewUri(localResource: Uri): Uri; + + /** + * Content security policy source for webview resources. + * + * This is the origin that should be used in a content security policy rule: + * + * ``` + * img-src https: ${webview.cspSource} ...; + * ``` + */ + readonly cspSource: string; } /** @@ -7533,9 +7587,9 @@ declare module '@theia/plugin' { */ readonly name: string; - /** - * The "resolved" [debug configuration](#DebugConfiguration) of this session. - */ + /** + * The "resolved" [debug configuration](#DebugConfiguration) of this session. + */ readonly configuration: DebugConfiguration; /** @@ -8424,10 +8478,10 @@ declare module '@theia/plugin' { } export interface TaskFilter { - /** - * The task version as used in the tasks.json file. - * The string support the package.json semver notation. - */ + /** + * The task version as used in the tasks.json file. + * The string support the package.json semver notation. + */ version?: string; /** @@ -8457,11 +8511,11 @@ declare module '@theia/plugin' { export function fetchTasks(filter?: TaskFilter): PromiseLike; /** - * Executes a task that is managed by VS Code. The returned - * task execution can be used to terminate the task. - * - * @param task the task to execute - */ + * Executes a task that is managed by VS Code. The returned + * task execution can be used to terminate the task. + * + * @param task the task to execute + */ export function executeTask(task: Task): PromiseLike; /** diff --git a/yarn.lock b/yarn.lock index 2fd7fefc56f47..1609539853b5c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -767,7 +767,7 @@ dependencies: "@phosphor/algorithm" "^1.2.0" -"@phosphor/widgets@^1.5.0": +"@phosphor/widgets@^1.9.3": version "1.9.3" resolved "https://registry.yarnpkg.com/@phosphor/widgets/-/widgets-1.9.3.tgz#b8b7ad69fd7cc7af8e8c312ebead0e0965a4cefd" integrity sha512-61jsxloDrW/+WWQs8wOgsS5waQ/MSsXBuhONt0o6mtdeL93HVz7CYO5krOoot5owammfF6oX1z0sDaUYIYgcPA== @@ -898,7 +898,7 @@ resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.4.tgz#8936cffad3c96ec470a2dc26a38c3ba8b9b6f619" integrity sha512-7qvf9F9tMTzo0akeswHPGqgUx/gIaJqrOEET/FCD8CFRkSUHlygQiM5yB6OvjrtdxBVLSyw7COJubsFYs0683g== -"@types/connect@*": +"@types/connect@*", "@types/connect@^3.4.32": version "3.4.32" resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.32.tgz#aa0e9616b9435ccad02bc52b5b454ffc2c70ba28" integrity sha512-4r8qa0quOvh7lGD0pre62CAb1oni1OO6ecJLGCezTmhQ8Fz50Arx9RUszryR8KlgK6avuSXvviL6yWyViQABOg== @@ -1175,7 +1175,7 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45" integrity sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ== -"@types/serve-static@*": +"@types/serve-static@*", "@types/serve-static@^1.13.3": version "1.13.3" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.3.tgz#eb7e1c41c4468272557e897e9171ded5e2ded9d1" integrity sha512-oprSwp094zOglVrXdlo/4bAHtKTAxX6VT8FOZlBKrmyLbNvE1zxZyJ6yikMVtHIvwP45+ZQGJn+FdXGKTozq0g== @@ -3623,6 +3623,16 @@ conf@^2.0.0: pkg-up "^2.0.0" write-file-atomic "^2.3.0" +connect@^3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/connect/-/connect-3.7.0.tgz#5d49348910caa5e07a01800b030d0c35f20484f8" + integrity sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ== + dependencies: + debug "2.6.9" + finalhandler "1.1.2" + parseurl "~1.3.3" + utils-merge "1.0.1" + console-browserify@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" @@ -5402,7 +5412,7 @@ fill-range@^4.0.0: repeat-string "^1.6.1" to-regex-range "^2.1.0" -finalhandler@~1.1.2: +finalhandler@1.1.2, finalhandler@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== @@ -11000,7 +11010,7 @@ serialize-javascript@^1.4.0, serialize-javascript@^1.7.0: resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-1.9.1.tgz#cfc200aef77b600c47da9bb8149c943e798c2fdb" integrity sha512-0Vb/54WJ6k5v8sSWN09S0ora+Hnr+cX40r9F170nT+mSkaxltoE/7R3OrIdBSUv1OoiobH1QoWQbCnAO+e8J1A== -serve-static@1.14.1: +serve-static@1.14.1, serve-static@^1.14.1: version "1.14.1" resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9" integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg== @@ -12532,6 +12542,11 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" +vhost@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/vhost/-/vhost-3.0.2.tgz#2fb1decd4c466aa88b0f9341af33dc1aff2478d5" + integrity sha1-L7HezUxGaqiLD5NBrzPcGv8keNU= + vinyl-file@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/vinyl-file/-/vinyl-file-2.0.0.tgz#a7ebf5ffbefda1b7d18d140fcb07b223efb6751a" From 6f4879e9389bd4c67ee3659d11d26161a987b112 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Wed, 30 Oct 2019 10:44:30 +0000 Subject: [PATCH 04/21] [webview] fix #5647: restore webviews Signed-off-by: Anton Kosyakov --- .../browser/shell/shell-layout-restorer.ts | 3 + .../src/hosted/browser/hosted-plugin.ts | 92 +++++++++++++++++++ .../src/main/browser/webview/webview.ts | 13 ++- .../src/main/browser/webviews-main.ts | 90 +++++++----------- 4 files changed, 136 insertions(+), 62 deletions(-) diff --git a/packages/core/src/browser/shell/shell-layout-restorer.ts b/packages/core/src/browser/shell/shell-layout-restorer.ts index 1e348a27fdc2c..dbd7f64761ed3 100644 --- a/packages/core/src/browser/shell/shell-layout-restorer.ts +++ b/packages/core/src/browser/shell/shell-layout-restorer.ts @@ -333,6 +333,9 @@ export class ShellLayoutRestorer implements CommandContribution { this.logger.warn(`Couldn't restore widget state for ${widget.id}. Error: ${e} `); } } + if (widget.isDisposed) { + return undefined; + } return widget; } catch (e) { if (ApplicationShellLayoutMigrationError.is(e)) { diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts index 6cf5551625e5a..b30e6286ee8a9 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts @@ -54,6 +54,8 @@ import { FrontendApplicationStateService } from '@theia/core/lib/browser/fronten import { PluginViewRegistry } from '../../main/browser/view/plugin-view-registry'; import { TaskProviderRegistry, TaskResolverRegistry } from '@theia/task/lib/browser/task-contribution'; import { WebviewEnvironment } from '../../main/browser/webview/webview-environment'; +import { WebviewWidget } from '../../main/browser/webview/webview'; +import { WidgetManager } from '@theia/core/lib/browser/widget-manager'; export type PluginHost = 'frontend' | string; export type DebugActivationEvent = 'onDebugResolve' | 'onDebugInitialConfigurations' | 'onDebugAdapterProtocolTracker'; @@ -131,6 +133,9 @@ export class HostedPluginSupport { @inject(WebviewEnvironment) protected readonly webviewEnvironment: WebviewEnvironment; + @inject(WidgetManager) + protected readonly widgets: WidgetManager; + private theiaReadyPromise: Promise; protected readonly managers = new Map(); @@ -158,6 +163,26 @@ export class HostedPluginSupport { this.viewRegistry.onDidExpandView(id => this.activateByView(id)); this.taskProviderRegistry.onWillProvideTaskProvider(event => this.ensureTaskActivation(event)); this.taskResolverRegistry.onWillProvideTaskResolver(event => this.ensureTaskActivation(event)); + this.widgets.onDidCreateWidget(({ factoryId, widget }) => { + if (factoryId === WebviewWidget.FACTORY_ID && widget instanceof WebviewWidget) { + const storeState = widget.storeState.bind(widget); + const restoreState = widget.restoreState.bind(widget); + widget.storeState = () => { + if (this.webviewRevivers.has(widget.viewType)) { + return storeState(); + } + return {}; + }; + widget.restoreState = oldState => { + if (oldState.viewType) { + restoreState(oldState); + this.preserveWebview(widget); + } else { + widget.dispose(); + } + }; + } + }); } get plugins(): PluginMetadata[] { @@ -185,6 +210,7 @@ export class HostedPluginSupport { protected async doLoad(): Promise { const toDisconnect = new DisposableCollection(Disposable.create(() => { /* mark as connected */ })); + toDisconnect.push(Disposable.create(() => this.preserveWebviews())); this.server.onDidCloseConnection(() => toDisconnect.dispose()); // process empty plugins as well in order to properly remove stale plugin widgets @@ -211,6 +237,7 @@ export class HostedPluginSupport { return; } await this.startPlugins(contributionsByHost, toDisconnect); + this.restoreWebviews(); } /** @@ -564,6 +591,71 @@ export class HostedPluginSupport { console.log(`[${this.clientId}] ${prefix} of ${pluginCount} took: ${measurement()} ms`); } + protected readonly webviewsToRestore = new Set(); + protected readonly webviewRevivers = new Map Promise>(); + + registerWebviewReviver(viewType: string, reviver: (webview: WebviewWidget) => Promise): void { + if (this.webviewRevivers.has(viewType)) { + throw new Error(`Reviver for ${viewType} already registered`); + } + this.webviewRevivers.set(viewType, reviver); + } + + unregisterWebviewReviver(viewType: string): void { + this.webviewRevivers.delete(viewType); + } + + protected preserveWebviews(): void { + for (const webview of this.widgets.getWidgets(WebviewWidget.FACTORY_ID)) { + this.preserveWebview(webview as WebviewWidget); + } + } + + protected preserveWebview(webview: WebviewWidget): void { + if (!this.webviewsToRestore.has(webview)) { + this.webviewsToRestore.add(webview); + webview.disposed.connect(() => this.webviewsToRestore.delete(webview)); + } + } + + protected restoreWebviews(): void { + for (const webview of this.webviewsToRestore) { + this.restoreWebview(webview); + } + this.webviewsToRestore.clear(); + } + + protected async restoreWebview(webview: WebviewWidget): Promise { + await this.activateByEvent(`onWebviewPanel:${webview.viewType}`); + const restore = this.webviewRevivers.get(webview.viewType); + if (!restore) { + webview.setHTML(this.getDeserializationFailedContents(` +

The extension providing '${webview.viewType}' view is not capable of restoring it.

+

Want to help fix this? Please inform the extension developer to register a reviver.

+ `)); + return; + } + try { + await restore(webview); + } catch (e) { + webview.setHTML(this.getDeserializationFailedContents(` + An error occurred while restoring '${webview.viewType}' view. Please check logs. + `)); + console.error('Failed to restore the webview', e); + } + } + + protected getDeserializationFailedContents(message: string): string { + return ` + + + + + + ${message} + `; + } + } export class PluginContributions extends DisposableCollection { diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index 0c79436d18adf..a18e167bf0780 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -16,7 +16,7 @@ import { injectable, inject, postConstruct } from 'inversify'; import { ArrayExt } from '@phosphor/algorithm/lib/array'; -import { WebviewPanelOptions, WebviewPortMapping, Uri } from '@theia/plugin'; +import { WebviewPanelOptions, WebviewPortMapping } from '@theia/plugin'; import { BaseWidget, Message } from '@theia/core/lib/browser/widgets/widget'; import { Disposable } from '@theia/core/lib/common/disposable'; // TODO: get rid of dependencies to the mini browser @@ -32,13 +32,15 @@ import { FileSystem } from '@theia/filesystem/lib/common/filesystem'; // tslint:disable:no-any export const enum WebviewMessageChannels { + doUpdateState = 'do-update-state', + doReload = 'do-reload', loadResource = 'load-resource', webviewReady = 'webview-ready' } export interface WebviewContentOptions { readonly allowScripts?: boolean; - readonly localResourceRoots?: ReadonlyArray; + readonly localResourceRoots?: ReadonlyArray; readonly portMapping?: ReadonlyArray; readonly enableCommandUris?: boolean; } @@ -137,6 +139,10 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { this.ready.resolve(); }); this.toDispose.push(subscription); + this.toDispose.push(this.on(WebviewMessageChannels.doUpdateState, (state: any) => { + this.state = state; + })); + this.toDispose.push(this.on(WebviewMessageChannels.doReload, () => this.reload())); this.toDispose.push(this.on(WebviewMessageChannels.loadResource, (entry: any) => { const rawPath = entry.path; const normalizedPath = decodeURIComponent(rawPath); @@ -300,7 +306,6 @@ export namespace WebviewWidget { viewType: string title: string options: WebviewPanelOptions - // TODO serialize/revive URIs contentOptions: WebviewContentOptions state: any // TODO: preserve icon class @@ -308,7 +313,7 @@ export namespace WebviewWidget { export function compareWebviewContentOptions(a: WebviewContentOptions, b: WebviewContentOptions): boolean { return a.enableCommandUris === b.enableCommandUris && a.allowScripts === b.allowScripts && - ArrayExt.shallowEqual(a.localResourceRoots || [], b.localResourceRoots || [], (uri, uri2) => uri.toString() === uri2.toString()) && + ArrayExt.shallowEqual(a.localResourceRoots || [], b.localResourceRoots || [], (uri, uri2) => uri === uri2) && ArrayExt.shallowEqual(a.portMapping || [], b.portMapping || [], (m, m2) => m.extensionHostPort === m2.extensionHostPort && m.webviewPort === m2.webviewPort ); diff --git a/packages/plugin-ext/src/main/browser/webviews-main.ts b/packages/plugin-ext/src/main/browser/webviews-main.ts index 37bf98fe17fd3..d810db6c747b1 100644 --- a/packages/plugin-ext/src/main/browser/webviews-main.ts +++ b/packages/plugin-ext/src/main/browser/webviews-main.ts @@ -27,16 +27,16 @@ import { ThemeRulesService } from './webview/theme-rules-service'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; import { ViewColumnService } from './view-column-service'; import { WidgetManager } from '@theia/core/lib/browser/widget-manager'; -import { HostedPluginSupport } from '../../hosted/browser/hosted-plugin'; import { JSONExt } from '@phosphor/coreutils/lib/json'; import { Mutable } from '@theia/core/lib/common/types'; +import { HostedPluginSupport } from '../../hosted/browser/hosted-plugin'; export class WebviewsMainImpl implements WebviewsMain, Disposable { - private readonly revivers = new Set(); private readonly proxy: WebviewsExt; protected readonly shell: ApplicationShell; protected readonly widgets: WidgetManager; + protected readonly pluginService: HostedPluginSupport; protected readonly viewColumnService: ViewColumnService; protected readonly keybindingRegistry: KeybindingRegistry; protected readonly themeService = ThemeService.get(); @@ -49,20 +49,10 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { this.keybindingRegistry = container.get(KeybindingRegistry); this.viewColumnService = container.get(ViewColumnService); this.widgets = container.get(WidgetManager); - const pluginService = container.get(HostedPluginSupport); + this.pluginService = container.get(HostedPluginSupport); this.toDispose.push(this.shell.onDidChangeActiveWidget(() => this.updateViewStates())); this.toDispose.push(this.shell.onDidChangeCurrentWidget(() => this.updateViewStates())); this.toDispose.push(this.viewColumnService.onViewColumnChanged(() => this.updateViewStates())); - this.toDispose.push(this.widgets.onDidCreateWidget(({ factoryId, widget }) => { - if (factoryId === WebviewWidget.FACTORY_ID && widget instanceof WebviewWidget) { - const restoreState = widget.restoreState.bind(widget); - widget.restoreState = async oldState => { - restoreState(oldState); - await pluginService.activateByEvent(`onWebviewPanel:${widget.viewType}`); - this.restoreWidget(widget); - }; - } - })); } dispose(): void { @@ -80,9 +70,13 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { this.hookWebview(view); view.viewType = viewType; view.title.label = title; - const { enableFindWidget, retainContextWhenHidden, enableScripts, ...contentOptions } = options; + const { enableFindWidget, retainContextWhenHidden, enableScripts, localResourceRoots, ...contentOptions } = options; view.options = { enableFindWidget, retainContextWhenHidden }; - view.setContentOptions({ allowScripts: enableScripts, ...contentOptions }); + view.setContentOptions({ + allowScripts: enableScripts, + localResourceRoots: localResourceRoots && localResourceRoots.map(root => root.toString()), + ...contentOptions + }); this.addOrReattachWidget(panelId, showOptions); } @@ -90,7 +84,9 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { const handle = view.identifier.id; const toDisposeOnClose = new DisposableCollection(); const toDisposeOnLoad = new DisposableCollection(); + view.eventDelegate = { + // TODO review callbacks onMessage: m => { this.proxy.$onMessage(handle, m); }, @@ -121,12 +117,14 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { })); } }; + this.toDispose.push(Disposable.create(() => view.eventDelegate = {})); + view.disposed.connect(() => { toDisposeOnClose.dispose(); - this.proxy.$onDidDisposeWebviewPanel(handle); + if (!this.toDispose.disposed) { + this.proxy.$onDidDisposeWebviewPanel(handle); + } }); - - this.toDispose.push(view); toDisposeOnClose.push(Disposable.create(() => this.themeRulesService.setIconPath(handle, undefined))); } @@ -191,11 +189,7 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { this.addOrReattachWidget(widget.identifier.id, showOptions); return; } - } else if (!widget.options.retainContextWhenHidden) { - // reload content when revealing - widget.reload(); } - if (showOptions.preserveFocus) { this.shell.revealWidget(widget.id); } else { @@ -221,8 +215,12 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { async $setOptions(handle: string, options: WebviewOptions): Promise { const webview = await this.getWebview(handle); - const { enableScripts, ...contentOptions } = options; - webview.setContentOptions({ allowScripts: enableScripts, ...contentOptions }); + const { enableScripts, localResourceRoots, ...contentOptions } = options; + webview.setContentOptions({ + allowScripts: enableScripts, + localResourceRoots: localResourceRoots && localResourceRoots.map(root => root.toString()), + ...contentOptions + }); } // tslint:disable-next-line:no-any @@ -233,47 +231,23 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { } $registerSerializer(viewType: string): void { - if (this.revivers.has(viewType)) { - throw new Error(`Reviver for ${viewType} already registered`); - } - this.revivers.add(viewType); + this.pluginService.registerWebviewReviver(viewType, widget => this.restoreWidget(widget)); this.toDispose.push(Disposable.create(() => this.$unregisterSerializer(viewType))); } $unregisterSerializer(viewType: string): void { - this.revivers.delete(viewType); + this.pluginService.unregisterWebviewReviver(viewType); } protected async restoreWidget(widget: WebviewWidget): Promise { - const viewType = widget.viewType; - if (!this.revivers.has(viewType)) { - widget.setHTML(this.getDeserializationFailedContents(viewType)); - return; - } - try { - this.hookWebview(widget); - const handle = widget.identifier.id; - const title = widget.title.label; - const state = widget.state; - const options = widget.options; - this.viewColumnService.updateViewColumns(); - const position = this.viewColumnService.getViewColumn(widget.id) || 0; - await this.proxy.$deserializeWebviewPanel(handle, viewType, title, state, position, options); - } catch (e) { - widget.setHTML(this.getDeserializationFailedContents(viewType)); - console.error('Failed to restore the webview', e); - } - } - - protected getDeserializationFailedContents(viewType: string): string { - return ` - - - - - - An error occurred while restoring view:${viewType} - `; + this.hookWebview(widget); + const handle = widget.identifier.id; + const title = widget.title.label; + const state = widget.state; + const options = widget.options; + this.viewColumnService.updateViewColumns(); + const position = this.viewColumnService.getViewColumn(widget.id) || 0; + await this.proxy.$deserializeWebviewPanel(handle, widget.viewType, title, state, position, options); } protected readonly updateViewStates = debounce(() => { From 0b43c51cb6a24c1c103e0b061ca2bb04c28fad49 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Wed, 30 Oct 2019 11:42:58 +0000 Subject: [PATCH 05/21] =?UTF-8?q?[webview]=C2=A0open=20links=20via=20Opene?= =?UTF-8?q?rService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Anton Kosyakov --- .../browser/webview/theme-rules-service.ts | 149 ------------------ .../src/main/browser/webview/webview.ts | 66 +++++++- .../src/main/browser/webviews-main.ts | 56 +------ 3 files changed, 62 insertions(+), 209 deletions(-) delete mode 100644 packages/plugin-ext/src/main/browser/webview/theme-rules-service.ts diff --git a/packages/plugin-ext/src/main/browser/webview/theme-rules-service.ts b/packages/plugin-ext/src/main/browser/webview/theme-rules-service.ts deleted file mode 100644 index 01d5dec1e9f11..0000000000000 --- a/packages/plugin-ext/src/main/browser/webview/theme-rules-service.ts +++ /dev/null @@ -1,149 +0,0 @@ -/******************************************************************************** - * Copyright (C) 2018 Red Hat, Inc. and others. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * This Source Code may also be made available under the following Secondary - * Licenses when the conditions for such availability set forth in the Eclipse - * Public License v. 2.0 are satisfied: GNU General Public License, version 2 - * with the GNU Classpath Exception which is available at - * https://www.gnu.org/software/classpath/license.html. - * - * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 - ********************************************************************************/ - -import { ThemeService } from '@theia/core/lib/browser/theming'; - -export const ThemeRulesServiceSymbol = Symbol('ThemeRulesService'); - -interface IconPath { - light: string, - dark: string -} - -const DEFAULT_RULE = 'body { font-size: var(--theia-ui-font-size1); color: var(--theia-ui-font-color1); }'; - -export class ThemeRulesService { - private styleElement?: HTMLStyleElement; - private icons = new Map(); - protected readonly themeService = ThemeService.get(); - protected readonly themeRules = new Map(); - - static get(): ThemeRulesService { - const global = window as any; // tslint:disable-line - return global[ThemeRulesServiceSymbol] || new ThemeRulesService(); - } - - protected constructor() { - const global = window as any; // tslint:disable-line - global[ThemeRulesServiceSymbol] = this; - - this.themeService.onThemeChange(() => { - this.updateIconStyleElement(); - }); - } - - createStyleSheet(container: HTMLElement = document.getElementsByTagName('head')[0]): HTMLStyleElement { - const style = document.createElement('style'); - style.type = 'text/css'; - style.media = 'screen'; - container.appendChild(style); - return style; - } - - getCurrentThemeRules(): string[] { - const cssText: string[] = []; - const themeId = this.themeService.getCurrentTheme().id; - if (this.themeRules.has(themeId)) { - return this.themeRules.get(themeId); - } - // tslint:disable-next-line:no-any - const styleElement = document.getElementById('theia-theme') as any; - if (!styleElement) { - return cssText; - } - - const sheet: { - insertRule: (rule: string, index: number) => void, - removeRule: (index: number) => void, - rules: CSSRuleList - // tslint:disable-next-line:no-any - } | undefined = (styleElement).sheet; - if (!sheet || !sheet.rules || !sheet.rules.length) { - return cssText; - } - - const ruleList = sheet.rules; - for (let index = 0; index < ruleList.length; index++) { - if (ruleList[index] && ruleList[index].cssText) { - cssText.push(ruleList[index].cssText.toString()); - } - } - - if (cssText.length) { - cssText.push(DEFAULT_RULE); - } - - return cssText; - } - - setRules(styleSheet: HTMLElement, newRules: string[]): boolean { - const sheet: { - insertRule: (rule: string, index: number) => void; - removeRule: (index: number) => void; - rules: CSSRuleList; - // tslint:disable-next-line:no-any - } | undefined = (styleSheet).sheet; - - if (!sheet) { - return false; - } - for (let index = sheet.rules!.length; index > 0; index--) { - sheet.removeRule(0); - } - newRules.forEach((rule: string, index: number) => { - sheet.insertRule(rule, index); - }); - return true; - } - - setIconPath(webviewId: string, iconPath: IconPath | string | undefined): void { - if (!iconPath) { - this.icons.delete(webviewId); - } else { - this.icons.set(webviewId, iconPath); - } - if (!this.styleElement) { - this.styleElement = this.createStyleSheet(); - this.styleElement.id = 'webview-icons'; - } - this.updateIconStyleElement(); - } - - private updateIconStyleElement(): void { - if (!this.styleElement) { - return; - } - const cssRules: string[] = []; - this.icons.forEach((value, key) => { - let path: string; - if (typeof value === 'string') { - path = value; - } else { - path = this.isDark() ? value.dark : value.light; - } - if (path.startsWith('/')) { - path = `/webview${path}`; - } - cssRules.push(`.webview-icon.${key}-file-icon::before { background-image: url(${path}); }`); - }); - this.setRules(this.styleElement, cssRules); - } - - private isDark(): boolean { - const currentThemeId: string = this.themeService.getCurrentTheme().id; - return !currentThemeId.includes('light'); - } -} diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index a18e167bf0780..87b008e11dcf0 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -28,14 +28,21 @@ import { Deferred } from '@theia/core/lib/common/promise-util'; import { WebviewEnvironment } from './webview-environment'; import URI from '@theia/core/lib/common/uri'; import { FileSystem } from '@theia/filesystem/lib/common/filesystem'; +import { Emitter } from '@theia/core/lib/common/event'; +import { open, OpenerService } from '@theia/core/lib/browser/opener-service'; +import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding'; +import { Schemes } from '../../../common/uri-components'; // tslint:disable:no-any export const enum WebviewMessageChannels { + onmessage = 'onmessage', + didClickLink = 'did-click-link', doUpdateState = 'do-update-state', doReload = 'do-reload', loadResource = 'load-resource', - webviewReady = 'webview-ready' + webviewReady = 'webview-ready', + didKeydown = 'did-keydown' } export interface WebviewContentOptions { @@ -45,12 +52,6 @@ export interface WebviewContentOptions { readonly enableCommandUris?: boolean; } -export interface WebviewEvents { - onMessage?(message: any): void; - onKeyboardEvent?(e: KeyboardEvent): void; - onLoad?(contentDocument: Document): void; -} - @injectable() export class WebviewWidgetIdentifier { id: string; @@ -61,6 +62,13 @@ export const WebviewWidgetExternalEndpoint = Symbol('WebviewWidgetExternalEndpoi @injectable() export class WebviewWidget extends BaseWidget implements StatefulWidget { + private static readonly standardSupportedLinkSchemes = new Set([ + Schemes.HTTP, + Schemes.HTTPS, + Schemes.MAILTO, + Schemes.VSCODE + ]); + static FACTORY_ID = 'plugin-webview'; protected element: HTMLIFrameElement; @@ -85,6 +93,12 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { @inject(FileSystem) protected readonly fileSystem: FileSystem; + @inject(OpenerService) + protected readonly openerService: OpenerService; + + @inject(KeybindingRegistry) + protected readonly keybindings: KeybindingRegistry; + viewState: WebviewPanelViewState = { visible: false, active: false, @@ -97,10 +111,12 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { viewType: string; options: WebviewPanelOptions = {}; - eventDelegate: WebviewEvents = {}; protected readonly ready = new Deferred(); + protected readonly onMessageEmitter = new Emitter(); + readonly onMessage = this.onMessageEmitter.event; + @postConstruct() protected init(): void { this.node.tabIndex = 0; @@ -108,6 +124,8 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { this.title.closable = true; this.addClass(WebviewWidget.Styles.WEBVIEW); + this.toDispose.push(this.onMessageEmitter); + this.transparentOverlay = document.createElement('div'); this.transparentOverlay.classList.add(MiniBrowserContentStyle.TRANSPARENT_OVERLAY); this.transparentOverlay.style.display = 'none'; @@ -139,6 +157,8 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { this.ready.resolve(); }); this.toDispose.push(subscription); + this.toDispose.push(this.on(WebviewMessageChannels.onmessage, (data: any) => this.onMessageEmitter.fire(data))); + this.toDispose.push(this.on(WebviewMessageChannels.didClickLink, (uri: string) => this.openLink(new URI(uri)))); this.toDispose.push(this.on(WebviewMessageChannels.doUpdateState, (state: any) => { this.state = state; })); @@ -149,6 +169,12 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { const uri = new URI(normalizedPath.replace(/^\/(\w+)\/(.+)$/, (_, scheme, path) => scheme + ':/' + path)); this.loadResource(rawPath, uri); })); + this.toDispose.push(this.on(WebviewMessageChannels.didKeydown, (data: KeyboardEvent) => { + // Electron: workaround for https://github.com/electron/electron/issues/14258 + // We have to detect keyboard events in the and dispatch them to our + // keybinding service because these events do not bubble to the parent window anymore. + this.dispatchKeyDown(data); + })); } setContentOptions(contentOptions: WebviewContentOptions): void { @@ -193,6 +219,30 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { this.doUpdateContent(); } + protected dispatchKeyDown(event: KeyboardEventInit): void { + // Create a fake KeyboardEvent from the data provided + const emulatedKeyboardEvent = new KeyboardEvent('keydown', event); + // Force override the target + Object.defineProperty(emulatedKeyboardEvent, 'target', { + get: () => this.element, + }); + // And re-dispatch + this.keybindings.run(emulatedKeyboardEvent); + } + + protected openLink(link: URI): void { + if (this.isSupportedLink(link)) { + open(this.openerService, link); + } + } + + protected isSupportedLink(link: URI): boolean { + if (WebviewWidget.standardSupportedLinkSchemes.has(link.scheme)) { + return true; + } + return !!this.contentOptions.enableCommandUris && link.scheme === Schemes.COMMAND; + } + protected async loadResource(requestPath: string, uri: URI): Promise { try { const normalizedUri = this.normalizeRequestUri(uri); diff --git a/packages/plugin-ext/src/main/browser/webviews-main.ts b/packages/plugin-ext/src/main/browser/webviews-main.ts index d810db6c747b1..461bfc47523db 100644 --- a/packages/plugin-ext/src/main/browser/webviews-main.ts +++ b/packages/plugin-ext/src/main/browser/webviews-main.ts @@ -20,17 +20,13 @@ import { interfaces } from 'inversify'; import { RPCProtocol } from '../../common/rpc-protocol'; import { WebviewOptions, WebviewPanelOptions, WebviewPanelShowOptions } from '@theia/plugin'; import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; -import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding'; import { WebviewWidget, WebviewWidgetIdentifier } from './webview/webview'; -import { ThemeService } from '@theia/core/lib/browser/theming'; -import { ThemeRulesService } from './webview/theme-rules-service'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; import { ViewColumnService } from './view-column-service'; import { WidgetManager } from '@theia/core/lib/browser/widget-manager'; import { JSONExt } from '@phosphor/coreutils/lib/json'; import { Mutable } from '@theia/core/lib/common/types'; import { HostedPluginSupport } from '../../hosted/browser/hosted-plugin'; - export class WebviewsMainImpl implements WebviewsMain, Disposable { private readonly proxy: WebviewsExt; @@ -38,15 +34,11 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { protected readonly widgets: WidgetManager; protected readonly pluginService: HostedPluginSupport; protected readonly viewColumnService: ViewColumnService; - protected readonly keybindingRegistry: KeybindingRegistry; - protected readonly themeService = ThemeService.get(); - protected readonly themeRulesService = ThemeRulesService.get(); private readonly toDispose = new DisposableCollection(); constructor(rpc: RPCProtocol, container: interfaces.Container) { this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.WEBVIEWS_EXT); this.shell = container.get(ApplicationShell); - this.keybindingRegistry = container.get(KeybindingRegistry); this.viewColumnService = container.get(ViewColumnService); this.widgets = container.get(WidgetManager); this.pluginService = container.get(HostedPluginSupport); @@ -82,50 +74,11 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { protected hookWebview(view: WebviewWidget): void { const handle = view.identifier.id; - const toDisposeOnClose = new DisposableCollection(); - const toDisposeOnLoad = new DisposableCollection(); + this.toDispose.push(view.onMessage(data => this.proxy.$onMessage(handle, data))); - view.eventDelegate = { - // TODO review callbacks - onMessage: m => { - this.proxy.$onMessage(handle, m); - }, - onKeyboardEvent: e => { - this.keybindingRegistry.run(e); - }, - onLoad: contentDocument => { - const styleId = 'webview-widget-theme'; - let styleElement: HTMLStyleElement | null | undefined; - if (!toDisposeOnLoad.disposed) { - // if reload the frame - toDisposeOnLoad.dispose(); - styleElement = contentDocument.getElementById(styleId); - } - toDisposeOnClose.push(toDisposeOnLoad); - if (!styleElement) { - const parent = contentDocument.head ? contentDocument.head : contentDocument.body; - styleElement = this.themeRulesService.createStyleSheet(parent); - styleElement.id = styleId; - parent.appendChild(styleElement); - } - - this.themeRulesService.setRules(styleElement, this.themeRulesService.getCurrentThemeRules()); - contentDocument.body.className = `vscode-${ThemeService.get().getCurrentTheme().id}`; - toDisposeOnLoad.push(this.themeService.onThemeChange(() => { - this.themeRulesService.setRules(styleElement, this.themeRulesService.getCurrentThemeRules()); - contentDocument.body.className = `vscode-${ThemeService.get().getCurrentTheme().id}`; - })); - } - }; - this.toDispose.push(Disposable.create(() => view.eventDelegate = {})); - - view.disposed.connect(() => { - toDisposeOnClose.dispose(); - if (!this.toDispose.disposed) { - this.proxy.$onDidDisposeWebviewPanel(handle); - } - }); - toDisposeOnClose.push(Disposable.create(() => this.themeRulesService.setIconPath(handle, undefined))); + const onDispose = () => this.proxy.$onDidDisposeWebviewPanel(handle); + view.disposed.connect(onDispose); + this.toDispose.push(Disposable.create(() => view.disposed.disconnect(onDispose))); } private async addOrReattachWidget(handle: string, showOptions: WebviewPanelShowOptions): Promise { @@ -205,7 +158,6 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { async $setIconPath(handle: string, iconPath: { light: string; dark: string; } | string | undefined): Promise { const webview = await this.getWebview(handle); webview.setIconClass(iconPath ? `webview-icon ${handle}-file-icon` : ''); - this.themeRulesService.setIconPath(handle, iconPath); } async $setHtml(handle: string, value: string): Promise { From a000a2771f8fc9d6f38fc95584dd0cfd2dbd7056 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Wed, 30 Oct 2019 13:39:08 +0000 Subject: [PATCH 06/21] [webview] fix #5521: emulate webview focus when something is focused in iframe Otherwise the webview is not detected as active and title actions are not available. Signed-off-by: Anton Kosyakov --- .../src/browser/shell/application-shell.ts | 5 ++ packages/plugin-ext/package.json | 2 + .../menus/menus-contribution-handler.ts | 24 +++++++- .../src/main/browser/webview/webview.ts | 60 ++++++++++++------- .../src/main/browser/webviews-main.ts | 33 +++++++--- yarn.lock | 10 ++++ 6 files changed, 104 insertions(+), 30 deletions(-) diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index a391ed3da3598..27f63b3034c98 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -1005,6 +1005,11 @@ export class ApplicationShell extends Widget { private readonly toDisposeOnActivationCheck = new DisposableCollection(); private assertActivated(widget: Widget): void { this.toDisposeOnActivationCheck.dispose(); + + const onDispose = () => this.toDisposeOnActivationCheck.dispose(); + widget.disposed.connect(onDispose); + this.toDisposeOnActivationCheck.push(Disposable.create(() => widget.disposed.disconnect(onDispose))); + let start = 0; const step: FrameRequestCallback = timestamp => { if (document.activeElement && widget.node.contains(document.activeElement)) { diff --git a/packages/plugin-ext/package.json b/packages/plugin-ext/package.json index 50cf8539f5dd4..1f42bceaf3039 100644 --- a/packages/plugin-ext/package.json +++ b/packages/plugin-ext/package.json @@ -25,6 +25,7 @@ "@theia/terminal": "^0.12.0", "@theia/workspace": "^0.12.0", "@types/connect": "^3.4.32", + "@types/mime": "^2.0.1", "@types/serve-static": "^1.13.3", "connect": "^3.7.0", "decompress": "^4.2.0", @@ -32,6 +33,7 @@ "jsonc-parser": "^2.0.2", "lodash.clonedeep": "^4.5.0", "macaddress": "^0.2.9", + "mime": "^2.4.4", "ps-tree": "^1.2.0", "request": "^2.82.0", "serve-static": "^1.14.1", diff --git a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts index fcd92dda94edd..108ee70b2f5a2 100644 --- a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts @@ -21,6 +21,7 @@ import { injectable, inject } from 'inversify'; import { MenuPath, ILogger, CommandRegistry, Command, Mutable, MenuAction, SelectionService, CommandHandler, Disposable, DisposableCollection } from '@theia/core'; import { EDITOR_CONTEXT_MENU, EditorWidget } from '@theia/editor/lib/browser'; import { MenuModelRegistry } from '@theia/core/lib/common'; +import { Emitter } from '@theia/core/lib/common/event'; import { TabBarToolbarRegistry, TabBarToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { NAVIGATOR_CONTEXT_MENU } from '@theia/navigator/lib/browser/navigator-contribution'; import { QuickCommandService } from '@theia/core/lib/browser/quick-open/quick-command-service'; @@ -38,6 +39,7 @@ import { PluginViewWidget } from '../view/plugin-view-widget'; import { ViewContextKeyService } from '../view/view-context-key-service'; import { WebviewWidget } from '../webview/webview'; import { Navigatable } from '@theia/core/lib/browser/navigatable'; +import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; type CodeEditorWidget = EditorWidget | WebviewWidget; export namespace CodeEditorWidget { @@ -80,6 +82,9 @@ export class MenusContributionPointHandler { @inject(ViewContextKeyService) protected readonly viewContextKeys: ViewContextKeyService; + @inject(ContextKeyService) + protected readonly contextKeyService: ContextKeyService; + handle(contributions: PluginContribution): Disposable { const allMenus = contributions.menus; if (!allMenus) { @@ -194,6 +199,23 @@ export class MenusContributionPointHandler { toDispose.push(this.commands.registerCommand(command, handler)); const { when } = action; + const whenKeys = when && this.contextKeyService.parseKeys(when); + let onDidChange; + if (whenKeys && whenKeys.size) { + const onDidChangeEmitter = new Emitter(); + toDispose.push(onDidChangeEmitter); + onDidChange = onDidChangeEmitter.event; + this.contextKeyService.onDidChange.maxListeners = this.contextKeyService.onDidChange.maxListeners + 1; + toDispose.push(this.contextKeyService.onDidChange(event => { + if (event.affects(whenKeys)) { + onDidChangeEmitter.fire(undefined); + } + })); + toDispose.push(Disposable.create(() => { + this.contextKeyService.onDidChange.maxListeners = this.contextKeyService.onDidChange.maxListeners - 1; + })); + } + // handle group and priority // if group is empty or white space is will be set to navigation // ' ' => ['navigation', 0] @@ -202,7 +224,7 @@ export class MenusContributionPointHandler { // if priority is not a number it will be set to 0 // navigation@test => ['navigation', 0] const [group, sort] = (action.group || 'navigation').split('@'); - const item: Mutable = { id, command: id, group: group.trim() || 'navigation', priority: ~~sort || undefined, when }; + const item: Mutable = { id, command: id, group: group.trim() || 'navigation', priority: ~~sort || undefined, when, onDidChange }; toDispose.push(this.tabBarToolbar.registerItem(item)); toDispose.push(this.onDidRegisterCommand(action.command, pluginCommand => { diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index 87b008e11dcf0..570e41c4b962c 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -14,8 +14,8 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import * as mime from 'mime'; import { injectable, inject, postConstruct } from 'inversify'; -import { ArrayExt } from '@phosphor/algorithm/lib/array'; import { WebviewPanelOptions, WebviewPortMapping } from '@theia/plugin'; import { BaseWidget, Message } from '@theia/core/lib/browser/widgets/widget'; import { Disposable } from '@theia/core/lib/common/disposable'; @@ -32,12 +32,15 @@ import { Emitter } from '@theia/core/lib/common/event'; import { open, OpenerService } from '@theia/core/lib/browser/opener-service'; import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding'; import { Schemes } from '../../../common/uri-components'; +import { JSONExt } from '@phosphor/coreutils'; // tslint:disable:no-any export const enum WebviewMessageChannels { onmessage = 'onmessage', didClickLink = 'did-click-link', + didFocus = 'did-focus', + didBlur = 'did-blur', doUpdateState = 'do-update-state', doReload = 'do-reload', loadResource = 'load-resource', @@ -71,7 +74,7 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { static FACTORY_ID = 'plugin-webview'; - protected element: HTMLIFrameElement; + protected element: HTMLIFrameElement | undefined; // tslint:disable-next-line:max-line-length // XXX This is a hack to be able to tack the mouse events when drag and dropping the widgets. @@ -106,8 +109,16 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { }; protected html = ''; - protected contentOptions: WebviewContentOptions = {}; - state: any; + + protected _contentOptions: WebviewContentOptions = {}; + get contentOptions(): WebviewContentOptions { + return this._contentOptions; + } + + protected _state: string | undefined; + get state(): string | undefined { + return this._state; + } viewType: string; options: WebviewPanelOptions = {}; @@ -132,12 +143,12 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { this.node.appendChild(this.transparentOverlay); this.toDispose.push(this.mouseTracker.onMousedown(() => { - if (this.element.style.display !== 'none') { + if (this.element && this.element.style.display !== 'none') { this.transparentOverlay.style.display = 'block'; } })); this.toDispose.push(this.mouseTracker.onMouseup(() => { - if (this.element.style.display !== 'none') { + if (this.element && this.element.style.display !== 'none') { this.transparentOverlay.style.display = 'none'; } })); @@ -151,6 +162,12 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { element.style.height = '100%'; this.element = element; this.node.appendChild(this.element); + this.toDispose.push(Disposable.create(() => { + if (this.element) { + this.element.remove(); + this.element = undefined; + } + })); const subscription = this.on(WebviewMessageChannels.webviewReady, () => { subscription.dispose(); @@ -160,7 +177,14 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { this.toDispose.push(this.on(WebviewMessageChannels.onmessage, (data: any) => this.onMessageEmitter.fire(data))); this.toDispose.push(this.on(WebviewMessageChannels.didClickLink, (uri: string) => this.openLink(new URI(uri)))); this.toDispose.push(this.on(WebviewMessageChannels.doUpdateState, (state: any) => { - this.state = state; + this._state = state; + })); + this.toDispose.push(this.on(WebviewMessageChannels.didFocus, () => + // emulate the webview focus without actually changing focus + this.node.dispatchEvent(new FocusEvent('focus')) + )); + this.toDispose.push(this.on(WebviewMessageChannels.didBlur, () => { + /* no-op: webview loses focus only if another element gains focus in the main window */ })); this.toDispose.push(this.on(WebviewMessageChannels.doReload, () => this.reload())); this.toDispose.push(this.on(WebviewMessageChannels.loadResource, (entry: any) => { @@ -178,10 +202,10 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { } setContentOptions(contentOptions: WebviewContentOptions): void { - if (WebviewWidget.compareWebviewContentOptions(this.contentOptions, contentOptions)) { + if (JSONExt.deepEqual(this.contentOptions, contentOptions)) { return; } - this.contentOptions = contentOptions; + this._contentOptions = contentOptions; this.doUpdateContent(); } @@ -206,6 +230,7 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { protected onActivateRequest(msg: Message): void { super.onActivateRequest(msg); + this.node.focus(); this.focus(); } @@ -256,7 +281,7 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { return this.doSend('did-load-resource', { status: 200, path: requestPath, - mime: 'text/plain', // TODO detect mimeType from URI extension + mime: mime.getType(normalizedUri.path.toString()) || 'application/octet-stream', data: content }); } @@ -313,8 +338,8 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { this.viewType = viewType; this.title.label = title; this.options = options; - this.contentOptions = contentOptions; - this.state = state; + this._contentOptions = contentOptions; + this._state = state; } protected async doSend(channel: string, data?: any): Promise { @@ -357,15 +382,6 @@ export namespace WebviewWidget { title: string options: WebviewPanelOptions contentOptions: WebviewContentOptions - state: any - // TODO: preserve icon class - } - export function compareWebviewContentOptions(a: WebviewContentOptions, b: WebviewContentOptions): boolean { - return a.enableCommandUris === b.enableCommandUris - && a.allowScripts === b.allowScripts && - ArrayExt.shallowEqual(a.localResourceRoots || [], b.localResourceRoots || [], (uri, uri2) => uri === uri2) && - ArrayExt.shallowEqual(a.portMapping || [], b.portMapping || [], (m, m2) => - m.extensionHostPort === m2.extensionHostPort && m.webviewPort === m2.webviewPort - ); + state?: string } } diff --git a/packages/plugin-ext/src/main/browser/webviews-main.ts b/packages/plugin-ext/src/main/browser/webviews-main.ts index 461bfc47523db..a45fdad761d40 100644 --- a/packages/plugin-ext/src/main/browser/webviews-main.ts +++ b/packages/plugin-ext/src/main/browser/webviews-main.ts @@ -15,8 +15,9 @@ ********************************************************************************/ import debounce = require('lodash.debounce'); -import { WebviewsMain, MAIN_RPC_CONTEXT, WebviewsExt, WebviewPanelViewState } from '../../common/plugin-api-rpc'; +import URI from 'vscode-uri'; import { interfaces } from 'inversify'; +import { WebviewsMain, MAIN_RPC_CONTEXT, WebviewsExt, WebviewPanelViewState } from '../../common/plugin-api-rpc'; import { RPCProtocol } from '../../common/rpc-protocol'; import { WebviewOptions, WebviewPanelOptions, WebviewPanelShowOptions } from '@theia/plugin'; import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; @@ -27,6 +28,7 @@ import { WidgetManager } from '@theia/core/lib/browser/widget-manager'; import { JSONExt } from '@phosphor/coreutils/lib/json'; import { Mutable } from '@theia/core/lib/common/types'; import { HostedPluginSupport } from '../../hosted/browser/hosted-plugin'; + export class WebviewsMainImpl implements WebviewsMain, Disposable { private readonly proxy: WebviewsExt; @@ -75,10 +77,12 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { protected hookWebview(view: WebviewWidget): void { const handle = view.identifier.id; this.toDispose.push(view.onMessage(data => this.proxy.$onMessage(handle, data))); - - const onDispose = () => this.proxy.$onDidDisposeWebviewPanel(handle); - view.disposed.connect(onDispose); - this.toDispose.push(Disposable.create(() => view.disposed.disconnect(onDispose))); + view.disposed.connect(() => { + if (this.toDispose.disposed) { + return; + } + this.proxy.$onDidDisposeWebviewPanel(handle); + }); } private async addOrReattachWidget(handle: string, showOptions: WebviewPanelShowOptions): Promise { @@ -195,11 +199,26 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { this.hookWebview(widget); const handle = widget.identifier.id; const title = widget.title.label; - const state = widget.state; + + let state = undefined; + if (widget.state) { + try { + state = JSON.parse(widget.state); + } catch { + // noop + } + } + const options = widget.options; + const { allowScripts, localResourceRoots, ...contentOptions } = widget.contentOptions; this.viewColumnService.updateViewColumns(); const position = this.viewColumnService.getViewColumn(widget.id) || 0; - await this.proxy.$deserializeWebviewPanel(handle, widget.viewType, title, state, position, options); + await this.proxy.$deserializeWebviewPanel(handle, widget.viewType, title, state, position, { + enableScripts: allowScripts, + localResourceRoots: localResourceRoots && localResourceRoots.map(root => URI.parse(root)), + ...contentOptions, + ...options + }); } protected readonly updateViewStates = debounce(() => { diff --git a/yarn.lock b/yarn.lock index 1609539853b5c..0fe1495f4dcce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1060,6 +1060,11 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d" integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw== +"@types/mime@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d" + integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw== + "@types/minimatch@*", "@types/minimatch@3.0.3": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" @@ -8297,6 +8302,11 @@ mime@^2.0.3: resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5" integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA== +mime@^2.4.4: + version "2.4.4" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5" + integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA== + mimic-fn@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" From 64c49f86602874d7f25b0977a5e1761d44541426 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Thu, 31 Oct 2019 07:20:50 +0000 Subject: [PATCH 07/21] [webview] fix #5786: unify the icon path resolution Also: - preserve the icon path between user sessions and reconnections - add missing icon-path getter api Signed-off-by: Anton Kosyakov --- .../plugin-ext/src/common/plugin-api-rpc.ts | 2 +- .../src/main/browser/plugin-shared-style.ts | 3 +- .../src/main/browser/style/webview.css | 6 +-- .../src/main/browser/webview/webview.ts | 38 ++++++++++++-- .../src/main/browser/webviews-main.ts | 5 +- .../plugin-ext/src/plugin/plugin-context.ts | 4 +- .../plugin-ext/src/plugin/plugin-icon-path.ts | 50 +++++++++++++++++++ .../plugin-ext/src/plugin/tree/tree-views.ts | 35 +++---------- packages/plugin-ext/src/plugin/webviews.ts | 45 ++++++++--------- 9 files changed, 122 insertions(+), 66 deletions(-) create mode 100644 packages/plugin-ext/src/plugin/plugin-icon-path.ts diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index 2ca91122f8433..a786b6666921a 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -1251,7 +1251,7 @@ export interface WebviewsMain { $disposeWebview(handle: string): void; $reveal(handle: string, showOptions: theia.WebviewPanelShowOptions): void; $setTitle(handle: string, value: string): void; - $setIconPath(handle: string, value: { light: string, dark: string } | string | undefined): void; + $setIconPath(handle: string, value: IconUrl | undefined): void; $setHtml(handle: string, value: string): void; $setOptions(handle: string, options: theia.WebviewOptions): void; $postMessage(handle: string, value: any): Thenable; diff --git a/packages/plugin-ext/src/main/browser/plugin-shared-style.ts b/packages/plugin-ext/src/main/browser/plugin-shared-style.ts index be303d75aaa9b..42c7c5a3419a0 100644 --- a/packages/plugin-ext/src/main/browser/plugin-shared-style.ts +++ b/packages/plugin-ext/src/main/browser/plugin-shared-style.ts @@ -78,9 +78,8 @@ export class PluginSharedStyle { }): void { const sheet = (this.style.sheet); const cssBody = body(ThemeService.get().getCurrentTheme()); - sheet.insertRule(selector + ' { ' + cssBody + ' }', 0); + sheet.insertRule(selector + ' {\n' + cssBody + '\n}', 0); } - deleteRule(selector: string): void { const sheet = (this.style.sheet); const rules = sheet.rules || sheet.cssRules || []; diff --git a/packages/plugin-ext/src/main/browser/style/webview.css b/packages/plugin-ext/src/main/browser/style/webview.css index 4484abee04005..56025c3cc2ddd 100644 --- a/packages/plugin-ext/src/main/browser/style/webview.css +++ b/packages/plugin-ext/src/main/browser/style/webview.css @@ -25,12 +25,12 @@ border: none; margin: 0; padding: 0; } -.webview-icon { +.theia-webview-icon { background: none !important; min-height: 20px; } -.webview-icon::before { +.theia-webview-icon::before { background-size: 13px; background-repeat: no-repeat; vertical-align: middle; @@ -41,7 +41,7 @@ content: ""; } -.p-TabBar.theia-app-sides .webview-icon::before { +.p-TabBar.theia-app-sides .theia-webview-icon::before { width: var(--theia-private-sidebar-icon-size); height: var(--theia-private-sidebar-icon-size); background-size: contain; diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index 570e41c4b962c..e8dc8500332b4 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -15,15 +15,17 @@ ********************************************************************************/ import * as mime from 'mime'; +import { JSONExt } from '@phosphor/coreutils/lib/json'; import { injectable, inject, postConstruct } from 'inversify'; import { WebviewPanelOptions, WebviewPortMapping } from '@theia/plugin'; import { BaseWidget, Message } from '@theia/core/lib/browser/widgets/widget'; -import { Disposable } from '@theia/core/lib/common/disposable'; +import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; // TODO: get rid of dependencies to the mini browser import { MiniBrowserContentStyle } from '@theia/mini-browser/lib/browser/mini-browser-content-style'; import { ApplicationShellMouseTracker } from '@theia/core/lib/browser/shell/application-shell-mouse-tracker'; import { StatefulWidget } from '@theia/core/lib/browser/shell/shell-layout-restorer'; import { WebviewPanelViewState } from '../../../common/plugin-api-rpc'; +import { IconUrl } from '../../../common/plugin-protocol'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { WebviewEnvironment } from './webview-environment'; import URI from '@theia/core/lib/common/uri'; @@ -32,7 +34,8 @@ import { Emitter } from '@theia/core/lib/common/event'; import { open, OpenerService } from '@theia/core/lib/browser/opener-service'; import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding'; import { Schemes } from '../../../common/uri-components'; -import { JSONExt } from '@phosphor/coreutils'; +import { PluginSharedStyle } from '../plugin-shared-style'; +import { BuiltinThemeProvider } from '@theia/core/lib/browser/theming'; // tslint:disable:no-any @@ -102,6 +105,9 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { @inject(KeybindingRegistry) protected readonly keybindings: KeybindingRegistry; + @inject(PluginSharedStyle) + protected readonly sharedStyle: PluginSharedStyle; + viewState: WebviewPanelViewState = { visible: false, active: false, @@ -209,8 +215,27 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { this.doUpdateContent(); } - setIconClass(iconClass: string): void { - this.title.iconClass = iconClass; + protected iconUrl: IconUrl | undefined; + protected readonly toDisposeOnIcon = new DisposableCollection(); + setIconUrl(iconUrl: IconUrl | undefined): void { + if ((this.iconUrl && iconUrl && JSONExt.deepEqual(this.iconUrl, iconUrl)) || (this.iconUrl === iconUrl)) { + return; + } + this.toDisposeOnIcon.dispose(); + this.toDispose.push(this.toDisposeOnIcon); + this.iconUrl = iconUrl; + if (iconUrl) { + const darkIconUrl = typeof iconUrl === 'object' ? iconUrl.dark : iconUrl; + const lightIconUrl = typeof iconUrl === 'object' ? iconUrl.light : iconUrl; + const iconClass = `webview-${this.identifier.id}-file-icon`; + this.toDisposeOnIcon.push(this.sharedStyle.insertRule( + `.theia-webview-icon.${iconClass}::before`, + theme => `background-image: url(${theme.id === BuiltinThemeProvider.lightTheme.id ? lightIconUrl : darkIconUrl});` + )); + this.title.iconClass = `theia-webview-icon ${iconClass}`; + } else { + this.title.iconClass = ''; + } } setHTML(value: string): void { @@ -327,6 +352,7 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { return { viewType: this.viewType, title: this.title.label, + iconUrl: this.iconUrl, options: this.options, contentOptions: this.contentOptions, state: this.state @@ -334,9 +360,10 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { } restoreState(oldState: WebviewWidget.State): void { - const { viewType, title, options, contentOptions, state } = oldState; + const { viewType, title, iconUrl, options, contentOptions, state } = oldState; this.viewType = viewType; this.title.label = title; + this.setIconUrl(iconUrl); this.options = options; this._contentOptions = contentOptions; this._state = state; @@ -380,6 +407,7 @@ export namespace WebviewWidget { export interface State { viewType: string title: string + iconUrl?: IconUrl options: WebviewPanelOptions contentOptions: WebviewContentOptions state?: string diff --git a/packages/plugin-ext/src/main/browser/webviews-main.ts b/packages/plugin-ext/src/main/browser/webviews-main.ts index a45fdad761d40..90e92d912ae4d 100644 --- a/packages/plugin-ext/src/main/browser/webviews-main.ts +++ b/packages/plugin-ext/src/main/browser/webviews-main.ts @@ -28,6 +28,7 @@ import { WidgetManager } from '@theia/core/lib/browser/widget-manager'; import { JSONExt } from '@phosphor/coreutils/lib/json'; import { Mutable } from '@theia/core/lib/common/types'; import { HostedPluginSupport } from '../../hosted/browser/hosted-plugin'; +import { IconUrl } from '../../common/plugin-protocol'; export class WebviewsMainImpl implements WebviewsMain, Disposable { @@ -159,9 +160,9 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { webview.title.label = value; } - async $setIconPath(handle: string, iconPath: { light: string; dark: string; } | string | undefined): Promise { + async $setIconPath(handle: string, iconUrl: IconUrl | undefined): Promise { const webview = await this.getWebview(handle); - webview.setIconClass(iconPath ? `webview-icon ${handle}-file-icon` : ''); + webview.setIconUrl(iconUrl); } async $setHtml(handle: string, value: string): Promise { diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 6fea4ba46766f..6d1518ffad59a 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -339,10 +339,10 @@ export function createAPIFactory( title: string, showOptions: theia.ViewColumn | theia.WebviewPanelShowOptions, options: theia.WebviewPanelOptions & theia.WebviewOptions = {}): theia.WebviewPanel { - return webviewExt.createWebview(viewType, title, showOptions, options, Uri.file(plugin.pluginPath)); + return webviewExt.createWebview(viewType, title, showOptions, options, plugin); }, registerWebviewPanelSerializer(viewType: string, serializer: theia.WebviewPanelSerializer): theia.Disposable { - return webviewExt.registerWebviewPanelSerializer(viewType, serializer, Uri.file(plugin.pluginPath)); + return webviewExt.registerWebviewPanelSerializer(viewType, serializer, plugin); }, get state(): theia.WindowState { return windowStateExt.getWindowState(); diff --git a/packages/plugin-ext/src/plugin/plugin-icon-path.ts b/packages/plugin-ext/src/plugin/plugin-icon-path.ts new file mode 100644 index 0000000000000..3c709ab31ce77 --- /dev/null +++ b/packages/plugin-ext/src/plugin/plugin-icon-path.ts @@ -0,0 +1,50 @@ +/******************************************************************************** + * Copyright (C) 2019 Red Hat, Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import * as path from 'path'; +import Uri from 'vscode-uri'; +import { IconUrl, PluginPackage } from '../common/plugin-protocol'; +import { Plugin } from '../common/plugin-api-rpc'; + +export type PluginIconPath = string | Uri | { + light: string | Uri, + dark: string | Uri +}; +export namespace PluginIconPath { + export function toUrl(iconPath: PluginIconPath | undefined, plugin: Plugin): IconUrl | undefined { + if (!iconPath) { + return undefined; + } + if (typeof iconPath === 'object' && 'light' in iconPath) { + return { + light: asString(iconPath.light, plugin), + dark: asString(iconPath.dark, plugin) + }; + } + return asString(iconPath, plugin); + } + export function asString(arg: string | Uri, plugin: Plugin): string { + arg = arg instanceof Uri && arg.scheme === 'file' ? arg.fsPath : arg; + if (typeof arg !== 'string') { + return arg.toString(true); + } + const { packagePath } = plugin.rawModel; + const absolutePath = path.isAbsolute(arg) ? arg : path.join(packagePath, arg); + const normalizedPath = path.normalize(absolutePath); + const relativePath = path.relative(packagePath, normalizedPath); + return PluginPackage.toPluginUrl(plugin.rawModel, relativePath); + } +} diff --git a/packages/plugin-ext/src/plugin/tree/tree-views.ts b/packages/plugin-ext/src/plugin/tree/tree-views.ts index 81a79ef52b60d..1ecc7fd96a728 100644 --- a/packages/plugin-ext/src/plugin/tree/tree-views.ts +++ b/packages/plugin-ext/src/plugin/tree/tree-views.ts @@ -16,8 +16,6 @@ // tslint:disable:no-any -import * as path from 'path'; -import URI from 'vscode-uri'; import { TreeDataProvider, TreeView, TreeViewExpansionEvent, TreeItem2, TreeItemLabel, TreeViewSelectionChangeEvent, TreeViewVisibilityChangeEvent @@ -31,7 +29,7 @@ import { Plugin, PLUGIN_RPC_CONTEXT, TreeViewsExt, TreeViewsMain, TreeViewItem } import { RPCProtocol } from '../../common/rpc-protocol'; import { CommandRegistryImpl, CommandsConverter } from '../command-registry'; import { TreeViewSelection } from '../../common'; -import { PluginPackage } from '../../common/plugin-protocol'; +import { PluginIconPath } from '../plugin-icon-path'; export class TreeViewsExtImpl implements TreeViewsExt { @@ -279,31 +277,12 @@ class TreeViewExtImpl implements Disposable { let iconUrl; let themeIconId; const { iconPath } = treeItem; - if (iconPath) { - const toUrl = (arg: string | URI) => { - arg = arg instanceof URI && arg.scheme === 'file' ? arg.fsPath : arg; - if (typeof arg !== 'string') { - return arg.toString(true); - } - const { packagePath } = this.plugin.rawModel; - const absolutePath = path.isAbsolute(arg) ? arg : path.join(packagePath, arg); - const normalizedPath = path.normalize(absolutePath); - const relativePath = path.relative(packagePath, normalizedPath); - return PluginPackage.toPluginUrl(this.plugin.rawModel, relativePath); - }; - if (typeof iconPath === 'string' && iconPath.indexOf('fa-') !== -1) { - icon = iconPath; - } else if (iconPath instanceof ThemeIcon) { - themeIconId = iconPath.id; - } else if (typeof iconPath === 'string' || iconPath instanceof URI) { - iconUrl = toUrl(iconPath); - } else { - const { light, dark } = iconPath as { light: string | URI, dark: string | URI }; - iconUrl = { - light: toUrl(light), - dark: toUrl(dark) - }; - } + if (typeof iconPath === 'string' && iconPath.indexOf('fa-') !== -1) { + icon = iconPath; + } else if (iconPath instanceof ThemeIcon) { + themeIconId = iconPath.id; + } else { + iconUrl = PluginIconPath.toUrl(iconPath, this.plugin); } const treeViewItem = { diff --git a/packages/plugin-ext/src/plugin/webviews.ts b/packages/plugin-ext/src/plugin/webviews.ts index d6bacec72a1ce..2d6412f815809 100644 --- a/packages/plugin-ext/src/plugin/webviews.ts +++ b/packages/plugin-ext/src/plugin/webviews.ts @@ -18,18 +18,20 @@ import { v4 } from 'uuid'; import { WebviewsExt, WebviewPanelViewState, WebviewsMain, PLUGIN_RPC_CONTEXT, WebviewInitData, /* WebviewsMain, PLUGIN_RPC_CONTEXT */ } from '../common/plugin-api-rpc'; import * as theia from '@theia/plugin'; import { RPCProtocol } from '../common/rpc-protocol'; +import { Plugin } from '../common/plugin-api-rpc'; import URI from 'vscode-uri'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { fromViewColumn, toViewColumn, toWebviewPanelShowOptions } from './type-converters'; import { Disposable, WebviewPanelTargetArea } from './types-impl'; import { WorkspaceExtImpl } from './workspace'; +import { PluginIconPath } from './plugin-icon-path'; export class WebviewsExtImpl implements WebviewsExt { private readonly proxy: WebviewsMain; private readonly webviewPanels = new Map(); private readonly serializers = new Map(); private initData: WebviewInitData | undefined; @@ -85,9 +87,9 @@ export class WebviewsExtImpl implements WebviewsExt { if (!entry) { return Promise.reject(new Error(`No serializer found for '${viewType}'`)); } - const { serializer, pluginLocation } = entry; + const { serializer, plugin } = entry; - const webview = new WebviewImpl(viewId, this.proxy, options, this.initData, this.workspace, pluginLocation); + const webview = new WebviewImpl(viewId, this.proxy, options, this.initData, this.workspace, plugin); const revivedPanel = new WebviewPanelImpl(viewId, this.proxy, viewType, title, toViewColumn(position)!, options, webview); this.webviewPanels.set(viewId, revivedPanel); return serializer.deserializeWebviewPanel(revivedPanel, state); @@ -98,7 +100,7 @@ export class WebviewsExtImpl implements WebviewsExt { title: string, showOptions: theia.ViewColumn | theia.WebviewPanelShowOptions, options: theia.WebviewPanelOptions & theia.WebviewOptions, - pluginLocation: URI + plugin: Plugin ): theia.WebviewPanel { if (!this.initData) { throw new Error('Webviews are not initialized'); @@ -107,7 +109,7 @@ export class WebviewsExtImpl implements WebviewsExt { const viewId = v4(); this.proxy.$createWebviewPanel(viewId, viewType, title, webviewShowOptions, options); - const webview = new WebviewImpl(viewId, this.proxy, options, this.initData, this.workspace, pluginLocation); + const webview = new WebviewImpl(viewId, this.proxy, options, this.initData, this.workspace, plugin); const panel = new WebviewPanelImpl(viewId, this.proxy, viewType, title, webviewShowOptions, options, webview); this.webviewPanels.set(viewId, panel); return panel; @@ -116,13 +118,13 @@ export class WebviewsExtImpl implements WebviewsExt { registerWebviewPanelSerializer( viewType: string, serializer: theia.WebviewPanelSerializer, - pluginLocation: URI + plugin: Plugin ): theia.Disposable { if (this.serializers.has(viewType)) { throw new Error(`Serializer for '${viewType}' already registered`); } - this.serializers.set(viewType, { serializer, pluginLocation }); + this.serializers.set(viewType, { serializer, plugin }); this.proxy.$registerSerializer(viewType); return new Disposable(() => { @@ -156,7 +158,7 @@ export class WebviewImpl implements theia.Webview { options: theia.WebviewOptions, private readonly initData: WebviewInitData, private readonly workspace: WorkspaceExtImpl, - private readonly pluginLocation: URI + readonly plugin: Plugin ) { this._options = options; } @@ -206,7 +208,7 @@ export class WebviewImpl implements theia.Webview { ...newOptions, localResourceRoots: newOptions.localResourceRoots || [ ...(this.workspace.workspaceFolders || []).map(x => x.uri), - this.pluginLocation, + URI.file(this.plugin.pluginPath) ] }); this._options = newOptions; @@ -231,6 +233,7 @@ export class WebviewPanelImpl implements theia.WebviewPanel { private _active = true; private _visible = true; private _showOptions: theia.WebviewPanelShowOptions; + private _iconPath: theia.Uri | { light: theia.Uri; dark: theia.Uri } | undefined; readonly onDisposeEmitter = new Emitter(); public readonly onDidDispose: Event = this.onDisposeEmitter.event; @@ -243,7 +246,7 @@ export class WebviewPanelImpl implements theia.WebviewPanel { private readonly _viewType: string, private _title: string, showOptions: theia.ViewColumn | theia.WebviewPanelShowOptions, - private readonly _options: theia.WebviewPanelOptions | undefined, + private readonly _options: theia.WebviewPanelOptions, private readonly _webview: WebviewImpl ) { this._showOptions = typeof showOptions === 'object' ? showOptions : { viewColumn: showOptions as theia.ViewColumn }; @@ -283,19 +286,15 @@ export class WebviewPanelImpl implements theia.WebviewPanel { } } - set iconPath(iconPath: theia.Uri | { light: theia.Uri; dark: theia.Uri }) { + get iconPath(): theia.Uri | { light: theia.Uri; dark: theia.Uri } | undefined { + return this._iconPath; + } + + set iconPath(iconPath: theia.Uri | { light: theia.Uri; dark: theia.Uri } | undefined) { this.checkIsDisposed(); - if (URI.isUri(iconPath)) { - if ('http' === iconPath.scheme || 'https' === iconPath.scheme) { - this.proxy.$setIconPath(this.viewId, iconPath.toString()); - } else { - this.proxy.$setIconPath(this.viewId, (iconPath).path); - } - } else { - this.proxy.$setIconPath(this.viewId, { - light: (<{ light: theia.Uri; dark: theia.Uri }>iconPath).light.path, - dark: (<{ light: theia.Uri; dark: theia.Uri }>iconPath).dark.path - }); + if (this._iconPath !== iconPath) { + this._iconPath = iconPath; + this.proxy.$setIconPath(this.viewId, PluginIconPath.toUrl(iconPath, this._webview.plugin)); } } @@ -306,7 +305,7 @@ export class WebviewPanelImpl implements theia.WebviewPanel { get options(): theia.WebviewPanelOptions { this.checkIsDisposed(); - return this._options!; + return this._options; } get viewColumn(): theia.ViewColumn | undefined { From 93e23c285f664b9d6ca808e53538aeebefe8e274 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Thu, 31 Oct 2019 08:51:50 +0000 Subject: [PATCH 08/21] =?UTF-8?q?[wbview]=C2=A0fix=20#5518:=20apply=20them?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Anton Kosyakov --- packages/core/src/browser/color-registry.ts | 31 +++++ .../browser/frontend-application-module.ts | 2 + packages/core/src/browser/theming.ts | 9 +- .../src/browser/monaco-color-registry.ts | 37 ++++++ .../src/browser/monaco-frontend-module.ts | 8 +- packages/monaco/src/browser/monaco-loader.ts | 5 +- packages/monaco/src/typings/monaco/index.d.ts | 11 ++ .../browser/plugin-ext-frontend-module.ts | 2 + .../webview/webview-theme-data-provider.ts | 115 ++++++++++++++++++ .../src/main/browser/webview/webview.ts | 18 +++ 10 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/browser/color-registry.ts create mode 100644 packages/monaco/src/browser/monaco-color-registry.ts create mode 100644 packages/plugin-ext/src/main/browser/webview/webview-theme-data-provider.ts diff --git a/packages/core/src/browser/color-registry.ts b/packages/core/src/browser/color-registry.ts new file mode 100644 index 0000000000000..128c528470de0 --- /dev/null +++ b/packages/core/src/browser/color-registry.ts @@ -0,0 +1,31 @@ +/******************************************************************************** + * Copyright (C) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable } from 'inversify'; + +/** + * It should be implemented by an extension, e.g. by the monaco extension. + */ +@injectable() +export class ColorRegistry { + + *getColors(): IterableIterator { } + + getCurrentColor(id: string): string | undefined { + return undefined; + } + +} diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 20c91dbc4747d..e87520db6a401 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -83,6 +83,7 @@ import { ProgressStatusBarItem } from './progress-status-bar-item'; import { TabBarDecoratorService, TabBarDecorator } from './shell/tab-bar-decorator'; import { ContextMenuContext } from './menu/context-menu-context'; import { bindResourceProvider, bindMessageService, bindPreferenceService } from './frontend-application-bindings'; +import { ColorRegistry } from './color-registry'; export { bindResourceProvider, bindMessageService, bindPreferenceService }; @@ -90,6 +91,7 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo const themeService = ThemeService.get(); themeService.register(...BuiltinThemeProvider.themes); themeService.startupTheme(); + bind(ColorRegistry).toSelf().inSingletonScope(); bind(FrontendApplication).toSelf().inSingletonScope(); bind(FrontendApplicationStateService).toSelf().inSingletonScope(); diff --git a/packages/core/src/browser/theming.ts b/packages/core/src/browser/theming.ts index a5c4c51473901..72949d198c16f 100644 --- a/packages/core/src/browser/theming.ts +++ b/packages/core/src/browser/theming.ts @@ -25,8 +25,11 @@ import { CommonMenus } from './common-frontend-contribution'; export const ThemeServiceSymbol = Symbol('ThemeService'); +export type ThemeType = 'light' | 'dark' | 'hc'; + export interface Theme { readonly id: string; + readonly type: ThemeType; readonly label: string; readonly description?: string; readonly editorTheme?: string; @@ -197,8 +200,9 @@ export class BuiltinThemeProvider { static readonly darkCss = require('../../src/browser/style/variables-dark.useable.css'); static readonly lightCss = require('../../src/browser/style/variables-bright.useable.css'); - static readonly darkTheme = { + static readonly darkTheme: Theme = { id: 'dark', + type: 'dark', label: 'Dark Theme', description: 'Bright fonts on dark backgrounds.', editorTheme: 'dark-plus', // loaded in /packages/monaco/src/browser/textmate/monaco-theme-registry.ts @@ -210,8 +214,9 @@ export class BuiltinThemeProvider { } }; - static readonly lightTheme = { + static readonly lightTheme: Theme = { id: 'light', + type: 'light', label: 'Light Theme', description: 'Dark fonts on light backgrounds.', editorTheme: 'light-plus', // loaded in /packages/monaco/src/browser/textmate/monaco-theme-registry.ts diff --git a/packages/monaco/src/browser/monaco-color-registry.ts b/packages/monaco/src/browser/monaco-color-registry.ts new file mode 100644 index 0000000000000..fd4f544d448ba --- /dev/null +++ b/packages/monaco/src/browser/monaco-color-registry.ts @@ -0,0 +1,37 @@ +/******************************************************************************** + * Copyright (C) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable } from 'inversify'; +import { ColorRegistry } from '@theia/core/lib/browser/color-registry'; + +@injectable() +export class MonacoColorRegistry implements ColorRegistry { + + protected readonly monacoThemeService = monaco.services.StaticServices.standaloneThemeService.get(); + protected readonly monacoColorRegistry = monaco.color.getColorRegistry(); + + *getColors(): IterableIterator { + for (const { id } of this.monacoColorRegistry.getColors()) { + yield id; + } + } + + getCurrentColor(id: string): string | undefined { + const color = this.monacoThemeService.getTheme().getColor(id); + return color && color.toString(); + } + +} diff --git a/packages/monaco/src/browser/monaco-frontend-module.ts b/packages/monaco/src/browser/monaco-frontend-module.ts index 8d205dace3dee..91f1967f016a4 100644 --- a/packages/monaco/src/browser/monaco-frontend-module.ts +++ b/packages/monaco/src/browser/monaco-frontend-module.ts @@ -18,6 +18,7 @@ import '../../src/browser/style/index.css'; import '../../src/browser/style/symbol-sprite.svg'; import '../../src/browser/style/symbol-icons.css'; +import debounce = require('lodash.debounce'); import { ContainerModule, decorate, injectable, interfaces } from 'inversify'; import { MenuContribution, CommandContribution } from '@theia/core/lib/common'; import { PreferenceScope } from '@theia/core/lib/common/preferences/preference-scope'; @@ -58,9 +59,9 @@ import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { MonacoContextKeyService } from './monaco-context-key-service'; import { MonacoMimeService } from './monaco-mime-service'; import { MimeService } from '@theia/core/lib/browser/mime-service'; - -import debounce = require('lodash.debounce'); import { MonacoEditorServices } from './monaco-editor'; +import { MonacoColorRegistry } from './monaco-color-registry'; +import { ColorRegistry } from '@theia/core/lib/browser/color-registry'; decorate(injectable(), MonacoToProtocolConverter); decorate(injectable(), ProtocolToMonacoConverter); @@ -130,6 +131,9 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(MonacoMimeService).toSelf().inSingletonScope(); rebind(MimeService).toService(MonacoMimeService); + + bind(MonacoColorRegistry).toSelf().inSingletonScope(); + rebind(ColorRegistry).toService(MonacoColorRegistry); }); export const MonacoConfigurationService = Symbol('MonacoConfigurationService'); diff --git a/packages/monaco/src/browser/monaco-loader.ts b/packages/monaco/src/browser/monaco-loader.ts index 5a3a7b9b8cd3d..b7455839b92c7 100644 --- a/packages/monaco/src/browser/monaco-loader.ts +++ b/packages/monaco/src/browser/monaco-loader.ts @@ -61,6 +61,7 @@ export function loadMonaco(vsRequire: any): Promise { 'vs/base/parts/quickopen/browser/quickOpenModel', 'vs/base/common/filters', 'vs/platform/theme/common/styler', + 'vs/platform/theme/common/colorRegistry', 'vs/base/common/platform', 'vs/editor/common/modes', 'vs/editor/contrib/suggest/suggest', @@ -76,7 +77,8 @@ export function loadMonaco(vsRequire: any): Promise { ], (css: any, html: any, commands: any, actions: any, keybindingsRegistry: any, keybindingResolver: any, resolvedKeybinding: any, keybindingLabels: any, keyCodes: any, mime: any, editorExtensions: any, simpleServices: any, standaloneServices: any, quickOpenWidget: any, quickOpenModel: any, - filters: any, styler: any, platform: any, modes: any, suggest: any, snippetParser: any, + filters: any, styler: any, colorRegistry: any, + platform: any, modes: any, suggest: any, snippetParser: any, configuration: any, configurationModels: any, codeEditorService: any, codeEditorServiceImpl: any, markerService: any, @@ -91,6 +93,7 @@ export function loadMonaco(vsRequire: any): Promise { global.monaco.quickOpen = Object.assign({}, quickOpenWidget, quickOpenModel); global.monaco.filters = filters; global.monaco.theme = styler; + global.monaco.color = colorRegistry; global.monaco.platform = platform; global.monaco.editorExtensions = editorExtensions; global.monaco.modes = modes; diff --git a/packages/monaco/src/typings/monaco/index.d.ts b/packages/monaco/src/typings/monaco/index.d.ts index 9a73d4e767b87..320cdc0211a73 100644 --- a/packages/monaco/src/typings/monaco/index.d.ts +++ b/packages/monaco/src/typings/monaco/index.d.ts @@ -477,6 +477,7 @@ declare module monaco.services { export interface IStandaloneTheme { tokenTheme: TokenTheme; + getColor(color: string): Color | undefined; } export interface TokenTheme { @@ -537,6 +538,16 @@ declare module monaco.theme { export function attachQuickOpenStyler(widget: IThemable, themeService: IThemeService): monaco.IDisposable; } +declare module monaco.color { + export interface ColorContribution { + readonly id: string; + } + export interface IColorRegistry { + getColors(): ColorContribution[]; + } + export function getColorRegistry(): IColorRegistry; +} + declare module monaco.referenceSearch { export interface Location { diff --git a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts index 863f1b775aa45..5896b5c9755e8 100644 --- a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts +++ b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts @@ -65,6 +65,7 @@ import { OutputChannelRegistryMainImpl } from './output-channel-registry-main'; import { InPluginFileSystemWatcherManager } from './in-plugin-filesystem-watcher-manager'; import { WebviewWidget, WebviewWidgetIdentifier, WebviewWidgetExternalEndpoint } from './webview/webview'; import { WebviewEnvironment } from './webview/webview-environment'; +import { WebviewThemeDataProvider } from './webview/webview-theme-data-provider'; export default new ContainerModule((bind, unbind, isBound, rebind) => { @@ -149,6 +150,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { })).inSingletonScope(); bind(WebviewEnvironment).toSelf().inSingletonScope(); + bind(WebviewThemeDataProvider).toSelf().inSingletonScope(); bind(WebviewWidget).toSelf(); bind(WidgetFactory).toDynamicValue(({ container }) => ({ id: WebviewWidget.FACTORY_ID, diff --git a/packages/plugin-ext/src/main/browser/webview/webview-theme-data-provider.ts b/packages/plugin-ext/src/main/browser/webview/webview-theme-data-provider.ts new file mode 100644 index 0000000000000..b15ce8d569283 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview/webview-theme-data-provider.ts @@ -0,0 +1,115 @@ +/******************************************************************************** + * Copyright (C) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// copied and modified from https://github.com/microsoft/vscode/blob/ba40bd16433d5a817bfae15f3b4350e18f144af4/src/vs/workbench/contrib/webview/common/themeing.ts + +import { inject, postConstruct, injectable } from 'inversify'; +import { Emitter } from '@theia/core/lib/common/event'; +import { EditorPreferences, EditorConfiguration } from '@theia/editor/lib/browser/editor-preferences'; +import { ThemeService } from '@theia/core/lib/browser/theming'; +import { ColorRegistry } from '@theia/core/lib/browser/color-registry'; + +export type WebviewThemeType = 'vscode-light' | 'vscode-dark' | 'vscode-high-contrast'; +export interface WebviewThemeData { + readonly activeTheme: WebviewThemeType; + readonly styles: { readonly [key: string]: string | number; }; +} + +@injectable() +export class WebviewThemeDataProvider { + + protected readonly onDidChangeThemeDataEmitter = new Emitter(); + readonly onDidChangeThemeData = this.onDidChangeThemeDataEmitter.event; + + @inject(EditorPreferences) + protected readonly editorPreferences: EditorPreferences; + + @inject(ColorRegistry) + protected readonly colorRegistry: ColorRegistry; + + protected themeData: WebviewThemeData | undefined; + + protected readonly editorStyles = new Map([ + ['editor.fontFamily', 'editor-font-family'], + ['editor.fontWeight', 'editor-font-weight'], + ['editor.fontSize', 'editor-font-size'] + ]); + + @postConstruct() + protected init(): void { + ThemeService.get().onThemeChange(() => this.reset()); + + this.editorPreferences.onPreferenceChanged(e => { + if (this.editorStyles.has(e.preferenceName)) { + this.reset(); + } + }); + } + + protected reset(): void { + if (this.themeData) { + this.themeData = undefined; + this.onDidChangeThemeDataEmitter.fire(undefined); + } + } + + getThemeData(): WebviewThemeData { + if (!this.themeData) { + this.themeData = this.computeThemeData(); + } + return this.themeData; + } + + protected computeThemeData(): WebviewThemeData { + const styles: { [key: string]: string | number; } = {}; + // tslint:disable-next-line:no-any + const addStyle = (id: string, rawValue: any) => { + if (rawValue) { + const value = typeof rawValue === 'number' || typeof rawValue === 'string' ? rawValue : String(rawValue); + styles['vscode-' + id.replace('.', '-')] = value; + styles['theia-' + id.replace('.', '-')] = value; + } + }; + + addStyle('font-family', '-apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", "Ubuntu", "Droid Sans", sans-serif'); + addStyle('font-weight', 'normal'); + addStyle('font-size', '13px'); + this.editorStyles.forEach((value, key) => addStyle(value, this.editorPreferences[key])); + + for (const id of this.colorRegistry.getColors()) { + const color = this.colorRegistry.getCurrentColor(id); + if (color) { + addStyle(id, color.toString()); + } + } + + const activeTheme = this.getActiveTheme(); + return { styles, activeTheme }; + } + + protected getActiveTheme(): WebviewThemeType { + const theme = ThemeService.get().getCurrentTheme(); + switch (theme.type) { + case 'light': return 'vscode-light'; + case 'dark': return 'vscode-dark'; + default: return 'vscode-high-contrast'; + } + } + +} diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index e8dc8500332b4..d0ff573557703 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -13,6 +13,12 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ +// copied and modified from https://github.com/microsoft/vscode/blob/ba40bd16433d5a817bfae15f3b4350e18f144af4/src/vs/workbench/contrib/webview/browser/baseWebviewElement.ts +// copied and modified from https://github.com/microsoft/vscode/blob/ba40bd16433d5a817bfae15f3b4350e18f144af4/src/vs/workbench/contrib/webview/browser/webviewElement.ts# import * as mime from 'mime'; import { JSONExt } from '@phosphor/coreutils/lib/json'; @@ -36,6 +42,7 @@ import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding'; import { Schemes } from '../../../common/uri-components'; import { PluginSharedStyle } from '../plugin-shared-style'; import { BuiltinThemeProvider } from '@theia/core/lib/browser/theming'; +import { WebviewThemeDataProvider } from './webview-theme-data-provider'; // tslint:disable:no-any @@ -108,6 +115,9 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { @inject(PluginSharedStyle) protected readonly sharedStyle: PluginSharedStyle; + @inject(WebviewThemeDataProvider) + protected readonly themeDataProvider: WebviewThemeDataProvider; + viewState: WebviewPanelViewState = { visible: false, active: false, @@ -205,6 +215,9 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { // keybinding service because these events do not bubble to the parent window anymore. this.dispatchKeyDown(data); })); + + this.style(); + this.toDispose.push(this.themeDataProvider.onDidChangeThemeData(() => this.style())); } setContentOptions(contentOptions: WebviewContentOptions): void { @@ -269,6 +282,11 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { this.doUpdateContent(); } + protected style(): void { + const { styles, activeTheme } = this.themeDataProvider.getThemeData(); + this.doSend('styles', { styles, activeTheme }); + } + protected dispatchKeyDown(event: KeyboardEventInit): void { // Create a fake KeyboardEvent from the data provided const emulatedKeyboardEvent = new KeyboardEvent('keydown', event); From 766dfd7740f038af0bc2bc04f84a7acc9b5b42a2 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Thu, 31 Oct 2019 10:22:36 +0000 Subject: [PATCH 09/21] [theming] #4831: color contribution point - Theia extensions can register new colors - All colors are translated into css variables with `.` replaced by `-` and `--theia` prefix - Theia extensions can use registered colors via css or programatically from `ColorRegistrsy.getCurrentColor` Signed-off-by: Anton Kosyakov --- .../browser/color-application-contribution.ts | 75 +++++++++++++++++++ packages/core/src/browser/color-registry.ts | 16 ++++ .../browser/frontend-application-module.ts | 5 ++ .../src/browser/monaco-color-registry.ts | 8 +- packages/monaco/src/typings/monaco/index.d.ts | 7 ++ .../webview/webview-theme-data-provider.ts | 6 +- 6 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/browser/color-application-contribution.ts diff --git a/packages/core/src/browser/color-application-contribution.ts b/packages/core/src/browser/color-application-contribution.ts new file mode 100644 index 0000000000000..9f03df91e57a1 --- /dev/null +++ b/packages/core/src/browser/color-application-contribution.ts @@ -0,0 +1,75 @@ +/******************************************************************************** + * Copyright (C) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable, inject, named } from 'inversify'; +import { ColorRegistry } from './color-registry'; +import { Emitter } from '../common/event'; +import { ThemeService } from './theming'; +import { FrontendApplicationContribution } from './frontend-application'; +import { ContributionProvider } from '../common/contribution-provider'; +import { Disposable, DisposableCollection } from '../common/disposable'; + +export const ColorContribution = Symbol('ColorContribution'); +export interface ColorContribution { + registerColors(colors: ColorRegistry): void; +} + +@injectable() +export class ColorApplicationContribution implements FrontendApplicationContribution { + + protected readonly onDidChangeEmitter = new Emitter(); + readonly onDidChange = this.onDidChangeEmitter.event; + + @inject(ColorRegistry) + protected readonly colors: ColorRegistry; + + @inject(ContributionProvider) @named(ColorContribution) + protected readonly colorContributions: ContributionProvider; + + onStart(): void { + for (const contribution of this.colorContributions.getContributions()) { + contribution.registerColors(this.colors); + } + + this.update(); + ThemeService.get().onThemeChange(() => this.update()); + } + + protected readonly toUpdate = new DisposableCollection(); + protected update(): void { + if (!document) { + return; + } + this.toUpdate.dispose(); + const theme = 'theia-' + ThemeService.get().getCurrentTheme().type; + document.body.classList.add(theme); + this.toUpdate.push(Disposable.create(() => document.body.classList.remove(theme))); + + const documentElement = document.documentElement; + if (documentElement) { + for (const id of this.colors.getColors()) { + const color = this.colors.getCurrentColor(id); + if (color) { + const propertyName = `--theia-${id.replace('.', '-')}`; + documentElement.style.setProperty(propertyName, color); + this.toUpdate.push(Disposable.create(() => documentElement.style.removeProperty(propertyName))); + } + } + } + this.onDidChangeEmitter.fire(undefined); + } + +} diff --git a/packages/core/src/browser/color-registry.ts b/packages/core/src/browser/color-registry.ts index 128c528470de0..03fcc2ec19530 100644 --- a/packages/core/src/browser/color-registry.ts +++ b/packages/core/src/browser/color-registry.ts @@ -15,6 +15,18 @@ ********************************************************************************/ import { injectable } from 'inversify'; +import { Disposable } from '../common/disposable'; + +export interface ColorDefaults { + light?: string + dark?: string + hc?: string +} + +export interface ColorOptions { + defaults?: ColorDefaults + description: string +} /** * It should be implemented by an extension, e.g. by the monaco extension. @@ -28,4 +40,8 @@ export class ColorRegistry { return undefined; } + register(id: string, options: ColorOptions): Disposable { + return Disposable.NULL; + } + } diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index e87520db6a401..6d8eb7f7883f9 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -84,6 +84,7 @@ import { TabBarDecoratorService, TabBarDecorator } from './shell/tab-bar-decorat import { ContextMenuContext } from './menu/context-menu-context'; import { bindResourceProvider, bindMessageService, bindPreferenceService } from './frontend-application-bindings'; import { ColorRegistry } from './color-registry'; +import { ColorContribution, ColorApplicationContribution } from './color-application-contribution'; export { bindResourceProvider, bindMessageService, bindPreferenceService }; @@ -91,7 +92,11 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo const themeService = ThemeService.get(); themeService.register(...BuiltinThemeProvider.themes); themeService.startupTheme(); + bind(ColorRegistry).toSelf().inSingletonScope(); + bindContributionProvider(bind, ColorContribution); + bind(ColorApplicationContribution).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService(ColorApplicationContribution); bind(FrontendApplication).toSelf().inSingletonScope(); bind(FrontendApplicationStateService).toSelf().inSingletonScope(); diff --git a/packages/monaco/src/browser/monaco-color-registry.ts b/packages/monaco/src/browser/monaco-color-registry.ts index fd4f544d448ba..f776ae4d9a721 100644 --- a/packages/monaco/src/browser/monaco-color-registry.ts +++ b/packages/monaco/src/browser/monaco-color-registry.ts @@ -15,7 +15,8 @@ ********************************************************************************/ import { injectable } from 'inversify'; -import { ColorRegistry } from '@theia/core/lib/browser/color-registry'; +import { ColorRegistry, ColorOptions } from '@theia/core/lib/browser/color-registry'; +import { Disposable } from '@theia/core/lib/common/disposable'; @injectable() export class MonacoColorRegistry implements ColorRegistry { @@ -34,4 +35,9 @@ export class MonacoColorRegistry implements ColorRegistry { return color && color.toString(); } + register(id: string, options: ColorOptions): Disposable { + const identifier = this.monacoColorRegistry.registerColor(id, options.defaults, options.description); + return Disposable.create(() => this.monacoColorRegistry.deregisterColor(identifier)); + } + } diff --git a/packages/monaco/src/typings/monaco/index.d.ts b/packages/monaco/src/typings/monaco/index.d.ts index 320cdc0211a73..c71fe10fa5f73 100644 --- a/packages/monaco/src/typings/monaco/index.d.ts +++ b/packages/monaco/src/typings/monaco/index.d.ts @@ -542,8 +542,15 @@ declare module monaco.color { export interface ColorContribution { readonly id: string; } + export interface ColorDefaults { + ligh?: string; + dark?: string; + hc?: string; + } export interface IColorRegistry { getColors(): ColorContribution[]; + registerColor(id: string, defaults: ColorDefaults | undefined, description: string): string; + deregisterColor(id: string): void; } export function getColorRegistry(): IColorRegistry; } diff --git a/packages/plugin-ext/src/main/browser/webview/webview-theme-data-provider.ts b/packages/plugin-ext/src/main/browser/webview/webview-theme-data-provider.ts index b15ce8d569283..081cad8ec4066 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview-theme-data-provider.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview-theme-data-provider.ts @@ -24,6 +24,7 @@ import { Emitter } from '@theia/core/lib/common/event'; import { EditorPreferences, EditorConfiguration } from '@theia/editor/lib/browser/editor-preferences'; import { ThemeService } from '@theia/core/lib/browser/theming'; import { ColorRegistry } from '@theia/core/lib/browser/color-registry'; +import { ColorApplicationContribution } from '@theia/core/lib/browser/color-application-contribution'; export type WebviewThemeType = 'vscode-light' | 'vscode-dark' | 'vscode-high-contrast'; export interface WebviewThemeData { @@ -43,6 +44,9 @@ export class WebviewThemeDataProvider { @inject(ColorRegistry) protected readonly colorRegistry: ColorRegistry; + @inject(ColorApplicationContribution) + protected readonly colorContribution: ColorApplicationContribution; + protected themeData: WebviewThemeData | undefined; protected readonly editorStyles = new Map([ @@ -53,7 +57,7 @@ export class WebviewThemeDataProvider { @postConstruct() protected init(): void { - ThemeService.get().onThemeChange(() => this.reset()); + this.colorContribution.onDidChange(() => this.reset()); this.editorPreferences.onPreferenceChanged(e => { if (this.editorStyles.has(e.preferenceName)) { From 2e7fca769dadf16c9df2d422abbc9db106a6df49 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Wed, 6 Nov 2019 13:25:59 +0000 Subject: [PATCH 10/21] [webview] retain iframe when widget is hidden only when `retainContextWhenHidden` is true Signed-off-by: Anton Kosyakov --- .../src/main/browser/webview/webview.ts | 64 +++++++++++++++---- 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index d0ff573557703..73f4a99618885 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -139,11 +139,13 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { viewType: string; options: WebviewPanelOptions = {}; - protected readonly ready = new Deferred(); + protected ready = new Deferred(); protected readonly onMessageEmitter = new Emitter(); readonly onMessage = this.onMessageEmitter.event; + protected readonly toHide = new DisposableCollection(); + @postConstruct() protected init(): void { this.node.tabIndex = 0; @@ -168,6 +170,34 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { this.transparentOverlay.style.display = 'none'; } })); + } + + protected onBeforeAttach(msg: Message): void { + super.onBeforeAttach(msg); + this.doShow(); + } + + protected onBeforeShow(msg: Message): void { + super.onBeforeShow(msg); + this.doShow(); + } + + protected onAfterHide(msg: Message): void { + super.onAfterHide(msg); + this.doHide(); + } + + protected doHide(): void { + if (this.options.retainContextWhenHidden !== true) { + this.toHide.dispose(); + } + } + + protected doShow(): void { + if (!this.toHide.disposed) { + return; + } + this.toDispose.push(this.toHide); const element = document.createElement('iframe'); element.className = 'webview'; @@ -178,38 +208,44 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { element.style.height = '100%'; this.element = element; this.node.appendChild(this.element); - this.toDispose.push(Disposable.create(() => { + this.toHide.push(Disposable.create(() => { if (this.element) { this.element.remove(); this.element = undefined; } })); + const oldReady = this.ready; + const ready = new Deferred(); + ready.promise.then(() => oldReady.resolve()); + this.ready = ready; + this.doUpdateContent(); const subscription = this.on(WebviewMessageChannels.webviewReady, () => { subscription.dispose(); - this.ready.resolve(); + ready.resolve(); }); - this.toDispose.push(subscription); - this.toDispose.push(this.on(WebviewMessageChannels.onmessage, (data: any) => this.onMessageEmitter.fire(data))); - this.toDispose.push(this.on(WebviewMessageChannels.didClickLink, (uri: string) => this.openLink(new URI(uri)))); - this.toDispose.push(this.on(WebviewMessageChannels.doUpdateState, (state: any) => { + this.toHide.push(subscription); + + this.toHide.push(this.on(WebviewMessageChannels.onmessage, (data: any) => this.onMessageEmitter.fire(data))); + this.toHide.push(this.on(WebviewMessageChannels.didClickLink, (uri: string) => this.openLink(new URI(uri)))); + this.toHide.push(this.on(WebviewMessageChannels.doUpdateState, (state: any) => { this._state = state; })); - this.toDispose.push(this.on(WebviewMessageChannels.didFocus, () => + this.toHide.push(this.on(WebviewMessageChannels.didFocus, () => // emulate the webview focus without actually changing focus this.node.dispatchEvent(new FocusEvent('focus')) )); - this.toDispose.push(this.on(WebviewMessageChannels.didBlur, () => { + this.toHide.push(this.on(WebviewMessageChannels.didBlur, () => { /* no-op: webview loses focus only if another element gains focus in the main window */ })); - this.toDispose.push(this.on(WebviewMessageChannels.doReload, () => this.reload())); - this.toDispose.push(this.on(WebviewMessageChannels.loadResource, (entry: any) => { + this.toHide.push(this.on(WebviewMessageChannels.doReload, () => this.reload())); + this.toHide.push(this.on(WebviewMessageChannels.loadResource, (entry: any) => { const rawPath = entry.path; const normalizedPath = decodeURIComponent(rawPath); const uri = new URI(normalizedPath.replace(/^\/(\w+)\/(.+)$/, (_, scheme, path) => scheme + ':/' + path)); this.loadResource(rawPath, uri); })); - this.toDispose.push(this.on(WebviewMessageChannels.didKeydown, (data: KeyboardEvent) => { + this.toHide.push(this.on(WebviewMessageChannels.didKeydown, (data: KeyboardEvent) => { // Electron: workaround for https://github.com/electron/electron/issues/14258 // We have to detect keyboard events in the and dispatch them to our // keybinding service because these events do not bubble to the parent window anymore. @@ -217,7 +253,7 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { })); this.style(); - this.toDispose.push(this.themeDataProvider.onDidChangeThemeData(() => this.style())); + this.toHide.push(this.themeDataProvider.onDidChangeThemeData(() => this.style())); } setContentOptions(contentOptions: WebviewContentOptions): void { @@ -268,11 +304,11 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { protected onActivateRequest(msg: Message): void { super.onActivateRequest(msg); - this.node.focus(); this.focus(); } focus(): void { + this.node.focus(); if (this.element) { this.doSend('focus'); } From ff9a58c6c7a5b540e1d026c26bcc78bf3de0cebd Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Thu, 7 Nov 2019 08:35:14 +0000 Subject: [PATCH 11/21] [plugin]: support webview port mapping and external URIs Signed-off-by: Anton Kosyakov --- .../core/src/browser/external-uri-service.ts | 64 +++++++++++++++++++ .../browser/frontend-application-module.ts | 3 + .../core/src/browser/http-open-handler.ts | 9 ++- .../plugin-ext/src/common/plugin-api-rpc.ts | 1 + .../src/main/browser/webview/webview.ts | 44 +++++++++++++ .../src/main/browser/window-state-main.ts | 21 ++++-- .../plugin-ext/src/plugin/plugin-context.ts | 3 + .../plugin-ext/src/plugin/window-state.ts | 13 ++++ packages/plugin/src/theia.d.ts | 22 +++++++ 9 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/browser/external-uri-service.ts diff --git a/packages/core/src/browser/external-uri-service.ts b/packages/core/src/browser/external-uri-service.ts new file mode 100644 index 0000000000000..8ccf66fd69db6 --- /dev/null +++ b/packages/core/src/browser/external-uri-service.ts @@ -0,0 +1,64 @@ +/******************************************************************************** + * Copyright (C) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable } from 'inversify'; +import URI from '../common/uri'; +import { MaybePromise } from '../common/types'; +import { Endpoint } from './endpoint'; + +@injectable() +export class ExternalUriService { + + /** + * Maps local to remote URLs. + * Should be no-op if the given URL is not a localhost URL. + * + * By default maps to an origin serving Theia. + * + * Use `parseLocalhost` to retrive localhost address and port information. + */ + resolve(uri: URI): MaybePromise { + const localhost = this.parseLocalhost(uri); + if (localhost) { + return this.toRemoteUrl(uri, localhost); + } + return uri; + } + + protected toRemoteUrl(uri: URI, localhost: { address: string, port: number }): URI { + const host = this.toRemoteHost(localhost); + return new Endpoint({ host }).getRestUrl().withPath(uri.path).withFragment(uri.fragment).withQuery(uri.query); + } + + protected toRemoteHost(localhost: { address: string, port: number }): string { + return `${window.location.hostname}:${localhost.port}`; + } + + parseLocalhost(uri: URI): { address: string, port: number } | undefined { + if (uri.scheme !== 'http' && uri.scheme !== 'https') { + return undefined; + } + const localhostMatch = /^(localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)$/.exec(uri.authority); + if (!localhostMatch) { + return undefined; + } + return { + address: localhostMatch[1], + port: +localhostMatch[2], + }; + } + +} diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 6d8eb7f7883f9..676130f3ffd9e 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -85,6 +85,7 @@ import { ContextMenuContext } from './menu/context-menu-context'; import { bindResourceProvider, bindMessageService, bindPreferenceService } from './frontend-application-bindings'; import { ColorRegistry } from './color-registry'; import { ColorContribution, ColorApplicationContribution } from './color-application-contribution'; +import { ExternalUriService } from './external-uri-service'; export { bindResourceProvider, bindMessageService, bindPreferenceService }; @@ -138,6 +139,8 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo bindContributionProvider(bind, OpenHandler); bind(DefaultOpenerService).toSelf().inSingletonScope(); bind(OpenerService).toService(DefaultOpenerService); + + bind(ExternalUriService).toSelf().inSingletonScope(); bind(HttpOpenHandler).toSelf().inSingletonScope(); bind(OpenHandler).toService(HttpOpenHandler); diff --git a/packages/core/src/browser/http-open-handler.ts b/packages/core/src/browser/http-open-handler.ts index bf23712663c6a..6696ca756b5c8 100644 --- a/packages/core/src/browser/http-open-handler.ts +++ b/packages/core/src/browser/http-open-handler.ts @@ -18,6 +18,7 @@ import { injectable, inject } from 'inversify'; import URI from '../common/uri'; import { OpenHandler } from './opener-service'; import { WindowService } from './window/window-service'; +import { ExternalUriService } from './external-uri-service'; @injectable() export class HttpOpenHandler implements OpenHandler { @@ -27,12 +28,16 @@ export class HttpOpenHandler implements OpenHandler { @inject(WindowService) protected readonly windowService: WindowService; + @inject(ExternalUriService) + protected readonly externalUriService: ExternalUriService; + canHandle(uri: URI): number { return uri.scheme.startsWith('http') ? 500 : 0; } - open(uri: URI): Window | undefined { - return this.windowService.openNewWindow(uri.toString(true), { external: true }); + async open(uri: URI): Promise { + const resolvedUri = await this.externalUriService.resolve(uri); + return this.windowService.openNewWindow(resolvedUri.toString(true), { external: true }); } } diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index a786b6666921a..79066fe6507d6 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -561,6 +561,7 @@ export enum TreeViewItemCollapsibleState { export interface WindowMain { $openUri(uri: UriComponents): Promise; + $asExternalUri(uri: UriComponents): Promise; } export interface WindowStateExt { diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index 73f4a99618885..688b117037023 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -43,6 +43,7 @@ import { Schemes } from '../../../common/uri-components'; import { PluginSharedStyle } from '../plugin-shared-style'; import { BuiltinThemeProvider } from '@theia/core/lib/browser/theming'; import { WebviewThemeDataProvider } from './webview-theme-data-provider'; +import { ExternalUriService } from '@theia/core/lib/browser/external-uri-service'; // tslint:disable:no-any @@ -54,6 +55,7 @@ export const enum WebviewMessageChannels { doUpdateState = 'do-update-state', doReload = 'do-reload', loadResource = 'load-resource', + loadLocalhost = 'load-localhost', webviewReady = 'webview-ready', didKeydown = 'did-keydown' } @@ -118,6 +120,9 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { @inject(WebviewThemeDataProvider) protected readonly themeDataProvider: WebviewThemeDataProvider; + @inject(ExternalUriService) + protected readonly externalUriService: ExternalUriService; + viewState: WebviewPanelViewState = { visible: false, active: false, @@ -245,6 +250,9 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { const uri = new URI(normalizedPath.replace(/^\/(\w+)\/(.+)$/, (_, scheme, path) => scheme + ':/' + path)); this.loadResource(rawPath, uri); })); + this.toHide.push(this.on(WebviewMessageChannels.loadLocalhost, (entry: any) => + this.loadLocalhost(entry.origin) + )); this.toHide.push(this.on(WebviewMessageChannels.didKeydown, (data: KeyboardEvent) => { // Electron: workaround for https://github.com/electron/electron/issues/14258 // We have to detect keyboard events in the and dispatch them to our @@ -256,6 +264,42 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { this.toHide.push(this.themeDataProvider.onDidChangeThemeData(() => this.style())); } + protected async loadLocalhost(origin: string): Promise { + const redirect = await this.getRedirect(origin); + return this.doSend('did-load-localhost', { origin, location: redirect }); + } + + protected async getRedirect(url: string): Promise { + const uri = new URI(url); + const localhost = this.externalUriService.parseLocalhost(uri); + if (!localhost) { + return undefined; + } + + if (this._contentOptions.portMapping) { + for (const mapping of this._contentOptions.portMapping) { + if (mapping.webviewPort === localhost.port) { + if (mapping.webviewPort !== mapping.extensionHostPort) { + return this.toRemoteUrl( + uri.withAuthority(`${localhost.address}:${mapping.extensionHostPort}`) + ); + } + } + } + } + + return this.toRemoteUrl(uri); + } + + protected async toRemoteUrl(localUri: URI): Promise { + const remoteUri = await this.externalUriService.resolve(localUri); + const remoteUrl = remoteUri.toString(); + if (remoteUrl[remoteUrl.length - 1] === '/') { + return remoteUrl.slice(0, remoteUrl.length - 1); + } + return remoteUrl; + } + setContentOptions(contentOptions: WebviewContentOptions): void { if (JSONExt.deepEqual(this.contentOptions, contentOptions)) { return; diff --git a/packages/plugin-ext/src/main/browser/window-state-main.ts b/packages/plugin-ext/src/main/browser/window-state-main.ts index 7d199850b0d36..8d0113df33517 100644 --- a/packages/plugin-ext/src/main/browser/window-state-main.ts +++ b/packages/plugin-ext/src/main/browser/window-state-main.ts @@ -15,24 +15,29 @@ ********************************************************************************/ import URI from 'vscode-uri'; +import CoreURI from '@theia/core/lib/common/uri'; import { interfaces } from 'inversify'; import { WindowStateExt, MAIN_RPC_CONTEXT, WindowMain } from '../../common/plugin-api-rpc'; import { RPCProtocol } from '../../common/rpc-protocol'; import { UriComponents } from '../../common/uri-components'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; -import { WindowService } from '@theia/core/lib/browser/window/window-service'; +import { open, OpenerService } from '@theia/core/lib/browser/opener-service'; +import { ExternalUriService } from '@theia/core/lib/browser/external-uri-service'; export class WindowStateMain implements WindowMain, Disposable { private readonly proxy: WindowStateExt; - private readonly windowService: WindowService; + private readonly openerService: OpenerService; + + private readonly externalUriService: ExternalUriService; private readonly toDispose = new DisposableCollection(); constructor(rpc: RPCProtocol, container: interfaces.Container) { this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.WINDOW_STATE_EXT); - this.windowService = container.get(WindowService); + this.openerService = container.get(OpenerService); + this.externalUriService = container.get(ExternalUriService); const fireDidFocus = () => this.onFocusChanged(true); window.addEventListener('focus', fireDidFocus); @@ -53,13 +58,19 @@ export class WindowStateMain implements WindowMain, Disposable { async $openUri(uriComponent: UriComponents): Promise { const uri = URI.revive(uriComponent); - const url = encodeURI(uri.toString(true)); + const url = new CoreURI(encodeURI(uri.toString(true))); try { - this.windowService.openNewWindow(url, { external: true }); + await open(this.openerService, url); return true; } catch (e) { return false; } } + async $asExternalUri(uriComponents: UriComponents): Promise { + const uri = URI.revive(uriComponents); + const resolved = await this.externalUriService.resolve(new CoreURI(uri)); + return URI.parse(resolved.toString()); + } + } diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 6d1518ffad59a..05e05bee14978 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -508,6 +508,9 @@ export function createAPIFactory( }, openExternal(uri: theia.Uri): PromiseLike { return windowStateExt.openUri(uri); + }, + asExternalUri(target: theia.Uri): PromiseLike { + return windowStateExt.asExternalUri(target); } }); diff --git a/packages/plugin-ext/src/plugin/window-state.ts b/packages/plugin-ext/src/plugin/window-state.ts index a75a34cb3bca5..eb5c5a065d031 100644 --- a/packages/plugin-ext/src/plugin/window-state.ts +++ b/packages/plugin-ext/src/plugin/window-state.ts @@ -19,6 +19,7 @@ import { WindowState } from '@theia/plugin'; import { WindowStateExt, WindowMain, PLUGIN_RPC_CONTEXT } from '../common/plugin-api-rpc'; import { Event, Emitter } from '@theia/core/lib/common/event'; import { RPCProtocol } from '../common/rpc-protocol'; +import { Schemes } from '../common/uri-components'; export class WindowStateExtImpl implements WindowStateExt { @@ -52,4 +53,16 @@ export class WindowStateExtImpl implements WindowStateExt { return this.proxy.$openUri(uri); } + async asExternalUri(target: URI): Promise { + if (!target.scheme.trim().length) { + throw new Error('Invalid scheme - cannot be empty'); + } + if (Schemes.HTTP !== target.scheme && Schemes.HTTPS !== target.scheme) { + throw new Error(`Invalid scheme '${target.scheme}'`); + } + + const uri = await this.proxy.$asExternalUri(target); + return URI.revive(uri); + } + } diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index dc51f9439939c..271666d6210cd 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -4878,6 +4878,28 @@ declare module '@theia/plugin' { */ export function openExternal(target: Uri): PromiseLike; + /** + * Resolves an *external* uri, such as a `http:` or `https:` link, from where the extension is running to a + * uri to the same resource on the client machine. + * + * This is a no-op if the extension is running on the client machine. Currently only supports + * `https:` and `http:` uris. + * + * If the extension is running remotely, this function automatically establishes a port forwarding tunnel + * from the local machine to `target` on the remote and returns a local uri to the tunnel. The lifetime of + * the port fowarding tunnel is managed by VS Code and the tunnel can be closed by the user. + * + * Extensions should not cache the result of `asExternalUri` as the resolved uri may become invalid due to + * a system or user action — for example, in remote cases, a user may close a port forwardng tunnel + * that was opened by `asExternalUri`. + * + * *Note* that uris passed through `openExternal` are automatically resolved and you should not call `asExternalUri` + * on them. + * + * @return A uri that can be used on the client machine. + */ + export function asExternalUri(target: Uri): PromiseLike; + } /** From 6e841326a322fb2ed982db870acfd1e14fb78834 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Thu, 7 Nov 2019 09:05:43 +0000 Subject: [PATCH 12/21] [plugin] register command open handler webviews can use such URIs to trigger a command Signed-off-by: Anton Kosyakov --- .../browser/plugin-command-open-handler.ts | 50 +++++++++++++++++++ .../browser/plugin-ext-frontend-module.ts | 6 ++- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 packages/plugin-ext/src/main/browser/plugin-command-open-handler.ts diff --git a/packages/plugin-ext/src/main/browser/plugin-command-open-handler.ts b/packages/plugin-ext/src/main/browser/plugin-command-open-handler.ts new file mode 100644 index 0000000000000..4d685ab30a446 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/plugin-command-open-handler.ts @@ -0,0 +1,50 @@ +/******************************************************************************** + * Copyright (C) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable, inject } from 'inversify'; +import URI from '@theia/core/lib/common/uri'; +import { OpenHandler } from '@theia/core/lib/browser/opener-service'; +import { Schemes } from '../../common/uri-components'; +import { CommandService } from '@theia/core/lib/common/command'; + +@injectable() +export class PluginCommandOpenHandler implements OpenHandler { + + readonly id = 'plugin-command'; + + @inject(CommandService) + protected readonly commands: CommandService; + + canHandle(uri: URI): number { + return uri.scheme === Schemes.COMMAND ? 500 : -1; + } + + async open(uri: URI): Promise { + // tslint:disable-next-line:no-any + let args: any = []; + try { + args = JSON.parse(uri.query); + if (!Array.isArray(args)) { + args = [args]; + } + } catch (e) { + // ignore error + } + await this.commands.executeCommand(uri.path.toString(), ...args); + return true; + } + +} diff --git a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts index 5896b5c9755e8..ea762e65f466e 100644 --- a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts +++ b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts @@ -20,7 +20,7 @@ import '../../../src/main/browser/style/index.css'; import { ContainerModule } from 'inversify'; import { FrontendApplicationContribution, FrontendApplication, WidgetFactory, bindViewContribution, - ViewContainerIdentifier, ViewContainer, createTreeContainer, TreeImpl, TreeWidget, TreeModelImpl + ViewContainerIdentifier, ViewContainer, createTreeContainer, TreeImpl, TreeWidget, TreeModelImpl, OpenHandler } from '@theia/core/lib/browser'; import { MaybePromise, CommandContribution, ResourceResolver, bindContributionProvider } from '@theia/core/lib/common'; import { WebSocketConnectionProvider } from '@theia/core/lib/browser/messaging'; @@ -66,6 +66,7 @@ import { InPluginFileSystemWatcherManager } from './in-plugin-filesystem-watcher import { WebviewWidget, WebviewWidgetIdentifier, WebviewWidgetExternalEndpoint } from './webview/webview'; import { WebviewEnvironment } from './webview/webview-environment'; import { WebviewThemeDataProvider } from './webview/webview-theme-data-provider'; +import { PluginCommandOpenHandler } from './plugin-command-open-handler'; export default new ContainerModule((bind, unbind, isBound, rebind) => { @@ -149,6 +150,9 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { } })).inSingletonScope(); + bind(PluginCommandOpenHandler).toSelf().inSingletonScope(); + bind(OpenHandler).toService(PluginCommandOpenHandler); + bind(WebviewEnvironment).toSelf().inSingletonScope(); bind(WebviewThemeDataProvider).toSelf().inSingletonScope(); bind(WebviewWidget).toSelf(); From ef478b4af0df46cd22847356f34fb72c46b8b84b Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Fri, 8 Nov 2019 16:09:37 +0000 Subject: [PATCH 13/21] [webview] compliance to vscode webview api tests 1.40.0 Signed-off-by: Anton Kosyakov --- .gitpod.yml | 5 +- .vscode/launch.json | 3 +- .../browser/plugin-ext-frontend-module.ts | 6 ++ .../src/main/browser/style/webview.css | 5 ++ .../browser/webview/webview-preferences.ts | 56 +++++++++++++++ .../src/main/browser/webview/webview.ts | 71 ++++++++++++++++--- .../src/main/browser/webviews-main.ts | 12 +--- .../src/main/common/webview-protocol.ts | 14 ++++ .../main/node/plugin-ext-backend-module.ts | 9 +++ .../main/node/webview-resource-loader-impl.ts | 30 ++++++++ packages/plugin-ext/src/plugin/webviews.ts | 20 +++--- 11 files changed, 203 insertions(+), 28 deletions(-) create mode 100644 packages/plugin-ext/src/main/browser/webview/webview-preferences.ts create mode 100644 packages/plugin-ext/src/main/node/webview-resource-loader-impl.ts diff --git a/.gitpod.yml b/.gitpod.yml index b6d3ac3377ef8..e334dfcd62823 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -1,7 +1,10 @@ image: file: .gitpod.dockerfile ports: -- port: 3000 +- port: 3000 # Theia +- port: 3030 # VS Code extension tests +- port: 9339 # Node.js debug port + onOpen: ignore - port: 6080 onOpen: ignore - port: 5900 diff --git a/.vscode/launch.json b/.vscode/launch.json index a8ce94844a108..825f002eca906 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -169,7 +169,8 @@ "--hosted-plugin-inspect=9339" ], "env": { - "THEIA_DEFAULT_PLUGINS": "local-dir:${workspaceFolder}/plugins" + "THEIA_DEFAULT_PLUGINS": "local-dir:${workspaceFolder}/plugins", + "THEIA_WEBVIEW_EXTERNAL_ENDPOINT": "${env:THEIA_WEBVIEW_EXTERNAL_ENDPOINT}" }, "stopOnEntry": false, "sourceMaps": true, diff --git a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts index ea762e65f466e..afe90c396903b 100644 --- a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts +++ b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts @@ -67,6 +67,8 @@ import { WebviewWidget, WebviewWidgetIdentifier, WebviewWidgetExternalEndpoint } import { WebviewEnvironment } from './webview/webview-environment'; import { WebviewThemeDataProvider } from './webview/webview-theme-data-provider'; import { PluginCommandOpenHandler } from './plugin-command-open-handler'; +import { bindWebviewPreferences } from './webview/webview-preferences'; +import { WebviewResourceLoader, WebviewResourceLoaderPath } from '../common/webview-protocol'; export default new ContainerModule((bind, unbind, isBound, rebind) => { @@ -153,8 +155,12 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(PluginCommandOpenHandler).toSelf().inSingletonScope(); bind(OpenHandler).toService(PluginCommandOpenHandler); + bindWebviewPreferences(bind); bind(WebviewEnvironment).toSelf().inSingletonScope(); bind(WebviewThemeDataProvider).toSelf().inSingletonScope(); + bind(WebviewResourceLoader).toDynamicValue(ctx => + WebSocketConnectionProvider.createProxy(ctx.container, WebviewResourceLoaderPath) + ).inSingletonScope(); bind(WebviewWidget).toSelf(); bind(WidgetFactory).toDynamicValue(({ container }) => ({ id: WebviewWidget.FACTORY_ID, diff --git a/packages/plugin-ext/src/main/browser/style/webview.css b/packages/plugin-ext/src/main/browser/style/webview.css index 56025c3cc2ddd..61a449d613eb9 100644 --- a/packages/plugin-ext/src/main/browser/style/webview.css +++ b/packages/plugin-ext/src/main/browser/style/webview.css @@ -14,6 +14,11 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +.theia-webview.p-mod-hidden { + visibility: hidden; + display: flex !important; +} + .theia-webview { display: flex; flex-direction: column; diff --git a/packages/plugin-ext/src/main/browser/webview/webview-preferences.ts b/packages/plugin-ext/src/main/browser/webview/webview-preferences.ts new file mode 100644 index 0000000000000..9935db33803d6 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview/webview-preferences.ts @@ -0,0 +1,56 @@ +/******************************************************************************** + * Copyright (C) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { interfaces } from 'inversify'; +import { + createPreferenceProxy, + PreferenceProxy, + PreferenceService, + PreferenceContribution, + PreferenceSchema +} from '@theia/core/lib/browser/preferences'; + +export const WebviewConfigSchema: PreferenceSchema = { + 'type': 'object', + 'properties': { + 'webview.trace': { + 'type': 'string', + 'enum': ['off', 'on', 'verbose'], + 'description': 'Controls communication tracing with webviews.', + 'default': 'off' + } + } +}; + +export interface WebviewConfiguration { + 'webview.trace': 'off' | 'on' | 'verbose' +} + +export const WebviewPreferences = Symbol('WebviewPreferences'); +export type WebviewPreferences = PreferenceProxy; + +export function createWebviewPreferences(preferences: PreferenceService): WebviewPreferences { + return createPreferenceProxy(preferences, WebviewConfigSchema); +} + +export function bindWebviewPreferences(bind: interfaces.Bind): void { + bind(WebviewPreferences).toDynamicValue(ctx => { + const preferences = ctx.container.get(PreferenceService); + return createWebviewPreferences(preferences); + }); + + bind(PreferenceContribution).toConstantValue({ schema: WebviewConfigSchema }); +} diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index 688b117037023..39b346c0c2706 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -35,7 +35,6 @@ import { IconUrl } from '../../../common/plugin-protocol'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { WebviewEnvironment } from './webview-environment'; import URI from '@theia/core/lib/common/uri'; -import { FileSystem } from '@theia/filesystem/lib/common/filesystem'; import { Emitter } from '@theia/core/lib/common/event'; import { open, OpenerService } from '@theia/core/lib/browser/opener-service'; import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding'; @@ -44,6 +43,9 @@ import { PluginSharedStyle } from '../plugin-shared-style'; import { BuiltinThemeProvider } from '@theia/core/lib/browser/theming'; import { WebviewThemeDataProvider } from './webview-theme-data-provider'; import { ExternalUriService } from '@theia/core/lib/browser/external-uri-service'; +import { OutputChannelManager } from '@theia/output/lib/common/output-channel'; +import { WebviewPreferences } from './webview-preferences'; +import { WebviewResourceLoader } from '../../common/webview-protocol'; // tslint:disable:no-any @@ -105,9 +107,6 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { @inject(WebviewEnvironment) protected readonly environment: WebviewEnvironment; - @inject(FileSystem) - protected readonly fileSystem: FileSystem; - @inject(OpenerService) protected readonly openerService: OpenerService; @@ -123,6 +122,15 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { @inject(ExternalUriService) protected readonly externalUriService: ExternalUriService; + @inject(OutputChannelManager) + protected readonly outputManager: OutputChannelManager; + + @inject(WebviewPreferences) + protected readonly preferences: WebviewPreferences; + + @inject(WebviewResourceLoader) + protected readonly resourceLoader: WebviewResourceLoader; + viewState: WebviewPanelViewState = { visible: false, active: false, @@ -148,8 +156,10 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { protected readonly onMessageEmitter = new Emitter(); readonly onMessage = this.onMessageEmitter.event; + protected readonly pendingMessages: any[] = []; protected readonly toHide = new DisposableCollection(); + protected hideTimeout: any | number | undefined; @postConstruct() protected init(): void { @@ -180,6 +190,8 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { protected onBeforeAttach(msg: Message): void { super.onBeforeAttach(msg); this.doShow(); + // iframe has to be reloaded when moved to another DOM element + this.toDisposeOnDetach.push(Disposable.create(() => this.forceHide())); } protected onBeforeShow(msg: Message): void { @@ -194,11 +206,22 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { protected doHide(): void { if (this.options.retainContextWhenHidden !== true) { - this.toHide.dispose(); + if (this.hideTimeout === undefined) { + // avoid removing iframe if a widget moved quickly + this.hideTimeout = setTimeout(() => this.forceHide(), 50); + } } } + protected forceHide(): void { + clearTimeout(this.hideTimeout); + this.hideTimeout = undefined; + this.toHide.dispose(); + } + protected doShow(): void { + clearTimeout(this.hideTimeout); + this.hideTimeout = undefined; if (!this.toHide.disposed) { return; } @@ -224,7 +247,7 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { const ready = new Deferred(); ready.promise.then(() => oldReady.resolve()); this.ready = ready; - this.doUpdateContent(); + this.toHide.push(Disposable.create(() => this.ready = new Deferred())); const subscription = this.on(WebviewMessageChannels.webviewReady, () => { subscription.dispose(); ready.resolve(); @@ -262,6 +285,11 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { this.style(); this.toHide.push(this.themeDataProvider.onDidChangeThemeData(() => this.style())); + + this.doUpdateContent(); + while (this.pendingMessages.length) { + this.sendMessage(this.pendingMessages.shift()); + } } protected async loadLocalhost(origin: string): Promise { @@ -400,12 +428,12 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { if (!new URI(root).path.isEqualOrParent(normalizedUri.path)) { continue; } - const { content } = await this.fileSystem.resolveContent(normalizedUri.toString()); + const { buffer } = await this.resourceLoader.load({ uri: normalizedUri.toString() }); return this.doSend('did-load-resource', { status: 200, path: requestPath, mime: mime.getType(normalizedUri.path.toString()) || 'application/octet-stream', - data: content + data: new Uint8Array(buffer) }); } } @@ -435,7 +463,11 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { } sendMessage(data: any): void { - this.doSend('message', data); + if (this.element) { + this.doSend('message', data); + } else { + this.pendingMessages.push(data); + } } protected doUpdateContent(): void { @@ -468,6 +500,9 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { } protected async doSend(channel: string, data?: any): Promise { + if (!this.element) { + return; + } try { await this.ready.promise; this.postMessage(channel, data); @@ -478,6 +513,7 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { protected postMessage(channel: string, data?: any): void { if (this.element) { + this.trace('out', channel, data); this.element.contentWindow!.postMessage({ channel, args: data }, '*'); } } @@ -488,6 +524,7 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { return; } if (e.data.channel === channel) { + this.trace('in', e.data.channel, e.data.data); handler(e.data.data); } }; @@ -497,6 +534,22 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { ); } + protected trace(kind: 'in' | 'out', channel: string, data?: any): void { + const value = this.preferences['webview.trace']; + if (value === 'off') { + return; + } + const output = this.outputManager.getChannel('webviews'); + output.append('\n' + this.identifier.id); + output.append(kind === 'out' ? ' => ' : ' <= '); + output.append(channel); + if (value === 'verbose') { + if (data) { + output.append('\n' + JSON.stringify(data, undefined, 2)); + } + } + } + } export namespace WebviewWidget { export namespace Styles { diff --git a/packages/plugin-ext/src/main/browser/webviews-main.ts b/packages/plugin-ext/src/main/browser/webviews-main.ts index 90e92d912ae4d..630d61be8bf2f 100644 --- a/packages/plugin-ext/src/main/browser/webviews-main.ts +++ b/packages/plugin-ext/src/main/browser/webviews-main.ts @@ -72,7 +72,7 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { localResourceRoots: localResourceRoots && localResourceRoots.map(root => root.toString()), ...contentOptions }); - this.addOrReattachWidget(panelId, showOptions); + this.addOrReattachWidget(view, showOptions); } protected hookWebview(view: WebviewWidget): void { @@ -86,11 +86,7 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { }); } - private async addOrReattachWidget(handle: string, showOptions: WebviewPanelShowOptions): Promise { - const widget = await this.tryGetWebview(handle); - if (!widget) { - return; - } + private addOrReattachWidget(widget: WebviewWidget, showOptions: WebviewPanelShowOptions): void { const widgetOptions: ApplicationShell.WidgetOptions = { area: showOptions.area ? showOptions.area : 'main' }; let mode = 'open-to-right'; @@ -123,8 +119,6 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { } else { this.shell.activateWidget(widget.id); } - this.updateViewState(widget, showOptions.viewColumn); - this.updateViewStates(); } async $disposeWebview(handle: string): Promise { @@ -144,7 +138,7 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { const columnIds = showOptions.viewColumn ? this.viewColumnService.getViewColumnIds(showOptions.viewColumn) : []; const area = this.shell.getAreaFor(widget); if (columnIds.indexOf(widget.id) === -1 || area !== showOptions.area) { - this.addOrReattachWidget(widget.identifier.id, showOptions); + this.addOrReattachWidget(widget, showOptions); return; } } diff --git a/packages/plugin-ext/src/main/common/webview-protocol.ts b/packages/plugin-ext/src/main/common/webview-protocol.ts index ee1d194132685..d3e4da9226380 100644 --- a/packages/plugin-ext/src/main/common/webview-protocol.ts +++ b/packages/plugin-ext/src/main/common/webview-protocol.ts @@ -25,3 +25,17 @@ export namespace WebviewExternalEndpoint { export const pattern = 'THEIA_WEBVIEW_EXTERNAL_ENDPOINT'; export const defaultPattern = '{{uuid}}.webview.{{hostname}}'; } + +export interface LoadWebviewResourceParams { + uri: string +} + +export interface LoadWebviewResourceResult { + buffer: number[] +} + +export const WebviewResourceLoader = Symbol('WebviewResourceLoader'); +export interface WebviewResourceLoader { + load(params: LoadWebviewResourceParams): Promise +} +export const WebviewResourceLoaderPath = '/services/webview-resource-loader'; diff --git a/packages/plugin-ext/src/main/node/plugin-ext-backend-module.ts b/packages/plugin-ext/src/main/node/plugin-ext-backend-module.ts index 4d1b2d9047316..c364c7ae25c89 100644 --- a/packages/plugin-ext/src/main/node/plugin-ext-backend-module.ts +++ b/packages/plugin-ext/src/main/node/plugin-ext-backend-module.ts @@ -34,8 +34,17 @@ import { PluginPathsService, pluginPathsServicePath } from '../common/plugin-pat import { PluginPathsServiceImpl } from './paths/plugin-paths-service'; import { PluginServerHandler } from './plugin-server-handler'; import { PluginCliContribution } from './plugin-cli-contribution'; +import { WebviewResourceLoaderImpl } from './webview-resource-loader-impl'; +import { WebviewResourceLoaderPath } from '../common/webview-protocol'; export function bindMainBackend(bind: interfaces.Bind): void { + bind(WebviewResourceLoaderImpl).toSelf().inSingletonScope(); + bind(ConnectionHandler).toDynamicValue(ctx => + new JsonRpcConnectionHandler(WebviewResourceLoaderPath, () => + ctx.container.get(WebviewResourceLoaderImpl) + ) + ).inSingletonScope(); + bind(PluginApiContribution).toSelf().inSingletonScope(); bind(BackendApplicationContribution).toService(PluginApiContribution); diff --git a/packages/plugin-ext/src/main/node/webview-resource-loader-impl.ts b/packages/plugin-ext/src/main/node/webview-resource-loader-impl.ts new file mode 100644 index 0000000000000..a9331ea8c0ff9 --- /dev/null +++ b/packages/plugin-ext/src/main/node/webview-resource-loader-impl.ts @@ -0,0 +1,30 @@ +/******************************************************************************** + * Copyright (C) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import * as fs from 'fs-extra'; +import { injectable } from 'inversify'; +import { WebviewResourceLoader, LoadWebviewResourceParams, LoadWebviewResourceResult } from '../common/webview-protocol'; +import { FileUri } from '@theia/core/lib/node/file-uri'; + +@injectable() +export class WebviewResourceLoaderImpl implements WebviewResourceLoader { + + async load(params: LoadWebviewResourceParams): Promise { + const buffer = await fs.readFile(FileUri.fsPath(params.uri)); + return { buffer: buffer.toJSON().data }; + } + +} diff --git a/packages/plugin-ext/src/plugin/webviews.ts b/packages/plugin-ext/src/plugin/webviews.ts index 2d6412f815809..e3c2f58fbe890 100644 --- a/packages/plugin-ext/src/plugin/webviews.ts +++ b/packages/plugin-ext/src/plugin/webviews.ts @@ -107,7 +107,7 @@ export class WebviewsExtImpl implements WebviewsExt { } const webviewShowOptions = toWebviewPanelShowOptions(showOptions); const viewId = v4(); - this.proxy.$createWebviewPanel(viewId, viewType, title, webviewShowOptions, options); + this.proxy.$createWebviewPanel(viewId, viewType, title, webviewShowOptions, WebviewImpl.toWebviewOptions(options, this.workspace, plugin)); const webview = new WebviewImpl(viewId, this.proxy, options, this.initData, this.workspace, plugin); const panel = new WebviewPanelImpl(viewId, this.proxy, viewType, title, webviewShowOptions, options, webview); @@ -204,13 +204,7 @@ export class WebviewImpl implements theia.Webview { set options(newOptions: theia.WebviewOptions) { this.checkIsDisposed(); - this.proxy.$setOptions(this.viewId, { - ...newOptions, - localResourceRoots: newOptions.localResourceRoots || [ - ...(this.workspace.workspaceFolders || []).map(x => x.uri), - URI.file(this.plugin.pluginPath) - ] - }); + this.proxy.$setOptions(this.viewId, WebviewImpl.toWebviewOptions(newOptions, this.workspace, this.plugin)); this._options = newOptions; } @@ -225,6 +219,16 @@ export class WebviewImpl implements theia.Webview { throw new Error('This Webview is disposed!'); } } + + static toWebviewOptions(options: theia.WebviewOptions, workspace: WorkspaceExtImpl, plugin: Plugin): theia.WebviewOptions { + return { + ...options, + localResourceRoots: options.localResourceRoots || [ + ...(workspace.workspaceFolders || []).map(x => x.uri), + URI.file(plugin.pluginFolder) + ] + }; + } } export class WebviewPanelImpl implements theia.WebviewPanel { From e462ae7f56bcb9545f13d42a91302b5e8e469952 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Thu, 14 Nov 2019 09:39:56 +0000 Subject: [PATCH 14/21] [webivew] clarify breaking changes for adopters Signed-off-by: Anton Kosyakov --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46769e1fa9dcb..e8e3ec689b3c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,29 @@ Breaking changes: - [core] renamed preference `list.openMode` to `workbench.list.openMode` [#6481](https://github.com/eclipse-theia/theia/pull/6481) - [task] changed `TaskSchemaUpdater.update()` from asynchronous to synchronous [#6483](https://github.com/eclipse-theia/theia/pull/6483) - [monaco] monaco prefix has been removed from commands [#5590](https://github.com/eclipse-theia/theia/pull/5590) +- [plugin] webviews are reimplemented to align with [VS Code browser implementation](https://blog.mattbierner.com/vscode-webview-web-learnings/) [#6465](https://github.com/eclipse-theia/theia/pull/6465) + - Security: `vscode.previewHTML` is removed, see https://code.visualstudio.com/updates/v1_33#_removing-the-vscodepreviewhtml-command + - Security: Before all webviews were deployed on [the same origin](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) + allowing them to break out and manipulate shared data as cookies, local storage or even start service workers + for the main window as well as for each other. Now each webview will be deployed on own origin by default. + - Webview origin pattern can be configured with `THEIA_WEBVIEW_EXTERNAL_ENDPOINT` env variable. The default value is `{{uuid}}.webview.{{hostname}}`. + Here `{{uuid}}` and `{{hostname}}` are placeholders which get replaced at runtime with proper webview uuid + and [hostname](https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/hostname) correspondingly. + - To switch to unsecure mode as before configure `THEIA_WEBVIEW_EXTERNAL_ENDPOINT` with `{{hostname}}` as a value. + You can also drop `{{uuid}}.` prefix, in this case, webviews still will be able to access each other but not the main window. + - Remote: Local URIs are resolved by default to the host serving Theia. + If you want to resolve to another host or change how remote URIs are constructed then + implement [ExternalUriService.resolve](./packages/core/src/browser/external-uri-service.ts) in a frontend module. + - Content loading: Webview HTTP endpoint is removed. Content loaded via [WebviewResourceLoader](./packages/plugin-ext/src/main/common/webview-protocol.ts) JSON-RPC service + with properly preserved resource URIs. Content is only loaded if it's allowed by WebviewOptions.localResourceRoots, otherwise, the service won't be called. + If you want to customize content loading then implement [WebviewResourceLoaderImpl](packages/plugin-ext/src/main/node/webview-resource-loader-impl.ts) in a backend module. + - Theming: Theia styles are not applied to webviews anymore + instead [VS Code way of styling](https://code.visualstudio.com/api/extension-guides/webview#theming-webview-content) should be used. + VS Code color variables also available with `--theia` prefix. + - Testing: Webview can work only in secure context because they rely on service workers to load local content and redirect local to remote requests. + Most browsers define a page as served from secure context if its url has `https` scheme. For local testing `localhost` is treated as a secure context as well. + Unfortunately, it does not work nicely in FireFox, since it does not treat subdomains of localhost as secure as well, compare to Chrome. + If you want to test with FireFox you can configure it as described [here](https://github.com/eclipse-theia/theia/pull/6465#issuecomment-556443218). ## v0.12.0 From 4a89eff2fa80a7bca5951db2b747133ab0d2571b Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Thu, 14 Nov 2019 16:07:46 +0000 Subject: [PATCH 15/21] [maximized] fix #6453: send attach/detach messages to widgets Allow them to react when the main area panel is moved between the area container and maximized overlay container, or backward. Signed-off-by: Anton Kosyakov --- .../core/src/browser/shell/theia-dock-panel.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/core/src/browser/shell/theia-dock-panel.ts b/packages/core/src/browser/shell/theia-dock-panel.ts index 37f696cbdf54f..efbef03ba8258 100644 --- a/packages/core/src/browser/shell/theia-dock-panel.ts +++ b/packages/core/src/browser/shell/theia-dock-panel.ts @@ -18,6 +18,7 @@ import { find, toArray, ArrayExt } from '@phosphor/algorithm'; import { TabBar, Widget, DockPanel, Title, DockLayout } from '@phosphor/widgets'; import { Signal } from '@phosphor/signaling'; import { Disposable, DisposableCollection } from '../../common/disposable'; +import { MessageLoop } from '../widgets'; const MAXIMIZED_CLASS = 'theia-maximized'; @@ -140,14 +141,28 @@ export class TheiaDockPanel extends DockPanel { this.toDisposeOnToggleMaximized.dispose(); return; } + if (this.isAttached) { + MessageLoop.sendMessage(this, Widget.Msg.BeforeDetach); + this.node.remove(); + MessageLoop.sendMessage(this, Widget.Msg.AfterDetach); + } maximizedElement.style.display = 'block'; this.addClass(MAXIMIZED_CLASS); + MessageLoop.sendMessage(this, Widget.Msg.BeforeAttach); maximizedElement.appendChild(this.node); + MessageLoop.sendMessage(this, Widget.Msg.AfterAttach); this.fit(); this.toDisposeOnToggleMaximized.push(Disposable.create(() => { maximizedElement.style.display = 'none'; this.removeClass(MAXIMIZED_CLASS); + if (this.isAttached) { + MessageLoop.sendMessage(this, Widget.Msg.BeforeDetach); + this.node.remove(); + MessageLoop.sendMessage(this, Widget.Msg.AfterDetach); + } + MessageLoop.sendMessage(this, Widget.Msg.BeforeAttach); areaContainer.appendChild(this.node); + MessageLoop.sendMessage(this, Widget.Msg.AfterAttach); this.fit(); })); From cabe495667a87dce6759c7253b6b6d62a3a1e8ad Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Sat, 16 Nov 2019 21:48:08 +0000 Subject: [PATCH 16/21] =?UTF-8?q?[webview]=C2=A0cross=20instance=20browser?= =?UTF-8?q?=20based=20resource=20caching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Otherwise all resources are reloaded for each new webview instance, or in the case of the markdown vscode built-in extension for each changed character. Signed-off-by: Anton Kosyakov --- .../browser/plugin-ext-frontend-module.ts | 2 + .../browser/webview/webview-resource-cache.ts | 88 +++++++++++++++++++ .../src/main/browser/webview/webview.ts | 35 ++++++-- .../src/main/common/webview-protocol.ts | 9 +- .../main/node/webview-resource-loader-impl.ts | 17 +++- 5 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 packages/plugin-ext/src/main/browser/webview/webview-resource-cache.ts diff --git a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts index afe90c396903b..ca0daed42b0e6 100644 --- a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts +++ b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts @@ -69,6 +69,7 @@ import { WebviewThemeDataProvider } from './webview/webview-theme-data-provider' import { PluginCommandOpenHandler } from './plugin-command-open-handler'; import { bindWebviewPreferences } from './webview/webview-preferences'; import { WebviewResourceLoader, WebviewResourceLoaderPath } from '../common/webview-protocol'; +import { WebviewResourceCache } from './webview/webview-resource-cache'; export default new ContainerModule((bind, unbind, isBound, rebind) => { @@ -161,6 +162,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(WebviewResourceLoader).toDynamicValue(ctx => WebSocketConnectionProvider.createProxy(ctx.container, WebviewResourceLoaderPath) ).inSingletonScope(); + bind(WebviewResourceCache).toSelf().inSingletonScope(); bind(WebviewWidget).toSelf(); bind(WidgetFactory).toDynamicValue(({ container }) => ({ id: WebviewWidget.FACTORY_ID, diff --git a/packages/plugin-ext/src/main/browser/webview/webview-resource-cache.ts b/packages/plugin-ext/src/main/browser/webview/webview-resource-cache.ts new file mode 100644 index 0000000000000..816dd219333dd --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview/webview-resource-cache.ts @@ -0,0 +1,88 @@ +/******************************************************************************** + * Copyright (C) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable } from 'inversify'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { MaybePromise } from '@theia/core/lib/common/types'; + +export interface WebviewResourceResponse { + eTag: string | undefined, + body(): MaybePromise +} + +/** + * Browser based cache of webview resources across all instances. + */ +@injectable() +export class WebviewResourceCache { + + protected readonly cache = new Deferred(); + + constructor() { + this.resolveCache(); + } + + protected async resolveCache(): Promise { + try { + this.cache.resolve(await caches.open('webview:v1')); + } catch (e) { + console.error('Failed to enable webview caching: ', e); + this.cache.resolve(undefined); + } + } + + async match(url: string): Promise { + const cache = await this.cache.promise; + if (!cache) { + return undefined; + } + const response = await cache.match(url); + if (!response) { + return undefined; + } + return { + eTag: response.headers.get('ETag') || undefined, + body: async () => { + const buffer = await response.arrayBuffer(); + return new Uint8Array(buffer); + } + }; + } + + async delete(url: string): Promise { + const cache = await this.cache.promise; + if (!cache) { + return false; + } + return cache.delete(url); + } + + async put(url: string, response: WebviewResourceResponse): Promise { + if (!response.eTag) { + return; + } + const cache = await this.cache.promise; + if (!cache) { + return; + } + const body = await response.body(); + await cache.put(url, new Response(body, { + status: 200, + headers: { 'ETag': response.eTag } + })); + } + +} diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index 39b346c0c2706..e01050a9e9620 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -46,6 +46,8 @@ import { ExternalUriService } from '@theia/core/lib/browser/external-uri-service import { OutputChannelManager } from '@theia/output/lib/common/output-channel'; import { WebviewPreferences } from './webview-preferences'; import { WebviewResourceLoader } from '../../common/webview-protocol'; +import { WebviewResourceCache } from './webview-resource-cache'; +import { Endpoint } from '@theia/core/lib/browser/endpoint'; // tslint:disable:no-any @@ -131,6 +133,9 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { @inject(WebviewResourceLoader) protected readonly resourceLoader: WebviewResourceLoader; + @inject(WebviewResourceCache) + protected readonly resourceCache: WebviewResourceCache; + viewState: WebviewPanelViewState = { visible: false, active: false, @@ -420,27 +425,39 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { } protected async loadResource(requestPath: string, uri: URI): Promise { - try { - const normalizedUri = this.normalizeRequestUri(uri); + const normalizedUri = this.normalizeRequestUri(uri); + // browser cache does not suppot file scheme, normalize to current endpoint scheme and host + const cacheUrl = new Endpoint({ path: normalizedUri.path.toString() }).getRestUrl().toString(); + try { if (this.contentOptions.localResourceRoots) { for (const root of this.contentOptions.localResourceRoots) { if (!new URI(root).path.isEqualOrParent(normalizedUri.path)) { continue; } - const { buffer } = await this.resourceLoader.load({ uri: normalizedUri.toString() }); - return this.doSend('did-load-resource', { - status: 200, - path: requestPath, - mime: mime.getType(normalizedUri.path.toString()) || 'application/octet-stream', - data: new Uint8Array(buffer) - }); + let cached = await this.resourceCache.match(cacheUrl); + const response = await this.resourceLoader.load({ uri: normalizedUri.toString(), eTag: cached && cached.eTag }); + if (response) { + const { buffer, eTag } = response; + cached = { body: () => new Uint8Array(buffer), eTag: eTag }; + this.resourceCache.put(cacheUrl, cached); + } + if (cached) { + const data = await cached.body(); + return this.doSend('did-load-resource', { + status: 200, + path: requestPath, + mime: mime.getType(normalizedUri.path.toString()) || 'application/octet-stream', + data + }); + } } } } catch { // no-op } + this.resourceCache.delete(cacheUrl); return this.doSend('did-load-resource', { status: 404, path: requestPath diff --git a/packages/plugin-ext/src/main/common/webview-protocol.ts b/packages/plugin-ext/src/main/common/webview-protocol.ts index d3e4da9226380..58a06a59fccb7 100644 --- a/packages/plugin-ext/src/main/common/webview-protocol.ts +++ b/packages/plugin-ext/src/main/common/webview-protocol.ts @@ -28,14 +28,21 @@ export namespace WebviewExternalEndpoint { export interface LoadWebviewResourceParams { uri: string + eTag?: string } export interface LoadWebviewResourceResult { buffer: number[] + eTag: string } export const WebviewResourceLoader = Symbol('WebviewResourceLoader'); export interface WebviewResourceLoader { - load(params: LoadWebviewResourceParams): Promise + /** + * Loads initial webview resource data. + * Returns `undefined` if a resource has not beed modified. + * Throws if a resource cannot be loaded. + */ + load(params: LoadWebviewResourceParams): Promise } export const WebviewResourceLoaderPath = '/services/webview-resource-loader'; diff --git a/packages/plugin-ext/src/main/node/webview-resource-loader-impl.ts b/packages/plugin-ext/src/main/node/webview-resource-loader-impl.ts index a9331ea8c0ff9..16d769a327cde 100644 --- a/packages/plugin-ext/src/main/node/webview-resource-loader-impl.ts +++ b/packages/plugin-ext/src/main/node/webview-resource-loader-impl.ts @@ -15,6 +15,7 @@ ********************************************************************************/ import * as fs from 'fs-extra'; +import * as crypto from 'crypto'; import { injectable } from 'inversify'; import { WebviewResourceLoader, LoadWebviewResourceParams, LoadWebviewResourceResult } from '../common/webview-protocol'; import { FileUri } from '@theia/core/lib/node/file-uri'; @@ -22,9 +23,21 @@ import { FileUri } from '@theia/core/lib/node/file-uri'; @injectable() export class WebviewResourceLoaderImpl implements WebviewResourceLoader { - async load(params: LoadWebviewResourceParams): Promise { + async load(params: LoadWebviewResourceParams): Promise { + const fsPath = FileUri.fsPath(params.uri); + const stat = await fs.stat(fsPath); + const eTag = this.compileETag(fsPath, stat); + if ('eTag' in params && params.eTag === eTag) { + return undefined; + } const buffer = await fs.readFile(FileUri.fsPath(params.uri)); - return { buffer: buffer.toJSON().data }; + return { buffer: buffer.toJSON().data, eTag }; + } + + protected compileETag(fsPath: string, stat: fs.Stats): string { + return crypto.createHash('md5') + .update(fsPath + stat.mtime.getTime() + stat.size, 'utf8') + .digest('base64'); } } From 73f7c56ac533c83e0606f594fdb4df4cd82681fc Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Mon, 18 Nov 2019 10:05:14 +0000 Subject: [PATCH 17/21] [plugin] move vscode built-ins translation to manifest loading Otherwise the plugin host load bogus package with wrong plugin names and consequently creates wrong icon urls. Signed-off-by: Anton Kosyakov --- packages/plugin-ext-vscode/src/node/scanner-vscode.ts | 5 ----- .../plugin-ext/src/hosted/node/plugin-manifest-loader.ts | 5 +++++ 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/plugin-ext-vscode/src/node/scanner-vscode.ts b/packages/plugin-ext-vscode/src/node/scanner-vscode.ts index 82407d75674ee..ad47e1eabb6bc 100644 --- a/packages/plugin-ext-vscode/src/node/scanner-vscode.ts +++ b/packages/plugin-ext-vscode/src/node/scanner-vscode.ts @@ -28,11 +28,6 @@ export class VsCodePluginScanner extends TheiaPluginScanner implements PluginSca } getModel(plugin: PluginPackage): PluginModel { - // translate vscode builtins, as they are published with a prefix. See https://github.com/theia-ide/vscode-builtin-extensions/blob/master/src/republish.js#L50 - const built_prefix = '@theia/vscode-builtin-'; - if (plugin && plugin.name && plugin.name.startsWith(built_prefix)) { - plugin.name = plugin.name.substr(built_prefix.length); - } const result: PluginModel = { packagePath: plugin.packagePath, // see id definition: https://github.com/microsoft/vscode/blob/15916055fe0cb9411a5f36119b3b012458fe0a1d/src/vs/platform/extensions/common/extensions.ts#L167-L169 diff --git a/packages/plugin-ext/src/hosted/node/plugin-manifest-loader.ts b/packages/plugin-ext/src/hosted/node/plugin-manifest-loader.ts index 6b64f6f8b0ae2..46feb067dffac 100644 --- a/packages/plugin-ext/src/hosted/node/plugin-manifest-loader.ts +++ b/packages/plugin-ext/src/hosted/node/plugin-manifest-loader.ts @@ -26,6 +26,11 @@ export async function loadManifest(pluginPath: string): Promise { fs.readJson(path.join(pluginPath, 'package.json')), loadTranslations(pluginPath) ]); + // translate vscode builtins, as they are published with a prefix. See https://github.com/theia-ide/vscode-builtin-extensions/blob/master/src/republish.js#L50 + const built_prefix = '@theia/vscode-builtin-'; + if (manifest && manifest.name && manifest.name.startsWith(built_prefix)) { + manifest.name = manifest.name.substr(built_prefix.length); + } return manifest && translations && Object.keys(translations).length ? localize(manifest, translations) : manifest; From fa9e20e184b2e5dc848d1ad3ef0c2a5d9218bbdb Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Mon, 18 Nov 2019 10:27:25 +0000 Subject: [PATCH 18/21] [core] open mailto with an external window For example to open mailto links from markdown preview. Signed-off-by: Anton Kosyakov --- packages/core/src/browser/http-open-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/browser/http-open-handler.ts b/packages/core/src/browser/http-open-handler.ts index 6696ca756b5c8..875c64b047aeb 100644 --- a/packages/core/src/browser/http-open-handler.ts +++ b/packages/core/src/browser/http-open-handler.ts @@ -32,7 +32,7 @@ export class HttpOpenHandler implements OpenHandler { protected readonly externalUriService: ExternalUriService; canHandle(uri: URI): number { - return uri.scheme.startsWith('http') ? 500 : 0; + return (uri.scheme.startsWith('http') || uri.scheme.startsWith('mailto')) ? 500 : 0; } async open(uri: URI): Promise { From 7d3856f396579d407f5a0bde6afc0057a0951526 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Mon, 18 Nov 2019 14:36:51 +0000 Subject: [PATCH 19/21] [webview] treat vscode-resource equally to theia-resource - Some VS Code extensions can rely on this assumption by checking for vscode-resource substing. - Performance-wise, get rid of unnecessary serialization/deserialization of webview messages and matching regex over them. Signed-off-by: Anton Kosyakov --- .../src/node/plugin-vscode-init.ts | 27 ------------------- .../src/main/browser/webview/pre/main.js | 4 +-- .../browser/webview/pre/service-worker.js | 13 +++++---- .../src/main/browser/webview/webview.ts | 4 +-- 4 files changed, 12 insertions(+), 36 deletions(-) diff --git a/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts b/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts index 993fb3f59aea9..601607dca3d85 100644 --- a/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts +++ b/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts @@ -54,33 +54,6 @@ export const doInitialization: BackendInitializationFn = (apiFactory: PluginAPIF return registerCommand(command, handler, thisArg); }; - // replace createWebviewPanel API for override html setter - const createWebviewPanel = vscode.window.createWebviewPanel; - vscode.window.createWebviewPanel = function (viewType: string, title: string, showOptions: any, options: any | undefined): any { - const panel = createWebviewPanel(viewType, title, showOptions, options); - // redefine property - Object.defineProperty(panel.webview, 'html', { - set: function (html: string): void { - const newHtml = html.replace(new RegExp('vscode-resource:/', 'g'), 'theia-resource:/'); - this.checkIsDisposed(); - if (this._html !== newHtml) { - this._html = newHtml; - this.proxy.$setHtml(this.viewId, newHtml); - } - } - }); - - // override postMessage method to replace vscode-resource: - const originalPostMessage = panel.webview.postMessage; - panel.webview.postMessage = (message: any): PromiseLike => { - const decoded = JSON.stringify(message); - const newMessage = decoded.replace(new RegExp('vscode-resource:/', 'g'), 'theia-resource:/'); - return originalPostMessage.call(panel.webview, JSON.parse(newMessage)); - }; - - return panel; - }; - // use Theia plugin api instead vscode extensions (vscode).extensions = { get all(): any[] { diff --git a/packages/plugin-ext/src/main/browser/webview/pre/main.js b/packages/plugin-ext/src/main/browser/webview/pre/main.js index 3560e6e6e816e..b667df984dd0d 100644 --- a/packages/plugin-ext/src/main/browser/webview/pre/main.js +++ b/packages/plugin-ext/src/main/browser/webview/pre/main.js @@ -342,11 +342,11 @@ if (!csp) { host.postMessage('no-csp-found'); } else { - // Rewrite theia-resource in csp + // Rewrite vscode-resource in csp if (data.endpoint) { try { const endpointUrl = new URL(data.endpoint); - csp.setAttribute('content', csp.getAttribute('content').replace(/theia-resource:(?=(\s|;|$))/g, endpointUrl.origin)); + csp.setAttribute('content', csp.getAttribute('content').replace(/(?:vscode|theia)-resource:(?=(\s|;|$))/g, endpointUrl.origin)); } catch (e) { console.error('Could not rewrite csp'); } diff --git a/packages/plugin-ext/src/main/browser/webview/pre/service-worker.js b/packages/plugin-ext/src/main/browser/webview/pre/service-worker.js index a6de65764b6d0..7a9d8675dedab 100644 --- a/packages/plugin-ext/src/main/browser/webview/pre/service-worker.js +++ b/packages/plugin-ext/src/main/browser/webview/pre/service-worker.js @@ -18,6 +18,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ // copied and modified from https://github.com/microsoft/vscode/blob/ba40bd16433d5a817bfae15f3b4350e18f144af4/src/vs/workbench/contrib/webview/browser/pre/service-worker.js +// @ts-check const VERSION = 1; const rootPath = self.location.pathname.replace(/\/service-worker.js$/, ''); @@ -25,7 +26,7 @@ const rootPath = self.location.pathname.replace(/\/service-worker.js$/, ''); /** * Root path for resources */ -const resourceRoot = rootPath + '/theia-resource'; +const resourceRoots = [rootPath + '/theia-resource', rootPath + '/vscode-resource']; const resolveTimeout = 30000; @@ -170,9 +171,11 @@ self.addEventListener('message', async (event) => { self.addEventListener('fetch', (event) => { const requestUrl = new URL(event.request.url); - // See if it's a resource request - if (requestUrl.origin === self.origin && requestUrl.pathname.startsWith(resourceRoot + '/')) { - return event.respondWith(processResourceRequest(event, requestUrl)); + for (const resourceRoot of resourceRoots) { + // See if it's a resource request + if (requestUrl.origin === self.origin && requestUrl.pathname.startsWith(resourceRoot + '/')) { + return event.respondWith(processResourceRequest(event, requestUrl, resourceRoot)); + } } // See if it's a localhost request @@ -189,7 +192,7 @@ self.addEventListener('activate', (event) => { event.waitUntil(self.clients.claim()); // Become available to all pages }); -async function processResourceRequest(event, requestUrl) { +async function processResourceRequest(event, requestUrl, resourceRoot) { const client = await self.clients.get(event.clientId); if (!client) { console.error('Could not find inner client for request'); diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index e01050a9e9620..3793fb4e67a89 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -371,7 +371,7 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { protected preprocessHtml(value: string): string { return value - .replace(/(["'])theia-resource:(\/\/([^\s\/'"]+?)(?=\/))?([^\s'"]+?)(["'])/gi, (_, startQuote, _1, scheme, path, endQuote) => { + .replace(/(["'])(?:vscode|theia)-resource:(\/\/([^\s\/'"]+?)(?=\/))?([^\s'"]+?)(["'])/gi, (_, startQuote, _1, scheme, path, endQuote) => { if (scheme) { return `${startQuote}${this.externalEndpoint}/theia-resource/${scheme}${path}${endQuote}`; } @@ -465,7 +465,7 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { } protected normalizeRequestUri(requestUri: URI): URI { - if (requestUri.scheme !== 'theia-resource') { + if (requestUri.scheme !== 'theia-resource' && requestUri.scheme !== 'vscode-resource') { return requestUri; } From 6831b9173e99c99957964a3c457a3c198e9f4427 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Tue, 19 Nov 2019 09:54:14 +0000 Subject: [PATCH 20/21] [webview] translate http vscode-resource links to file links to open them with an appropriate open handler Signed-off-by: Anton Kosyakov --- .../src/main/browser/webview/webview.ts | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index 3793fb4e67a89..61c1852cd908a 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -272,12 +272,7 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { /* no-op: webview loses focus only if another element gains focus in the main window */ })); this.toHide.push(this.on(WebviewMessageChannels.doReload, () => this.reload())); - this.toHide.push(this.on(WebviewMessageChannels.loadResource, (entry: any) => { - const rawPath = entry.path; - const normalizedPath = decodeURIComponent(rawPath); - const uri = new URI(normalizedPath.replace(/^\/(\w+)\/(.+)$/, (_, scheme, path) => scheme + ':/' + path)); - this.loadResource(rawPath, uri); - })); + this.toHide.push(this.on(WebviewMessageChannels.loadResource, (entry: any) => this.loadResource(entry.path))); this.toHide.push(this.on(WebviewMessageChannels.loadLocalhost, (entry: any) => this.loadLocalhost(entry.origin) )); @@ -412,20 +407,30 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { } protected openLink(link: URI): void { - if (this.isSupportedLink(link)) { - open(this.openerService, link); + const supported = this.toSupportedLink(link); + if (supported) { + open(this.openerService, supported); } } - protected isSupportedLink(link: URI): boolean { + protected toSupportedLink(link: URI): URI | undefined { if (WebviewWidget.standardSupportedLinkSchemes.has(link.scheme)) { - return true; + const linkAsString = link.toString(); + for (const resourceRoot of [this.externalEndpoint + '/theia-resource', this.externalEndpoint + '/vscode-resource']) { + if (linkAsString.startsWith(resourceRoot + '/')) { + return this.normalizeRequestUri(linkAsString.substr(resourceRoot.length)); + } + } + return link; + } + if (!!this.contentOptions.enableCommandUris && link.scheme === Schemes.COMMAND) { + return link; } - return !!this.contentOptions.enableCommandUris && link.scheme === Schemes.COMMAND; + return undefined; } - protected async loadResource(requestPath: string, uri: URI): Promise { - const normalizedUri = this.normalizeRequestUri(uri); + protected async loadResource(requestPath: string): Promise { + const normalizedUri = this.normalizeRequestUri(requestPath); // browser cache does not suppot file scheme, normalize to current endpoint scheme and host const cacheUrl = new Endpoint({ path: normalizedUri.path.toString() }).getRestUrl().toString(); @@ -464,7 +469,9 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { }); } - protected normalizeRequestUri(requestUri: URI): URI { + protected normalizeRequestUri(requestPath: string): URI { + const normalizedPath = decodeURIComponent(requestPath); + const requestUri = new URI(normalizedPath.replace(/^\/(\w+)\/(.+)$/, (_, scheme, path) => scheme + ':/' + path)); if (requestUri.scheme !== 'theia-resource' && requestUri.scheme !== 'vscode-resource') { return requestUri; } From 98e8bd931ce957775220ad59b9f63393484022fc Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Tue, 19 Nov 2019 11:35:28 +0000 Subject: [PATCH 21/21] [webview] fix computation of view columns Before rows were ignored, now Theia sorts all tab bars by left and top positions and then assigns: - (1, 2, 3, 3, 3, ...) positions for tab bars in first row - (4, 5, 6, 6, 6, ...) positions for tab bars in second row - (7, 8, 9, 9, 9,....) positions for tab bars in third row - (7, 8, 9, 9, 9,....) positions for tab bars in following rows Signed-off-by: Anton Kosyakov --- .../plugin-ext/src/common/plugin-api-rpc.ts | 2 +- .../src/main/browser/view-column-service.ts | 68 ++++++++++++++----- .../src/main/browser/webviews-main.ts | 8 +-- packages/plugin-ext/src/plugin/webviews.ts | 7 +- 4 files changed, 60 insertions(+), 25 deletions(-) diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index 79066fe6507d6..62d99bbc3cb7c 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -1239,7 +1239,7 @@ export interface WebviewsExt { viewType: string, title: string, state: any, - position: number, + viewState: WebviewPanelViewState, options: theia.WebviewOptions & theia.WebviewPanelOptions): PromiseLike; } diff --git a/packages/plugin-ext/src/main/browser/view-column-service.ts b/packages/plugin-ext/src/main/browser/view-column-service.ts index 4c0384e2b7399..aad11a196eb6e 100644 --- a/packages/plugin-ext/src/main/browser/view-column-service.ts +++ b/packages/plugin-ext/src/main/browser/view-column-service.ts @@ -18,6 +18,7 @@ import { injectable, inject } from 'inversify'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; import { toArray } from '@phosphor/algorithm'; +import { TabBar, Widget } from '@phosphor/widgets'; @injectable() export class ViewColumnService { @@ -51,26 +52,59 @@ export class ViewColumnService { } updateViewColumns(): void { - const positionIds = new Map(); - toArray(this.shell.mainPanel.tabBars()).forEach(tabBar => { - if (!tabBar.node.style.left) { - return; - } - const position = parseInt(tabBar.node.style.left); - const viewColumnIds = tabBar.titles.map(title => title.owner.id); - positionIds.set(position, viewColumnIds); - }); this.columnValues.clear(); this.viewColumnIds.clear(); - [...positionIds.keys()].sort((a, b) => a - b).forEach((key: number, viewColumn: number) => { - positionIds.get(key)!.forEach((id: string) => { - this.columnValues.set(id, viewColumn); - if (!this.viewColumnIds.has(viewColumn)) { - this.viewColumnIds.set(viewColumn, []); + + const rows = new Map>(); + const columns = new Map>>(); + for (const tabBar of toArray(this.shell.mainPanel.tabBars())) { + if (!tabBar.node.style.top || !tabBar.node.style.left) { + continue; + } + const top = parseInt(tabBar.node.style.top); + const left = parseInt(tabBar.node.style.left); + + const row = rows.get(top) || new Set(); + row.add(left); + rows.set(top, row); + + const column = columns.get(left) || new Map>(); + column.set(top, tabBar); + columns.set(left, column); + } + const firstRow = rows.get([...rows.keys()].sort()[0]); + if (!firstRow) { + return; + } + const lefts = [...firstRow.keys()].sort(); + for (let i = 0; i < lefts.length; i++) { + const column = columns.get(lefts[i]); + if (!column) { + break; + } + const cellIndexes = [...column.keys()].sort(); + let viewColumn = Math.min(i, 2); + for (let j = 0; j < cellIndexes.length; j++) { + const cell = column.get(cellIndexes[j]); + if (!cell) { + break; } - this.viewColumnIds.get(viewColumn)!.push(id); - }); - }); + this.setViewColumn(cell, viewColumn); + if (viewColumn < 7) { + viewColumn += 3; + } + } + } + } + + protected setViewColumn(tabBar: TabBar, viewColumn: number): void { + const ids = []; + for (const title of tabBar.titles) { + const id = title.owner.id; + ids.push(id); + this.columnValues.set(id, viewColumn); + } + this.viewColumnIds.set(viewColumn, ids); } getViewColumnIds(viewColumn: number): string[] { diff --git a/packages/plugin-ext/src/main/browser/webviews-main.ts b/packages/plugin-ext/src/main/browser/webviews-main.ts index 630d61be8bf2f..06185115bcd8b 100644 --- a/packages/plugin-ext/src/main/browser/webviews-main.ts +++ b/packages/plugin-ext/src/main/browser/webviews-main.ts @@ -77,6 +77,7 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { protected hookWebview(view: WebviewWidget): void { const handle = view.identifier.id; + this.toDispose.push(view.onDidChangeVisibility(() => this.updateViewState(view))); this.toDispose.push(view.onMessage(data => this.proxy.$onMessage(handle, data))); view.disposed.connect(() => { if (this.toDispose.disposed) { @@ -206,9 +207,8 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { const options = widget.options; const { allowScripts, localResourceRoots, ...contentOptions } = widget.contentOptions; - this.viewColumnService.updateViewColumns(); - const position = this.viewColumnService.getViewColumn(widget.id) || 0; - await this.proxy.$deserializeWebviewPanel(handle, widget.viewType, title, state, position, { + this.updateViewState(widget); + await this.proxy.$deserializeWebviewPanel(handle, widget.viewType, title, state, widget.viewState, { enableScripts: allowScripts, localResourceRoots: localResourceRoots && localResourceRoots.map(root => URI.parse(root)), ...contentOptions, @@ -224,7 +224,7 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { } }, 100); - private async updateViewState(widget: WebviewWidget, viewColumn?: number | undefined): Promise { + private updateViewState(widget: WebviewWidget, viewColumn?: number | undefined): void { const viewState: Mutable = { active: this.shell.activeWidget === widget, visible: !widget.isHidden, diff --git a/packages/plugin-ext/src/plugin/webviews.ts b/packages/plugin-ext/src/plugin/webviews.ts index e3c2f58fbe890..eeda7354eb984 100644 --- a/packages/plugin-ext/src/plugin/webviews.ts +++ b/packages/plugin-ext/src/plugin/webviews.ts @@ -78,7 +78,7 @@ export class WebviewsExtImpl implements WebviewsExt { title: string, // tslint:disable-next-line:no-any state: any, - position: number, + viewState: WebviewPanelViewState, options: theia.WebviewOptions & theia.WebviewPanelOptions): PromiseLike { if (!this.initData) { return Promise.reject(new Error('Webviews are not initialized')); @@ -90,7 +90,9 @@ export class WebviewsExtImpl implements WebviewsExt { const { serializer, plugin } = entry; const webview = new WebviewImpl(viewId, this.proxy, options, this.initData, this.workspace, plugin); - const revivedPanel = new WebviewPanelImpl(viewId, this.proxy, viewType, title, toViewColumn(position)!, options, webview); + const revivedPanel = new WebviewPanelImpl(viewId, this.proxy, viewType, title, toViewColumn(viewState.position)!, options, webview); + revivedPanel.setActive(viewState.active); + revivedPanel.setVisible(viewState.visible); this.webviewPanels.set(viewId, revivedPanel); return serializer.deserializeWebviewPanel(revivedPanel, state); } @@ -254,7 +256,6 @@ export class WebviewPanelImpl implements theia.WebviewPanel { private readonly _webview: WebviewImpl ) { this._showOptions = typeof showOptions === 'object' ? showOptions : { viewColumn: showOptions as theia.ViewColumn }; - this.setViewColumn(undefined); } dispose(): void {