Path: blob/main/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/4import { AsyncIterableObject } from '../../../../../base/common/async.js';5import { VSBuffer } from '../../../../../base/common/buffer.js';6import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js';7import { CharCode } from '../../../../../base/common/charCode.js';8import { isCancellationError } from '../../../../../base/common/errors.js';9import { isEqual } from '../../../../../base/common/resources.js';10import * as strings from '../../../../../base/common/strings.js';11import { URI } from '../../../../../base/common/uri.js';12import { getCodeEditor, IActiveCodeEditor } from '../../../../../editor/browser/editorBrowser.js';13import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js';14import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js';15import { Range } from '../../../../../editor/common/core/range.js';16import { TextEdit } from '../../../../../editor/common/languages.js';17import { ILanguageService } from '../../../../../editor/common/languages/language.js';18import { ITextModel } from '../../../../../editor/common/model.js';19import { EditDeltaInfo, EditSuggestionId } from '../../../../../editor/common/textModelEditSource.js';20import { localize } from '../../../../../nls.js';21import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';22import { IFileService } from '../../../../../platform/files/common/files.js';23import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';24import { ILabelService } from '../../../../../platform/label/common/label.js';25import { ILogService } from '../../../../../platform/log/common/log.js';26import { IProgressService, ProgressLocation } from '../../../../../platform/progress/common/progress.js';27import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js';28import { IEditorService } from '../../../../services/editor/common/editorService.js';29import { ITextFileService } from '../../../../services/textfile/common/textfiles.js';30import { IAiEditTelemetryService } from '../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js';31import { reviewEdits, reviewNotebookEdits } from '../../../inlineChat/browser/inlineChatController.js';32import { insertCell } from '../../../notebook/browser/controller/cellOperations.js';33import { IActiveNotebookEditor, INotebookEditor } from '../../../notebook/browser/notebookBrowser.js';34import { CellKind, ICellEditOperation, NOTEBOOK_EDITOR_ID } from '../../../notebook/common/notebookCommon.js';35import { INotebookService } from '../../../notebook/common/notebookService.js';36import { ICodeMapperCodeBlock, ICodeMapperRequest, ICodeMapperResponse, ICodeMapperService } from '../../common/chatCodeMapperService.js';37import { ChatUserAction, IChatService } from '../../common/chatService.js';38import { IChatRequestViewModel, isRequestVM, isResponseVM } from '../../common/chatViewModel.js';39import { ICodeBlockActionContext } from '../codeBlockPart.js';4041export class InsertCodeBlockOperation {42constructor(43@IEditorService private readonly editorService: IEditorService,44@ITextFileService private readonly textFileService: ITextFileService,45@IBulkEditService private readonly bulkEditService: IBulkEditService,46@ICodeEditorService private readonly codeEditorService: ICodeEditorService,47@IChatService private readonly chatService: IChatService,48@ILanguageService private readonly languageService: ILanguageService,49@IDialogService private readonly dialogService: IDialogService,50@IAiEditTelemetryService private readonly aiEditTelemetryService: IAiEditTelemetryService,51) {52}5354public async run(context: ICodeBlockActionContext) {55const activeEditorControl = getEditableActiveCodeEditor(this.editorService);56if (activeEditorControl) {57await this.handleTextEditor(activeEditorControl, context);58} else {59const activeNotebookEditor = getActiveNotebookEditor(this.editorService);60if (activeNotebookEditor) {61await this.handleNotebookEditor(activeNotebookEditor, context);62} else {63this.notify(localize('insertCodeBlock.noActiveEditor', "To insert the code block, open a code editor or notebook editor and set the cursor at the location where to insert the code block."));64}65}6667if (isResponseVM(context.element)) {68const requestId = context.element.requestId;69const request = context.element.session.getItems().find(item => item.id === requestId && isRequestVM(item)) as IChatRequestViewModel | undefined;70notifyUserAction(this.chatService, context, {71kind: 'insert',72codeBlockIndex: context.codeBlockIndex,73totalCharacters: context.code.length,74totalLines: context.code.split('\n').length,75languageId: context.languageId,76modelId: request?.modelId ?? '',77});7879const codeBlockInfo = context.element.model.codeBlockInfos?.at(context.codeBlockIndex);8081this.aiEditTelemetryService.handleCodeAccepted({82acceptanceMethod: 'insertAtCursor',83suggestionId: codeBlockInfo?.suggestionId,84editDeltaInfo: EditDeltaInfo.fromText(context.code),85feature: 'sideBarChat',86languageId: context.languageId,87modeId: context.element.model.request?.modeInfo?.modeId,88modelId: request?.modelId,89presentation: 'codeBlock',90applyCodeBlockSuggestionId: undefined,91});92}93}9495private async handleNotebookEditor(notebookEditor: IActiveNotebookEditor, codeBlockContext: ICodeBlockActionContext): Promise<boolean> {96if (notebookEditor.isReadOnly) {97this.notify(localize('insertCodeBlock.readonlyNotebook', "Cannot insert the code block to read-only notebook editor."));98return false;99}100const focusRange = notebookEditor.getFocus();101const next = Math.max(focusRange.end - 1, 0);102insertCell(this.languageService, notebookEditor, next, CellKind.Code, 'below', codeBlockContext.code, true);103return true;104}105106private async handleTextEditor(codeEditor: IActiveCodeEditor, codeBlockContext: ICodeBlockActionContext): Promise<boolean> {107const activeModel = codeEditor.getModel();108if (isReadOnly(activeModel, this.textFileService)) {109this.notify(localize('insertCodeBlock.readonly', "Cannot insert the code block to read-only code editor."));110return false;111}112113const range = codeEditor.getSelection() ?? new Range(activeModel.getLineCount(), 1, activeModel.getLineCount(), 1);114const text = reindent(codeBlockContext.code, activeModel, range.startLineNumber);115116const edits = [new ResourceTextEdit(activeModel.uri, { range, text })];117await this.bulkEditService.apply(edits);118this.codeEditorService.listCodeEditors().find(editor => editor.getModel()?.uri.toString() === activeModel.uri.toString())?.focus();119return true;120}121122private notify(message: string) {123//this.notificationService.notify({ severity: Severity.Info, message });124this.dialogService.info(message);125}126}127128type IComputeEditsResult = { readonly editsProposed: boolean; readonly codeMapper?: string };129130export class ApplyCodeBlockOperation {131132constructor(133@IEditorService private readonly editorService: IEditorService,134@ITextFileService private readonly textFileService: ITextFileService,135@IChatService private readonly chatService: IChatService,136@IFileService private readonly fileService: IFileService,137@IDialogService private readonly dialogService: IDialogService,138@ILogService private readonly logService: ILogService,139@ICodeMapperService private readonly codeMapperService: ICodeMapperService,140@IProgressService private readonly progressService: IProgressService,141@IQuickInputService private readonly quickInputService: IQuickInputService,142@ILabelService private readonly labelService: ILabelService,143@IInstantiationService private readonly instantiationService: IInstantiationService,144@INotebookService private readonly notebookService: INotebookService,145) {146}147148public async run(context: ICodeBlockActionContext): Promise<void> {149let activeEditorControl = getEditableActiveCodeEditor(this.editorService);150151const codemapperUri = await this.evaluateURIToUse(context.codemapperUri, activeEditorControl);152if (!codemapperUri) {153return;154}155156if (codemapperUri && !isEqual(activeEditorControl?.getModel().uri, codemapperUri) && !this.notebookService.hasSupportedNotebooks(codemapperUri)) {157// reveal the target file158try {159const editorPane = await this.editorService.openEditor({ resource: codemapperUri });160const codeEditor = getCodeEditor(editorPane?.getControl());161if (codeEditor && codeEditor.hasModel()) {162this.tryToRevealCodeBlock(codeEditor, context.code);163activeEditorControl = codeEditor;164} else {165this.notify(localize('applyCodeBlock.errorOpeningFile', "Failed to open {0} in a code editor.", codemapperUri.toString()));166return;167}168} catch (e) {169this.logService.info('[ApplyCodeBlockOperation] error opening code mapper file', codemapperUri, e);170return;171}172}173174let codeBlockSuggestionId: EditSuggestionId | undefined = undefined;175176if (isResponseVM(context.element)) {177const codeBlockInfo = context.element.model.codeBlockInfos?.at(context.codeBlockIndex);178if (codeBlockInfo) {179codeBlockSuggestionId = codeBlockInfo.suggestionId;180}181}182183let result: IComputeEditsResult | undefined = undefined;184185if (activeEditorControl && !this.notebookService.hasSupportedNotebooks(codemapperUri)) {186result = await this.handleTextEditor(activeEditorControl, context.chatSessionId, context.code, codeBlockSuggestionId);187} else {188const activeNotebookEditor = getActiveNotebookEditor(this.editorService);189if (activeNotebookEditor) {190result = await this.handleNotebookEditor(activeNotebookEditor, context.chatSessionId, context.code);191} else {192this.notify(localize('applyCodeBlock.noActiveEditor', "To apply this code block, open a code or notebook editor."));193}194}195196if (isResponseVM(context.element)) {197const requestId = context.element.requestId;198const request = context.element.session.getItems().find(item => item.id === requestId && isRequestVM(item)) as IChatRequestViewModel | undefined;199notifyUserAction(this.chatService, context, {200kind: 'apply',201codeBlockIndex: context.codeBlockIndex,202totalCharacters: context.code.length,203codeMapper: result?.codeMapper,204editsProposed: !!result?.editsProposed,205totalLines: context.code.split('\n').length,206modelId: request?.modelId ?? '',207languageId: context.languageId,208});209}210}211212private async evaluateURIToUse(resource: URI | undefined, activeEditorControl: IActiveCodeEditor | undefined): Promise<URI | undefined> {213if (resource && await this.fileService.exists(resource)) {214return resource;215}216217const activeEditorOption = activeEditorControl?.getModel().uri ? { label: localize('activeEditor', "Active editor '{0}'", this.labelService.getUriLabel(activeEditorControl.getModel().uri, { relative: true })), id: 'activeEditor' } : undefined;218const untitledEditorOption = { label: localize('newUntitledFile', "New untitled editor"), id: 'newUntitledFile' };219220const options = [];221if (resource) {222// code block had an URI, but it doesn't exist223options.push({ label: localize('createFile', "New file '{0}'", this.labelService.getUriLabel(resource, { relative: true })), id: 'createFile' });224options.push(untitledEditorOption);225if (activeEditorOption) {226options.push(activeEditorOption);227}228} else {229// code block had no URI230if (activeEditorOption) {231options.push(activeEditorOption);232}233options.push(untitledEditorOption);234}235236const selected = options.length > 1 ? await this.quickInputService.pick(options, { placeHolder: localize('selectOption', "Select where to apply the code block") }) : options[0];237if (selected) {238switch (selected.id) {239case 'createFile':240if (resource) {241try {242await this.fileService.writeFile(resource, VSBuffer.fromString(''));243} catch (error) {244this.notify(localize('applyCodeBlock.fileWriteError', "Failed to create file: {0}", error.message));245return URI.from({ scheme: 'untitled', path: resource.path });246}247}248return resource;249case 'newUntitledFile':250return URI.from({ scheme: 'untitled', path: resource ? resource.path : 'Untitled-1' });251case 'activeEditor':252return activeEditorControl?.getModel().uri;253}254}255return undefined;256}257258private async handleNotebookEditor(notebookEditor: IActiveNotebookEditor, chatSessionId: string | undefined, code: string): Promise<IComputeEditsResult | undefined> {259if (notebookEditor.isReadOnly) {260this.notify(localize('applyCodeBlock.readonlyNotebook', "Cannot apply code block to read-only notebook editor."));261return undefined;262}263const uri = notebookEditor.textModel.uri;264const codeBlock = { code, resource: uri, markdownBeforeBlock: undefined };265const codeMapper = this.codeMapperService.providers[0]?.displayName;266if (!codeMapper) {267this.notify(localize('applyCodeBlock.noCodeMapper', "No code mapper available."));268return undefined;269}270let editsProposed = false;271const cancellationTokenSource = new CancellationTokenSource();272try {273const iterable = await this.progressService.withProgress<AsyncIterable<[URI, TextEdit[]] | ICellEditOperation[]>>(274{ location: ProgressLocation.Notification, delay: 500, sticky: true, cancellable: true },275async progress => {276progress.report({ message: localize('applyCodeBlock.progress', "Applying code block using {0}...", codeMapper) });277const editsIterable = this.getNotebookEdits(codeBlock, chatSessionId, cancellationTokenSource.token);278return await this.waitForFirstElement(editsIterable);279},280() => cancellationTokenSource.cancel()281);282editsProposed = await this.applyNotebookEditsWithInlinePreview(iterable, uri, cancellationTokenSource);283} catch (e) {284if (!isCancellationError(e)) {285this.notify(localize('applyCodeBlock.error', "Failed to apply code block: {0}", e.message));286}287} finally {288cancellationTokenSource.dispose();289}290291return {292editsProposed,293codeMapper294};295}296297private async handleTextEditor(codeEditor: IActiveCodeEditor, chatSessionId: string | undefined, code: string, applyCodeBlockSuggestionId: EditSuggestionId | undefined): Promise<IComputeEditsResult | undefined> {298const activeModel = codeEditor.getModel();299if (isReadOnly(activeModel, this.textFileService)) {300this.notify(localize('applyCodeBlock.readonly', "Cannot apply code block to read-only file."));301return undefined;302}303304const codeBlock = { code, resource: activeModel.uri, chatSessionId, markdownBeforeBlock: undefined };305306const codeMapper = this.codeMapperService.providers[0]?.displayName;307if (!codeMapper) {308this.notify(localize('applyCodeBlock.noCodeMapper', "No code mapper available."));309return undefined;310}311let editsProposed = false;312const cancellationTokenSource = new CancellationTokenSource();313try {314const iterable = await this.progressService.withProgress<AsyncIterable<TextEdit[]>>(315{ location: ProgressLocation.Notification, delay: 500, sticky: true, cancellable: true },316async progress => {317progress.report({ message: localize('applyCodeBlock.progress', "Applying code block using {0}...", codeMapper) });318const editsIterable = this.getTextEdits(codeBlock, chatSessionId, cancellationTokenSource.token);319return await this.waitForFirstElement(editsIterable);320},321() => cancellationTokenSource.cancel()322);323editsProposed = await this.applyWithInlinePreview(iterable, codeEditor, cancellationTokenSource, applyCodeBlockSuggestionId);324} catch (e) {325if (!isCancellationError(e)) {326this.notify(localize('applyCodeBlock.error', "Failed to apply code block: {0}", e.message));327}328} finally {329cancellationTokenSource.dispose();330}331332return {333editsProposed,334codeMapper335};336}337338private getTextEdits(codeBlock: ICodeMapperCodeBlock, chatSessionId: string | undefined, token: CancellationToken): AsyncIterable<TextEdit[]> {339return new AsyncIterableObject<TextEdit[]>(async executor => {340const request: ICodeMapperRequest = {341codeBlocks: [codeBlock],342chatSessionId343};344const response: ICodeMapperResponse = {345textEdit: (target: URI, edit: TextEdit[]) => {346executor.emitOne(edit);347},348notebookEdit(_resource, _edit) {349//350},351};352const result = await this.codeMapperService.mapCode(request, response, token);353if (result?.errorMessage) {354executor.reject(new Error(result.errorMessage));355}356});357}358359private getNotebookEdits(codeBlock: ICodeMapperCodeBlock, chatSessionId: string | undefined, token: CancellationToken): AsyncIterable<[URI, TextEdit[]] | ICellEditOperation[]> {360return new AsyncIterableObject<[URI, TextEdit[]] | ICellEditOperation[]>(async executor => {361const request: ICodeMapperRequest = {362codeBlocks: [codeBlock],363chatSessionId,364location: 'panel'365};366const response: ICodeMapperResponse = {367textEdit: (target: URI, edits: TextEdit[]) => {368executor.emitOne([target, edits]);369},370notebookEdit(_resource, edit) {371executor.emitOne(edit);372},373};374const result = await this.codeMapperService.mapCode(request, response, token);375if (result?.errorMessage) {376executor.reject(new Error(result.errorMessage));377}378});379}380381private async waitForFirstElement<T>(iterable: AsyncIterable<T>): Promise<AsyncIterable<T>> {382const iterator = iterable[Symbol.asyncIterator]();383let result = await iterator.next();384385if (result.done) {386return {387async *[Symbol.asyncIterator]() {388return;389}390};391}392393return {394async *[Symbol.asyncIterator]() {395while (!result.done) {396yield result.value;397result = await iterator.next();398}399}400};401}402403private async applyWithInlinePreview(edits: AsyncIterable<TextEdit[]>, codeEditor: IActiveCodeEditor, tokenSource: CancellationTokenSource, applyCodeBlockSuggestionId: EditSuggestionId | undefined): Promise<boolean> {404return this.instantiationService.invokeFunction(reviewEdits, codeEditor, edits, tokenSource.token, applyCodeBlockSuggestionId);405}406407private async applyNotebookEditsWithInlinePreview(edits: AsyncIterable<[URI, TextEdit[]] | ICellEditOperation[]>, uri: URI, tokenSource: CancellationTokenSource): Promise<boolean> {408return this.instantiationService.invokeFunction(reviewNotebookEdits, uri, edits, tokenSource.token);409}410411private tryToRevealCodeBlock(codeEditor: IActiveCodeEditor, codeBlock: string): void {412const match = codeBlock.match(/(\S[^\n]*)\n/); // substring that starts with a non-whitespace character and ends with a newline413if (match && match[1].length > 10) {414const findMatch = codeEditor.getModel().findNextMatch(match[1], { lineNumber: 1, column: 1 }, false, false, null, false);415if (findMatch) {416codeEditor.revealRangeInCenter(findMatch.range);417}418}419}420421private notify(message: string) {422//this.notificationService.notify({ severity: Severity.Info, message });423this.dialogService.info(message);424}425426}427428function notifyUserAction(chatService: IChatService, context: ICodeBlockActionContext, action: ChatUserAction) {429if (isResponseVM(context.element)) {430chatService.notifyUserAction({431agentId: context.element.agent?.id,432command: context.element.slashCommand?.name,433sessionId: context.element.sessionId,434requestId: context.element.requestId,435result: context.element.result,436action437});438}439}440441function getActiveNotebookEditor(editorService: IEditorService): IActiveNotebookEditor | undefined {442const activeEditorPane = editorService.activeEditorPane;443if (activeEditorPane?.getId() === NOTEBOOK_EDITOR_ID) {444const notebookEditor = activeEditorPane.getControl() as INotebookEditor;445if (notebookEditor.hasModel()) {446return notebookEditor;447}448}449return undefined;450}451452function getEditableActiveCodeEditor(editorService: IEditorService): IActiveCodeEditor | undefined {453const activeCodeEditorInNotebook = getActiveNotebookEditor(editorService)?.activeCodeEditor;454if (activeCodeEditorInNotebook && activeCodeEditorInNotebook.hasTextFocus() && activeCodeEditorInNotebook.hasModel()) {455return activeCodeEditorInNotebook;456}457458let codeEditor = getCodeEditor(editorService.activeTextEditorControl);459if (!codeEditor) {460for (const editor of editorService.visibleTextEditorControls) {461codeEditor = getCodeEditor(editor);462if (codeEditor) {463break;464}465}466}467468if (!codeEditor || !codeEditor.hasModel()) {469return undefined;470}471return codeEditor;472}473474function isReadOnly(model: ITextModel, textFileService: ITextFileService): boolean {475// Check if model is editable, currently only support untitled and text file476const activeTextModel = textFileService.files.get(model.uri) ?? textFileService.untitled.get(model.uri);477return !!activeTextModel?.isReadonly();478}479480function reindent(codeBlockContent: string, model: ITextModel, seletionStartLine: number): string {481const newContent = strings.splitLines(codeBlockContent);482if (newContent.length === 0) {483return codeBlockContent;484}485486const formattingOptions = model.getFormattingOptions();487const codeIndentLevel = computeIndentation(model.getLineContent(seletionStartLine), formattingOptions.tabSize).level;488489const indents = newContent.map(line => computeIndentation(line, formattingOptions.tabSize));490491// find the smallest indent level in the code block492const newContentIndentLevel = indents.reduce<number>((min, indent, index) => {493if (indent.length !== newContent[index].length) { // ignore empty lines494return Math.min(indent.level, min);495}496return min;497}, Number.MAX_VALUE);498499if (newContentIndentLevel === Number.MAX_VALUE || newContentIndentLevel === codeIndentLevel) {500// all lines are empty or the indent is already correct501return codeBlockContent;502}503const newLines = [];504for (let i = 0; i < newContent.length; i++) {505const { level, length } = indents[i];506const newLevel = Math.max(0, codeIndentLevel + level - newContentIndentLevel);507const newIndentation = formattingOptions.insertSpaces ? ' '.repeat(formattingOptions.tabSize * newLevel) : '\t'.repeat(newLevel);508newLines.push(newIndentation + newContent[i].substring(length));509}510return newLines.join('\n');511}512513/**514* Returns:515* - level: the line's the ident level in tabs516* - length: the number of characters of the leading whitespace517*/518export function computeIndentation(line: string, tabSize: number): { level: number; length: number } {519let nSpaces = 0;520let level = 0;521let i = 0;522let length = 0;523const len = line.length;524while (i < len) {525const chCode = line.charCodeAt(i);526if (chCode === CharCode.Space) {527nSpaces++;528if (nSpaces === tabSize) {529level++;530nSpaces = 0;531length = i + 1;532}533} else if (chCode === CharCode.Tab) {534level++;535nSpaces = 0;536length = i + 1;537} else {538break;539}540i++;541}542return { level, length };543}544545546