diff --git a/CHANGELOG.md b/CHANGELOG.md
index 933c864b893db..1dc8ec99e815e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@
- [plugin] added support for `SnippetString.appendChoice` [#10969](https://github.com/eclipse-theia/theia/pull/10969) - Contributed on behalf of STMicroelectronics
- [plugin] added support for `AccessibilityInformation` [#10961](https://github.com/eclipse-theia/theia/pull/10961) - Contributed on behalf of STMicroelectronics
- [plugin] added missing properties `id`, `name` and `backgroundColor` to `StatusBarItem` [#11026](https://github.com/eclipse-theia/theia/pull/11026) - Contributed on behalf of STMicroelectronics
+- [core] Added support for moving webview-based views into a secondary window/tab. Added new extension `secondary-window-ui` that contributes the UI integration to use this. [#xx](https://github.com/eclipse-theia/theia/pull/xx)- Contributed on behalf of ST Microelectronics and Ericsson and by ARM and EclipseSource
[Breaking Changes:](#breaking_changes_1.25.0)
- [debug]
diff --git a/dev-packages/application-manager/src/generator/frontend-generator.ts b/dev-packages/application-manager/src/generator/frontend-generator.ts
index f5a69730f096e..6e2cc4c714f1b 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));
@@ -197,4 +198,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..913d466258cd0 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']
diff --git a/examples/browser/package.json b/examples/browser/package.json
index 5684e3807b7e2..ca3227e737bda 100644
--- a/examples/browser/package.json
+++ b/examples/browser/package.json
@@ -46,6 +46,7 @@
"@theia/scm": "1.24.0",
"@theia/scm-extra": "1.24.0",
"@theia/search-in-workspace": "1.24.0",
+ "@theia/secondary-window-ui": "1.24.0",
"@theia/task": "1.24.0",
"@theia/terminal": "1.24.0",
"@theia/timeline": "1.24.0",
diff --git a/examples/browser/tsconfig.json b/examples/browser/tsconfig.json
index 163dd78f86414..90a70b9eb7e3e 100644
--- a/examples/browser/tsconfig.json
+++ b/examples/browser/tsconfig.json
@@ -101,6 +101,9 @@
{
"path": "../../packages/search-in-workspace"
},
+ {
+ "path": "../../packages/secondary-window-ui"
+ },
{
"path": "../../packages/task"
},
diff --git a/examples/electron/package.json b/examples/electron/package.json
index dc4de03ccd43d..707b09065beaf 100644
--- a/examples/electron/package.json
+++ b/examples/electron/package.json
@@ -47,6 +47,7 @@
"@theia/scm": "1.24.0",
"@theia/scm-extra": "1.24.0",
"@theia/search-in-workspace": "1.24.0",
+ "@theia/secondary-window-ui": "1.24.0",
"@theia/task": "1.24.0",
"@theia/terminal": "1.24.0",
"@theia/timeline": "1.24.0",
diff --git a/examples/electron/tsconfig.json b/examples/electron/tsconfig.json
index 1ba97be103fc7..fa916b92a30a4 100644
--- a/examples/electron/tsconfig.json
+++ b/examples/electron/tsconfig.json
@@ -104,6 +104,9 @@
{
"path": "../../packages/search-in-workspace"
},
+ {
+ "path": "../../packages/secondary-window-ui"
+ },
{
"path": "../../packages/task"
},
diff --git a/package.json b/package.json
index 9ca5044ca8fae..b01e59baaf35e 100644
--- a/package.json
+++ b/package.json
@@ -68,6 +68,7 @@
"lint:clean": "rimraf .eslintcache",
"lint:oneshot": "node --max-old-space-size=4096 node_modules/eslint/bin/eslint.js --cache=true \"{dev-packages,packages,examples}/**/*.{ts,tsx}\"",
"preinstall": "node-gyp install",
+ "postinstall": "node scripts/patch-libraries.js",
"prepare": "yarn -s compile:references && lerna run prepare && yarn -s compile",
"publish:latest": "lerna publish --exact --yes --no-push && yarn -s publish:check",
"publish:next": "lerna publish preminor --exact --canary --preid next --dist-tag next --no-git-reset --no-git-tag-version --no-push --yes && yarn -s publish:check",
diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts
index c00b41e8df977..9020c41187eea 100644
--- a/packages/core/src/browser/frontend-application-module.ts
+++ b/packages/core/src/browser/frontend-application-module.ts
@@ -121,6 +121,7 @@ import { RendererHost } from './widgets';
import { TooltipService, TooltipServiceImpl } from './tooltip-service';
import { bindFrontendStopwatch, bindBackendStopwatch } from './performance';
import { SaveResourceService } from './save-resource-service';
+import { SecondaryWindowHandler } from './secondary-window-handler';
import { UserWorkingDirectoryProvider } from './user-working-directory-provider';
export { bindResourceProvider, bindMessageService, bindPreferenceService };
@@ -400,4 +401,6 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo
bind(SaveResourceService).toSelf().inSingletonScope();
bind(UserWorkingDirectoryProvider).toSelf().inSingletonScope();
+
+ 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..c076d8fec4854
--- /dev/null
+++ b/packages/core/src/browser/secondary-window-handler.ts
@@ -0,0 +1,243 @@
+// *****************************************************************************
+// 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 { WindowService } from './window/window-service';
+
+/** Widget to be contained directly in a secondary window. */
+class SecondaryWindowRootWidget extends Widget {
+
+ constructor() {
+ super();
+ this.layout = new BoxLayout();
+ }
+
+ addWidget(widget: Widget): void {
+ (this.layout as BoxLayout).addWidget(widget);
+ BoxPanel.setStretch(widget, 1);
+ }
+
+}
+
+/**
+ * Offers functionality to move a widget out of the main window to a newly created window.
+ * Widgets must explicitly implement the `ExtractableWidget` interface to support this.
+ *
+ * This handler manages the opened secondary windows and sets up messaging between them and the Theia main window.
+ * In addition, it provides access to the extracted widgets and provides notifications when widgets are added to or removed from this handler.
+ *
+ * _Note:_ This handler is used by the application shell and there should be no need for callers to directly interact with this class.
+ * Instead, consider using the application shell.
+ */
+@injectable()
+export class SecondaryWindowHandler {
+ /** List of currently open secondary windows. Window references should be removed once the window is closed. */
+ protected readonly secondaryWindows: Window[] = [];
+ /** List of widgets in secondary windows. */
+ protected readonly _widgets: ExtractableWidget[] = [];
+
+ /**
+ * 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 Uint16Array(1))[0];
+
+ 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;
+
+ constructor(
+ @inject(MessageService) protected readonly messageService: MessageService,
+ @inject(WindowService) protected readonly windowService: WindowService
+ ) { }
+
+ /** @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 }, '*');
+ }
+ });
+ });
+
+ // 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();
+ }
+ });
+ }
+
+ /**
+ * 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;
+ }
+
+ // secondary-window.html is part of Theia's generated code. It is generated by dev-packages/application-manager/src/generator/frontend-generator.ts
+ const newWindow = window.open('secondary-window.html', `${this.prefix}-subwindow${this.secondaryWindows.length}`, 'popup');
+
+ 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 popus.');
+ return;
+ }
+
+ this.secondaryWindows.push(newWindow);
+
+ newWindow.onload = () => {
+ // Use the widget's title as the window title
+ // Even if the widget's label were malicious, this should be safe against XSS because the HTML standard defines this is inserted via a text node.
+ // See https://html.spec.whatwg.org/multipage/dom.html#document.title
+ newWindow.document.title = widget.title.label;
+
+ const element = newWindow.document.getElementById('pwidget');
+ 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 widget and remove window from this handler when the window is closed.
+ newWindow.addEventListener('beforeunload', () => {
+ this.applicationShell.closeWidget(widget.id);
+ const extIndex = this.secondaryWindows.indexOf(newWindow);
+ if (extIndex > -1) {
+ this.secondaryWindows.splice(extIndex, 1);
+ }
+ });
+
+ // 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) {
+ // TODO This is not sufficient for electron
+ trackedWidget.secondaryWindow?.focus();
+ 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 2ffad67e9b972..2f1c85fde2034 100644
--- a/packages/core/src/browser/shell/application-shell.ts
+++ b/packages/core/src/browser/shell/application-shell.ts
@@ -34,11 +34,12 @@ import { FrontendApplicationStateService } from '../frontend-application-state';
import { TabBarToolbarRegistry, TabBarToolbarFactory } from './tab-bar-toolbar';
import { ContextKeyService } from '../context-key-service';
import { Emitter } from '../../common/event';
-import { waitForRevealed, waitForClosed } from '../widgets';
+import { waitForRevealed, waitForClosed, ExtractableWidget } from '../widgets';
import { CorePreferences } from '../core-preferences';
import { BreadcrumbsRendererFactory } from '../breadcrumbs/breadcrumbs-renderer';
import { Deferred } from '../../common/promise-util';
import { SaveResourceService } from '../save-resource-service';
+import { SecondaryWindowHandler } from '../secondary-window-handler';
/** The class name added to ApplicationShell instances. */
const APPLICATION_SHELL_CLASS = 'theia-ApplicationShell';
@@ -219,6 +220,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);
}
@@ -274,6 +276,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);
@@ -835,6 +841,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);
}
@@ -858,6 +867,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);
}
@@ -974,6 +985,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);
}
@@ -1184,6 +1198,7 @@ export class ApplicationShell extends Widget {
if (widget) {
return widget;
}
+ return this.secondaryWindowHandler.activateWidget(id);
}
/**
@@ -1282,7 +1297,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);
}
/**
@@ -1459,11 +1478,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) {
@@ -1472,32 +1520,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;
}
/**
@@ -1582,6 +1606,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;
}
@@ -1632,6 +1659,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);
}
@@ -1902,6 +1932,10 @@ export class ApplicationShell extends Widget {
this.revealWidget(widget!.id);
}
}
+
+ async moveWidgetToSecondaryWindow(widget: ExtractableWidget): Promise {
+ this.secondaryWindowHandler.moveWidgetToSecondaryWindow(widget);
+ }
}
/**
@@ -1911,7 +1945,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,
@@ -1923,7 +1957,7 @@ export namespace ApplicationShell {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isValidArea(area?: any): area is ApplicationShell.Area {
- const areas = ['main', 'top', 'left', 'right', 'bottom'];
+ const areas = ['main', 'top', 'left', 'right', 'bottom', 'secondaryWindow'];
return (area !== undefined && 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/electron-main/electron-main-application.ts b/packages/core/src/electron-main/electron-main-application.ts
index 3902a91f626f5..c76d083188df3 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, DidCreateWindowDetails, 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,41 @@ 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,
+ };
+ });
+ electronWindow.webContents.on('did-create-window', (newWindow: BrowserWindow, details: DidCreateWindowDetails) => {
+ if (this.isSecondaryWindowUrl(details.url)) {
+ newWindow.setMenuBarVisibility(false);
+ }
+ });
+ }
+
+ /** @returns whether the given url references the html file for creating a secondary window for an extracted widget. */
+ protected isSecondaryWindowUrl(url: string): boolean {
+ return !!url && url.endsWith('secondary-window.html');
+ }
+
/**
* "Gently" close all windows, application will not stop if a `beforeunload` handler returns `false`.
*/
@@ -348,6 +384,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 +404,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 1f62175e69039..0367bd4382130 100644
--- a/packages/navigator/src/browser/open-editors-widget/navigator-open-editors-tree-model.ts
+++ b/packages/navigator/src/browser/open-editors-widget/navigator-open-editors-tree-model.ts
@@ -114,7 +114,7 @@ export class OpenEditorsModel extends FileTreeModel {
this._lastEditorWidgetsByArea = this._editorWidgetsByArea;
this._editorWidgetsByArea = new Map();
let doRebuild = true;
- const areas: ApplicationShell.Area[] = ['main', 'bottom', 'left', 'right', 'top'];
+ const areas: ApplicationShell.Area[] = ['main', 'bottom', 'left', 'right', 'top', 'secondaryWindow'];
areas.forEach(area => {
const editorWidgetsForArea = this.applicationShell.getWidgets(area).filter((widget): widget is NavigatableWidget => NavigatableWidget.is(widget));
if (editorWidgetsForArea.length) {
diff --git a/packages/plugin-ext/src/main/browser/custom-editors/custom-editors-main.ts b/packages/plugin-ext/src/main/browser/custom-editors/custom-editors-main.ts
index f4b8a56a6788f..c223f908521b2 100644
--- a/packages/plugin-ext/src/main/browser/custom-editors/custom-editors-main.ts
+++ b/packages/plugin-ext/src/main/browser/custom-editors/custom-editors-main.ts
@@ -258,7 +258,7 @@ export class CustomEditorsMainImpl implements CustomEditorsMain, Disposable {
area = WebviewPanelTargetArea.Right;
case 'bottom':
area = WebviewPanelTargetArea.Bottom;
- default: // includes 'top'
+ default: // includes 'top' and 'secondaryWindow'
area = WebviewPanelTargetArea.Main;
}
showOptions.area = area;
diff --git a/packages/plugin-ext/src/main/browser/webview/pre/host.js b/packages/plugin-ext/src/main/browser/webview/pre/host.js
index 35f67feb97468..8668c0fd2fd31 100644
--- a/packages/plugin-ext/src/main/browser/webview/pre/host.js
+++ b/packages/plugin-ext/src/main/browser/webview/pre/host.js
@@ -52,6 +52,13 @@
postMessage(channel, data) {
window.parent.postMessage({ target: id, channel, data }, '*');
+ let currentWindow = window;
+ while (currentWindow.parent !== currentWindow) {
+ currentWindow = currentWindow.parent;
+ if (currentWindow.opener) {
+ currentWindow.opener.postMessage({ target: id, channel, data, fromSecondary: true }, '*');
+ }
+ }
}
onMessage(channel, handler) {
diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts
index cb29ab215b878..1461e2ebfcb43 100644
--- a/packages/plugin-ext/src/main/browser/webview/webview.ts
+++ b/packages/plugin-ext/src/main/browser/webview/webview.ts
@@ -49,6 +49,7 @@ import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { FileOperationError, FileOperationResult } from '@theia/filesystem/lib/common/files';
import { BinaryBufferReadableStream } from '@theia/core/lib/common/buffer';
import { ViewColumn } from '../../../plugin/types-impl';
+import { ExtractableWidget } from '@theia/core/lib/browser/widgets/extractable-widget';
// Style from core
const TRANSPARENT_OVERLAY_STYLE = 'theia-transparent-overlay';
@@ -85,7 +86,7 @@ export class WebviewWidgetIdentifier {
export const WebviewWidgetExternalEndpoint = Symbol('WebviewWidgetExternalEndpoint');
@injectable()
-export class WebviewWidget extends BaseWidget implements StatefulWidget {
+export class WebviewWidget extends BaseWidget implements StatefulWidget, ExtractableWidget {
private static readonly standardSupportedLinkSchemes = new Set([
Schemes.http,
@@ -173,6 +174,9 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget {
protected readonly toHide = new DisposableCollection();
protected hideTimeout: any | number | undefined;
+ isExtractable: boolean = true;
+ secondaryWindow: Window | undefined = undefined;
+
@postConstruct()
protected init(): void {
this.node.tabIndex = 0;
@@ -564,7 +568,11 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget {
protected postMessage(channel: string, data?: any): void {
if (this.element) {
this.trace('out', channel, data);
- this.element.contentWindow!.postMessage({ channel, args: data }, '*');
+ if (this.secondaryWindow) {
+ this.secondaryWindow.postMessage({ channel, args: data }, '*');
+ } else {
+ this.element.contentWindow!.postMessage({ channel, args: data }, '*');
+ }
}
}
diff --git a/packages/secondary-window-ui/.eslintrc.js b/packages/secondary-window-ui/.eslintrc.js
new file mode 100644
index 0000000000000..13089943582b6
--- /dev/null
+++ b/packages/secondary-window-ui/.eslintrc.js
@@ -0,0 +1,10 @@
+/** @type {import('eslint').Linter.Config} */
+module.exports = {
+ extends: [
+ '../../configs/build.eslintrc.json'
+ ],
+ parserOptions: {
+ tsconfigRootDir: __dirname,
+ project: 'tsconfig.json'
+ }
+};
diff --git a/packages/secondary-window-ui/README.md b/packages/secondary-window-ui/README.md
new file mode 100644
index 0000000000000..988b16fd08bd6
--- /dev/null
+++ b/packages/secondary-window-ui/README.md
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
ECLIPSE THEIA - WIDGET-EXTRACTION-UI EXTENSION
+
+
+
+
+
+## Description
+
+The `@theia/secondary-window-ui` extension contributes UI integration that allows moving widgets to secondary windows.
+
+_Note_: Currently, only webview widgets can be moved to secondary windows.
+
+## Additional Information
+
+- [Theia - GitHub](https://github.com/eclipse-theia/theia)
+- [Theia - Website](https://theia-ide.org/)
+
+## License
+
+- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
+- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
+
+## Trademark
+
+"Theia" is a trademark of the Eclipse Foundation
+
diff --git a/packages/secondary-window-ui/package.json b/packages/secondary-window-ui/package.json
new file mode 100644
index 0000000000000..18169fa7a3c9d
--- /dev/null
+++ b/packages/secondary-window-ui/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@theia/secondary-window-ui",
+ "version": "1.24.0",
+ "description": "Theia - Secondary window UI contributions",
+ "dependencies": {
+ "@theia/core": "1.24.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "theiaExtensions": [
+ {
+ "frontend": "lib/browser/secondary-window-ui-frontend-module"
+ }
+ ],
+ "keywords": [
+ "theia-extension"
+ ],
+ "license": "EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/eclipse-theia/theia.git"
+ },
+ "bugs": {
+ "url": "https://github.com/eclipse-theia/theia/issues"
+ },
+ "homepage": "https://github.com/eclipse-theia/theia",
+ "files": [
+ "lib",
+ "src"
+ ],
+ "scripts": {
+ "build": "theiaext build",
+ "clean": "theiaext clean",
+ "compile": "theiaext compile",
+ "lint": "theiaext lint",
+ "test": "theiaext test",
+ "watch": "theiaext watch"
+ },
+ "devDependencies": {
+ "@theia/ext-scripts": "1.24.0"
+ }
+}
diff --git a/packages/secondary-window-ui/src/browser/secondary-window-ui-frontend-contribution.ts b/packages/secondary-window-ui/src/browser/secondary-window-ui-frontend-contribution.ts
new file mode 100644
index 0000000000000..5e24d8d69dbc5
--- /dev/null
+++ b/packages/secondary-window-ui/src/browser/secondary-window-ui-frontend-contribution.ts
@@ -0,0 +1,60 @@
+// *****************************************************************************
+// Copyright (C) 2022 STMicroelectronics, Ericsson, ARM, EclipseSource and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// http://www.eclipse.org/legal/epl-2.0.
+//
+// This Source Code may also be made available under the following Secondary
+// Licenses when the conditions for such availability set forth in the Eclipse
+// Public License v. 2.0 are satisfied: GNU General Public License, version 2
+// with the GNU Classpath Exception which is available at
+// https://www.gnu.org/software/classpath/license.html.
+//
+// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
+// *****************************************************************************
+
+import { inject, injectable } from '@theia/core/shared/inversify';
+import { ApplicationShell } from '@theia/core/lib/browser/shell';
+import { CommandRegistry, CommandContribution, Command } from '@theia/core/lib/common/command';
+import { codicon, ExtractableWidget } from '@theia/core/lib/browser/widgets';
+import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
+
+export const EXTRACT_WIDGET = Command.toDefaultLocalizedCommand({
+ id: 'extract-widget',
+ label: 'Move View to Secondary Window'
+});
+
+/** Contributes the widget extraction command and registers it in the toolbar of extractable widgets. */
+@injectable()
+export class SecondaryWindowUiContribution implements CommandContribution, TabBarToolbarContribution {
+
+ @inject(ApplicationShell)
+ protected readonly shell: ApplicationShell;
+
+ registerCommands(commands: CommandRegistry): void {
+ commands.registerCommand(EXTRACT_WIDGET, {
+ execute: async widget => {
+
+ // sanity check
+ if (!ExtractableWidget.is(widget)) {
+ // command executed with a non-extractable widget
+ console.error('Invalid command execution');
+ return;
+ }
+
+ await this.shell.moveWidgetToSecondaryWindow(widget);
+ },
+ isVisible: widget => ExtractableWidget.is(widget),
+ isEnabled: widget => ExtractableWidget.is(widget)
+ });
+ }
+
+ registerToolbarItems(registry: TabBarToolbarRegistry): void {
+ registry.registerItem({
+ id: EXTRACT_WIDGET.id,
+ command: EXTRACT_WIDGET.id,
+ icon: codicon('window'),
+ });
+ }
+}
diff --git a/packages/secondary-window-ui/src/browser/secondary-window-ui-frontend-module.ts b/packages/secondary-window-ui/src/browser/secondary-window-ui-frontend-module.ts
new file mode 100644
index 0000000000000..0326a8d8647a9
--- /dev/null
+++ b/packages/secondary-window-ui/src/browser/secondary-window-ui-frontend-module.ts
@@ -0,0 +1,27 @@
+// *****************************************************************************
+// Copyright (C) 2022 STMicroelectronics, Ericsson, ARM, EclipseSource and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// http://www.eclipse.org/legal/epl-2.0.
+//
+// This Source Code may also be made available under the following Secondary
+// Licenses when the conditions for such availability set forth in the Eclipse
+// Public License v. 2.0 are satisfied: GNU General Public License, version 2
+// with the GNU Classpath Exception which is available at
+// https://www.gnu.org/software/classpath/license.html.
+//
+// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
+// *****************************************************************************
+
+import { ContainerModule } from '@theia/core/shared/inversify';
+import { SecondaryWindowUiContribution } from './secondary-window-ui-frontend-contribution';
+import { CommandContribution } from '@theia/core/lib/common/command';
+import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
+
+export default new ContainerModule(bind => {
+ bind(SecondaryWindowUiContribution).toSelf().inSingletonScope();
+ bind(CommandContribution).toService(SecondaryWindowUiContribution);
+ bind(TabBarToolbarContribution).toService(SecondaryWindowUiContribution);
+});
+
diff --git a/packages/secondary-window-ui/src/package.spec.ts b/packages/secondary-window-ui/src/package.spec.ts
new file mode 100644
index 0000000000000..230369d996150
--- /dev/null
+++ b/packages/secondary-window-ui/src/package.spec.ts
@@ -0,0 +1,19 @@
+// *****************************************************************************
+// Copyright (C) 2022 STMicroelectronics, Ericsson, ARM, EclipseSource and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// http://www.eclipse.org/legal/epl-2.0.
+//
+// This Source Code may also be made available under the following Secondary
+// Licenses when the conditions for such availability set forth in the Eclipse
+// Public License v. 2.0 are satisfied: GNU General Public License, version 2
+// with the GNU Classpath Exception which is available at
+// https://www.gnu.org/software/classpath/license.html.
+//
+// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
+// *****************************************************************************
+
+describe('secondary-window-ui package', () => {
+ it('supports code coverage statistics', () => true);
+});
diff --git a/packages/secondary-window-ui/tsconfig.json b/packages/secondary-window-ui/tsconfig.json
new file mode 100644
index 0000000000000..b623c1e105ac7
--- /dev/null
+++ b/packages/secondary-window-ui/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../../configs/base.tsconfig",
+ "compilerOptions": {
+ "composite": true,
+ "rootDir": "src",
+ "outDir": "lib"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../core"
+ }
+ ]
+}
diff --git a/scripts/patch-libraries.js b/scripts/patch-libraries.js
new file mode 100644
index 0000000000000..c3fbd849a5b92
--- /dev/null
+++ b/scripts/patch-libraries.js
@@ -0,0 +1,33 @@
+#!/usr/bin/env node
+// *****************************************************************************
+// Copyright (C) 2022 Arm 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
+// *****************************************************************************
+// @ts-check
+
+const fs = require('fs');
+
+const patches = [
+ {
+ file: 'node_modules/@phosphor/widgets/lib/widget.js',
+ find: /^.*?'Host is not attached.'.*?$/gm,
+ replace: ''
+ }
+]
+
+for (const patch of patches) {
+ const contents = fs.readFileSync(patch.file).toString();
+ const modified = contents.replace(patch.find, patch.replace);
+ fs.writeFileSync(patch.file, modified);
+}
diff --git a/tsconfig.json b/tsconfig.json
index b9997bb80a9d2..09f0f530d0c13 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -141,6 +141,9 @@
{
"path": "packages/search-in-workspace"
},
+ {
+ "path": "packages/secondary-window-ui"
+ },
{
"path": "packages/task"
},