diff --git a/extensions/vscode-api-tests/src/workspace.test.ts b/extensions/vscode-api-tests/src/workspace.test.ts index d4c4ae7013c185bb0ebb311b0cbdd4a4ad886906..d698bba1eabe0a51e728da3881f53fee440b751e 100644 --- a/extensions/vscode-api-tests/src/workspace.test.ts +++ b/extensions/vscode-api-tests/src/workspace.test.ts @@ -10,6 +10,7 @@ import * as vscode from 'vscode'; import { createRandomFile, deleteFile, closeAllEditors, pathEquals } from './utils'; import { join, basename } from 'path'; import * as fs from 'fs'; +import { Uri } from 'vscode'; suite('workspace-namespace', () => { @@ -505,4 +506,44 @@ suite('workspace-namespace', () => { return vscode.workspace.applyEdit(edit); }); }); + + + test('applyEdit should fail when editing deleted resource', async () => { + const resource = await createRandomFile(); + + const edit = new vscode.WorkspaceEdit(); + edit.deleteResource(resource); + try { + edit.insert(resource, new vscode.Position(0, 0), ''); + assert.fail(false, 'Should disallow edit of deleted resource'); + } catch { + // noop + } + }); + + test('applyEdit should fail when renaming deleted resource', async () => { + const resource = await createRandomFile(); + + const edit = new vscode.WorkspaceEdit(); + edit.deleteResource(resource); + try { + edit.renameResource(resource, resource); + assert.fail(false, 'Should disallow rename of deleted resource'); + } catch { + // noop + } + }); + + test('applyEdit should fail when editing renamed from resource', async () => { + const resource = await createRandomFile(); + const newResource = Uri.parse(resource.fsPath + '.1'); + const edit = new vscode.WorkspaceEdit(); + edit.renameResource(resource, newResource); + try { + edit.insert(resource, new vscode.Position(0, 0), ''); + assert.fail(false, 'Should disallow editing renamed file'); + } catch { + // noop + } + }); }); diff --git a/src/vs/editor/browser/services/bulkEdit.ts b/src/vs/editor/browser/services/bulkEdit.ts index bfe4d9c4df32dda0a59c6a32b217eb4a2f3064f1..cded7ca6be52ef01c03ef3d763a79779d31712ae 100644 --- a/src/vs/editor/browser/services/bulkEdit.ts +++ b/src/vs/editor/browser/services/bulkEdit.ts @@ -5,68 +5,48 @@ 'use strict'; import * as nls from 'vs/nls'; -import { flatten } from 'vs/base/common/arrays'; -import { IStringDictionary, forEach, values, groupBy, size } from 'vs/base/common/collections'; import { IDisposable, dispose, IReference } from 'vs/base/common/lifecycle'; import URI from 'vs/base/common/uri'; import { TPromise } from 'vs/base/common/winjs.base'; import { ITextModelService, ITextEditorModel } from 'vs/editor/common/services/resolverService'; -import { IFileService, IFileChange } from 'vs/platform/files/common/files'; +import { IFileService, FileChangeType } from 'vs/platform/files/common/files'; import { EditOperation } from 'vs/editor/common/core/editOperation'; -import { Range, IRange } from 'vs/editor/common/core/range'; -import { Selection, ISelection } from 'vs/editor/common/core/selection'; +import { Range } from 'vs/editor/common/core/range'; +import { Selection } from 'vs/editor/common/core/selection'; import { IIdentifiedSingleEditOperation, ITextModel, EndOfLineSequence } from 'vs/editor/common/model'; -import { IProgressRunner } from 'vs/platform/progress/common/progress'; +import { IProgressRunner, emptyProgressRunner, IProgress } from 'vs/platform/progress/common/progress'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { optional } from 'vs/platform/instantiation/common/instantiation'; +import { ResourceTextEdit, ResourceFileEdit, isResourceFileEdit, isResourceTextEdit } from 'vs/editor/common/modes'; +import { getPathLabel } from 'vs/base/common/labels'; -export interface IResourceEdit { - resource: URI; - range?: IRange; - newText: string; - newEol?: EndOfLineSequence; -} - -interface IRecording { - stop(): void; - hasChanged(resource: URI): boolean; - allChanges(): IFileChange[]; -} -class ChangeRecorder { - - private _fileService: IFileService; - - constructor(fileService?: IFileService) { - this._fileService = fileService; - } +abstract class IRecording { - public start(): IRecording { - - const changes: IStringDictionary = Object.create(null); + static start(fileService: IFileService): IRecording { + const _changes = new Set(); let stop: IDisposable; - if (this._fileService) { - stop = this._fileService.onFileChanges((event) => { - event.changes.forEach(change => { - - const key = String(change.resource); - let array = changes[key]; - if (!array) { - changes[key] = array = []; + if (fileService) { + // watch only when there is a fileservice available + stop = fileService.onFileChanges(event => { + for (const change of event.changes) { + if (change.type === FileChangeType.UPDATED) { + _changes.add(change.resource.toString()); } - - array.push(change); - }); + } }); } return { - stop: () => { return stop && stop.dispose(); }, - hasChanged: (resource: URI) => !!changes[resource.toString()], - allChanges: () => flatten(values(changes)) + stop() { return dispose(stop); }, + hasChanged(resource) { return _changes.has(resource.toString()); } }; } + + abstract stop(): void; + abstract hasChanged(resource: URI): boolean; } class EditTask implements IDisposable { @@ -84,26 +64,34 @@ class EditTask implements IDisposable { this._edits = []; } - public addEdit(edit: IResourceEdit): void { - - if (typeof edit.newEol === 'number') { - // honor eol-change - this._newEol = edit.newEol; + dispose() { + if (this._model) { + this._modelReference.dispose(); + this._modelReference = null; } + } - if (edit.range || edit.newText) { - // create edit operation - let range: Range; - if (!edit.range) { - range = this._model.getFullModelRange(); - } else { - range = Range.lift(edit.range); + addEdit(resourceEdit: ResourceTextEdit): void { + + for (const edit of resourceEdit.edits) { + if (typeof edit.eol === 'number') { + // honor eol-change + this._newEol = edit.eol; + } + if (edit.range || edit.text) { + // create edit operation + let range: Range; + if (!edit.range) { + range = this._model.getFullModelRange(); + } else { + range = Range.lift(edit.range); + } + this._edits.push(EditOperation.replaceMove(range, edit.text)); } - this._edits.push(EditOperation.replaceMove(range, edit.newText)); } } - public apply(): void { + apply(): void { if (this._edits.length > 0) { this._edits = this._edits.map((value, index) => ({ value, index })).sort((a, b) => { @@ -160,16 +148,10 @@ class EditTask implements IDisposable { return [this._endCursorSelection]; } - public getEndCursorSelection(): Selection { + getEndCursorSelection(): Selection { return this._endCursorSelection; } - dispose() { - if (this._model) { - this._modelReference.dispose(); - this._modelReference = null; - } - } } class SourceModelEditTask extends EditTask { @@ -189,34 +171,42 @@ class SourceModelEditTask extends EditTask { class BulkEditModel implements IDisposable { private _textModelResolverService: ITextModelService; - private _numberOfResourcesToModify: number = 0; - private _edits: IStringDictionary = Object.create(null); + private _edits = new Map(); private _tasks: EditTask[]; private _sourceModel: URI; private _sourceSelections: Selection[]; private _sourceModelTask: SourceModelEditTask; - - constructor(textModelResolverService: ITextModelService, sourceModel: URI, sourceSelections: Selection[], edits: IResourceEdit[], private progress: IProgressRunner = null) { + private _progress: IProgress; + + constructor( + textModelResolverService: ITextModelService, + editor: ICodeEditor, + edits: ResourceTextEdit[], + progress: IProgress + ) { this._textModelResolverService = textModelResolverService; - this._sourceModel = sourceModel; - this._sourceSelections = sourceSelections; - this._sourceModelTask = null; + this._sourceModel = editor ? editor.getModel().uri : undefined; + this._sourceSelections = editor ? editor.getSelections() : undefined; + this._sourceModelTask = undefined; + this._progress = progress; - for (let edit of edits) { - this._addEdit(edit); - } + edits.forEach(this.addEdit, this); } - private _addEdit(edit: IResourceEdit): void { - let array = this._edits[edit.resource.toString()]; + dispose(): void { + this._tasks = dispose(this._tasks); + } + + addEdit(edit: ResourceTextEdit): void { + let array = this._edits.get(edit.resource.toString()); if (!array) { - this._edits[edit.resource.toString()] = array = []; - this._numberOfResourcesToModify += 1; + array = []; + this._edits.set(edit.resource.toString(), array); } array.push(edit); } - public prepare(): TPromise { + async prepare(): TPromise { if (this._tasks) { throw new Error('illegal state - already prepared'); @@ -225,158 +215,167 @@ class BulkEditModel implements IDisposable { this._tasks = []; const promises: TPromise[] = []; - if (this.progress) { - this.progress.total(this._numberOfResourcesToModify * 2); - } - - forEach(this._edits, entry => { - const promise = this._textModelResolverService.createModelReference(URI.parse(entry.key)).then(ref => { + this._edits.forEach((value, key) => { + const promise = this._textModelResolverService.createModelReference(URI.parse(key)).then(ref => { const model = ref.object; if (!model || !model.textEditorModel) { - throw new Error(`Cannot load file ${entry.key}`); + throw new Error(`Cannot load file ${key}`); } - const textEditorModel = model.textEditorModel; let task: EditTask; - - if (this._sourceModel && textEditorModel.uri.toString() === this._sourceModel.toString()) { + if (this._sourceModel && model.textEditorModel.uri.toString() === this._sourceModel.toString()) { this._sourceModelTask = new SourceModelEditTask(ref, this._sourceSelections); task = this._sourceModelTask; } else { task = new EditTask(ref); } - entry.value.forEach(edit => task.addEdit(edit)); + value.forEach(edit => task.addEdit(edit)); this._tasks.push(task); - if (this.progress) { - this.progress.worked(1); - } + this._progress.report(undefined); }); promises.push(promise); }); + await TPromise.join(promises); - return TPromise.join(promises).then(_ => this); + return this; } - public apply(): Selection { - this._tasks.forEach(task => this.applyTask(task)); - let r: Selection = null; - if (this._sourceModelTask) { - r = this._sourceModelTask.getEndCursorSelection(); + apply(): Selection { + for (const task of this._tasks) { + task.apply(); + this._progress.report(undefined); } - return r; - } - - private applyTask(task: EditTask): void { - task.apply(); - if (this.progress) { - this.progress.worked(1); - } - } - - dispose(): void { - this._tasks = dispose(this._tasks); + return this._sourceModelTask + ? this._sourceModelTask.getEndCursorSelection() + : undefined; } } -export interface BulkEdit { - progress(progress: IProgressRunner): void; - add(edit: IResourceEdit[]): void; - finish(): TPromise; - ariaMessage(): string; -} - -export function bulkEdit(textModelResolverService: ITextModelService, editor: ICodeEditor, edits: IResourceEdit[], fileService?: IFileService, progress: IProgressRunner = null): TPromise { - let bulk = createBulkEdit(textModelResolverService, editor, fileService); - bulk.add(edits); - bulk.progress(progress); - return bulk.finish(); -} +export type Edit = ResourceFileEdit | ResourceTextEdit; -export function createBulkEdit(textModelResolverService: ITextModelService, editor?: ICodeEditor, fileService?: IFileService): BulkEdit { +export class BulkEdit { - let all: IResourceEdit[] = []; - let recording = new ChangeRecorder(fileService).start(); - let progressRunner: IProgressRunner; - - function progress(progress: IProgressRunner) { - progressRunner = progress; + static perform(edits: Edit[], textModelService: ITextModelService, fileService: IFileService, editor: ICodeEditor): TPromise { + const edit = new BulkEdit(editor, null, textModelService, fileService); + edit.add(edits); + return edit.perform(); } - function add(edits: IResourceEdit[]): void { - all.push(...edits); + private _edits: Edit[] = []; + private _editor: ICodeEditor; + private _progress: IProgressRunner; + + constructor( + editor: ICodeEditor, + progress: IProgressRunner, + @ITextModelService private _textModelService: ITextModelService, + @optional(IFileService) private _fileService: IFileService + ) { + this._editor = editor; + this._progress = progress || emptyProgressRunner; } - function getConcurrentEdits() { - let names: string[]; - for (let edit of all) { - if (recording.hasChanged(edit.resource)) { - if (!names) { - names = []; - } - names.push(edit.resource.fsPath); - } + add(edits: Edit[] | Edit): void { + if (Array.isArray(edits)) { + this._edits.push(...edits); + } else { + this._edits.push(edits); } - if (names) { - return nls.localize('conflict', "These files have changed in the meantime: {0}", names.join(', ')); + } + + ariaMessage(): string { + const editCount = this._edits.reduce((prev, cur) => isResourceFileEdit(cur) ? prev : prev + cur.edits.length, 0); + const resourceCount = this._edits.length; + if (editCount === 0) { + return nls.localize('summary.0', "Made no edits"); + } else if (editCount > 1 && resourceCount > 1) { + return nls.localize('summary.nm', "Made {0} text edits in {1} files", editCount, resourceCount); + } else { + return nls.localize('summary.n0', "Made {0} text edits in one file", editCount, resourceCount); } - return undefined; } - function finish(): TPromise { + async perform(): TPromise { + + let seen = new Set(); + let total = 0; + + const groups: Edit[][] = []; + let group: Edit[]; + for (const edit of this._edits) { + if (!group + || (isResourceFileEdit(group[0]) && !isResourceFileEdit(edit)) + || (isResourceTextEdit(group[0]) && !isResourceTextEdit(edit)) + ) { + group = []; + groups.push(group); + } + group.push(edit); - if (all.length === 0) { - return TPromise.as(undefined); + if (isResourceFileEdit(edit)) { + total += 1; + } else if (!seen.has(edit.resource.toString())) { + seen.add(edit.resource.toString()); + total += 2; + } } - let concurrentEdits = getConcurrentEdits(); - if (concurrentEdits) { - return TPromise.wrapError(new Error(concurrentEdits)); + // define total work and progress callback + // for child operations + this._progress.total(total); + let progress: IProgress = { report: _ => this._progress.worked(1) }; + + // do it. return the last selection computed + // by a text change (can be undefined then) + let res: Selection = undefined; + for (const group of groups) { + if (isResourceFileEdit(group[0])) { + await this._performFileEdits(group, progress); + } else { + res = await this._performTextEdits(group, progress) || res; + } } + return res; + } + + private async _performFileEdits(edits: ResourceFileEdit[], progress: IProgress) { + for (const edit of edits) { - let uri: URI; - let selections: Selection[]; + progress.report(undefined); - if (editor && editor.getModel()) { - uri = editor.getModel().uri; - selections = editor.getSelections(); + if (edit.newUri && edit.oldUri) { + await this._fileService.moveFile(edit.oldUri, edit.newUri, false); + } else if (!edit.newUri && edit.oldUri) { + await this._fileService.del(edit.oldUri, true); + } else if (edit.newUri && !edit.oldUri) { + await this._fileService.createFile(edit.newUri, undefined, { overwrite: false }); + } } + } - const model = new BulkEditModel(textModelResolverService, uri, selections, all, progressRunner); + private async _performTextEdits(edits: ResourceTextEdit[], progress: IProgress): TPromise { - return model.prepare().then(_ => { + const recording = IRecording.start(this._fileService); + const model = new BulkEditModel(this._textModelService, this._editor, edits, progress); - let concurrentEdits = getConcurrentEdits(); - if (concurrentEdits) { - throw new Error(concurrentEdits); - } + await model.prepare(); - recording.stop(); + const conflicts = edits + .filter(edit => recording.hasChanged(edit.resource)) + .map(edit => getPathLabel(edit.resource)); - const result = model.apply(); - model.dispose(); - return result; - }); - } + recording.stop(); - function ariaMessage(): string { - let editCount = all.length; - let resourceCount = size(groupBy(all, edit => edit.resource.toString())); - if (editCount === 0) { - return nls.localize('summary.0', "Made no edits"); - } else if (editCount > 1 && resourceCount > 1) { - return nls.localize('summary.nm', "Made {0} text edits in {1} files", editCount, resourceCount); - } else { - return nls.localize('summary.n0', "Made {0} text edits in one file", editCount, resourceCount); + if (conflicts.length > 0) { + model.dispose(); + throw new Error(nls.localize('conflict', "These files have changed in the meantime: {0}", conflicts.join(', '))); } - } - return { - progress, - add, - finish, - ariaMessage - }; + const selection = await model.apply(); + model.dispose(); + return selection; + } } diff --git a/src/vs/editor/common/modes.ts b/src/vs/editor/common/modes.ts index 6b573d085a9c2a72a92b580246e94bde0c638ddc..a09587997eebdb920a8eec33936bbd714df3c450 100644 --- a/src/vs/editor/common/modes.ts +++ b/src/vs/editor/common/modes.ts @@ -17,6 +17,7 @@ import { TokenizationRegistryImpl } from 'vs/editor/common/modes/tokenizationReg import { Color } from 'vs/base/common/color'; import { IMarkerData } from 'vs/platform/markers/common/markers'; import * as model from 'vs/editor/common/model'; +import { isObject } from 'vs/base/common/types'; /** * Open ended enum at runtime @@ -825,15 +826,36 @@ export interface DocumentColorProvider { provideColorPresentations(model: model.ITextModel, colorInfo: IColorInformation, token: CancellationToken): IColorPresentation[] | Thenable; } -export interface IResourceEdit { +/** + * @internal + */ +export function isResourceFileEdit(thing: any): thing is ResourceFileEdit { + return isObject(thing) && (Boolean((thing).newUri) || Boolean((thing).oldUri)); +} + +/** + * @internal + */ +export function isResourceTextEdit(thing: any): thing is ResourceTextEdit { + return isObject(thing) && (thing).resource && Array.isArray((thing).edits); +} + +export interface ResourceFileEdit { + oldUri: URI; + newUri: URI; +} + +export interface ResourceTextEdit { resource: URI; - range: IRange; - newText: string; + modelVersionId?: number; + edits: TextEdit[]; } + export interface WorkspaceEdit { - edits: IResourceEdit[]; - rejectReason?: string; + edits: Array; + rejectReason?: string; // TODO@joh, move to rename } + export interface RenameProvider { provideRenameEdits(model: model.ITextModel, position: Position, newName: string, token: CancellationToken): WorkspaceEdit | Thenable; } diff --git a/src/vs/editor/contrib/quickFix/quickFixCommands.ts b/src/vs/editor/contrib/quickFix/quickFixCommands.ts index 15f8c5ee8d86894dac80de1fb9f62362b1b3028b..61f424520f67f32e861065d9ec97af8e92c9efc8 100644 --- a/src/vs/editor/contrib/quickFix/quickFixCommands.ts +++ b/src/vs/editor/contrib/quickFix/quickFixCommands.ts @@ -23,7 +23,7 @@ import { QuickFixModel, QuickFixComputeEvent } from './quickFixModel'; import { CodeActionKind, CodeActionAutoApply } from './codeActionTrigger'; import { TPromise } from 'vs/base/common/winjs.base'; import { CodeAction } from 'vs/editor/common/modes'; -import { createBulkEdit } from 'vs/editor/browser/services/bulkEdit'; +import { BulkEdit } from 'vs/editor/browser/services/bulkEdit'; import { IFileService } from 'vs/platform/files/common/files'; import { ITextModelService } from 'vs/editor/common/services/resolverService'; @@ -129,9 +129,7 @@ export class QuickFixController implements IEditorContribution { private async _onApplyCodeAction(action: CodeAction): TPromise { if (action.edit) { - const edit = createBulkEdit(this._textModelService, this._editor, this._fileService); - edit.add(action.edit.edits); - await edit.finish(); + await BulkEdit.perform(action.edit.edits, this._textModelService, this._fileService, this._editor); } if (action.command) { diff --git a/src/vs/editor/contrib/quickFix/test/quickFix.test.ts b/src/vs/editor/contrib/quickFix/test/quickFix.test.ts index ba52da9dde7c2d4612f9565f406b0016366be84d..2d2185e7529148e87e562f03570e2536f9c813af 100644 --- a/src/vs/editor/contrib/quickFix/test/quickFix.test.ts +++ b/src/vs/editor/contrib/quickFix/test/quickFix.test.ts @@ -8,7 +8,7 @@ import * as assert from 'assert'; import URI from 'vs/base/common/uri'; import Severity from 'vs/base/common/severity'; import { TextModel } from 'vs/editor/common/model/textModel'; -import { CodeActionProviderRegistry, LanguageIdentifier, CodeActionProvider, Command, WorkspaceEdit, IResourceEdit, CodeAction, CodeActionContext } from 'vs/editor/common/modes'; +import { CodeActionProviderRegistry, LanguageIdentifier, CodeActionProvider, Command, WorkspaceEdit, ResourceTextEdit, CodeAction, CodeActionContext } from 'vs/editor/common/modes'; import { IDisposable, dispose } from 'vs/base/common/lifecycle'; import { Range } from 'vs/editor/common/core/range'; import { getCodeActions } from 'vs/editor/contrib/quickFix/quickFix'; @@ -58,7 +58,7 @@ suite('QuickFix', () => { bcd: { diagnostics: [], edit: new class implements WorkspaceEdit { - edits: IResourceEdit[]; + edits: ResourceTextEdit[]; }, title: 'abc' } diff --git a/src/vs/editor/contrib/rename/rename.ts b/src/vs/editor/contrib/rename/rename.ts index 0fbe5db81f991a4e28421e283dd95e95de509b8c..b25b9b3f74b0ca334c990290288303117ada2181 100644 --- a/src/vs/editor/contrib/rename/rename.ts +++ b/src/vs/editor/contrib/rename/rename.ts @@ -18,7 +18,7 @@ import { registerEditorAction, registerEditorContribution, ServicesAccessor, Edi import { IEditorContribution } from 'vs/editor/common/editorCommon'; import { ITextModel } from 'vs/editor/common/model'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; -import { createBulkEdit } from 'vs/editor/browser/services/bulkEdit'; +import { BulkEdit } from 'vs/editor/browser/services/bulkEdit'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import RenameInputField from './renameInputField'; import { ITextModelService } from 'vs/editor/common/services/resolverService'; @@ -147,9 +147,7 @@ class RenameController implements IEditorContribution { this._renameInputVisible.reset(); this.editor.focus(); - // start recording of file changes so that we can figure out if a file that - // is to be renamed conflicts with another (concurrent) modification - const edit = createBulkEdit(this._textModelResolverService, this.editor, this._fileService); + const edit = new BulkEdit(this.editor, null, this._textModelResolverService, this._fileService); const state = new EditorState(this.editor, CodeEditorStateFlag.Position | CodeEditorStateFlag.Value | CodeEditorStateFlag.Selection | CodeEditorStateFlag.Scroll); const renameOperation = rename(this.editor.getModel(), this.editor.getPosition(), newName).then(result => { @@ -163,7 +161,7 @@ class RenameController implements IEditorContribution { } edit.add(result.edits); - return edit.finish().then(selection => { + return edit.perform().then(selection => { if (selection) { this.editor.setSelection(selection); } diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index dd87b203a176010c3ffa57d505ec48ed0ca0cf54..fcaccb90a61ab2d8914129ac0c1c4f1cf384ace9 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -4913,14 +4913,19 @@ declare module monaco.languages { provideColorPresentations(model: editor.ITextModel, colorInfo: IColorInformation, token: CancellationToken): IColorPresentation[] | Thenable; } - export interface IResourceEdit { + export interface ResourceFileEdit { + oldUri: Uri; + newUri: Uri; + } + + export interface ResourceTextEdit { resource: Uri; - range: IRange; - newText: string; + modelVersionId?: number; + edits: TextEdit[]; } export interface WorkspaceEdit { - edits: IResourceEdit[]; + edits: Array; rejectReason?: string; } diff --git a/src/vs/platform/progress/common/progress.ts b/src/vs/platform/progress/common/progress.ts index 71a17a511767365389fb4f29275a53d1adaf4524..100276698ea2e43340fbb0a25f375d8c7a9ad58f 100644 --- a/src/vs/platform/progress/common/progress.ts +++ b/src/vs/platform/progress/common/progress.ts @@ -31,6 +31,12 @@ export interface IProgressRunner { done(): void; } +export const emptyProgressRunner: IProgressRunner = Object.freeze({ + total() { }, + worked() { }, + done() { } +}); + export interface IProgress { report(item: T): void; } diff --git a/src/vs/vscode.d.ts b/src/vs/vscode.d.ts index 184c8b3f1106df2e1dc427326c98e4cc0ba4770c..7b80ea7ab9598a5c19cbf389f7c5d8f1ac7a2b04 100644 --- a/src/vs/vscode.d.ts +++ b/src/vs/vscode.d.ts @@ -2559,6 +2559,42 @@ declare module 'vscode' { * @return An array of `[Uri, TextEdit[]]`-tuples. */ entries(): [Uri, TextEdit[]][]; + + /** + * Renames a given resource in the workspace. + * + * @param from Uri of current resource. + * @param to Uri of renamed resource. + */ + renameResource(from: Uri, to: Uri): void; + + /** + * Create a new resource in the workspace. + * + * @param uri Uri of resource to create. + */ + createResource(uri: Uri): void; + + /** + * Delete a given resource in the workspace. + * + * @param uri Uri of resource to delete. + */ + deleteResource(uri: Uri): void; + + /** + * Get the resource edits for this workspace edit. + * + * @returns A array of uri-tuples in which a rename-edit + * is represented as `[from, to]`, a delete-operation as `[from, null]`, + * and a create-operation as `[null, to]`; + */ + resourceEdits(): [Uri, Uri][]; + + /** + * + */ + allEntries(): ([Uri, TextEdit[]] | [Uri, Uri])[]; } /** diff --git a/src/vs/workbench/api/electron-browser/mainThreadEditors.ts b/src/vs/workbench/api/electron-browser/mainThreadEditors.ts index 0952ff3985fc1601e0f36459661ffc77e1dd5afc..409e9df963db84bd6b1bb0c0e3c8539166250e88 100644 --- a/src/vs/workbench/api/electron-browser/mainThreadEditors.ts +++ b/src/vs/workbench/api/electron-browser/mainThreadEditors.ts @@ -15,17 +15,18 @@ import { IWorkbenchEditorService } from 'vs/workbench/services/editor/common/edi import { IEditorGroupService } from 'vs/workbench/services/group/common/groupService'; import { Position as EditorPosition, ITextEditorOptions } from 'vs/platform/editor/common/editor'; import { MainThreadTextEditor } from './mainThreadEditor'; -import { ITextEditorConfigurationUpdate, TextEditorRevealType, IApplyEditsOptions, IUndoStopOptions } from 'vs/workbench/api/node/extHost.protocol'; +import { ITextEditorConfigurationUpdate, TextEditorRevealType, IApplyEditsOptions, IUndoStopOptions, WorkspaceEditDto, reviveWorkspaceEditDto } from 'vs/workbench/api/node/extHost.protocol'; import { MainThreadDocumentsAndEditors } from './mainThreadDocumentsAndEditors'; import { equals as objectEquals } from 'vs/base/common/objects'; -import { ExtHostContext, MainThreadEditorsShape, ExtHostEditorsShape, ITextDocumentShowOptions, ITextEditorPositionData, IExtHostContext, IWorkspaceResourceEdit } from '../node/extHost.protocol'; +import { ExtHostContext, MainThreadEditorsShape, ExtHostEditorsShape, ITextDocumentShowOptions, ITextEditorPositionData, IExtHostContext } from '../node/extHost.protocol'; import { IRange } from 'vs/editor/common/core/range'; import { ISelection } from 'vs/editor/common/core/selection'; import { ITextModelService } from 'vs/editor/common/services/resolverService'; import { IFileService } from 'vs/platform/files/common/files'; -import { bulkEdit, IResourceEdit } from 'vs/editor/browser/services/bulkEdit'; +import { BulkEdit } from 'vs/editor/browser/services/bulkEdit'; import { IModelService } from 'vs/editor/common/services/modelService'; import { isCodeEditor, ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { isResourceFileEdit } from 'vs/editor/common/modes'; export class MainThreadEditors implements MainThreadEditorsShape { @@ -210,40 +211,22 @@ export class MainThreadEditors implements MainThreadEditorsShape { return TPromise.as(this._documentsAndEditors.getEditor(id).applyEdits(modelVersionId, edits, opts)); } - $tryApplyWorkspaceEdit(workspaceResourceEdits: IWorkspaceResourceEdit[]): TPromise { + $tryApplyWorkspaceEdit(dto: WorkspaceEditDto): TPromise { + + const { edits } = reviveWorkspaceEditDto(dto); // First check if loaded models were not changed in the meantime - for (let i = 0, len = workspaceResourceEdits.length; i < len; i++) { - const workspaceResourceEdit = workspaceResourceEdits[i]; - if (workspaceResourceEdit.modelVersionId) { - const uri = URI.revive(workspaceResourceEdit.resource); - let model = this._modelService.getModel(uri); - if (model && model.getVersionId() !== workspaceResourceEdit.modelVersionId) { + for (let i = 0, len = edits.length; i < len; i++) { + const edit = edits[i]; + if (!isResourceFileEdit(edit) && edit.modelVersionId) { + let model = this._modelService.getModel(edit.resource); + if (model && model.getVersionId() !== edit.modelVersionId) { // model changed in the meantime return TPromise.as(false); } } } - // Convert to shape expected by bulkEdit below - let resourceEdits: IResourceEdit[] = []; - for (let i = 0, len = workspaceResourceEdits.length; i < len; i++) { - const workspaceResourceEdit = workspaceResourceEdits[i]; - const uri = URI.revive(workspaceResourceEdit.resource); - const edits = workspaceResourceEdit.edits; - - for (let j = 0, lenJ = edits.length; j < lenJ; j++) { - const edit = edits[j]; - - resourceEdits.push({ - resource: uri, - newText: edit.newText, - newEol: edit.newEol, - range: edit.range - }); - } - } - let codeEditor: ICodeEditor; let editor = this._workbenchEditorService.getActiveEditor(); if (editor) { @@ -253,8 +236,7 @@ export class MainThreadEditors implements MainThreadEditorsShape { } } - return bulkEdit(this._textModelResolverService, codeEditor, resourceEdits, this._fileService) - .then(() => true); + return BulkEdit.perform(edits, this._textModelResolverService, this._fileService, codeEditor).then(() => true); } $tryInsertSnippet(id: string, template: string, ranges: IRange[], opts: IUndoStopOptions): TPromise { diff --git a/src/vs/workbench/api/electron-browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/electron-browser/mainThreadLanguageFeatures.ts index 1b6408a0ec8e7628a7a7c54401f488ef6745f0bd..1d658c9e33aa4f59e6d9bf70081e48a9a2c4e61b 100644 --- a/src/vs/workbench/api/electron-browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/electron-browser/mainThreadLanguageFeatures.ts @@ -15,7 +15,7 @@ import { wireCancellationToken } from 'vs/base/common/async'; import { CancellationToken } from 'vs/base/common/cancellation'; import { Position as EditorPosition } from 'vs/editor/common/core/position'; import { Range as EditorRange } from 'vs/editor/common/core/range'; -import { ExtHostContext, MainThreadLanguageFeaturesShape, ExtHostLanguageFeaturesShape, MainContext, IExtHostContext, ISerializedLanguageConfiguration, ISerializedRegExp, ISerializedIndentationRule, ISerializedOnEnterRule, LocationDto, SymbolInformationDto, WorkspaceEditDto, ResourceEditDto, CodeActionDto } from '../node/extHost.protocol'; +import { ExtHostContext, MainThreadLanguageFeaturesShape, ExtHostLanguageFeaturesShape, MainContext, IExtHostContext, ISerializedLanguageConfiguration, ISerializedRegExp, ISerializedIndentationRule, ISerializedOnEnterRule, LocationDto, SymbolInformationDto, CodeActionDto, reviveWorkspaceEditDto } from '../node/extHost.protocol'; import { LanguageConfigurationRegistry } from 'vs/editor/common/modes/languageConfigurationRegistry'; import { LanguageConfiguration, IndentationRule, OnEnterRule } from 'vs/editor/common/modes/languageConfiguration'; import { IHeapService } from './mainThreadHeapService'; @@ -86,21 +86,9 @@ export class MainThreadLanguageFeatures implements MainThreadLanguageFeaturesSha } } - private static _reviveResourceEditDto(data: ResourceEditDto): modes.IResourceEdit { - data.resource = URI.revive(data.resource); - return data; - } - - private static _reviveWorkspaceEditDto(data: WorkspaceEditDto): modes.WorkspaceEdit { - if (data && data.edits) { - data.edits.forEach(MainThreadLanguageFeatures._reviveResourceEditDto); - } - return data; - } - private static _reviveCodeActionDto(data: CodeActionDto[]): modes.CodeAction[] { if (data) { - data.forEach(code => MainThreadLanguageFeatures._reviveWorkspaceEditDto(code.edit)); + data.forEach(code => reviveWorkspaceEditDto(code.edit)); } return data; } @@ -266,7 +254,7 @@ export class MainThreadLanguageFeatures implements MainThreadLanguageFeaturesSha $registerRenameSupport(handle: number, selector: vscode.DocumentSelector): void { this._registrations[handle] = modes.RenameProviderRegistry.register(toLanguageSelector(selector), { provideRenameEdits: (model: ITextModel, position: EditorPosition, newName: string, token: CancellationToken): Thenable => { - return wireCancellationToken(token, this._proxy.$provideRenameEdits(handle, model.uri, position, newName)).then(MainThreadLanguageFeatures._reviveWorkspaceEditDto); + return wireCancellationToken(token, this._proxy.$provideRenameEdits(handle, model.uri, position, newName)).then(reviveWorkspaceEditDto); } }); } diff --git a/src/vs/workbench/api/node/extHost.protocol.ts b/src/vs/workbench/api/node/extHost.protocol.ts index 9df097ce6efc0b4e4f1f73ea9d12866de478be0e..332a2ee89408777fd9c122850e6ce3e1cd6b1ec5 100644 --- a/src/vs/workbench/api/node/extHost.protocol.ts +++ b/src/vs/workbench/api/node/extHost.protocol.ts @@ -14,7 +14,7 @@ import { import * as vscode from 'vscode'; -import { UriComponents } from 'vs/base/common/uri'; +import URI, { UriComponents } from 'vs/base/common/uri'; import Severity from 'vs/base/common/severity'; import { TPromise } from 'vs/base/common/winjs.base'; @@ -52,7 +52,7 @@ import { IStat, FileChangeType } from 'vs/platform/files/common/files'; import { ConfigurationScope } from 'vs/platform/configuration/common/configurationRegistry'; import { ParsedArgs } from 'vs/platform/environment/common/environment'; import { CommentRule, CharacterPair, EnterAction } from 'vs/editor/common/modes/languageConfiguration'; -import { EndOfLineSequence, ISingleEditOperation } from 'vs/editor/common/model'; +import { ISingleEditOperation } from 'vs/editor/common/model'; import { ILineMatch, IPatternInfo } from 'vs/platform/search/common/search'; import { LogLevel } from 'vs/platform/log/common/log'; @@ -194,8 +194,6 @@ export interface IApplyEditsOptions extends IUndoStopOptions { setEndOfLine: EndOfLine; } - - export interface ITextDocumentShowOptions { position?: EditorPosition; preserveFocus?: boolean; @@ -203,16 +201,6 @@ export interface ITextDocumentShowOptions { selection?: IRange; } -export interface IWorkspaceResourceEdit { - resource: UriComponents; - modelVersionId?: number; - edits: { - range?: IRange; - newText: string; - newEol?: EndOfLineSequence; - }[]; -} - export interface MainThreadEditorsShape extends IDisposable { $tryShowTextDocument(resource: UriComponents, options: ITextDocumentShowOptions): TPromise; $registerTextEditorDecorationType(key: string, options: editorCommon.IDecorationRenderOptions): void; @@ -225,7 +213,7 @@ export interface MainThreadEditorsShape extends IDisposable { $tryRevealRange(id: string, range: IRange, revealType: TextEditorRevealType): TPromise; $trySetSelections(id: string, selections: ISelection[]): TPromise; $tryApplyEdits(id: string, modelVersionId: number, edits: ISingleEditOperation[], opts: IApplyEditsOptions): TPromise; - $tryApplyWorkspaceEdit(workspaceResourceEdits: IWorkspaceResourceEdit[]): TPromise; + $tryApplyWorkspaceEdit(workspaceEditDto: WorkspaceEditDto): TPromise; $tryInsertSnippet(id: string, template: string, selections: IRange[], opts: IUndoStopOptions): TPromise; $getDiffInformation(id: string): TPromise; } @@ -625,23 +613,44 @@ export interface WorkspaceSymbolsDto extends IdObject { symbols: SymbolInformationDto[]; } -export interface ResourceEditDto { +export interface ResourceFileEditDto { + oldUri: UriComponents; + newUri: UriComponents; +} + +export interface ResourceTextEditDto { resource: UriComponents; - range: IRange; - newText: string; + modelVersionId?: number; + edits: modes.TextEdit[]; } export interface WorkspaceEditDto { - edits: ResourceEditDto[]; + edits: (ResourceFileEditDto | ResourceTextEditDto)[]; + + // todo@joh reject should go into rename rejectReason?: string; } +export function reviveWorkspaceEditDto(data: WorkspaceEditDto): modes.WorkspaceEdit { + if (data && data.edits) { + for (const edit of data.edits) { + if (typeof (edit).resource === 'object') { + (edit).resource = URI.revive((edit).resource); + } else { + (edit).newUri = URI.revive((edit).newUri); + (edit).oldUri = URI.revive((edit).oldUri); + } + } + } + return data; +} + export interface CodeActionDto { title: string; edit?: WorkspaceEditDto; diagnostics?: IMarkerData[]; command?: modes.Command; - scope?: string; + kind?: string; } export interface ExtHostLanguageFeaturesShape { diff --git a/src/vs/workbench/api/node/extHostApiCommands.ts b/src/vs/workbench/api/node/extHostApiCommands.ts index f95fca70c4208d8e364018c8d6aee7bd4d384c77..17a987c549fd90bf584e735ef3fa5d6f400c8531 100644 --- a/src/vs/workbench/api/node/extHostApiCommands.ts +++ b/src/vs/workbench/api/node/extHostApiCommands.ts @@ -353,11 +353,7 @@ export class ExtHostApiCommands { if (value.rejectReason) { return TPromise.wrapError(new Error(value.rejectReason)); } - let workspaceEdit = new types.WorkspaceEdit(); - for (let edit of value.edits) { - workspaceEdit.replace(edit.resource, typeConverters.toRange(edit.range), edit.newText); - } - return workspaceEdit; + return typeConverters.WorkspaceEdit.to(value); }); } diff --git a/src/vs/workbench/api/node/extHostDocumentSaveParticipant.ts b/src/vs/workbench/api/node/extHostDocumentSaveParticipant.ts index 3733a20adbc49e9da144d928817be219ef62cd09..d6edc95eb207ed6682c23b380b87560689f19d95 100644 --- a/src/vs/workbench/api/node/extHostDocumentSaveParticipant.ts +++ b/src/vs/workbench/api/node/extHostDocumentSaveParticipant.ts @@ -8,7 +8,7 @@ import Event from 'vs/base/common/event'; import URI, { UriComponents } from 'vs/base/common/uri'; import { sequence, always } from 'vs/base/common/async'; import { illegalState } from 'vs/base/common/errors'; -import { ExtHostDocumentSaveParticipantShape, MainThreadEditorsShape, IWorkspaceResourceEdit } from 'vs/workbench/api/node/extHost.protocol'; +import { ExtHostDocumentSaveParticipantShape, MainThreadEditorsShape, ResourceTextEditDto } from 'vs/workbench/api/node/extHost.protocol'; import { TextEdit } from 'vs/workbench/api/node/extHostTypes'; import { fromRange, TextDocumentSaveReason, EndOfLine } from 'vs/workbench/api/node/extHostTypeConverters'; import { ExtHostDocuments } from 'vs/workbench/api/node/extHostDocuments'; @@ -142,7 +142,7 @@ export class ExtHostDocumentSaveParticipant implements ExtHostDocumentSavePartic }).then(values => { - let workspaceResourceEdit: IWorkspaceResourceEdit = { + const resourceEdit: ResourceTextEditDto = { resource: document.uri, edits: [] }; @@ -150,10 +150,10 @@ export class ExtHostDocumentSaveParticipant implements ExtHostDocumentSavePartic for (const value of values) { if (Array.isArray(value) && (value).every(e => e instanceof TextEdit)) { for (const { newText, newEol, range } of value) { - workspaceResourceEdit.edits.push({ + resourceEdit.edits.push({ range: range && fromRange(range), - newText, - newEol: EndOfLine.from(newEol) + text: newText, + eol: EndOfLine.from(newEol) }); } } @@ -161,12 +161,12 @@ export class ExtHostDocumentSaveParticipant implements ExtHostDocumentSavePartic // apply edits if any and if document // didn't change somehow in the meantime - if (workspaceResourceEdit.edits.length === 0) { + if (resourceEdit.edits.length === 0) { return undefined; } if (version === document.version) { - return this._mainThreadEditors.$tryApplyWorkspaceEdit([workspaceResourceEdit]); + return this._mainThreadEditors.$tryApplyWorkspaceEdit({ edits: [resourceEdit] }); } // TODO@joh bubble this to listener? diff --git a/src/vs/workbench/api/node/extHostLanguageFeatures.ts b/src/vs/workbench/api/node/extHostLanguageFeatures.ts index 9ebfc2176560ea268de848fdc8c71d11eb5abe53..164f55f6ad1fd15a78d02a41ce0b2768c7de28d3 100644 --- a/src/vs/workbench/api/node/extHostLanguageFeatures.ts +++ b/src/vs/workbench/api/node/extHostLanguageFeatures.ts @@ -17,7 +17,7 @@ import { ExtHostDocuments } from 'vs/workbench/api/node/extHostDocuments'; import { ExtHostCommands, CommandsConverter } from 'vs/workbench/api/node/extHostCommands'; import { ExtHostDiagnostics, DiagnosticCollection } from 'vs/workbench/api/node/extHostDiagnostics'; import { asWinJsPromise } from 'vs/base/common/async'; -import { MainContext, MainThreadLanguageFeaturesShape, ExtHostLanguageFeaturesShape, ObjectIdentifier, IRawColorInfo, IMainContext, IdObject, ISerializedRegExp, ISerializedIndentationRule, ISerializedOnEnterRule, ISerializedLanguageConfiguration, SymbolInformationDto, SuggestResultDto, WorkspaceSymbolsDto, SuggestionDto } from './extHost.protocol'; +import { MainContext, MainThreadLanguageFeaturesShape, ExtHostLanguageFeaturesShape, ObjectIdentifier, IRawColorInfo, IMainContext, IdObject, ISerializedRegExp, ISerializedIndentationRule, ISerializedOnEnterRule, ISerializedLanguageConfiguration, SymbolInformationDto, SuggestResultDto, WorkspaceSymbolsDto, SuggestionDto, CodeActionDto } from './extHost.protocol'; import { regExpLeadsToEndlessLoop } from 'vs/base/common/strings'; import { IPosition } from 'vs/editor/common/core/position'; import { IRange } from 'vs/editor/common/core/range'; @@ -255,7 +255,7 @@ class ReferenceAdapter { } } -export interface CustomCodeAction extends modes.CodeAction { +export interface CustomCodeAction extends CodeActionDto { _isSynthetic?: boolean; } @@ -273,7 +273,8 @@ class CodeActionAdapter { this._provider = provider; } - provideCodeActions(resource: URI, range: IRange, context: modes.CodeActionContext): TPromise { + + provideCodeActions(resource: URI, range: IRange, context: modes.CodeActionContext): TPromise { const doc = this._documents.getDocumentData(resource).document; const ran = TypeConverters.toRange(range); @@ -948,7 +949,8 @@ export class ExtHostLanguageFeatures implements ExtHostLanguageFeaturesShape { return this._createDisposable(handle); } - $provideCodeActions(handle: number, resource: UriComponents, range: IRange, context: modes.CodeActionContext): TPromise { + + $provideCodeActions(handle: number, resource: UriComponents, range: IRange, context: modes.CodeActionContext): TPromise { return this._withAdapter(handle, CodeActionAdapter, adapter => adapter.provideCodeActions(URI.revive(resource), range, context)); } diff --git a/src/vs/workbench/api/node/extHostTextEditors.ts b/src/vs/workbench/api/node/extHostTextEditors.ts index 35ad03c3a49417c2bc31c176aeeb2a0c47315dc0..ea973d68b2262be6b468f4a143fad00177680cde 100644 --- a/src/vs/workbench/api/node/extHostTextEditors.ts +++ b/src/vs/workbench/api/node/extHostTextEditors.ts @@ -12,7 +12,7 @@ import * as TypeConverters from './extHostTypeConverters'; import { TextEditorDecorationType, ExtHostTextEditor } from './extHostTextEditor'; import { ExtHostDocumentsAndEditors } from './extHostDocumentsAndEditors'; import { Position as EditorPosition } from 'vs/platform/editor/common/editor'; -import { MainContext, MainThreadEditorsShape, ExtHostEditorsShape, ITextDocumentShowOptions, ITextEditorPositionData, IResolvedTextEditorConfiguration, ISelectionChangeEvent, IMainContext, IWorkspaceResourceEdit } from './extHost.protocol'; +import { MainContext, MainThreadEditorsShape, ExtHostEditorsShape, ITextDocumentShowOptions, ITextEditorPositionData, IResolvedTextEditorConfiguration, ISelectionChangeEvent, IMainContext, WorkspaceEditDto } from './extHost.protocol'; import * as vscode from 'vscode'; export class ExtHostEditors implements ExtHostEditorsShape { @@ -92,36 +92,23 @@ export class ExtHostEditors implements ExtHostEditorsShape { applyWorkspaceEdit(edit: vscode.WorkspaceEdit): TPromise { - let workspaceResourceEdits: IWorkspaceResourceEdit[] = []; + const dto: WorkspaceEditDto = { edits: [] }; - let entries = edit.entries(); - for (let entry of entries) { - let [uri, edits] = entry; - - let doc = this._extHostDocumentsAndEditors.getDocument(uri.toString()); - let docVersion: number = undefined; - if (doc) { - docVersion = doc.version; - } - - let workspaceResourceEdit: IWorkspaceResourceEdit = { - resource: uri, - modelVersionId: docVersion, - edits: [] - }; - - for (let edit of edits) { - workspaceResourceEdit.edits.push({ - newText: edit.newText, - newEol: TypeConverters.EndOfLine.from(edit.newEol), - range: edit.range && TypeConverters.fromRange(edit.range) + for (let entry of edit.allEntries()) { + let [uri, uriOrEdits] = entry; + if (Array.isArray(uriOrEdits)) { + let doc = this._extHostDocumentsAndEditors.getDocument(uri.toString()); + dto.edits.push({ + resource: uri, + modelVersionId: doc && doc.version, + edits: uriOrEdits.map(TypeConverters.TextEdit.from) }); + } else { + dto.edits.push({ oldUri: uri, newUri: uriOrEdits }); } - - workspaceResourceEdits.push(workspaceResourceEdit); } - return this._proxy.$tryApplyWorkspaceEdit(workspaceResourceEdits); + return this._proxy.$tryApplyWorkspaceEdit(dto); } // --- called from main thread diff --git a/src/vs/workbench/api/node/extHostTypeConverters.ts b/src/vs/workbench/api/node/extHostTypeConverters.ts index a25d0430ff9b9effd13322192c95c09a15501a71..38ce5b918897175d83a76e0b145f0eda497cf549 100644 --- a/src/vs/workbench/api/node/extHostTypeConverters.ts +++ b/src/vs/workbench/api/node/extHostTypeConverters.ts @@ -20,6 +20,7 @@ import { ISelection } from 'vs/editor/common/core/selection'; import * as htmlContent from 'vs/base/common/htmlContent'; import { IRelativePattern } from 'vs/base/common/glob'; import { LanguageSelector, LanguageFilter } from 'vs/editor/common/modes/languageSelector'; +import { WorkspaceEditDto, ResourceTextEditDto, ResourceFileEditDto } from 'vs/workbench/api/node/extHost.protocol'; export interface PositionLike { line: number; @@ -228,24 +229,36 @@ export const TextEdit = { export namespace WorkspaceEdit { export function from(value: vscode.WorkspaceEdit): modes.WorkspaceEdit { - const result: modes.WorkspaceEdit = { edits: [] }; - for (let entry of value.entries()) { - let [uri, textEdits] = entry; - for (let textEdit of textEdits) { - result.edits.push({ - resource: uri, - newText: textEdit.newText, - range: fromRange(textEdit.range) - }); + const result: modes.WorkspaceEdit = { + edits: [] + }; + for (const entry of value.allEntries()) { + const [uri, uriOrEdits] = entry; + if (Array.isArray(uriOrEdits)) { + // text edits + result.edits.push({ resource: uri, edits: uriOrEdits.map(TextEdit.from) }); + } else { + // resource edits + result.edits.push({ oldUri: uri, newUri: uriOrEdits }); } } return result; } - export function to(value: modes.WorkspaceEdit) { + export function to(value: WorkspaceEditDto) { const result = new types.WorkspaceEdit(); for (const edit of value.edits) { - result.replace(edit.resource, toRange(edit.range), edit.newText); + if (Array.isArray((edit).edits)) { + result.set( + URI.revive((edit).resource), + (edit).edits.map(TextEdit.to) + ); + } else { + result.renameResource( + URI.revive((edit).oldUri), + URI.revive((edit).newUri) + ); + } } return result; } diff --git a/src/vs/workbench/api/node/extHostTypes.ts b/src/vs/workbench/api/node/extHostTypes.ts index 761ffee760804c350475989818057ff4b04cd7c6..af2825a3eb8afdfd67a3d0b97a370b934757035e 100644 --- a/src/vs/workbench/api/node/extHostTypes.ts +++ b/src/vs/workbench/api/node/extHostTypes.ts @@ -492,10 +492,28 @@ export class TextEdit { } } -export class WorkspaceEdit { +export class WorkspaceEdit implements vscode.WorkspaceEdit { - private _values: [URI, TextEdit[]][] = []; - private _index = new Map(); + private _seqPool: number = 0; + + private _resourceEdits: { seq: number, from: URI, to: URI }[] = []; + private _textEdits = new Map(); + + createResource(uri: vscode.Uri): void { + this.renameResource(undefined, uri); + } + + deleteResource(uri: vscode.Uri): void { + this.renameResource(uri, undefined); + } + + renameResource(from: vscode.Uri, to: vscode.Uri): void { + this._resourceEdits.push({ seq: this._seqPool++, from, to }); + } + + resourceEdits(): [vscode.Uri, vscode.Uri][] { + return this._resourceEdits.map(({ from, to }) => (<[vscode.Uri, vscode.Uri]>[from, to])); + } replace(uri: URI, range: Range, newText: string): void { let edit = new TextEdit(range, newText); @@ -503,8 +521,9 @@ export class WorkspaceEdit { if (array) { array.push(edit); } else { - this.set(uri, [edit]); + array = [edit]; } + this.set(uri, array); } insert(resource: URI, position: Position, newText: string): void { @@ -516,34 +535,58 @@ export class WorkspaceEdit { } has(uri: URI): boolean { - return this._index.has(uri.toString()); + return this._textEdits.has(uri.toString()); } set(uri: URI, edits: TextEdit[]): void { - const idx = this._index.get(uri.toString()); - if (typeof idx === 'undefined') { - let newLen = this._values.push([uri, edits]); - this._index.set(uri.toString(), newLen - 1); + let data = this._textEdits.get(uri.toString()); + if (!data) { + data = { seq: this._seqPool++, uri, edits: [] }; + this._textEdits.set(uri.toString(), data); + } + if (!edits) { + data.edits = undefined; } else { - this._values[idx][1] = edits; + data.edits = edits.slice(0); } } get(uri: URI): TextEdit[] { - let idx = this._index.get(uri.toString()); - return typeof idx !== 'undefined' && this._values[idx][1]; + if (!this._textEdits.has(uri.toString())) { + return undefined; + } + const { edits } = this._textEdits.get(uri.toString()); + return edits ? edits.slice() : undefined; } entries(): [URI, TextEdit[]][] { - return this._values; + const res: [URI, TextEdit[]][] = []; + this._textEdits.forEach(value => res.push([value.uri, value.edits])); + return res.slice(); + } + + allEntries(): ([URI, TextEdit[]] | [URI, URI])[] { + // use the 'seq' the we have assigned when inserting + // the operation and use that order in the resulting + // array + const res: ([URI, TextEdit[]] | [URI, URI])[] = []; + this._textEdits.forEach(value => { + const { seq, uri, edits } = value; + res[seq] = [uri, edits]; + }); + this._resourceEdits.forEach(value => { + const { seq, from, to } = value; + res[seq] = [from, to]; + }); + return res; } get size(): number { - return this._values.length; + return this._textEdits.size + this._resourceEdits.length; } toJSON(): any { - return this._values; + return this.entries(); } } diff --git a/src/vs/workbench/parts/search/browser/replaceService.ts b/src/vs/workbench/parts/search/browser/replaceService.ts index fca4c6b1a3678521a122f6c3c0ed07167bbcd242..5abd6630a1c1e3abfda107137d580c34b083c327 100644 --- a/src/vs/workbench/parts/search/browser/replaceService.ts +++ b/src/vs/workbench/parts/search/browser/replaceService.ts @@ -15,7 +15,7 @@ import { IWorkbenchEditorService } from 'vs/workbench/services/editor/common/edi import { IModelService } from 'vs/editor/common/services/modelService'; import { IModeService } from 'vs/editor/common/services/modeService'; import { Match, FileMatch, FileMatchOrMatch, ISearchWorkbenchService } from 'vs/workbench/parts/search/common/searchModel'; -import { BulkEdit, IResourceEdit, createBulkEdit } from 'vs/editor/browser/services/bulkEdit'; +import { BulkEdit } from 'vs/editor/browser/services/bulkEdit'; import { IProgressRunner } from 'vs/platform/progress/common/progress'; import { IDiffEditor } from 'vs/editor/browser/editorBrowser'; import { ITextModelService, ITextModelContentProvider } from 'vs/editor/common/services/resolverService'; @@ -24,6 +24,7 @@ import { ScrollType } from 'vs/editor/common/editorCommon'; import { ITextModel } from 'vs/editor/common/model'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IFileService } from 'vs/platform/files/common/files'; +import { ResourceTextEdit } from 'vs/editor/common/modes'; import { createTextBufferFactoryFromSnapshot } from 'vs/editor/common/model/textModel'; const REPLACE_PREVIEW = 'replacePreview'; @@ -104,8 +105,7 @@ export class ReplaceService implements IReplaceService { public replace(match: FileMatchOrMatch, progress?: IProgressRunner, resource?: URI): TPromise; public replace(arg: any, progress: IProgressRunner = null, resource: URI = null): TPromise { - let bulkEdit: BulkEdit = createBulkEdit(this.textModelResolverService, null, this.fileService); - bulkEdit.progress(progress); + let bulkEdit = new BulkEdit(null, progress, this.textModelResolverService, this.fileService); if (arg instanceof Match) { let match = arg; @@ -127,7 +127,7 @@ export class ReplaceService implements IReplaceService { }); } - return bulkEdit.finish(); + return bulkEdit.perform(); } public openReplacePreview(element: FileMatchOrMatch, preserveFocus?: boolean, sideBySide?: boolean, pinned?: boolean): TPromise { @@ -175,12 +175,14 @@ export class ReplaceService implements IReplaceService { }); } - private createEdit(match: Match, text: string, resource: URI = null): IResourceEdit { + private createEdit(match: Match, text: string, resource: URI = null): ResourceTextEdit { let fileMatch: FileMatch = match.parent(); - let resourceEdit: IResourceEdit = { + let resourceEdit: ResourceTextEdit = { resource: resource !== null ? resource : fileMatch.resource(), - range: match.range(), - newText: text + edits: [{ + range: match.range(), + text: text + }] }; return resourceEdit; } diff --git a/src/vs/workbench/test/electron-browser/api/extHostDocumentSaveParticipant.test.ts b/src/vs/workbench/test/electron-browser/api/extHostDocumentSaveParticipant.test.ts index a4c0366b6e4a1d9466fbd69235d999155108942d..8ec74bfb0735e76e04ec4fee1e8c0b22bd62a03d 100644 --- a/src/vs/workbench/test/electron-browser/api/extHostDocumentSaveParticipant.test.ts +++ b/src/vs/workbench/test/electron-browser/api/extHostDocumentSaveParticipant.test.ts @@ -10,7 +10,7 @@ import { TPromise } from 'vs/base/common/winjs.base'; import { ExtHostDocuments } from 'vs/workbench/api/node/extHostDocuments'; import { ExtHostDocumentsAndEditors } from 'vs/workbench/api/node/extHostDocumentsAndEditors'; import { TextDocumentSaveReason, TextEdit, Position, EndOfLine } from 'vs/workbench/api/node/extHostTypes'; -import { MainThreadEditorsShape, IWorkspaceResourceEdit } from 'vs/workbench/api/node/extHost.protocol'; +import { MainThreadEditorsShape, WorkspaceEditDto } from 'vs/workbench/api/node/extHost.protocol'; import { ExtHostDocumentSaveParticipant } from 'vs/workbench/api/node/extHostDocumentSaveParticipant'; import { SingleProxyRPCProtocol } from './testRPCProtocol'; import { SaveReason } from 'vs/workbench/services/textfile/common/textfiles'; @@ -18,6 +18,7 @@ import * as vscode from 'vscode'; import { mock } from 'vs/workbench/test/electron-browser/api/mock'; import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'; import { NullLogService } from 'vs/platform/log/common/log'; +import { isResourceTextEdit, ResourceTextEdit } from 'vs/editor/common/modes'; suite('ExtHostDocumentSaveParticipant', () => { @@ -262,10 +263,10 @@ suite('ExtHostDocumentSaveParticipant', () => { test('event delivery, pushEdits sync', () => { - let edits: IWorkspaceResourceEdit[]; + let dto: WorkspaceEditDto; const participant = new ExtHostDocumentSaveParticipant(nullLogService, documents, new class extends mock() { - $tryApplyWorkspaceEdit(_edits: IWorkspaceResourceEdit[]) { - edits = _edits; + $tryApplyWorkspaceEdit(_edits: WorkspaceEditDto) { + dto = _edits; return TPromise.as(true); } }); @@ -278,16 +279,17 @@ suite('ExtHostDocumentSaveParticipant', () => { return participant.$participateInSave(resource, SaveReason.EXPLICIT).then(() => { sub.dispose(); - assert.equal(edits.length, 1); - assert.equal(edits[0].edits.length, 2); + assert.equal(dto.edits.length, 1); + assert.ok(isResourceTextEdit(dto.edits[0])); + assert.equal((dto.edits[0]).edits.length, 2); }); }); test('event delivery, concurrent change', () => { - let edits: IWorkspaceResourceEdit[]; + let edits: WorkspaceEditDto; const participant = new ExtHostDocumentSaveParticipant(nullLogService, documents, new class extends mock() { - $tryApplyWorkspaceEdit(_edits: IWorkspaceResourceEdit[]) { + $tryApplyWorkspaceEdit(_edits: WorkspaceEditDto) { edits = _edits; return TPromise.as(true); } @@ -321,16 +323,20 @@ suite('ExtHostDocumentSaveParticipant', () => { test('event delivery, two listeners -> two document states', () => { const participant = new ExtHostDocumentSaveParticipant(nullLogService, documents, new class extends mock() { - $tryApplyWorkspaceEdit(_edits: IWorkspaceResourceEdit[]) { + $tryApplyWorkspaceEdit(dto: WorkspaceEditDto) { - for (const { resource, edits } of _edits) { + for (const edit of dto.edits) { + if (!isResourceTextEdit(edit)) { + continue; + } + const { resource, edits } = edit; const uri = URI.revive(resource); - for (const { newText, range } of edits) { + for (const { text, range } of edits) { documents.$acceptModelChanged(uri.toString(), { changes: [{ range, + text, rangeLength: undefined, - text: newText }], eol: undefined, versionId: documents.getDocumentData(uri).version + 1 diff --git a/src/vs/workbench/test/electron-browser/api/extHostLanguageFeatures.test.ts b/src/vs/workbench/test/electron-browser/api/extHostLanguageFeatures.test.ts index e86703048ecc182ccab5247653f23affaddaf185..ad73ec6a899c0be763b90cd66a05f8e1fdfe7e73 100644 --- a/src/vs/workbench/test/electron-browser/api/extHostLanguageFeatures.test.ts +++ b/src/vs/workbench/test/electron-browser/api/extHostLanguageFeatures.test.ts @@ -24,7 +24,7 @@ import { IHeapService } from 'vs/workbench/api/electron-browser/mainThreadHeapSe import { ExtHostDocuments } from 'vs/workbench/api/node/extHostDocuments'; import { ExtHostDocumentsAndEditors } from 'vs/workbench/api/node/extHostDocumentsAndEditors'; import { getDocumentSymbols } from 'vs/editor/contrib/quickOpen/quickOpen'; -import { DocumentSymbolProviderRegistry, DocumentHighlightKind, Hover } from 'vs/editor/common/modes'; +import { DocumentSymbolProviderRegistry, DocumentHighlightKind, Hover, ResourceTextEdit } from 'vs/editor/common/modes'; import { getCodeLensData } from 'vs/editor/contrib/codelens/codelens'; import { getDefinitionsAtPosition, getImplementationsAtPosition, getTypeDefinitionsAtPosition } from 'vs/editor/contrib/goToDeclaration/goToDeclaration'; import { getHover } from 'vs/editor/contrib/hover/getHover'; @@ -840,7 +840,8 @@ suite('ExtHostLanguageFeatures', function () { return rpcProtocol.sync().then(() => { return rename(model, new EditorPosition(1, 1), 'newName').then(value => { - assert.equal(value.edits.length, 2); // least relevant renamer + assert.equal(value.edits.length, 1); // least relevant renamer + assert.equal((value.edits)[0].edits.length, 2); // least relevant renamer }); }); }); diff --git a/src/vs/workbench/test/electron-browser/api/extHostTextEditors.test.ts b/src/vs/workbench/test/electron-browser/api/extHostTextEditors.test.ts index b5ec3f552749eee8c2f86d81ab4b02339c6a2363..41663d277ef776f616e07029f39683bb24d76c2e 100644 --- a/src/vs/workbench/test/electron-browser/api/extHostTextEditors.test.ts +++ b/src/vs/workbench/test/electron-browser/api/extHostTextEditors.test.ts @@ -7,25 +7,26 @@ import * as assert from 'assert'; import { TPromise } from 'vs/base/common/winjs.base'; import * as extHostTypes from 'vs/workbench/api/node/extHostTypes'; -import { MainContext, MainThreadEditorsShape, IWorkspaceResourceEdit } from 'vs/workbench/api/node/extHost.protocol'; +import { MainContext, MainThreadEditorsShape, WorkspaceEditDto } from 'vs/workbench/api/node/extHost.protocol'; import URI from 'vs/base/common/uri'; import { mock } from 'vs/workbench/test/electron-browser/api/mock'; import { ExtHostDocumentsAndEditors } from 'vs/workbench/api/node/extHostDocumentsAndEditors'; import { SingleProxyRPCProtocol, TestRPCProtocol } from 'vs/workbench/test/electron-browser/api/testRPCProtocol'; import { ExtHostEditors } from 'vs/workbench/api/node/extHostTextEditors'; +import { ResourceTextEdit } from 'vs/editor/common/modes'; suite('ExtHostTextEditors.applyWorkspaceEdit', () => { const resource = URI.parse('foo:bar'); let editors: ExtHostEditors; - let workspaceResourceEdits: IWorkspaceResourceEdit[]; + let workspaceResourceEdits: WorkspaceEditDto; setup(() => { workspaceResourceEdits = null; let rpcProtocol = new TestRPCProtocol(); rpcProtocol.set(MainContext.MainThreadEditors, new class extends mock() { - $tryApplyWorkspaceEdit(_workspaceResourceEdits: IWorkspaceResourceEdit[]): TPromise { + $tryApplyWorkspaceEdit(_workspaceResourceEdits: WorkspaceEditDto): TPromise { workspaceResourceEdits = _workspaceResourceEdits; return TPromise.as(true); } @@ -48,8 +49,8 @@ suite('ExtHostTextEditors.applyWorkspaceEdit', () => { let edit = new extHostTypes.WorkspaceEdit(); edit.replace(resource, new extHostTypes.Range(0, 0, 0, 0), 'hello'); return editors.applyWorkspaceEdit(edit).then((result) => { - assert.equal(workspaceResourceEdits.length, 1); - assert.equal(workspaceResourceEdits[0].modelVersionId, 1337); + assert.equal(workspaceResourceEdits.edits.length, 1); + assert.equal((workspaceResourceEdits.edits[0]).modelVersionId, 1337); }); }); @@ -57,8 +58,8 @@ suite('ExtHostTextEditors.applyWorkspaceEdit', () => { let edit = new extHostTypes.WorkspaceEdit(); edit.replace(URI.parse('foo:bar2'), new extHostTypes.Range(0, 0, 0, 0), 'hello'); return editors.applyWorkspaceEdit(edit).then((result) => { - assert.equal(workspaceResourceEdits.length, 1); - assert.ok(typeof workspaceResourceEdits[0].modelVersionId === 'undefined'); + assert.equal(workspaceResourceEdits.edits.length, 1); + assert.ok(typeof (workspaceResourceEdits.edits[0]).modelVersionId === 'undefined'); }); }); diff --git a/src/vs/workbench/test/electron-browser/api/extHostTypes.test.ts b/src/vs/workbench/test/electron-browser/api/extHostTypes.test.ts index 3cd1208f264efac082102115f6363f3c6df3011e..478b506e60f323fd23d1ff5c00673c59d44bfdde 100644 --- a/src/vs/workbench/test/electron-browser/api/extHostTypes.test.ts +++ b/src/vs/workbench/test/electron-browser/api/extHostTypes.test.ts @@ -362,6 +362,53 @@ suite('ExtHostTypes', function () { }); + // test('WorkspaceEdit should fail when editing deleted resource', () => { + // const resource = URI.parse('file:///a.ts'); + + // const edit = new types.WorkspaceEdit(); + // edit.deleteResource(resource); + // try { + // edit.insert(resource, new types.Position(0, 0), ''); + // assert.fail(false, 'Should disallow edit of deleted resource'); + // } catch { + // // expected + // } + // }); + + test('WorkspaceEdit - keep order of text and file changes', function () { + + const edit = new types.WorkspaceEdit(); + edit.replace(URI.parse('foo:a'), new types.Range(1, 1, 1, 1), 'foo'); + edit.renameResource(URI.parse('foo:a'), URI.parse('foo:b')); + edit.replace(URI.parse('foo:a'), new types.Range(2, 1, 2, 1), 'bar'); + edit.replace(URI.parse('foo:b'), new types.Range(3, 1, 3, 1), 'bazz'); + + const all = edit.allEntries(); + assert.equal(all.length, 3); + + function isFileChange(thing: [URI, types.TextEdit[]] | [URI, URI]): thing is [URI, URI] { + const [f, s] = thing; + return URI.isUri(f) && URI.isUri(s); + } + + function isTextChange(thing: [URI, types.TextEdit[]] | [URI, URI]): thing is [URI, types.TextEdit[]] { + const [f, s] = thing; + return URI.isUri(f) && Array.isArray(s); + } + + const [first, second, third] = all; + assert.equal(first[0].toString(), 'foo:a'); + assert.ok(!isFileChange(first)); + assert.ok(isTextChange(first) && first[1].length === 2); + + assert.equal(second[0].toString(), 'foo:a'); + assert.ok(isFileChange(second)); + + assert.equal(third[0].toString(), 'foo:b'); + assert.ok(!isFileChange(third)); + assert.ok(isTextChange(third) && third[1].length === 1); + }); + test('DocumentLink', function () { assert.throws(() => new types.DocumentLink(null, null)); assert.throws(() => new types.DocumentLink(new types.Range(1, 1, 1, 1), null)); diff --git a/src/vs/workbench/test/electron-browser/api/mainThreadEditors.test.ts b/src/vs/workbench/test/electron-browser/api/mainThreadEditors.test.ts index 73887be8ea36544017f56eda159daa4f34d4065a..03ad3ac792677b65e46e249ecad20f6fe11a4942 100644 --- a/src/vs/workbench/test/electron-browser/api/mainThreadEditors.test.ts +++ b/src/vs/workbench/test/electron-browser/api/mainThreadEditors.test.ts @@ -13,7 +13,7 @@ import { ModelServiceImpl } from 'vs/editor/common/services/modelServiceImpl'; import { TestCodeEditorService } from 'vs/editor/test/browser/testCodeEditorService'; import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; import { IWorkbenchEditorService } from 'vs/workbench/services/editor/common/editorService'; -import { ExtHostDocumentsAndEditorsShape, IWorkspaceResourceEdit, ExtHostContext, ExtHostDocumentsShape } from 'vs/workbench/api/node/extHost.protocol'; +import { ExtHostDocumentsAndEditorsShape, ExtHostContext, ExtHostDocumentsShape } from 'vs/workbench/api/node/extHost.protocol'; import { mock } from 'vs/workbench/test/electron-browser/api/mock'; import { IEditorGroupService } from 'vs/workbench/services/group/common/groupService'; import Event from 'vs/base/common/event'; @@ -23,6 +23,10 @@ import { Range } from 'vs/editor/common/core/range'; import { Position } from 'vs/editor/common/core/position'; import { IModelService } from 'vs/editor/common/services/modelService'; import { EditOperation } from 'vs/editor/common/core/editOperation'; +import { TestFileService } from 'vs/workbench/test/workbenchTestServices'; +import { TPromise } from 'vs/base/common/winjs.base'; +import { IFileStat } from 'vs/platform/files/common/files'; +import { ResourceTextEdit } from 'vs/editor/common/modes'; suite('MainThreadEditors', () => { @@ -31,10 +35,34 @@ suite('MainThreadEditors', () => { let modelService: IModelService; let editors: MainThreadEditors; + const movedResources = new Map(); + const createdResources = new Set(); + const deletedResources = new Set(); + setup(() => { const configService = new TestConfigurationService(); modelService = new ModelServiceImpl(null, configService); const codeEditorService = new TestCodeEditorService(); + + movedResources.clear(); + createdResources.clear(); + deletedResources.clear(); + + const fileService = new class extends TestFileService { + async moveFile(from, target): TPromise { + movedResources.set(from, target); + return createMockFileStat(target); + } + async createFile(uri): TPromise { + createdResources.add(uri); + return createMockFileStat(uri); + } + async del(uri): TPromise { + deletedResources.add(uri); + } + }; + + const textFileService = new class extends mock() { isDirty() { return false; } models = { @@ -69,7 +97,7 @@ suite('MainThreadEditors', () => { workbenchEditorService, codeEditorService, null, - null, + fileService, null, null, editorGroupService, @@ -82,7 +110,7 @@ suite('MainThreadEditors', () => { workbenchEditorService, editorGroupService, null, - null, + fileService, modelService ); }); @@ -91,11 +119,11 @@ suite('MainThreadEditors', () => { let model = modelService.createModel('something', null, resource); - let workspaceResourceEdit: IWorkspaceResourceEdit = { + let workspaceResourceEdit: ResourceTextEdit = { resource: resource, modelVersionId: model.getVersionId(), edits: [{ - newText: 'asdfg', + text: 'asdfg', range: new Range(1, 1, 1, 1) }] }; @@ -103,8 +131,35 @@ suite('MainThreadEditors', () => { // Act as if the user edited the model model.applyEdits([EditOperation.insert(new Position(0, 0), 'something')]); - return editors.$tryApplyWorkspaceEdit([workspaceResourceEdit]).then((result) => { + return editors.$tryApplyWorkspaceEdit({ edits: [workspaceResourceEdit] }).then((result) => { assert.equal(result, false); }); }); + + test(`applyWorkspaceEdit with only resource edit`, () => { + return editors.$tryApplyWorkspaceEdit({ + edits: [ + { oldUri: resource, newUri: resource }, + { oldUri: undefined, newUri: resource }, + { oldUri: resource, newUri: undefined } + ] + }).then((result) => { + assert.equal(result, true); + assert.equal(movedResources.get(resource), resource); + assert.equal(createdResources.has(resource), true); + assert.equal(deletedResources.has(resource), true); + }); + }); }); + + +function createMockFileStat(target: URI): IFileStat { + return { + etag: '', + isDirectory: false, + name: target.path, + mtime: 0, + resource: target + }; +} +