/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { join } from 'vs/base/common/path'; import * as nls from 'vs/nls'; import { Event, Emitter } from 'vs/base/common/event'; import { guessMimeTypes } from 'vs/base/common/mime'; import { toErrorMessage } from 'vs/base/common/errorMessage'; import { URI } from 'vs/base/common/uri'; import { isUndefinedOrNull, withUndefinedAsNull } from 'vs/base/common/types'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { ITextFileService, IAutoSaveConfiguration, ModelState, ITextFileEditorModel, ISaveOptions, ISaveErrorHandler, ISaveParticipant, StateChange, SaveReason, ITextFileContent, ILoadOptions, LoadReason, IResolvedTextFileEditorModel } from 'vs/workbench/services/textfile/common/textfiles'; import { EncodingMode } from 'vs/workbench/common/editor'; import { BaseTextEditorModel } from 'vs/workbench/common/editor/textEditorModel'; import { IBackupFileService } from 'vs/workbench/services/backup/common/backup'; import { IFileService, FileOperationError, FileOperationResult, CONTENT_CHANGE_EVENT_BUFFER_DELAY, FileChangesEvent, FileChangeType, IFileStatWithMetadata, etag } from 'vs/platform/files/common/files'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IModeService, ILanguageSelection } from 'vs/editor/common/services/modeService'; import { IModelService } from 'vs/editor/common/services/modelService'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { RunOnceScheduler, timeout } from 'vs/base/common/async'; import { ITextBufferFactory } from 'vs/editor/common/model'; import { hash } from 'vs/base/common/hash'; import { createTextBufferFactory } from 'vs/editor/common/model/textModel'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { isLinux } from 'vs/base/common/platform'; import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { ILogService } from 'vs/platform/log/common/log'; import { isEqual, isEqualOrParent, extname, basename } from 'vs/base/common/resources'; import { onUnexpectedError } from 'vs/base/common/errors'; /** * The text file editor model listens to changes to its underlying code editor model and saves these changes through the file service back to the disk. */ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFileEditorModel { static DEFAULT_CONTENT_CHANGE_BUFFER_DELAY = CONTENT_CHANGE_EVENT_BUFFER_DELAY; static DEFAULT_ORPHANED_CHANGE_BUFFER_DELAY = 100; static WHITELIST_JSON = ['package.json', 'package-lock.json', 'tsconfig.json', 'jsconfig.json', 'bower.json', '.eslintrc.json', 'tslint.json', 'composer.json']; static WHITELIST_WORKSPACE_JSON = ['settings.json', 'extensions.json', 'tasks.json', 'launch.json']; private static saveErrorHandler: ISaveErrorHandler; static setSaveErrorHandler(handler: ISaveErrorHandler): void { TextFileEditorModel.saveErrorHandler = handler; } private static saveParticipant: ISaveParticipant | null; static setSaveParticipant(handler: ISaveParticipant | null): void { TextFileEditorModel.saveParticipant = handler; } private readonly _onDidContentChange: Emitter = this._register(new Emitter()); get onDidContentChange(): Event { return this._onDidContentChange.event; } private readonly _onDidStateChange: Emitter = this._register(new Emitter()); get onDidStateChange(): Event { return this._onDidStateChange.event; } private resource: URI; private contentEncoding: string; // encoding as reported from disk private preferredEncoding: string; // encoding as chosen by the user private dirty: boolean; private versionId: number; private bufferSavedVersionId: number; private lastResolvedDiskStat: IFileStatWithMetadata; private blockModelContentChange: boolean; private autoSaveAfterMillies?: number; private autoSaveAfterMilliesEnabled: boolean; private autoSaveDisposable?: IDisposable; private contentChangeEventScheduler: RunOnceScheduler; private orphanedChangeEventScheduler: RunOnceScheduler; private saveSequentializer: SaveSequentializer; private disposed: boolean; private lastSaveAttemptTime: number; private createTextEditorModelPromise: Promise | null; private inConflictMode: boolean; private inOrphanMode: boolean; private inErrorMode: boolean; constructor( resource: URI, preferredEncoding: string, @INotificationService private readonly notificationService: INotificationService, @IModeService modeService: IModeService, @IModelService modelService: IModelService, @IFileService private readonly fileService: IFileService, @IInstantiationService private readonly instantiationService: IInstantiationService, @ITelemetryService private readonly telemetryService: ITelemetryService, @ITextFileService private readonly textFileService: ITextFileService, @IBackupFileService private readonly backupFileService: IBackupFileService, @IEnvironmentService private readonly environmentService: IEnvironmentService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @ILogService private readonly logService: ILogService ) { super(modelService, modeService); this.resource = resource; this.preferredEncoding = preferredEncoding; this.inOrphanMode = false; this.dirty = false; this.versionId = 0; this.lastSaveAttemptTime = 0; this.saveSequentializer = new SaveSequentializer(); this.contentChangeEventScheduler = this._register(new RunOnceScheduler(() => this._onDidContentChange.fire(StateChange.CONTENT_CHANGE), TextFileEditorModel.DEFAULT_CONTENT_CHANGE_BUFFER_DELAY)); this.orphanedChangeEventScheduler = this._register(new RunOnceScheduler(() => this._onDidStateChange.fire(StateChange.ORPHANED_CHANGE), TextFileEditorModel.DEFAULT_ORPHANED_CHANGE_BUFFER_DELAY)); this.updateAutoSaveConfiguration(textFileService.getAutoSaveConfiguration()); this.registerListeners(); } private registerListeners(): void { this._register(this.fileService.onFileChanges(e => this.onFileChanges(e))); this._register(this.textFileService.onAutoSaveConfigurationChange(config => this.updateAutoSaveConfiguration(config))); this._register(this.textFileService.onFilesAssociationChange(e => this.onFilesAssociationChange())); this._register(this.onDidStateChange(e => this.onStateChange(e))); } private onStateChange(e: StateChange): void { if (e === StateChange.REVERTED) { // Cancel any content change event promises as they are no longer valid. this.contentChangeEventScheduler.cancel(); // Refire state change reverted events as content change events this._onDidContentChange.fire(StateChange.REVERTED); } } private onFileChanges(e: FileChangesEvent): void { let fileEventImpactsModel = false; let newInOrphanModeGuess: boolean | undefined; // If we are currently orphaned, we check if the model file was added back if (this.inOrphanMode) { const modelFileAdded = e.contains(this.resource, FileChangeType.ADDED); if (modelFileAdded) { newInOrphanModeGuess = false; fileEventImpactsModel = true; } } // Otherwise we check if the model file was deleted else { const modelFileDeleted = e.contains(this.resource, FileChangeType.DELETED); if (modelFileDeleted) { newInOrphanModeGuess = true; fileEventImpactsModel = true; } } if (fileEventImpactsModel && this.inOrphanMode !== newInOrphanModeGuess) { let checkOrphanedPromise: Promise; 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. checkOrphanedPromise = timeout(100).then(() => { if (this.disposed) { return true; } return this.fileService.exists(this.resource).then(exists => !exists); }); } else { checkOrphanedPromise = Promise.resolve(false); } checkOrphanedPromise.then(newInOrphanModeValidated => { if (this.inOrphanMode !== newInOrphanModeValidated && !this.disposed) { this.setOrphaned(newInOrphanModeValidated); } }); } } private setOrphaned(orphaned: boolean): void { if (this.inOrphanMode !== orphaned) { this.inOrphanMode = orphaned; this.orphanedChangeEventScheduler.schedule(); } } private updateAutoSaveConfiguration(config: IAutoSaveConfiguration): void { const autoSaveAfterMilliesEnabled = (typeof config.autoSaveDelay === 'number') && config.autoSaveDelay > 0; this.autoSaveAfterMilliesEnabled = autoSaveAfterMilliesEnabled; this.autoSaveAfterMillies = autoSaveAfterMilliesEnabled ? config.autoSaveDelay : undefined; } private onFilesAssociationChange(): void { if (!this.textEditorModel) { return; } const firstLineText = this.getFirstLineText(this.textEditorModel); const languageSelection = this.getOrCreateMode(this.modeService, undefined, firstLineText); this.modelService.setMode(this.textEditorModel, languageSelection); } getVersionId(): number { return this.versionId; } async revert(soft?: boolean): Promise { if (!this.isResolved()) { return Promise.resolve(undefined); } // Cancel any running auto-save this.cancelPendingAutoSave(); // Unset flags const undo = this.setDirty(false); let loadPromise: Promise; if (soft) { loadPromise = Promise.resolve(); } else { loadPromise = this.load({ forceReadFromDisk: true }); } try { await loadPromise; // Emit file change event this._onDidStateChange.fire(StateChange.REVERTED); } catch (error) { // Set flags back to previous values, we are still dirty if revert failed undo(); return Promise.reject(error); } } load(options?: ILoadOptions): Promise { this.logService.trace('load() - enter', this.resource); // It is very important to not reload the model when the model is dirty. // We also only want to reload the model from the disk if no save is pending // to avoid data loss. if (this.dirty || this.saveSequentializer.hasPendingSave()) { this.logService.trace('load() - exit - without loading because model is dirty or being saved', this.resource); return Promise.resolve(this); } // Only for new models we support to load from backup if (!this.textEditorModel && !this.createTextEditorModelPromise) { return this.loadFromBackup(options); } // Otherwise load from file resource return this.loadFromFile(options); } private async loadFromBackup(options?: ILoadOptions): Promise { const backup = await this.backupFileService.loadBackupResource(this.resource); // Make sure meanwhile someone else did not suceed or start loading if (this.createTextEditorModelPromise || this.textEditorModel) { return this.createTextEditorModelPromise || this; } // If we have a backup, continue loading with it if (!!backup) { const content: ITextFileContent = { resource: this.resource, name: basename(this.resource), mtime: Date.now(), size: 0, etag: etag(Date.now(), 0), value: createTextBufferFactory(''), /* will be filled later from backup */ encoding: this.textFileService.encoding.getPreferredWriteEncoding(this.resource, this.preferredEncoding).encoding, isReadonly: false }; return this.loadWithContent(content, options, backup); } // Otherwise load from file return this.loadFromFile(options); } private async loadFromFile(options?: ILoadOptions): Promise { const forceReadFromDisk = options && options.forceReadFromDisk; const allowBinary = this.isResolved() /* always allow if we resolved previously */ || (options && options.allowBinary); // Decide on etag let etag: string | undefined; if (forceReadFromDisk) { etag = undefined; // reset ETag if we enforce to read from disk } else if (this.lastResolvedDiskStat) { etag = this.lastResolvedDiskStat.etag; // otherwise respect etag to support caching } // Ensure to track the versionId before doing a long running operation // to make sure the model was not changed in the meantime which would // indicate that the user or program has made edits. If we would ignore // this, we could potentially loose the changes that were made because // after resolving the content we update the model and reset the dirty // flag. const currentVersionId = this.versionId; // Resolve Content try { const content = await this.textFileService.legacyRead(this.resource, { acceptTextOnly: !allowBinary, etag, encoding: this.preferredEncoding }); // Clear orphaned state when loading was successful this.setOrphaned(false); // Guard against the model having changed in the meantime if (currentVersionId === this.versionId) { return this.loadWithContent(content, options); } return this; } catch (error) { 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) { // Guard against the model having changed in the meantime if (currentVersionId === this.versionId) { this.setDirty(false); // Ensure we are not tracking a stale state } return 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 this; } // Otherwise bubble up the error throw error; } } private async loadWithContent(content: ITextFileContent, options?: ILoadOptions, backup?: URI): Promise { const model = await this.doLoadWithContent(content, backup); // Telemetry: We log the fileGet telemetry event after the model has been loaded to ensure a good mimetype const settingsType = this.getTypeIfSettings(); if (settingsType) { /* __GDPR__ "settingsRead" : { "settingsType": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } } */ this.telemetryService.publicLog('settingsRead', { settingsType }); // Do not log read to user settings.json and .vscode folder as a fileGet event as it ruins our JSON usage data } else { /* __GDPR__ "fileGet" : { "${include}": [ "${FileTelemetryData}" ] } */ this.telemetryService.publicLog('fileGet', this.getTelemetryData(options && options.reason ? options.reason : LoadReason.OTHER)); } return model; } private doLoadWithContent(content: ITextFileContent, backup?: URI): Promise { this.logService.trace('load() - resolved content', this.resource); // Update our resolved disk stat model this.updateLastResolvedDiskStat({ resource: this.resource, name: content.name, mtime: content.mtime, size: content.size, etag: content.etag, isDirectory: false, isSymbolicLink: false, isReadonly: content.isReadonly }); // Keep the original encoding to not loose it when saving const oldEncoding = this.contentEncoding; this.contentEncoding = content.encoding; // Handle events if encoding changed if (this.preferredEncoding) { this.updatePreferredEncoding(this.contentEncoding); // make sure to reflect the real encoding of the file (never out of sync) } else if (oldEncoding !== this.contentEncoding) { this._onDidStateChange.fire(StateChange.ENCODING); } // Update Existing Model if (this.textEditorModel) { this.doUpdateTextModel(content.value); return Promise.resolve(this); } // Join an existing request to create the editor model to avoid race conditions else if (this.createTextEditorModelPromise) { this.logService.trace('load() - join existing text editor model promise', this.resource); return this.createTextEditorModelPromise; } // Create New Model return this.doCreateTextModel(content.resource, content.value, backup); } private doUpdateTextModel(value: ITextBufferFactory): void { this.logService.trace('load() - updated text editor model', this.resource); // Ensure we are not tracking a stale state this.setDirty(false); // Update model value in a block that ignores model content change events this.blockModelContentChange = true; try { this.updateTextEditorModel(value); } finally { this.blockModelContentChange = false; } // Ensure we track the latest saved version ID given that the contents changed this.updateSavedVersionId(); } private doCreateTextModel(resource: URI, value: ITextBufferFactory, backup: URI | undefined): Promise { this.logService.trace('load() - created text editor model', this.resource); this.createTextEditorModelPromise = this.doLoadBackup(backup).then(backupContent => { this.createTextEditorModelPromise = null; // Create model const hasBackupContent = !!backupContent; this.createTextEditorModel(backupContent ? backupContent : value, resource); // We restored a backup so we have to set the model as being dirty // We also want to trigger auto save if it is enabled to simulate the exact same behaviour // you would get if manually making the model dirty (fixes https://github.com/Microsoft/vscode/issues/16977) if (hasBackupContent) { this.makeDirty(); if (this.autoSaveAfterMilliesEnabled) { this.doAutoSave(this.versionId); } } // Ensure we are not tracking a stale state else { this.setDirty(false); } // Model Listeners this.installModelListeners(); return this; }, error => { this.createTextEditorModelPromise = null; return Promise.reject(error); }); return this.createTextEditorModelPromise; } private installModelListeners(): void { // See https://github.com/Microsoft/vscode/issues/30189 // This code has been extracted to a different method because it caused a memory leak // where `value` was captured in the content change listener closure scope. // Content Change if (this.textEditorModel) { this._register(this.textEditorModel.onDidChangeContent(() => this.onModelContentChanged())); } } private async doLoadBackup(backup: URI | undefined): Promise { if (!backup) { return null; } try { return withUndefinedAsNull(await this.backupFileService.resolveBackupContent(backup)); } catch (error) { return null; // ignore errors } } protected getOrCreateMode(modeService: IModeService, preferredModeIds: string | undefined, firstLineText?: string): ILanguageSelection { return modeService.createByFilepathOrFirstLine(this.resource.fsPath, firstLineText); } private onModelContentChanged(): void { this.logService.trace(`onModelContentChanged() - enter`, this.resource); // In any case increment the version id because it tracks the textual content state of the model at all times this.versionId++; this.logService.trace(`onModelContentChanged() - new versionId ${this.versionId}`, this.resource); // Ignore if blocking model changes if (this.blockModelContentChange) { return; } // The contents changed as a matter of Undo and the version reached matches the saved one // 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 && this.textEditorModel.getAlternativeVersionId() === this.bufferSavedVersionId) { this.logService.trace('onModelContentChanged() - model content changed back to last saved version', this.resource); // Clear flags const wasDirty = this.dirty; this.setDirty(false); // Emit event if (wasDirty) { this._onDidStateChange.fire(StateChange.REVERTED); } return; } this.logService.trace('onModelContentChanged() - model content changed and marked as dirty', this.resource); // Mark as dirty this.makeDirty(); // Start auto save process unless we are in conflict resolution mode and unless it is disabled if (this.autoSaveAfterMilliesEnabled) { if (!this.inConflictMode) { this.doAutoSave(this.versionId); } else { this.logService.trace('makeDirty() - prevented save because we are in conflict resolution mode', this.resource); } } // Handle content change events this.contentChangeEventScheduler.schedule(); } private makeDirty(): void { // Track dirty state and version id const wasDirty = this.dirty; this.setDirty(true); // Emit as Event if we turned dirty if (!wasDirty) { this._onDidStateChange.fire(StateChange.DIRTY); } } private doAutoSave(versionId: number): void { this.logService.trace(`doAutoSave() - enter for versionId ${versionId}`, this.resource); // Cancel any currently running auto saves to make this the one that succeeds this.cancelPendingAutoSave(); // Create new save timer and store it for disposal as needed const handle = setTimeout(() => { // Only trigger save if the version id has not changed meanwhile if (versionId === this.versionId) { this.doSave(versionId, { reason: SaveReason.AUTO }); // Very important here to not return the promise because if the timeout promise is canceled it will bubble up the error otherwise - do not change } }, this.autoSaveAfterMillies); this.autoSaveDisposable = toDisposable(() => clearTimeout(handle)); } private cancelPendingAutoSave(): void { if (this.autoSaveDisposable) { this.autoSaveDisposable.dispose(); this.autoSaveDisposable = undefined; } } save(options: ISaveOptions = Object.create(null)): Promise { if (!this.isResolved()) { return Promise.resolve(undefined); } this.logService.trace('save() - enter', this.resource); // Cancel any currently running auto saves to make this the one that succeeds this.cancelPendingAutoSave(); return this.doSave(this.versionId, options); } private doSave(versionId: number, options: ISaveOptions): Promise { if (isUndefinedOrNull(options.reason)) { options.reason = SaveReason.EXPLICIT; } this.logService.trace(`doSave(${versionId}) - enter with versionId ' + versionId`, this.resource); // Lookup any running pending save for this versionId and return it if found // // Scenario: user invoked the save action multiple times quickly for the same contents // while the save was not yet finished to disk // if (this.saveSequentializer.hasPendingSave(versionId)) { this.logService.trace(`doSave(${versionId}) - exit - found a pending save for versionId ${versionId}`, this.resource); return this.saveSequentializer.pendingSave || Promise.resolve(undefined); } // Return early if not dirty (unless forced) or version changed meanwhile // // Scenario A: user invoked save action even though the model is not dirty // Scenario B: auto save was triggered for a certain change by the user but meanwhile the user changed // the contents and the version for which auto save was started is no longer the latest. // Thus we avoid spawning multiple auto saves and only take the latest. // if ((!options.force && !this.dirty) || versionId !== this.versionId) { this.logService.trace(`doSave(${versionId}) - exit - because not dirty and/or versionId is different (this.isDirty: ${this.dirty}, this.versionId: ${this.versionId})`, this.resource); return Promise.resolve(undefined); } // Return if currently saving by storing this save request as the next save that should happen. // Never ever must 2 saves execute at the same time because this can lead to dirty writes and race conditions. // // Scenario A: auto save was triggered and is currently busy saving to disk. this takes long enough that another auto save // kicks in. // Scenario B: save is very slow (e.g. network share) and the user manages to change the buffer and trigger another save // while the first save has not returned yet. // if (this.saveSequentializer.hasPendingSave()) { this.logService.trace(`doSave(${versionId}) - exit - because busy saving`, this.resource); // Register this as the next upcoming save and return return this.saveSequentializer.setNext(() => this.doSave(this.versionId /* make sure to use latest version id here */, options)); } // Push all edit operations to the undo stack so that the user has a chance to // Ctrl+Z back to the saved version. We only do this when auto-save is turned off if (!this.autoSaveAfterMilliesEnabled && this.textEditorModel) { this.textEditorModel.pushStackElement(); } // A save participant can still change the model now and since we are so close to saving // we do not want to trigger another auto save or similar, so we block this // In addition we update our version right after in case it changed because of a model change // Save participants can also be skipped through API. let saveParticipantPromise: Promise = Promise.resolve(versionId); if (TextFileEditorModel.saveParticipant && !options.skipSaveParticipants) { const onCompleteOrError = () => { this.blockModelContentChange = false; return this.versionId; }; this.blockModelContentChange = true; saveParticipantPromise = TextFileEditorModel.saveParticipant.participate(this as IResolvedTextFileEditorModel, { reason: options.reason }).then(onCompleteOrError, onCompleteOrError); } // mark the save participant as current pending save operation return this.saveSequentializer.setPending(versionId, saveParticipantPromise.then(newVersionId => { // We have to protect against being disposed at this point. It could be that the save() operation // was triggerd followed by a dispose() operation right after without waiting. Typically we cannot // be disposed if we are dirty, but if we are not dirty, save() and dispose() can still be triggered // one after the other without waiting for the save() to complete. If we are disposed(), we risk // saving contents to disk that are stale (see https://github.com/Microsoft/vscode/issues/50942). // To fix this issue, we will not store the contents to disk when we got disposed. if (this.disposed) { return undefined; } // Under certain conditions we do a short-cut of flushing contents to disk when we can assume that // the file has not changed and as such was not dirty before. // The conditions are all of: // - a forced, explicit save (Ctrl+S) // - the model is not dirty (otherwise we know there are changed which needs to go to the file) // - the model is not in orphan mode (because in that case we know the file does not exist on disk) // - the model version did not change due to save participants running if (options.force && !this.dirty && !this.inOrphanMode && options.reason === SaveReason.EXPLICIT && versionId === newVersionId) { return this.doTouch(newVersionId); } // update versionId with its new value (if pre-save changes happened) versionId = newVersionId; // Clear error flag since we are trying to save again this.inErrorMode = false; // Remember when this model was saved last this.lastSaveAttemptTime = Date.now(); // Save to Disk // mark the save operation as currently pending with the versionId (it might have changed from a save participant triggering) this.logService.trace(`doSave(${versionId}) - before write()`, this.resource); const snapshot = this.createSnapshot(); if (!snapshot) { throw new Error('Invalid snapshot'); } return this.saveSequentializer.setPending(newVersionId, this.textFileService.write(this.lastResolvedDiskStat.resource, snapshot, { overwriteReadonly: options.overwriteReadonly, overwriteEncoding: options.overwriteEncoding, mtime: this.lastResolvedDiskStat.mtime, encoding: this.getEncoding(), etag: this.lastResolvedDiskStat.etag, writeElevated: options.writeElevated }).then(stat => { this.logService.trace(`doSave(${versionId}) - after write()`, this.resource); // Update dirty state unless model has changed meanwhile if (versionId === this.versionId) { this.logService.trace(`doSave(${versionId}) - setting dirty to false because versionId did not change`, this.resource); this.setDirty(false); } else { this.logService.trace(`doSave(${versionId}) - not setting dirty to false because versionId did change meanwhile`, this.resource); } // Updated resolved stat with updated stat this.updateLastResolvedDiskStat(stat); // Cancel any content change event promises as they are no longer valid this.contentChangeEventScheduler.cancel(); // Emit File Saved Event this._onDidStateChange.fire(StateChange.SAVED); // Telemetry const settingsType = this.getTypeIfSettings(); if (settingsType) { /* __GDPR__ "settingsWritten" : { "settingsType": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } } */ this.telemetryService.publicLog('settingsWritten', { settingsType }); // Do not log write to user settings.json and .vscode folder as a filePUT event as it ruins our JSON usage data } else { /* __GDPR__ "filePUT" : { "${include}": [ "${FileTelemetryData}" ] } */ this.telemetryService.publicLog('filePUT', this.getTelemetryData(options.reason)); } }, error => { if (!error) { error = new Error('Unknown Save Error'); // TODO@remote we should never get null as error (https://github.com/Microsoft/vscode/issues/55051) } this.logService.error(`doSave(${versionId}) - exit - resulted in a save error: ${error.toString()}`, this.resource); // Flag as error state in the model this.inErrorMode = true; // Look out for a save conflict if ((error).fileOperationResult === FileOperationResult.FILE_MODIFIED_SINCE) { this.inConflictMode = true; } // Show to user this.onSaveError(error); // Emit as event this._onDidStateChange.fire(StateChange.SAVE_ERROR); })); })); } private getTypeIfSettings(): string { if (extname(this.resource) !== '.json') { return ''; } // Check for global settings file if (isEqual(this.resource, URI.file(this.environmentService.appSettingsPath), !isLinux)) { return 'global-settings'; } // Check for keybindings file if (isEqual(this.resource, URI.file(this.environmentService.appKeybindingsPath), !isLinux)) { return 'keybindings'; } // Check for locale file if (isEqual(this.resource, URI.file(join(this.environmentService.appSettingsHome, 'locale.json')), !isLinux)) { return 'locale'; } // Check for snippets if (isEqualOrParent(this.resource, URI.file(join(this.environmentService.appSettingsHome, 'snippets')))) { return 'snippets'; } // Check for workspace settings file const folders = this.contextService.getWorkspace().folders; for (const folder of folders) { if (isEqualOrParent(this.resource, folder.toResource('.vscode'))) { const filename = basename(this.resource); if (TextFileEditorModel.WHITELIST_WORKSPACE_JSON.indexOf(filename) > -1) { return `.vscode/${filename}`; } } } return ''; } private getTelemetryData(reason: number | undefined): object { const ext = extname(this.resource); const fileName = basename(this.resource); const telemetryData = { mimeType: guessMimeTypes(this.resource.fsPath).join(', '), ext, path: hash(this.resource.fsPath), reason }; if (ext === '.json' && TextFileEditorModel.WHITELIST_JSON.indexOf(fileName) > -1) { telemetryData['whitelistedjson'] = fileName; } /* __GDPR__FRAGMENT__ "FileTelemetryData" : { "mimeType" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "ext": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "path": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "reason": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, "whitelistedjson": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } } */ return telemetryData; } private doTouch(versionId: number): Promise { const snapshot = this.createSnapshot(); if (!snapshot) { throw new Error('invalid snapshot'); } return this.saveSequentializer.setPending(versionId, this.textFileService.write(this.lastResolvedDiskStat.resource, snapshot, { mtime: this.lastResolvedDiskStat.mtime, encoding: this.getEncoding(), etag: this.lastResolvedDiskStat.etag }).then(stat => { // Updated resolved stat with updated stat since touching it might have changed mtime this.updateLastResolvedDiskStat(stat); }, error => onUnexpectedError(error) /* just log any error but do not notify the user since the file was not dirty */)); } private setDirty(dirty: boolean): () => void { const wasDirty = this.dirty; const wasInConflictMode = this.inConflictMode; const wasInErrorMode = this.inErrorMode; const oldBufferSavedVersionId = this.bufferSavedVersionId; if (!dirty) { this.dirty = false; this.inConflictMode = false; this.inErrorMode = false; this.updateSavedVersionId(); } else { this.dirty = true; } // Return function to revert this call return () => { this.dirty = wasDirty; this.inConflictMode = wasInConflictMode; this.inErrorMode = wasInErrorMode; this.bufferSavedVersionId = oldBufferSavedVersionId; }; } private updateSavedVersionId(): void { // we remember the models alternate version id to remember when the version // of the model matches with the saved version on disk. we need to keep this // in order to find out if the model changed back to a saved version (e.g. // when undoing long enough to reach to a version that is saved and then to // clear the dirty flag) if (this.textEditorModel) { this.bufferSavedVersionId = this.textEditorModel.getAlternativeVersionId(); } } private updateLastResolvedDiskStat(newVersionOnDiskStat: IFileStatWithMetadata): void { // First resolve - just take if (!this.lastResolvedDiskStat) { this.lastResolvedDiskStat = newVersionOnDiskStat; } // Subsequent resolve - make sure that we only assign it if the mtime is equal or has advanced. // This prevents race conditions from loading and saving. If a save comes in late after a revert // was called, the mtime could be out of sync. else if (this.lastResolvedDiskStat.mtime <= newVersionOnDiskStat.mtime) { this.lastResolvedDiskStat = newVersionOnDiskStat; } } private onSaveError(error: Error): void { // Prepare handler if (!TextFileEditorModel.saveErrorHandler) { TextFileEditorModel.setSaveErrorHandler(this.instantiationService.createInstance(DefaultSaveErrorHandler)); } // Handle TextFileEditorModel.saveErrorHandler.onSaveError(error, this); } isDirty(): boolean { return this.dirty; } getLastSaveAttemptTime(): number { return this.lastSaveAttemptTime; } getETag(): string | null { return this.lastResolvedDiskStat ? this.lastResolvedDiskStat.etag || null : null; } 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; } } getEncoding(): string { return this.preferredEncoding || this.contentEncoding; } setEncoding(encoding: string, mode: EncodingMode): void { if (!this.isNewEncoding(encoding)) { return; // return early if the encoding is already the same } // Encode: Save with encoding if (mode === EncodingMode.Encode) { this.updatePreferredEncoding(encoding); // Save if (!this.isDirty()) { this.versionId++; // needs to increment because we change the model potentially this.makeDirty(); } if (!this.inConflictMode) { this.save({ overwriteEncoding: true }); } } // Decode: Load with encoding else { if (this.isDirty()) { this.notificationService.info(nls.localize('saveFileFirst', "The file is dirty. Please save it first before reopening it with another encoding.")); return; } this.updatePreferredEncoding(encoding); // Load this.load({ forceReadFromDisk: true // because encoding has changed }); } } updatePreferredEncoding(encoding: string): void { if (!this.isNewEncoding(encoding)) { return; } this.preferredEncoding = encoding; // Emit this._onDidStateChange.fire(StateChange.ENCODING); } private isNewEncoding(encoding: string): boolean { if (this.preferredEncoding === encoding) { return false; // return early if the encoding is already the same } if (!this.preferredEncoding && this.contentEncoding === encoding) { return false; // also return if we don't have a preferred encoding but the content encoding is already the same } return true; } isResolved(): boolean { return !isUndefinedOrNull(this.lastResolvedDiskStat); } isReadonly(): boolean { return !!(this.lastResolvedDiskStat && this.lastResolvedDiskStat.isReadonly); } isDisposed(): boolean { return this.disposed; } getResource(): URI { return this.resource; } getStat(): IFileStatWithMetadata { return this.lastResolvedDiskStat; } dispose(): void { this.disposed = true; this.inConflictMode = false; this.inOrphanMode = false; this.inErrorMode = false; this.createTextEditorModelPromise = null; this.cancelPendingAutoSave(); super.dispose(); } } interface IPendingSave { versionId: number; promise: Promise; } interface ISaveOperation { promise: Promise; promiseResolve: () => void; promiseReject: (error: Error) => void; run: () => Promise; } export class SaveSequentializer { private _pendingSave?: IPendingSave; private _nextSave?: ISaveOperation; hasPendingSave(versionId?: number): boolean { if (!this._pendingSave) { return false; } if (typeof versionId === 'number') { return this._pendingSave.versionId === versionId; } return !!this._pendingSave; } get pendingSave(): Promise | undefined { return this._pendingSave ? this._pendingSave.promise : undefined; } setPending(versionId: number, promise: Promise): Promise { this._pendingSave = { versionId, promise }; promise.then(() => this.donePending(versionId), () => this.donePending(versionId)); return promise; } private donePending(versionId: number): void { if (this._pendingSave && versionId === this._pendingSave.versionId) { // only set pending to done if the promise finished that is associated with that versionId this._pendingSave = undefined; // schedule the next save now that we are free if we have any this.triggerNextSave(); } } private triggerNextSave(): void { if (this._nextSave) { const saveOperation = this._nextSave; this._nextSave = undefined; // Run next save and complete on the associated promise saveOperation.run().then(saveOperation.promiseResolve, saveOperation.promiseReject); } } setNext(run: () => Promise): Promise { // this is our first next save, so we create associated promise with it // so that we can return a promise that completes when the save operation // has completed. if (!this._nextSave) { let promiseResolve: () => void; let promiseReject: (error: Error) => void; const promise = new Promise((resolve, reject) => { promiseResolve = resolve; promiseReject = reject; }); this._nextSave = { run, promise, promiseResolve: promiseResolve!, promiseReject: promiseReject! }; } // we have a previous next save, just overwrite it else { this._nextSave.run = run; } return this._nextSave.promise; } } class DefaultSaveErrorHandler implements ISaveErrorHandler { constructor(@INotificationService private readonly notificationService: INotificationService) { } onSaveError(error: Error, model: TextFileEditorModel): void { this.notificationService.error(nls.localize('genericSaveError', "Failed to save '{0}': {1}", basename(model.getResource()), toErrorMessage(error, false))); } }