提交 2dabc3ba 编写于 作者: B Benjamin Pasero 提交者: GitHub

Improved behaviour of dealing with deleted files (for #11642) (#22506)

上级 f1c2355a
......@@ -905,7 +905,7 @@ export interface IWorkbenchEditorConfiguration {
showIcons: boolean;
enablePreview: boolean;
enablePreviewFromQuickOpen: boolean;
closeOnExternalFileDelete: boolean;
closeOnFileDelete: boolean;
openPositioning: 'left' | 'right' | 'first' | 'last';
revealIfOpen: boolean;
}
......
......@@ -138,9 +138,9 @@ configurationRegistry.registerConfiguration({
'default': true,
'description': nls.localize('activityBarVisibility', "Controls the visibility of the activity bar in the workbench.")
},
'workbench.editor.closeOnExternalFileDelete': {
'workbench.editor.closeOnFileDelete': {
'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."),
'description': nls.localize('closeOnFileDelete', "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
}
}
......
......@@ -85,7 +85,7 @@ export class DirtyFilesTracker implements IWorkbenchContribution {
// Only dirty models that are not PENDING_SAVE
const model = this.textFileService.models.get(e.resource);
const shouldOpen = model && model.isDirty() && model.getState() !== ModelState.PENDING_SAVE;
const shouldOpen = model && model.isDirty() && !model.hasState(ModelState.PENDING_SAVE);
// Only if not open already
return shouldOpen && !this.stacks.isOpen(e.resource);
......
......@@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
'use strict';
import { localize } from 'vs/nls';
import { TPromise } from 'vs/base/common/winjs.base';
import paths = require('vs/base/common/paths');
import labels = require('vs/base/common/labels');
......@@ -68,6 +69,7 @@ export class FileEditorInput extends EditorInput implements IFileEditorInput {
this.toUnbind.push(this.textFileService.models.onModelSaveError(e => this.onDirtyStateChange(e)));
this.toUnbind.push(this.textFileService.models.onModelSaved(e => this.onDirtyStateChange(e)));
this.toUnbind.push(this.textFileService.models.onModelReverted(e => this.onDirtyStateChange(e)));
this.toUnbind.push(this.textFileService.models.onModelOrphanedChanged(e => this.onModelOrphanedChanged(e)));
}
private onDirtyStateChange(e: TextFileModelChangeEvent): void {
......@@ -76,6 +78,12 @@ export class FileEditorInput extends EditorInput implements IFileEditorInput {
}
}
private onModelOrphanedChanged(e: TextFileModelChangeEvent): void {
if (e.resource.toString() === this.resource.toString()) {
this._onDidChangeLabel.fire();
}
}
public setResource(resource: URI): void {
this.resource = resource;
......@@ -130,7 +138,7 @@ export class FileEditorInput extends EditorInput implements IFileEditorInput {
this.name = paths.basename(this.resource.fsPath);
}
return this.name;
return this.decorateOrphanedFiles(this.name);
}
public getDescription(): string {
......@@ -142,14 +150,29 @@ export class FileEditorInput extends EditorInput implements IFileEditorInput {
}
public getTitle(verbosity: Verbosity): string {
let title: string;
switch (verbosity) {
case Verbosity.SHORT:
return this.shortTitle ? this.shortTitle : (this.shortTitle = this.getName());
title = this.shortTitle ? this.shortTitle : (this.shortTitle = this.getName());
break;
case Verbosity.MEDIUM:
return this.mediumTitle ? this.mediumTitle : (this.mediumTitle = labels.getPathLabel(this.resource, this.contextService));
title = this.mediumTitle ? this.mediumTitle : (this.mediumTitle = labels.getPathLabel(this.resource, this.contextService));
break;
case Verbosity.LONG:
return this.longTitle ? this.longTitle : (this.longTitle = labels.tildify(labels.getPathLabel(this.resource), this.environmentService.userHome));
title = this.longTitle ? this.longTitle : (this.longTitle = labels.tildify(labels.getPathLabel(this.resource), this.environmentService.userHome));
break;
}
return this.decorateOrphanedFiles(title);
}
private decorateOrphanedFiles(label: string): string {
const model = this.textFileService.models.get(this.resource);
if (model && model.hasState(ModelState.ORPHAN)) {
return localize('orphanedFile', "{0} (deleted from disk)", label);
}
return label;
}
public isDirty(): boolean {
......@@ -158,9 +181,8 @@ export class FileEditorInput extends EditorInput implements IFileEditorInput {
return false;
}
const state = model.getState();
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 (model.hasState(ModelState.CONFLICT) || model.hasState(ModelState.ERROR)) {
return true; // always indicate dirty state if we are in conflict or error state
}
if (this.textFileService.getAutoSaveMode() === AutoSaveMode.AFTER_SHORT_DELAY) {
......
......@@ -12,7 +12,7 @@ import paths = require('vs/base/common/paths');
import { IEditor, IEditorViewState, isCommonCodeEditor } from 'vs/editor/common/editorCommon';
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 { ITextFileService, ITextFileEditorModel } from 'vs/workbench/services/textfile/common/textfiles';
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';
......@@ -22,14 +22,11 @@ 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';
import { TextFileEditorModel } from 'vs/workbench/services/textfile/common/textFileEditorModel';
export class FileEditorTracker implements IWorkbenchContribution {
private stacks: IEditorStacksModel;
private toUnbind: IDisposable[];
protected closeOnExternalFileDelete: boolean;
private mapResourceToUndoDirtyFromExternalDelete: { [resource: string]: () => void };
protected closeOnFileDelete: boolean;
constructor(
@IWorkbenchEditorService private editorService: IWorkbenchEditorService,
......@@ -42,7 +39,6 @@ export class FileEditorTracker implements IWorkbenchContribution {
) {
this.toUnbind = [];
this.stacks = editorGroupService.getStacksModel();
this.mapResourceToUndoDirtyFromExternalDelete = Object.create(null);
this.onConfigurationUpdated(configurationService.getConfiguration<IWorkbenchEditorConfiguration>());
......@@ -69,10 +65,10 @@ export class FileEditorTracker implements IWorkbenchContribution {
}
private onConfigurationUpdated(configuration: IWorkbenchEditorConfiguration): void {
if (configuration.workbench && configuration.workbench.editor && typeof configuration.workbench.editor.closeOnExternalFileDelete === 'boolean') {
this.closeOnExternalFileDelete = configuration.workbench.editor.closeOnExternalFileDelete;
if (configuration.workbench && configuration.workbench.editor && typeof configuration.workbench.editor.closeOnFileDelete === 'boolean') {
this.closeOnFileDelete = configuration.workbench.editor.closeOnFileDelete;
} else {
this.closeOnExternalFileDelete = true; // default
this.closeOnFileDelete = true; // default
}
}
......@@ -95,11 +91,6 @@ export class FileEditorTracker implements IWorkbenchContribution {
private onFileChanges(e: FileChangesEvent): void {
// Handle added
if (e.gotAdded()) {
this.handleAdded(e);
}
// Handle updates
this.handleUpdates(e);
......@@ -109,57 +100,34 @@ export class FileEditorTracker implements IWorkbenchContribution {
}
}
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 */);
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 === resource.fsPath) {
return;
}
let matches = false;
if (arg1 instanceof FileChangesEvent) {
matches = arg1.contains(resource, FileChangeType.DELETED);
} else {
matches = isEqual(resource.fsPath, arg1.fsPath) || isParent(resource.fsPath, arg1.fsPath);
}
if (!matches) {
return;
}
// Handle deletes in opened editors depending on:
// - the user has not disabled the setting closeOnExternalFileDelete
// - the user has not disabled the setting closeOnFileDelete
// - 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()) {
if (this.closeOnFileDelete || !isExternal || !editor.isResolved()) {
// 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 === resource.fsPath) {
return;
}
let matches = false;
if (arg1 instanceof FileChangesEvent) {
matches = arg1.contains(resource, FileChangeType.DELETED);
} else {
matches = isEqual(resource.fsPath, arg1.fsPath) || isParent(resource.fsPath, arg1.fsPath);
}
if (!matches) {
return;
}
// 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).
......@@ -183,18 +151,6 @@ export class FileEditorTracker implements IWorkbenchContribution {
}
});
}
// 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) as TextFileEditorModel;
if (model && model.getState() === ModelState.SAVED) {
const undo = model.setOrphaned();
this.mapResourceToUndoDirtyFromExternalDelete[resource.toString()] = undo;
once(model.onDispose)(() => {
this.mapResourceToUndoDirtyFromExternalDelete[resource.toString()] = void 0;
});
}
}
});
}
......@@ -292,7 +248,7 @@ export class FileEditorTracker implements IWorkbenchContribution {
// and updated right after.
const modelsToUpdate = distinct([...e.getUpdated(), ...e.getAdded()]
.map(u => this.textFileService.models.get(u.resource))
.filter(model => model && model.getState() === ModelState.SAVED), m => m.getResource().toString());
.filter(model => model && !model.isDirty()), m => m.getResource().toString());
// Handle updates to visible editors specially to preserve view state
const visibleModels = this.handleUpdatesToVisibleEditors(e);
......@@ -328,7 +284,7 @@ export class FileEditorTracker implements IWorkbenchContribution {
if (textModel) {
// We only ever update models that are in good saved state
if (textModel.getState() === ModelState.SAVED) {
if (!textModel.isDirty()) {
const codeEditor = editor.getControl() as IEditor;
const viewState = codeEditor.saveViewState();
const lastKnownEtag = textModel.getETag();
......
......@@ -22,8 +22,8 @@ import { once } from 'vs/base/common/event';
class TestFileEditorTracker extends FileEditorTracker {
setCloseOnExternalFileDelete(value: boolean): void {
this.closeOnExternalFileDelete = value;
setCloseOnFileDelete(value: boolean): void {
this.closeOnFileDelete = value;
}
}
......@@ -88,6 +88,44 @@ suite('Files - FileEditorTracker', () => {
tracker.dispose();
});
test('disposes input when resource gets deleted - local file changes - even when closeOnFileDelete = false', function () {
const stacks = accessor.editorGroupService.getStacksModel() as EditorStacksModel;
const group = stacks.openGroup('first', true);
const tracker = instantiationService.createInstance(TestFileEditorTracker);
tracker.setCloseOnFileDelete(false);
assert.ok(tracker);
const parent = toResource.call(this, '/foo/bar');
const resource = toResource.call(this, '/foo/bar/updatefile.js');
let input = instantiationService.createInstance(FileEditorInput, resource, void 0);
group.openEditor(input);
assert.ok(!input.isDisposed());
accessor.fileService.fireAfterOperation(new FileOperationEvent(resource, FileOperation.DELETE));
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.fireAfterOperation(new FileOperationEvent(other, FileOperation.DELETE));
assert.ok(!input.isDisposed());
accessor.fileService.fireAfterOperation(new FileOperationEvent(parent, FileOperation.DELETE));
assert.ok(input.isDisposed());
// Move
const to = toResource.call(this, '/foo/barfoo/change.js');
accessor.fileService.fireAfterOperation(new FileOperationEvent(resource, FileOperation.MOVE, to));
assert.ok(input.isDisposed());
tracker.dispose();
});
test('disposes when resource gets deleted - remote file changes', function (done) {
const stacks = accessor.editorGroupService.getStacksModel() as EditorStacksModel;
const group = stacks.openGroup('first', true);
......@@ -127,52 +165,23 @@ suite('Files - FileEditorTracker', () => {
});
});
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);
input.resolve().then(() => {
accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource, type: FileChangeType.DELETED }]));
assert.equal(input.isDirty(), true);
accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource, type: FileChangeType.ADDED }]));
assert.equal(input.isDirty(), false);
done();
});
});
test('marks dirty when resource gets deleted and undirty when added again unless model changed meanwhile - remote file changes - closeOnExternalFileDelete = false', function (done) {
test('keeps open when resource gets deleted - remote file changes - closeOnFileDelete = false', function () {
const stacks = accessor.editorGroupService.getStacksModel() as EditorStacksModel;
const group = stacks.openGroup('first', true);
const tracker = instantiationService.createInstance(TestFileEditorTracker);
tracker.setCloseOnExternalFileDelete(false);
tracker.setCloseOnFileDelete(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.DELETED }]));
accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource, type: FileChangeType.ADDED }]));
assert.equal(input.isDirty(), true);
assert.ok(!input.isDisposed());
done();
});
tracker.dispose();
});
test('file change event updates model', function (done) {
......
......@@ -23,7 +23,7 @@ import { ITextFileService, IAutoSaveConfiguration, ModelState, ITextFileEditorMo
import { EncodingMode, EditorModel } from 'vs/workbench/common/editor';
import { BaseTextEditorModel } from 'vs/workbench/common/editor/textEditorModel';
import { IBackupFileService, BACKUP_FILE_RESOLVE_OPTIONS } from 'vs/workbench/services/backup/common/backup';
import { IFileService, IFileStat, IFileOperationResult, FileOperationResult, IContent, CONTENT_CHANGE_EVENT_BUFFER_DELAY } from 'vs/platform/files/common/files';
import { IFileService, IFileStat, IFileOperationResult, FileOperationResult, IContent, CONTENT_CHANGE_EVENT_BUFFER_DELAY, FileChangesEvent, FileChangeType } from 'vs/platform/files/common/files';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IMessageService, Severity } from 'vs/platform/message/common/message';
import { IModeService } from 'vs/editor/common/services/modeService';
......@@ -41,6 +41,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
public static ID = 'workbench.editors.files.textFileEditorModel';
public static DEFAULT_CONTENT_CHANGE_BUFFER_DELAY = CONTENT_CHANGE_EVENT_BUFFER_DELAY;
public static DEFAULT_ORPHANED_CHANGE_BUFFER_DELAY = 100;
private static saveErrorHandler: ISaveErrorHandler;
private static saveParticipant: ISaveParticipant;
......@@ -58,6 +59,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
private autoSaveAfterMilliesEnabled: boolean;
private autoSavePromise: TPromise<void>;
private contentChangeEventScheduler: RunOnceScheduler;
private orphanedChangeEventScheduler: RunOnceScheduler;
private saveSequentializer: SaveSequentializer;
private disposed: boolean;
private lastSaveAttemptTime: number;
......@@ -101,12 +103,16 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
this.contentChangeEventScheduler = new RunOnceScheduler(() => this._onDidContentChange.fire(StateChange.CONTENT_CHANGE), TextFileEditorModel.DEFAULT_CONTENT_CHANGE_BUFFER_DELAY);
this.toDispose.push(this.contentChangeEventScheduler);
this.orphanedChangeEventScheduler = new RunOnceScheduler(() => this._onDidStateChange.fire(StateChange.ORPHANED_CHANGE), TextFileEditorModel.DEFAULT_ORPHANED_CHANGE_BUFFER_DELAY);
this.toDispose.push(this.orphanedChangeEventScheduler);
this.updateAutoSaveConfiguration(textFileService.getAutoSaveConfiguration());
this.registerListeners();
}
private registerListeners(): void {
this.toDispose.push(this.fileService.onFileChanges(e => this.onFileChanges(e)));
this.toDispose.push(this.textFileService.onAutoSaveConfigurationChange(config => this.updateAutoSaveConfiguration(config)));
this.toDispose.push(this.textFileService.onFilesAssociationChange(e => this.onFilesAssociationChange()));
this.toDispose.push(this.onDidStateChange(e => {
......@@ -121,6 +127,26 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
}));
}
private onFileChanges(e: FileChangesEvent): void {
// Handle added if we are in orphan mode
if (this.inOrphanMode && e.contains(this.resource, FileChangeType.ADDED)) {
this.setOrphaned(false);
}
// Handle deletes
if (!this.inOrphanMode && e.contains(this.resource, FileChangeType.DELETED)) {
this.setOrphaned(true);
}
}
private setOrphaned(orphaned: boolean): void {
if (this.inOrphanMode !== orphaned) {
this.inOrphanMode = orphaned;
this.orphanedChangeEventScheduler.schedule();
}
}
private updateAutoSaveConfiguration(config: IAutoSaveConfiguration): void {
if (typeof config.autoSaveDelay === 'number' && config.autoSaveDelay > 0) {
this.autoSaveAfterMillies = config.autoSaveDelay;
......@@ -204,15 +230,8 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
this._onDidStateChange.fire(StateChange.REVERTED);
}, error => {
// FileNotFound means the file got deleted meanwhile, so emit revert event because thats ok
if ((<IFileOperationResult>error).fileOperationResult === FileOperationResult.FILE_NOT_FOUND) {
this._onDidStateChange.fire(StateChange.REVERTED);
}
// Set flags back to previous values, we are still dirty if revert failed
else {
undo();
}
undo();
return TPromise.wrapError(error);
});
......@@ -279,12 +298,23 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
// Resolve Content
return this.textFileService
.resolveTextContent(this.resource, { acceptTextOnly: true, etag, encoding: this.preferredEncoding })
.then(content => this.loadWithContent(content), (error: IFileOperationResult) => this.handleLoadError(error));
.then(content => this.handleLoadSuccess(content), error => this.handleLoadError(error));
}
private handleLoadSuccess(content: IRawTextContent): TPromise<EditorModel> {
// Clear orphaned state when load was successful
this.setOrphaned(false);
return this.loadWithContent(content);
}
private handleLoadError(error: IFileOperationResult): TPromise<EditorModel> {
const result = error.fileOperationResult;
// Apply orphaned state based on error code
this.setOrphaned(result === FileOperationResult.FILE_NOT_FOUND);
// NotModified status is expected and can be handled gracefully
if (result === FileOperationResult.FILE_NOT_MODIFIED_SINCE) {
this.setDirty(false); // Ensure we are not tracking a stale state
......@@ -292,6 +322,13 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
return TPromise.as<EditorModel>(this);
}
// Ignore when a model has been resolved once and the file was deleted meanwhile. Since
// we already have the model loaded, we can return to this state and update the orphaned
// flag to indicate that this model has no version on disk anymore.
if (this.isResolved() && result === FileOperationResult.FILE_NOT_FOUND) {
return TPromise.as<EditorModel>(this);
}
// Otherwise bubble up the error
return TPromise.wrapError(error);
}
......@@ -435,9 +472,7 @@ 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.
// 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) {
if (!this.autoSaveAfterMilliesEnabled && this.textEditorModel.getAlternativeVersionId() === this.bufferSavedVersionId) {
diag('onModelContentChanged() - model content changed back to last saved version', this.resource, new Date());
// Clear flags
......@@ -459,7 +494,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.inConflictMode && !this.inOrphanMode) {
if (!this.inConflictMode) {
this.doAutoSave(this.versionId);
} else {
diag('makeDirty() - prevented save because we are in conflict resolution mode', this.resource, new Date());
......@@ -598,11 +633,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
// to write the contents to disk, as they are already on disk. we still want to trigger
// a change on the file though so that external file watchers can be notified
if (force && !this.dirty && reason === SaveReason.EXPLICIT && versionId === newVersionId) {
return this.fileService.touchFile(this.resource).then(stat => {
// Updated resolved stat with updated stat since touching it might have changed mtime
this.updateLastResolvedDiskStat(stat);
}, () => void 0 /* gracefully ignore errors if just touching */);
return this.doTouch();
}
// update versionId with its new value (if pre-save changes happened)
......@@ -665,17 +696,27 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
}));
}
private doTouch(): TPromise<void> {
if (this.inOrphanMode) {
return TPromise.as(void 0); // do not create the file if this model is orphaned
}
return this.fileService.touchFile(this.resource).then(stat => {
// Updated resolved stat with updated stat since touching it might have changed mtime
this.updateLastResolvedDiskStat(stat);
}, () => void 0 /* gracefully ignore errors if just touching */);
}
private setDirty(dirty: boolean): () => void {
const wasDirty = this.dirty;
const wasInConflictMode = this.inConflictMode;
const wasInOrphanMode = this.inOrphanMode;
const wasInErrorMode = this.inErrorMode;
const oldBufferSavedVersionId = this.bufferSavedVersionId;
if (!dirty) {
this.dirty = false;
this.inConflictMode = false;
this.inOrphanMode = false;
this.inErrorMode = false;
// we remember the models alternate version id to remember when the version
......@@ -694,7 +735,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
return () => {
this.dirty = wasDirty;
this.inConflictMode = wasInConflictMode;
this.inOrphanMode = wasInOrphanMode;
this.inErrorMode = wasInErrorMode;
this.bufferSavedVersionId = oldBufferSavedVersionId;
};
......@@ -748,33 +788,23 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
}
/**
* Returns the state this text text file editor model is in with regards to changes and saving.
* Answers if this model is in a specific state.
*/
public getState(): ModelState {
if (this.inConflictMode) {
return ModelState.CONFLICT;
public hasState(state: ModelState): boolean {
switch (state) {
case ModelState.CONFLICT:
return this.inConflictMode;
case ModelState.DIRTY:
return this.dirty;
case ModelState.ERROR:
return this.inErrorMode;
case ModelState.ORPHAN:
return this.inOrphanMode;
case ModelState.PENDING_SAVE:
return this.saveSequentializer.hasPendingSave();
case ModelState.SAVED:
return !this.dirty;
}
if (this.inOrphanMode) {
return ModelState.ORPHAN;
}
if (this.inErrorMode) {
return ModelState.ERROR;
}
if (!this.dirty) {
return ModelState.SAVED;
}
if (this.saveSequentializer.hasPendingSave()) {
return ModelState.PENDING_SAVE;
}
if (this.dirty) {
return ModelState.DIRTY;
}
return undefined;
}
public getEncoding(): string {
......@@ -864,45 +894,6 @@ 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.inConflictMode = false;
......@@ -913,7 +904,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
this.createTextEditorModelPromise = null;
this.cancelAutoSavePromise();
this.contentChangeEventScheduler.cancel();
super.dispose();
}
......
......@@ -10,7 +10,7 @@ import URI from 'vs/base/common/uri';
import { TextFileEditorModel } from 'vs/workbench/services/textfile/common/textFileEditorModel';
import { dispose, IDisposable } from 'vs/base/common/lifecycle';
import { IEditorGroupService } from 'vs/workbench/services/group/common/groupService';
import { ModelState, ITextFileEditorModel, ITextFileEditorModelManager, TextFileModelChangeEvent, StateChange } from 'vs/workbench/services/textfile/common/textfiles';
import { 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';
......@@ -24,6 +24,7 @@ export class TextFileEditorModelManager implements ITextFileEditorModelManager {
private _onModelSaved: Emitter<TextFileModelChangeEvent>;
private _onModelReverted: Emitter<TextFileModelChangeEvent>;
private _onModelEncodingChanged: Emitter<TextFileModelChangeEvent>;
private _onModelOrphanedChanged: Emitter<TextFileModelChangeEvent>;
private _onModelsDirtyEvent: Event<TextFileModelChangeEvent[]>;
private _onModelsSaveError: Event<TextFileModelChangeEvent[]>;
......@@ -50,6 +51,7 @@ export class TextFileEditorModelManager implements ITextFileEditorModelManager {
this._onModelSaved = new Emitter<TextFileModelChangeEvent>();
this._onModelReverted = new Emitter<TextFileModelChangeEvent>();
this._onModelEncodingChanged = new Emitter<TextFileModelChangeEvent>();
this._onModelOrphanedChanged = new Emitter<TextFileModelChangeEvent>();
this.toUnbind.push(this._onModelDisposed);
this.toUnbind.push(this._onModelContentChanged);
......@@ -58,6 +60,7 @@ export class TextFileEditorModelManager implements ITextFileEditorModelManager {
this.toUnbind.push(this._onModelSaved);
this.toUnbind.push(this._onModelReverted);
this.toUnbind.push(this._onModelEncodingChanged);
this.toUnbind.push(this._onModelOrphanedChanged);
this.mapResourceToModel = Object.create(null);
this.mapResourceToDisposeListener = Object.create(null);
......@@ -111,7 +114,7 @@ export class TextFileEditorModelManager implements ITextFileEditorModelManager {
return false; // not yet loaded
}
if (model.getState() !== ModelState.SAVED) {
if (model.isDirty()) {
return false; // not saved
}
......@@ -154,6 +157,10 @@ export class TextFileEditorModelManager implements ITextFileEditorModelManager {
return this._onModelEncodingChanged.event;
}
public get onModelOrphanedChanged(): Event<TextFileModelChangeEvent> {
return this._onModelOrphanedChanged.event;
}
public get onModelsDirty(): Event<TextFileModelChangeEvent[]> {
if (!this._onModelsDirtyEvent) {
this._onModelsDirtyEvent = this.debounce(this.onModelDirty);
......@@ -249,6 +256,9 @@ export class TextFileEditorModelManager implements ITextFileEditorModelManager {
case StateChange.ENCODING:
this._onModelEncodingChanged.fire(event);
break;
case StateChange.ORPHANED_CHANGE:
this._onModelOrphanedChanged.fire(event);
break;
}
});
......
......@@ -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 || 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
if (model.hasState(ModelState.CONFLICT) && (reason === SaveReason.AUTO || reason === SaveReason.FOCUS_CHANGE || reason === SaveReason.WINDOW_CHANGE)) {
return false; // if model is in save conflict, do not save unless save reason is explicit or not provided at all
}
return true;
......@@ -589,28 +589,37 @@ export abstract class TextFileService implements ITextFileService {
}
private doSaveTextFileAs(sourceModel: ITextFileEditorModel | UntitledEditorModel, resource: URI, target: URI): TPromise<void> {
let targetModelResolver: TPromise<ITextFileEditorModel>;
// create the target file empty if it does not exist already
return this.fileService.resolveFile(target).then(stat => stat, () => null).then(stat => stat || this.fileService.updateContent(target, '')).then(stat => {
// Prefer an existing model if it is already loaded for the given target resource
const targetModel = this.models.get(target);
if (targetModel && targetModel.isResolved()) {
targetModelResolver = TPromise.as(targetModel);
}
// resolve a model for the file (which can be binary if the file is not a text file)
return this.models.loadOrCreate(target).then((targetModel: ITextFileEditorModel) => {
// Otherwise create the target file empty if it does not exist already and resolve it from there
else {
targetModelResolver = this.fileService.resolveFile(target).then(stat => stat, () => null).then(stat => stat || this.fileService.updateContent(target, '')).then(stat => {
return this.models.loadOrCreate(target);
});
}
// take over encoding and model value from source model
targetModel.updatePreferredEncoding(sourceModel.getEncoding());
targetModel.textEditorModel.setValue(sourceModel.getValue());
return targetModelResolver.then(targetModel => {
// save model
return targetModel.save();
}, error => {
// take over encoding and model value from source model
targetModel.updatePreferredEncoding(sourceModel.getEncoding());
targetModel.textEditorModel.setValue(sourceModel.getValue());
// binary model: delete the file and run the operation again
if ((<IFileOperationResult>error).fileOperationResult === FileOperationResult.FILE_IS_BINARY || (<IFileOperationResult>error).fileOperationResult === FileOperationResult.FILE_TOO_LARGE) {
return this.fileService.del(target).then(() => this.doSaveTextFileAs(sourceModel, resource, target));
}
// save model
return targetModel.save();
}, error => {
return TPromise.wrapError(error);
});
// binary model: delete the file and run the operation again
if ((<IFileOperationResult>error).fileOperationResult === FileOperationResult.FILE_IS_BINARY || (<IFileOperationResult>error).fileOperationResult === FileOperationResult.FILE_TOO_LARGE) {
return this.fileService.del(target).then(() => this.doSaveTextFileAs(sourceModel, resource, target));
}
return TPromise.wrapError(error);
});
}
......
......@@ -49,7 +49,6 @@ export enum ModelState {
/**
* A model is in orphan state when the underlying file has been deleted.
* Models in orphan mode are always dirty.
*/
ORPHAN,
......@@ -67,7 +66,8 @@ export enum StateChange {
SAVED,
REVERTED,
ENCODING,
CONTENT_CHANGE
CONTENT_CHANGE,
ORPHANED_CHANGE
}
export class TextFileModelChangeEvent {
......@@ -151,6 +151,7 @@ export interface ITextFileEditorModelManager {
onModelSaveError: Event<TextFileModelChangeEvent>;
onModelSaved: Event<TextFileModelChangeEvent>;
onModelReverted: Event<TextFileModelChangeEvent>;
onModelOrphanedChanged: Event<TextFileModelChangeEvent>;
onModelsDirty: Event<TextFileModelChangeEvent[]>;
onModelsSaveError: Event<TextFileModelChangeEvent[]>;
......@@ -180,7 +181,7 @@ export interface ITextFileEditorModel extends ITextEditorModel, IEncodingSupport
getResource(): URI;
getState(): ModelState;
hasState(state: ModelState): boolean;
getETag(): string;
......@@ -194,6 +195,8 @@ export interface ITextFileEditorModel extends ITextEditorModel, IEncodingSupport
isDirty(): boolean;
isResolved(): boolean;
isDisposed(): boolean;
}
......
......@@ -11,14 +11,14 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti
import { EncodingMode } from 'vs/workbench/common/editor';
import { TextFileEditorModel, SaveSequentializer } from 'vs/workbench/services/textfile/common/textFileEditorModel';
import { ITextFileService, ModelState, StateChange } from 'vs/workbench/services/textfile/common/textfiles';
import { workbenchInstantiationService, TestTextFileService, createFileInput } from 'vs/workbench/test/workbenchTestServices';
import { workbenchInstantiationService, TestTextFileService, createFileInput, TestFileService } from 'vs/workbench/test/workbenchTestServices';
import { onError, toResource } from 'vs/base/test/common/utils';
import { TextFileEditorModelManager } from 'vs/workbench/services/textfile/common/textFileEditorModelManager';
import { FileOperationResult, IFileOperationResult } from 'vs/platform/files/common/files';
import { FileOperationResult, IFileOperationResult, IFileService, FileChangesEvent, FileChangeType } from 'vs/platform/files/common/files';
import { IModelService } from 'vs/editor/common/services/modelService';
class ServiceAccessor {
constructor( @ITextFileService public textFileService: TestTextFileService, @IModelService public modelService: IModelService) {
constructor( @ITextFileService public textFileService: TestTextFileService, @IModelService public modelService: IModelService, @IFileService public fileService: TestFileService) {
}
}
......@@ -99,7 +99,7 @@ suite('Files - TextFileEditorModel', () => {
test('Load does not trigger save', function (done) {
const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index.txt'), 'utf8');
assert.equal(model.getState(), ModelState.SAVED);
assert.ok(model.hasState(ModelState.SAVED));
model.onDidStateChange(e => {
assert.ok(e !== StateChange.DIRTY && e !== StateChange.SAVED);
......@@ -123,7 +123,7 @@ suite('Files - TextFileEditorModel', () => {
model.textEditorModel.setValue('foo');
assert.ok(model.isDirty());
assert.equal(model.getState(), ModelState.DIRTY);
assert.ok(model.hasState(ModelState.DIRTY));
return model.load().then(() => {
assert.ok(model.isDirty());
......@@ -210,6 +210,24 @@ suite('Files - TextFileEditorModel', () => {
}, error => onError(error, done));
});
test('Load error is handled gracefully if model already exists', function (done) {
const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8');
model.load().done(() => {
accessor.textFileService.setResolveTextContentErrorOnce(<IFileOperationResult>{
message: 'error',
fileOperationResult: FileOperationResult.FILE_NOT_FOUND
});
return model.load().then((model: TextFileEditorModel) => {
assert.ok(model);
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');
......@@ -357,28 +375,20 @@ suite('Files - TextFileEditorModel', () => {
}, error => onError(error, done));
});
test('Orphaned models', function (done) {
test('Orphaned models - state and event', 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);
assert.equal(model.getState(), ModelState.ORPHAN);
undo();
assert.equal(model.isDirty(), false);
assert.notEqual(model.getState(), ModelState.ORPHAN);
model.onDidStateChange(e => {
if (e === StateChange.ORPHANED_CHANGE) {
done();
}
});
// can not undo when model changed meanwhile (but orphaned state clears)
undo = model.setOrphaned();
assert.equal(model.getState(), ModelState.ORPHAN);
model.textEditorModel.setValue('foo');
undo();
assert.equal(model.isDirty(), true);
assert.notEqual(model.getState(), ModelState.ORPHAN);
accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource: model.getResource(), type: FileChangeType.DELETED }]));
assert.ok(model.hasState(ModelState.ORPHAN));
done();
}, error => onError(error, done));
accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource: model.getResource(), type: FileChangeType.ADDED }]));
assert.ok(!model.hasState(ModelState.ORPHAN));
});
test('SaveSequentializer - pending basics', function (done) {
......
......@@ -15,7 +15,7 @@ import { workbenchInstantiationService, TestEditorGroupService, createFileInput,
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 { IFileService } from 'vs/platform/files/common/files';
import { IFileService, FileChangesEvent, FileChangeType } from 'vs/platform/files/common/files';
import { IModelService } from 'vs/editor/common/services/modelService';
export class TestTextFileEditorModelManager extends TextFileEditorModelManager {
......@@ -169,6 +169,9 @@ suite('Files - TextFileEditorModelManager', () => {
});
test('events', function (done) {
TextFileEditorModel.DEFAULT_CONTENT_CHANGE_BUFFER_DELAY = 0;
TextFileEditorModel.DEFAULT_ORPHANED_CHANGE_BUFFER_DELAY = 0;
const manager: TestTextFileEditorModelManager = instantiationService.createInstance(TestTextFileEditorModelManager);
const resource1 = toResource('/path/index.txt');
......@@ -178,34 +181,44 @@ suite('Files - TextFileEditorModelManager', () => {
let revertedCounter = 0;
let savedCounter = 0;
let encodingCounter = 0;
let orphanedCounter = 0;
let disposeCounter = 0;
let contentCounter = 0;
TextFileEditorModel.DEFAULT_CONTENT_CHANGE_BUFFER_DELAY = 0;
manager.onModelDirty(e => {
dirtyCounter++;
assert.equal(e.resource.toString(), resource1.toString());
if (e.resource.toString() === resource1.toString()) {
dirtyCounter++;
}
});
manager.onModelReverted(e => {
revertedCounter++;
assert.equal(e.resource.toString(), resource1.toString());
if (e.resource.toString() === resource1.toString()) {
revertedCounter++;
}
});
manager.onModelSaved(e => {
savedCounter++;
assert.equal(e.resource.toString(), resource1.toString());
if (e.resource.toString() === resource1.toString()) {
savedCounter++;
}
});
manager.onModelEncodingChanged(e => {
encodingCounter++;
assert.equal(e.resource.toString(), resource1.toString());
if (e.resource.toString() === resource1.toString()) {
encodingCounter++;
}
});
manager.onModelOrphanedChanged(e => {
if (e.resource.toString() === resource1.toString()) {
orphanedCounter++;
}
});
manager.onModelContentChanged(e => {
contentCounter++;
assert.equal(e.resource.toString(), resource1.toString());
if (e.resource.toString() === resource1.toString()) {
contentCounter++;
}
});
manager.onModelDisposed(e => {
......@@ -213,6 +226,9 @@ suite('Files - TextFileEditorModelManager', () => {
});
manager.loadOrCreate(resource1, 'utf8').done(model1 => {
accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource: resource1, type: FileChangeType.DELETED }]));
accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource: resource1, type: FileChangeType.ADDED }]));
return manager.loadOrCreate(resource2, 'utf8').then(model2 => {
model1.textEditorModel.setValue('changed');
model1.updatePreferredEncoding('utf16');
......@@ -232,8 +248,9 @@ suite('Files - TextFileEditorModelManager', () => {
assert.equal(encodingCounter, 2);
// content change event if done async
TPromise.timeout(0).then(() => {
TPromise.timeout(10).then(() => {
assert.equal(contentCounter, 2);
assert.equal(orphanedCounter, 1);
model1.dispose();
model2.dispose();
......
......@@ -343,7 +343,7 @@ suite('Workbench UI Services', () => {
});
});
test('DelegatingWorkbenchEditorService', function () {
test('DelegatingWorkbenchEditorService', function (done) {
let instantiationService = workbenchInstantiationService();
let activeInput: EditorInput = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/something.js'), void 0);
......@@ -372,13 +372,23 @@ suite('Workbench UI Services', () => {
let ed = instantiationService.createInstance(MyEditor, 'my.editor');
let inp = instantiationService.createInstance(StringEditorInput, 'name', 'description', 'hello world', 'text/plain', false);
let delegate: any = instantiationService.createInstance(<any>DelegatingWorkbenchEditorService, (input: EditorInput, options?: EditorOptions, arg3?: any) => {
let delegate = instantiationService.createInstance(DelegatingWorkbenchEditorService);
delegate.setEditorOpenHandler((input, options?) => {
assert.strictEqual(input, inp);
return TPromise.as(ed);
});
delegate.setEditorCloseHandler((position, input) => {
assert.strictEqual(input, inp);
done();
return TPromise.as(void 0);
});
delegate.openEditor(inp);
delegate.closeEditor(0, inp);
});
test('ScopedService', () => {
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册