Path: blob/main/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts
5240 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 { AsyncIterableProducer } 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/editing/chatCodeMapperService.js';37import { ChatUserAction, IChatService } from '../../common/chatService/chatService.js';38import { IChatRequestViewModel, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js';39import { ICodeBlockActionContext } from '../widget/chatContentParts/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,91source: undefined,92});93}94}9596private async handleNotebookEditor(notebookEditor: IActiveNotebookEditor, codeBlockContext: ICodeBlockActionContext): Promise<boolean> {97if (notebookEditor.isReadOnly) {98this.notify(localize('insertCodeBlock.readonlyNotebook', "Cannot insert the code block to read-only notebook editor."));99return false;100}101const focusRange = notebookEditor.getFocus();102const next = Math.max(focusRange.end - 1, 0);103insertCell(this.languageService, notebookEditor, next, CellKind.Code, 'below', codeBlockContext.code, true);104return true;105}106107private async handleTextEditor(codeEditor: IActiveCodeEditor, codeBlockContext: ICodeBlockActionContext): Promise<boolean> {108const activeModel = codeEditor.getModel();109if (isReadOnly(activeModel, this.textFileService)) {110this.notify(localize('insertCodeBlock.readonly', "Cannot insert the code block to read-only code editor."));111return false;112}113114const range = codeEditor.getSelection() ?? new Range(activeModel.getLineCount(), 1, activeModel.getLineCount(), 1);115const text = reindent(codeBlockContext.code, activeModel, range.startLineNumber);116117const edits = [new ResourceTextEdit(activeModel.uri, { range, text })];118await this.bulkEditService.apply(edits);119this.codeEditorService.listCodeEditors().find(editor => editor.getModel()?.uri.toString() === activeModel.uri.toString())?.focus();120return true;121}122123private notify(message: string) {124//this.notificationService.notify({ severity: Severity.Info, message });125this.dialogService.info(message);126}127}128129type IComputeEditsResult = { readonly editsProposed: boolean; readonly codeMapper?: string };130131export class ApplyCodeBlockOperation {132133constructor(134@IEditorService private readonly editorService: IEditorService,135@ITextFileService private readonly textFileService: ITextFileService,136@IChatService private readonly chatService: IChatService,137@IFileService private readonly fileService: IFileService,138@IDialogService private readonly dialogService: IDialogService,139@ILogService private readonly logService: ILogService,140@ICodeMapperService private readonly codeMapperService: ICodeMapperService,141@IProgressService private readonly progressService: IProgressService,142@IQuickInputService private readonly quickInputService: IQuickInputService,143@ILabelService private readonly labelService: ILabelService,144@IInstantiationService private readonly instantiationService: IInstantiationService,145@INotebookService private readonly notebookService: INotebookService,146) {147}148149public async run(context: ICodeBlockActionContext): Promise<void> {150let activeEditorControl = getEditableActiveCodeEditor(this.editorService);151152const codemapperUri = await this.evaluateURIToUse(context.codemapperUri, activeEditorControl);153if (!codemapperUri) {154return;155}156157if (codemapperUri && !isEqual(activeEditorControl?.getModel().uri, codemapperUri) && !this.notebookService.hasSupportedNotebooks(codemapperUri)) {158// reveal the target file159try {160const editorPane = await this.editorService.openEditor({ resource: codemapperUri });161const codeEditor = getCodeEditor(editorPane?.getControl());162if (codeEditor && codeEditor.hasModel()) {163this.tryToRevealCodeBlock(codeEditor, context.code);164activeEditorControl = codeEditor;165} else {166this.notify(localize('applyCodeBlock.errorOpeningFile', "Failed to open {0} in a code editor.", codemapperUri.toString()));167return;168}169} catch (e) {170this.logService.info('[ApplyCodeBlockOperation] error opening code mapper file', codemapperUri, e);171return;172}173}174175let codeBlockSuggestionId: EditSuggestionId | undefined = undefined;176177if (isResponseVM(context.element)) {178const codeBlockInfo = context.element.model.codeBlockInfos?.at(context.codeBlockIndex);179if (codeBlockInfo) {180codeBlockSuggestionId = codeBlockInfo.suggestionId;181}182}183184let result: IComputeEditsResult | undefined = undefined;185186if (activeEditorControl && !this.notebookService.hasSupportedNotebooks(codemapperUri)) {187result = await this.handleTextEditor(activeEditorControl, context.chatSessionResource, context.code, codeBlockSuggestionId);188} else {189const activeNotebookEditor = getActiveNotebookEditor(this.editorService);190if (activeNotebookEditor) {191result = await this.handleNotebookEditor(activeNotebookEditor, context.chatSessionResource, context.code);192} else {193this.notify(localize('applyCodeBlock.noActiveEditor', "To apply this code block, open a code or notebook editor."));194}195}196197if (isResponseVM(context.element)) {198const requestId = context.element.requestId;199const request = context.element.session.getItems().find(item => item.id === requestId && isRequestVM(item)) as IChatRequestViewModel | undefined;200notifyUserAction(this.chatService, context, {201kind: 'apply',202codeBlockIndex: context.codeBlockIndex,203totalCharacters: context.code.length,204codeMapper: result?.codeMapper,205editsProposed: !!result?.editsProposed,206totalLines: context.code.split('\n').length,207modelId: request?.modelId ?? '',208languageId: context.languageId,209});210}211}212213private async evaluateURIToUse(resource: URI | undefined, activeEditorControl: IActiveCodeEditor | undefined): Promise<URI | undefined> {214if (resource && await this.fileService.exists(resource)) {215return resource;216}217218const activeEditorOption = activeEditorControl?.getModel().uri ? { label: localize('activeEditor', "Active editor '{0}'", this.labelService.getUriLabel(activeEditorControl.getModel().uri, { relative: true })), id: 'activeEditor' } : undefined;219const untitledEditorOption = { label: localize('newUntitledFile', "New untitled editor"), id: 'newUntitledFile' };220221const options = [];222if (resource) {223// code block had an URI, but it doesn't exist224options.push({ label: localize('createFile', "New file '{0}'", this.labelService.getUriLabel(resource, { relative: true })), id: 'createFile' });225options.push(untitledEditorOption);226if (activeEditorOption) {227options.push(activeEditorOption);228}229} else {230// code block had no URI231if (activeEditorOption) {232options.push(activeEditorOption);233}234options.push(untitledEditorOption);235}236237const selected = options.length > 1 ? await this.quickInputService.pick(options, { placeHolder: localize('selectOption', "Select where to apply the code block") }) : options[0];238if (selected) {239switch (selected.id) {240case 'createFile':241if (resource) {242try {243await this.fileService.writeFile(resource, VSBuffer.fromString(''));244} catch (error) {245this.notify(localize('applyCodeBlock.fileWriteError', "Failed to create file: {0}", error.message));246return URI.from({ scheme: 'untitled', path: resource.path });247}248}249return resource;250case 'newUntitledFile':251return URI.from({ scheme: 'untitled', path: resource ? resource.path : 'Untitled-1' });252case 'activeEditor':253return activeEditorControl?.getModel().uri;254}255}256return undefined;257}258259private async handleNotebookEditor(notebookEditor: IActiveNotebookEditor, chatSessionResource: URI | undefined, code: string): Promise<IComputeEditsResult | undefined> {260if (notebookEditor.isReadOnly) {261this.notify(localize('applyCodeBlock.readonlyNotebook', "Cannot apply code block to read-only notebook editor."));262return undefined;263}264const uri = notebookEditor.textModel.uri;265const codeBlock = { code, resource: uri, markdownBeforeBlock: undefined };266const codeMapper = this.codeMapperService.providers[0]?.displayName;267if (!codeMapper) {268this.notify(localize('applyCodeBlock.noCodeMapper', "No code mapper available."));269return undefined;270}271let editsProposed = false;272const cancellationTokenSource = new CancellationTokenSource();273try {274const iterable = await this.progressService.withProgress<AsyncIterable<[URI, TextEdit[]] | ICellEditOperation[]>>(275{ location: ProgressLocation.Notification, delay: 500, sticky: true, cancellable: true },276async progress => {277progress.report({ message: localize('applyCodeBlock.progress', "Applying code block using {0}...", codeMapper) });278const editsIterable = this.getNotebookEdits(codeBlock, chatSessionResource, cancellationTokenSource.token);279return await this.waitForFirstElement(editsIterable);280},281() => cancellationTokenSource.cancel()282);283editsProposed = await this.applyNotebookEditsWithInlinePreview(iterable, uri, cancellationTokenSource);284} catch (e) {285if (!isCancellationError(e)) {286this.notify(localize('applyCodeBlock.error', "Failed to apply code block: {0}", e.message));287}288} finally {289cancellationTokenSource.dispose();290}291292return {293editsProposed,294codeMapper295};296}297298private async handleTextEditor(codeEditor: IActiveCodeEditor, chatSessionResource: URI | undefined, code: string, applyCodeBlockSuggestionId: EditSuggestionId | undefined): Promise<IComputeEditsResult | undefined> {299const activeModel = codeEditor.getModel();300if (isReadOnly(activeModel, this.textFileService)) {301this.notify(localize('applyCodeBlock.readonly', "Cannot apply code block to read-only file."));302return undefined;303}304305const codeBlock = { code, resource: activeModel.uri, chatSessionResource, markdownBeforeBlock: undefined };306307const codeMapper = this.codeMapperService.providers[0]?.displayName;308if (!codeMapper) {309this.notify(localize('applyCodeBlock.noCodeMapper', "No code mapper available."));310return undefined;311}312let editsProposed = false;313const cancellationTokenSource = new CancellationTokenSource();314try {315const iterable = await this.progressService.withProgress<AsyncIterable<TextEdit[]>>(316{ location: ProgressLocation.Notification, delay: 500, sticky: true, cancellable: true },317async progress => {318progress.report({ message: localize('applyCodeBlock.progress', "Applying code block using {0}...", codeMapper) });319const editsIterable = this.getTextEdits(codeBlock, chatSessionResource, cancellationTokenSource.token);320return await this.waitForFirstElement(editsIterable);321},322() => cancellationTokenSource.cancel()323);324editsProposed = await this.applyWithInlinePreview(iterable, codeEditor, cancellationTokenSource, applyCodeBlockSuggestionId);325} catch (e) {326if (!isCancellationError(e)) {327this.notify(localize('applyCodeBlock.error', "Failed to apply code block: {0}", e.message));328}329} finally {330cancellationTokenSource.dispose();331}332333return {334editsProposed,335codeMapper336};337}338339private getTextEdits(codeBlock: ICodeMapperCodeBlock, chatSessionResource: URI | undefined, token: CancellationToken): AsyncIterable<TextEdit[]> {340return new AsyncIterableProducer<TextEdit[]>(async executor => {341const request: ICodeMapperRequest = {342codeBlocks: [codeBlock],343chatSessionResource,344};345const response: ICodeMapperResponse = {346textEdit: (target: URI, edit: TextEdit[]) => {347executor.emitOne(edit);348},349notebookEdit(_resource, _edit) {350//351},352};353const result = await this.codeMapperService.mapCode(request, response, token);354if (result?.errorMessage) {355executor.reject(new Error(result.errorMessage));356}357});358}359360private getNotebookEdits(codeBlock: ICodeMapperCodeBlock, chatSessionResource: URI | undefined, token: CancellationToken): AsyncIterable<[URI, TextEdit[]] | ICellEditOperation[]> {361return new AsyncIterableProducer<[URI, TextEdit[]] | ICellEditOperation[]>(async executor => {362const request: ICodeMapperRequest = {363codeBlocks: [codeBlock],364chatSessionResource,365location: 'panel'366};367const response: ICodeMapperResponse = {368textEdit: (target: URI, edits: TextEdit[]) => {369executor.emitOne([target, edits]);370},371notebookEdit(_resource, edit) {372executor.emitOne(edit);373},374};375const result = await this.codeMapperService.mapCode(request, response, token);376if (result?.errorMessage) {377executor.reject(new Error(result.errorMessage));378}379});380}381382private async waitForFirstElement<T>(iterable: AsyncIterable<T>): Promise<AsyncIterable<T>> {383const iterator = iterable[Symbol.asyncIterator]();384let result = await iterator.next();385386if (result.done) {387return {388async *[Symbol.asyncIterator]() {389return;390}391};392}393394return {395async *[Symbol.asyncIterator]() {396while (!result.done) {397yield result.value;398result = await iterator.next();399}400}401};402}403404private async applyWithInlinePreview(edits: AsyncIterable<TextEdit[]>, codeEditor: IActiveCodeEditor, tokenSource: CancellationTokenSource, applyCodeBlockSuggestionId: EditSuggestionId | undefined): Promise<boolean> {405return this.instantiationService.invokeFunction(reviewEdits, codeEditor, edits, tokenSource.token, applyCodeBlockSuggestionId);406}407408private async applyNotebookEditsWithInlinePreview(edits: AsyncIterable<[URI, TextEdit[]] | ICellEditOperation[]>, uri: URI, tokenSource: CancellationTokenSource): Promise<boolean> {409return this.instantiationService.invokeFunction(reviewNotebookEdits, uri, edits, tokenSource.token);410}411412private tryToRevealCodeBlock(codeEditor: IActiveCodeEditor, codeBlock: string): void {413const match = codeBlock.match(/(\S[^\n]*)\n/); // substring that starts with a non-whitespace character and ends with a newline414if (match && match[1].length > 10) {415const findMatch = codeEditor.getModel().findNextMatch(match[1], { lineNumber: 1, column: 1 }, false, false, null, false);416if (findMatch) {417codeEditor.revealRangeInCenter(findMatch.range);418}419}420}421422private notify(message: string) {423//this.notificationService.notify({ severity: Severity.Info, message });424this.dialogService.info(message);425}426427}428429function notifyUserAction(chatService: IChatService, context: ICodeBlockActionContext, action: ChatUserAction) {430if (isResponseVM(context.element)) {431chatService.notifyUserAction({432agentId: context.element.agent?.id,433command: context.element.slashCommand?.name,434sessionResource: context.element.sessionResource,435requestId: context.element.requestId,436result: context.element.result,437action438});439}440}441442function getActiveNotebookEditor(editorService: IEditorService): IActiveNotebookEditor | undefined {443const activeEditorPane = editorService.activeEditorPane;444if (activeEditorPane?.getId() === NOTEBOOK_EDITOR_ID) {445const notebookEditor = activeEditorPane.getControl() as INotebookEditor;446if (notebookEditor.hasModel()) {447return notebookEditor;448}449}450return undefined;451}452453function getEditableActiveCodeEditor(editorService: IEditorService): IActiveCodeEditor | undefined {454const activeCodeEditorInNotebook = getActiveNotebookEditor(editorService)?.activeCodeEditor;455if (activeCodeEditorInNotebook && activeCodeEditorInNotebook.hasTextFocus() && activeCodeEditorInNotebook.hasModel()) {456return activeCodeEditorInNotebook;457}458459let codeEditor = getCodeEditor(editorService.activeTextEditorControl);460if (!codeEditor) {461for (const editor of editorService.visibleTextEditorControls) {462codeEditor = getCodeEditor(editor);463if (codeEditor) {464break;465}466}467}468469if (!codeEditor || !codeEditor.hasModel()) {470return undefined;471}472return codeEditor;473}474475function isReadOnly(model: ITextModel, textFileService: ITextFileService): boolean {476// Check if model is editable, currently only support untitled and text file477const activeTextModel = textFileService.files.get(model.uri) ?? textFileService.untitled.get(model.uri);478return !!activeTextModel?.isReadonly();479}480481function reindent(codeBlockContent: string, model: ITextModel, seletionStartLine: number): string {482const newContent = strings.splitLines(codeBlockContent);483if (newContent.length === 0) {484return codeBlockContent;485}486487const formattingOptions = model.getFormattingOptions();488const codeIndentLevel = computeIndentation(model.getLineContent(seletionStartLine), formattingOptions.tabSize).level;489490const indents = newContent.map(line => computeIndentation(line, formattingOptions.tabSize));491492// find the smallest indent level in the code block493const newContentIndentLevel = indents.reduce<number>((min, indent, index) => {494if (indent.length !== newContent[index].length) { // ignore empty lines495return Math.min(indent.level, min);496}497return min;498}, Number.MAX_VALUE);499500if (newContentIndentLevel === Number.MAX_VALUE || newContentIndentLevel === codeIndentLevel) {501// all lines are empty or the indent is already correct502return codeBlockContent;503}504const newLines = [];505for (let i = 0; i < newContent.length; i++) {506const { level, length } = indents[i];507const newLevel = Math.max(0, codeIndentLevel + level - newContentIndentLevel);508const newIndentation = formattingOptions.insertSpaces ? ' '.repeat(formattingOptions.tabSize * newLevel) : '\t'.repeat(newLevel);509newLines.push(newIndentation + newContent[i].substring(length));510}511return newLines.join('\n');512}513514/**515* Returns:516* - level: the line's the ident level in tabs517* - length: the number of characters of the leading whitespace518*/519export function computeIndentation(line: string, tabSize: number): { level: number; length: number } {520let nSpaces = 0;521let level = 0;522let i = 0;523let length = 0;524const len = line.length;525while (i < len) {526const chCode = line.charCodeAt(i);527if (chCode === CharCode.Space) {528nSpaces++;529if (nSpaces === tabSize) {530level++;531nSpaces = 0;532length = i + 1;533}534} else if (chCode === CharCode.Tab) {535level++;536nSpaces = 0;537length = i + 1;538} else {539break;540}541i++;542}543return { level, length };544}545546547