diff --git a/src/vs/workbench/api/browser/mainThreadCustomEditors.ts b/src/vs/workbench/api/browser/mainThreadCustomEditors.ts index e5f1d3f5700..3016f3a0111 100644 --- a/src/vs/workbench/api/browser/mainThreadCustomEditors.ts +++ b/src/vs/workbench/api/browser/mainThreadCustomEditors.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { multibyteAwareBtoa } from 'vs/base/browser/dom'; -import { CancelablePromise, createCancelablePromise } from 'vs/base/common/async'; +import { CancelablePromise, createCancelablePromise, timeout } from 'vs/base/common/async'; import { CancellationToken } from 'vs/base/common/cancellation'; import { isPromiseCanceledError, onUnexpectedError } from 'vs/base/common/errors'; import { Emitter, Event } from 'vs/base/common/event'; @@ -16,7 +16,7 @@ import { URI, UriComponents } from 'vs/base/common/uri'; import * as modes from 'vs/editor/common/modes'; import { localize } from 'vs/nls'; import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; -import { IFileService } from 'vs/platform/files/common/files'; +import { FileChangesEvent, FileChangeType, IFileService } from 'vs/platform/files/common/files'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILabelService } from 'vs/platform/label/common/label'; import { IUndoRedoService, UndoRedoElementType } from 'vs/platform/undoRedo/common/undoRedo'; @@ -274,6 +274,8 @@ namespace HotExitState { class MainThreadCustomEditorModel extends Disposable implements ICustomEditorModel, IWorkingCopy { + #isDisposed = false; + private _fromBackup: boolean = false; private _hotExitState: HotExitState.State = HotExitState.Allowed; private _backupId: string | undefined; @@ -282,9 +284,13 @@ class MainThreadCustomEditorModel extends Disposable implements ICustomEditorMod private _savePoint: number = -1; private readonly _edits: Array = []; private _isDirtyFromContentChange = false; + private _inOrphaned = false; private _ongoingSave?: CancelablePromise; + private readonly _onDidChangeOrphaned = this._register(new Emitter()); + public readonly onDidChangeOrphaned = this._onDidChangeOrphaned.event; + public static async create( instantiationService: IInstantiationService, proxy: extHostProtocol.ExtHostCustomEditorsShape, @@ -321,6 +327,8 @@ class MainThreadCustomEditorModel extends Disposable implements ICustomEditorMod if (_editable) { this._register(workingCopyService.registerWorkingCopy(this)); } + + this._register(_fileService.onDidFilesChange(e => this.onDidFilesChange(e))); } get editorResource() { @@ -328,10 +336,14 @@ class MainThreadCustomEditorModel extends Disposable implements ICustomEditorMod } dispose() { + this.#isDisposed = true; + if (this._editable) { this._undoService.removeElements(this._editorResource); } + this._proxy.$disposeCustomDocument(this._editorResource, this._viewType); + super.dispose(); } @@ -371,6 +383,10 @@ class MainThreadCustomEditorModel extends Disposable implements ICustomEditorMod return this._fromBackup; } + public isOrphaned(): boolean { + return this._inOrphaned; + } + private isUntitled() { return this._editorResource.scheme === Schemas.untitled; } @@ -383,6 +399,58 @@ class MainThreadCustomEditorModel extends Disposable implements ICustomEditorMod //#endregion + private async onDidFilesChange(e: FileChangesEvent): Promise { + let fileEventImpactsModel = false; + let newInOrphanModeGuess: boolean | undefined; + + // If we are currently orphaned, we check if the model file was added back + if (this._inOrphaned) { + const modelFileAdded = e.contains(this.editorResource, FileChangeType.ADDED); + if (modelFileAdded) { + newInOrphanModeGuess = false; + fileEventImpactsModel = true; + } + } + + // Otherwise we check if the model file was deleted + else { + const modelFileDeleted = e.contains(this.editorResource, FileChangeType.DELETED); + if (modelFileDeleted) { + newInOrphanModeGuess = true; + fileEventImpactsModel = true; + } + } + + if (fileEventImpactsModel && this._inOrphaned !== newInOrphanModeGuess) { + let newInOrphanModeValidated: boolean = false; + if (newInOrphanModeGuess) { + // We have received reports of users seeing delete events even though the file still + // exists (network shares issue: https://github.com/microsoft/vscode/issues/13665). + // Since we do not want to mark the model as orphaned, we have to check if the + // file is really gone and not just a faulty file event. + await timeout(100); + + if (this.#isDisposed) { + newInOrphanModeValidated = true; + } else { + const exists = await this._fileService.exists(this.editorResource); + newInOrphanModeValidated = !exists; + } + } + + if (this._inOrphaned !== newInOrphanModeValidated && !this.#isDisposed) { + this.setOrphaned(newInOrphanModeValidated); + } + } + } + + private setOrphaned(orphaned: boolean): void { + if (this._inOrphaned !== orphaned) { + this._inOrphaned = orphaned; + this._onDidChangeOrphaned.fire(); + } + } + public isReadonly() { return !this._editable; } diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts b/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts index 4a0d03d42b3..3206803ff35 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts @@ -11,6 +11,7 @@ import { basename } from 'vs/base/common/path'; import { isEqual } from 'vs/base/common/resources'; import { assertIsDefined } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; +import { localize } from 'vs/nls'; import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILabelService } from 'vs/platform/label/common/label'; @@ -63,9 +64,9 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { return true; } - @memoize getName(): string { - return basename(this.labelService.getUriLabel(this.resource)); + const name = basename(this.labelService.getUriLabel(this.resource)); + return this.decorateLabel(name); } matches(other: IEditorInput): boolean { @@ -92,15 +93,34 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { public getTitle(verbosity?: Verbosity): string { switch (verbosity) { case Verbosity.SHORT: - return this.shortTitle; + return this.decorateLabel(this.shortTitle); default: case Verbosity.MEDIUM: - return this.mediumTitle; + return this.decorateLabel(this.mediumTitle); case Verbosity.LONG: - return this.longTitle; + return this.decorateLabel(this.longTitle); } } + private decorateLabel(label: string): string { + const orphaned = this._modelRef?.object.isOrphaned(); + const readonly = this.isReadonly(); + + if (orphaned && readonly) { + return localize('orphanedReadonlyFile', "{0} (deleted, read-only)", label); + } + + if (orphaned) { + return localize('orphanedFile', "{0} (deleted)", label); + } + + if (readonly) { + return localize('readonlyFile', "{0} (read-only)", label); + } + + return label; + } + public isReadonly(): boolean { return this._modelRef ? this._modelRef.object.isReadonly() : false; } @@ -169,6 +189,7 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { if (!this._modelRef) { this._modelRef = this._register(assertIsDefined(await this.customEditorService.models.tryRetain(this.resource, this.viewType))); this._register(this._modelRef.object.onDidChangeDirty(() => this._onDidChangeDirty.fire())); + this._register(this._modelRef.object.onDidChangeOrphaned(() => this._onDidChangeLabel.fire())); if (this.isDirty()) { this._onDidChangeDirty.fire(); diff --git a/src/vs/workbench/contrib/customEditor/common/customEditor.ts b/src/vs/workbench/contrib/customEditor/common/customEditor.ts index 658ca9c5771..19494b09415 100644 --- a/src/vs/workbench/contrib/customEditor/common/customEditor.ts +++ b/src/vs/workbench/contrib/customEditor/common/customEditor.ts @@ -61,6 +61,9 @@ export interface ICustomEditorModel extends IDisposable { isReadonly(): boolean; + isOrphaned(): boolean; + readonly onDidChangeOrphaned: Event; + isDirty(): boolean; readonly onDidChangeDirty: Event; diff --git a/src/vs/workbench/contrib/customEditor/common/customTextEditorModel.ts b/src/vs/workbench/contrib/customEditor/common/customTextEditorModel.ts index c1fa3b8a408..eff8a252954 100644 --- a/src/vs/workbench/contrib/customEditor/common/customTextEditorModel.ts +++ b/src/vs/workbench/contrib/customEditor/common/customTextEditorModel.ts @@ -10,7 +10,7 @@ import { URI } from 'vs/base/common/uri'; import { IResolvedTextEditorModel, ITextModelService } from 'vs/editor/common/services/resolverService'; import { IRevertOptions, ISaveOptions } from 'vs/workbench/common/editor'; import { ICustomEditorModel } from 'vs/workbench/contrib/customEditor/common/customEditor'; -import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; +import { ITextFileEditorModel, ITextFileService, TextFileEditorModelState } from 'vs/workbench/services/textfile/common/textfiles'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; export class CustomTextEditorModel extends Disposable implements ICustomEditorModel { @@ -28,6 +28,11 @@ export class CustomTextEditorModel extends Disposable implements ICustomEditorMo }); } + private readonly _textFileModel: ITextFileEditorModel | undefined; + + private readonly _onDidChangeOrphaned = this._register(new Emitter()); + public readonly onDidChangeOrphaned = this._onDidChangeOrphaned.event; + private constructor( public readonly viewType: string, private readonly _resource: URI, @@ -38,6 +43,11 @@ export class CustomTextEditorModel extends Disposable implements ICustomEditorMo this._register(_model); + this._textFileModel = this.textFileService.files.get(_resource); + if (this._textFileModel) { + this._register(this._textFileModel.onDidChangeOrphaned(() => this._onDidChangeOrphaned.fire())); + } + this._register(this.textFileService.files.onDidChangeDirty(e => { if (isEqual(this.resource, e.resource)) { this._onDidChangeDirty.fire(); @@ -62,6 +72,10 @@ export class CustomTextEditorModel extends Disposable implements ICustomEditorMo return this.textFileService.isDirty(this.resource); } + public isOrphaned(): boolean { + return !!this._textFileModel?.hasState(TextFileEditorModelState.ORPHAN); + } + private readonly _onDidChangeDirty: Emitter = this._register(new Emitter()); readonly onDidChangeDirty: Event = this._onDidChangeDirty.event;