Label custom editor as deleted if the backing file is deleted

Fixes #99270

This should make it so a custom editor tab is tagged `(deleted)` if the backing file is deleted
This commit is contained in:
Matt Bierner 2021-02-26 21:07:47 -08:00
parent d89e2e17a5
commit f3f4eebecb
4 changed files with 114 additions and 8 deletions

View file

@ -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<number> = [];
private _isDirtyFromContentChange = false;
private _inOrphaned = false;
private _ongoingSave?: CancelablePromise<void>;
private readonly _onDidChangeOrphaned = this._register(new Emitter<void>());
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<void> {
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;
}

View file

@ -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();

View file

@ -61,6 +61,9 @@ export interface ICustomEditorModel extends IDisposable {
isReadonly(): boolean;
isOrphaned(): boolean;
readonly onDidChangeOrphaned: Event<void>;
isDirty(): boolean;
readonly onDidChangeDirty: Event<void>;

View file

@ -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<void>());
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<void> = this._register(new Emitter<void>());
readonly onDidChangeDirty: Event<void> = this._onDidChangeDirty.event;