From 7c47bee9eb065331392af85ab0de0dbd742b8b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=A4der?= Date: Fri, 2 Jun 2023 09:50:03 +0200 Subject: [PATCH] Implement "secondary window" support for Electron (#12481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #11642 The main change is to prevent the secondary window from closing until the extracted widget is removed from the window. This includes waiting until any close handling (including dialogs) are finished. To enable this properly, dialog support has been extended to work with secondary windows, including support for the StylingService in secondary windows. Contributed on behalf of STMicroelectronics Signed-off-by: Thomas Mäder --- examples/electron/package.json | 1 + examples/electron/tsconfig.json | 3 + packages/core/src/browser/dialogs.ts | 33 ++++--- packages/core/src/browser/saveable.ts | 4 +- .../src/browser/secondary-window-handler.ts | 45 ++-------- packages/core/src/browser/styling-service.ts | 23 +++-- packages/core/src/browser/widgets/widget.ts | 4 + .../default-secondary-window-service.ts | 85 +++++++++++++++---- .../window/secondary-window-service.ts | 5 +- packages/core/src/electron-browser/preload.ts | 22 ++++- .../electron-secondary-window-service.ts | 10 ++- .../core/src/electron-common/electron-api.ts | 4 + .../src/electron-main/electron-api-main.ts | 20 ++++- .../electron-main/theia-electron-window.ts | 30 +++++++ .../custom-editors/custom-editors-main.ts | 27 ++---- 15 files changed, 215 insertions(+), 101 deletions(-) diff --git a/examples/electron/package.json b/examples/electron/package.json index 6f197f8e1fd60..c25f70b2e9b97 100644 --- a/examples/electron/package.json +++ b/examples/electron/package.json @@ -48,6 +48,7 @@ "@theia/scm": "1.38.0", "@theia/scm-extra": "1.38.0", "@theia/search-in-workspace": "1.38.0", + "@theia/secondary-window": "1.38.0", "@theia/task": "1.38.0", "@theia/terminal": "1.38.0", "@theia/timeline": "1.38.0", diff --git a/examples/electron/tsconfig.json b/examples/electron/tsconfig.json index a9ea549221e47..ccd4b9fbd4d34 100644 --- a/examples/electron/tsconfig.json +++ b/examples/electron/tsconfig.json @@ -107,6 +107,9 @@ { "path": "../../packages/search-in-workspace" }, + { + "path": "../../packages/secondary-window" + }, { "path": "../../packages/task" }, diff --git a/packages/core/src/browser/dialogs.ts b/packages/core/src/browser/dialogs.ts index d33bbbcee77ff..65f420f0f5473 100644 --- a/packages/core/src/browser/dialogs.ts +++ b/packages/core/src/browser/dialogs.ts @@ -84,10 +84,9 @@ export class DialogOverlayService implements FrontendApplicationContribution { // eslint-disable-next-line @typescript-eslint/no-explicit-any protected readonly dialogs: AbstractDialog[] = []; + protected readonly documents: Document[] = []; constructor() { - addKeyListener(document.body, Key.ENTER, e => this.handleEnter(e)); - addKeyListener(document.body, Key.ESCAPE, e => this.handleEscape(e)); } initialize(): void { @@ -101,6 +100,11 @@ export class DialogOverlayService implements FrontendApplicationContribution { // eslint-disable-next-line @typescript-eslint/no-explicit-any push(dialog: AbstractDialog): Disposable { + if (this.documents.findIndex(document => document === dialog.node.ownerDocument) < 0) { + addKeyListener(dialog.node.ownerDocument.body, Key.ENTER, e => this.handleEnter(e)); + addKeyListener(dialog.node.ownerDocument.body, Key.ESCAPE, e => this.handleEscape(e)); + this.documents.push(dialog.node.ownerDocument); + } this.dialogs.unshift(dialog); return Disposable.create(() => { const index = this.dialogs.indexOf(dialog); @@ -147,9 +151,10 @@ export abstract class AbstractDialog extends BaseWidget { protected activeElement: HTMLElement | undefined; constructor( - @inject(DialogProps) protected readonly props: DialogProps + protected readonly props: DialogProps, + options?: Widget.IOptions ) { - super(); + super(options); this.id = 'theia-dialog-shell'; this.addClass('dialogOverlay'); this.toDispose.push(Disposable.create(() => { @@ -157,7 +162,7 @@ export abstract class AbstractDialog extends BaseWidget { Widget.detach(this); } })); - const container = document.createElement('div'); + const container = this.node.ownerDocument.createElement('div'); container.classList.add('dialogBlock'); if (props.maxWidth === undefined) { container.setAttribute('style', 'max-width: none'); @@ -166,31 +171,31 @@ export abstract class AbstractDialog extends BaseWidget { } this.node.appendChild(container); - const titleContentNode = document.createElement('div'); + const titleContentNode = this.node.ownerDocument.createElement('div'); titleContentNode.classList.add('dialogTitle'); container.appendChild(titleContentNode); - this.titleNode = document.createElement('div'); + this.titleNode = this.node.ownerDocument.createElement('div'); this.titleNode.textContent = props.title; titleContentNode.appendChild(this.titleNode); - this.closeCrossNode = document.createElement('i'); + this.closeCrossNode = this.node.ownerDocument.createElement('i'); this.closeCrossNode.classList.add(...codiconArray('close')); this.closeCrossNode.classList.add('closeButton'); titleContentNode.appendChild(this.closeCrossNode); - this.contentNode = document.createElement('div'); + this.contentNode = this.node.ownerDocument.createElement('div'); this.contentNode.classList.add('dialogContent'); if (props.wordWrap !== undefined) { this.contentNode.setAttribute('style', `word-wrap: ${props.wordWrap}`); } container.appendChild(this.contentNode); - this.controlPanel = document.createElement('div'); + this.controlPanel = this.node.ownerDocument.createElement('div'); this.controlPanel.classList.add('dialogControl'); container.appendChild(this.controlPanel); - this.errorMessageNode = document.createElement('div'); + this.errorMessageNode = this.node.ownerDocument.createElement('div'); this.errorMessageNode.classList.add('error'); this.errorMessageNode.setAttribute('style', 'flex: 2'); this.controlPanel.appendChild(this.errorMessageNode); @@ -255,7 +260,7 @@ export abstract class AbstractDialog extends BaseWidget { if (this.resolve) { return Promise.reject(new Error('The dialog is already opened.')); } - this.activeElement = window.document.activeElement as HTMLElement; + this.activeElement = this.node.ownerDocument.activeElement as HTMLElement; return new Promise((resolve, reject) => { this.resolve = resolve; this.reject = reject; @@ -264,7 +269,7 @@ export abstract class AbstractDialog extends BaseWidget { this.reject = undefined; })); - Widget.attach(this, document.body); + Widget.attach(this, this.node.ownerDocument.body); this.activate(); }); } @@ -388,7 +393,7 @@ export class ConfirmDialog extends AbstractDialog { protected createMessageNode(msg: string | HTMLElement): HTMLElement { if (typeof msg === 'string') { - const messageNode = document.createElement('div'); + const messageNode = this.node.ownerDocument.createElement('div'); messageNode.textContent = msg; return messageNode; } diff --git a/packages/core/src/browser/saveable.ts b/packages/core/src/browser/saveable.ts index 4a5c2308edd17..66b4365c38c27 100644 --- a/packages/core/src/browser/saveable.ts +++ b/packages/core/src/browser/saveable.ts @@ -287,9 +287,11 @@ export class ShouldSaveDialog extends AbstractDialog { constructor(widget: Widget) { super({ title: nls.localizeByDefault('Do you want to save the changes you made to {0}?', widget.title.label || widget.title.caption) + }, { + node: widget.node.ownerDocument.createElement('div') }); - const messageNode = document.createElement('div'); + const messageNode = this.node.ownerDocument.createElement('div'); messageNode.textContent = nls.localizeByDefault("Your changes will be lost if you don't save them."); messageNode.setAttribute('style', 'flex: 1 100%; padding-bottom: calc(var(--theia-ui-padding)*3);'); this.contentNode.appendChild(messageNode); diff --git a/packages/core/src/browser/secondary-window-handler.ts b/packages/core/src/browser/secondary-window-handler.ts index 377bce895cbd8..80902bacd809b 100644 --- a/packages/core/src/browser/secondary-window-handler.ts +++ b/packages/core/src/browser/secondary-window-handler.ts @@ -23,6 +23,7 @@ import { Emitter } from '../common/event'; import { SecondaryWindowService } from './window/secondary-window-service'; import { KeybindingRegistry } from './keybinding'; import { ColorApplicationContribution } from './color-application-contribution'; +import { StylingService } from './styling-service'; /** Widget to be contained directly in a secondary window. */ class SecondaryWindowRootWidget extends Widget { @@ -50,8 +51,6 @@ class SecondaryWindowRootWidget extends Widget { */ @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[] = []; @@ -63,6 +62,9 @@ export class SecondaryWindowHandler { @inject(ColorApplicationContribution) protected colorAppContribution: ColorApplicationContribution; + @inject(StylingService) + protected stylingService: StylingService; + 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; @@ -95,33 +97,6 @@ export class SecondaryWindowHandler { 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 }, '*'); - } - }); - }); } /** @@ -139,21 +114,13 @@ export class SecondaryWindowHandler { return; } - const newWindow = this.secondaryWindowService.createSecondaryWindow(closed => { - this.applicationShell.closeWidget(widget.id); - const extIndex = this.secondaryWindows.indexOf(closed); - if (extIndex > -1) { - this.secondaryWindows.splice(extIndex, 1); - } - }); + const newWindow = this.secondaryWindowService.createSecondaryWindow(widget, this.applicationShell); if (!newWindow) { this.messageService.error('The widget could not be moved to a secondary window because the window creation failed. Please make sure to allow popups.'); return; } - this.secondaryWindows.push(newWindow); - const mainWindowTitle = document.title; newWindow.onload = () => { this.keybindings.registerEventListeners(newWindow); @@ -168,6 +135,7 @@ export class SecondaryWindowHandler { return; } const unregisterWithColorContribution = this.colorAppContribution.registerWindow(newWindow); + const unregisterWithStylingService = this.stylingService.registerWindow(newWindow); widget.secondaryWindow = newWindow; const rootWidget = new SecondaryWindowRootWidget(); @@ -182,6 +150,7 @@ export class SecondaryWindowHandler { // Close the window if the widget is disposed, e.g. by a command closing all widgets. widget.disposed.connect(() => { unregisterWithColorContribution.dispose(); + unregisterWithStylingService.dispose(); this.removeWidget(widget); if (!newWindow.closed) { newWindow.close(); diff --git a/packages/core/src/browser/styling-service.ts b/packages/core/src/browser/styling-service.ts index c230940a828c4..ea0406204af8a 100644 --- a/packages/core/src/browser/styling-service.ts +++ b/packages/core/src/browser/styling-service.ts @@ -21,6 +21,7 @@ import { ColorRegistry } from './color-registry'; import { DecorationStyle } from './decoration-style'; import { FrontendApplicationContribution } from './frontend-application'; import { ThemeService } from './theming'; +import { Disposable } from '../common'; export const StylingParticipant = Symbol('StylingParticipant'); @@ -40,8 +41,7 @@ export interface CssStyleCollector { @injectable() export class StylingService implements FrontendApplicationContribution { - - protected cssElement = DecorationStyle.createStyleElement('contributedColorTheme'); + protected cssElements = new Map(); @inject(ThemeService) protected readonly themeService: ThemeService; @@ -53,11 +53,22 @@ export class StylingService implements FrontendApplicationContribution { protected readonly themingParticipants: ContributionProvider; onStart(): void { - this.applyStyling(this.themeService.getCurrentTheme()); - this.themeService.onDidColorThemeChange(e => this.applyStyling(e.newTheme)); + this.registerWindow(window); + this.themeService.onDidColorThemeChange(e => this.applyStylingToWindows(e.newTheme)); + } + + registerWindow(win: Window): Disposable { + const cssElement = DecorationStyle.createStyleElement('contributedColorTheme', win.document.head); + this.cssElements.set(win, cssElement); + this.applyStyling(this.themeService.getCurrentTheme(), cssElement); + return Disposable.create(() => this.cssElements.delete(win)); + } + + protected applyStylingToWindows(theme: Theme): void { + this.cssElements.forEach(cssElement => this.applyStyling(theme, cssElement)); } - protected applyStyling(theme: Theme): void { + protected applyStyling(theme: Theme, cssElement: HTMLStyleElement): void { const rules: string[] = []; const colorTheme: ColorTheme = { type: theme.type, @@ -71,6 +82,6 @@ export class StylingService implements FrontendApplicationContribution { themingParticipant.registerThemeStyle(colorTheme, styleCollector); } const fullCss = rules.join('\n'); - this.cssElement.innerText = fullCss; + cssElement.innerText = fullCss; } } diff --git a/packages/core/src/browser/widgets/widget.ts b/packages/core/src/browser/widgets/widget.ts index 02af3612c6fd8..d57ba6ac44511 100644 --- a/packages/core/src/browser/widgets/widget.ts +++ b/packages/core/src/browser/widgets/widget.ts @@ -115,6 +115,10 @@ export class BaseWidget extends Widget { protected scrollBar?: PerfectScrollbar; protected scrollOptions?: PerfectScrollbar.Options; + constructor(options?: Widget.IOptions) { + super(options); + } + override dispose(): void { if (this.isDisposed) { return; diff --git a/packages/core/src/browser/window/default-secondary-window-service.ts b/packages/core/src/browser/window/default-secondary-window-service.ts index 643afe79d9741..166d5b3de0ccf 100644 --- a/packages/core/src/browser/window/default-secondary-window-service.ts +++ b/packages/core/src/browser/window/default-secondary-window-service.ts @@ -16,6 +16,9 @@ import { inject, injectable, postConstruct } from 'inversify'; import { SecondaryWindowService } from './secondary-window-service'; import { WindowService } from './window-service'; +import { ExtractableWidget } from '../widgets'; +import { ApplicationShell } from '../shell'; +import { Saveable } from '../saveable'; @injectable() export class DefaultSecondaryWindowService implements SecondaryWindowService { @@ -37,6 +40,33 @@ export class DefaultSecondaryWindowService implements SecondaryWindowService { @postConstruct() init(): void { + // 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 @@ -46,33 +76,52 @@ export class DefaultSecondaryWindowService implements SecondaryWindowService { }); } - createSecondaryWindow(onClose?: (closedWin: Window) => void): Window | undefined { - const win = this.doCreateSecondaryWindow(onClose); + createSecondaryWindow(widget: ExtractableWidget, shell: ApplicationShell): Window | undefined { + const win = this.doCreateSecondaryWindow(widget, shell); if (win) { this.secondaryWindows.push(win); + win.addEventListener('close', () => { + const extIndex = this.secondaryWindows.indexOf(win); + if (extIndex > -1) { + this.secondaryWindows.splice(extIndex, 1); + }; + }); } return win; } - protected doCreateSecondaryWindow(onClose?: (closedWin: Window) => void): Window | undefined { - const win = window.open(DefaultSecondaryWindowService.SECONDARY_WINDOW_URL, this.nextWindowId(), 'popup'); - if (win) { - // Add the unload listener after the dom content was loaded because otherwise the unload listener is called already on open in some browsers (e.g. Chrome). - win.addEventListener('DOMContentLoaded', () => { - win.addEventListener('unload', () => { - this.handleWindowClosed(win, onClose); - }); - }); + protected findWindow(windowName: string): Window | undefined { + for (const w of this.secondaryWindows) { + if (w.name === windowName) { + return w; + } } - return win ?? undefined; + return undefined; } - protected handleWindowClosed(win: Window, onClose?: (closedWin: Window) => void): void { - const extIndex = this.secondaryWindows.indexOf(win); - if (extIndex > -1) { - this.secondaryWindows.splice(extIndex, 1); - }; - onClose?.(win); + protected doCreateSecondaryWindow(widget: ExtractableWidget, shell: ApplicationShell): Window | undefined { + const newWindow = window.open(DefaultSecondaryWindowService.SECONDARY_WINDOW_URL, this.nextWindowId(), 'popup') ?? undefined; + if (newWindow) { + newWindow.addEventListener('DOMContentLoaded', () => { + newWindow.addEventListener('beforeunload', evt => { + const saveable = Saveable.get(widget); + const wouldLoseState = !!saveable && saveable.dirty && saveable.autoSave === 'off'; + if (wouldLoseState) { + evt.returnValue = ''; + evt.preventDefault(); + return 'non-empty'; + } + }, { capture: true }); + + newWindow.addEventListener('close', () => { + const saveable = Saveable.get(widget); + shell.closeWidget(widget.id, { + save: !!saveable && saveable.dirty && saveable.autoSave !== 'off' + }); + }); + }); + } + return newWindow; } focus(win: Window): void { diff --git a/packages/core/src/browser/window/secondary-window-service.ts b/packages/core/src/browser/window/secondary-window-service.ts index 784e32bf974e2..9f133ebc21b87 100644 --- a/packages/core/src/browser/window/secondary-window-service.ts +++ b/packages/core/src/browser/window/secondary-window-service.ts @@ -14,6 +14,9 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** +import { ApplicationShell } from '../shell'; +import { ExtractableWidget } from '../widgets'; + export const SecondaryWindowService = Symbol('SecondaryWindowService'); /** @@ -29,7 +32,7 @@ export interface SecondaryWindowService { * @param onClose optional callback that is invoked when the secondary window is closed * @returns the created window or `undefined` if it could not be created */ - createSecondaryWindow(onClose?: (win: Window) => void): Window | undefined; + createSecondaryWindow(widget: ExtractableWidget, shell: ApplicationShell): Window | undefined; /** Handles focussing the given secondary window in the browser and on Electron. */ focus(win: Window): void; diff --git a/packages/core/src/electron-browser/preload.ts b/packages/core/src/electron-browser/preload.ts index 33b816c592aa9..fbae86bffefec 100644 --- a/packages/core/src/electron-browser/preload.ts +++ b/packages/core/src/electron-browser/preload.ts @@ -13,6 +13,7 @@ // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // +import { IpcRendererEvent } from '@theia/electron/shared/electron'; import { Disposable } from '../common/disposable'; import { StopReason } from '../common/frontend-application-state'; import { NativeKeyboardLayout } from '../common/keyboard/keyboard-layout-provider'; @@ -24,7 +25,7 @@ import { CHANNEL_ON_WINDOW_EVENT, CHANNEL_GET_ZOOM_LEVEL, CHANNEL_SET_ZOOM_LEVEL, CHANNEL_IS_FULL_SCREENABLE, CHANNEL_TOGGLE_FULL_SCREEN, CHANNEL_IS_FULL_SCREEN, CHANNEL_SET_MENU_BAR_VISIBLE, CHANNEL_REQUEST_CLOSE, CHANNEL_SET_TITLE_STYLE, CHANNEL_RESTART, CHANNEL_REQUEST_RELOAD, CHANNEL_APP_STATE_CHANGED, CHANNEL_SHOW_ITEM_IN_FOLDER, CHANNEL_READ_CLIPBOARD, CHANNEL_WRITE_CLIPBOARD, - CHANNEL_KEYBOARD_LAYOUT_CHANGED, CHANNEL_IPC_CONNECTION, InternalMenuDto + CHANNEL_KEYBOARD_LAYOUT_CHANGED, CHANNEL_IPC_CONNECTION, InternalMenuDto, CHANNEL_REQUEST_SECONDARY_CLOSE } from '../electron-common/electron-api'; // eslint-disable-next-line import/no-extraneous-dependencies @@ -138,6 +139,25 @@ const api: TheiaCoreAPI = { }); }, + setSecondaryWindowCloseRequestHandler(windowName: string, handler: () => Promise): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const listener: (event: IpcRendererEvent, ...args: any[]) => void = async (event, name, confirmChannel, cancelChannel) => { + if (name === windowName) { + try { + if (await handler()) { + event.sender.send(confirmChannel); + ipcRenderer.removeListener(CHANNEL_REQUEST_SECONDARY_CLOSE, listener); + return; + }; + } catch (e) { + console.warn('exception in close handler ', e); + } + event.sender.send(cancelChannel); + } + }; + ipcRenderer.on(CHANNEL_REQUEST_SECONDARY_CLOSE, listener); + }, + toggleDevTools: function (): void { ipcRenderer.send(CHANNEL_TOGGLE_DEVTOOLS); }, diff --git a/packages/core/src/electron-browser/window/electron-secondary-window-service.ts b/packages/core/src/electron-browser/window/electron-secondary-window-service.ts index fb0aed2ef831a..ea95749c45f5c 100644 --- a/packages/core/src/electron-browser/window/electron-secondary-window-service.ts +++ b/packages/core/src/electron-browser/window/electron-secondary-window-service.ts @@ -16,6 +16,7 @@ import { injectable } from 'inversify'; import { DefaultSecondaryWindowService } from '../../browser/window/default-secondary-window-service'; +import { ApplicationShell, ExtractableWidget } from 'src/browser'; @injectable() export class ElectronSecondaryWindowService extends DefaultSecondaryWindowService { @@ -23,11 +24,16 @@ export class ElectronSecondaryWindowService extends DefaultSecondaryWindowServic window.electronTheiaCore.focusWindow(win.name); } - protected override doCreateSecondaryWindow(onClose?: (closedWin: Window) => void): Window | undefined { - const w = super.doCreateSecondaryWindow(onClose); + protected override doCreateSecondaryWindow(widget: ExtractableWidget, shell: ApplicationShell): Window | undefined { + const w = super.doCreateSecondaryWindow(widget, shell); if (w) { window.electronTheiaCore.setMenuBarVisible(false, w.name); + window.electronTheiaCore.setSecondaryWindowCloseRequestHandler(w.name, () => this.canClose(widget, shell)); } return w; } + private async canClose(widget: ExtractableWidget, shell: ApplicationShell): Promise { + await shell.closeWidget(widget.id, undefined); + return widget.isDisposed; + } } diff --git a/packages/core/src/electron-common/electron-api.ts b/packages/core/src/electron-common/electron-api.ts index 5ebc5cc13d43d..f34cf7fbc2a2b 100644 --- a/packages/core/src/electron-common/electron-api.ts +++ b/packages/core/src/electron-common/electron-api.ts @@ -64,6 +64,8 @@ export interface TheiaCoreAPI { onWindowEvent(event: WindowEvent, handler: () => void): Disposable; setCloseRequestHandler(handler: (reason: StopReason) => Promise): void; + setSecondaryWindowCloseRequestHandler(windowName: string, handler: () => Promise): void; + toggleDevTools(): void; getZoomLevel(): Promise; setZoomLevel(desired: number): void; @@ -121,6 +123,8 @@ export const CHANNEL_IS_FULL_SCREENABLE = 'IsFullScreenable'; export const CHANNEL_IS_FULL_SCREEN = 'IsFullScreen'; export const CHANNEL_TOGGLE_FULL_SCREEN = 'ToggleFullScreen'; +export const CHANNEL_REQUEST_SECONDARY_CLOSE = 'RequestSecondaryClose'; + export const CHANNEL_REQUEST_CLOSE = 'RequestClose'; export const CHANNEL_REQUEST_RELOAD = 'RequestReload'; export const CHANNEL_RESTART = 'Restart'; diff --git a/packages/core/src/electron-main/electron-api-main.ts b/packages/core/src/electron-main/electron-api-main.ts index 0061c4acb1b2f..37b3e0a037ad7 100644 --- a/packages/core/src/electron-main/electron-api-main.ts +++ b/packages/core/src/electron-main/electron-api-main.ts @@ -49,7 +49,8 @@ import { InternalMenuDto, CHANNEL_SET_MENU_BAR_VISIBLE, CHANNEL_TOGGLE_FULL_SCREEN, - CHANNEL_IS_MAXIMIZED + CHANNEL_IS_MAXIMIZED, + CHANNEL_REQUEST_SECONDARY_CLOSE } from '../electron-common/electron-api'; import { ElectronMainApplication, ElectronMainApplicationContribution } from './electron-main-application'; import { Disposable, DisposableCollection, isOSX, MaybePromise } from '../common'; @@ -267,6 +268,23 @@ export namespace TheiaRendererAPI { }).finally(() => disposables.dispose()); } + export function requestSecondaryClose(mainWindow: WebContents, secondaryWindow: WebContents): Promise { + const channelNr = nextReplyChannel++; + const confirmChannel = `confirm-${channelNr}`; + const cancelChannel = `cancel-${channelNr}`; + const disposables = new DisposableCollection(); + + return new Promise(resolve => { + mainWindow.send(CHANNEL_REQUEST_SECONDARY_CLOSE, secondaryWindow.mainFrame.name, confirmChannel, cancelChannel); + createDisposableListener(ipcMain, confirmChannel, e => { + resolve(true); + }, disposables); + createDisposableListener(ipcMain, cancelChannel, e => { + resolve(false); + }, disposables); + }).finally(() => disposables.dispose()); + } + export function onRequestReload(wc: WebContents, handler: () => void): Disposable { return createWindowListener(wc, CHANNEL_REQUEST_RELOAD, handler); } diff --git a/packages/core/src/electron-main/theia-electron-window.ts b/packages/core/src/electron-main/theia-electron-window.ts index a61be0ac2b5d8..07abf5592d548 100644 --- a/packages/core/src/electron-main/theia-electron-window.ts +++ b/packages/core/src/electron-main/theia-electron-window.ts @@ -44,6 +44,12 @@ export const TheiaBrowserWindowOptions = Symbol('TheiaBrowserWindowOptions'); export const WindowApplicationConfig = Symbol('WindowApplicationConfig'); export type WindowApplicationConfig = FrontendApplicationConfig; +enum ClosingState { + initial, + inProgress, + readyToClose +} + @injectable() export class TheiaElectronWindow { @inject(TheiaBrowserWindowOptions) protected readonly options: TheiaBrowserWindowOptions; @@ -75,8 +81,32 @@ export class TheiaElectronWindow { this.attachCloseListeners(); this.trackApplicationState(); this.attachReloadListener(); + this.attachSecondaryWindowListener(); } + protected attachSecondaryWindowListener(): void { + createDisposableListener(this._window.webContents, 'did-create-window', (newWindow: BrowserWindow) => { + let closingState = ClosingState.initial; + newWindow.on('close', event => { + if (closingState === ClosingState.initial) { + closingState = ClosingState.inProgress; + event.preventDefault(); + TheiaRendererAPI.requestSecondaryClose(this._window.webContents, newWindow.webContents).then(shouldClose => { + if (shouldClose) { + closingState = ClosingState.readyToClose; + newWindow.close(); + } else { + closingState = ClosingState.initial; + } + }); + } else if (closingState === ClosingState.inProgress) { + // When the extracted widget is disposed programmatically, a dispose listener on it will try to close the window. + // if we dispose the widget because of closing the window, we'll get a recursive call to window.close() + event.preventDefault(); + } + }); + }); + } /** * Only show the window when the content is ready. */ 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 6886ab2faab1e..ebe1bbd8dea28 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 @@ -185,7 +185,7 @@ export class CustomEditorsMainImpl implements CustomEditorsMain, Disposable { switch (modelType) { case CustomEditorModelType.Text: { - const model = CustomTextEditorModel.create(viewType, resource, this.textModelService, this.fileService, this.editorPreferences); + const model = CustomTextEditorModel.create(viewType, resource, this.textModelService, this.fileService); return this.customEditorService.models.add(resource, viewType, model); } case CustomEditorModelType.Custom: { @@ -521,19 +521,16 @@ export class CustomTextEditorModel implements CustomEditorModel { private readonly toDispose = new DisposableCollection(); private readonly onDirtyChangedEmitter = new Emitter(); readonly onDirtyChanged = this.onDirtyChangedEmitter.event; - autoSave: 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange'; - autoSaveDelay: number; static async create( viewType: string, resource: TheiaURI, editorModelService: EditorModelService, fileService: FileService, - editorPreferences: EditorPreferences, ): Promise { const model = await editorModelService.createModelReference(resource); model.object.suppressOpenEditorWhenDirty = true; - return new CustomTextEditorModel(viewType, resource, model, fileService, editorPreferences); + return new CustomTextEditorModel(viewType, resource, model, fileService); } constructor( @@ -541,7 +538,6 @@ export class CustomTextEditorModel implements CustomEditorModel { readonly editorResource: TheiaURI, private readonly model: Reference, private readonly fileService: FileService, - private readonly editorPreferences: EditorPreferences ) { this.toDispose.push( this.editorTextModel.onDirtyChanged(e => { @@ -549,21 +545,14 @@ export class CustomTextEditorModel implements CustomEditorModel { }) ); this.toDispose.push(this.onDirtyChangedEmitter); + } - this.autoSave = this.editorPreferences.get('files.autoSave', undefined, editorResource.toString()); - this.autoSaveDelay = this.editorPreferences.get('files.autoSaveDelay', undefined, editorResource.toString()); + get autoSave(): 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange' { + return this.editorTextModel.autoSave; + } - this.toDispose.push( - this.editorPreferences.onPreferenceChanged(event => { - if (event.preferenceName === 'files.autoSave') { - this.autoSave = this.editorPreferences.get('files.autoSave', undefined, editorResource.toString()); - } - if (event.preferenceName === 'files.autoSaveDelay') { - this.autoSaveDelay = this.editorPreferences.get('files.autoSaveDelay', undefined, editorResource.toString()); - } - }) - ); - this.toDispose.push(this.onDirtyChangedEmitter); + get autoSaveDelay(): number { + return this.editorTextModel.autoSaveDelay; } dispose(): void {