/*--------------------------------------------------------------------------------------------- * 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 arrays = require('vs/base/common/arrays'); import uri from 'vs/base/common/uri'; import Event, {Emitter} from 'vs/base/common/event'; import paths = require('vs/base/common/paths'); import errors = require('vs/base/common/errors'); import labels = require('vs/base/common/labels'); import {disposeAll, IDisposable} from 'vs/base/common/lifecycle'; import {ITextFileService, IWorkingFilesModel, IWorkingFileModelChangeEvent, IWorkingFileEntry, EventType, LocalFileChangeEvent, WORKING_FILES_MODEL_ENTRY_CLASS_ID, AutoSaveMode} from 'vs/workbench/parts/files/common/files'; import {IFileStat, FileChangeType, FileChangesEvent, EventType as FileEventType} from 'vs/platform/files/common/files'; import {UntitledEditorEvent, EventType as WorkbenchEventType, EditorEvent} from 'vs/workbench/common/events'; import {IUntitledEditorService} from 'vs/workbench/services/untitled/common/untitledEditorService'; import {IWorkbenchEditorService} from 'vs/workbench/services/editor/common/editorService'; import {IPartService} from 'vs/workbench/services/part/common/partService'; import {IWorkspaceContextService} from 'vs/workbench/services/workspace/common/contextService'; import {asFileEditorInput} from 'vs/workbench/common/editor'; import {IStorageService, StorageScope} from 'vs/platform/storage/common/storage'; import {IEventService} from 'vs/platform/event/common/event'; export class WorkingFilesModel implements IWorkingFilesModel { private static STORAGE_KEY = 'workingFiles.model.entries'; private entries: WorkingFileEntry[]; private pathLabelProvider: labels.PathLabelProvider; private mapEntryToResource: { [resource: string]: WorkingFileEntry; }; private _onModelChange: Emitter; private _onWorkingFileChange: Emitter; private toDispose: IDisposable[]; constructor( @IStorageService private storageService: IStorageService, @IEventService private eventService: IEventService, @IPartService private partService: IPartService, @IWorkspaceContextService private contextService: IWorkspaceContextService, @IWorkbenchEditorService private editorService: IWorkbenchEditorService, @IUntitledEditorService private untitledEditorService: IUntitledEditorService, @ITextFileService private textFileService: ITextFileService ) { this.pathLabelProvider = new labels.PathLabelProvider(this.contextService); this.entries = []; this.toDispose = []; this.mapEntryToResource = Object.create(null); this._onModelChange = new Emitter(); this._onWorkingFileChange = new Emitter(); this.load(); this.registerListeners(); } private registerListeners(): void { // listen to global file changes to clean up model this.toDispose.push(this.eventService.addListener2('files.internal:fileChanged', (e: LocalFileChangeEvent) => this.onLocalFileChange(e))); this.toDispose.push(this.eventService.addListener2(FileEventType.FILE_CHANGES, (e) => this.onFileChanges(e))); // listen to untitled this.toDispose.push(this.eventService.addListener2(WorkbenchEventType.UNTITLED_FILE_DIRTY, (e) => this.onUntitledFileDirty(e))); this.toDispose.push(this.eventService.addListener2(WorkbenchEventType.UNTITLED_FILE_DELETED, (e) => this.onUntitledFileDeleted(e))); // listen to files being changed locally this.toDispose.push(this.eventService.addListener2(EventType.FILE_DIRTY, (e: LocalFileChangeEvent) => this.onTextFileDirty(e))); this.toDispose.push(this.eventService.addListener2(EventType.FILE_SAVE_ERROR, (e: LocalFileChangeEvent) => this.onTextFileSaveError(e))); this.toDispose.push(this.eventService.addListener2(EventType.FILE_SAVED, (e: LocalFileChangeEvent) => this.onTextFileSaved(e))); this.toDispose.push(this.eventService.addListener2(EventType.FILE_REVERTED, (e: LocalFileChangeEvent) => this.onTextFileReverted(e))); // clean up model on set input error this.toDispose.push(this.eventService.addListener2(WorkbenchEventType.EDITOR_SET_INPUT_ERROR, (e: EditorEvent) => this.onEditorInputSetError(e))); } private onUntitledFileDirty(e: UntitledEditorEvent): void { this.updateDirtyState(e.resource, true); } private onUntitledFileDeleted(e: UntitledEditorEvent): void { let entry = this.mapEntryToResource[e.resource.toString()]; if (entry) { this.removeEntry(entry); } } private onTextFileDirty(e: LocalFileChangeEvent): void { if (this.textFileService.getAutoSaveMode() !== AutoSaveMode.AFTER_SHORT_DELAY) { this.updateDirtyState(e.getAfter().resource, true); // no indication needed when auto save is enabled for short delay } else { this.addEntry(e.getAfter().resource); } } private onTextFileSaveError(e: LocalFileChangeEvent): void { this.updateDirtyState(e.getAfter().resource, true); } private onTextFileSaved(e: LocalFileChangeEvent): void { this.updateDirtyState(e.getAfter().resource, false); } private onTextFileReverted(e: LocalFileChangeEvent): void { if (this.hasEntry(e.getAfter().resource)) { this.updateDirtyState(e.getAfter().resource, false); } } private updateDirtyState(resource: uri, isDirty: boolean): void { // Add to model let entry = this.addEntry(resource); // Toggle dirty let oldDirty = entry.dirty; entry.setDirty(isDirty); if (oldDirty !== isDirty) { this.fireWorkingFileChange(entry); } } private onLocalFileChange(e: LocalFileChangeEvent): void { if (e.gotMoved()) { this.moveEntry(e.getBefore().resource, e.getAfter().resource); } } private onFileChanges(e: FileChangesEvent): void { for (let resource in this.mapEntryToResource) { let entry = this.mapEntryToResource[resource]; if (!entry.dirty && e.contains(uri.parse(resource), FileChangeType.DELETED)) { this.removeEntry(entry); } } } private onEditorInputSetError(e: EditorEvent): void { let fileInput = asFileEditorInput(e.editorInput); if (fileInput) { let entry = this.mapEntryToResource[fileInput.getResource().toString()]; if (entry && !entry.dirty) { this.removeEntry(fileInput.getResource()); } } } public get onModelChange(): Event { return this._onModelChange.event; } public get onWorkingFileChange(): Event { return this._onWorkingFileChange.event; } public getEntries(excludeOutOfContext?: boolean): WorkingFileEntry[] { return this.entries; } public first(): WorkingFileEntry { if (!this.entries.length) { return null; } return this.entries.slice(0).sort(WorkingFilesModel.compare)[0]; } public last(): WorkingFileEntry { if (!this.entries.length) { return null; } return this.entries.slice(0).sort(WorkingFilesModel.compare)[this.entries.length - 1]; } public next(start?: uri): WorkingFileEntry { let entry = start && this.findEntry(start); if (entry) { let sorted = this.entries.slice(0).sort(WorkingFilesModel.compare); let index = sorted.indexOf(entry); if (index + 1 < sorted.length) { return sorted[index + 1]; } } return this.first(); } public previous(start?: uri): WorkingFileEntry { let entry = start && this.findEntry(start); if (entry) { let sorted = this.entries.slice(0).sort(WorkingFilesModel.compare); let index = sorted.indexOf(entry); if (index > 0) { return sorted[index - 1]; } } return this.last(); } public getOutOfWorkspaceContextEntries(): WorkingFileEntry[] { return this.entries.filter((entry) => this.isOutOfWorkspace(entry.resource)); } private isOutOfWorkspace(resource: uri): boolean { if (resource.scheme !== 'file') { return false; } let workspace = this.contextService.getWorkspace(); return !workspace || !paths.isEqualOrParent(resource.fsPath, workspace.resource.fsPath); } public count(): number { return this.entries.length; } public addEntry(resource: uri): WorkingFileEntry; public addEntry(stat: IFileStat): WorkingFileEntry; public addEntry(entry: WorkingFileEntry): WorkingFileEntry; public addEntry(arg1: IFileStat | WorkingFileEntry | uri): WorkingFileEntry { let resource: uri; if (arg1 instanceof WorkingFileEntry) { resource = (arg1).resource; } else if (uri.isURI(arg1)) { resource = arg1; } else { resource = (arg1).resource; } let existing = this.findEntry(resource); if (existing) { return existing; } let entry: WorkingFileEntry; if (arg1 instanceof WorkingFileEntry) { entry = arg1; } else { entry = this.createEntry(resource); } this.entries.push(entry); this.mapEntryToResource[entry.resource.toString()] = entry; this.fireModelChange({ added: [entry] }); return entry; } private createEntry(resource: uri, index?: number, dirty?: boolean): WorkingFileEntry { return new WorkingFileEntry(resource, typeof index === 'number' ? index : this.entries.length, dirty); } public moveEntry(oldResource: uri, newResource: uri): void { let oldEntry = this.findEntry(oldResource); if (oldEntry && !this.findEntry(newResource)) { // Add new with properties from old one let newEntry = this.createEntry(newResource, oldEntry.index, oldEntry.dirty); this.entries.push(newEntry); this.mapEntryToResource[newResource.toString()] = newEntry; // Remove old this.entries.splice(this.entries.indexOf(oldEntry), 1); delete this.mapEntryToResource[oldResource.toString()]; this.fireModelChange({ added: [newEntry], removed: [oldEntry] }); } } public removeEntry(resource: uri): WorkingFileEntry; public removeEntry(entry: WorkingFileEntry): WorkingFileEntry; public removeEntry(arg1: WorkingFileEntry | uri): WorkingFileEntry { let resource: uri = arg1 instanceof WorkingFileEntry ? (arg1).resource : arg1; let index = this.indexOf(resource); if (index >= 0) { // Remove entry let removed = this.entries.splice(index, 1)[0]; delete this.mapEntryToResource[resource.toString()]; // Rebalance index let sortedEntries = this.entries.slice(0).sort(WorkingFilesModel.compare); let newTopIndex = 0; for (let i = 0; i < sortedEntries.length; i++) { if (sortedEntries[i] === removed) { continue; } sortedEntries[i].setIndex(newTopIndex++); } this.fireModelChange({ removed: [removed] }); return removed; } return null; } public reorder(source: WorkingFileEntry, target: WorkingFileEntry): void { let sortedEntries = this.entries.slice(0).sort(WorkingFilesModel.compare); let indexOfSource = sortedEntries.indexOf(source); let indexOfTarget = sortedEntries.indexOf(target); arrays.move(sortedEntries, indexOfSource, indexOfTarget); // Rebalance index let newTopIndex = 0; for (let i = 0; i < sortedEntries.length; i++) { sortedEntries[i].setIndex(newTopIndex++); } // Fire event this.fireModelChange({}); } public clear(): void { let deleted = this.entries; this.entries = []; this.mapEntryToResource = Object.create(null); this.fireModelChange({ removed: deleted }); } public hasEntry(resource: uri): boolean { return !!this.findEntry(resource); } public findEntry(resource: uri): WorkingFileEntry { return this.mapEntryToResource[resource.toString()]; } private indexOf(resource: uri): number { let entry = this.findEntry(resource); if (entry) { return this.entries.indexOf(entry); } return -1; } public shutdown(): void { let sortedEntries = this.entries.slice(0).sort(WorkingFilesModel.compare); let index = 0; let entries: ISerializedWorkingFileEntry[] = []; sortedEntries.forEach((entry) => { if (!entry.isUntitled) { entries.push({ resource: entry.resource.toString(), index: index++ }); } }); this.storageService.store(WorkingFilesModel.STORAGE_KEY, JSON.stringify(entries), StorageScope.WORKSPACE); } private load(): void { // Load from settings let modelRaw = this.storageService.get(WorkingFilesModel.STORAGE_KEY, StorageScope.WORKSPACE); if (modelRaw) { let model: ISerializedWorkingFileEntry[] = JSON.parse(modelRaw); model.forEach((entry) => { this.addEntry(new WorkingFileEntry(uri.parse(entry.resource), entry.index, false)); }); } // Add those that are set to open on startup let options = this.contextService.getOptions(); let filesToOpen = (options && options.filesToOpen) || []; arrays .distinct(filesToOpen, (r) => r.resource.toString()) // no duplicates .map((f) => f.resource) // just the resource .filter((r) => r.scheme === 'untitled' || this.isOutOfWorkspace(r)) // untitled or out of workspace .forEach((r) => { this.addEntry(r); }); // Add untitled ones (after joinCreation() to make sure we catch everything) this.partService.joinCreation().done(() => { if (this.untitledEditorService) { this.untitledEditorService.getAll().map((u) => u.getResource()) .filter((r) => !this.untitledEditorService.hasAssociatedFilePath(r)) // only those without association .forEach((r) => { this.addEntry(r); }); } }, errors.onUnexpectedError); } public dispose(): void { this.toDispose = disposeAll(this.toDispose); } private fireModelChange(event: IWorkingFileModelChangeEvent): void { this._onModelChange.fire(event); } private fireWorkingFileChange(file: WorkingFileEntry): void { this._onWorkingFileChange.fire(file); } public static compare(element: WorkingFileEntry, otherElement: WorkingFileEntry): number { return element.index - otherElement.index; } } interface ISerializedWorkingFileEntry { resource: string; index: number; } export class WorkingFileEntry implements IWorkingFileEntry { private _resource: uri; private _index: number; private _dirty: boolean; constructor(resource: uri, index: number, dirty: boolean) { this._resource = resource; this._index = index; this._dirty = dirty; } public get resource(): uri { return this._resource; } public get index(): number { return this._index; } public setIndex(index: number): void { this._index = index; } public get dirty(): boolean { return this._dirty; } public setDirty(dirty: boolean): void { this._dirty = dirty; } public get CLASS_ID(): string { return WORKING_FILES_MODEL_ENTRY_CLASS_ID; } public get isFile(): boolean { return this._resource.scheme === 'file'; } public get isUntitled(): boolean { return this._resource.scheme === 'untitled'; } }