/*--------------------------------------------------------------------------------------------- * 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 {toErrorMessage, onUnexpectedError} from 'vs/base/common/errors'; import {EmitterEvent} from 'vs/base/common/eventEmitter'; import {IModelService} from 'vs/editor/common/services/modelService'; import * as EditorCommon from 'vs/editor/common/editorCommon'; import {MirrorModel2} from 'vs/editor/common/model/mirrorModel2'; import {Remotable, IThreadService} from 'vs/platform/thread/common/thread'; import Event, {Emitter} from 'vs/base/common/event'; import URI from 'vs/base/common/uri'; import {IDisposable, dispose} from 'vs/base/common/lifecycle'; import {Range, Position, Disposable} from 'vs/workbench/api/node/extHostTypes'; import {IEventService} from 'vs/platform/event/common/event'; import {IWorkbenchEditorService} from 'vs/workbench/services/editor/common/editorService'; import {EventType as FileEventType, TextFileChangeEvent, ITextFileService} from 'vs/workbench/parts/files/common/files'; import * as TypeConverters from './extHostTypeConverters'; import {TPromise} from 'vs/base/common/winjs.base'; import * as vscode from 'vscode'; import {WordHelper} from 'vs/editor/common/model/textModelWithTokensHelpers'; import {IFileService} from 'vs/platform/files/common/files'; import {IModeService} from 'vs/editor/common/services/modeService'; import {IUntitledEditorService} from 'vs/workbench/services/untitled/common/untitledEditorService'; import {ResourceEditorInput} from 'vs/workbench/common/editor/resourceEditorInput'; import {asWinJsPromise} from 'vs/base/common/async'; export interface IModelAddedData { url: URI; versionId: number; value: EditorCommon.IRawText; modeId: string; isDirty: boolean; } const _modeId2WordDefinition: { [modeId: string]: RegExp; } = Object.create(null); export function setWordDefinitionFor(modeId: string, wordDefinition: RegExp): void { _modeId2WordDefinition[modeId] = wordDefinition; } export function getWordDefinitionFor(modeId: string): RegExp { return _modeId2WordDefinition[modeId]; } @Remotable.ExtHostContext('ExtHostModelService') export class ExtHostModelService { private static _handlePool: number = 0; private _onDidAddDocumentEventEmitter: Emitter; public onDidAddDocument: Event; private _onDidRemoveDocumentEventEmitter: Emitter; public onDidRemoveDocument: Event; private _onDidChangeDocumentEventEmitter: Emitter; public onDidChangeDocument: Event; private _onDidSaveDocumentEventEmitter: Emitter; public onDidSaveDocument: Event; private _documentData: { [modelUri: string]: ExtHostDocumentData; }; private _documentLoader: { [modelUri: string]: TPromise }; private _documentContentProviders: { [handle: number]: vscode.TextDocumentContentProvider; }; private _proxy: MainThreadDocuments; constructor(@IThreadService threadService: IThreadService) { this._proxy = threadService.getRemotable(MainThreadDocuments); this._onDidAddDocumentEventEmitter = new Emitter(); this.onDidAddDocument = this._onDidAddDocumentEventEmitter.event; this._onDidRemoveDocumentEventEmitter = new Emitter(); this.onDidRemoveDocument = this._onDidRemoveDocumentEventEmitter.event; this._onDidChangeDocumentEventEmitter = new Emitter(); this.onDidChangeDocument = this._onDidChangeDocumentEventEmitter.event; this._onDidSaveDocumentEventEmitter = new Emitter(); this.onDidSaveDocument = this._onDidSaveDocumentEventEmitter.event; this._documentData = Object.create(null); this._documentLoader = Object.create(null); this._documentContentProviders = Object.create(null); } public getAllDocumentData(): ExtHostDocumentData[] { const result: ExtHostDocumentData[] = []; for (let key in this._documentData) { result.push(this._documentData[key]); } return result; } public getDocumentData(resource: vscode.Uri): ExtHostDocumentData { if (!resource) { return; } const data = this._documentData[resource.toString()]; if (data) { return data; } } public ensureDocumentData(uri: URI): TPromise { let cached = this._documentData[uri.toString()]; if (cached) { return TPromise.as(cached); } let promise = this._documentLoader[uri.toString()]; if (!promise) { promise = this._proxy._tryOpenDocument(uri).then(() => { delete this._documentLoader[uri.toString()]; return this._documentData[uri.toString()]; }, err => { delete this._documentLoader[uri.toString()]; return TPromise.wrapError(err); }); this._documentLoader[uri.toString()] = promise; } return promise; } public registerTextDocumentContentProvider(scheme: string, provider: vscode.TextDocumentContentProvider): vscode.Disposable { if (scheme === 'file' || scheme === 'untitled') { throw new Error(`scheme '${scheme}' already registered`); } const handle = ExtHostModelService._handlePool++; this._documentContentProviders[handle] = provider; this._proxy.$registerTextContentProvider(handle, scheme); let subscription: IDisposable; if (typeof provider.onDidChange === 'function') { subscription = provider.onDidChange(uri => { if (this._documentData[uri.toString()]) { this.$provideTextDocumentContent(handle, uri).then(value => { return this._proxy.$onVirtualDocumentChange(uri, value); }, onUnexpectedError); } }); } return new Disposable(() => { if (delete this._documentContentProviders[handle]) { this._proxy.$unregisterTextContentProvider(handle); } if (subscription) { subscription.dispose(); subscription = undefined; } }); } $provideTextDocumentContent(handle: number, uri: URI): TPromise { const provider = this._documentContentProviders[handle]; if (!provider) { return TPromise.wrapError(`unsupported uri-scheme: ${uri.scheme}`); } return asWinJsPromise(token => provider.provideTextDocumentContent(uri, token)); } public _acceptModelAdd(initData: IModelAddedData): void { let data = new ExtHostDocumentData(this._proxy, initData.url, initData.value.lines, initData.value.EOL, initData.modeId, initData.versionId, initData.isDirty); let key = data.document.uri.toString(); if (this._documentData[key]) { throw new Error('Document `' + key + '` already exists.'); } this._documentData[key] = data; this._onDidAddDocumentEventEmitter.fire(data.document); } public _acceptModelModeChanged(strURL: string, oldModeId: string, newModeId: string): void { let data = this._documentData[strURL]; // Treat a mode change as a remove + add this._onDidRemoveDocumentEventEmitter.fire(data.document); data._acceptLanguageId(newModeId); this._onDidAddDocumentEventEmitter.fire(data.document); } public _acceptModelSaved(strURL: string): void { let data = this._documentData[strURL]; data._acceptIsDirty(false); this._onDidSaveDocumentEventEmitter.fire(data.document); } public _acceptModelDirty(strURL: string): void { let document = this._documentData[strURL]; document._acceptIsDirty(true); } public _acceptModelReverted(strURL: string): void { let document = this._documentData[strURL]; document._acceptIsDirty(false); } public _acceptModelRemoved(strURL: string): void { if (!this._documentData[strURL]) { throw new Error('Document `' + strURL + '` does not exist.'); } let data = this._documentData[strURL]; delete this._documentData[strURL]; this._onDidRemoveDocumentEventEmitter.fire(data.document); data.dispose(); } public _acceptModelChanged(strURL: string, events: EditorCommon.IModelContentChangedEvent2[]): void { let data = this._documentData[strURL]; data.onEvents(events); this._onDidChangeDocumentEventEmitter.fire({ document: data.document, contentChanges: events.map((e) => { return { range: TypeConverters.toRange(e.range), rangeLength: e.rangeLength, text: e.text }; }) }); } } export class ExtHostDocumentData extends MirrorModel2 { private _proxy: MainThreadDocuments; private _languageId: string; private _isDirty: boolean; private _textLines: vscode.TextLine[]; private _document: vscode.TextDocument; constructor(proxy: MainThreadDocuments, uri: URI, lines: string[], eol: string, languageId: string, versionId: number, isDirty: boolean) { super(uri, lines, eol, versionId); this._proxy = proxy; this._languageId = languageId; this._isDirty = isDirty; this._textLines = []; } dispose(): void { this._textLines.length = 0; this._isDirty = false; super.dispose(); } get document(): vscode.TextDocument { if (!this._document) { const data = this; this._document = { get uri() { return data._uri; }, get fileName() { return data._uri.fsPath; }, get isUntitled() { return data._uri.scheme !== 'file'; }, get languageId() { return data._languageId; }, get version() { return data._versionId; }, get isDirty() { return data._isDirty; }, save() { return data._proxy._trySaveDocument(data._uri); }, getText(range?) { return range ? data._getTextInRange(range) : data.getText(); }, get lineCount() { return data._lines.length; }, lineAt(lineOrPos) { return data.lineAt(lineOrPos); }, offsetAt(pos) { return data.offsetAt(pos); }, positionAt(offset) { return data.positionAt(offset); }, validateRange(ran) { return data.validateRange(ran); }, validatePosition(pos) { return data.validatePosition(pos); }, getWordRangeAtPosition(pos) { return data.getWordRangeAtPosition(pos); } }; } return this._document; } _acceptLanguageId(newLanguageId: string): void { this._languageId = newLanguageId; } _acceptIsDirty(isDirty: boolean): void { this._isDirty = isDirty; } private _getTextInRange(_range: vscode.Range): string { let range = this.validateRange(_range); if (range.isEmpty) { return ''; } if (range.isSingleLine) { return this._lines[range.start.line].substring(range.start.character, range.end.character); } let lineEnding = this._eol, startLineIndex = range.start.line, endLineIndex = range.end.line, resultLines: string[] = []; resultLines.push(this._lines[startLineIndex].substring(range.start.character)); for (var i = startLineIndex + 1; i < endLineIndex; i++) { resultLines.push(this._lines[i]); } resultLines.push(this._lines[endLineIndex].substring(0, range.end.character)); return resultLines.join(lineEnding); } lineAt(lineOrPosition: number | vscode.Position): vscode.TextLine { let line: number; if (lineOrPosition instanceof Position) { line = lineOrPosition.line; } else if (typeof lineOrPosition === 'number') { line = lineOrPosition; } if (line < 0 || line >= this._lines.length) { throw new Error('Illegal value for `line`'); } let result = this._textLines[line]; if (!result || result.lineNumber !== line || result.text !== this._lines[line]) { const text = this._lines[line]; const firstNonWhitespaceCharacterIndex = /^(\s*)/.exec(text)[1].length; const range = new Range(line, 0, line, text.length); const rangeIncludingLineBreak = line < this._lines.length - 1 ? new Range(line, 0, line + 1, 0) : range; result = Object.freeze({ lineNumber: line, range, rangeIncludingLineBreak, text, firstNonWhitespaceCharacterIndex, //TODO@api, rename to 'leadingWhitespaceLength' isEmptyOrWhitespace: firstNonWhitespaceCharacterIndex === text.length }); this._textLines[line] = result; } return result; } offsetAt(position: vscode.Position): number { position = this.validatePosition(position); this._ensureLineStarts(); return this._lineStarts.getAccumulatedValue(position.line - 1) + position.character; } positionAt(offset: number): vscode.Position { offset = Math.floor(offset); offset = Math.max(0, offset); this._ensureLineStarts(); let out = this._lineStarts.getIndexOf(offset); let lineLength = this._lines[out.index].length; // Ensure we return a valid position return new Position(out.index, Math.min(out.remainder, lineLength)); } // ---- range math validateRange(range: vscode.Range): vscode.Range { if (!(range instanceof Range)) { throw new Error('Invalid argument'); } let start = this.validatePosition(range.start); let end = this.validatePosition(range.end); if (start === range.start && end === range.end) { return range; } return new Range(start.line, start.character, end.line, end.character); } validatePosition(position: vscode.Position): vscode.Position { if (!(position instanceof Position)) { throw new Error('Invalid argument'); } let {line, character} = position; let hasChanged = false; if (line < 0) { line = 0; character = 0; hasChanged = true; } else if (line >= this._lines.length) { line = this._lines.length - 1; character = this._lines[line].length; hasChanged = true; } else { let maxCharacter = this._lines[line].length; if (character < 0) { character = 0; hasChanged = true; } else if (character > maxCharacter) { character = maxCharacter; hasChanged = true; } } if (!hasChanged) { return position; } return new Position(line, character); } getWordRangeAtPosition(_position: vscode.Position): vscode.Range { let position = this.validatePosition(_position); let wordAtText = WordHelper._getWordAtText( position.character + 1, WordHelper.ensureValidWordDefinition(getWordDefinitionFor(this._languageId)), this._lines[position.line], 0 ); if (wordAtText) { return new Range(position.line, wordAtText.startColumn - 1, position.line, wordAtText.endColumn - 1); } } } @Remotable.MainContext('MainThreadDocuments') export class MainThreadDocuments { private _modelService: IModelService; private _modeService: IModeService; private _textFileService: ITextFileService; private _editorService: IWorkbenchEditorService; private _fileService: IFileService; private _untitledEditorService: IUntitledEditorService; private _toDispose: IDisposable[]; private _modelToDisposeMap: { [modelUrl: string]: IDisposable; }; private _proxy: ExtHostModelService; private _modelIsSynced: { [modelId: string]: boolean; }; private _resourceContentProvider: { [handle: number]: IDisposable }; private _virtualDocumentSet: { [resource: string]: boolean }; constructor( @IThreadService threadService: IThreadService, @IModelService modelService: IModelService, @IModeService modeService: IModeService, @IEventService eventService: IEventService, @ITextFileService textFileService: ITextFileService, @IWorkbenchEditorService editorService: IWorkbenchEditorService, @IFileService fileService: IFileService, @IUntitledEditorService untitledEditorService: IUntitledEditorService ) { this._modelService = modelService; this._modeService = modeService; this._textFileService = textFileService; this._editorService = editorService; this._fileService = fileService; this._untitledEditorService = untitledEditorService; this._proxy = threadService.getRemotable(ExtHostModelService); this._modelIsSynced = {}; this._toDispose = []; modelService.onModelAdded(this._onModelAdded, this, this._toDispose); modelService.onModelRemoved(this._onModelRemoved, this, this._toDispose); modelService.onModelModeChanged(this._onModelModeChanged, this, this._toDispose); this._toDispose.push(eventService.addListener2(FileEventType.FILE_SAVED, (e: TextFileChangeEvent) => { if (this._shouldHandleFileEvent(e)) { this._proxy._acceptModelSaved(e.resource.toString()); } })); this._toDispose.push(eventService.addListener2(FileEventType.FILE_REVERTED, (e: TextFileChangeEvent) => { if (this._shouldHandleFileEvent(e)) { this._proxy._acceptModelReverted(e.resource.toString()); } })); this._toDispose.push(eventService.addListener2(FileEventType.FILE_DIRTY, (e: TextFileChangeEvent) => { if (this._shouldHandleFileEvent(e)) { this._proxy._acceptModelDirty(e.resource.toString()); } })); const handle = setInterval(() => this._runDocumentCleanup(), 1000 * 60 * 3); this._toDispose.push({ dispose() { clearInterval(handle); } }); this._modelToDisposeMap = Object.create(null); this._resourceContentProvider = Object.create(null); this._virtualDocumentSet = Object.create(null); } public dispose(): void { Object.keys(this._modelToDisposeMap).forEach((modelUrl) => { this._modelToDisposeMap[modelUrl].dispose(); }); this._modelToDisposeMap = Object.create(null); this._toDispose = dispose(this._toDispose); } private _shouldHandleFileEvent(e: TextFileChangeEvent): boolean { const model = this._modelService.getModel(e.resource); return model && !model.isTooLargeForHavingARichMode(); } private _onModelAdded(model: EditorCommon.IModel): void { // Same filter as in mainThreadEditors if (model.isTooLargeForHavingARichMode()) { // don't synchronize too large models return null; } let modelUrl = model.uri; this._modelIsSynced[modelUrl.toString()] = true; this._modelToDisposeMap[modelUrl.toString()] = model.addBulkListener2((events) => this._onModelEvents(modelUrl, events)); this._proxy._acceptModelAdd({ url: model.uri, versionId: model.getVersionId(), value: model.toRawText(), modeId: model.getMode().getId(), isDirty: this._textFileService.isDirty(modelUrl) }); } private _onModelModeChanged(event: { model: EditorCommon.IModel; oldModeId: string; }): void { let {model, oldModeId} = event; let modelUrl = model.uri; if (!this._modelIsSynced[modelUrl.toString()]) { return; } this._proxy._acceptModelModeChanged(model.uri.toString(), oldModeId, model.getMode().getId()); } private _onModelRemoved(model: EditorCommon.IModel): void { let modelUrl = model.uri; if (!this._modelIsSynced[modelUrl.toString()]) { return; } delete this._modelIsSynced[modelUrl.toString()]; this._modelToDisposeMap[modelUrl.toString()].dispose(); delete this._modelToDisposeMap[modelUrl.toString()]; this._proxy._acceptModelRemoved(modelUrl.toString()); } private _onModelEvents(modelUrl: URI, events: EmitterEvent[]): void { let changedEvents: EditorCommon.IModelContentChangedEvent2[] = []; for (let i = 0, len = events.length; i < len; i++) { let e = events[i]; switch (e.getType()) { case EditorCommon.EventType.ModelContentChanged2: changedEvents.push(e.getData()); break; } } if (changedEvents.length > 0) { this._proxy._acceptModelChanged(modelUrl.toString(), changedEvents); } } // --- from extension host process _trySaveDocument(uri: URI): TPromise { return this._textFileService.save(uri); } _tryOpenDocument(uri: URI): TPromise { if (!uri.scheme || !(uri.fsPath || uri.authority)) { return TPromise.wrapError(`Invalid uri. Scheme and authority or path must be set.`); } let promise: TPromise; switch (uri.scheme) { case 'untitled': promise = this._handleUnititledScheme(uri); break; case 'file': default: promise = this._handleAsResourceInput(uri); break; } return promise.then(success => { if (!success) { return TPromise.wrapError('cannot open ' + uri.toString()); } }, err => { return TPromise.wrapError('cannot open ' + uri.toString() + '. Detail: ' + toErrorMessage(err)); }); } private _handleAsResourceInput(uri: URI): TPromise { return this._editorService.resolveEditorModel({ resource: uri }).then(model => { return !!model; }); } private _handleUnititledScheme(uri: URI): TPromise { let asFileUri = URI.file(uri.fsPath); return this._fileService.resolveFile(asFileUri).then(stats => { // don't create a new file ontop of an existing file return TPromise.wrapError('file already exists on disk'); }, err => { let input = this._untitledEditorService.createOrGet(asFileUri); // using file-uri makes it show in 'Working Files' section return input.resolve(true).then(model => { if (input.getResource().toString() !== uri.toString()) { throw new Error(`expected URI ${uri.toString() } BUT GOT ${input.getResource().toString() }`); } if (!this._modelIsSynced[uri.toString()]) { throw new Error(`expected URI ${uri.toString()} to have come to LIFE`); } return this._proxy._acceptModelDirty(uri.toString()); // mark as dirty }).then(() => { return true; }); }); } // --- virtual document logic $registerTextContentProvider(handle:number, scheme: string): void { this._resourceContentProvider[handle] = ResourceEditorInput.registerResourceContentProvider(scheme, { provideTextContent: (uri: URI): TPromise => { return this._proxy.$provideTextDocumentContent(handle, uri).then(value => { if (typeof value === 'string') { this._virtualDocumentSet[uri.toString()] = true; const firstLineText = value.substr(0, 1 + value.search(/\r?\n/)); const mode = this._modeService.getOrCreateModeByFilenameOrFirstLine(uri.fsPath, firstLineText); return this._modelService.createModel(value, mode, uri); } }); } }); } $unregisterTextContentProvider(handle: number): void { const registration = this._resourceContentProvider[handle]; if (registration) { registration.dispose(); delete this._resourceContentProvider[handle]; } } $onVirtualDocumentChange(uri: URI, value: string): void { const model = this._modelService.getModel(uri); if (model) { model.setValue(value); } } private _runDocumentCleanup(): void { const toBeDisposed: URI[] = []; TPromise.join(Object.keys(this._virtualDocumentSet).map(key => { let resource = URI.parse(key); return this._editorService.inputToType({ resource }).then(input => { if (!this._editorService.isVisible(input, true)) { toBeDisposed.push(resource); } }); })).then(() => { for (let resource of toBeDisposed) { this._modelService.destroyModel(resource); delete this._virtualDocumentSet[resource.toString()]; } }, onUnexpectedError); } }