From 164d672446ffa86993c9a172d79e7e47f060de9a Mon Sep 17 00:00:00 2001 From: Abhishar Sinha Date: Sat, 11 Dec 2021 03:05:18 +0530 Subject: [PATCH] Revert "Revert "Merge branch 'master' into removed_activateTerminal_method"" This reverts commit 8836604187c5e70d763f4246973e85448ac635df. --- CHANGELOG.md | 6 ++ .../browser/common-frontend-contribution.ts | 29 ++++-- packages/core/src/browser/dialogs.ts | 9 ++ .../core/src/browser/frontend-application.ts | 21 ++++- .../browser/shell/shell-layout-restorer.ts | 18 ++-- .../window/default-window-service.spec.ts | 6 +- .../browser/window/default-window-service.ts | 82 ++++++++++++++--- .../window/test/mock-window-service.ts | 4 +- .../core/src/browser/window/window-service.ts | 30 +++++-- .../menu/electron-menu-contribution.ts | 7 +- .../window/electron-window-service.ts | 62 +++++-------- .../messaging/electron-messages.ts | 29 ++++++ .../electron-main-application.ts | 89 +++++++++++++++---- .../plugin-vscode-commands-contribution.ts | 5 +- .../browser/terminal-frontend-contribution.ts | 40 +++++++-- 15 files changed, 334 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73438c64c3130..e0c8e08038900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - [scripts] added Electron frontend start-up performance measurement script [#10442](https://github.com/eclipse-theia/theia/pull/10442) - Contributed on behalf of STMicroelectronics - [core, editor, editor-preview] additional commands added to tabbar context menu for editor widgets. [#10394](https://github.com/eclipse-theia/theia/pull/10394) - [preferences] Updated `AbstractResourcePreferenceProvider` to handle multiple preference settings in the same tick and handle open preference files. It will save the file exactly once, and prompt the user if the file is dirty when a programmatic setting is attempted. [#7775](https://github.com/eclipse-theia/theia/pull/7775) +- [core] `WindowService` and `ElectronMainApplication` updated to allow for asynchronous pre-exit code in Electron. [#10379](https://github.com/eclipse-theia/theia/pull/10379) [Breaking Changes:](#breaking_changes_1.21.0) @@ -15,6 +16,11 @@ to your application package's root. - [core] `SelectionService` added to constructor arguments of `TabBarRenderer`. [#10394](https://github.com/eclipse-theia/theia/pull/10394) - [preferences] Removed `PreferenceProvider#pendingChanges` field. It was previously set unreliably and caused race conditions. If a `PreferenceProvider` needs a mechanism for deferring the resolution of `PreferenceProvider#setPreference`, it should implement its own system. See PR for example implementation in `AbstractResourcePreferenceProvider`. [#7775](https://github.com/eclipse-theia/theia/pull/7775) +- [core] `WindowService` interface changed considerably [#10379](https://github.com/eclipse-theia/theia/pull/10379) + - remove `canUnload(): boolean`- it's replaced by `isSafeToShutDown(): Promise` to allow asynchronous handling in Electron. + - add `isSafeToShutDown()` - replaces `canUnload()`. + - add `setSafeToShutDown()` - ensures that next close event will not be prevented. + - add `reload()` - to allow different handling in Electron and browser. - [terminal] removed deprecated `activateTerminal` method in favor of `open`. [#10529](https://github.com/eclipse-theia/theia/pull/10529) ## v1.20.0 - 11/25/2021 diff --git a/packages/core/src/browser/common-frontend-contribution.ts b/packages/core/src/browser/common-frontend-contribution.ts index aa85276086ef6..9711a4145afb9 100644 --- a/packages/core/src/browser/common-frontend-contribution.ts +++ b/packages/core/src/browser/common-frontend-contribution.ts @@ -20,7 +20,7 @@ import debounce = require('lodash.debounce'); import { injectable, inject, optional } from 'inversify'; import { MAIN_MENU_BAR, SETTINGS_MENU, MenuContribution, MenuModelRegistry, ACCOUNTS_MENU } from '../common/menu'; import { KeybindingContribution, KeybindingRegistry } from './keybinding'; -import { FrontendApplication, FrontendApplicationContribution } from './frontend-application'; +import { FrontendApplication, FrontendApplicationContribution, OnWillStopAction } from './frontend-application'; import { CommandContribution, CommandRegistry, Command } from '../common/command'; import { UriAwareCommandHandler } from '../common/uri-command-handler'; import { SelectionService } from '../common/selection-service'; @@ -55,6 +55,9 @@ import { QuickInputService, QuickPick, QuickPickItem } from './quick-input'; import { AsyncLocalizationProvider } from '../common/i18n/localization'; import { nls } from '../common/nls'; import { CurrentWidgetCommandAdapter } from './shell/current-widget-command-adapter'; +import { ConfirmDialog, confirmExit, Dialog } from './dialogs'; +import { WindowService } from './window/window-service'; +import { FrontendApplicationConfigProvider } from './frontend-application-config-provider'; export namespace CommonMenus { @@ -353,6 +356,9 @@ export class CommonFrontendContribution implements FrontendApplicationContributi @inject(AuthenticationService) protected readonly authenticationService: AuthenticationService; + @inject(WindowService) + protected readonly windowService: WindowService; + async configure(app: FrontendApplication): Promise { const configDirUri = await this.environments.getConfigDirUri(); // Global settings @@ -967,10 +973,10 @@ export class CommonFrontendContribution implements FrontendApplicationContributi }); } - onWillStop(): true | undefined { + onWillStop(): OnWillStopAction | undefined { try { if (this.shouldPreventClose || this.shell.canSaveAll()) { - return true; + return { reason: 'Dirty editors present', action: () => confirmExit() }; } } finally { this.shouldPreventClose = false; @@ -983,10 +989,11 @@ export class CommonFrontendContribution implements FrontendApplicationContributi for (const additionalLanguage of ['en', ...availableLanguages]) { items.push({ label: additionalLanguage, - execute: () => { - if (additionalLanguage !== nls.locale) { + execute: async () => { + if (additionalLanguage !== nls.locale && await this.confirmRestart()) { + this.windowService.setSafeToShutDown(); window.localStorage.setItem(nls.localeId, additionalLanguage); - window.location.reload(); + this.windowService.reload(); } } }); @@ -998,6 +1005,16 @@ export class CommonFrontendContribution implements FrontendApplicationContributi }); } + protected async confirmRestart(): Promise { + const shouldRestart = await new ConfirmDialog({ + title: nls.localizeByDefault('A restart is required for the change in display language to take effect.'), + msg: nls.localizeByDefault('Press the restart button to restart {0} and change the display language.', FrontendApplicationConfigProvider.get().applicationName), + ok: nls.localizeByDefault('Restart'), + cancel: Dialog.CANCEL, + }).open(); + return shouldRestart === true; + } + protected selectIconTheme(): void { let resetTo: string | undefined = this.iconThemes.current; const previewTheme = debounce((id: string) => this.iconThemes.current = id, 200); diff --git a/packages/core/src/browser/dialogs.ts b/packages/core/src/browser/dialogs.ts index 45be8358f8e18..6bac5f3900159 100644 --- a/packages/core/src/browser/dialogs.ts +++ b/packages/core/src/browser/dialogs.ts @@ -385,7 +385,16 @@ export class ConfirmDialog extends AbstractDialog { } return msg; } +} +export async function confirmExit(): Promise { + const safeToExit = await new ConfirmDialog({ + title: nls.localize('theia/core/quitTitle', 'Are you sure you want to quit?'), + msg: nls.localize('theia/core/quitMessage', 'Any unsaved changes will not be saved.'), + ok: Dialog.YES, + cancel: Dialog.NO, + }).open(); + return safeToExit === true; } @injectable() diff --git a/packages/core/src/browser/frontend-application.ts b/packages/core/src/browser/frontend-application.ts index 567d4c32f3549..9d2e4ad62f6cc 100644 --- a/packages/core/src/browser/frontend-application.ts +++ b/packages/core/src/browser/frontend-application.ts @@ -52,10 +52,10 @@ export interface FrontendApplicationContribution { /** * Called on `beforeunload` event, right before the window closes. - * Return `true` in order to prevent exit. + * Return `true` or an OnWillStopAction in order to prevent exit. * Note: No async code allowed, this function has to run on one tick. */ - onWillStop?(app: FrontendApplication): boolean | void; + onWillStop?(app: FrontendApplication): boolean | undefined | OnWillStopAction; /** * Called when an application is stopped or unloaded. @@ -77,6 +77,23 @@ export interface FrontendApplicationContribution { onDidInitializeLayout?(app: FrontendApplication): MaybePromise; } +export interface OnWillStopAction { + /** + * @resolves to `true` if it is safe to close the application; `false` otherwise. + */ + action: () => MaybePromise; + /** + * A descriptive string for the reason preventing close. + */ + reason: string; +} + +export namespace OnWillStopAction { + export function is(candidate: unknown): candidate is OnWillStopAction { + return typeof candidate === 'object' && !!candidate && 'action' in candidate && 'reason' in candidate; + } +} + const TIMER_WARNING_THRESHOLD = 100; /** diff --git a/packages/core/src/browser/shell/shell-layout-restorer.ts b/packages/core/src/browser/shell/shell-layout-restorer.ts index d8af37ad78df8..8fa6e0def5f0e 100644 --- a/packages/core/src/browser/shell/shell-layout-restorer.ts +++ b/packages/core/src/browser/shell/shell-layout-restorer.ts @@ -26,6 +26,7 @@ import { ContributionProvider } from '../../common/contribution-provider'; import { MaybePromise } from '../../common/types'; import { ApplicationShell, applicationShellLayoutVersion, ApplicationShellLayoutVersion } from './application-shell'; import { CommonCommands } from '../common-frontend-contribution'; +import { WindowService } from '../window/window-service'; /** * A contract for widgets that want to store and restore their inner state, between sessions. @@ -125,6 +126,9 @@ export class ShellLayoutRestorer implements CommandContribution { @inject(ContributionProvider) @named(ApplicationShellLayoutMigration) protected readonly migrations: ContributionProvider; + @inject(WindowService) + protected readonly windowService: WindowService; + constructor( @inject(WidgetManager) protected widgetManager: WidgetManager, @inject(ILogger) protected logger: ILogger, @@ -137,12 +141,14 @@ export class ShellLayoutRestorer implements CommandContribution { } protected async resetLayout(): Promise { - this.logger.info('>>> Resetting layout...'); - this.shouldStoreLayout = false; - this.storageService.setData(this.storageKey, undefined); - ThemeService.get().reset(); // Theme service cannot use DI, so the current theme ID is stored elsewhere. Hence the explicit reset. - this.logger.info('<<< The layout has been successfully reset.'); - window.location.reload(true); + if (await this.windowService.isSafeToShutDown()) { + this.logger.info('>>> Resetting layout...'); + this.shouldStoreLayout = false; + this.storageService.setData(this.storageKey, undefined); + ThemeService.get().reset(); // Theme service cannot use DI, so the current theme ID is stored elsewhere. Hence the explicit reset. + this.logger.info('<<< The layout has been successfully reset.'); + this.windowService.reload(); + } } storeLayout(app: FrontendApplication): void { diff --git a/packages/core/src/browser/window/default-window-service.spec.ts b/packages/core/src/browser/window/default-window-service.spec.ts index 8656a5601086f..5fcd323df2696 100644 --- a/packages/core/src/browser/window/default-window-service.spec.ts +++ b/packages/core/src/browser/window/default-window-service.spec.ts @@ -51,7 +51,7 @@ describe('DefaultWindowService', () => { ]; const windowService = setupWindowService('never', frontendContributions); assert(frontendContributions.every(contribution => !contribution.onWillStopCalled), 'contributions should not be called yet'); - assert(windowService.canUnload(), 'canUnload should return true'); + assert(windowService['collectContributionUnloadVetoes']().length === 0, 'there should be no vetoes'); assert(frontendContributions.every(contribution => contribution.onWillStopCalled), 'contributions should have been called'); }); it('onWillStop should be called on every contribution (ifRequired)', () => { @@ -62,7 +62,7 @@ describe('DefaultWindowService', () => { ]; const windowService = setupWindowService('ifRequired', frontendContributions); assert(frontendContributions.every(contribution => !contribution.onWillStopCalled), 'contributions should not be called yet'); - assert(!windowService.canUnload(), 'canUnload should return false'); + assert(windowService['collectContributionUnloadVetoes']().length > 0, 'There should be vetoes'); assert(frontendContributions.every(contribution => contribution.onWillStopCalled), 'contributions should have been called'); }); it('onWillStop should be called on every contribution (always)', () => { @@ -72,7 +72,7 @@ describe('DefaultWindowService', () => { ]; const windowService = setupWindowService('always', frontendContributions); assert(frontendContributions.every(contribution => !contribution.onWillStopCalled), 'contributions should not be called yet'); - assert(!windowService.canUnload(), 'canUnload should return false'); + assert(windowService['collectContributionUnloadVetoes']().length > 0, 'there should be vetoes'); assert(frontendContributions.every(contribution => contribution.onWillStopCalled), 'contributions should have been called'); }); }); diff --git a/packages/core/src/browser/window/default-window-service.ts b/packages/core/src/browser/window/default-window-service.ts index c170d64277caf..82bba3a90fbe2 100644 --- a/packages/core/src/browser/window/default-window-service.ts +++ b/packages/core/src/browser/window/default-window-service.ts @@ -18,14 +18,16 @@ import { inject, injectable, named } from 'inversify'; import { Event, Emitter } from '../../common'; import { CorePreferences } from '../core-preferences'; import { ContributionProvider } from '../../common/contribution-provider'; -import { FrontendApplicationContribution, FrontendApplication } from '../frontend-application'; +import { FrontendApplicationContribution, FrontendApplication, OnWillStopAction } from '../frontend-application'; import { WindowService } from './window-service'; import { DEFAULT_WINDOW_HASH } from '../../common/window'; +import { confirmExit } from '../dialogs'; @injectable() export class DefaultWindowService implements WindowService, FrontendApplicationContribution { protected frontendApplication: FrontendApplication; + protected allowVetoes = true; protected onUnloadEmitter = new Emitter(); get onUnload(): Event { @@ -53,26 +55,40 @@ export class DefaultWindowService implements WindowService, FrontendApplicationC this.openNewWindow(`#${DEFAULT_WINDOW_HASH}`); } - canUnload(): boolean { - const confirmExit = this.corePreferences['application.confirmExit']; - let preventUnload = confirmExit === 'always'; - for (const contribution of this.contributions.getContributions()) { - if (contribution.onWillStop?.(this.frontendApplication)) { - preventUnload = true; + /** + * Returns a list of actions that {@link FrontendApplicationContribution}s would like to take before shutdown + * It is expected that this will succeed - i.e. return an empty array - at most once per session. If no vetoes are received + * during any cycle, no further checks will be made. In that case, shutdown should proceed unconditionally. + */ + protected collectContributionUnloadVetoes(): OnWillStopAction[] { + const vetoes = []; + if (this.allowVetoes) { + const shouldConfirmExit = this.corePreferences['application.confirmExit']; + for (const contribution of this.contributions.getContributions()) { + const veto = contribution.onWillStop?.(this.frontendApplication); + if (veto && shouldConfirmExit !== 'never') { // Ignore vetoes if we should not prompt the user on exit. + if (OnWillStopAction.is(veto)) { + vetoes.push(veto); + } else { + vetoes.push({ reason: 'No reason given', action: () => false }); + } + } + } + if (vetoes.length === 0 && shouldConfirmExit === 'always') { + vetoes.push({ reason: 'application.confirmExit preference', action: () => confirmExit() }); + } + if (vetoes.length === 0) { + this.allowVetoes = false; } } - return confirmExit === 'never' || !preventUnload; + return vetoes; } /** * Implement the mechanism to detect unloading of the page. */ protected registerUnloadListeners(): void { - window.addEventListener('beforeunload', event => { - if (!this.canUnload()) { - return this.preventUnload(event); - } - }); + window.addEventListener('beforeunload', event => this.handleBeforeUnloadEvent(event)); // In a browser, `unload` is correctly fired when the page unloads, unlike Electron. // If `beforeunload` is cancelled, the user will be prompted to leave or stay. // If the user stays, the page won't be unloaded, so `unload` is not fired. @@ -80,6 +96,43 @@ export class DefaultWindowService implements WindowService, FrontendApplicationC window.addEventListener('unload', () => this.onUnloadEmitter.fire()); } + async isSafeToShutDown(): Promise { + const vetoes = this.collectContributionUnloadVetoes(); + if (vetoes.length === 0) { + return true; + } + console.debug('Shutdown prevented by', vetoes.map(({ reason }) => reason).join(', ')); + const resolvedVetoes = await Promise.allSettled(vetoes.map(({ action }) => action())); + if (resolvedVetoes.every(resolution => resolution.status === 'rejected' || resolution.value === true)) { + console.debug('OnWillStop actions resolved; allowing shutdown'); + this.allowVetoes = false; + return true; + } else { + return false; + } + } + + setSafeToShutDown(): void { + this.allowVetoes = false; + } + + /** + * Called when the `window` is about to `unload` its resources. + * At this point, the `document` is still visible and the [`BeforeUnloadEvent`](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event) + * event will be canceled if the return value of this method is `false`. + * + * In Electron, handleCloseRequestEvent is is run instead. + */ + protected handleBeforeUnloadEvent(event: BeforeUnloadEvent): string | void { + const vetoes = this.collectContributionUnloadVetoes(); + if (vetoes.length) { + // In the browser, we don't call the functions because this has to finish in a single tick, so we treat any desired action as a veto. + console.debug('Shutdown prevented by', vetoes.map(({ reason }) => reason).join(', ')); + return this.preventUnload(event); + } + console.debug('Shutdown will proceed.'); + } + /** * Notify the browser that we do not want to unload. * @@ -95,4 +148,7 @@ export class DefaultWindowService implements WindowService, FrontendApplicationC return ''; } + reload(): void { + window.location.reload(); + } } diff --git a/packages/core/src/browser/window/test/mock-window-service.ts b/packages/core/src/browser/window/test/mock-window-service.ts index 94c0da87dbb61..07b06443bfe08 100644 --- a/packages/core/src/browser/window/test/mock-window-service.ts +++ b/packages/core/src/browser/window/test/mock-window-service.ts @@ -21,6 +21,8 @@ import { WindowService } from '../window-service'; export class MockWindowService implements WindowService { openNewWindow(): undefined { return undefined; } openNewDefaultWindow(): void { } - canUnload(): boolean { return true; } + reload(): void { } + isSafeToShutDown(): Promise { return Promise.resolve(true); } + setSafeToShutDown(): void { } get onUnload(): Event { return Event.None; } } diff --git a/packages/core/src/browser/window/window-service.ts b/packages/core/src/browser/window/window-service.ts index 86a58cb8f8e97..8807b09f11b13 100644 --- a/packages/core/src/browser/window/window-service.ts +++ b/packages/core/src/browser/window/window-service.ts @@ -23,7 +23,6 @@ import { NewWindowOptions } from '../../common/window'; export const WindowService = Symbol('WindowService'); export interface WindowService { - /** * Opens a new window and loads the content from the given URL. * In a browser, opening a new Theia tab or open a link is the same thing. @@ -37,17 +36,32 @@ export interface WindowService { */ openNewDefaultWindow(): void; - /** - * Called when the `window` is about to `unload` its resources. - * At this point, the `document` is still visible and the [`BeforeUnloadEvent`](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event) - * event will be canceled if the return value of this method is `false`. - */ - canUnload(): boolean; - /** * Fires when the `window` unloads. The unload event is inevitable. On this event, the frontend application can save its state and release resource. * Saving the state and releasing any resources must be a synchronous call. Any asynchronous calls invoked after emitting this event might be ignored. */ readonly onUnload: Event; + /** + * Checks `FrontendApplicationContribution#willStop` for impediments to shutdown and runs any actions returned. + * Can be used safely in browser and Electron when triggering reload or shutdown programmatically. + * Should _only_ be called before a shutdown - if this returns `true`, `FrontendApplicationContribution#willStop` + * will not be called again in the current session. I.e. if this return `true`, the shutdown should proceed without + * further condition. + */ + isSafeToShutDown(): Promise; + + /** + * Will prevent subsequent checks of `FrontendApplicationContribution#willStop`. Should only be used after requesting + * user confirmation. + * + * This is primarily intended programmatic restarts due to e.g. change of display language. It allows for a single confirmation + * of intent, rather than one warning and then several warnings from other contributions. + */ + setSafeToShutDown(): void; + + /** + * Reloads the window according to platform. + */ + reload(): void; } diff --git a/packages/core/src/electron-browser/menu/electron-menu-contribution.ts b/packages/core/src/electron-browser/menu/electron-menu-contribution.ts index 912c5657536a0..61ddf4246ea0c 100644 --- a/packages/core/src/electron-browser/menu/electron-menu-contribution.ts +++ b/packages/core/src/electron-browser/menu/electron-menu-contribution.ts @@ -22,7 +22,7 @@ import { } from '../../common'; import { ApplicationShell, codicon, ConfirmDialog, KeybindingContribution, KeybindingRegistry, - PreferenceScope, Widget, FrontendApplication, FrontendApplicationContribution, CommonMenus, CommonCommands, Dialog + PreferenceScope, Widget, FrontendApplication, FrontendApplicationContribution, CommonMenus, CommonCommands, Dialog, } from '../../browser'; import { ElectronMainMenuFactory } from './electron-main-menu-factory'; import { FrontendApplicationStateService, FrontendApplicationState } from '../../browser/frontend-application-state'; @@ -30,6 +30,7 @@ import { FrontendApplicationConfigProvider } from '../../browser/frontend-applic import { RequestTitleBarStyle, Restart, TitleBarStyleAtStartup, TitleBarStyleChanged } from '../../electron-common/messaging/electron-messages'; import { ZoomLevel } from '../window/electron-window-preferences'; import { BrowserMenuBarContribution } from '../../browser/menu/browser-menu-plugin'; +import { WindowService } from '../../browser/window/window-service'; import '../../../src/electron-browser/menu/electron-menu-style.css'; @@ -84,6 +85,9 @@ export class ElectronMenuContribution extends BrowserMenuBarContribution impleme @inject(FrontendApplicationStateService) protected readonly stateService: FrontendApplicationStateService; + @inject(WindowService) + protected readonly windowService: WindowService; + protected titleBarStyleChangeFlag = false; protected titleBarStyle?: string; @@ -241,6 +245,7 @@ export class ElectronMenuContribution extends BrowserMenuBarContribution impleme cancel: Dialog.CANCEL }); if (await dialog.open()) { + this.windowService.setSafeToShutDown(); electron.ipcRenderer.send(Restart); } } diff --git a/packages/core/src/electron-browser/window/electron-window-service.ts b/packages/core/src/electron-browser/window/electron-window-service.ts index 95a9e85d09028..14c2bb41baa75 100644 --- a/packages/core/src/electron-browser/window/electron-window-service.ts +++ b/packages/core/src/electron-browser/window/electron-window-service.ts @@ -15,11 +15,12 @@ ********************************************************************************/ import { injectable, inject, postConstruct } from 'inversify'; -import { remote } from '../../../shared/electron'; +import * as electron from '../../../shared/electron'; import { NewWindowOptions } from '../../common/window'; import { DefaultWindowService } from '../../browser/window/default-window-service'; import { ElectronMainWindowService } from '../../electron-common/electron-main-window-service'; import { ElectronWindowPreferences } from './electron-window-preferences'; +import { CloseRequestArguments, CLOSE_REQUESTED_SIGNAL, RELOAD_REQUESTED_SIGNAL, StopReason } from '../../electron-common/messaging/electron-messages'; @injectable() export class ElectronWindowService extends DefaultWindowService { @@ -59,49 +60,24 @@ export class ElectronWindowService extends DefaultWindowService { }); } - registerUnloadListeners(): void { - window.addEventListener('beforeunload', event => { - if (this.isUnloading) { - // Unloading process ongoing, do nothing: - return this.preventUnload(event); - } else if (this.closeOnUnload || this.canUnload()) { - // Let the window close and notify clients: - delete event.returnValue; - this.onUnloadEmitter.fire(); - return; - } else { - this.isUnloading = true; - // Fix https://github.com/eclipse-theia/theia/issues/8186#issuecomment-742624480 - // On Electron/Linux doing `showMessageBoxSync` does not seems to block the closing - // process long enough and closes the window no matter what you click on (yes/no). - // Instead we'll prevent closing right away, ask for confirmation and finally close. - setTimeout(() => { - if (this.shouldUnload()) { - this.closeOnUnload = true; - window.close(); - } - this.isUnloading = false; - }); - return this.preventUnload(event); - } - }); + protected registerUnloadListeners(): void { + electron.ipcRenderer.on(CLOSE_REQUESTED_SIGNAL, (_event, closeRequestEvent: CloseRequestArguments) => this.handleCloseRequestedEvent(closeRequestEvent)); + window.addEventListener('unload', () => this.onUnloadEmitter.fire()); } /** - * When preventing `beforeunload` on Electron, no popup is shown. - * - * This method implements a modal to ask the user if he wants to quit the page. + * Run when ElectronMain detects a `close` event and emits a `close-requested` event. + * Should send an event to `electron.ipcRenderer` on the event's `confirmChannel` if it is safe to exit + * after running FrontentApplication `onWillStop` handlers or on the `cancelChannel` if it is not safe to exit. */ - protected shouldUnload(): boolean { - const electronWindow = remote.getCurrentWindow(); - const response = remote.dialog.showMessageBoxSync(electronWindow, { - type: 'question', - buttons: ['Yes', 'No'], - title: 'Confirm', - message: 'Are you sure you want to quit?', - detail: 'Any unsaved changes will not be saved.' - }); - return response === 0; // 'Yes', close the window. + protected async handleCloseRequestedEvent(event: CloseRequestArguments): Promise { + const safeToClose = await this.isSafeToShutDown(); + if (safeToClose) { + console.debug(`Shutting down because of ${StopReason[event.reason]} request.`); + electron.ipcRenderer.send(event.confirmChannel); + } else { + electron.ipcRenderer.send(event.cancelChannel); + } } /** @@ -109,9 +85,13 @@ export class ElectronWindowService extends DefaultWindowService { */ protected updateWindowZoomLevel(): void { const preferredZoomLevel = this.electronWindowPreferences['window.zoomLevel']; - const webContents = remote.getCurrentWindow().webContents; + const webContents = electron.remote.getCurrentWindow().webContents; if (webContents.getZoomLevel() !== preferredZoomLevel) { webContents.setZoomLevel(preferredZoomLevel); } } + + reload(): void { + electron.ipcRenderer.send(RELOAD_REQUESTED_SIGNAL); + } } diff --git a/packages/core/src/electron-common/messaging/electron-messages.ts b/packages/core/src/electron-common/messaging/electron-messages.ts index b64f3db3d9e92..5a01665af1269 100644 --- a/packages/core/src/electron-common/messaging/electron-messages.ts +++ b/packages/core/src/electron-common/messaging/electron-messages.ts @@ -18,3 +18,32 @@ export const RequestTitleBarStyle = 'requestTitleBarStyle'; export const TitleBarStyleChanged = 'titleBarStyleChanged'; export const TitleBarStyleAtStartup = 'titleBarStyleAtStartup'; export const Restart = 'restart'; +/** + * Emitted by main when close requested. + */ +export const CLOSE_REQUESTED_SIGNAL = 'close-requested'; +/** + * Emitted by window when a reload is requested. + */ +export const RELOAD_REQUESTED_SIGNAL = 'reload-requested'; + +export enum StopReason { + /** + * Closing the window with no prospect of restart. + */ + Close, + /** + * Reload without closing the window. + */ + Reload, + /** + * Reload that includes closing the window. + */ + Restart, // eslint-disable-line @typescript-eslint/no-shadow +} + +export interface CloseRequestArguments { + confirmChannel: string; + cancelChannel: string; + reason: StopReason; +} diff --git a/packages/core/src/electron-main/electron-main-application.ts b/packages/core/src/electron-main/electron-main-application.ts index 28bcb2ea21ed8..219454a182ca8 100644 --- a/packages/core/src/electron-main/electron-main-application.ts +++ b/packages/core/src/electron-main/electron-main-application.ts @@ -31,7 +31,14 @@ import { ElectronSecurityTokenService } from './electron-security-token-service' import { ElectronSecurityToken } from '../electron-common/electron-token'; import Storage = require('electron-store'); import { isOSX, isWindows } from '../common'; -import { RequestTitleBarStyle, Restart, TitleBarStyleAtStartup, TitleBarStyleChanged } from '../electron-common/messaging/electron-messages'; +import { + CLOSE_REQUESTED_SIGNAL, + RELOAD_REQUESTED_SIGNAL, + RequestTitleBarStyle, + Restart, StopReason, + TitleBarStyleAtStartup, + TitleBarStyleChanged +} from '../electron-common/messaging/electron-messages'; import { DEFAULT_WINDOW_HASH } from '../common/window'; const createYargs: (argv?: string[], cwd?: string) => Argv = require('yargs/yargs'); @@ -192,6 +199,8 @@ export class ElectronMainApplication { protected useNativeWindowFrame: boolean = true; protected didUseNativeWindowFrameOnStart = new Map(); protected restarting = false; + protected closeIsConfirmed = new Set(); + protected closeRequested = 0; get config(): FrontendApplicationConfig { if (!this._config) { @@ -256,6 +265,7 @@ export class ElectronMainApplication { this.attachSaveWindowState(electronWindow); this.attachGlobalShortcuts(electronWindow); this.restoreMaximizedState(electronWindow, options); + this.attachCloseListeners(electronWindow, options); return electronWindow; } @@ -433,19 +443,20 @@ export class ElectronMainApplication { * Catch certain keybindings to prevent reloading the window using keyboard shortcuts. */ protected attachGlobalShortcuts(electronWindow: BrowserWindow): void { - if (this.config.electron?.disallowReloadKeybinding) { - const accelerators = ['CmdOrCtrl+R', 'F5']; - electronWindow.on('focus', () => { - for (const accelerator of accelerators) { - globalShortcut.register(accelerator, () => { }); - } - }); - electronWindow.on('blur', () => { - for (const accelerator of accelerators) { - globalShortcut.unregister(accelerator); - } - }); - } + const handler = this.config.electron?.disallowReloadKeybinding + ? () => { } + : () => this.reload(electronWindow); + const accelerators = ['CmdOrCtrl+R', 'F5']; + electronWindow.on('focus', () => { + for (const accelerator of accelerators) { + globalShortcut.register(accelerator, handler); + } + }); + electronWindow.on('blur', () => { + for (const accelerator of accelerators) { + globalShortcut.unregister(accelerator); + } + }); } protected restoreMaximizedState(electronWindow: BrowserWindow, options: TheiaBrowserWindowOptions): void { @@ -456,6 +467,43 @@ export class ElectronMainApplication { } } + protected attachCloseListeners(electronWindow: BrowserWindow, options: TheiaBrowserWindowOptions): void { + electronWindow.on('close', async event => { + // User has already indicated that it is OK to close this window. + if (this.closeIsConfirmed.has(electronWindow.id)) { + this.closeIsConfirmed.delete(electronWindow.id); + return; + } + + event.preventDefault(); + this.handleStopRequest(electronWindow, () => this.doCloseWindow(electronWindow), StopReason.Close); + }); + } + + protected doCloseWindow(electronWindow: BrowserWindow): void { + this.closeIsConfirmed.add(electronWindow.id); + electronWindow.close(); + } + + protected async handleStopRequest(electronWindow: BrowserWindow, onSafeCallback: () => unknown, reason: StopReason): Promise { + // Only confirm close to windows that have loaded our front end. + const safeToClose = !electronWindow.webContents.getURL().includes(this.globals.THEIA_FRONTEND_HTML_PATH) || await this.checkSafeToStop(electronWindow, reason); + if (safeToClose) { + onSafeCallback(); + } + } + + protected checkSafeToStop(electronWindow: BrowserWindow, reason: StopReason): Promise { + const closeRequest = this.closeRequested++; + const confirmChannel = `safeToClose-${electronWindow.id}-${closeRequest}`; + const cancelChannel = `notSafeToClose-${electronWindow.id}-${closeRequest}`; + return new Promise(resolve => { + electronWindow.webContents.send(CLOSE_REQUESTED_SIGNAL, { confirmChannel, cancelChannel, reason }); + ipcMain.once(confirmChannel, () => resolve(true)); + ipcMain.once(cancelChannel, () => resolve(false)); + }); + } + /** * Start the NodeJS backend server. * @@ -541,6 +589,8 @@ export class ElectronMainApplication { this.restart(sender.id); }); + ipcMain.on(RELOAD_REQUESTED_SIGNAL, event => this.handleReload(event)); + ipcMain.on(RequestTitleBarStyle, ({ sender }) => { sender.send(TitleBarStyleAtStartup, this.didUseNativeWindowFrameOnStart.get(sender.id) ? 'native' : 'custom'); }); @@ -578,7 +628,16 @@ export class ElectronMainApplication { }); this.restarting = false; }); - window.close(); + this.handleStopRequest(window, () => this.doCloseWindow(window), StopReason.Restart); + } + + protected async handleReload(event: Electron.IpcMainEvent): Promise { + const window = BrowserWindow.fromId(event.sender.id); + this.reload(window); + } + + protected reload(electronWindow: BrowserWindow): void { + this.handleStopRequest(electronWindow, () => electronWindow.reload(), StopReason.Reload); } protected async startContributions(): Promise { diff --git a/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts b/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts index 8933ebdef6da2..7f679ccefe416 100755 --- a/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts +++ b/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts @@ -78,6 +78,7 @@ import { } from '@theia/plugin-ext/lib/main/browser/callhierarchy/callhierarchy-type-converters'; import { CustomEditorOpener } from '@theia/plugin-ext/lib/main/browser/custom-editors/custom-editor-opener'; import { nls } from '@theia/core/lib/common/nls'; +import { WindowService } from '@theia/core/lib/browser/window/window-service'; export namespace VscodeCommands { export const OPEN: Command = { @@ -137,6 +138,8 @@ export class PluginVscodeCommandsContribution implements CommandContribution { protected readonly callHierarchyProvider: CallHierarchyServiceProvider; @inject(MonacoTextModelService) protected readonly textModelService: MonacoTextModelService; + @inject(WindowService) + protected readonly windowService: WindowService; private async openWith(commandId: string, resource: URI, columnOrOptions?: ViewColumn | TextDocumentShowOptions, openerId?: string): Promise { if (!resource) { @@ -477,7 +480,7 @@ export class PluginVscodeCommandsContribution implements CommandContribution { commands.registerCommand({ id: 'workbench.action.reloadWindow' }, { execute: () => { - window.location.reload(); + this.windowService.reload(); } }); diff --git a/packages/terminal/src/browser/terminal-frontend-contribution.ts b/packages/terminal/src/browser/terminal-frontend-contribution.ts index 1fe107c6c5b00..ff67a1b20719c 100644 --- a/packages/terminal/src/browser/terminal-frontend-contribution.ts +++ b/packages/terminal/src/browser/terminal-frontend-contribution.ts @@ -29,7 +29,8 @@ import { } from '@theia/core/lib/common'; import { ApplicationShell, KeybindingContribution, KeyCode, Key, WidgetManager, - KeybindingRegistry, Widget, LabelProvider, WidgetOpenerOptions, StorageService, QuickInputService, codicon, CommonCommands, FrontendApplicationContribution + KeybindingRegistry, Widget, LabelProvider, WidgetOpenerOptions, StorageService, + QuickInputService, codicon, CommonCommands, FrontendApplicationContribution, OnWillStopAction, Dialog, ConfirmDialog } from '@theia/core/lib/browser'; import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { TERMINAL_WIDGET_FACTORY_ID, TerminalWidgetFactoryOptions, TerminalWidgetImpl } from './terminal-widget-impl'; @@ -205,11 +206,38 @@ export class TerminalFrontendContribution implements FrontendApplicationContribu }); } - onWillStop(): boolean { - return ( - this.terminalPreferences['terminal.integrated.confirmOnExit'] !== 'never' && - this.widgetManager.getWidgets(TERMINAL_WIDGET_FACTORY_ID).length > 0 - ); + onWillStop(): OnWillStopAction | undefined { + const preferenceValue = this.terminalPreferences['terminal.integrated.confirmOnExit']; + if (preferenceValue !== 'never') { + const allTerminals = this.widgetManager.getWidgets(TERMINAL_WIDGET_FACTORY_ID) as TerminalWidget[]; + if (allTerminals.length) { + return { + action: async () => { + if (preferenceValue === 'always') { + return this.confirmExitWithActiveTerminals(allTerminals.length); + } else { + const activeTerminals = await Promise.all(allTerminals.map(widget => widget.hasChildProcesses())) + .then(hasChildProcesses => hasChildProcesses.filter(hasChild => hasChild)); + return activeTerminals.length === 0 || this.confirmExitWithActiveTerminals(activeTerminals.length); + } + }, + reason: 'Active integrated terminal', + }; + } + } + } + + protected async confirmExitWithActiveTerminals(activeTerminalCount: number): Promise { + const msg = activeTerminalCount === 1 + ? nls.localize('theia/terminal/terminateActive', 'Do you want to terminate the active terminal session?') + : nls.localize('theia/terminal/terminateActiveMultiple', 'Do you want to terminate the {0} active terminal sessions?', activeTerminalCount); + const safeToExit = await new ConfirmDialog({ + title: '', + msg, + ok: nls.localize('theia/terminal/terminate', 'Terminate'), + cancel: Dialog.CANCEL, + }).open(); + return safeToExit === true; } protected _currentTerminal: TerminalWidget | undefined;