diff --git a/CHANGELOG.md b/CHANGELOG.md index d148f36b74e70..e1d674445e17a 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 for browser applications. Added new extension `secondary-window` 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..f7fea4d84f232 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": "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..d4bcfc14426b9 100644 --- a/examples/browser/tsconfig.json +++ b/examples/browser/tsconfig.json @@ -104,6 +104,9 @@ { "path": "../../packages/search-in-workspace" }, + { + "path": "../../packages/secondary-window" + }, { "path": "../../packages/task" }, diff --git a/packages/core/i18n/nls.cs.json b/packages/core/i18n/nls.cs.json index e7d02a75c4d62..32b950463d2ac 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": { + "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..d8eaeb1bba782 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": { + "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..64f585e5f97e8 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": { + "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..2139137bba522 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": { + "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..cbbd13fd7d61c 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": { + "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..0f2f596e32a8e 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": { + "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..2e9e202751a6c 100644 --- a/packages/core/i18n/nls.ja.json +++ b/packages/core/i18n/nls.ja.json @@ -409,6 +409,9 @@ "resultSubset": "これは、すべての結果の一部に過ぎません。より具体的な検索用語を使って、結果リストを絞り込んでください。", "searchOnEditorModification": "修正されたときにアクティブなエディタを検索します。" }, + "secondary-window": { + "extract-widget": "セカンダリーウィンドウへの表示移動" + }, "task": { "attachTask": "タスクの添付...", "clearHistory": "明確な歴史", diff --git a/packages/core/i18n/nls.json b/packages/core/i18n/nls.json index 81673e6be4ed3..57a4044029f04 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": { + "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..676ba8bf5e9fa 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": { + "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..182e4af4ebfcd 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": { + "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..476d9b89a17e0 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": { + "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..42053bbdf8cff 100644 --- a/packages/core/i18n/nls.ru.json +++ b/packages/core/i18n/nls.ru.json @@ -409,6 +409,9 @@ "resultSubset": "Это только часть всех результатов. Используйте более конкретный поисковый запрос, чтобы сузить список результатов.", "searchOnEditorModification": "Поиск активного редактора при изменении." }, + "secondary-window": { + "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..be66788018ff0 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": { + "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..2211181a4308f --- /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. + * + * @experimental The functionality provided by this handler is experimental and has known issues in Electron apps. + */ +@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); + + const mainWindowTitle = document.title; + 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} — ${mainWindowTitle}`; + + 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..9fd0ae410dda2 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -40,6 +40,7 @@ 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,7 @@ export class ApplicationShell extends Widget { this.revealWidget(widget!.id); } } + } /** @@ -1924,7 +1955,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 +1966,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..784e32bf974e2 --- /dev/null +++ b/packages/core/src/browser/window/secondary-window-service.ts @@ -0,0 +1,36 @@ +// ***************************************************************************** +// 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. + * + * @experimental The functionality provided by this service and its implementation is still under development. Use with caution. + */ +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..4ba20bcc97473 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) { @@ -192,7 +192,7 @@ export class OpenEditorsModel extends FileTreeModel { const areaNode: CompositeTreeNode & ExpandableTreeNode = { id: `${OpenEditorsModel.AREA_NODE_ID_PREFIX}:${area}`, parent: rootNode, - name: area, + name: area === 'secondaryWindow' ? 'in secondary window' : area, expanded: true, children: [] }; 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/.eslintrc.js b/packages/secondary-window/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/packages/secondary-window/.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/README.md b/packages/secondary-window/README.md new file mode 100644 index 0000000000000..6cfb92c619a21 --- /dev/null +++ b/packages/secondary-window/README.md @@ -0,0 +1,35 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - SECONDARY WINDOW EXTENSION

+ +
+ +
+ +## Description + +The `@theia/secondary-window` 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/package.json b/packages/secondary-window/package.json new file mode 100644 index 0000000000000..201120791e4df --- /dev/null +++ b/packages/secondary-window/package.json @@ -0,0 +1,43 @@ +{ + "name": "@theia/secondary-window", + "version": "1.29.0", + "description": "Theia - Secondary Window Extension", + "dependencies": { + "@theia/core": "1.29.0" + }, + "publishConfig": { + "access": "public" + }, + "theiaExtensions": [ + { + "frontend": "lib/browser/secondary-window-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/src/browser/secondary-window-frontend-contribution.ts b/packages/secondary-window/src/browser/secondary-window-frontend-contribution.ts new file mode 100644 index 0000000000000..15c43dc002e6c --- /dev/null +++ b/packages/secondary-window/src/browser/secondary-window-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 { 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'; +import { SecondaryWindowHandler } from '@theia/core/lib/browser/secondary-window-handler'; + +export const EXTRACT_WIDGET = Command.toLocalizedCommand({ + id: 'extract-widget', + label: 'Move View to Secondary Window' +}, 'theia/secondary-window/extract-widget'); + +/** Contributes the widget extraction command and registers it in the toolbar of extractable widgets. */ +@injectable() +export class SecondaryWindowContribution implements CommandContribution, TabBarToolbarContribution { + + @inject(SecondaryWindowHandler) + protected readonly secondaryWindowHandler: SecondaryWindowHandler; + + registerCommands(commands: CommandRegistry): void { + commands.registerCommand(EXTRACT_WIDGET, { + execute: async widget => this.secondaryWindowHandler.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/src/browser/secondary-window-frontend-module.ts b/packages/secondary-window/src/browser/secondary-window-frontend-module.ts new file mode 100644 index 0000000000000..474730cb970ed --- /dev/null +++ b/packages/secondary-window/src/browser/secondary-window-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 { SecondaryWindowContribution } from './secondary-window-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(SecondaryWindowContribution).toSelf().inSingletonScope(); + bind(CommandContribution).toService(SecondaryWindowContribution); + bind(TabBarToolbarContribution).toService(SecondaryWindowContribution); +}); + diff --git a/packages/secondary-window/src/package.spec.ts b/packages/secondary-window/src/package.spec.ts new file mode 100644 index 0000000000000..dc03411b1269d --- /dev/null +++ b/packages/secondary-window/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 package', () => { + it('supports code coverage statistics', () => true); +}); diff --git a/packages/secondary-window/tsconfig.json b/packages/secondary-window/tsconfig.json new file mode 100644 index 0000000000000..b623c1e105ac7 --- /dev/null +++ b/packages/secondary-window/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..bcdceef63de0c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -147,6 +147,9 @@ { "path": "packages/search-in-workspace" }, + { + "path": "packages/secondary-window" + }, { "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"