diff --git a/src/vs/editor/browser/services/bulkEditService.ts b/src/vs/editor/browser/services/bulkEditService.ts index 558376fae33..1fa168aedb1 100644 --- a/src/vs/editor/browser/services/bulkEditService.ts +++ b/src/vs/editor/browser/services/bulkEditService.ts @@ -45,9 +45,9 @@ export class ResourceEdit { export class ResourceTextEdit extends ResourceEdit { constructor( readonly resource: URI, - readonly textEdit: TextEdit, + readonly textEdit: TextEdit & { insertAsSnippet?: boolean }, readonly versionId?: number, - metadata?: WorkspaceEditMetadata + metadata?: WorkspaceEditMetadata, ) { super(metadata); } diff --git a/src/vs/editor/contrib/snippet/browser/snippetController2.ts b/src/vs/editor/contrib/snippet/browser/snippetController2.ts index bb8282d9ee5..43e72f6ce57 100644 --- a/src/vs/editor/contrib/snippet/browser/snippetController2.ts +++ b/src/vs/editor/contrib/snippet/browser/snippetController2.ts @@ -16,7 +16,7 @@ import { CompletionItem, CompletionItemKind, CompletionItemProvider } from 'vs/e import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; import { ITextModel } from 'vs/editor/common/model'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; -import { Choice } from 'vs/editor/contrib/snippet/browser/snippetParser'; +import { Choice, SnippetParser } from 'vs/editor/contrib/snippet/browser/snippetParser'; import { showSimpleSuggestions } from 'vs/editor/contrib/suggest/browser/suggest'; import { OvertypingCapturer } from 'vs/editor/contrib/suggest/browser/suggestOvertypingCapturer'; import { localize } from 'vs/nls'; @@ -346,3 +346,46 @@ export function performSnippetEdit(editor: ICodeEditor, snippet: string, selecti controller.insert(snippet); return controller.isInSnippet(); } + + +export type ISnippetEdit = { + range: Range; + snippet: string; +}; + +// --- + +export function performSnippetEdits(editor: ICodeEditor, edits: ISnippetEdit[]) { + + if (!editor.hasModel()) { + return false; + } + if (edits.length === 0) { + return false; + } + + const model = editor.getModel(); + let newText = ''; + let last: ISnippetEdit | undefined; + edits.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range)); + + for (const item of edits) { + if (last) { + const between = Range.fromPositions(last.range.getEndPosition(), item.range.getStartPosition()); + const text = model.getValueInRange(between); + newText += SnippetParser.escape(text); + } + newText += item.snippet; + last = item; + } + + const controller = SnippetController2.get(editor); + if (!controller) { + return false; + } + model.pushStackElement(); + const range = Range.plusRange(edits[0].range, edits[edits.length - 1].range); + editor.setSelection(range); + controller.insert(newText, { undoStopBefore: false }); + return controller.isInSnippet(); +} diff --git a/src/vs/editor/contrib/snippet/browser/snippetSession.ts b/src/vs/editor/contrib/snippet/browser/snippetSession.ts index d1629256724..3ad72329e91 100644 --- a/src/vs/editor/contrib/snippet/browser/snippetSession.ts +++ b/src/vs/editor/contrib/snippet/browser/snippetSession.ts @@ -49,7 +49,7 @@ export class OneSnippet { this._placeholderGroupsIdx = -1; } - public initialize(textChange: TextChange): void { + initialize(textChange: TextChange): void { this._offset = textChange.newPosition; } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 16fafcafade..4eb48fda17b 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -856,7 +856,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I return extHostWorkspace.saveAll(includeUntitled); }, applyEdit(edit: vscode.WorkspaceEdit): Thenable { - return extHostBulkEdits.applyWorkspaceEdit(edit); + return extHostBulkEdits.applyWorkspaceEdit(edit, extension); }, createFileSystemWatcher: (pattern, ignoreCreate, ignoreChange, ignoreDelete): vscode.FileSystemWatcher => { return extHostFileSystemEvent.createFileSystemWatcher(extHostWorkspace, extension, pattern, ignoreCreate, ignoreChange, ignoreDelete); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 1a0197ffd24..73da98403c4 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1602,7 +1602,7 @@ export interface IWorkspaceFileEditDto { export interface IWorkspaceTextEditDto { _type: WorkspaceEditType.Text; resource: UriComponents; - edit: languages.TextEdit; + edit: languages.TextEdit & { insertAsSnippet?: boolean }; modelVersionId?: number; metadata?: IWorkspaceEditEntryMetadataDto; } diff --git a/src/vs/workbench/api/common/extHostBulkEdits.ts b/src/vs/workbench/api/common/extHostBulkEdits.ts index 6547c861438..2126d41724e 100644 --- a/src/vs/workbench/api/common/extHostBulkEdits.ts +++ b/src/vs/workbench/api/common/extHostBulkEdits.ts @@ -3,10 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'; import { MainContext, MainThreadBulkEditsShape } from 'vs/workbench/api/common/extHost.protocol'; import { ExtHostDocumentsAndEditors } from 'vs/workbench/api/common/extHostDocumentsAndEditors'; import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; import { WorkspaceEdit } from 'vs/workbench/api/common/extHostTypeConverters'; +import { isProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; import type * as vscode from 'vscode'; export class ExtHostBulkEdits { @@ -26,8 +28,9 @@ export class ExtHostBulkEdits { }; } - applyWorkspaceEdit(edit: vscode.WorkspaceEdit): Promise { - const dto = WorkspaceEdit.from(edit, this._versionInformationProvider); + applyWorkspaceEdit(edit: vscode.WorkspaceEdit, extension: IExtensionDescription): Promise { + const allowSnippetTextEdit = isProposedApiEnabled(extension, 'snippetWorkspaceEdit'); + const dto = WorkspaceEdit.from(edit, this._versionInformationProvider, allowSnippetTextEdit); return this._proxy.$tryApplyWorkspaceEdit(dto); } } diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 6e4d8a09a5a..f37cc63a6f8 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -569,7 +569,7 @@ export namespace WorkspaceEdit { getNotebookDocumentVersion(uri: URI): number | undefined; } - export function from(value: vscode.WorkspaceEdit, versionInfo?: IVersionInformationProvider): extHostProtocol.IWorkspaceEditDto { + export function from(value: vscode.WorkspaceEdit, versionInfo?: IVersionInformationProvider, allowSnippetTextEdit?: boolean): extHostProtocol.IWorkspaceEditDto { const result: extHostProtocol.IWorkspaceEditDto = { edits: [] }; @@ -598,14 +598,21 @@ export namespace WorkspaceEdit { }); } else if (entry._type === types.FileEditType.Text) { + // text edits - result.edits.push({ + const dto = { _type: extHostProtocol.WorkspaceEditType.Text, resource: entry.uri, edit: TextEdit.from(entry.edit), modelVersionId: !toCreate.has(entry.uri) ? versionInfo?.getTextDocumentVersion(entry.uri) : undefined, metadata: entry.metadata - }); + }; + if (allowSnippetTextEdit && entry.edit.newText2 instanceof types.SnippetString) { + dto.edit.insertAsSnippet = true; + dto.edit.text = entry.edit.newText2.value; + } + result.edits.push(dto); + } else if (entry._type === types.FileEditType.Cell) { result.edits.push({ _type: extHostProtocol.WorkspaceEditType.Cell, diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 3f9ca317b1c..d3bb21fb997 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -549,6 +549,7 @@ export class TextEdit { protected _range: Range; protected _newText: string | null; + newText2?: string | SnippetString; protected _newEol?: EndOfLine; get range(): Range { diff --git a/src/vs/workbench/api/test/browser/extHostBulkEdits.test.ts b/src/vs/workbench/api/test/browser/extHostBulkEdits.test.ts index 4974c24314c..3b393b35347 100644 --- a/src/vs/workbench/api/test/browser/extHostBulkEdits.test.ts +++ b/src/vs/workbench/api/test/browser/extHostBulkEdits.test.ts @@ -12,6 +12,7 @@ import { SingleProxyRPCProtocol, TestRPCProtocol } from 'vs/workbench/api/test/c import { NullLogService } from 'vs/platform/log/common/log'; import { assertType } from 'vs/base/common/types'; import { ExtHostBulkEdits } from 'vs/workbench/api/common/extHostBulkEdits'; +import { nullExtensionDescription } from 'vs/workbench/services/extensions/common/extensions'; suite('ExtHostBulkEdits.applyWorkspaceEdit', () => { @@ -46,7 +47,7 @@ suite('ExtHostBulkEdits.applyWorkspaceEdit', () => { test('uses version id if document available', async () => { const edit = new extHostTypes.WorkspaceEdit(); edit.replace(resource, new extHostTypes.Range(0, 0, 0, 0), 'hello'); - await bulkEdits.applyWorkspaceEdit(edit); + await bulkEdits.applyWorkspaceEdit(edit, nullExtensionDescription); assert.strictEqual(workspaceResourceEdits.edits.length, 1); const [first] = workspaceResourceEdits.edits; assertType(first._type === WorkspaceEditType.Text); @@ -56,7 +57,7 @@ suite('ExtHostBulkEdits.applyWorkspaceEdit', () => { test('does not use version id if document is not available', async () => { const edit = new extHostTypes.WorkspaceEdit(); edit.replace(URI.parse('foo:bar2'), new extHostTypes.Range(0, 0, 0, 0), 'hello'); - await bulkEdits.applyWorkspaceEdit(edit); + await bulkEdits.applyWorkspaceEdit(edit, nullExtensionDescription); assert.strictEqual(workspaceResourceEdits.edits.length, 1); const [first] = workspaceResourceEdits.edits; assertType(first._type === WorkspaceEditType.Text); diff --git a/src/vs/workbench/contrib/bulkEdit/browser/bulkTextEdits.ts b/src/vs/workbench/contrib/bulkEdit/browser/bulkTextEdits.ts index b4cf1ff3071..047600a3912 100644 --- a/src/vs/workbench/contrib/bulkEdit/browser/bulkTextEdits.ts +++ b/src/vs/workbench/contrib/bulkEdit/browser/bulkTextEdits.ts @@ -19,6 +19,8 @@ import { ResourceMap } from 'vs/base/common/map'; import { IModelService } from 'vs/editor/common/services/model'; import { ResourceTextEdit } from 'vs/editor/browser/services/bulkEditService'; import { CancellationToken } from 'vs/base/common/cancellation'; +import { SnippetParser } from 'vs/editor/contrib/snippet/browser/snippetParser'; +import { performSnippetEdits } from 'vs/editor/contrib/snippet/browser/snippetController2'; type ValidationResult = { canApply: true } | { canApply: false; reason: URI }; @@ -27,7 +29,7 @@ class ModelEditTask implements IDisposable { readonly model: ITextModel; private _expectedModelVersionId: number | undefined; - protected _edits: ISingleEditOperation[]; + protected _edits: (ISingleEditOperation & { insertAsSnippet?: boolean })[]; protected _newEol: EndOfLineSequence | undefined; constructor(private readonly _modelReference: IReference) { @@ -75,7 +77,7 @@ class ModelEditTask implements IDisposable { } else { range = Range.lift(textEdit.range); } - this._edits.push(EditOperation.replaceMove(range, textEdit.text)); + this._edits.push({ ...EditOperation.replaceMove(range, textEdit.text), insertAsSnippet: textEdit.insertAsSnippet }); } validate(): ValidationResult { @@ -91,7 +93,9 @@ class ModelEditTask implements IDisposable { apply(): void { if (this._edits.length > 0) { - this._edits = this._edits.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range)); + this._edits = this._edits + .sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range)) + .map(edit => ({ ...edit, text: edit.text && SnippetParser.escape(edit.text) })); this.model.pushEditOperations(null, this._edits, () => null); } if (this._newEol !== undefined) { @@ -121,10 +125,19 @@ class EditorEditTask extends ModelEditTask { super.apply(); return; } - if (this._edits.length > 0) { - this._edits = this._edits.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range)); - this._editor.executeEdits('', this._edits); + + const insertAsSnippet = this._edits.every(edit => edit.insertAsSnippet); + if (insertAsSnippet) { + // todo@jrieken what ABOUT EOL? + performSnippetEdits(this._editor, this._edits.map(edit => ({ range: Range.lift(edit.range!), snippet: edit.text! }))); + + } else { + this._edits = this._edits + .sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range)) + .map(edit => ({ ...edit, text: edit.text && SnippetParser.escape(edit.text) })); + this._editor.executeEdits('', this._edits); + } } if (this._newEol !== undefined) { if (this._editor.hasModel()) { @@ -193,7 +206,7 @@ export class BulkTextEdits { let makeMinimal = false; if (this._editor?.getModel()?.uri.toString() === ref.object.textEditorModel.uri.toString()) { task = new EditorEditTask(ref, this._editor); - makeMinimal = true; + makeMinimal = true && false; // todo@jrieken HACK } else { task = new ModelEditTask(ref); } diff --git a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts index 483c4f13eb0..8fcd4d47199 100644 --- a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts +++ b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts @@ -50,6 +50,7 @@ export const allApiProposals = Object.freeze({ scmInput: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.scmInput.d.ts', scmSelectedProvider: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.scmSelectedProvider.d.ts', scmValidation: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.scmValidation.d.ts', + snippetWorkspaceEdit: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.snippetWorkspaceEdit.d.ts', taskPresentationGroup: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.taskPresentationGroup.d.ts', telemetry: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.telemetry.d.ts', terminalDataWriteEvent: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.terminalDataWriteEvent.d.ts', diff --git a/src/vscode-dts/vscode.proposed.snippetWorkspaceEdit.d.ts b/src/vscode-dts/vscode.proposed.snippetWorkspaceEdit.d.ts new file mode 100644 index 00000000000..43305c7b56e --- /dev/null +++ b/src/vscode-dts/vscode.proposed.snippetWorkspaceEdit.d.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/145374 + + export interface TextEdit { + + // will be merged with newText + // will NOT be supported everywhere, only: `workspace.applyEdit` + newText2?: string | SnippetString; + } +}