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');
}