add vscode.executeMappedEditsProvider command & use it to have integration tests for mapped-edits service

This commit is contained in:
Ulugbek Abdullaev 2023-08-21 13:00:50 +02:00
parent 3e9e7b3b5a
commit c3a4fbbe8f
10 changed files with 221 additions and 6 deletions

View file

@ -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"

View file

@ -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.ProviderResult<vscode.WorkspaceEdit | null>>(
'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.ProviderResult<vscode.WorkspaceEdit | null>>(
'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);
});
});

View file

@ -0,0 +1,3 @@
// 1
// 2
// 3

View file

@ -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';

View file

@ -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<IWorkspaceEditDto, vscode.WorkspaceEdit>(
'A promise that resolves to a workspace edit or null',
(value) => {
return typeConverters.WorkspaceEdit.to(value);
})
),
];
//#endregion

View file

@ -444,6 +444,16 @@ export class ApiCommandArgument<V, O = V> {
static readonly Selection = new ApiCommandArgument<extHostTypes.Selection, ISelection>('selection', 'A selection in a text document', v => extHostTypes.Selection.isSelection(v), extHostTypeConverter.Selection.from);
static readonly Number = new ApiCommandArgument<number>('number', '', v => typeof v === 'number', v => v);
static readonly String = new ApiCommandArgument<string>('string', '', v => typeof v === 'string', v => v);
static readonly StringArray = ApiCommandArgument.Arr(ApiCommandArgument.String);
static Arr<T, K = T>(element: ApiCommandArgument<T, K>) {
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);

View file

@ -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 {

View file

@ -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);

View file

@ -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)

View file

@ -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';