Add an option to disable closing open files that are removed from the directory (#21962)

This commit is contained in:
Benjamin Pasero
2017-03-06 11:26:56 +01:00
committed by GitHub
parent 4c2600fd3f
commit 16b59a51d4
13 changed files with 344 additions and 267 deletions

View File

@@ -905,6 +905,7 @@ export interface IWorkbenchEditorConfiguration {
showIcons: boolean;
enablePreview: boolean;
enablePreviewFromQuickOpen: boolean;
closeOnExternalFileDelete: boolean;
openPositioning: 'left' | 'right' | 'first' | 'last';
}
};

View File

@@ -132,6 +132,11 @@ configurationRegistry.registerConfiguration({
'type': 'boolean',
'default': true,
'description': nls.localize('activityBarVisibility', "Controls the visibility of the activity bar in the workbench.")
},
'workbench.editor.closeOnExternalFileDelete': {
'type': 'boolean',
'description': nls.localize('closeOnExternalFileDelete', "Controls if editors showing a file should close automatically when the file is deleted or renamed by some other process. Disabling this will keep the editor open as dirty on such an event. Note that deleting from within the application will always close the editor and that dirty files will never close to preserve your data."),
'default': true
}
}
});

View File

@@ -212,9 +212,6 @@ class ResolveSaveConflictMessage implements IMessageWithAction {
return this.editorService.openEditor({ leftResource: URI.from({ scheme: CONFLICT_RESOLUTION_SCHEME, path: resource.fsPath }), rightResource: resource, label: editorLabel, options: { pinned: true } }).then(() => {
// We have to bring the model into conflict resolution mode to prevent subsequent save erros when the user makes edits
this.model.setConflictResolutionMode();
// Inform user
pendingResolveSaveConflictMessages.push(this.messageService.show(Severity.Info, nls.localize('userGuide', "Use the actions in the editor tool bar to either **undo** your changes or **overwrite** the content on disk with your changes")));
});

View File

@@ -159,8 +159,8 @@ export class FileEditorInput extends EditorInput implements IFileEditorInput {
}
const state = model.getState();
if (state === ModelState.CONFLICT || state === ModelState.ERROR) {
return true; // always indicate dirty state if we are in conflict or error state
if (state === ModelState.CONFLICT || state === ModelState.ORPHAN || state === ModelState.ERROR) {
return true; // always indicate dirty state if we are in conflict, orphan or error state
}
if (this.textFileService.getAutoSaveMode() === AutoSaveMode.AFTER_SHORT_DELAY) {
@@ -199,6 +199,10 @@ export class FileEditorInput extends EditorInput implements IFileEditorInput {
});
}
public isResolved(): boolean {
return !!this.textFileService.models.get(this.resource);
}
public getTelemetryDescriptor(): { [key: string]: any; } {
const descriptor = super.getTelemetryDescriptor();
descriptor['resource'] = telemetryURIDescriptor(this.getResource());
@@ -209,7 +213,7 @@ export class FileEditorInput extends EditorInput implements IFileEditorInput {
public dispose(): void {
// Listeners
dispose(this.toUnbind);
this.toUnbind = dispose(this.toUnbind);
super.dispose();
}

View File

@@ -4,15 +4,16 @@
*--------------------------------------------------------------------------------------------*/
'use strict';
import { TPromise } from 'vs/base/common/winjs.base';
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
import errors = require('vs/base/common/errors');
import URI from 'vs/base/common/uri';
import paths = require('vs/base/common/paths');
import { IEditor, IEditorViewState, isCommonCodeEditor } from 'vs/editor/common/editorCommon';
import { toResource, IEditorStacksModel, SideBySideEditorInput, IEditorGroup } from 'vs/workbench/common/editor';
import { toResource, IEditorStacksModel, SideBySideEditorInput, IEditorGroup, IWorkbenchEditorConfiguration } from 'vs/workbench/common/editor';
import { BINARY_FILE_EDITOR_ID } from 'vs/workbench/parts/files/common/files';
import { ITextFileService, ModelState, ITextFileEditorModel } from 'vs/workbench/services/textfile/common/textfiles';
import { FileOperationEvent, FileOperation, IFileService, FileChangeType, FileChangesEvent, isEqual, indexOf } from 'vs/platform/files/common/files';
import { FileOperationEvent, FileOperation, IFileService, FileChangeType, FileChangesEvent, isEqual, indexOf, isParent } from 'vs/platform/files/common/files';
import { FileEditorInput } from 'vs/workbench/parts/files/common/editors/fileEditorInput';
import { IEditorGroupService } from 'vs/workbench/services/group/common/groupService';
import { ILifecycleService } from 'vs/platform/lifecycle/common/lifecycle';
@@ -20,10 +21,14 @@ import { IWorkbenchEditorService } from 'vs/workbench/services/editor/common/edi
import { IDisposable, dispose } from 'vs/base/common/lifecycle';
import { distinct } from 'vs/base/common/arrays';
import { IEnvironmentService } from 'vs/platform/environment/common/environment';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { once } from 'vs/base/common/event';
export class FileEditorTracker implements IWorkbenchContribution {
private stacks: IEditorStacksModel;
private toUnbind: IDisposable[];
protected closeOnExternalFileDelete: boolean;
private mapResourceToUndoDirtyFromExternalDelete: { [resource: string]: () => void };
constructor(
@IWorkbenchEditorService private editorService: IWorkbenchEditorService,
@@ -31,10 +36,14 @@ export class FileEditorTracker implements IWorkbenchContribution {
@ILifecycleService private lifecycleService: ILifecycleService,
@IEditorGroupService private editorGroupService: IEditorGroupService,
@IFileService private fileService: IFileService,
@IEnvironmentService private environmentService: IEnvironmentService
@IEnvironmentService private environmentService: IEnvironmentService,
@IConfigurationService private configurationService: IConfigurationService
) {
this.toUnbind = [];
this.stacks = editorGroupService.getStacksModel();
this.mapResourceToUndoDirtyFromExternalDelete = Object.create(null);
this.onConfigurationUpdated(configurationService.getConfiguration<IWorkbenchEditorConfiguration>());
this.registerListeners();
}
@@ -53,6 +62,17 @@ export class FileEditorTracker implements IWorkbenchContribution {
// Lifecycle
this.lifecycleService.onShutdown(this.dispose, this);
// Configuration
this.toUnbind.push(this.configurationService.onDidUpdateConfiguration(e => this.onConfigurationUpdated(e.config)));
}
private onConfigurationUpdated(configuration: IWorkbenchEditorConfiguration): void {
if (configuration.workbench && configuration.workbench.editor && typeof configuration.workbench.editor.closeOnExternalFileDelete === 'boolean') {
this.closeOnExternalFileDelete = configuration.workbench.editor.closeOnExternalFileDelete;
} else {
this.closeOnExternalFileDelete = true; // default
}
}
// Note: there is some duplication with the other file event handler below. Since we cannot always rely on the disk events
@@ -68,94 +88,145 @@ export class FileEditorTracker implements IWorkbenchContribution {
// Handle deletes
if (e.operation === FileOperation.DELETE || e.operation === FileOperation.MOVE) {
this.handleDeletes(e.resource, e.target ? e.target.resource : void 0);
this.handleDeletes(e.resource, false, e.target ? e.target.resource : void 0);
}
}
private onFileChanges(e: FileChangesEvent): void {
// Handle added
if (e.gotAdded()) {
this.handleAdded(e);
}
// Handle updates
this.handleUpdates(e);
// Handle deletes
if (e.gotDeleted()) {
this.handleDeletes(e);
this.handleDeletes(e, true);
}
}
private handleDeletes(arg1: URI | FileChangesEvent, movedTo?: URI): void {
const fileInputs = this.getOpenedFileInputs();
fileInputs.forEach(input => {
if (input.isDirty()) {
return; // we never dispose dirty files
}
private handleAdded(e: FileChangesEvent): void {
// Flag models as saved that are identical to disk contents
// (only if we do not dispose from external deletes and caused them to be dirty)
if (!this.closeOnExternalFileDelete) {
const dirtyFileEditors = this.getOpenedFileEditors(true /* dirty only */);
dirtyFileEditors.forEach(editor => {
const resource = editor.getResource();
// See if we have a stored undo operation for this editor
const undo = this.mapResourceToUndoDirtyFromExternalDelete[resource.toString()];
if (undo) {
// file showing in editor was added
if (e.contains(resource, FileChangeType.ADDED)) {
undo();
this.mapResourceToUndoDirtyFromExternalDelete[resource.toString()] = void 0;
}
}
});
}
}
private handleDeletes(arg1: URI | FileChangesEvent, isExternal: boolean, movedTo?: URI): void {
const nonDirtyFileEditors = this.getOpenedFileEditors(false /* non dirty only */);
nonDirtyFileEditors.forEach(editor => {
const resource = editor.getResource();
// Special case: a resource was renamed to the same path with different casing. Since our paths
// API is treating the paths as equal (they are on disk), we end up disposing the input we just
// renamed. The workaround is to detect that we do not dispose any input we are moving the file to
if (movedTo && movedTo.fsPath === input.getResource().fsPath) {
if (movedTo && movedTo.fsPath === resource.fsPath) {
return;
}
let matches = false;
if (arg1 instanceof FileChangesEvent) {
matches = arg1.contains(input.getResource(), FileChangeType.DELETED);
matches = arg1.contains(resource, FileChangeType.DELETED);
} else {
matches = paths.isEqualOrParent(input.getResource().toString(), arg1.toString());
matches = isEqual(resource.fsPath, arg1.fsPath) || isParent(resource.fsPath, arg1.fsPath);
}
if (matches) {
// TODO@Ben this is for debugging https://github.com/Microsoft/vscode/issues/13665
if (this.environmentService.verbose) {
this.fileService.existsFile(input.getResource()).done(exists => {
if (!exists) {
input.dispose();
console.warn(`[13665] The file ${input.getResource().fsPath} actually does not exist anymore.`);
setTimeout(() => {
this.fileService.existsFile(input.getResource()).done(exists => {
console.warn(`[13665] The file ${input.getResource().fsPath} after 2 seconds exists: ${exists}`);
}, error => {
console.error(`[13665] Error checking existance for ${input.getResource().fsPath} after 2 seconds!`, error);
});
}, 2000);
} else {
console.warn(`[13665] The file ${input.getResource().fsPath} actually still exists!`);
}
}, error => {
console.error(`[13665] Error checking existance for ${input.getResource().fsPath}`, error);
input.dispose();
});
if (!matches) {
return;
}
// Handle deletes in opened editors depending on:
// - the user has not disabled the setting closeOnExternalFileDelete
// - the file change is local or external
// - the input is not resolved (we need to dispose because we cannot restore otherwise since we do not have the contents)
if (this.closeOnExternalFileDelete || !isExternal || !editor.isResolved()) {
// 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 close an editor without reason, we have to check if the
// file is really gone and not just a faulty file event (TODO@Ben revisit when we
// have a more stable file watcher in place for this scenario).
// This only applies to external file events, so we need to check for the isExternal
// flag.
let checkExists: TPromise<boolean>;
if (isExternal) {
checkExists = TPromise.timeout(100).then(() => this.fileService.existsFile(resource));
} else {
input.dispose();
checkExists = TPromise.as(false);
}
checkExists.done(exists => {
if (!exists && !editor.isDisposed()) {
editor.dispose();
} else if (this.environmentService.verbose) {
console.warn(`File exists even though we received a delete event: ${resource.toString()}`);
}
});
}
// Otherwise we want to keep the editor open and mark it as dirty since its underlying resource was deleted
else {
const model = this.textFileService.models.get(resource);
if (model && model.getState() === ModelState.SAVED) {
const undo = model.setOrphaned();
this.mapResourceToUndoDirtyFromExternalDelete[resource.toString()] = undo;
once(model.onDispose)(() => {
this.mapResourceToUndoDirtyFromExternalDelete[resource.toString()] = void 0;
});
}
}
});
}
private getOpenedFileInputs(): FileEditorInput[] {
const inputs: FileEditorInput[] = [];
private getOpenedFileEditors(dirtyState: boolean): FileEditorInput[] {
const editors: FileEditorInput[] = [];
const stacks = this.editorGroupService.getStacksModel();
stacks.groups.forEach(group => {
group.getEditors().forEach(input => {
if (input instanceof FileEditorInput) {
inputs.push(input);
} else if (input instanceof SideBySideEditorInput) {
const master = input.master;
const details = input.details;
group.getEditors().forEach(editor => {
if (editor instanceof FileEditorInput) {
if (!!editor.isDirty() === dirtyState) {
editors.push(editor);
}
} else if (editor instanceof SideBySideEditorInput) {
const master = editor.master;
const details = editor.details;
if (master instanceof FileEditorInput) {
inputs.push(master);
if (!!master.isDirty() === dirtyState) {
editors.push(master);
}
}
if (details instanceof FileEditorInput) {
inputs.push(details);
if (!!details.isDirty() === dirtyState) {
editors.push(details);
}
}
}
});
});
return inputs;
return editors;
}
private handleMovedFileInOpenedEditors(oldResource: URI, newResource: URI): void {
@@ -166,7 +237,7 @@ export class FileEditorTracker implements IWorkbenchContribution {
const resource = input.getResource();
// Update Editor if file (or any parent of the input) got renamed or moved
if (paths.isEqualOrParent(resource.fsPath, oldResource.fsPath)) {
if (isEqual(resource.fsPath, oldResource.fsPath) || isParent(resource.fsPath, oldResource.fsPath)) {
let reopenFileResource: URI;
if (oldResource.toString() === resource.toString()) {
reopenFileResource = newResource; // file got moved

View File

@@ -59,10 +59,12 @@ suite('Files - FileEditorInput', () => {
input = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar.html'), void 0);
const inputToResolve = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/file.js'), void 0);
const sameOtherInput = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/file.js'), void 0);
const inputToResolve: FileEditorInput = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/file.js'), void 0);
const sameOtherInput: FileEditorInput = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/file.js'), void 0);
return inputToResolve.resolve(true).then(resolved => {
assert.ok(inputToResolve.isResolved());
const resolvedModelA = resolved;
return inputToResolve.resolve(true).then(resolved => {
assert(resolvedModelA === resolved); // OK: Resolved Model cached globally per input

View File

@@ -18,6 +18,14 @@ import { EditorStacksModel } from 'vs/workbench/common/editor/editorStacksModel'
import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
import { FileOperation, FileOperationEvent, FileChangesEvent, FileChangeType, IFileService } from 'vs/platform/files/common/files';
import { TextFileEditorModel } from 'vs/workbench/services/textfile/common/textFileEditorModel';
import { once } from 'vs/base/common/event';
class TestFileEditorTracker extends FileEditorTracker {
setCloseOnExternalFileDelete(value: boolean): void {
this.closeOnExternalFileDelete = value;
}
}
function toResource(path) {
return URI.file(join('C:\\', new Buffer(this.test.fullTitle()).toString('base64'), path));
@@ -80,7 +88,7 @@ suite('Files - FileEditorTracker', () => {
tracker.dispose();
});
test('disposes when resource gets deleted - remote file changes', function () {
test('disposes when resource gets deleted - remote file changes', function (done) {
const stacks = accessor.editorGroupService.getStacksModel() as EditorStacksModel;
const group = stacks.openGroup('first', true);
@@ -95,21 +103,76 @@ suite('Files - FileEditorTracker', () => {
assert.ok(!input.isDisposed());
accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource, type: FileChangeType.DELETED }]));
assert.ok(input.isDisposed());
group.closeEditor(input);
input = instantiationService.createInstance(FileEditorInput, resource, void 0);
once(input.onDispose)(() => {
assert.ok(input.isDisposed());
group.closeEditor(input);
input = instantiationService.createInstance(FileEditorInput, resource, void 0);
group.openEditor(input);
const other = toResource.call(this, '/foo/barfoo');
accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource: other, type: FileChangeType.DELETED }]));
assert.ok(!input.isDisposed());
accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource: parent, type: FileChangeType.DELETED }]));
once(input.onDispose)(() => {
assert.ok(input.isDisposed());
tracker.dispose();
done();
});
});
});
test('marks dirty when resource gets deleted and undirty when added again - remote file changes - closeOnExternalFileDelete = false', function (done) {
const stacks = accessor.editorGroupService.getStacksModel() as EditorStacksModel;
const group = stacks.openGroup('first', true);
const tracker = instantiationService.createInstance(TestFileEditorTracker);
tracker.setCloseOnExternalFileDelete(false);
assert.ok(tracker);
const resource = toResource.call(this, '/foo/bar/updatefile.js');
let input = instantiationService.createInstance(FileEditorInput, resource, void 0);
group.openEditor(input);
const other = toResource.call(this, '/foo/barfoo');
input.resolve().then(() => {
accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource, type: FileChangeType.DELETED }]));
assert.equal(input.isDirty(), true);
accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource: other, type: FileChangeType.DELETED }]));
assert.ok(!input.isDisposed());
accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource, type: FileChangeType.ADDED }]));
assert.equal(input.isDirty(), false);
accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource: parent, type: FileChangeType.DELETED }]));
assert.ok(input.isDisposed());
done();
});
});
tracker.dispose();
test('marks dirty when resource gets deleted and undirty when added again unless model changed meanwhile - remote file changes - closeOnExternalFileDelete = false', function (done) {
const stacks = accessor.editorGroupService.getStacksModel() as EditorStacksModel;
const group = stacks.openGroup('first', true);
const tracker = instantiationService.createInstance(TestFileEditorTracker);
tracker.setCloseOnExternalFileDelete(false);
assert.ok(tracker);
const resource = toResource.call(this, '/foo/bar/updatefile.js');
let input = instantiationService.createInstance(FileEditorInput, resource, void 0);
group.openEditor(input);
input.resolve().then(model => {
accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource, type: FileChangeType.DELETED }]));
assert.equal(input.isDirty(), true);
model.textEditorModel.setValue('This is cool');
accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource, type: FileChangeType.ADDED }]));
assert.equal(input.isDirty(), true);
done();
});
});
test('file change event updates model', function (done) {

View File

@@ -60,13 +60,15 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
private contentChangeEventScheduler: RunOnceScheduler;
private saveSequentializer: SaveSequentializer;
private disposed: boolean;
private inConflictResolutionMode: boolean;
private inErrorMode: boolean;
private lastSaveAttemptTime: number;
private createTextEditorModelPromise: TPromise<TextFileEditorModel>;
private _onDidContentChange: Emitter<StateChange>;
private _onDidStateChange: Emitter<StateChange>;
private inConflictMode: boolean;
private inOrphanMode: boolean;
private inErrorMode: boolean;
constructor(
resource: URI,
preferredEncoding: string,
@@ -173,16 +175,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
TextFileEditorModel.saveParticipant = handler;
}
/**
* When set, will disable any saving (including auto save) until the model is loaded again. This allows to resolve save conflicts
* without running into subsequent save errors when editing the model.
*/
public setConflictResolutionMode(): void {
diag('setConflictResolutionMode() - enabled conflict resolution mode', this.resource, new Date());
this.inConflictResolutionMode = true;
}
/**
* Discards any local changes and replaces the model with the contents of the version on disk.
*
@@ -431,7 +423,9 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
// In this case we clear the dirty flag and emit a SAVED event to indicate this state.
// Note: we currently only do this check when auto-save is turned off because there you see
// a dirty indicator that you want to get rid of when undoing to the saved version.
if (!this.autoSaveAfterMilliesEnabled && this.textEditorModel.getAlternativeVersionId() === this.bufferSavedVersionId) {
// Note: if the model is in orphan mode, we cannot clear the dirty indicator because there
// is no version on disk after all.
if (!this.autoSaveAfterMilliesEnabled && !this.inOrphanMode && this.textEditorModel.getAlternativeVersionId() === this.bufferSavedVersionId) {
diag('onModelContentChanged() - model content changed back to last saved version', this.resource, new Date());
// Clear flags
@@ -453,7 +447,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
// Start auto save process unless we are in conflict resolution mode and unless it is disabled
if (this.autoSaveAfterMilliesEnabled) {
if (!this.inConflictResolutionMode) {
if (!this.inConflictMode && !this.inOrphanMode) {
this.doAutoSave(this.versionId);
} else {
diag('makeDirty() - prevented save because we are in conflict resolution mode', this.resource, new Date());
@@ -642,9 +636,14 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
}, error => {
diag(`doSave(${versionId}) - exit - resulted in a save error: ${error.toString()}`, this.resource, new Date());
// Flag as error state
// Flag as error state in the model
this.inErrorMode = true;
// Look out for a save conflict
if ((<IFileOperationResult>error).fileOperationResult === FileOperationResult.FILE_MODIFIED_SINCE) {
this.inConflictMode = true;
}
// Show to user
this.onSaveError(error);
@@ -656,13 +655,15 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
private setDirty(dirty: boolean): () => void {
const wasDirty = this.dirty;
const wasInConflictResolutionMode = this.inConflictResolutionMode;
const wasInConflictMode = this.inConflictMode;
const wasInOrphanMode = this.inOrphanMode;
const wasInErrorMode = this.inErrorMode;
const oldBufferSavedVersionId = this.bufferSavedVersionId;
if (!dirty) {
this.dirty = false;
this.inConflictResolutionMode = false;
this.inConflictMode = false;
this.inOrphanMode = false;
this.inErrorMode = false;
// we remember the models alternate version id to remember when the version
@@ -680,7 +681,8 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
// Return function to revert this call
return () => {
this.dirty = wasDirty;
this.inConflictResolutionMode = wasInConflictResolutionMode;
this.inConflictMode = wasInConflictMode;
this.inOrphanMode = wasInOrphanMode;
this.inErrorMode = wasInErrorMode;
this.bufferSavedVersionId = oldBufferSavedVersionId;
};
@@ -737,10 +739,14 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
* Returns the state this text text file editor model is in with regards to changes and saving.
*/
public getState(): ModelState {
if (this.inConflictResolutionMode) {
if (this.inConflictMode) {
return ModelState.CONFLICT;
}
if (this.inOrphanMode) {
return ModelState.ORPHAN;
}
if (this.inErrorMode) {
return ModelState.ERROR;
}
@@ -778,7 +784,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
this.makeDirty();
}
if (!this.inConflictResolutionMode) {
if (!this.inConflictMode) {
this.save({ overwriteEncoding: true }).done(null, onUnexpectedError);
}
}
@@ -846,9 +852,49 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
return this.lastResolvedDiskStat;
}
/**
* Makes this model an orphan, indicating that the file on disk no longer exists. Returns
* a function to undo this and go back to the previous state.
*/
public setOrphaned(): () => void {
// Only saved models can turn into orphans
if (this.getState() !== ModelState.SAVED) {
return () => { };
}
// Mark as dirty
const undo = this.setDirty(true);
// Mark as oprhaned
this.inOrphanMode = true;
// Emit as Event if we turned dirty
this._onDidStateChange.fire(StateChange.DIRTY);
// Return undo function
const currentVersionId = this.versionId;
return () => {
// Leave orphan mode
this.inOrphanMode = false;
// Undo is only valid if version is the one we left with
if (this.versionId === currentVersionId) {
// Revert
undo();
// Events
this._onDidStateChange.fire(StateChange.SAVED);
}
};
}
public dispose(): void {
this.disposed = true;
this.inConflictResolutionMode = false;
this.inConflictMode = false;
this.inOrphanMode = false;
this.inErrorMode = false;
this.toDispose = dispose(this.toDispose);

View File

@@ -13,10 +13,8 @@ import { IEditorGroupService } from 'vs/workbench/services/group/common/groupSer
import { ModelState, ITextFileEditorModel, ITextFileEditorModelManager, TextFileModelChangeEvent, StateChange } from 'vs/workbench/services/textfile/common/textfiles';
import { ILifecycleService } from 'vs/platform/lifecycle/common/lifecycle';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { FileOperation, FileOperationEvent, FileChangesEvent, IFileService } from 'vs/platform/files/common/files';
export class TextFileEditorModelManager implements ITextFileEditorModelManager {
private toUnbind: IDisposable[];
private _onModelDisposed: Emitter<URI>;
@@ -41,8 +39,7 @@ export class TextFileEditorModelManager implements ITextFileEditorModelManager {
constructor(
@ILifecycleService private lifecycleService: ILifecycleService,
@IInstantiationService private instantiationService: IInstantiationService,
@IEditorGroupService private editorGroupService: IEditorGroupService,
@IFileService private fileService: IFileService
@IEditorGroupService private editorGroupService: IEditorGroupService
) {
this.toUnbind = [];
@@ -77,10 +74,6 @@ export class TextFileEditorModelManager implements ITextFileEditorModelManager {
this.toUnbind.push(this.editorGroupService.onEditorsChanged(() => this.onEditorsChanged()));
this.toUnbind.push(this.editorGroupService.getStacksModel().onEditorClosed(() => this.onEditorClosed()));
// File changes
this.toUnbind.push(this.fileService.onAfterOperation(e => this.onFileOperation(e)));
this.toUnbind.push(this.fileService.onFileChanges(e => this.onFileChanges(e)));
// Lifecycle
this.lifecycleService.onShutdown(this.dispose, this);
}
@@ -93,46 +86,41 @@ export class TextFileEditorModelManager implements ITextFileEditorModelManager {
this.disposeUnusedModels();
}
private disposeModelIfPossible(resource: URI): void {
const model = this.get(resource);
if (this.canDispose(model)) {
private disposeUnusedModels(): void {
// To not grow our text file model cache infinitly, we dispose models that
// are not showing up in any opened editor.
// TODO@Ben this is a workaround until we have adopted model references from
// the resolver service (https://github.com/Microsoft/vscode/issues/17888)
this.getAll(void 0, model => this.canDispose(model)).forEach(model => {
model.dispose();
}
}
private onFileOperation(e: FileOperationEvent): void {
if (e.operation === FileOperation.MOVE || e.operation === FileOperation.DELETE) {
this.disposeModelIfPossible(e.resource); // dispose models of moved or deleted files
}
}
private onFileChanges(e: FileChangesEvent): void {
// Dispose inputs that got deleted
e.getDeleted().forEach(deleted => {
this.disposeModelIfPossible(deleted.resource);
});
}
private canDispose(textModel: ITextFileEditorModel): boolean {
if (!textModel) {
private canDispose(model: ITextFileEditorModel): boolean {
if (!model) {
return false; // we need data!
}
if (textModel.isDisposed()) {
if (model.isDisposed()) {
return false; // already disposed
}
if (textModel.textEditorModel && textModel.textEditorModel.isAttachedToEditor()) {
return false; // never dispose when attached to editor
if (this.mapResourceToPendingModelLoaders[model.getResource().toString()]) {
return false; // not yet loaded
}
if (textModel.getState() !== ModelState.SAVED) {
return false; // never dispose unsaved models
if (model.getState() !== ModelState.SAVED) {
return false; // not saved
}
if (this.mapResourceToPendingModelLoaders[textModel.getResource().toString()]) {
return false; // never dispose models that we are about to load at the same time
if (model.textEditorModel && model.textEditorModel.isAttachedToEditor()) {
return false; // never dispose when attached to editor (e.g. viewzones)
}
if (this.editorGroupService.getStacksModel().isOpen(model.getResource())) {
return false; // never dispose when opened inside an editor (e.g. tabs)
}
return true;
@@ -299,7 +287,7 @@ export class TextFileEditorModelManager implements ITextFileEditorModelManager {
});
}
public getAll(resource?: URI): ITextFileEditorModel[] {
public getAll(resource?: URI, filter?: (model: ITextFileEditorModel) => boolean): ITextFileEditorModel[] {
if (resource) {
const res = this.mapResourceToModel[resource.toString()];
@@ -309,7 +297,10 @@ export class TextFileEditorModelManager implements ITextFileEditorModelManager {
const keys = Object.keys(this.mapResourceToModel);
const res: ITextFileEditorModel[] = [];
for (let i = 0; i < keys.length; i++) {
res.push(this.mapResourceToModel[keys[i]]);
const model = this.mapResourceToModel[keys[i]];
if (!filter || filter(model)) {
res.push(model);
}
}
return res;
@@ -378,21 +369,6 @@ export class TextFileEditorModelManager implements ITextFileEditorModelManager {
this.mapResourceToModelContentChangeListener = Object.create(null);
}
private disposeUnusedModels(): void {
// To not grow our text file model cache infinitly, we dispose models that
// are not showing up in any opened editor.
// Get all cached file models
this.getAll()
// Only models that are not open inside the editor area
.filter(model => !this.editorGroupService.getStacksModel().isOpen(model.getResource()))
// Dispose
.forEach(model => this.disposeModelIfPossible(model.getResource()));
}
public dispose(): void {
this.toUnbind = dispose(this.toUnbind);
}

View File

@@ -479,8 +479,8 @@ export abstract class TextFileService implements ITextFileService {
private doSaveAllFiles(arg1?: any /* URI[] */, reason?: SaveReason): TPromise<ITextFileOperationResult> {
const dirtyFileModels = this.getDirtyFileModels(Array.isArray(arg1) ? arg1 : void 0 /* Save All */)
.filter(model => {
if (model.getState() === ModelState.CONFLICT && (reason === SaveReason.AUTO || reason === SaveReason.FOCUS_CHANGE || reason === SaveReason.WINDOW_CHANGE)) {
return false; // if model is in save conflict, do not save when reason is auto save, since saving needs manual resolution by the user
if ((model.getState() === ModelState.CONFLICT || model.getState() === ModelState.ORPHAN) && (reason === SaveReason.AUTO || reason === SaveReason.FOCUS_CHANGE || reason === SaveReason.WINDOW_CHANGE)) {
return false; // if model is in an orphan or in save conflict, do not save unless save reason is explicit or not provided at all
}
return true;

View File

@@ -40,7 +40,23 @@ export enum ModelState {
SAVED,
DIRTY,
PENDING_SAVE,
/**
* A model is in conflict mode when changes cannot be saved because the
* underlying file has changed. Models in conflict mode are always dirty.
*/
CONFLICT,
/**
* A model is in orphan state when the underlying file has been deleted.
* Models in orphan mode are always dirty.
*/
ORPHAN,
/**
* Any error that happens during a save that is not causing the CONFLICT state.
* Models in error mode are always diry.
*/
ERROR
}
@@ -176,10 +192,10 @@ export interface ITextFileEditorModel extends ITextEditorModel, IEncodingSupport
revert(soft?: boolean): TPromise<void>;
setConflictResolutionMode(): void;
getValue(): string;
setOrphaned(): () => void;
isDirty(): boolean;
isResolved(): boolean;

View File

@@ -210,31 +210,6 @@ suite('Files - TextFileEditorModel', () => {
}, error => onError(error, done));
});
test('Conflict Resolution Mode', function (done) {
const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8');
model.load().done(() => {
model.setConflictResolutionMode();
model.textEditorModel.setValue('foo');
assert.ok(model.isDirty());
assert.equal(model.getState(), ModelState.CONFLICT);
return model.revert().then(() => {
model.textEditorModel.setValue('bar');
assert.ok(model.isDirty());
return model.save().then(() => {
assert.ok(!model.isDirty());
model.dispose();
done();
});
});
}, error => onError(error, done));
});
test('Auto Save triggered when model changes', function (done) {
let eventCounter = 0;
const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index.txt'), 'utf8');
@@ -382,6 +357,25 @@ suite('Files - TextFileEditorModel', () => {
}, error => onError(error, done));
});
test('setOrphaned basics', function (done) {
const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8');
return model.load().then(() => {
let undo = model.setOrphaned();
assert.equal(model.isDirty(), true);
undo();
assert.equal(model.isDirty(), false);
// can not undo when model changed meanwhile
undo = model.setOrphaned();
model.textEditorModel.setValue('foo');
undo();
assert.equal(model.isDirty(), true);
done();
}, error => onError(error, done));
});
test('SaveSequentializer - pending basics', function (done) {
const sequentializer = new SaveSequentializer();

View File

@@ -10,12 +10,12 @@ import URI from 'vs/base/common/uri';
import { TPromise } from 'vs/base/common/winjs.base';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { TextFileEditorModelManager } from 'vs/workbench/services/textfile/common/textFileEditorModelManager';
import { join, basename } from 'vs/base/common/paths';
import { join } from 'vs/base/common/paths';
import { workbenchInstantiationService, TestEditorGroupService, createFileInput, TestFileService } from 'vs/workbench/test/workbenchTestServices';
import { onError } from 'vs/base/test/common/utils';
import { IEditorGroupService } from 'vs/workbench/services/group/common/groupService';
import { TextFileEditorModel } from 'vs/workbench/services/textfile/common/textFileEditorModel';
import { FileOperation, FileOperationEvent, FileChangesEvent, FileChangeType, IFileService } from 'vs/platform/files/common/files';
import { IFileService } from 'vs/platform/files/common/files';
import { IModelService } from 'vs/editor/common/services/modelService';
export class TestTextFileEditorModelManager extends TextFileEditorModelManager {
@@ -25,17 +25,6 @@ export class TestTextFileEditorModelManager extends TextFileEditorModelManager {
}
}
function toStat(resource: URI) {
return {
resource,
isDirectory: false,
hasChildren: false,
name: basename(resource.fsPath),
mtime: Date.now(),
etag: 'etag'
};
}
class ServiceAccessor {
constructor(
@IEditorGroupService public editorGroupService: TestEditorGroupService,
@@ -179,93 +168,6 @@ suite('Files - TextFileEditorModelManager', () => {
manager.dispose();
});
test('local file changes dispose model - delete', function () {
const manager: TestTextFileEditorModelManager = instantiationService.createInstance(TestTextFileEditorModelManager);
const resource = toResource('/path/index.txt');
const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, resource, 'utf8');
manager.add(resource, model);
assert.ok(!model.isDisposed());
// delete operation
accessor.fileService.fireAfterOperation(new FileOperationEvent(resource, FileOperation.DELETE));
assert.ok(model.isDisposed());
model.dispose();
assert.ok(!accessor.modelService.getModel(model.getResource()));
manager.dispose();
});
test('local file changes dispose model - move', function () {
const manager: TestTextFileEditorModelManager = instantiationService.createInstance(TestTextFileEditorModelManager);
const resource = toResource('/path/index.txt');
const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, resource, 'utf8');
manager.add(resource, model);
assert.ok(!model.isDisposed());
// move operation
accessor.fileService.fireAfterOperation(new FileOperationEvent(resource, FileOperation.MOVE, toStat(toResource('/path/index_moved.txt'))));
assert.ok(model.isDisposed());
assert.ok(!accessor.modelService.getModel(model.getResource()));
manager.dispose();
});
test('file event delete dispose model', function () {
const manager: TestTextFileEditorModelManager = instantiationService.createInstance(TestTextFileEditorModelManager);
const resource = toResource('/path/index.txt');
const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, resource, 'utf8');
manager.add(resource, model);
assert.ok(!model.isDisposed());
// delete event (watcher)
accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource, type: FileChangeType.DELETED }]));
assert.ok(model.isDisposed());
assert.ok(!accessor.modelService.getModel(model.getResource()));
manager.dispose();
});
test('file change event does NOT dispose model if happening < 2 second after last save', function (done) {
const manager: TestTextFileEditorModelManager = instantiationService.createInstance(TestTextFileEditorModelManager);
const resource = toResource('/path/index.txt');
const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, resource, 'utf8');
manager.add(resource, model);
assert.ok(!model.isDisposed());
model.load().done(resolved => {
model.textEditorModel.setValue('changed');
return model.save().then(() => {
// change event (watcher)
accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource, type: FileChangeType.UPDATED }]));
assert.ok(!model.isDisposed());
model.dispose();
assert.ok(!accessor.modelService.getModel(model.getResource()));
manager.dispose();
done();
});
}, error => onError(error, done));
});
test('events', function (done) {
const manager: TestTextFileEditorModelManager = instantiationService.createInstance(TestTextFileEditorModelManager);