From 434690b3ebb60e9a616d106288ad68bce25754d2 Mon Sep 17 00:00:00 2001 From: Lucas Koehler Date: Fri, 2 Sep 2022 10:40:54 +0200 Subject: [PATCH] Multi window support for web views Support for moving webview-based views into a secondary window or tab. For webview-based views a new button becomes available in the toolbar of the view to move the view to a secondary window. This is only supported for webview-based views and only one view can be moved into the secondary window. The window extraction is not added to the electron app for now as there are issues with close handling of secondary windows on Electron. There can be multiple secondary windows though. Primary code changes: - Add concept of extractable widgets. Only widgets implementing the interface can be extracted. - Add `SecondaryWindowHandler` that encapsulates logic to move widgets to new windows. - Add service `SecondaryWindowService` to handle creating and focussing external windows based on the platform. - Only webviews can be extracted - Configure opened secondary windows in electron - Hide electron menu - Always use the native window frame for secondary windows to get window controls - Do not show secondary window icon if main window uses a custom title bar - Contribute widget extraction button in a separate new extension `widget-extraction-ui` - Extend application shell areas with a `secondaryWindow` area that contains all extracted widgets - Extend frontend and webpack generators to generate the base html for secondary windows and copy it to the lib folder - Bridge plugin communication securely via secure messaging between external webview and Theia: Webviews only accept messages from its direct parent window. This is necessary to avoid Cross Site Scripting (XSS). To make the messaging work in secondary windows, messages are sent to the external widget and then delegated to the webview's iframe. Thereby, the secondary window only accepts messages from its opener which is the theia main window. To achieve this, a webview knows the secondary window it is in (if any). It then sends messages to this window instead of directly to the webview iframe. - Patch PhosphorJS during application webpack build - Use string-replace-loader to remove the critical line from PhosphorJS during application bundleing - Extend `webpack generator.ts` to add this to applications' `gen-webpack.config.js` Contributed on behalf of ST Microelectronics and Ericsson and by ARM and EclipseSource. Co-authored-by: Stefan Dirix Co-authored-by: robmor01 Signed-off-by: Lucas Koehler --- CHANGELOG.md | 8 + dev-packages/application-manager/package.json | 1 + .../src/generator/frontend-generator.ts | 45 ++++ .../src/generator/webpack-generator.ts | 17 ++ examples/browser/package.json | 1 + examples/browser/tsconfig.json | 3 + packages/core/i18n/nls.cs.json | 3 + packages/core/i18n/nls.de.json | 3 + packages/core/i18n/nls.es.json | 3 + packages/core/i18n/nls.fr.json | 3 + packages/core/i18n/nls.hu.json | 3 + packages/core/i18n/nls.it.json | 3 + packages/core/i18n/nls.ja.json | 3 + packages/core/i18n/nls.json | 3 + packages/core/i18n/nls.pl.json | 3 + packages/core/i18n/nls.pt-br.json | 3 + packages/core/i18n/nls.pt-pt.json | 3 + packages/core/i18n/nls.ru.json | 3 + packages/core/i18n/nls.zh-cn.json | 3 + .../browser/frontend-application-module.ts | 3 + .../src/browser/secondary-window-handler.ts | 225 ++++++++++++++++++ .../src/browser/shell/application-shell.ts | 94 +++++--- .../src/browser/widgets/extractable-widget.ts | 33 +++ packages/core/src/browser/widgets/index.ts | 1 + .../browser/window/browser-window-module.ts | 3 + .../default-secondary-window-service.ts | 85 +++++++ .../window/secondary-window-service.ts | 32 +++ .../electron-secondary-window-service.ts | 57 +++++ .../window/electron-window-module.ts | 3 + .../electron-main-application.ts | 44 +++- .../navigator-open-editors-tree-model.ts | 2 +- .../custom-editors/custom-editors-main.ts | 2 +- .../src/main/browser/webview/pre/host.js | 7 + .../src/main/browser/webview/webview.ts | 12 +- packages/secondary-window-ui/.eslintrc.js | 10 + packages/secondary-window-ui/README.md | 35 +++ packages/secondary-window-ui/package.json | 43 ++++ ...condary-window-ui-frontend-contribution.ts | 50 ++++ .../secondary-window-ui-frontend-module.ts | 27 +++ .../secondary-window-ui/src/package.spec.ts | 19 ++ packages/secondary-window-ui/tsconfig.json | 16 ++ tsconfig.json | 3 + yarn.lock | 8 + 43 files changed, 888 insertions(+), 40 deletions(-) create mode 100644 packages/core/src/browser/secondary-window-handler.ts create mode 100644 packages/core/src/browser/widgets/extractable-widget.ts create mode 100644 packages/core/src/browser/window/default-secondary-window-service.ts create mode 100644 packages/core/src/browser/window/secondary-window-service.ts create mode 100644 packages/core/src/electron-browser/window/electron-secondary-window-service.ts create mode 100644 packages/secondary-window-ui/.eslintrc.js create mode 100644 packages/secondary-window-ui/README.md create mode 100644 packages/secondary-window-ui/package.json create mode 100644 packages/secondary-window-ui/src/browser/secondary-window-ui-frontend-contribution.ts create mode 100644 packages/secondary-window-ui/src/browser/secondary-window-ui-frontend-module.ts create mode 100644 packages/secondary-window-ui/src/package.spec.ts create mode 100644 packages/secondary-window-ui/tsconfig.json diff --git a/CHANGELOG.md b/CHANGELOG.md index d148f36b74e70..74bb6b60ab9e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ - [Previous Changelogs](https://github.com/eclipse-theia/theia/tree/master/doc/changelogs/) +## v1.30.0 + +- [core] Added support for moving webview-based views into a secondary window/tab for browser applications. Added new extension `secondary-window-ui` that contributes the UI integration to use this. [#11048](https://github.com/eclipse-theia/theia/pull/11048) - Contributed on behalf of ST Microelectronics and Ericsson and by ARM and EclipseSource + +[Breaking Changes:](#breaking_changes_1.30.0) + +- [core] Added constructor injection to `ApplicationShell`: `SecondaryWindowHandler`. [#11048](https://github.com/eclipse-theia/theia/pull/11048) - Contributed on behalf of ST Microelectronics and Ericsson and by ARM and EclipseSource + ## v1.29.0 - 8/25/2022 - [application-manager] added the `applicationName` in the frontend generator [#11575](https://github.com/eclipse-theia/theia/pull/11575) diff --git a/dev-packages/application-manager/package.json b/dev-packages/application-manager/package.json index 6047431b7973b..6be3faccdabbc 100644 --- a/dev-packages/application-manager/package.json +++ b/dev-packages/application-manager/package.json @@ -54,6 +54,7 @@ "source-map": "^0.6.1", "source-map-loader": "^2.0.1", "source-map-support": "^0.5.19", + "string-replace-loader": "^3.1.0", "style-loader": "^2.0.0", "umd-compat-loader": "^2.1.2", "webpack": "^5.48.0", diff --git a/dev-packages/application-manager/src/generator/frontend-generator.ts b/dev-packages/application-manager/src/generator/frontend-generator.ts index 8030c05381278..e0951ff1b2fb1 100644 --- a/dev-packages/application-manager/src/generator/frontend-generator.ts +++ b/dev-packages/application-manager/src/generator/frontend-generator.ts @@ -25,6 +25,7 @@ export class FrontendGenerator extends AbstractGenerator { const frontendModules = this.pck.targetFrontendModules; await this.write(this.pck.frontend('index.html'), this.compileIndexHtml(frontendModules)); await this.write(this.pck.frontend('index.js'), this.compileIndexJs(frontendModules)); + await this.write(this.pck.frontend('secondary-window.html'), this.compileSecondaryWindowHtml()); if (this.pck.isElectron()) { const electronMainModules = this.pck.targetElectronMainModules; await this.write(this.pck.frontend('electron-main.js'), this.compileElectronMain(electronMainModules)); @@ -195,4 +196,48 @@ module.exports = Promise.resolve()${this.compileElectronMainModuleImports(electr `; } + /** HTML for secondary windows that contain an extracted widget. */ + protected compileSecondaryWindowHtml(): string { + return ` + + + + + Theia — Secondary Window + + + + + +
+ + +`; + } } diff --git a/dev-packages/application-manager/src/generator/webpack-generator.ts b/dev-packages/application-manager/src/generator/webpack-generator.ts index 3cc8db8fb5254..ef2d2a72b0d7c 100644 --- a/dev-packages/application-manager/src/generator/webpack-generator.ts +++ b/dev-packages/application-manager/src/generator/webpack-generator.ts @@ -56,6 +56,7 @@ export class WebpackGenerator extends AbstractGenerator { const path = require('path'); const webpack = require('webpack'); const yargs = require('yargs'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); const CircularDependencyPlugin = require('circular-dependency-plugin'); const CompressionPlugin = require('compression-webpack-plugin') @@ -72,6 +73,12 @@ const { mode, staticCompression } = yargs.option('mode', { const development = mode === 'development'; const plugins = [ + new CopyWebpackPlugin({ + patterns: [{ + // copy secondary window html file to lib folder + from: path.resolve(__dirname, 'src-gen/frontend/secondary-window.html') + }] + }), new webpack.ProvidePlugin({ // the Buffer class doesn't exist in the browser but some dependencies rely on it Buffer: ['buffer', 'Buffer'] @@ -104,6 +111,16 @@ module.exports = { cache: staticCompression, module: { rules: [ + { + // Removes the host check in PhosphorJS to enable moving widgets to secondary windows. + test: /widget\\.js$/, + loader: 'string-replace-loader', + include: /node_modules[\\\\/]@phosphor[\\\\/]widgets[\\\\/]lib/, + options: { + search: /^.*?throw new Error\\('Host is not attached.'\\).*?$/gm, + replace: '' + } + }, { test: /\\.css$/, exclude: /materialcolors\\.css$|\\.useable\\.css$/, diff --git a/examples/browser/package.json b/examples/browser/package.json index efc7df2a123ef..2b5c427881614 100644 --- a/examples/browser/package.json +++ b/examples/browser/package.json @@ -47,6 +47,7 @@ "@theia/scm": "1.29.0", "@theia/scm-extra": "1.29.0", "@theia/search-in-workspace": "1.29.0", + "@theia/secondary-window-ui": "1.29.0", "@theia/task": "1.29.0", "@theia/terminal": "1.29.0", "@theia/timeline": "1.29.0", diff --git a/examples/browser/tsconfig.json b/examples/browser/tsconfig.json index a5cfae6625a33..de282d396c123 100644 --- a/examples/browser/tsconfig.json +++ b/examples/browser/tsconfig.json @@ -104,6 +104,9 @@ { "path": "../../packages/search-in-workspace" }, + { + "path": "../../packages/secondary-window-ui" + }, { "path": "../../packages/task" }, diff --git a/packages/core/i18n/nls.cs.json b/packages/core/i18n/nls.cs.json index e7d02a75c4d62..0daf47157a1d7 100644 --- a/packages/core/i18n/nls.cs.json +++ b/packages/core/i18n/nls.cs.json @@ -409,6 +409,9 @@ "resultSubset": "Jedná se pouze o podmnožinu všech výsledků. Pro zúžení seznamu výsledků použijte konkrétnější vyhledávací výraz.", "searchOnEditorModification": "Prohledat aktivní editor při úpravě." }, + "secondary-window-ui": { + "extract-widget": "Přesunutí zobrazení do sekundárního okna" + }, "task": { "attachTask": "Připojte úkol...", "clearHistory": "Vymazat historii", diff --git a/packages/core/i18n/nls.de.json b/packages/core/i18n/nls.de.json index 3da35f17eb0e0..b005093e09537 100644 --- a/packages/core/i18n/nls.de.json +++ b/packages/core/i18n/nls.de.json @@ -409,6 +409,9 @@ "resultSubset": "Dies ist nur eine Teilmenge aller Ergebnisse. Verwenden Sie einen spezifischeren Suchbegriff, um die Ergebnisliste einzugrenzen.", "searchOnEditorModification": "Durchsucht den aktiven Editor nach Änderungen." }, + "secondary-window-ui": { + "extract-widget": "Ansicht in sekundäres Fenster verschieben" + }, "task": { "attachTask": "Aufgabe anhängen...", "clearHistory": "Geschichte löschen", diff --git a/packages/core/i18n/nls.es.json b/packages/core/i18n/nls.es.json index 13b67fe7a9689..ea81cbf3e6a3a 100644 --- a/packages/core/i18n/nls.es.json +++ b/packages/core/i18n/nls.es.json @@ -409,6 +409,9 @@ "resultSubset": "Esto es sólo un subconjunto de todos los resultados. Utilice un término de búsqueda más específico para reducir la lista de resultados.", "searchOnEditorModification": "Busca en el editor activo cuando se modifica." }, + "secondary-window-ui": { + "extract-widget": "Mover la vista a la ventana secundaria" + }, "task": { "attachTask": "Adjuntar tarea...", "clearHistory": "Historia clara", diff --git a/packages/core/i18n/nls.fr.json b/packages/core/i18n/nls.fr.json index f4aa462ec63e2..019794e482920 100644 --- a/packages/core/i18n/nls.fr.json +++ b/packages/core/i18n/nls.fr.json @@ -409,6 +409,9 @@ "resultSubset": "Il ne s'agit que d'un sous-ensemble de tous les résultats. Utilisez un terme de recherche plus spécifique pour réduire la liste des résultats.", "searchOnEditorModification": "Rechercher l'éditeur actif lorsqu'il est modifié." }, + "secondary-window-ui": { + "extract-widget": "Déplacer la vue vers une fenêtre secondaire" + }, "task": { "attachTask": "Attacher la tâche...", "clearHistory": "Histoire claire", diff --git a/packages/core/i18n/nls.hu.json b/packages/core/i18n/nls.hu.json index 7dfd123a28240..ebafb0b38a77d 100644 --- a/packages/core/i18n/nls.hu.json +++ b/packages/core/i18n/nls.hu.json @@ -409,6 +409,9 @@ "resultSubset": "Ez csak egy részhalmaza az összes eredménynek. A találati lista szűkítéséhez használjon konkrétabb keresési kifejezést.", "searchOnEditorModification": "Keresés az aktív szerkesztőben, amikor módosítják." }, + "secondary-window-ui": { + "extract-widget": "Nézet áthelyezése másodlagos ablakba" + }, "task": { "attachTask": "Feladat csatolása...", "clearHistory": "Történelem törlése", diff --git a/packages/core/i18n/nls.it.json b/packages/core/i18n/nls.it.json index a06ba98cff1e6..4562a889cd006 100644 --- a/packages/core/i18n/nls.it.json +++ b/packages/core/i18n/nls.it.json @@ -409,6 +409,9 @@ "resultSubset": "Questo è solo un sottoinsieme di tutti i risultati. Usa un termine di ricerca più specifico per restringere la lista dei risultati.", "searchOnEditorModification": "Cerca l'editor attivo quando viene modificato." }, + "secondary-window-ui": { + "extract-widget": "Sposta la vista nella finestra secondaria" + }, "task": { "attachTask": "Allegare il compito...", "clearHistory": "Storia chiara", diff --git a/packages/core/i18n/nls.ja.json b/packages/core/i18n/nls.ja.json index 62908ab01340d..9464b73689654 100644 --- a/packages/core/i18n/nls.ja.json +++ b/packages/core/i18n/nls.ja.json @@ -409,6 +409,9 @@ "resultSubset": "これは、すべての結果の一部に過ぎません。より具体的な検索用語を使って、結果リストを絞り込んでください。", "searchOnEditorModification": "修正されたときにアクティブなエディタを検索します。" }, + "secondary-window-ui": { + "extract-widget": "セカンダリーウィンドウへの表示移動" + }, "task": { "attachTask": "タスクの添付...", "clearHistory": "明確な歴史", diff --git a/packages/core/i18n/nls.json b/packages/core/i18n/nls.json index 81673e6be4ed3..4be1ec58ad45d 100644 --- a/packages/core/i18n/nls.json +++ b/packages/core/i18n/nls.json @@ -409,6 +409,9 @@ "resultSubset": "This is only a subset of all results. Use a more specific search term to narrow down the result list.", "searchOnEditorModification": "Search the active editor when modified." }, + "secondary-window-ui": { + "extract-widget": "Move View to Secondary Window" + }, "task": { "attachTask": "Attach Task...", "clearHistory": "Clear History", diff --git a/packages/core/i18n/nls.pl.json b/packages/core/i18n/nls.pl.json index afc66a973ed7f..a406a1225bf8e 100644 --- a/packages/core/i18n/nls.pl.json +++ b/packages/core/i18n/nls.pl.json @@ -409,6 +409,9 @@ "resultSubset": "To jest tylko podzbiór wszystkich wyników. Użyj bardziej szczegółowego terminu wyszukiwania, aby zawęzić listę wyników.", "searchOnEditorModification": "Przeszukiwanie aktywnego edytora po modyfikacji." }, + "secondary-window-ui": { + "extract-widget": "Przenieś widok do okna podrzędnego" + }, "task": { "attachTask": "Dołącz zadanie...", "clearHistory": "Czysta historia", diff --git a/packages/core/i18n/nls.pt-br.json b/packages/core/i18n/nls.pt-br.json index eef5b89d6117a..6cdfef0b350d8 100644 --- a/packages/core/i18n/nls.pt-br.json +++ b/packages/core/i18n/nls.pt-br.json @@ -409,6 +409,9 @@ "resultSubset": "Este é apenas um subconjunto de todos os resultados. Use um termo de busca mais específico para restringir a lista de resultados.", "searchOnEditorModification": "Pesquise o editor ativo quando modificado." }, + "secondary-window-ui": { + "extract-widget": "Mover vista para a janela secundária" + }, "task": { "attachTask": "Anexar Tarefa...", "clearHistory": "Histórico claro", diff --git a/packages/core/i18n/nls.pt-pt.json b/packages/core/i18n/nls.pt-pt.json index 2c1673596382a..326d3fa052721 100644 --- a/packages/core/i18n/nls.pt-pt.json +++ b/packages/core/i18n/nls.pt-pt.json @@ -409,6 +409,9 @@ "resultSubset": "Este é apenas um subconjunto de todos os resultados. Use um termo de pesquisa mais específico para restringir a lista de resultados.", "searchOnEditorModification": "Pesquisar o editor activo quando modificado." }, + "secondary-window-ui": { + "extract-widget": "Mover vista para a janela secundária" + }, "task": { "attachTask": "Anexar Tarefa...", "clearHistory": "História clara", diff --git a/packages/core/i18n/nls.ru.json b/packages/core/i18n/nls.ru.json index c1e36ed8a9edc..ffd791428eb60 100644 --- a/packages/core/i18n/nls.ru.json +++ b/packages/core/i18n/nls.ru.json @@ -409,6 +409,9 @@ "resultSubset": "Это только часть всех результатов. Используйте более конкретный поисковый запрос, чтобы сузить список результатов.", "searchOnEditorModification": "Поиск активного редактора при изменении." }, + "secondary-window-ui": { + "extract-widget": "Переместить вид в дополнительное окно" + }, "task": { "attachTask": "Прикрепите задание...", "clearHistory": "Чистая история", diff --git a/packages/core/i18n/nls.zh-cn.json b/packages/core/i18n/nls.zh-cn.json index 0fed753b5114c..9d043783c0db9 100644 --- a/packages/core/i18n/nls.zh-cn.json +++ b/packages/core/i18n/nls.zh-cn.json @@ -409,6 +409,9 @@ "resultSubset": "这只是所有结果的一个子集。使用一个更具体的搜索词来缩小结果列表。", "searchOnEditorModification": "修改时搜索活动的编辑器。" }, + "secondary-window-ui": { + "extract-widget": "将视图移至第二窗口" + }, "task": { "attachTask": "附加任务...", "clearHistory": "清除历史", diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index d04b95f077c87..319c4aa159acf 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -126,6 +126,7 @@ import { TooltipService, TooltipServiceImpl } from './tooltip-service'; import { BackendRequestService, RequestService, REQUEST_SERVICE_PATH } from '@theia/request'; import { bindFrontendStopwatch, bindBackendStopwatch } from './performance'; import { SaveResourceService } from './save-resource-service'; +import { SecondaryWindowHandler } from './secondary-window-handler'; import { UserWorkingDirectoryProvider } from './user-working-directory-provider'; import { TheiaDockPanel } from './shell/theia-dock-panel'; import { bindStatusBar } from './status-bar'; @@ -430,4 +431,6 @@ export const frontendApplicationModule = new ContainerModule((bind, _unbind, _is bind(StylingService).toSelf().inSingletonScope(); bindContributionProvider(bind, StylingParticipant); bind(FrontendApplicationContribution).toService(StylingService); + + bind(SecondaryWindowHandler).toSelf().inSingletonScope(); }); diff --git a/packages/core/src/browser/secondary-window-handler.ts b/packages/core/src/browser/secondary-window-handler.ts new file mode 100644 index 0000000000000..e8d551b4e1d13 --- /dev/null +++ b/packages/core/src/browser/secondary-window-handler.ts @@ -0,0 +1,225 @@ +// ***************************************************************************** +// Copyright (C) 2022 STMicroelectronics, Ericsson, ARM, EclipseSource 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 debounce = require('lodash.debounce'); +import { inject, injectable } from 'inversify'; +import { BoxLayout, BoxPanel, ExtractableWidget, Widget } from './widgets'; +import { MessageService } from '../common/message-service'; +import { ApplicationShell } from './shell/application-shell'; +import { Emitter } from '../common/event'; +import { SecondaryWindowService } from './window/secondary-window-service'; + +/** Widget to be contained directly in a secondary window. */ +class SecondaryWindowRootWidget extends Widget { + + constructor() { + super(); + this.layout = new BoxLayout(); + } + + addWidget(widget: Widget): void { + (this.layout as BoxLayout).addWidget(widget); + BoxPanel.setStretch(widget, 1); + } + +} + +/** + * Offers functionality to move a widget out of the main window to a newly created window. + * Widgets must explicitly implement the `ExtractableWidget` interface to support this. + * + * This handler manages the opened secondary windows and sets up messaging between them and the Theia main window. + * In addition, it provides access to the extracted widgets and provides notifications when widgets are added to or removed from this handler. + * + * _Note:_ This handler is used by the application shell and there should be no need for callers to directly interact with this class. + * Instead, consider using the application shell. + */ +@injectable() +export class SecondaryWindowHandler { + /** List of currently open secondary windows. Window references should be removed once the window is closed. */ + protected readonly secondaryWindows: Window[] = []; + /** List of widgets in secondary windows. */ + protected readonly _widgets: ExtractableWidget[] = []; + + protected applicationShell: ApplicationShell; + + protected readonly onDidAddWidgetEmitter = new Emitter(); + /** Subscribe to get notified when a widget is added to this handler, i.e. the widget was moved to an secondary window . */ + readonly onDidAddWidget = this.onDidAddWidgetEmitter.event; + + protected readonly onDidRemoveWidgetEmitter = new Emitter(); + /** Subscribe to get notified when a widget is removed from this handler, i.e. the widget's window was closed or the widget was disposed. */ + readonly onDidRemoveWidget = this.onDidRemoveWidgetEmitter.event; + + @inject(MessageService) + protected readonly messageService: MessageService; + + @inject(SecondaryWindowService) + protected readonly secondaryWindowService: SecondaryWindowService; + + /** @returns List of widgets in secondary windows. */ + get widgets(): ReadonlyArray { + // Create new array in case the original changes while this is used. + return [...this._widgets]; + } + + /** + * Sets up message forwarding from the main window to secondary windows. + * Does nothing if this service has already been initialized. + * + * @param shell The `ApplicationShell` that widgets will be moved out from. + */ + init(shell: ApplicationShell): void { + if (this.applicationShell) { + // Already initialized + return; + } + this.applicationShell = shell; + + // Set up messaging with secondary windows + window.addEventListener('message', (event: MessageEvent) => { + console.trace('Message on main window', event); + if (event.data.fromSecondary) { + console.trace('Message comes from secondary window'); + return; + } + if (event.data.fromMain) { + console.trace('Message has mainWindow marker, therefore ignore it'); + return; + } + + // Filter setImmediate messages. Do not forward because these come in with very high frequency. + // They are not needed in secondary windows because these messages are just a work around + // to make setImmediate work in the main window: https://developer.mozilla.org/en-US/docs/Web/API/Window/setImmediate + if (typeof event.data === 'string' && event.data.startsWith('setImmediate')) { + return; + } + + console.trace('Delegate main window message to secondary windows', event); + this.secondaryWindows.forEach(secondaryWindow => { + if (!secondaryWindow.window.closed) { + secondaryWindow.window.postMessage({ ...event.data, fromMain: true }, '*'); + } + }); + }); + } + + /** + * Moves the given widget to a new window. + * + * @param widget the widget to extract + */ + moveWidgetToSecondaryWindow(widget: ExtractableWidget): void { + if (!this.applicationShell) { + console.error('Widget cannot be extracted because the WidgetExtractionHandler has not been initialized.'); + return; + } + if (!widget.isExtractable) { + console.error('Widget is not extractable.', widget.id); + return; + } + + const newWindow = this.secondaryWindowService.createSecondaryWindow(closed => { + this.applicationShell.closeWidget(widget.id); + const extIndex = this.secondaryWindows.indexOf(closed); + if (extIndex > -1) { + this.secondaryWindows.splice(extIndex, 1); + } + }); + + if (!newWindow) { + this.messageService.error('The widget could not be moved to a secondary window because the window creation failed. Please make sure to allow popups.'); + return; + } + + this.secondaryWindows.push(newWindow); + + newWindow.onload = () => { + // Use the widget's title as the window title + // Even if the widget's label were malicious, this should be safe against XSS because the HTML standard defines this is inserted via a text node. + // See https://html.spec.whatwg.org/multipage/dom.html#document.title + newWindow.document.title = widget.title.label; + + const element = newWindow.document.getElementById('widget-host'); + if (!element) { + console.error('Could not find dom element to attach to in secondary window'); + return; + } + + widget.secondaryWindow = newWindow; + const rootWidget = new SecondaryWindowRootWidget(); + Widget.attach(rootWidget, element); + rootWidget.addWidget(widget); + widget.update(); + + this.addWidget(widget); + + // Close the window if the widget is disposed, e.g. by a command closing all widgets. + widget.disposed.connect(() => { + this.removeWidget(widget); + if (!newWindow.closed) { + newWindow.close(); + } + }); + + // debounce to avoid rapid updates while resizing the secondary window + const updateWidget = debounce(widget.update.bind(widget), 100); + newWindow.addEventListener('resize', () => updateWidget()); + }; + } + + /** + * If the given widget is tracked by this handler, activate it and focus its secondary window. + * + * @param widgetId The widget to activate specified by its id + * @returns The activated `ExtractableWidget` or `undefined` if the given widget id is unknown to this handler. + */ + activateWidget(widgetId: string): ExtractableWidget | undefined { + const trackedWidget = this.revealWidget(widgetId); + trackedWidget?.activate(); + return trackedWidget; + } + + /** + * If the given widget is tracked by this handler, reveal it by focussing its secondary window. + * + * @param widgetId The widget to reveal specified by its id + * @returns The revealed `ExtractableWidget` or `undefined` if the given widget id is unknown to this handler. + */ + revealWidget(widgetId: string): ExtractableWidget | undefined { + const trackedWidget = this._widgets.find(w => w.id === widgetId); + if (trackedWidget) { + this.secondaryWindowService.focus(trackedWidget.secondaryWindow!); + return trackedWidget; + } + return undefined; + } + + protected addWidget(widget: ExtractableWidget): void { + if (!this._widgets.includes(widget)) { + this._widgets.push(widget); + this.onDidAddWidgetEmitter.fire(widget); + } + } + + protected removeWidget(widget: ExtractableWidget): void { + const index = this._widgets.indexOf(widget); + if (index > -1) { + this._widgets.splice(index, 1); + this.onDidRemoveWidgetEmitter.fire(widget); + } + } +} diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index 51b9ffd830576..28ed92508ef7e 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -34,12 +34,13 @@ import { FrontendApplicationStateService } from '../frontend-application-state'; import { TabBarToolbarRegistry, TabBarToolbarFactory } from './tab-bar-toolbar'; import { ContextKeyService } from '../context-key-service'; import { Emitter } from '../../common/event'; -import { waitForRevealed, waitForClosed, PINNED_CLASS } from '../widgets'; +import { ExtractableWidget, waitForRevealed, waitForClosed, PINNED_CLASS } from '../widgets'; import { CorePreferences } from '../core-preferences'; import { BreadcrumbsRendererFactory } from '../breadcrumbs/breadcrumbs-renderer'; import { Deferred } from '../../common/promise-util'; import { SaveResourceService } from '../save-resource-service'; import { nls } from '../../common/nls'; +import { SecondaryWindowHandler } from '../secondary-window-handler'; /** The class name added to ApplicationShell instances. */ const APPLICATION_SHELL_CLASS = 'theia-ApplicationShell'; @@ -226,6 +227,7 @@ export class ApplicationShell extends Widget { @inject(ApplicationShellOptions) @optional() options: RecursivePartial = {}, @inject(CorePreferences) protected readonly corePreferences: CorePreferences, @inject(SaveResourceService) protected readonly saveResourceService: SaveResourceService, + @inject(SecondaryWindowHandler) protected readonly secondaryWindowHandler: SecondaryWindowHandler, ) { super(options as Widget.IOptions); } @@ -281,6 +283,10 @@ export class ApplicationShell extends Widget { this.rightPanelHandler.dockPanel.widgetAdded.connect((_, widget) => this.fireDidAddWidget(widget)); this.rightPanelHandler.dockPanel.widgetRemoved.connect((_, widget) => this.fireDidRemoveWidget(widget)); + this.secondaryWindowHandler.init(this); + this.secondaryWindowHandler.onDidAddWidget(widget => this.fireDidAddWidget(widget)); + this.secondaryWindowHandler.onDidRemoveWidget(widget => this.fireDidRemoveWidget(widget)); + this.layout = this.createLayout(); this.tracker.currentChanged.connect(this.onCurrentChanged, this); @@ -819,6 +825,9 @@ export class ApplicationShell extends Widget { case 'right': this.rightPanelHandler.addWidget(widget, sidePanelOptions); break; + case 'secondaryWindow': + /** At the moment, widgets are only moved to this area (i.e. a secondary window) by moving them from one of the other areas. */ + throw new Error('Widgets cannot be added directly to a secondary window'); default: throw new Error('Unexpected area: ' + options?.area); } @@ -870,6 +879,8 @@ export class ApplicationShell extends Widget { return toArray(this.leftPanelHandler.dockPanel.widgets()); case 'right': return toArray(this.rightPanelHandler.dockPanel.widgets()); + case 'secondaryWindow': + return toArray(this.secondaryWindowHandler.widgets); default: throw new Error('Illegal argument: ' + area); } @@ -986,6 +997,9 @@ export class ApplicationShell extends Widget { case 'right': title = this.rightPanelHandler.tabBar.currentTitle; break; + case 'secondaryWindow': + // The current widget in a secondary window is not tracked. + return undefined; default: throw new Error('Illegal argument: ' + area); } @@ -1196,6 +1210,7 @@ export class ApplicationShell extends Widget { if (widget) { return widget; } + return this.secondaryWindowHandler.activateWidget(id); } /** @@ -1294,7 +1309,11 @@ export class ApplicationShell extends Widget { if (widget) { return widget; } - return this.rightPanelHandler.expand(id); + widget = this.rightPanelHandler.expand(id); + if (widget) { + return widget; + } + return this.secondaryWindowHandler.revealWidget(id); } /** @@ -1472,11 +1491,40 @@ export class ApplicationShell extends Widget { */ async closeTabs(tabBarOrArea: TabBar | ApplicationShell.Area, filter?: (title: Title, index: number) => boolean): Promise { - const titles: Array> = []; + const titles: Array> = this.getWidgetTitles(tabBarOrArea, filter); + if (titles.length) { + await this.closeMany(titles.map(title => title.owner)); + } + } + + saveTabs(tabBarOrArea: TabBar | ApplicationShell.Area, + filter?: (title: Title, index: number) => boolean): void { + + const titles = this.getWidgetTitles(tabBarOrArea, filter); + for (let i = 0; i < titles.length; i++) { + const widget = titles[i].owner; + const saveable = Saveable.get(widget); + saveable?.save(); + } + } + + /** + * Collects all widget titles for the given tab bar or area and optionally filters them. + * + * @param tabBarOrArea The tab bar or area to retrieve the widget titles for + * @param filter The filter to apply to the result + * @returns The filtered array of widget titles or an empty array + */ + protected getWidgetTitles(tabBarOrArea: TabBar | ApplicationShell.Area, + filter?: (title: Title, index: number) => boolean): Title[] { + + const titles: Title[] = []; if (tabBarOrArea === 'main') { this.mainAreaTabBars.forEach(tabbar => titles.push(...toArray(tabbar.titles))); } else if (tabBarOrArea === 'bottom') { this.bottomAreaTabBars.forEach(tabbar => titles.push(...toArray(tabbar.titles))); + } else if (tabBarOrArea === 'secondaryWindow') { + titles.push(...this.secondaryWindowHandler.widgets.map(w => w.title)); } else if (typeof tabBarOrArea === 'string') { const tabbar = this.getTabBarFor(tabBarOrArea); if (tabbar) { @@ -1485,32 +1533,8 @@ export class ApplicationShell extends Widget { } else if (tabBarOrArea) { titles.push(...toArray(tabBarOrArea.titles)); } - if (titles.length) { - await this.closeMany((filter ? titles.filter(filter) : titles).map(title => title.owner)); - } - } - saveTabs(tabBarOrArea: TabBar | ApplicationShell.Area, - filter?: (title: Title, index: number) => boolean): void { - if (tabBarOrArea === 'main') { - this.mainAreaTabBars.forEach(tb => this.saveTabs(tb, filter)); - } else if (tabBarOrArea === 'bottom') { - this.bottomAreaTabBars.forEach(tb => this.saveTabs(tb, filter)); - } else if (typeof tabBarOrArea === 'string') { - const tabBar = this.getTabBarFor(tabBarOrArea); - if (tabBar) { - this.saveTabs(tabBar, filter); - } - } else if (tabBarOrArea) { - const titles = toArray(tabBarOrArea.titles); - for (let i = 0; i < titles.length; i++) { - if (filter === undefined || filter(titles[i], i)) { - const widget = titles[i].owner; - const saveable = Saveable.get(widget); - saveable?.save(); - } - } - } + return filter ? titles.filter(filter) : titles; } /** @@ -1595,6 +1619,9 @@ export class ApplicationShell extends Widget { if (ArrayExt.firstIndexOf(this.rightPanelHandler.tabBar.titles, title) > -1) { return 'right'; } + if (this.secondaryWindowHandler.widgets.includes(widget)) { + return 'secondaryWindow'; + } return undefined; } @@ -1645,6 +1672,9 @@ export class ApplicationShell extends Widget { return this.leftPanelHandler.tabBar; case 'right': return this.rightPanelHandler.tabBar; + case 'secondaryWindow': + // Secondary windows don't have a tab bar + return undefined; default: throw new Error('Illegal argument: ' + widgetOrArea); } @@ -1915,6 +1945,10 @@ export class ApplicationShell extends Widget { this.revealWidget(widget!.id); } } + + async moveWidgetToSecondaryWindow(widget: ExtractableWidget): Promise { + this.secondaryWindowHandler.moveWidgetToSecondaryWindow(widget); + } } /** @@ -1924,7 +1958,7 @@ export namespace ApplicationShell { /** * The areas of the application shell where widgets can reside. */ - export type Area = 'main' | 'top' | 'left' | 'right' | 'bottom'; + export type Area = 'main' | 'top' | 'left' | 'right' | 'bottom' | 'secondaryWindow'; /** * The _side areas_ are those shell areas that can be collapsed and expanded, @@ -1935,7 +1969,7 @@ export namespace ApplicationShell { } export function isValidArea(area?: unknown): area is ApplicationShell.Area { - const areas = ['main', 'top', 'left', 'right', 'bottom']; + const areas = ['main', 'top', 'left', 'right', 'bottom', 'secondaryWindow']; return typeof area === 'string' && areas.includes(area); } diff --git a/packages/core/src/browser/widgets/extractable-widget.ts b/packages/core/src/browser/widgets/extractable-widget.ts new file mode 100644 index 0000000000000..8a0ada5ce8f96 --- /dev/null +++ b/packages/core/src/browser/widgets/extractable-widget.ts @@ -0,0 +1,33 @@ +// ***************************************************************************** +// Copyright (C) 2022 STMicroelectronics, Ericsson, ARM, EclipseSource 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 { Widget } from './widget'; + +/** + * A contract for widgets that are extractable to a secondary window. + */ +export interface ExtractableWidget extends Widget { + /** Set to `true` to mark the widget to be extractable. */ + isExtractable: boolean; + /** The secondary window that the window was extracted to or `undefined` if it is not yet extracted. */ + secondaryWindow: Window | undefined; +} + +export namespace ExtractableWidget { + export function is(widget: unknown): widget is ExtractableWidget { + return widget instanceof Widget && widget.hasOwnProperty('isExtractable') && (widget as ExtractableWidget).isExtractable === true; + } +} diff --git a/packages/core/src/browser/widgets/index.ts b/packages/core/src/browser/widgets/index.ts index 0cc53d9e8d27f..429fd92d24130 100644 --- a/packages/core/src/browser/widgets/index.ts +++ b/packages/core/src/browser/widgets/index.ts @@ -17,3 +17,4 @@ export * from './widget'; export * from './react-renderer'; export * from './react-widget'; +export * from './extractable-widget'; diff --git a/packages/core/src/browser/window/browser-window-module.ts b/packages/core/src/browser/window/browser-window-module.ts index 5a8d5d70efb8c..29abfd81789d6 100644 --- a/packages/core/src/browser/window/browser-window-module.ts +++ b/packages/core/src/browser/window/browser-window-module.ts @@ -20,10 +20,13 @@ import { DefaultWindowService } from '../../browser/window/default-window-servic import { FrontendApplicationContribution } from '../frontend-application'; import { ClipboardService } from '../clipboard-service'; import { BrowserClipboardService } from '../browser-clipboard-service'; +import { SecondaryWindowService } from './secondary-window-service'; +import { DefaultSecondaryWindowService } from './default-secondary-window-service'; export default new ContainerModule(bind => { bind(DefaultWindowService).toSelf().inSingletonScope(); bind(WindowService).toService(DefaultWindowService); bind(FrontendApplicationContribution).toService(DefaultWindowService); bind(ClipboardService).to(BrowserClipboardService).inSingletonScope(); + bind(SecondaryWindowService).to(DefaultSecondaryWindowService).inSingletonScope(); }); diff --git a/packages/core/src/browser/window/default-secondary-window-service.ts b/packages/core/src/browser/window/default-secondary-window-service.ts new file mode 100644 index 0000000000000..643afe79d9741 --- /dev/null +++ b/packages/core/src/browser/window/default-secondary-window-service.ts @@ -0,0 +1,85 @@ +// ***************************************************************************** +// Copyright (C) 2022 STMicroelectronics, Ericsson, ARM, EclipseSource 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 { inject, injectable, postConstruct } from 'inversify'; +import { SecondaryWindowService } from './secondary-window-service'; +import { WindowService } from './window-service'; + +@injectable() +export class DefaultSecondaryWindowService implements SecondaryWindowService { + // secondary-window.html is part of Theia's generated code. It is generated by dev-packages/application-manager/src/generator/frontend-generator.ts + protected static SECONDARY_WINDOW_URL = 'secondary-window.html'; + + /** + * Randomized prefix to be included in opened windows' ids. + * This avoids conflicts when creating sub-windows from multiple theia instances (e.g. by opening Theia multiple times in the same browser) + */ + protected readonly prefix = crypto.getRandomValues(new Uint32Array(1))[0]; + /** Unique id. Increase after every access. */ + private nextId = 0; + + protected secondaryWindows: Window[] = []; + + @inject(WindowService) + protected readonly windowService: WindowService; + + @postConstruct() + init(): void { + // Close all open windows when the main window is closed. + this.windowService.onUnload(() => { + // Iterate backwards because calling window.close might remove the window from the array + for (let i = this.secondaryWindows.length - 1; i >= 0; i--) { + this.secondaryWindows[i].close(); + } + }); + } + + createSecondaryWindow(onClose?: (closedWin: Window) => void): Window | undefined { + const win = this.doCreateSecondaryWindow(onClose); + if (win) { + this.secondaryWindows.push(win); + } + return win; + } + + protected doCreateSecondaryWindow(onClose?: (closedWin: Window) => void): Window | undefined { + const win = window.open(DefaultSecondaryWindowService.SECONDARY_WINDOW_URL, this.nextWindowId(), 'popup'); + if (win) { + // Add the unload listener after the dom content was loaded because otherwise the unload listener is called already on open in some browsers (e.g. Chrome). + win.addEventListener('DOMContentLoaded', () => { + win.addEventListener('unload', () => { + this.handleWindowClosed(win, onClose); + }); + }); + } + return win ?? undefined; + } + + protected handleWindowClosed(win: Window, onClose?: (closedWin: Window) => void): void { + const extIndex = this.secondaryWindows.indexOf(win); + if (extIndex > -1) { + this.secondaryWindows.splice(extIndex, 1); + }; + onClose?.(win); + } + + focus(win: Window): void { + win.focus(); + } + + protected nextWindowId(): string { + return `${this.prefix}-secondaryWindow-${this.nextId++}`; + } +} diff --git a/packages/core/src/browser/window/secondary-window-service.ts b/packages/core/src/browser/window/secondary-window-service.ts new file mode 100644 index 0000000000000..d314fab9ae63e --- /dev/null +++ b/packages/core/src/browser/window/secondary-window-service.ts @@ -0,0 +1,32 @@ +// ***************************************************************************** +// Copyright (C) 2022 STMicroelectronics, Ericsson, ARM, EclipseSource 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 +// ***************************************************************************** + +export const SecondaryWindowService = Symbol('SecondaryWindowService'); + +/** Service for opening new secondary windows to contain widgets extracted from the application shell. */ +export interface SecondaryWindowService { + /** + * Creates a new secondary window for a widget to be extracted from the application shell. + * The created window is closed automatically when the current theia instance is closed. + * + * @param onClose optional callback that is invoked when the secondary window is closed + * @returns the created window or `undefined` if it could not be created + */ + createSecondaryWindow(onClose?: (win: Window) => void): Window | undefined; + + /** Handles focussing the given secondary window in the browser and on Electron. */ + focus(win: Window): void; +} diff --git a/packages/core/src/electron-browser/window/electron-secondary-window-service.ts b/packages/core/src/electron-browser/window/electron-secondary-window-service.ts new file mode 100644 index 0000000000000..987420e056fd3 --- /dev/null +++ b/packages/core/src/electron-browser/window/electron-secondary-window-service.ts @@ -0,0 +1,57 @@ +// ***************************************************************************** +// Copyright (C) 2022 STMicroelectronics, Ericsson, ARM, EclipseSource 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 { BrowserWindow } from '../../../electron-shared/electron'; +import * as electronRemote from '../../../electron-shared/@electron/remote'; +import { injectable } from 'inversify'; +import { DefaultSecondaryWindowService } from '../../browser/window/default-secondary-window-service'; + +@injectable() +export class ElectronSecondaryWindowService extends DefaultSecondaryWindowService { + protected electronWindows: Map = new Map(); + + protected override doCreateSecondaryWindow(onClose?: (closedWin: Window) => void): Window | undefined { + const id = this.nextWindowId(); + electronRemote.getCurrentWindow().webContents.once('did-create-window', newElectronWindow => { + newElectronWindow.setMenuBarVisibility(false); + this.electronWindows.set(id, newElectronWindow); + newElectronWindow.on('closed', () => { + this.electronWindows.delete(id); + const browserWin = this.secondaryWindows.find(w => w.name === id); + if (browserWin) { + this.handleWindowClosed(browserWin, onClose); + } else { + console.warn(`Could not execute proper close handling for secondary window '${id}' because its frontend window could not be found.`); + }; + }); + }); + const win = window.open(DefaultSecondaryWindowService.SECONDARY_WINDOW_URL, id); + return win ?? undefined; + } + + override focus(win: Window): void { + // window.name is the target name given to the window.open call as the second parameter. + const electronWindow = this.electronWindows.get(win.name); + if (electronWindow) { + if (electronWindow.isMinimized()) { + electronWindow.restore(); + } + electronWindow.focus(); + } else { + console.warn(`There is no known secondary window '${win.name}'. Thus, the window could not be focussed.`); + } + } +} diff --git a/packages/core/src/electron-browser/window/electron-window-module.ts b/packages/core/src/electron-browser/window/electron-window-module.ts index 0318d184aabcd..717d51aa7f60d 100644 --- a/packages/core/src/electron-browser/window/electron-window-module.ts +++ b/packages/core/src/electron-browser/window/electron-window-module.ts @@ -25,6 +25,8 @@ import { ElectronIpcConnectionProvider } from '../messaging/electron-ipc-connect import { bindWindowPreferences } from './electron-window-preferences'; import { FrontendApplicationStateService } from '../../browser/frontend-application-state'; import { ElectronFrontendApplicationStateService } from './electron-frontend-application-state'; +import { ElectronSecondaryWindowService } from './electron-secondary-window-service'; +import { SecondaryWindowService } from '../../browser/window/secondary-window-service'; export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(ElectronMainWindowService).toDynamicValue(context => @@ -35,4 +37,5 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(FrontendApplicationContribution).toService(WindowService); bind(ClipboardService).to(ElectronClipboardService).inSingletonScope(); rebind(FrontendApplicationStateService).to(ElectronFrontendApplicationStateService).inSingletonScope(); + bind(SecondaryWindowService).to(ElectronSecondaryWindowService).inSingletonScope(); }); diff --git a/packages/core/src/electron-main/electron-main-application.ts b/packages/core/src/electron-main/electron-main-application.ts index 9d0b5083d23aa..bcb213a46d07a 100644 --- a/packages/core/src/electron-main/electron-main-application.ts +++ b/packages/core/src/electron-main/electron-main-application.ts @@ -16,7 +16,7 @@ import { inject, injectable, named } from 'inversify'; import * as electronRemoteMain from '../../electron-shared/@electron/remote/main'; -import { screen, ipcMain, app, BrowserWindow, Event as ElectronEvent } from '../../electron-shared/electron'; +import { screen, ipcMain, app, BrowserWindow, Event as ElectronEvent, BrowserWindowConstructorOptions, nativeImage } from '../../electron-shared/electron'; import * as path from 'path'; import { Argv } from 'yargs'; import { AddressInfo } from 'net'; @@ -252,6 +252,7 @@ export class ElectronMainApplication { electronWindow.onDidClose(() => this.windows.delete(id)); this.attachSaveWindowState(electronWindow.window); electronRemoteMain.enable(electronWindow.window.webContents); + this.configureNativeSecondaryWindowCreation(electronWindow.window); return electronWindow.window; } @@ -315,6 +316,31 @@ export class ElectronMainApplication { return electronWindow; } + /** Configures native window creation, i.e. using window.open or links with target "_blank" in the frontend. */ + protected configureNativeSecondaryWindowCreation(electronWindow: BrowserWindow): void { + electronWindow.webContents.setWindowOpenHandler(() => { + const { minWidth, minHeight } = this.getDefaultOptions(); + const options: BrowserWindowConstructorOptions = { + ...this.getDefaultTheiaWindowBounds(), + // We always need the native window frame for now because the secondary window does not have Theia's title bar by default. + // In 'custom' title bar mode this would leave the window without any window controls (close, min, max) + // TODO set to this.useNativeWindowFrame when secondary windows support a custom title bar. + frame: true, + minWidth, + minHeight + }; + if (!this.useNativeWindowFrame) { + // If the main window does not have a native window frame, do not show an icon in the secondary window's native title bar. + // The data url is a 1x1 transparent png + options.icon = nativeImage.createFromDataURL('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12P4DwQACfsD/WMmxY8AAAAASUVORK5CYII='); + } + return { + action: 'allow', + overrideBrowserWindowOptions: options, + }; + }); + } + /** * "Gently" close all windows, application will not stop if a `beforeunload` handler returns `false`. */ @@ -348,6 +374,16 @@ export class ElectronMainApplication { } protected getDefaultTheiaWindowOptions(): TheiaBrowserWindowOptions { + return { + frame: this.useNativeWindowFrame, + isFullScreen: false, + isMaximized: false, + ...this.getDefaultTheiaWindowBounds(), + ...this.getDefaultOptions() + }; + } + + protected getDefaultTheiaWindowBounds(): TheiaBrowserWindowOptions { // The `screen` API must be required when the application is ready. // See: https://electronjs.org/docs/api/screen#screen // We must center by hand because `browserWindow.center()` fails on multi-screen setups @@ -358,14 +394,10 @@ export class ElectronMainApplication { const y = Math.round(bounds.y + (bounds.height - height) / 2); const x = Math.round(bounds.x + (bounds.width - width) / 2); return { - frame: this.useNativeWindowFrame, - isFullScreen: false, - isMaximized: false, width, height, x, - y, - ...this.getDefaultOptions() + y }; } diff --git a/packages/navigator/src/browser/open-editors-widget/navigator-open-editors-tree-model.ts b/packages/navigator/src/browser/open-editors-widget/navigator-open-editors-tree-model.ts index c6ed0009ba6c2..6d6d326932f96 100644 --- a/packages/navigator/src/browser/open-editors-widget/navigator-open-editors-tree-model.ts +++ b/packages/navigator/src/browser/open-editors-widget/navigator-open-editors-tree-model.ts @@ -114,7 +114,7 @@ export class OpenEditorsModel extends FileTreeModel { this._lastEditorWidgetsByArea = this._editorWidgetsByArea; this._editorWidgetsByArea = new Map(); let doRebuild = true; - const areas: ApplicationShell.Area[] = ['main', 'bottom', 'left', 'right', 'top']; + const areas: ApplicationShell.Area[] = ['main', 'bottom', 'left', 'right', 'top', 'secondaryWindow']; areas.forEach(area => { const editorWidgetsForArea = this.applicationShell.getWidgets(area).filter((widget): widget is NavigatableWidget => NavigatableWidget.is(widget)); if (editorWidgetsForArea.length) { diff --git a/packages/plugin-ext/src/main/browser/custom-editors/custom-editors-main.ts b/packages/plugin-ext/src/main/browser/custom-editors/custom-editors-main.ts index f4b8a56a6788f..c223f908521b2 100644 --- a/packages/plugin-ext/src/main/browser/custom-editors/custom-editors-main.ts +++ b/packages/plugin-ext/src/main/browser/custom-editors/custom-editors-main.ts @@ -258,7 +258,7 @@ export class CustomEditorsMainImpl implements CustomEditorsMain, Disposable { area = WebviewPanelTargetArea.Right; case 'bottom': area = WebviewPanelTargetArea.Bottom; - default: // includes 'top' + default: // includes 'top' and 'secondaryWindow' area = WebviewPanelTargetArea.Main; } showOptions.area = area; diff --git a/packages/plugin-ext/src/main/browser/webview/pre/host.js b/packages/plugin-ext/src/main/browser/webview/pre/host.js index 35f67feb97468..8668c0fd2fd31 100644 --- a/packages/plugin-ext/src/main/browser/webview/pre/host.js +++ b/packages/plugin-ext/src/main/browser/webview/pre/host.js @@ -52,6 +52,13 @@ postMessage(channel, data) { window.parent.postMessage({ target: id, channel, data }, '*'); + let currentWindow = window; + while (currentWindow.parent !== currentWindow) { + currentWindow = currentWindow.parent; + if (currentWindow.opener) { + currentWindow.opener.postMessage({ target: id, channel, data, fromSecondary: true }, '*'); + } + } } onMessage(channel, handler) { diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index cb29ab215b878..1461e2ebfcb43 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -49,6 +49,7 @@ import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { FileOperationError, FileOperationResult } from '@theia/filesystem/lib/common/files'; import { BinaryBufferReadableStream } from '@theia/core/lib/common/buffer'; import { ViewColumn } from '../../../plugin/types-impl'; +import { ExtractableWidget } from '@theia/core/lib/browser/widgets/extractable-widget'; // Style from core const TRANSPARENT_OVERLAY_STYLE = 'theia-transparent-overlay'; @@ -85,7 +86,7 @@ export class WebviewWidgetIdentifier { export const WebviewWidgetExternalEndpoint = Symbol('WebviewWidgetExternalEndpoint'); @injectable() -export class WebviewWidget extends BaseWidget implements StatefulWidget { +export class WebviewWidget extends BaseWidget implements StatefulWidget, ExtractableWidget { private static readonly standardSupportedLinkSchemes = new Set([ Schemes.http, @@ -173,6 +174,9 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { protected readonly toHide = new DisposableCollection(); protected hideTimeout: any | number | undefined; + isExtractable: boolean = true; + secondaryWindow: Window | undefined = undefined; + @postConstruct() protected init(): void { this.node.tabIndex = 0; @@ -564,7 +568,11 @@ 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 }, '*'); + if (this.secondaryWindow) { + this.secondaryWindow.postMessage({ channel, args: data }, '*'); + } else { + this.element.contentWindow!.postMessage({ channel, args: data }, '*'); + } } } diff --git a/packages/secondary-window-ui/.eslintrc.js b/packages/secondary-window-ui/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/packages/secondary-window-ui/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + } +}; diff --git a/packages/secondary-window-ui/README.md b/packages/secondary-window-ui/README.md new file mode 100644 index 0000000000000..4eba946554837 --- /dev/null +++ b/packages/secondary-window-ui/README.md @@ -0,0 +1,35 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - WIDGET-EXTRACTION-UI EXTENSION

+ +
+ +
+ +## Description + +The `@theia/secondary-window-ui` extension contributes UI integration that allows moving widgets to secondary windows. + +### Limitations + +- **The extension is currently only suitable for use in browser applications** because there are some unresolved issues with *Electron*. +- Currently, only webview widgets can be moved to secondary windows. + +## Additional Information + +- [Theia - GitHub](https://github.com/eclipse-theia/theia) +- [Theia - Website](https://theia-ide.org/) + +## License + +- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/) +- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp) + +## Trademark + +"Theia" is a trademark of the Eclipse Foundation + diff --git a/packages/secondary-window-ui/package.json b/packages/secondary-window-ui/package.json new file mode 100644 index 0000000000000..ffde35054c3f2 --- /dev/null +++ b/packages/secondary-window-ui/package.json @@ -0,0 +1,43 @@ +{ + "name": "@theia/secondary-window-ui", + "version": "1.29.0", + "description": "Theia - Secondary window UI contributions", + "dependencies": { + "@theia/core": "1.29.0" + }, + "publishConfig": { + "access": "public" + }, + "theiaExtensions": [ + { + "frontend": "lib/browser/secondary-window-ui-frontend-module" + } + ], + "keywords": [ + "theia-extension" + ], + "license": "EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-theia/theia.git" + }, + "bugs": { + "url": "https://github.com/eclipse-theia/theia/issues" + }, + "homepage": "https://github.com/eclipse-theia/theia", + "files": [ + "lib", + "src" + ], + "scripts": { + "build": "theiaext build", + "clean": "theiaext clean", + "compile": "theiaext compile", + "lint": "theiaext lint", + "test": "theiaext test", + "watch": "theiaext watch" + }, + "devDependencies": { + "@theia/ext-scripts": "1.29.0" + } +} diff --git a/packages/secondary-window-ui/src/browser/secondary-window-ui-frontend-contribution.ts b/packages/secondary-window-ui/src/browser/secondary-window-ui-frontend-contribution.ts new file mode 100644 index 0000000000000..31ad13442a5dc --- /dev/null +++ b/packages/secondary-window-ui/src/browser/secondary-window-ui-frontend-contribution.ts @@ -0,0 +1,50 @@ +// ***************************************************************************** +// Copyright (C) 2022 STMicroelectronics, Ericsson, ARM, EclipseSource 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 { inject, injectable } from '@theia/core/shared/inversify'; +import { ApplicationShell } from '@theia/core/lib/browser/shell'; +import { CommandRegistry, CommandContribution, Command } from '@theia/core/lib/common/command'; +import { codicon, ExtractableWidget } from '@theia/core/lib/browser/widgets'; +import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; + +export const EXTRACT_WIDGET = Command.toLocalizedCommand({ + id: 'extract-widget', + label: 'Move View to Secondary Window' +}, 'theia/secondary-window-ui/extract-widget'); + +/** Contributes the widget extraction command and registers it in the toolbar of extractable widgets. */ +@injectable() +export class SecondaryWindowUiContribution implements CommandContribution, TabBarToolbarContribution { + + @inject(ApplicationShell) + protected readonly shell: ApplicationShell; + + registerCommands(commands: CommandRegistry): void { + commands.registerCommand(EXTRACT_WIDGET, { + execute: async widget => this.shell.moveWidgetToSecondaryWindow(widget), + isVisible: widget => ExtractableWidget.is(widget), + isEnabled: widget => ExtractableWidget.is(widget) + }); + } + + registerToolbarItems(registry: TabBarToolbarRegistry): void { + registry.registerItem({ + id: EXTRACT_WIDGET.id, + command: EXTRACT_WIDGET.id, + icon: codicon('window'), + }); + } +} diff --git a/packages/secondary-window-ui/src/browser/secondary-window-ui-frontend-module.ts b/packages/secondary-window-ui/src/browser/secondary-window-ui-frontend-module.ts new file mode 100644 index 0000000000000..0326a8d8647a9 --- /dev/null +++ b/packages/secondary-window-ui/src/browser/secondary-window-ui-frontend-module.ts @@ -0,0 +1,27 @@ +// ***************************************************************************** +// Copyright (C) 2022 STMicroelectronics, Ericsson, ARM, EclipseSource 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 { ContainerModule } from '@theia/core/shared/inversify'; +import { SecondaryWindowUiContribution } from './secondary-window-ui-frontend-contribution'; +import { CommandContribution } from '@theia/core/lib/common/command'; +import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; + +export default new ContainerModule(bind => { + bind(SecondaryWindowUiContribution).toSelf().inSingletonScope(); + bind(CommandContribution).toService(SecondaryWindowUiContribution); + bind(TabBarToolbarContribution).toService(SecondaryWindowUiContribution); +}); + diff --git a/packages/secondary-window-ui/src/package.spec.ts b/packages/secondary-window-ui/src/package.spec.ts new file mode 100644 index 0000000000000..230369d996150 --- /dev/null +++ b/packages/secondary-window-ui/src/package.spec.ts @@ -0,0 +1,19 @@ +// ***************************************************************************** +// Copyright (C) 2022 STMicroelectronics, Ericsson, ARM, EclipseSource 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 +// ***************************************************************************** + +describe('secondary-window-ui package', () => { + it('supports code coverage statistics', () => true); +}); diff --git a/packages/secondary-window-ui/tsconfig.json b/packages/secondary-window-ui/tsconfig.json new file mode 100644 index 0000000000000..b623c1e105ac7 --- /dev/null +++ b/packages/secondary-window-ui/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../core" + } + ] +} diff --git a/tsconfig.json b/tsconfig.json index a9b09373177b3..2839e74fe8d6d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -147,6 +147,9 @@ { "path": "packages/search-in-workspace" }, + { + "path": "packages/secondary-window-ui" + }, { "path": "packages/task" }, diff --git a/yarn.lock b/yarn.lock index 05aecc7140277..73fe8a699b03f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10573,6 +10573,14 @@ string-argv@^0.1.1: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.1.2.tgz#c5b7bc03fb2b11983ba3a72333dd0559e77e4738" integrity sha512-mBqPGEOMNJKXRo7z0keX0wlAhbBAjilUdPW13nN0PecVryZxdHIeM7TqbsSUA7VYuS00HGC6mojP7DlQzfa9ZA== +string-replace-loader@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/string-replace-loader/-/string-replace-loader-3.1.0.tgz#11ac6ee76bab80316a86af358ab773193dd57a4f" + integrity sha512-5AOMUZeX5HE/ylKDnEa/KKBqvlnFmRZudSOjVJHxhoJg9QYTwl1rECx7SLR8BBH7tfxb4Rp7EM2XVfQFxIhsbQ== + dependencies: + loader-utils "^2.0.0" + schema-utils "^3.0.0" + string-width@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"