UNPKG

@theia/core

Version:

Theia is a cloud & desktop IDE framework implemented in TypeScript.

401 lines (358 loc) • 13.5 kB
// ***************************************************************************** // Copyright (C) 2017 TypeFox and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { Widget } from '@phosphor/widgets'; import { Message } from '@phosphor/messaging'; import { Emitter, Event } from '../common/event'; import { MaybePromise } from '../common/types'; import { Key } from './keyboard/keys'; import { AbstractDialog } from './dialogs'; import { nls } from '../common/nls'; import { Disposable, DisposableCollection, isObject } from '../common'; import { BinaryBuffer } from '../common/buffer'; export type AutoSaveMode = 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange'; export interface Saveable { readonly dirty: boolean; /** If false, the saveable will not participate in autosaving. */ readonly autosaveable?: boolean; /** * This event is fired when the content of the `dirty` variable changes. */ readonly onDirtyChanged: Event<void>; /** * This event is fired when the content of the saveable changes. * While `onDirtyChanged` is fired to notify the UI that the widget is dirty, * `onContentChanged` is used for the auto save throttling. */ readonly onContentChanged: Event<void>; /** * Saves dirty changes. */ save(options?: SaveOptions): MaybePromise<void>; /** * Reverts dirty changes. */ revert?(options?: Saveable.RevertOptions): Promise<void>; /** * Creates a snapshot of the dirty state. See also {@link Saveable.Snapshot}. */ createSnapshot?(): Saveable.Snapshot; /** * Applies the given snapshot to the dirty state. */ applySnapshot?(snapshot: object): void; /** * Serializes the full state of the saveable item to a binary buffer. */ serialize?(): Promise<BinaryBuffer>; } export interface SaveableSource { readonly saveable: Saveable; } export class DelegatingSaveable implements Saveable { dirty = false; protected readonly onDirtyChangedEmitter = new Emitter<void>(); protected readonly onContentChangedEmitter = new Emitter<void>(); get onDirtyChanged(): Event<void> { return this.onDirtyChangedEmitter.event; } get onContentChanged(): Event<void> { return this.onContentChangedEmitter.event; } async save(options?: SaveOptions): Promise<void> { await this._delegate?.save(options); } revert?(options?: Saveable.RevertOptions): Promise<void>; createSnapshot?(): Saveable.Snapshot; applySnapshot?(snapshot: object): void; serialize?(): Promise<BinaryBuffer>; protected _delegate?: Saveable; protected toDispose = new DisposableCollection(); set delegate(delegate: Saveable) { this.toDispose.dispose(); this.toDispose = new DisposableCollection(); this._delegate = delegate; this.toDispose.push(delegate.onDirtyChanged(() => { this.dirty = delegate.dirty; this.onDirtyChangedEmitter.fire(); })); this.toDispose.push(delegate.onContentChanged(() => { this.onContentChangedEmitter.fire(); })); if (this.dirty !== delegate.dirty) { this.dirty = delegate.dirty; this.onDirtyChangedEmitter.fire(); } this.revert = delegate.revert?.bind(delegate); this.createSnapshot = delegate.createSnapshot?.bind(delegate); this.applySnapshot = delegate.applySnapshot?.bind(delegate); this.serialize = delegate.serialize?.bind(delegate); } } export class CompositeSaveable implements Saveable { protected isDirty = false; protected readonly onDirtyChangedEmitter = new Emitter<void>(); protected readonly onContentChangedEmitter = new Emitter<void>(); protected readonly toDispose = new DisposableCollection(this.onDirtyChangedEmitter, this.onContentChangedEmitter); protected readonly saveablesMap = new Map<Saveable, Disposable>(); get dirty(): boolean { return this.isDirty; } get onDirtyChanged(): Event<void> { return this.onDirtyChangedEmitter.event; } get onContentChanged(): Event<void> { return this.onContentChangedEmitter.event; } async save(options?: SaveOptions): Promise<void> { await Promise.all(this.saveables.map(saveable => saveable.save(options))); } async revert(options?: Saveable.RevertOptions): Promise<void> { await Promise.all(this.saveables.map(saveable => saveable.revert?.(options))); } get saveables(): readonly Saveable[] { return Array.from(this.saveablesMap.keys()); } add(saveable: Saveable): void { if (this.saveablesMap.has(saveable)) { return; } const toDispose = new DisposableCollection(); this.toDispose.push(toDispose); this.saveablesMap.set(saveable, toDispose); toDispose.push(Disposable.create(() => { this.saveablesMap.delete(saveable); })); toDispose.push(saveable.onDirtyChanged(() => { const wasDirty = this.isDirty; this.isDirty = this.saveables.some(s => s.dirty); if (this.isDirty !== wasDirty) { this.onDirtyChangedEmitter.fire(); } })); toDispose.push(saveable.onContentChanged(() => { this.onContentChangedEmitter.fire(); })); if (saveable.dirty && !this.isDirty) { this.isDirty = true; this.onDirtyChangedEmitter.fire(); } } remove(saveable: Saveable): boolean { const toDispose = this.saveablesMap.get(saveable); toDispose?.dispose(); return !!toDispose; } dispose(): void { this.toDispose.dispose(); } } export namespace Saveable { export interface RevertOptions { /** * If soft then only dirty flag should be updated, otherwise * the underlying data should be reverted as well. */ soft?: boolean } /** * A snapshot of a saveable item. * Applying a snapshot of a saveable on another (of the same type) using the `applySnapshot` should yield the state of the original saveable. */ export type Snapshot = { value: string } | { read(): string | null }; export namespace Snapshot { export function read(snapshot: Snapshot): string | undefined { return 'value' in snapshot ? snapshot.value : (snapshot.read() ?? undefined); } } export function isSource(arg: unknown): arg is SaveableSource { return isObject<SaveableSource>(arg) && is(arg.saveable); } export function is(arg: unknown): arg is Saveable { return isObject(arg) && 'dirty' in arg && 'onDirtyChanged' in arg; } export function get(arg: unknown): Saveable | undefined { if (is(arg)) { return arg; } if (isSource(arg)) { return arg.saveable; } return undefined; } export function getDirty(arg: unknown): Saveable | undefined { const saveable = get(arg); if (saveable && saveable.dirty) { return saveable; } return undefined; } export function isDirty(arg: unknown): boolean { return !!getDirty(arg); } export async function save(arg: unknown, options?: SaveOptions): Promise<void> { const saveable = get(arg); if (saveable) { await saveable.save(options); } } 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; } export function closingWidgetWouldLoseSaveable(widget: Widget, others: Widget[]): boolean { const saveable = Saveable.get(widget); return !!saveable && !others.some(otherWidget => otherWidget !== widget && Saveable.get(otherWidget) === saveable); } } 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>; } export const close = Symbol('close'); /** * An interface describing saveable widgets that are created by the `Saveable.apply` function. * The original `close` function is reassigned to a locally-defined `Symbol` */ export interface PostCreationSaveableWidget extends SaveableWidget { /** * The original `close` function of the widget */ [close](): void; } export namespace SaveableWidget { export function is(widget: Widget | undefined): widget is SaveableWidget { return !!widget && 'closeWithoutSaving' in widget; } export function getDirty<T extends Widget>(widgets: Iterable<T>): IterableIterator<SaveableWidget & T> { return get<T>(widgets, Saveable.isDirty); } export function* get<T extends Widget>( widgets: Iterable<T>, filter: (widget: T) => boolean = () => true ): IterableIterator<SaveableWidget & T> { for (const widget of widgets) { if (SaveableWidget.is(widget) && filter(widget)) { yield widget; } } } export interface CloseOptions { shouldSave?(): MaybePromise<boolean | undefined> } } /** * Possible formatting types when saving. */ export const enum FormatType { /** * Formatting should occur (default). */ ON = 1, /** * Formatting should not occur. */ OFF, /** * Formatting should only occur if the resource is dirty. */ DIRTY }; export enum SaveReason { Manual = 1, AfterDelay = 2, FocusChange = 3 } export namespace SaveReason { export function isManual(reason?: number): reason is typeof SaveReason.Manual { return reason === SaveReason.Manual; } } export interface SaveOptions { /** * Formatting type to apply when saving. */ readonly formatType?: FormatType; /** * The reason for saving the resource. */ readonly saveReason?: SaveReason; } /** * The class name added to the dirty widget's title. */ const DIRTY_CLASS = 'theia-mod-dirty'; export function setDirty(widget: Widget, dirty: boolean): void { const dirtyClass = ` ${DIRTY_CLASS}`; widget.title.className = widget.title.className.replace(dirtyClass, ''); if (dirty) { widget.title.className += dirtyClass; } } export class ShouldSaveDialog extends AbstractDialog<boolean> { protected shouldSave = true; protected readonly dontSaveButton: HTMLButtonElement; 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 = 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); this.appendCloseButton(); this.dontSaveButton = this.appendDontSaveButton(); this.appendAcceptButton(nls.localizeByDefault('Save')); } protected appendDontSaveButton(): HTMLButtonElement { const button = this.createButton(nls.localizeByDefault("Don't Save")); this.controlPanel.appendChild(button); button.classList.add('secondary'); return button; } protected override onAfterAttach(msg: Message): void { super.onAfterAttach(msg); this.addKeyListener(this.dontSaveButton, Key.ENTER, () => { this.shouldSave = false; this.accept(); }, 'click'); } get value(): boolean { return this.shouldSave; } override async open(disposeOnResolve?: boolean): Promise<boolean | undefined> { return super.open(disposeOnResolve); } }