diff --git a/CHANGELOG.md b/CHANGELOG.md index f135fb348667b..3f4cd7eff0935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,11 @@ +[Breaking Changes:](#breaking_changes_not_yet_released) + +- [filesystem] Adjusted the "Save As" mechanism. It now assumes that `Saveable.getSnapshot()` returns a full snapshot of the editor model [#13689](https://github.com/eclipse-theia/theia/pull/13689). + +--> ## 1.50.0 - 06/03/2024 diff --git a/packages/core/src/browser/saveable.ts b/packages/core/src/browser/saveable.ts index 2f9390ca2b8fd..f65a6a29162af 100644 --- a/packages/core/src/browser/saveable.ts +++ b/packages/core/src/browser/saveable.ts @@ -46,7 +46,7 @@ export interface Saveable { */ revert?(options?: Saveable.RevertOptions): Promise; /** - * Creates a snapshot of the dirty state. + * Creates a snapshot of the dirty state. See also {@link Saveable.Snapshot}. */ createSnapshot?(): Saveable.Snapshot; /** @@ -114,7 +114,15 @@ export namespace Saveable { soft?: boolean } + /** + * A snapshot of a saveable item. Contains the full content of the saveable file in a string serializable format. + */ 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(arg) && is(arg.saveable); } diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index c752bffdf1827..cfb5a72f8af68 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -960,7 +960,7 @@ export class ApplicationShell extends Widget { } } - getInsertionOptions(options?: Readonly): { area: string; addOptions: DockLayout.IAddOptions; } { + getInsertionOptions(options?: Readonly): { area: string; addOptions: TheiaDockPanel.AddOptions; } { let ref: Widget | undefined = options?.ref; let area: ApplicationShell.Area = options?.area || 'main'; if (!ref && (area === 'main' || area === 'bottom')) { @@ -969,7 +969,7 @@ export class ApplicationShell extends Widget { } // make sure that ref belongs to area area = ref && this.getAreaFor(ref) || area; - const addOptions: DockPanel.IAddOptions = {}; + const addOptions: TheiaDockPanel.AddOptions = {}; if (ApplicationShell.isOpenToSideMode(options?.mode)) { const areaPanel = area === 'main' ? this.mainPanel : area === 'bottom' ? this.bottomPanel : undefined; const sideRef = areaPanel && ref && (options?.mode === 'open-to-left' ? @@ -981,6 +981,10 @@ export class ApplicationShell extends Widget { addOptions.ref = ref; addOptions.mode = options?.mode === 'open-to-left' ? 'split-left' : 'split-right'; } + } else if (ApplicationShell.isReplaceMode(options?.mode)) { + addOptions.ref = options?.ref; + addOptions.closeRef = true; + addOptions.mode = 'tab-after'; } else { addOptions.ref = ref; addOptions.mode = options?.mode; @@ -2172,6 +2176,15 @@ export namespace ApplicationShell { return mode === 'open-to-left' || mode === 'open-to-right'; } + /** + * Whether the `ref` of the options widget should be replaced. + */ + export type ReplaceMode = 'tab-replace'; + + export function isReplaceMode(mode: unknown): mode is ReplaceMode { + return mode === 'tab-replace'; + } + /** * Options for adding a widget to the application shell. */ @@ -2185,7 +2198,7 @@ export namespace ApplicationShell { * * The default is `'tab-after'`. */ - mode?: DockLayout.InsertMode | OpenToSideMode + mode?: DockLayout.InsertMode | OpenToSideMode | ReplaceMode /** * The reference widget for the insert location. * diff --git a/packages/core/src/browser/shell/theia-dock-panel.ts b/packages/core/src/browser/shell/theia-dock-panel.ts index f5818217ec98f..19c2b60e92c77 100644 --- a/packages/core/src/browser/shell/theia-dock-panel.ts +++ b/packages/core/src/browser/shell/theia-dock-panel.ts @@ -133,11 +133,14 @@ export class TheiaDockPanel extends DockPanel { } } - override addWidget(widget: Widget, options?: DockPanel.IAddOptions): void { + override addWidget(widget: Widget, options?: TheiaDockPanel.AddOptions): void { if (this.mode === 'single-document' && widget.parent === this) { return; } super.addWidget(widget, options); + if (options?.closeRef) { + options.ref?.close(); + } this.widgetAdded.emit(widget); this.markActiveTabBar(widget.title); } @@ -252,4 +255,11 @@ export namespace TheiaDockPanel { export interface Factory { (options?: DockPanel.IOptions): TheiaDockPanel; } + + export interface AddOptions extends DockPanel.IAddOptions { + /** + * Whether to also close the widget referenced by `ref`. + */ + closeRef?: boolean + } } diff --git a/packages/core/src/browser/widgets/react-renderer.tsx b/packages/core/src/browser/widgets/react-renderer.tsx index f24325072ac13..ff8176405e2d2 100644 --- a/packages/core/src/browser/widgets/react-renderer.tsx +++ b/packages/core/src/browser/widgets/react-renderer.tsx @@ -41,7 +41,10 @@ export class ReactRenderer implements Disposable { } render(): void { - this.hostRoot.render({this.doRender()}); + // Ignore all render calls after the host element has unmounted + if (!this.toDispose.disposed) { + this.hostRoot.render({this.doRender()}); + } } protected doRender(): React.ReactNode { diff --git a/packages/filesystem/src/browser/filesystem-saveable-service.ts b/packages/filesystem/src/browser/filesystem-saveable-service.ts index 39fe1e3eb3f98..e7aaa51ce389e 100644 --- a/packages/filesystem/src/browser/filesystem-saveable-service.ts +++ b/packages/filesystem/src/browser/filesystem-saveable-service.ts @@ -16,7 +16,7 @@ import { environment, MessageService, nls } from '@theia/core'; import { inject, injectable } from '@theia/core/shared/inversify'; -import { Navigatable, Saveable, SaveableSource, SaveOptions, Widget, open, OpenerService, ConfirmDialog, FormatType, CommonCommands } from '@theia/core/lib/browser'; +import { Navigatable, Saveable, SaveableSource, SaveOptions, Widget, open, OpenerService, ConfirmDialog, CommonCommands, LabelProvider } from '@theia/core/lib/browser'; import { SaveableService } from '@theia/core/lib/browser/saveable-service'; import URI from '@theia/core/lib/common/uri'; import { FileService } from './file-service'; @@ -33,6 +33,8 @@ export class FilesystemSaveableService extends SaveableService { protected readonly fileDialogService: FileDialogService; @inject(OpenerService) protected readonly openerService: OpenerService; + @inject(LabelProvider) + protected readonly labelProvider: LabelProvider; /** * This method ensures a few things about `widget`: @@ -76,7 +78,7 @@ export class FilesystemSaveableService extends SaveableService { return this.save(sourceWidget, options); } else if (selected) { try { - await this.copyAndSave(sourceWidget, selected, overwrite); + await this.saveSnapshot(sourceWidget, selected, overwrite); return selected; } catch (e) { console.warn(e); @@ -85,30 +87,26 @@ export class FilesystemSaveableService extends SaveableService { } /** + * Saves the current snapshot of the {@link sourceWidget} to the target file + * and replaces the widget with a new one that contains the snapshot content + * * @param sourceWidget widget to save as `target`. * @param target The new URI for the widget. * @param overwrite */ - private async copyAndSave(sourceWidget: Widget & SaveableSource & Navigatable, target: URI, overwrite: boolean): Promise { - const snapshot = sourceWidget.saveable.createSnapshot!(); - if (!await this.fileService.exists(target)) { - const sourceUri = sourceWidget.getResourceUri()!; - if (this.fileService.canHandleResource(sourceUri)) { - await this.fileService.copy(sourceUri, target, { overwrite }); - } else { - await this.fileService.createFile(target); - } - } - const targetWidget = await open(this.openerService, target, { widgetOptions: { ref: sourceWidget } }); - const targetSaveable = Saveable.get(targetWidget); - if (targetWidget && targetSaveable && targetSaveable.applySnapshot) { - targetSaveable.applySnapshot(snapshot); - await sourceWidget.saveable.revert!(); - sourceWidget.close(); - Saveable.save(targetWidget, { formatType: FormatType.ON }); + protected async saveSnapshot(sourceWidget: Widget & SaveableSource & Navigatable, target: URI, overwrite: boolean): Promise { + const saveable = sourceWidget.saveable; + const snapshot = saveable.createSnapshot!(); + const content = Saveable.Snapshot.read(snapshot) ?? ''; + if (await this.fileService.exists(target)) { + // Do not fire the `onDidCreate` event as the file already exists. + await this.fileService.write(target, content); } else { - this.messageService.error(nls.localize('theia/workspace/failApply', 'Could not apply changes to new file')); + // Ensure to actually call `create` as that fires the `onDidCreate` event. + await this.fileService.create(target, content, { overwrite }); } + await saveable.revert!(); + await open(this.openerService, target, { widgetOptions: { ref: sourceWidget, mode: 'tab-replace' } }); } async confirmOverwrite(uri: URI): Promise { @@ -119,7 +117,7 @@ export class FilesystemSaveableService extends SaveableService { // Prompt users for confirmation before overwriting. const confirmed = await new ConfirmDialog({ title: nls.localizeByDefault('Overwrite'), - msg: nls.localizeByDefault('{0} already exists. Are you sure you want to overwrite it?', uri.toString()) + msg: nls.localizeByDefault('{0} already exists. Are you sure you want to overwrite it?', this.labelProvider.getName(uri)) }).open(); return !!confirmed; } diff --git a/packages/filesystem/src/browser/location/location-renderer.tsx b/packages/filesystem/src/browser/location/location-renderer.tsx index bf89c946fb7c5..b6f61a60564b2 100644 --- a/packages/filesystem/src/browser/location/location-renderer.tsx +++ b/packages/filesystem/src/browser/location/location-renderer.tsx @@ -122,7 +122,9 @@ export class LocationListRenderer extends ReactRenderer { } override render(): void { - this.hostRoot.render(this.doRender()); + if (!this.toDispose.disposed) { + this.hostRoot.render(this.doRender()); + } } protected initResolveDirectoryCache(): void { diff --git a/packages/monaco/src/browser/monaco-editor-model.ts b/packages/monaco/src/browser/monaco-editor-model.ts index eef64d64aef89..e8f6fa2629ef8 100644 --- a/packages/monaco/src/browser/monaco-editor-model.ts +++ b/packages/monaco/src/browser/monaco-editor-model.ts @@ -651,7 +651,7 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo } applySnapshot(snapshot: Saveable.Snapshot): void { - const value = 'value' in snapshot ? snapshot.value : snapshot.read() ?? ''; + const value = Saveable.Snapshot.read(snapshot) ?? ''; this.model.setValue(value); } diff --git a/packages/notebook/src/browser/view-model/notebook-model.ts b/packages/notebook/src/browser/view-model/notebook-model.ts index 5099cee977e46..b1f7f06a403c6 100644 --- a/packages/notebook/src/browser/view-model/notebook-model.ts +++ b/packages/notebook/src/browser/view-model/notebook-model.ts @@ -190,7 +190,7 @@ export class NotebookModel implements Saveable, Disposable { } async applySnapshot(snapshot: Saveable.Snapshot): Promise { - const rawData = 'read' in snapshot ? snapshot.read() : snapshot.value; + const rawData = Saveable.Snapshot.read(snapshot); if (!rawData) { throw new Error('could not read notebook snapshot'); }