diff --git a/extensions/vscode-api-tests/src/workspace.test.ts b/extensions/vscode-api-tests/src/workspace.test.ts index d4c4ae7013c18..4fccf98385b1d 100644 --- a/extensions/vscode-api-tests/src/workspace.test.ts +++ b/extensions/vscode-api-tests/src/workspace.test.ts @@ -505,4 +505,18 @@ suite('workspace-namespace', () => { return vscode.workspace.applyEdit(edit); }); }); + + + test('applyEdit should fail when editing deleted resource', async () => { + const resource = await createRandomFile(); + + let 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 + } + }); }); diff --git a/src/vs/editor/browser/services/bulkEdit.ts b/src/vs/editor/browser/services/bulkEdit.ts index bfe4d9c4df32d..d5052d8c70624 100644 --- a/src/vs/editor/browser/services/bulkEdit.ts +++ b/src/vs/editor/browser/services/bulkEdit.ts @@ -18,6 +18,13 @@ import { Selection, ISelection } from 'vs/editor/common/core/selection'; import { IIdentifiedSingleEditOperation, ITextModel, EndOfLineSequence } from 'vs/editor/common/model'; import { IProgressRunner } from 'vs/platform/progress/common/progress'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { IResourceRename, IResourceCreate } from 'vs/editor/common/modes'; + +export interface IResourceFileEdit { + readonly renamedResources: { from: URI, to }[]; + readonly createdResources: { uri: URI, contents: string }[]; + readonly deletedResources: URI[]; +} export interface IResourceEdit { resource: URI; @@ -196,12 +203,24 @@ class BulkEditModel implements IDisposable { private _sourceSelections: Selection[]; private _sourceModelTask: SourceModelEditTask; - constructor(textModelResolverService: ITextModelService, sourceModel: URI, sourceSelections: Selection[], edits: IResourceEdit[], private progress: IProgressRunner = null) { + constructor( + textModelResolverService: ITextModelService, + sourceModel: URI, + sourceSelections: Selection[], + edits: IResourceEdit[], + private progress: IProgressRunner, + private renames: IResourceRename[], + private creates: IResourceCreate[], + private deletes: URI[], + private fileService: IFileService + ) { this._textModelResolverService = textModelResolverService; this._sourceModel = sourceModel; this._sourceSelections = sourceSelections; this._sourceModelTask = null; + this._numberOfResourcesToModify += this.renames.length + this.deletes.length + this.creates.length; + for (let edit of edits) { this._addEdit(edit); } @@ -216,7 +235,7 @@ class BulkEditModel implements IDisposable { array.push(edit); } - public prepare(): TPromise<BulkEditModel> { + public async prepare(): TPromise<BulkEditModel> { if (this._tasks) { throw new Error('illegal state - already prepared'); @@ -229,6 +248,15 @@ class BulkEditModel implements IDisposable { this.progress.total(this._numberOfResourcesToModify * 2); } + await TPromise.join(this.renames.map(rename => + this.fileService.moveFile(rename.from, rename.to))); + + await TPromise.join(this.creates.map(create => + this.fileService.createFile(create.uri, create.contents))); + + await TPromise.join(this.deletes.map(uri => + this.fileService.del(uri))); + forEach(this._edits, entry => { const promise = this._textModelResolverService.createModelReference(URI.parse(entry.key)).then(ref => { const model = ref.object; @@ -256,8 +284,9 @@ class BulkEditModel implements IDisposable { promises.push(promise); }); + await TPromise.join(promises); - return TPromise.join(promises).then(_ => this); + return this; } public apply(): Selection { @@ -284,20 +313,29 @@ class BulkEditModel implements IDisposable { export interface BulkEdit { progress(progress: IProgressRunner): void; add(edit: IResourceEdit[]): void; + addRename(edit: IResourceRename[]): void; + addCreate(edit: IResourceCreate[]): void; + addDelete(edit: URI[]): void; finish(): TPromise<ISelection>; ariaMessage(): string; } -export function bulkEdit(textModelResolverService: ITextModelService, editor: ICodeEditor, edits: IResourceEdit[], fileService?: IFileService, progress: IProgressRunner = null): TPromise<any> { +export function bulkEdit(textModelResolverService: ITextModelService, editor: ICodeEditor, edits: IResourceEdit[], fileService: IFileService, resourceFileEdits?: IResourceFileEdit): TPromise<any> { let bulk = createBulkEdit(textModelResolverService, editor, fileService); bulk.add(edits); - bulk.progress(progress); + bulk.addRename(resourceFileEdits.renamedResources); + bulk.addCreate(resourceFileEdits.createdResources); + bulk.addDelete(resourceFileEdits.deletedResources); + bulk.progress(null); return bulk.finish(); } export function createBulkEdit(textModelResolverService: ITextModelService, editor?: ICodeEditor, fileService?: IFileService): BulkEdit { let all: IResourceEdit[] = []; + const renames: IResourceRename[] = []; + const creates: IResourceCreate[] = []; + const deletes: URI[] = []; let recording = new ChangeRecorder(fileService).start(); let progressRunner: IProgressRunner; @@ -309,6 +347,18 @@ export function createBulkEdit(textModelResolverService: ITextModelService, edit all.push(...edits); } + function addRename(edits: IResourceRename[]): void { + renames.push(...edits); + } + + function addCreate(edits: IResourceCreate[]): void { + creates.push(...edits); + } + + function addDelete(edits: URI[]): void { + deletes.push(...edits); + } + function getConcurrentEdits() { let names: string[]; for (let edit of all) { @@ -327,7 +377,7 @@ export function createBulkEdit(textModelResolverService: ITextModelService, edit function finish(): TPromise<ISelection> { - if (all.length === 0) { + if (all.length === 0 && renames.length === 0 && creates.length === 0 && deletes.length === 0) { return TPromise.as(undefined); } @@ -344,9 +394,9 @@ export function createBulkEdit(textModelResolverService: ITextModelService, edit selections = editor.getSelections(); } - const model = new BulkEditModel(textModelResolverService, uri, selections, all, progressRunner); + const model = new BulkEditModel(textModelResolverService, uri, selections, all, progressRunner, renames, creates, deletes, fileService); - return model.prepare().then(_ => { + return model.prepare().then(async _ => { let concurrentEdits = getConcurrentEdits(); if (concurrentEdits) { @@ -355,7 +405,7 @@ export function createBulkEdit(textModelResolverService: ITextModelService, edit recording.stop(); - const result = model.apply(); + const result = await model.apply(); model.dispose(); return result; }); @@ -376,6 +426,9 @@ export function createBulkEdit(textModelResolverService: ITextModelService, edit return { progress, add, + addRename, + addCreate, + addDelete, finish, ariaMessage }; diff --git a/src/vs/editor/common/modes.ts b/src/vs/editor/common/modes.ts index 1693993e6db77..9aa2e6ae1ba9f 100644 --- a/src/vs/editor/common/modes.ts +++ b/src/vs/editor/common/modes.ts @@ -821,8 +821,22 @@ export interface IResourceEdit { range: IRange; newText: string; } + +export interface IResourceRename { + readonly from: URI; + readonly to: URI; +} + +export interface IResourceCreate { + readonly uri: URI; + readonly contents: string; +} + export interface WorkspaceEdit { edits: IResourceEdit[]; + renamedResources?: IResourceRename[]; + createdResources?: IResourceCreate[]; + deletedResources?: URI[]; rejectReason?: string; } export interface RenameProvider { diff --git a/src/vs/editor/contrib/quickFix/quickFixCommands.ts b/src/vs/editor/contrib/quickFix/quickFixCommands.ts index 2d067db0bf0ec..f012b11a4ca8e 100644 --- a/src/vs/editor/contrib/quickFix/quickFixCommands.ts +++ b/src/vs/editor/contrib/quickFix/quickFixCommands.ts @@ -22,9 +22,10 @@ import { LightBulbWidget } from './lightBulbWidget'; import { QuickFixModel, QuickFixComputeEvent } from './quickFixModel'; 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'; +import URI from 'vs/base/common/uri'; export class QuickFixController implements IEditorContribution { @@ -112,9 +113,11 @@ export class QuickFixController implements IEditorContribution { private async _onApplyCodeAction(action: CodeAction): TPromise<void> { if (action.edit) { - const edit = createBulkEdit(this._textModelService, this._editor, this._fileService); - edit.add(action.edit.edits); - await edit.finish(); + await bulkEdit(this._textModelService, this._editor, action.edit.edits, this._fileService, { + createdResources: action.edit.createdResources.map(create => ({ uri: URI.revive(create.uri), contents: create.contents })), + renamedResources: action.edit.renamedResources.map(rename => ({ from: URI.revive(rename.from), to: URI.revive(rename.to) })), + deletedResources: action.edit.deletedResources.map(URI.revive) + }); } if (action.command) { diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 81d3e99523b52..58eb60dd2250f 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -4913,8 +4913,21 @@ declare module monaco.languages { newText: string; } + export interface IResourceRename { + readonly from: Uri; + readonly to: Uri; + } + + export interface IResourceCreate { + readonly uri: Uri; + readonly contents: string; + } + export interface WorkspaceEdit { edits: IResourceEdit[]; + renamedResources?: IResourceRename[]; + createdResources?: IResourceCreate[]; + deletedResources?: Uri[]; rejectReason?: string; } diff --git a/src/vs/vscode.d.ts b/src/vs/vscode.d.ts index 0920acba7475f..ad5cb36dc8c18 100644 --- a/src/vs/vscode.d.ts +++ b/src/vs/vscode.d.ts @@ -2430,6 +2430,29 @@ declare module 'vscode' { */ readonly size: number; + /** + * 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. + * @param contents New file contents. + */ + createResource(uri: Uri, contents: String): void; + + /** + * Delete a given resource in the workspace. + * + * @param uri Uri of resource to delete. + */ + deleteResource(uri: Uri): void; + /** * Replace the given range with given text for the given resource. * @@ -2485,6 +2508,21 @@ declare module 'vscode' { * @return An array of `[Uri, TextEdit[]]`-tuples. */ entries(): [Uri, TextEdit[]][]; + + /** + * Get all resource rename edits. + */ + readonly renamedResources: { from: Uri, to: Uri }[]; + + /** + * Get all resource create edits. + */ + readonly createdResources: { uri: Uri, contents: string }[]; + + /** + * Get all resource delete edits. + */ + readonly deletedResources: Uri[]; } /** diff --git a/src/vs/workbench/api/electron-browser/mainThreadEditors.ts b/src/vs/workbench/api/electron-browser/mainThreadEditors.ts index 0952ff3985fc1..1af196d24914c 100644 --- a/src/vs/workbench/api/electron-browser/mainThreadEditors.ts +++ b/src/vs/workbench/api/electron-browser/mainThreadEditors.ts @@ -15,7 +15,7 @@ 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, IResourceFileEdit } 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'; @@ -210,7 +210,7 @@ export class MainThreadEditors implements MainThreadEditorsShape { return TPromise.as(this._documentsAndEditors.getEditor(id).applyEdits(modelVersionId, edits, opts)); } - $tryApplyWorkspaceEdit(workspaceResourceEdits: IWorkspaceResourceEdit[]): TPromise<boolean> { + $tryApplyWorkspaceEdit(workspaceResourceEdits: IWorkspaceResourceEdit[], resourceFileEdits?: IResourceFileEdit): TPromise<boolean> { // First check if loaded models were not changed in the meantime for (let i = 0, len = workspaceResourceEdits.length; i < len; i++) { @@ -253,8 +253,17 @@ export class MainThreadEditors implements MainThreadEditorsShape { } } - return bulkEdit(this._textModelResolverService, codeEditor, resourceEdits, this._fileService) - .then(() => true); + return bulkEdit( + this._textModelResolverService, + codeEditor, + resourceEdits, + this._fileService, + resourceFileEdits ? { + renamedResources: resourceFileEdits.renamedResources.map(entry => ({ from: URI.revive(entry.from), to: URI.revive(entry.to) })), + createdResources: resourceFileEdits.createdResources.map(entry => ({ uri: URI.revive(entry.uri), contents: entry.contents })), + deletedResources: resourceFileEdits.deletedResources.map(URI.revive) + } : undefined + ).then(() => true); } $tryInsertSnippet(id: string, template: string, ranges: IRange[], opts: IUndoStopOptions): TPromise<boolean> { diff --git a/src/vs/workbench/api/node/extHost.protocol.ts b/src/vs/workbench/api/node/extHost.protocol.ts index d189f463f6bdb..0d3ad1d8df194 100644 --- a/src/vs/workbench/api/node/extHost.protocol.ts +++ b/src/vs/workbench/api/node/extHost.protocol.ts @@ -210,6 +210,12 @@ export interface IWorkspaceResourceEdit { }[]; } +export interface IResourceFileEdit { + renamedResources: { from: UriComponents, to: UriComponents }[]; + createdResources: { uri: UriComponents, contents: string }[]; + deletedResources: UriComponents[]; +} + export interface MainThreadEditorsShape extends IDisposable { $tryShowTextDocument(resource: UriComponents, options: ITextDocumentShowOptions): TPromise<string>; $registerTextEditorDecorationType(key: string, options: editorCommon.IDecorationRenderOptions): void; @@ -222,7 +228,7 @@ export interface MainThreadEditorsShape extends IDisposable { $tryRevealRange(id: string, range: IRange, revealType: TextEditorRevealType): TPromise<void>; $trySetSelections(id: string, selections: ISelection[]): TPromise<void>; $tryApplyEdits(id: string, modelVersionId: number, edits: ISingleEditOperation[], opts: IApplyEditsOptions): TPromise<boolean>; - $tryApplyWorkspaceEdit(workspaceResourceEdits: IWorkspaceResourceEdit[]): TPromise<boolean>; + $tryApplyWorkspaceEdit(workspaceResourceEdits: IWorkspaceResourceEdit[], resourceFileEdits?: IResourceFileEdit): TPromise<boolean>; $tryInsertSnippet(id: string, template: string, selections: IRange[], opts: IUndoStopOptions): TPromise<boolean>; $getDiffInformation(id: string): TPromise<editorCommon.ILineChange[]>; } diff --git a/src/vs/workbench/api/node/extHostTextEditors.ts b/src/vs/workbench/api/node/extHostTextEditors.ts index 35ad03c3a4941..79e50c129a2b7 100644 --- a/src/vs/workbench/api/node/extHostTextEditors.ts +++ b/src/vs/workbench/api/node/extHostTextEditors.ts @@ -121,7 +121,7 @@ export class ExtHostEditors implements ExtHostEditorsShape { workspaceResourceEdits.push(workspaceResourceEdit); } - return this._proxy.$tryApplyWorkspaceEdit(workspaceResourceEdits); + return this._proxy.$tryApplyWorkspaceEdit(workspaceResourceEdits, edit); } // --- called from main thread diff --git a/src/vs/workbench/api/node/extHostTypeConverters.ts b/src/vs/workbench/api/node/extHostTypeConverters.ts index 46b7171459620..f137970845988 100644 --- a/src/vs/workbench/api/node/extHostTypeConverters.ts +++ b/src/vs/workbench/api/node/extHostTypeConverters.ts @@ -228,7 +228,12 @@ export const TextEdit = { export namespace WorkspaceEdit { export function from(value: vscode.WorkspaceEdit): modes.WorkspaceEdit { - const result: modes.WorkspaceEdit = { edits: [] }; + const result: modes.WorkspaceEdit = { + edits: [], + renamedResources: value.renamedResources, + createdResources: value.createdResources, + deletedResources: value.deletedResources + }; for (let entry of value.entries()) { let [uri, textEdits] = entry; for (let textEdit of textEdits) { diff --git a/src/vs/workbench/api/node/extHostTypes.ts b/src/vs/workbench/api/node/extHostTypes.ts index 154012715bf31..a7b250b6d6971 100644 --- a/src/vs/workbench/api/node/extHostTypes.ts +++ b/src/vs/workbench/api/node/extHostTypes.ts @@ -494,8 +494,43 @@ export class TextEdit { export class WorkspaceEdit { private _values: [URI, TextEdit[]][] = []; + private readonly _resourcesCreated: { uri: URI, contents: string }[] = []; + private readonly _resourcesDeleted: URI[] = []; + private readonly _resourcesRenamed: { from: URI, to: URI }[] = []; private _index = new Map<string, number>(); + private _validResources = new Set<URI>(); + private _invalidResources = new Set<URI>(); + + + createResource(uri: URI, contents: string): void { + if (this._invalidResources.has(uri)) { + throw illegalArgument('Cannot create already deleted resource'); + } + this._resourcesCreated.push({ uri: uri, contents: contents }); + this._validResources.add(uri); + } + + deleteResource(uri: URI): void { + if (this._validResources.has(uri)) { + throw illegalArgument('Cannot delete newly created resource'); + } + this._resourcesDeleted.push(uri); + this._invalidResources.add(uri); + } + + renameResource(uri: URI, newUri: URI): void { + if (this._validResources.has(uri)) { + throw illegalArgument('Cannot delete newly created resource'); + } + if (this._invalidResources.has(newUri)) { + throw illegalArgument('Cannot create already deleted resource'); + } + this._resourcesRenamed.push({ from: uri, to: newUri }); + this._invalidResources.add(uri); + this._validResources.add(newUri); + } + replace(uri: URI, range: Range, newText: string): void { let edit = new TextEdit(range, newText); let array = this.get(uri); @@ -519,6 +554,10 @@ export class WorkspaceEdit { } set(uri: URI, edits: TextEdit[]): void { + if (this._invalidResources.has(uri)) { + throw illegalArgument('Cannot modify already deleted resource'); + } + this._validResources.add(uri); const idx = this._index.get(uri.toString()); if (typeof idx === 'undefined') { let newLen = this._values.push([uri, edits]); @@ -537,8 +576,20 @@ export class WorkspaceEdit { return this._values; } + get createdResources(): { uri: URI, contents: string }[] { + return this._resourcesCreated; + } + + get deletedResources(): URI[] { + return this._resourcesDeleted; + } + + get renamedResources(): { from: URI, to: URI }[] { + return this._resourcesRenamed; + } + get size(): number { - return this._values.length; + return this._values.length + this._resourcesCreated.length + this._resourcesRenamed.length + this._resourcesDeleted.length; } toJSON(): any { 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 3cd1208f264ef..3e99b175fc79b 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,19 @@ 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('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 73887be8ea365..b725c6163bd13 100644 --- a/src/vs/workbench/test/electron-browser/api/mainThreadEditors.test.ts +++ b/src/vs/workbench/test/electron-browser/api/mainThreadEditors.test.ts @@ -23,6 +23,9 @@ 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'; suite('MainThreadEditors', () => { @@ -31,10 +34,35 @@ suite('MainThreadEditors', () => { let modelService: IModelService; let editors: MainThreadEditors; + const movedResources = new Map<URI, URI>(); + const createdResources = new Map<URI, string>(); + const deletedResources = new Set<URI>(); + setup(() => { const configService = new TestConfigurationService(); modelService = new ModelServiceImpl(null, configService); const codeEditorService = new TestCodeEditorService(); + + movedResources.clear(); + createdResources.clear(); + deletedResources.clear(); + const fileService = new TestFileService(); + + fileService.moveFile = async (from, target): TPromise<IFileStat> => { + assert(!movedResources.has(from)); + movedResources.set(from, target); + return createMockFileStat(target); + }; + fileService.createFile = async (uri, contents): TPromise<IFileStat> => { + assert(!createdResources.has(uri)); + createdResources.set(uri, contents); + return createMockFileStat(uri); + }; + fileService.del = async (uri): TPromise<void> => { + assert(!deletedResources.has(uri)); + deletedResources.add(uri); + }; + const textFileService = new class extends mock<ITextFileService>() { isDirty() { return false; } models = <any>{ @@ -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 ); }); @@ -107,4 +135,38 @@ suite('MainThreadEditors', () => { assert.equal(result, false); }); }); + + test(`applyWorkspaceEdit with only resource edit`, () => { + let model = modelService.createModel('something', null, resource); + + let workspaceResourceEdit: IWorkspaceResourceEdit = { + resource: resource, + modelVersionId: model.getVersionId(), + edits: [] + }; + + return editors.$tryApplyWorkspaceEdit([workspaceResourceEdit], { + renamedResources: [{ from: resource, to: resource }], + createdResources: [{ uri: resource, contents: 'foo' }], + deletedResources: [resource] + }).then((result) => { + assert.equal(result, true); + assert.equal(movedResources.get(resource), resource); + assert.equal(createdResources.get(resource), 'foo'); + assert.equal(deletedResources.has(resource), true); + }); + }); }); + + +function createMockFileStat(target: URI): IFileStat { + return { + etag: '', + hasChildren: false, + isDirectory: false, + name: target.path, + mtime: 0, + resource: target + }; +} +