Skip to content

Commit

Permalink
handle multi-close commands
Browse files Browse the repository at this point in the history
  • Loading branch information
colin-grant-work committed Jan 11, 2022
1 parent 8f9d392 commit 8f69c1e
Show file tree
Hide file tree
Showing 7 changed files with 126 additions and 87 deletions.
99 changes: 71 additions & 28 deletions packages/core/src/browser/saveable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,55 +93,93 @@ export namespace Saveable {
await saveable.save(options);
}
}
export function apply(widget: Widget, getOtherSaveables?: () => Array<Widget & (Saveable | SaveableSource)>): SaveableWidget | undefined {
if (SaveableWidget.is(widget)) {
return widget;
}
const saveable = Saveable.get(widget);
if (!saveable) {
return undefined;

async function closeWithoutSaving(this: SaveableWidget, doRevert: boolean = true): Promise<void> {
const saveable = get(this);
if (saveable && doRevert && saveable.dirty && saveable.revert) {
await saveable.revert();
}
setDirty(widget, saveable.dirty);
saveable.onDirtyChanged(() => setDirty(widget, saveable.dirty));
const closeWidget = widget.close.bind(widget);
const closeWithoutSaving: SaveableWidget['closeWithoutSaving'] = async (doRevert = true) => {
if (doRevert && saveable.dirty && saveable.revert) {
await saveable.revert();
}
closeWidget();
return waitForClosed(widget);
};
this[close]();
return waitForClosed(this);
}

function createCloseWithSaving(getOtherSaveables?: () => Array<Widget | SaveableWidget>): (this: SaveableWidget, options?: SaveableWidget.CloseOptions) => Promise<void> {
let closing = false;
const closeWithSaving: SaveableWidget['closeWithSaving'] = async options => {
if (closing) {
return;
}
return async function (this: SaveableWidget, options: SaveableWidget.CloseOptions): Promise<void> {
if (closing) { return; }
const saveable = get(this);
if (!saveable) { return; }
closing = true;
try {
const result = await shouldSave(saveable, () => {
const notLastWithDocument = getOtherSaveables?.().some(otherWidget => otherWidget !== widget && Saveable.get(otherWidget) === saveable);
const notLastWithDocument = !closingWidgetWouldLoseSaveable(this, getOtherSaveables?.() ?? []);
if (notLastWithDocument) {
return closeWithoutSaving(false).then(() => undefined);
return this.closeWithoutSaving(false).then(() => undefined);
}
if (options && options.shouldSave) {
return options.shouldSave();
}
return new ShouldSaveDialog(widget).open();
return new ShouldSaveDialog(this).open();
});
if (typeof result === 'boolean') {
if (result) {
await Saveable.save(widget);
await Saveable.save(this);
}
await closeWithoutSaving(result);
await this.closeWithoutSaving(result);
}
} finally {
closing = false;
}
};
return Object.assign(widget, {
}

export async function confirmSaveBeforeClose(toClose: Iterable<Widget>, others: Widget[]): Promise<boolean | undefined> {
for (const widget of toClose) {
const saveable = Saveable.get(widget);
if (saveable?.dirty) {
if (!closingWidgetWouldLoseSaveable(widget, others)) {
continue;
}
const userWantsToSave = await new ShouldSaveDialog(widget).open();
if (userWantsToSave === undefined) { // User clicked cancel.
return undefined;
} else if (userWantsToSave) {
await saveable.save();
} else {
await saveable.revert?.();
}
}
}
return true;
}

/**
* @param widget the widget that may be closed
* @param others widgets that will not be closed.
* @returns `true` if widget is saveable and no widget among the `others` refers to the same saveable. `false` otherwise.
*/
function closingWidgetWouldLoseSaveable(widget: Widget, others: Widget[]): boolean {
const saveable = get(widget);
return !!saveable && !others.some(otherWidget => otherWidget !== widget && get(otherWidget) === saveable);
}

export function apply(widget: Widget, getOtherSaveables?: () => Array<Widget | SaveableWidget>): SaveableWidget | undefined {
if (SaveableWidget.is(widget)) {
return widget;
}
const saveable = Saveable.get(widget);
if (!saveable) {
return undefined;
}
const saveableWidget = widget as SaveableWidget;
setDirty(saveableWidget, saveable.dirty);
saveable.onDirtyChanged(() => setDirty(saveableWidget, saveable.dirty));
const closeWithSaving = createCloseWithSaving(getOtherSaveables);
return Object.assign(saveableWidget, {
closeWithoutSaving,
closeWithSaving,
close: () => closeWithSaving()
close: closeWithSaving,
[close]: saveableWidget.close,
});
}
export async function shouldSave(saveable: Saveable, cb: () => MaybePromise<boolean | undefined>): Promise<boolean | undefined> {
Expand All @@ -157,12 +195,17 @@ export namespace Saveable {
}
}

export const close = Symbol('close');
export interface SaveableWidget extends Widget {
/**
* @param doRevert whether the saveable should be reverted before being saved. Defaults to `true`.
*/
closeWithoutSaving(doRevert?: boolean): Promise<void>;
closeWithSaving(options?: SaveableWidget.CloseOptions): Promise<void>;
/**
* The original close function of the widget
*/
[close](): void;
}
export namespace SaveableWidget {
export function is(widget: Widget | undefined): widget is SaveableWidget {
Expand Down
62 changes: 38 additions & 24 deletions packages/core/src/browser/shell/application-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,11 @@ export class ApplicationShell extends Widget {
}
}

/**
* @returns an array of Widgets, all of which are tracked by the focus tracker
* The first member of the array is the widget whose id is passed in, and the other widgets
* are its tracked parents in ascending order
*/
protected toTrackedStack(id: string): Widget[] {
const tracked = new Map<string, Widget>(this.tracker.widgets.map(w => [w.id, w] as [string, Widget]));
let current = tracked.get(id);
Expand Down Expand Up @@ -1392,24 +1397,23 @@ export class ApplicationShell extends Widget {
* @param filter
* If undefined, all tabs are closed; otherwise only those tabs that match the filter are closed.
*/
closeTabs(tabBarOrArea: TabBar<Widget> | ApplicationShell.Area,
filter?: (title: Title<Widget>, index: number) => boolean): void {
async closeTabs(tabBarOrArea: TabBar<Widget> | ApplicationShell.Area,
filter?: (title: Title<Widget>, index: number) => boolean): Promise<void> {
const titles: Array<Title<Widget>> = [];
if (tabBarOrArea === 'main') {
this.mainAreaTabBars.forEach(tb => this.closeTabs(tb, filter));
this.mainAreaTabBars.forEach(tabbar => titles.push(...toArray(tabbar.titles)));
} else if (tabBarOrArea === 'bottom') {
this.bottomAreaTabBars.forEach(tb => this.closeTabs(tb, filter));
this.bottomAreaTabBars.forEach(tabbar => titles.push(...toArray(tabbar.titles)));
} else if (typeof tabBarOrArea === 'string') {
const tabBar = this.getTabBarFor(tabBarOrArea);
if (tabBar) {
this.closeTabs(tabBar, filter);
const tabbar = this.getTabBarFor(tabBarOrArea);
if (tabbar) {
titles.push(...toArray(tabbar.titles));
}
} else if (tabBarOrArea) {
const titles = toArray(tabBarOrArea.titles);
for (let i = 0; i < titles.length; i++) {
if (filter === undefined || filter(titles[i], i)) {
titles[i].owner.close();
}
}
titles.push(...toArray(tabBarOrArea.titles));
}
if (titles.length) {
await this.closeMany((filter ? titles.filter(filter) : titles).map(title => title.owner));
}
}

Expand All @@ -1436,24 +1440,34 @@ export class ApplicationShell extends Widget {
}
}

/**
* @param targets the widgets to be closed
* @return an array of all the widgets that were actually closed.
*/
async closeMany(targets: Widget[], options?: ApplicationShell.CloseOptions): Promise<Widget[]> {
const others = this.widgets.filter(widget => !targets.includes(widget));
if (options?.save === false || await Saveable.confirmSaveBeforeClose(targets, others)) {
return (await Promise.all(targets.map(target => this.closeWidget(target.id, options)))).filter((widget): widget is Widget => widget !== undefined);
}
return [];
}

/**
* @returns the widget that was closed, if any, `undefined` otherwise.
*
* If your use case requires closing multiple widgets, use {@link ApplicationShell#closeMany} instead. That method handles closing saveable widgets more reliably.
*/
async closeWidget(id: string, options?: ApplicationShell.CloseOptions): Promise<Widget | undefined> {
// TODO handle save for composite widgets, i.e. the preference widget has 2 editors
const stack = this.toTrackedStack(id);
const current = stack.pop();
if (!current) {
return undefined;
}
let pendingClose;
if (SaveableWidget.is(current)) {
let shouldSave;
if (options && 'save' in options) {
shouldSave = () => options.save;
}
pendingClose = current.closeWithSaving({ shouldSave });
} else {
current.close();
pendingClose = waitForClosed(current);
};
const saveableOptions = options && { shouldSave: () => options.save };
const pendingClose = SaveableWidget.is(current)
? current.closeWithSaving(saveableOptions)
: (current.close(), waitForClosed(current));
await Promise.all([
pendingClose,
this.pendingUpdates
Expand Down
4 changes: 1 addition & 3 deletions packages/core/src/browser/widget-open-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,6 @@ export abstract class WidgetOpenHandler<W extends BaseWidget> implements OpenHan
* @returns a promise of all closed widgets that resolves after they have been closed.
*/
async closeAll(options?: ApplicationShell.CloseOptions): Promise<W[]> {
const closed = await Promise.all(this.all.map(widget => this.shell.closeWidget(widget.id, options)));
return closed.filter(widget => !!widget) as W[];
return this.shell.closeMany(this.all, options) as Promise<W[]>;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import {
NavigatableWidget, NavigatableWidgetOptions,
Saveable, WidgetManager, StatefulWidget, FrontendApplication, ExpandableTreeNode, waitForClosed,
CorePreferences,
CommonCommands
CommonCommands,
Widget
} from '@theia/core/lib/browser';
import { MimeService } from '@theia/core/lib/browser/mime-service';
import { TreeWidgetSelection } from '@theia/core/lib/browser/tree/tree-widget-selection';
Expand Down Expand Up @@ -272,24 +273,20 @@ export class FileSystemFrontendContribution implements FrontendApplicationContri
if (!event.gotDeleted() && !event.gotAdded()) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pending: Promise<any>[] = [];

const dirty = new Set<string>();
const toClose = new Map<string, NavigatableWidget[]>();
for (const [uri, widget] of NavigatableWidget.get(this.shell.widgets)) {
this.updateWidget(uri, widget, event, { dirty, toClose });
this.updateWidget(uri, widget, event, { dirty, toClose: toClose });
}
for (const [uriString, widgets] of toClose.entries()) {
if (!dirty.has(uriString) && this.corePreferences['workbench.editor.closeOnFileDelete']) {
for (const widget of widgets) {
widget.close();
pending.push(waitForClosed(widget));
if (this.corePreferences['workbench.editor.closeOnFileDelete']) {
const doClose = [];
for (const [uri, widgets] of toClose.entries()) {
if (!dirty.has(uri)) {
doClose.push(...widgets);
}
}
await this.shell.closeMany(doClose);
}

await Promise.all(pending);
}
protected updateWidget(uri: URI, widget: NavigatableWidget, event: FileChangesEvent, { dirty, toClose }: {
dirty: Set<string>;
Expand Down
2 changes: 1 addition & 1 deletion packages/navigator/src/browser/navigator-contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ export class FileNavigatorContribution extends AbstractViewContribution<FileNavi
}
});
registry.registerCommand(OpenEditorsCommands.CLOSE_ALL_TABS_FROM_TOOLBAR, {
execute: widget => this.withOpenEditorsWidget(widget, () => this.editorWidgets.forEach(editor => editor.close())),
execute: widget => this.withOpenEditorsWidget(widget, () => this.shell.closeMany(this.editorWidgets)),
isEnabled: widget => this.withOpenEditorsWidget(widget, () => !!this.editorWidgets.length),
isVisible: widget => this.withOpenEditorsWidget(widget, () => !!this.editorWidgets.length)
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,11 +349,8 @@ export class PluginVscodeCommandsContribution implements CommandContribution {
return (resourceUri && resourceUri.toString()) === uriString;
});
}
for (const widget of this.shell.widgets) {
if (this.codeEditorWidgetUtil.is(widget) && widget !== editor) {
await this.shell.closeWidget(widget.id);
}
}
const toClose = this.shell.widgets.filter(widget => widget !== editor && this.codeEditorWidgetUtil.is(widget));
await this.shell.closeMany(toClose);
}
});

Expand Down Expand Up @@ -449,13 +446,8 @@ export class PluginVscodeCommandsContribution implements CommandContribution {
});
commands.registerCommand({ id: 'workbench.action.closeAllEditors' }, {
execute: async () => {
const promises = [];
for (const widget of this.shell.widgets) {
if (this.codeEditorWidgetUtil.is(widget)) {
promises.push(this.shell.closeWidget(widget.id));
}
}
await Promise.all(promises);
const toClose = this.shell.widgets.filter(widget => this.codeEditorWidgetUtil.is(widget));
await this.shell.closeMany(toClose);
}
});
commands.registerCommand({ id: 'workbench.action.nextEditor' }, {
Expand Down
9 changes: 2 additions & 7 deletions packages/workspace/src/browser/workspace-delete-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,7 @@ export class WorkspaceDeleteHandler implements UriCommandHandler<URI[]> {
* @param uri URI of a selected resource.
*/
protected async closeWithoutSaving(uri: URI): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pending: Promise<any>[] = [];
for (const [, widget] of NavigatableWidget.getAffected(this.shell.widgets, uri)) {
pending.push(this.shell.closeWidget(widget.id, { save: false }));
}
await Promise.all(pending);
const toClose = [...NavigatableWidget.getAffected(this.shell.widgets, uri)].map(([, widget]) => widget);
await this.shell.closeMany(toClose, { save: false });
}

}

0 comments on commit 8f69c1e

Please sign in to comment.