From 93d63d60e08309cbed1f035a4ec011854783a90f Mon Sep 17 00:00:00 2001 From: Lucas Koehler Date: Wed, 20 Apr 2022 16:46:15 +0200 Subject: [PATCH] Multi window support for web views Support for moving webview-based views into a secondary window or tab. For webview-based views a new button becomes available in the toolbar of the view to move the view to a secondary window. This is only support for webview-based views and only one view can be moved into the secondary window. There can be multiple secondary windows though. Primary code changes: - Add concept of extractable widgets. Only widgets implementing the interface can be extracted. - Add SecondaryWindowHandler that encapsulates logic to move widgets to new windows. - Only webviews can be extracted - Configure opened secondary windows in electron - Hide electron menu - Always use the native window frame for secondary windows to get window controls - Do not show secondary window icon if main window uses a custom title bar - Contribute widget extraction button in a separate new extension `widget-extraction-ui` - Extend application shell areas with a `secondaryWindow` area that contains all extracted widgets - Extend frontend and webpack generators to generate the base html for secondary windows and copy it to the lib folder - Bridge plugin communication securely via secure messaging between external webview and Theia: Webviews only accept messages from its direct parent window. This is necessary to avoid Cross Site Scripting (XSS). To make the messaging work in secondary windows, messages are sent to the external widget and then delegated to the webview's iframe. Thereby, the secondary window only accepts messages from its opener which is the theia main window. To achieve this, a webview knows the secondary window it is in (if any). It then sends messages to this window instead of directly to the webview iframe. - Patch phosphor library during install via a postinstall hook. Remove check that widget attachment target must be in same DOM. Contributed on behalf of ST Microelectronics and Ericsson and by ARM and EclipseSource. Co-authored-by: Stefan Dirix Co-authored-by: robmor01 Signed-off-by: Lucas Koehler --- CHANGELOG.md | 1 + .../src/generator/frontend-generator.ts | 45 ++++ .../src/generator/webpack-generator.ts | 7 + examples/browser/package.json | 1 + examples/browser/tsconfig.json | 3 + examples/electron/package.json | 1 + examples/electron/tsconfig.json | 3 + package.json | 1 + .../browser/frontend-application-module.ts | 3 + .../src/browser/secondary-window-handler.ts | 243 ++++++++++++++++++ .../src/browser/shell/application-shell.ts | 94 ++++--- .../src/browser/widgets/extractable-widget.ts | 33 +++ packages/core/src/browser/widgets/index.ts | 1 + .../electron-main-application.ts | 54 +++- .../navigator-open-editors-tree-model.ts | 2 +- .../custom-editors/custom-editors-main.ts | 2 +- .../src/main/browser/webview/pre/host.js | 7 + .../src/main/browser/webview/webview.ts | 12 +- packages/secondary-window-ui/.eslintrc.js | 10 + packages/secondary-window-ui/README.md | 32 +++ packages/secondary-window-ui/package.json | 43 ++++ ...condary-window-ui-frontend-contribution.ts | 60 +++++ .../secondary-window-ui-frontend-module.ts | 27 ++ .../secondary-window-ui/src/package.spec.ts | 19 ++ packages/secondary-window-ui/tsconfig.json | 16 ++ scripts/patch-libraries.js | 33 +++ tsconfig.json | 3 + 27 files changed, 716 insertions(+), 40 deletions(-) create mode 100644 packages/core/src/browser/secondary-window-handler.ts create mode 100644 packages/core/src/browser/widgets/extractable-widget.ts create mode 100644 packages/secondary-window-ui/.eslintrc.js create mode 100644 packages/secondary-window-ui/README.md create mode 100644 packages/secondary-window-ui/package.json create mode 100644 packages/secondary-window-ui/src/browser/secondary-window-ui-frontend-contribution.ts create mode 100644 packages/secondary-window-ui/src/browser/secondary-window-ui-frontend-module.ts create mode 100644 packages/secondary-window-ui/src/package.spec.ts create mode 100644 packages/secondary-window-ui/tsconfig.json create mode 100644 scripts/patch-libraries.js 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" },