Skip to content

Commit

Permalink
Revert "Revert "Merge branch 'master' into removed_activateTerminal_m…
Browse files Browse the repository at this point in the history
…ethod""

This reverts commit 8836604.
  • Loading branch information
abhisharsinha committed Dec 10, 2021
1 parent 8836604 commit 164d672
Show file tree
Hide file tree
Showing 15 changed files with 334 additions and 103 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

<a name="breaking_changes_1.21.0">[Breaking Changes:](#breaking_changes_1.21.0)</a>

Expand All @@ -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<boolean>` 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
Expand Down
29 changes: 23 additions & 6 deletions packages/core/src/browser/common-frontend-contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {

Expand Down Expand Up @@ -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<void> {
const configDirUri = await this.environments.getConfigDirUri();
// Global settings
Expand Down Expand Up @@ -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;
Expand All @@ -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();
}
}
});
Expand All @@ -998,6 +1005,16 @@ export class CommonFrontendContribution implements FrontendApplicationContributi
});
}

protected async confirmRestart(): Promise<boolean> {
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);
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/browser/dialogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,16 @@ export class ConfirmDialog extends AbstractDialog<boolean> {
}
return msg;
}
}

export async function confirmExit(): Promise<boolean> {
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()
Expand Down
21 changes: 19 additions & 2 deletions packages/core/src/browser/frontend-application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -77,6 +77,23 @@ export interface FrontendApplicationContribution {
onDidInitializeLayout?(app: FrontendApplication): MaybePromise<void>;
}

export interface OnWillStopAction {
/**
* @resolves to `true` if it is safe to close the application; `false` otherwise.
*/
action: () => MaybePromise<boolean>;
/**
* 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;

/**
Expand Down
18 changes: 12 additions & 6 deletions packages/core/src/browser/shell/shell-layout-restorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -125,6 +126,9 @@ export class ShellLayoutRestorer implements CommandContribution {
@inject(ContributionProvider) @named(ApplicationShellLayoutMigration)
protected readonly migrations: ContributionProvider<ApplicationShellLayoutMigration>;

@inject(WindowService)
protected readonly windowService: WindowService;

constructor(
@inject(WidgetManager) protected widgetManager: WidgetManager,
@inject(ILogger) protected logger: ILogger,
Expand All @@ -137,12 +141,14 @@ export class ShellLayoutRestorer implements CommandContribution {
}

protected async resetLayout(): Promise<void> {
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand All @@ -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)', () => {
Expand All @@ -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');
});
});
82 changes: 69 additions & 13 deletions packages/core/src/browser/window/default-window-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>();
get onUnload(): Event<void> {
Expand Down Expand Up @@ -53,33 +55,84 @@ 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.
// If the user leaves, the page will be unloaded, so `unload` is fired.
window.addEventListener('unload', () => this.onUnloadEmitter.fire());
}

async isSafeToShutDown(): Promise<boolean> {
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.
*
Expand All @@ -95,4 +148,7 @@ export class DefaultWindowService implements WindowService, FrontendApplicationC
return '';
}

reload(): void {
window.location.reload();
}
}
4 changes: 3 additions & 1 deletion packages/core/src/browser/window/test/mock-window-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> { return Promise.resolve(true); }
setSafeToShutDown(): void { }
get onUnload(): Event<void> { return Event.None; }
}
Loading

0 comments on commit 164d672

Please sign in to comment.