/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ 'use strict'; import {IWorkbenchContribution} from 'vs/workbench/common/contributions'; import errors = require('vs/base/common/errors'); import nls = require('vs/nls'); import {MIME_UNKNOWN} from 'vs/base/common/mime'; import URI from 'vs/base/common/uri'; import paths = require('vs/base/common/paths'); import {DiffEditorInput} from 'vs/workbench/common/editor/diffEditorInput'; import {EditorInput, EditorOptions} from 'vs/workbench/common/editor'; import {BaseEditor} from 'vs/workbench/browser/parts/editor/baseEditor'; import {BaseTextEditor} from 'vs/workbench/browser/parts/editor/textEditor'; import {LocalFileChangeEvent, VIEWLET_ID, EventType as FileEventType, IWorkingFilesModel, ITextFileService} from 'vs/workbench/parts/files/common/files'; import {FileChangeType, FileChangesEvent, EventType as CommonFileEventType} from 'vs/platform/files/common/files'; import {FileEditorInput} from 'vs/workbench/parts/files/browser/editors/fileEditorInput'; import {DerivedFrameEditorInput} from 'vs/workbench/parts/files/browser/editors/derivedFrameEditorInput'; import {State, TextFileEditorModel, CACHE} from 'vs/workbench/parts/files/browser/editors/textFileEditorModel'; import {IFrameEditor} from 'vs/workbench/browser/parts/editor/iframeEditor'; import {EventType as WorkbenchEventType, EditorInputEvent, UntitledEditorEvent} from 'vs/workbench/common/events'; import {IUntitledEditorService} from 'vs/workbench/services/untitled/common/untitledEditorService'; import {IWorkbenchEditorService} from 'vs/workbench/services/editor/common/editorService'; import {IQuickOpenService} from 'vs/workbench/services/quickopen/common/quickOpenService'; import {IActivityService, NumberBadge} from 'vs/workbench/services/activity/common/activityService'; import {IEditorInput} from 'vs/platform/editor/common/editor'; import {IEventService} from 'vs/platform/event/common/event'; import {IInstantiationService} from 'vs/platform/instantiation/common/instantiation'; // This extension tracks files for changes to update editors and inputs accordingly. export class FileTracker implements IWorkbenchContribution { // Delay in ms that we wait at minimum before we update a model from a file change event. // This reduces the chance that a save from the client triggers an update of the editor. private static FILE_CHANGE_UPDATE_DELAY = 2000; private lastDirtyCount: number; private workingFiles: IWorkingFilesModel; private toUnbind: { (): void; }[]; constructor( @IEventService private eventService: IEventService, @IQuickOpenService private quickOpenService: IQuickOpenService, @IWorkbenchEditorService private editorService: IWorkbenchEditorService, @IActivityService private activityService: IActivityService, @ITextFileService private textFileService: ITextFileService, @IInstantiationService private instantiationService: IInstantiationService, @IUntitledEditorService private untitledEditorService: IUntitledEditorService ) { this.toUnbind = []; this.workingFiles = textFileService.getWorkingFilesModel(); this.registerListeners(); } public getId(): string { return 'vs.files.filetracker'; } private registerListeners(): void { // Update editors and inputs from local changes and saves this.toUnbind.push(this.eventService.addListener(WorkbenchEventType.EDITOR_INPUT_CHANGED, (e: EditorInputEvent) => this.onEditorInputChanged(e))); this.toUnbind.push(this.eventService.addListener(WorkbenchEventType.UNTITLED_FILE_DELETED, (e: UntitledEditorEvent) => this.onUntitledEditorDeleted(e))); this.toUnbind.push(this.eventService.addListener(WorkbenchEventType.UNTITLED_FILE_DIRTY, (e: UntitledEditorEvent) => this.onUntitledEditorDirty(e))); this.toUnbind.push(this.eventService.addListener(FileEventType.FILE_DIRTY, (e: LocalFileChangeEvent) => this.onTextFileDirty(e))); this.toUnbind.push(this.eventService.addListener(FileEventType.FILE_SAVING, (e: LocalFileChangeEvent) => this.onTextFileSaving(e))); this.toUnbind.push(this.eventService.addListener(FileEventType.FILE_SAVE_ERROR, (e: LocalFileChangeEvent) => this.onTextFileSaveError(e))); this.toUnbind.push(this.eventService.addListener(FileEventType.FILE_SAVED, (e: LocalFileChangeEvent) => this.onTextFileSaved(e))); this.toUnbind.push(this.eventService.addListener(FileEventType.FILE_REVERTED, (e: LocalFileChangeEvent) => this.onTextFileReverted(e))); this.toUnbind.push(this.eventService.addListener('files.internal:fileChanged', (e: LocalFileChangeEvent) => this.onLocalFileChange(e))); // Update editors and inputs from disk changes this.toUnbind.push(this.eventService.addListener(CommonFileEventType.FILE_CHANGES, (e: FileChangesEvent) => this.onFileChanges(e))); } private onEditorInputChanged(e: EditorInputEvent): void { this.disposeTextFileModels(); } private onTextFileDirty(e: LocalFileChangeEvent): void { this.emitInputStateChangeEvent(e.getAfter().resource); if (!this.textFileService.isAutoSaveEnabled()) { this.updateActivityBadge(); // no indication needed when auto save is turned off and we didn't show dirty } } private onTextFileSaving(e: LocalFileChangeEvent): void { this.emitInputStateChangeEvent(e.getAfter().resource); } private onTextFileSaveError(e: LocalFileChangeEvent): void { this.emitInputStateChangeEvent(e.getAfter().resource); this.updateActivityBadge(); } private onTextFileSaved(e: LocalFileChangeEvent): void { this.emitInputStateChangeEvent(e.getAfter().resource); if (this.lastDirtyCount > 0) { this.updateActivityBadge(); } } private onTextFileReverted(e: LocalFileChangeEvent): void { this.emitInputStateChangeEvent(e.getAfter().resource); if (this.lastDirtyCount > 0) { this.updateActivityBadge(); } } private onUntitledEditorDirty(e: UntitledEditorEvent): void { let input = this.untitledEditorService.get(e.resource); if (input) { this.eventService.emit(WorkbenchEventType.EDITOR_INPUT_STATE_CHANGED, new EditorInputEvent(input)); } this.updateActivityBadge(); } private onUntitledEditorDeleted(e: UntitledEditorEvent): void { let input = this.untitledEditorService.get(e.resource); if (input) { this.eventService.emit(WorkbenchEventType.EDITOR_INPUT_STATE_CHANGED, new EditorInputEvent(input)); } if (this.lastDirtyCount > 0) { this.updateActivityBadge(); } } private updateActivityBadge(): void { let dirtyCount = this.textFileService.getDirty().length; this.lastDirtyCount = dirtyCount; if (dirtyCount > 0) { this.activityService.showActivity(VIEWLET_ID, new NumberBadge(dirtyCount, (num) => nls.localize('dirtyFiles', "{0} unsaved files")), 'explorer-viewlet-label'); } else { this.activityService.clearActivity(VIEWLET_ID); } } private emitInputStateChangeEvent(resource: URI): void { // Find all file editor inputs that are open from the given file resource and emit a editor input state change event. // We could do all of this within the file editor input but having all the file change listeners in // one place is more elegant and keeps the logic together at once place. let editors = this.editorService.getVisibleEditors(); editors.forEach((editor) => { let input = editor.input; if (input instanceof DiffEditorInput) { input = (input).getModifiedInput(); } // File Editor Input if (input instanceof FileEditorInput) { let fileInput = input; if (fileInput.getResource().toString() === resource.toString()) { this.eventService.emit(WorkbenchEventType.EDITOR_INPUT_STATE_CHANGED, new EditorInputEvent(fileInput)); } } }); } // Note: there is some duplication with the other file event handler below. Since we cannot always rely on the disk events // carrying all necessary data in all environments, we also use the local file events to make sure operations are handled. // In any case there is no guarantee if the local event is fired first or the disk one. Thus, code must handle the case // that the event ordering is random as well as might not carry all information needed. private onLocalFileChange(e: LocalFileChangeEvent): void { // Handle moves specially when file is opened if (e.gotMoved()) { let before = e.getBefore(); let after = e.getAfter(); this.handleMovedFileInVisibleEditors(before ? before.resource : null, after ? after.resource : null, after ? after.mime : null); } // Dispose all known inputs passed on resource let oldFile = e.getBefore(); if ((e.gotMoved() || e.gotDeleted())) { this.disposeAll(oldFile.resource, this.quickOpenService.getEditorHistory()); } } private onFileChanges(e: FileChangesEvent): void { // Dispose inputs that got deleted let allDeleted = e.getDeleted(); if (allDeleted && allDeleted.length > 0) { allDeleted.forEach((deleted) => { this.disposeAll(deleted.resource, this.quickOpenService.getEditorHistory()); }); } // Dispose models that got changed and are not visible. We do this because otherwise // cached file models will be stale from the contents on disk. e.getUpdated() .map((u) => CACHE.get(u.resource)) .filter((model) => { let canDispose = this.canDispose(model); if (!canDispose) { return false; } if (new Date().getTime() - model.getLastDirtyTime() < FileTracker.FILE_CHANGE_UPDATE_DELAY) { return false; // this is a weak check to see if the change came from outside the editor or not } return true; // ok boss }) .forEach((model) => CACHE.dispose(model.getResource())); // Update inputs that got updated let editors = this.editorService.getVisibleEditors(); editors.forEach((editor) => { let input = editor.input; if (input instanceof DiffEditorInput) { input = this.getMatchingFileEditorInputFromDiff(input, e); } // File Editor Input if (input instanceof FileEditorInput) { let fileInput = input; let fileInputResource = fileInput.getResource(); // Input got added or updated, so check for model and update // Note: we also consider the added event because it could be that a file was added // and updated right after. if (e.contains(fileInputResource, FileChangeType.UPDATED) || e.contains(fileInputResource, FileChangeType.ADDED)) { let textModel = CACHE.get(fileInputResource); // Text file: check for last dirty time if (textModel) { let state = textModel.getState(); // We only ever update models that are in good saved state if (state === State.SAVED) { let lastDirtyTime = textModel.getLastDirtyTime(); // Force a reopen of the input if this change came in later than our wait interval before we consider it if (new Date().getTime() - lastDirtyTime > FileTracker.FILE_CHANGE_UPDATE_DELAY) { let codeEditor = (editor).getControl(); let viewState = codeEditor.saveViewState(); let currentMtime = textModel.getLastModifiedTime(); // optimize for the case where the file did actually not change textModel.load().done(() => { if (textModel.getLastModifiedTime() !== currentMtime && this.isEditorShowingPath(editor, textModel.getResource())) { codeEditor.restoreViewState(viewState); } }, errors.onUnexpectedError); } } } // Binary file: always update else { let editorOptions = new EditorOptions(); editorOptions.forceOpen = true; editorOptions.preserveFocus = true; this.editorService.openEditor(editor.input, editorOptions, editor.position).done(null, errors.onUnexpectedError); } } } // Derived Frame Editor Input else if (input instanceof DerivedFrameEditorInput) { let derivedInput = input; if (e.contains(derivedInput.getResource(), FileChangeType.UPDATED)) { (editor).reload(); } } }); } private isEditorShowingPath(editor: BaseEditor, resource: URI): boolean { // Only relevant if Editor is visible if (!editor.isVisible()) { return false; } // Only relevant if Input is set let input = editor.getInput(); if (!input) { return false; } // Support diff editor input too if (input instanceof DiffEditorInput) { input = (input).getModifiedInput(); } return input instanceof FileEditorInput && (input).getResource().toString() === resource.toString(); } private handleMovedFileInVisibleEditors(oldResource: URI, newResource: URI, mimeHint?: string): void { let editors = this.editorService.getVisibleEditors(); editors.forEach((editor) => { let input = editor.input; if (input instanceof DiffEditorInput) { input = (input).getModifiedInput(); } let inputResource: URI; if (input instanceof FileEditorInput) { inputResource = (input).getResource(); } else if (input instanceof DerivedFrameEditorInput) { inputResource = (input).getResource(); } // Editor Input with associated Resource if (inputResource) { // Update Editor if file (or any parent of the input) got renamed or moved let updateInput = false; if (paths.isEqualOrParent(inputResource.fsPath, oldResource.fsPath)) { updateInput = true; } // Do update from move if (updateInput) { let reopenFileResource: URI; if (oldResource.toString() === inputResource.toString()) { reopenFileResource = newResource; } else { let index = inputResource.fsPath.indexOf(oldResource.fsPath); reopenFileResource = URI.file(paths.join(newResource.fsPath, inputResource.fsPath.substr(index + oldResource.fsPath.length + 1))); // update the path by changing the old path value to the new one } let editorInput: EditorInput; let editorOptions = new EditorOptions(); editorOptions.preserveFocus = true; // Reopen File Input if (input instanceof FileEditorInput) { editorInput = this.instantiationService.createInstance(FileEditorInput, reopenFileResource, mimeHint || MIME_UNKNOWN, void 0); } // Reopen Derived Input else if (input instanceof DerivedFrameEditorInput) { let derivedFrameInput = input; editorInput = derivedFrameInput.createNew(reopenFileResource); } if (editorInput) { this.editorService.openEditor(editorInput, editorOptions, editor.position).done(null, errors.onUnexpectedError); } } } }); } private getMatchingFileEditorInputFromDiff(input: DiffEditorInput, deletedResource: URI): FileEditorInput; private getMatchingFileEditorInputFromDiff(input: DiffEditorInput, updatedFiles: FileChangesEvent): FileEditorInput; private getMatchingFileEditorInputFromDiff(input: DiffEditorInput, arg: any): FileEditorInput { // First try modifiedInput let modifiedInput = input.getModifiedInput(); let res = this.getMatchingFileEditorInputFromInput(modifiedInput, arg); if (res) { return res; } // Second try originalInput let originalInput = input.getOriginalInput(); return this.getMatchingFileEditorInputFromInput(originalInput, arg); } private getMatchingFileEditorInputFromInput(input: EditorInput, deletedResource: URI): FileEditorInput; private getMatchingFileEditorInputFromInput(input: EditorInput, updatedFiles: FileChangesEvent): FileEditorInput; private getMatchingFileEditorInputFromInput(input: EditorInput, arg: any): FileEditorInput { if (input instanceof FileEditorInput) { if (URI.isURI(arg)) { let deletedResource = arg; if (this.containsResource(input, deletedResource)) { return input; } } else { let updatedFiles = arg; if (updatedFiles.contains(input.getResource(), FileChangeType.UPDATED)) { return input; } } } return null; } private disposeAll(deletedResource: URI, history: IEditorInput[]): void { if (this.textFileService.isDirty(deletedResource)) { return; // never dispose dirty resources } // Add existing clients matching resource let inputsContainingPath: EditorInput[] = FileEditorInput.getAll(deletedResource); // Add those from history as well for (let i = 0; i < history.length; i++) { let element = history[i]; // File Input if (element instanceof FileEditorInput && this.containsResource(element, deletedResource)) { inputsContainingPath.push(element); } // Derived Frame Input else if (element instanceof DerivedFrameEditorInput && this.containsResource(element, deletedResource)) { inputsContainingPath.push(element); } } // Add those from visible editors too let editors = this.editorService.getVisibleEditors(); editors.forEach((editor) => { let input = editor.input; if (input instanceof DiffEditorInput) { input = this.getMatchingFileEditorInputFromDiff(input, deletedResource); if (input instanceof FileEditorInput) { inputsContainingPath.push(input); } } // File Editor Input else if (input instanceof FileEditorInput && this.containsResource(input, deletedResource)) { inputsContainingPath.push(input); } // Derived Frame Input else if (input instanceof DerivedFrameEditorInput && this.containsResource(input, deletedResource)) { inputsContainingPath.push(input); } }); // Dispose all inputsContainingPath.forEach((input) => { if (!input.isDisposed()) { if (input instanceof FileEditorInput) { let fileInputToDispose = input; fileInputToDispose.dispose(true /* force */); } else { input.dispose(); } } }); } private containsResource(input: FileEditorInput, resource: URI): boolean; private containsResource(input: DerivedFrameEditorInput, resource: URI): boolean; private containsResource(input: EditorInput, resource: URI): boolean { let fileResource: URI; if (input instanceof FileEditorInput) { fileResource = (input).getResource(); } else { fileResource = (input).getResource(); } if (paths.isEqualOrParent(fileResource.fsPath, resource.fsPath)) { return true; } return false; } private disposeTextFileModels(): void { // To not grow our text file model cache infinitly, we dispose models that // are not showing up in any editor and are not in the working file set or dirty. // Get all cached file models CACHE.getAll() // Only take text file models and remove those that are under working files or opened .filter((model) => !this.workingFiles.hasEntry(model.getResource()) && this.canDispose(model)) // Dispose .forEach((model) => CACHE.dispose(model.getResource())); } private canDispose(textModel: TextFileEditorModel): boolean { if (!textModel) { return false; // we need data! } if (textModel.textEditorModel && textModel.textEditorModel.isAttachedToEditor()) { return false; // never dispose when attached to editor } if (textModel.getState() !== State.SAVED) { return false; // never dispose unsaved models } if (this.editorService.getVisibleEditors().some(e => { if (e.input instanceof DerivedFrameEditorInput) { let derivedInputResource = (e.input).getResource(); return derivedInputResource && derivedInputResource.toString() === textModel.getResource().toString(); } return false; })) { return false; // never dispose models that are used in derived frame inputs } return true; } public dispose(): void { while (this.toUnbind.length) { this.toUnbind.pop()(); } } }