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 @@
+
+
+
+
+
+
+
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"