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 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - WIDGET-EXTRACTION-UI EXTENSION

+ +
+ +
+ +## Description + +The `@theia/secondary-window-ui` extension contributes UI integration that allows moving widgets to secondary windows. + +_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" },