diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index f83225cc974..f7f31337124 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -58,6 +58,10 @@ "name": "vs/workbench/contrib/commands", "project": "vscode-workbench" }, + { + "name": "vs/workbench/contrib/mappedEdits", + "project": "vscode-workbench" + }, { "name": "vs/workbench/contrib/comments", "project": "vscode-workbench" diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/mappedEdits.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/mappedEdits.test.ts new file mode 100644 index 00000000000..f4fc349f115 --- /dev/null +++ b/extensions/vscode-api-tests/src/singlefolder-tests/mappedEdits.test.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import * as vscode from 'vscode'; +import * as assert from 'assert'; + +suite('mapped edits provider', () => { + + test('mapped edits does not provide edits for unregistered langs', async function () { + + const uri = vscode.Uri.file(path.join(vscode.workspace.rootPath || '', './myFile.ts')); + + const tsDocFilter = [{ language: 'json' }]; + + const r1 = vscode.chat.registerMappedEditsProvider(tsDocFilter, { + provideMappedEdits: (_doc: vscode.TextDocument, codeBlocks: string[], context: vscode.MappedEditsContext, _token: vscode.CancellationToken) => { + + assert(context.selections.length === 1); + assert(context.related.length === 1); + assert('uri' in context.related[0] && 'range' in context.related[0]); + + const edit = new vscode.WorkspaceEdit(); + const text = codeBlocks.join('\n//----\n'); + edit.replace(uri, context.selections[0], text); + return edit; + } + }); + await vscode.workspace.openTextDocument(uri); + const result = await vscode.commands.executeCommand>( + 'vscode.executeMappedEditsProvider', + uri, + [ + '// hello', + `function foo() {\n\treturn 1;\n}`, + ], + { + selections: [new vscode.Selection(0, 0, 1, 0)], + related: [ + { + uri, + range: new vscode.Range(new vscode.Position(0, 0), new vscode.Position(1, 0)) + } + ] + } + ); + r1.dispose(); + + assert(result === null, 'returned null'); + }); + + test('mapped edits provides a single edit replacing the selection', async function () { + + const uri = vscode.Uri.file(path.join(vscode.workspace.rootPath || '', './myFile.ts')); + + const tsDocFilter = [{ language: 'typescript' }]; + + const r1 = vscode.chat.registerMappedEditsProvider(tsDocFilter, { + provideMappedEdits: (_doc: vscode.TextDocument, codeBlocks: string[], context: vscode.MappedEditsContext, _token: vscode.CancellationToken) => { + + assert(context.selections.length === 1); + assert(context.related.length === 1); + assert('uri' in context.related[0] && 'range' in context.related[0]); + + const edit = new vscode.WorkspaceEdit(); + const text = codeBlocks.join('\n//----\n'); + edit.replace(uri, context.selections[0], text); + return edit; + } + }); + + await vscode.workspace.openTextDocument(uri); + const result = await vscode.commands.executeCommand>( + 'vscode.executeMappedEditsProvider', + uri, + [ + '// hello', + `function foo() {\n\treturn 1;\n}`, + ], + { + selections: [new vscode.Selection(0, 0, 1, 0)], + related: [ + { + uri, + range: new vscode.Range(new vscode.Position(0, 0), new vscode.Position(1, 0)) + } + ] + } + ); + r1.dispose(); + + assert(result, 'non null response'); + const edits = result.get(uri); + assert(edits.length === 1); + assert(edits[0].range.start.line === 0); + assert(edits[0].range.start.character === 0); + assert(edits[0].range.end.line === 1); + assert(edits[0].range.end.character === 0); + }); +}); diff --git a/extensions/vscode-api-tests/testWorkspace/myFile.ts b/extensions/vscode-api-tests/testWorkspace/myFile.ts new file mode 100644 index 00000000000..2a2a4927869 --- /dev/null +++ b/extensions/vscode-api-tests/testWorkspace/myFile.ts @@ -0,0 +1,3 @@ +// 1 +// 2 +// 3 diff --git a/src/vs/workbench/api/browser/mainThreadMappedEdits.ts b/src/vs/workbench/api/browser/mainThreadMappedEdits.ts index a2a34f4155d..a268da7bbe1 100644 --- a/src/vs/workbench/api/browser/mainThreadMappedEdits.ts +++ b/src/vs/workbench/api/browser/mainThreadMappedEdits.ts @@ -6,7 +6,7 @@ import { IDisposable, dispose } from 'vs/base/common/lifecycle'; import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; import { reviveWorkspaceEditDto } from 'vs/workbench/api/browser/mainThreadBulkEdits'; -import { ExtHostContext, ExtHostMappedEditsShape, IDocumentFilterDto, IMappedEditsContextDto, MainContext, MainThreadMappedEditsShape } from 'vs/workbench/api/common/extHost.protocol'; +import { ExtHostContext, ExtHostMappedEditsShape, IDocumentFilterDto, MainContext, MainThreadMappedEditsShape } from 'vs/workbench/api/common/extHost.protocol'; import { IMappedEditsProvider, IMappedEditsService } from 'vs/workbench/services/mappedEdits/common/mappedEdits'; import { IExtHostContext, extHostNamedCustomer } from 'vs/workbench/services/extensions/common/extHostCustomers'; diff --git a/src/vs/workbench/api/common/extHostApiCommands.ts b/src/vs/workbench/api/common/extHostApiCommands.ts index 68db008d1b1..fdb8ffa2cd6 100644 --- a/src/vs/workbench/api/common/extHostApiCommands.ts +++ b/src/vs/workbench/api/common/extHostApiCommands.ts @@ -464,7 +464,26 @@ const newCommands: ApiCommand[] = [ new ApiCommandArgument('value', 'The context key value', () => true, v => v), ], ApiCommandResult.Void - ) + ), + // --- mapped edits + new ApiCommand( + 'vscode.executeMappedEditsProvider', '_executeMappedEditsProvider', 'Execute Mapped Edits Provider', + [ + ApiCommandArgument.Uri, + ApiCommandArgument.StringArray, + new ApiCommandArgument( + 'MappedEditsContext', + 'Mapped Edits Context', + (v: unknown) => typeConverters.MappedEditsContext.is(v), + (v: vscode.MappedEditsContext) => typeConverters.MappedEditsContext.from(v) + ) + ], + new ApiCommandResult( + 'A promise that resolves to a workspace edit or null', + (value) => { + return typeConverters.WorkspaceEdit.to(value); + }) + ), ]; //#endregion diff --git a/src/vs/workbench/api/common/extHostCommands.ts b/src/vs/workbench/api/common/extHostCommands.ts index e8fc48765c7..fd3ba6c52b9 100644 --- a/src/vs/workbench/api/common/extHostCommands.ts +++ b/src/vs/workbench/api/common/extHostCommands.ts @@ -444,6 +444,16 @@ export class ApiCommandArgument { static readonly Selection = new ApiCommandArgument('selection', 'A selection in a text document', v => extHostTypes.Selection.isSelection(v), extHostTypeConverter.Selection.from); static readonly Number = new ApiCommandArgument('number', '', v => typeof v === 'number', v => v); static readonly String = new ApiCommandArgument('string', '', v => typeof v === 'string', v => v); + static readonly StringArray = ApiCommandArgument.Arr(ApiCommandArgument.String); + + static Arr(element: ApiCommandArgument) { + return new ApiCommandArgument( + `${element.name}_array`, + `Array of ${element.name}, ${element.description}`, + (v: unknown) => Array.isArray(v) && v.every(e => element.validate(e)), + (v: T[]) => v.map(e => element.convert(e)) + ); + } static readonly CallHierarchyItem = new ApiCommandArgument('item', 'A call hierarchy item', v => v instanceof extHostTypes.CallHierarchyItem, extHostTypeConverter.CallHierarchyItem.from); static readonly TypeHierarchyItem = new ApiCommandArgument('item', 'A type hierarchy item', v => v instanceof extHostTypes.TypeHierarchyItem, extHostTypeConverter.TypeHierarchyItem.from); diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index d0e1bc99d4a..0f7efee2be4 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -32,6 +32,7 @@ import { IMarkerData, IRelatedInformation, MarkerSeverity, MarkerTag } from 'vs/ import { ProgressLocation as MainProgressLocation } from 'vs/platform/progress/common/progress'; import * as extHostProtocol from 'vs/workbench/api/common/extHost.protocol'; import { getPrivateApiFor } from 'vs/workbench/api/common/extHostTestingPrivateApi'; +import type * as mappedEdits from 'vs/workbench/services/mappedEdits/common/mappedEdits'; import { DEFAULT_EDITOR_ASSOCIATION, SaveReason } from 'vs/workbench/common/editor'; import { IViewBadge } from 'vs/workbench/common/views'; import { IChatFollowup, IChatReplyFollowup, IChatResponseCommandFollowup } from 'vs/workbench/contrib/chat/common/chatService'; @@ -1563,6 +1564,30 @@ export namespace LanguageSelector { } } +export namespace MappedEditsContext { + + export function is(v: unknown): v is vscode.MappedEditsContext { + return (!!v && + typeof v === 'object' && + 'selections' in v && + Array.isArray(v.selections) && + v.selections.every(s => s instanceof types.Selection) && + 'related' in v && + Array.isArray(v.related) && + v.related.every(e => e && typeof e === 'object' && URI.isUri(e.uri) && e.range instanceof types.Range)); + } + + export function from(context: vscode.MappedEditsContext): mappedEdits.MappedEditsContext { + return { + selections: context.selections.map(s => Selection.from(s)), + related: context.related.map(r => ({ + uri: URI.from(r.uri), + range: Range.from(r.range) + })) + }; + } +} + export namespace NotebookRange { export function from(range: vscode.NotebookRange): ICellRange { diff --git a/src/vs/workbench/contrib/mappedEdits/common/mappedEdits.contribution.ts b/src/vs/workbench/contrib/mappedEdits/common/mappedEdits.contribution.ts new file mode 100644 index 00000000000..469d369d14a --- /dev/null +++ b/src/vs/workbench/contrib/mappedEdits/common/mappedEdits.contribution.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { URI } from 'vs/base/common/uri'; +import { ITextModelService } from 'vs/editor/common/services/resolverService'; +import * as nls from 'vs/nls'; +import { Action2, registerAction2 } from 'vs/platform/actions/common/actions'; +import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { IMappedEditsService, MappedEditsContext } from 'vs/workbench/services/mappedEdits/common/mappedEdits'; + +class ExecuteMappedEditsProvider extends Action2 { + + static readonly ID = '_executeMappedEditsProvider'; + + constructor() { + super({ + id: ExecuteMappedEditsProvider.ID, + title: { value: nls.localize('executeMappedEditsProvider', "Execute Mapped Edits Provider"), original: '' }, + f1: false, + description: { + description: nls.localize('executeMappedEditsProvider.description', "Executes Mapped Edits Provider and returns the corresponding WorkspaceEdit or null if no edits are provided."), + args: [ + // FIXME@ulugbekna + ] + } + }); + } + + async run(accessor: ServicesAccessor, documentUri: URI, codeBlocks: string[], context: MappedEditsContext) { + + const mappedEditsService = accessor.get(IMappedEditsService); + const modelService = accessor.get(ITextModelService); + + const document = await modelService.createModelReference(documentUri); + + const cancellationTokenSource = new CancellationTokenSource(); + + const res = await mappedEditsService.provideMappedEdits(document.object.textEditorModel, codeBlocks, context, cancellationTokenSource.token); + + document.dispose(); + + return res; + } +} + +registerAction2(ExecuteMappedEditsProvider); diff --git a/src/vs/workbench/services/mappedEdits/common/mappedEdits.ts b/src/vs/workbench/services/mappedEdits/common/mappedEdits.ts index 8e58c5c1031..9a60304881c 100644 --- a/src/vs/workbench/services/mappedEdits/common/mappedEdits.ts +++ b/src/vs/workbench/services/mappedEdits/common/mappedEdits.ts @@ -10,16 +10,16 @@ import { LanguageSelector } from 'vs/editor/common/languageSelector'; import { IDisposable } from 'vs/base/common/lifecycle'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { WorkspaceEdit } from 'vs/editor/common/languages'; -import { Selection } from 'vs/editor/common/core/selection'; -import { Range } from 'vs/editor/common/core/range'; +import { ISelection } from 'vs/editor/common/core/selection'; +import { IRange } from 'vs/editor/common/core/range'; export interface RelatedContextItem { readonly uri: URI; - readonly range: Range; + readonly range: IRange; } export interface MappedEditsContext { - selections: Selection[]; + selections: ISelection[]; /** * If there's no context, the array should be empty. It's also empty until we figure out how to compute this or retrieve from an extension (eg, copilot chat) diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 81170dfd99e..d30cd73ec49 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -227,6 +227,9 @@ import 'vs/workbench/contrib/markers/browser/markers.contribution'; // Merge Editor import 'vs/workbench/contrib/mergeEditor/browser/mergeEditor.contribution'; +// Mapped Edits +import 'vs/workbench/contrib/mappedEdits/common/mappedEdits.contribution'; + // Commands import 'vs/workbench/contrib/commands/common/commands.contribution';