diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 61bda2cda0d..96c3e1380c0 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1915,6 +1915,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ChatResponseWarningPart: extHostTypes.ChatResponseWarningPart, ChatResponseTextEditPart: extHostTypes.ChatResponseTextEditPart, ChatResponseNotebookEditPart: extHostTypes.ChatResponseNotebookEditPart, + ChatResponseWorkspaceEditPart: extHostTypes.ChatResponseWorkspaceEditPart, ChatResponseMarkdownWithVulnerabilitiesPart: extHostTypes.ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseCommandButtonPart: extHostTypes.ChatResponseCommandButtonPart, ChatResponseConfirmationPart: extHostTypes.ChatResponseConfirmationPart, diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index a55f2238694..bc91aeec37d 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -280,6 +280,15 @@ export class ChatAgentResponseStream { _report(dto); return this; }, + workspaceEdit(edits) { + throwIfDone(this.workspaceEdit); + checkProposedApiEnabled(that._extension, 'chatParticipantAdditions'); + + const part = new extHostTypes.ChatResponseWorkspaceEditPart(edits); + const dto = typeConvert.ChatResponseWorkspaceEditPart.from(part); + _report(dto); + return this; + }, async externalEdit(target, callback) { throwIfDone(this.externalEdit); const resources = Array.isArray(target) ? target : [target]; diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index b49df82b3bb..10f15a6c485 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -42,7 +42,7 @@ import { IViewBadge } from '../../common/views.js'; import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/participants/chatAgents.js'; import { IChatRequestDraft } from '../../contrib/chat/common/editing/chatEditingService.js'; import { IChatRequestModeInstructions } from '../../contrib/chat/common/model/chatModel.js'; -import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatFollowup, IChatMarkdownContent, IChatMoveMessage, IChatMultiDiffDataSerialized, IChatProgressMessage, IChatPullRequestContent, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTextEdit, IChatThinkingPart, IChatToolInvocationSerialized, IChatTreeData, IChatUserActionEvent, IChatWarningMessage } from '../../contrib/chat/common/chatService/chatService.js'; +import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatFollowup, IChatMarkdownContent, IChatMoveMessage, IChatMultiDiffDataSerialized, IChatProgressMessage, IChatPullRequestContent, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTextEdit, IChatThinkingPart, IChatToolInvocationSerialized, IChatTreeData, IChatUserActionEvent, IChatWarningMessage, IChatWorkspaceEdit } from '../../contrib/chat/common/chatService/chatService.js'; import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImageVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; @@ -2991,6 +2991,18 @@ export namespace ChatResponseNotebookEditPart { } } +export namespace ChatResponseWorkspaceEditPart { + export function from(part: vscode.ChatResponseWorkspaceEditPart): IChatWorkspaceEdit { + return { + kind: 'workspaceEdit', + edits: part.edits.map(e => ({ + oldResource: e.oldResource, + newResource: e.newResource, + })), + }; + } +} + export namespace ChatResponseReferencePart { export function from(part: types.ChatResponseReferencePart): Dto { const iconPath = ThemeIcon.isThemeIcon(part.iconPath) ? part.iconPath @@ -3090,6 +3102,8 @@ export namespace ChatResponsePart { return ChatResponsePullRequestPart.from(part); } else if (part instanceof types.ChatToolInvocationPart) { return ChatToolInvocationPart.from(part); + } else if (part instanceof types.ChatResponseWorkspaceEditPart) { + return ChatResponseWorkspaceEditPart.from(part); } return { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 1efc19c563c..b4f2638fc95 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3340,6 +3340,13 @@ export class ChatResponseNotebookEditPart implements vscode.ChatResponseNotebook } } +export class ChatResponseWorkspaceEditPart implements vscode.ChatResponseWorkspaceEditPart { + edits: vscode.ChatWorkspaceFileEdit[]; + constructor(edits: vscode.ChatWorkspaceFileEdit[]) { + this.edits = edits; + } +} + export interface ChatTerminalToolInvocationData2 { commandLine: { original: string; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingDeletedFileEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingDeletedFileEntry.ts new file mode 100644 index 00000000000..2e14eb81176 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingDeletedFileEntry.ts @@ -0,0 +1,274 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { constObservable, IObservable, ITransaction, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { LineRange } from '../../../../../editor/common/core/ranges/lineRange.js'; +import { IDocumentDiff } from '../../../../../editor/common/diff/documentDiffProvider.js'; +import { DetailedLineRangeMapping } from '../../../../../editor/common/diff/rangeMapping.js'; +import { TextEdit } from '../../../../../editor/common/languages.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { createTextBufferFactoryFromSnapshot } from '../../../../../editor/common/model/textModel.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IUndoRedoElement, IUndoRedoService, UndoRedoElementType } from '../../../../../platform/undoRedo/common/undoRedo.js'; +import { IEditorPane } from '../../../../common/editor.js'; +import { IFilesConfigurationService } from '../../../../services/filesConfiguration/common/filesConfigurationService.js'; +import { stringToSnapshot } from '../../../../services/textfile/common/textfiles.js'; +import { IAiEditTelemetryService } from '../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js'; +import { ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; +import { IChatService } from '../../common/chatService/chatService.js'; +import { ChatEditKind, IModifiedEntryTelemetryInfo, IModifiedFileEntry, IModifiedFileEntryEditorIntegration, ISnapshotEntry, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; +import { IChatResponseModel } from '../../common/model/chatModel.js'; +import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js'; +import { ChatEditingTextModelContentProvider } from './chatEditingTextModelContentProviders.js'; + +interface IMultiDiffEntryDelegate { + collapse: (transaction: ITransaction | undefined) => void; +} + +/** + * Represents a file that has been deleted by the chat editing session. + * Unlike ChatEditingModifiedDocumentEntry, this doesn't maintain a live model + * since the file no longer exists on disk. + */ +export class ChatEditingDeletedFileEntry extends AbstractChatEditingModifiedFileEntry implements IModifiedFileEntry { + + readonly initialContent: string; + + /** + * The original content before deletion, stored for diff display and potential restoration. + */ + private readonly _originalContent: string; + + /** + * Lazily created model for the original content (for diff display). + */ + private _originalModel: ITextModel | undefined; + + /** + * Lazily created empty model representing the deleted state (for diff display). + */ + private _modifiedModel: ITextModel | undefined; + + readonly originalURI: URI; + + readonly diffInfo: IObservable; + readonly linesAdded: IObservable = constObservable(0); + readonly linesRemoved: IObservable; + + private readonly _changesCount = observableValue(this, 1); + override readonly changesCount = this._changesCount; + readonly isDeletion = true; + + constructor( + resource: URI, + originalContent: string, + private readonly _multiDiffEntryDelegate: IMultiDiffEntryDelegate, + telemetryInfo: IModifiedEntryTelemetryInfo, + private readonly _languageId: string, + @IModelService private readonly _modelService: IModelService, + @ILanguageService private readonly _languageService: ILanguageService, + @IConfigurationService configService: IConfigurationService, + @IFilesConfigurationService fileConfigService: IFilesConfigurationService, + @IChatService chatService: IChatService, + @IFileService fileService: IFileService, + @IUndoRedoService undoRedoService: IUndoRedoService, + @IInstantiationService instantiationService: IInstantiationService, + @IAiEditTelemetryService aiEditTelemetryService: IAiEditTelemetryService, + ) { + super( + resource, + telemetryInfo, + ChatEditKind.Deleted, + configService, + fileConfigService, + chatService, + fileService, + undoRedoService, + instantiationService, + aiEditTelemetryService, + ); + + this._originalContent = originalContent; + this.initialContent = originalContent; + this.originalURI = ChatEditingTextModelContentProvider.getFileURI(telemetryInfo.sessionResource, this.entryId, resource.path); + this.diffInfo = constObservable(this._diffInfo()); + this.linesRemoved = constObservable(this._getOrCreateOriginalModel().getLineCount()); + } + + override dispose(): void { + this._originalModel?.dispose(); + this._modifiedModel?.dispose(); + super.dispose(); + } + + /** + * Gets or creates the original model for diff display. + */ + private _getOrCreateOriginalModel(): ITextModel { + if (!this._originalModel || this._originalModel.isDisposed()) { + this._originalModel = this._modelService.createModel( + createTextBufferFactoryFromSnapshot(stringToSnapshot(this._originalContent)), + this._languageService.createById(this._languageId), + this.originalURI, + false + ); + } + return this._originalModel; + } + + /** + * Gets or creates an empty model representing the deleted state. + */ + private _getOrCreateModifiedModel(): ITextModel { + if (!this._modifiedModel || this._modifiedModel.isDisposed()) { + // Create empty model - file is deleted so content is empty + this._modifiedModel = this._modelService.createModel( + '', + this._languageService.createById(this._languageId), + this.modifiedURI.with({ scheme: 'deleted-file' }), + false + ); + } + return this._modifiedModel; + } + + private _diffInfo() { + // For deleted files, return a simple diff showing all content removed + const originalModel = this._getOrCreateOriginalModel(); + this._getOrCreateModifiedModel(); // Ensure the modified model exists for the diff view + const originalLineCount = originalModel.getLineCount(); + + return { + changes: [new DetailedLineRangeMapping( + new LineRange(1, originalLineCount + 1), + new LineRange(1, 1), + undefined + )], + quitEarly: false, + identical: false, + moves: [] + }; + } + + getDiffInfo(): Promise { + return Promise.resolve(this._diffInfo()); + } + + equalsSnapshot(snapshot: ISnapshotEntry | undefined): boolean { + return !!snapshot && + this.modifiedURI.toString() === snapshot.resource.toString() && + this._languageId === snapshot.languageId && + this._originalContent === snapshot.original && + snapshot.current === '' && + this.state.get() === snapshot.state; + } + + createSnapshot(chatSessionResource: URI, requestId: string | undefined, undoStop: string | undefined): ISnapshotEntry { + return { + resource: this.modifiedURI, + languageId: this._languageId, + snapshotUri: this.originalURI, + original: this._originalContent, + current: '', // File is deleted, so current content is empty + state: this.state.get(), + telemetryInfo: this._telemetryInfo, + isDeleted: true, + }; + } + + async restoreFromSnapshot(snapshot: ISnapshotEntry, restoreToDisk = true): Promise { + this._stateObs.set(snapshot.state, undefined); + + if (restoreToDisk && snapshot.current !== '') { + // Restore file to disk with the snapshot content + await this._fileService.writeFile(this.modifiedURI, VSBuffer.fromString(snapshot.current)); + } + } + + async resetToInitialContent(): Promise { + // Restore the file with original content + await this._fileService.writeFile(this.modifiedURI, VSBuffer.fromString(this._originalContent)); + } + + protected override async _areOriginalAndModifiedIdentical(): Promise { + // A deleted file is never identical to its original (unless original was empty) + return this._originalContent === ''; + } + + protected override _createUndoRedoElement(response: IChatResponseModel): IUndoRedoElement { + return { + type: UndoRedoElementType.Resource, + resource: this.modifiedURI, + label: 'Chat File Deletion', + code: 'chat.delete', + undo: async () => { + // Restore the file + await this._fileService.writeFile(this.modifiedURI, VSBuffer.fromString(this._originalContent)); + }, + redo: async () => { + // Delete the file again + await this._fileService.del(this.modifiedURI, { useTrash: false }); + } + }; + } + + async acceptAgentEdits(_uri: URI, _edits: (TextEdit | ICellEditOperation)[], isLastEdits: boolean, _responseModel: IChatResponseModel | undefined): Promise { + // For deleted files, there are no incremental edits - the file is just deleted + transaction((tx) => { + this._waitsForLastEdits.set(!isLastEdits, tx); + this._stateObs.set(ModifiedFileEntryState.Modified, tx); + + if (isLastEdits) { + this._resetEditsState(tx); + this._rewriteRatioObs.set(1, tx); + } + }); + } + + protected override async _doAccept(): Promise { + // File deletion is already done - just collapse the entry + this._multiDiffEntryDelegate.collapse(undefined); + } + + protected override async _doReject(): Promise { + // Restore the file from original content + await this._fileService.writeFile(this.modifiedURI, VSBuffer.fromString(this._originalContent)); + this._multiDiffEntryDelegate.collapse(undefined); + } + + protected _createEditorIntegration(_editor: IEditorPane): IModifiedFileEntryEditorIntegration { + // Deleted files don't need complex editor integration since there's nothing to navigate + return { + currentIndex: observableValue(this, 0), + reveal: () => { }, + next: () => false, + previous: () => false, + enableAccessibleDiffView: () => { }, + acceptNearestChange: async () => { }, + rejectNearestChange: async () => { }, + toggleDiff: async () => { }, + dispose: () => { } + }; + } + + async computeEditsFromSnapshots(_beforeSnapshot: string, _afterSnapshot: string): Promise<(TextEdit | ICellEditOperation)[]> { + // For deleted files, we don't compute incremental edits + return []; + } + + async save(): Promise { + // Nothing to save - file is deleted + } + + async revertToDisk(): Promise { + // Nothing to revert - file is deleted + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedDocumentEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedDocumentEntry.ts index 7dd93ca2e07..f35b828ff1f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedDocumentEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedDocumentEntry.ts @@ -12,7 +12,7 @@ import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; import { TextEdit as EditorTextEdit } from '../../../../../editor/common/core/edits/textEdit.js'; import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; import { IDocumentDiff } from '../../../../../editor/common/diff/documentDiffProvider.js'; -import { Location, TextEdit } from '../../../../../editor/common/languages.js'; +import { TextEdit } from '../../../../../editor/common/languages.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { ITextModel } from '../../../../../editor/common/model.js'; import { SingleModelEditStackElement } from '../../../../../editor/common/model/editStack.js'; @@ -31,9 +31,9 @@ import { IFilesConfigurationService } from '../../../../services/filesConfigurat import { ITextFileService, isTextFileEditorModel, stringToSnapshot } from '../../../../services/textfile/common/textfiles.js'; import { IAiEditTelemetryService } from '../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js'; import { ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; +import { IChatService } from '../../common/chatService/chatService.js'; import { ChatEditKind, IModifiedEntryTelemetryInfo, IModifiedFileEntry, IModifiedFileEntryEditorIntegration, ISnapshotEntry, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { IChatResponseModel } from '../../common/model/chatModel.js'; -import { IChatService } from '../../common/chatService/chatService.js'; import { ChatEditingCodeEditorIntegration } from './chatEditingCodeEditorIntegration.js'; import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js'; import { ChatEditingTextModelChangeService } from './chatEditingTextModelChangeService.js'; @@ -209,10 +209,6 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie return this.modifiedModel.getValue(); } - public override hasModificationAt(location: Location): boolean { - return location.uri.toString() === this.modifiedModel.uri.toString() && this._textModelChangeService.hasHunkAt(location.range); - } - async restoreFromSnapshot(snapshot: ISnapshotEntry, restoreToDisk = true) { this._stateObs.set(snapshot.state, undefined); await this._textModelChangeService.resetDocumentValues(snapshot.original, restoreToDisk ? snapshot.current : undefined); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts index f4150e54b48..2b02a675551 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts @@ -10,7 +10,7 @@ import { Schemas } from '../../../../../base/common/network.js'; import { clamp } from '../../../../../base/common/numbers.js'; import { autorun, derived, IObservable, ITransaction, observableValue, observableValueOpts, transaction } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; -import { Location, TextEdit } from '../../../../../editor/common/languages.js'; +import { TextEdit } from '../../../../../editor/common/languages.js'; import { EditDeltaInfo } from '../../../../../editor/common/textModelEditSource.js'; import { localize } from '../../../../../nls.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -23,9 +23,9 @@ import { IEditorPane } from '../../../../common/editor.js'; import { IFilesConfigurationService } from '../../../../services/filesConfiguration/common/filesConfigurationService.js'; import { IAiEditTelemetryService } from '../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js'; import { ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; +import { ChatUserAction, IChatService } from '../../common/chatService/chatService.js'; import { ChatEditKind, IModifiedEntryTelemetryInfo, IModifiedFileEntry, IModifiedFileEntryEditorIntegration, ISnapshotEntry, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { IChatResponseModel } from '../../common/model/chatModel.js'; -import { ChatUserAction, IChatService } from '../../common/chatService/chatService.js'; class AutoAcceptControl { constructor( @@ -188,8 +188,6 @@ export abstract class AbstractChatEditingModifiedFileEntry extends Disposable im } } - public abstract hasModificationAt(location: Location): boolean; - acquire() { this._refCounter++; return this; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts index d4beb85749b..48073f03acf 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts @@ -9,16 +9,16 @@ import { StringSHA1 } from '../../../../../base/common/hash.js'; import { DisposableStore, IReference, thenRegisterOrDispose } from '../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; -import { ITransaction, IObservable, observableValue, autorun, transaction, ObservablePromise } from '../../../../../base/common/observable.js'; +import { autorun, IObservable, ITransaction, ObservablePromise, observableValue, transaction } from '../../../../../base/common/observable.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { assertType } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; -import { LineRange } from '../../../../../editor/common/core/ranges/lineRange.js'; import { Range } from '../../../../../editor/common/core/range.js'; +import { LineRange } from '../../../../../editor/common/core/ranges/lineRange.js'; import { nullDocumentDiff } from '../../../../../editor/common/diff/documentDiffProvider.js'; import { DetailedLineRangeMapping, RangeMapping } from '../../../../../editor/common/diff/rangeMapping.js'; -import { Location, TextEdit } from '../../../../../editor/common/languages.js'; +import { TextEdit } from '../../../../../editor/common/languages.js'; import { ITextModel } from '../../../../../editor/common/model.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; @@ -30,6 +30,7 @@ import { IUndoRedoElement, IUndoRedoService, UndoRedoElementType } from '../../. import { IEditorPane, SaveReason } from '../../../../common/editor.js'; import { IFilesConfigurationService } from '../../../../services/filesConfiguration/common/filesConfigurationService.js'; import { SnapshotContext } from '../../../../services/workingCopy/common/fileWorkingCopy.js'; +import { IAiEditTelemetryService } from '../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js'; import { NotebookTextDiffEditor } from '../../../notebook/browser/diff/notebookDiffEditor.js'; import { INotebookTextDiffEditor } from '../../../notebook/browser/diff/notebookDiffEditorBrowser.js'; import { CellDiffInfo } from '../../../notebook/browser/diff/notebookDiffViewModel.js'; @@ -42,9 +43,9 @@ import { INotebookEditorModelResolverService } from '../../../notebook/common/no import { INotebookLoggingService } from '../../../notebook/common/notebookLoggingService.js'; import { INotebookService } from '../../../notebook/common/notebookService.js'; import { INotebookEditorWorkerService } from '../../../notebook/common/services/notebookWorkerService.js'; +import { IChatService } from '../../common/chatService/chatService.js'; import { ChatEditKind, IModifiedEntryTelemetryInfo, IModifiedFileEntryEditorIntegration, ISnapshotEntry, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { IChatResponseModel } from '../../common/model/chatModel.js'; -import { IChatService } from '../../common/chatService/chatService.js'; import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js'; import { createSnapshot, deserializeSnapshot, getNotebookSnapshotFileURI, restoreSnapshot, SnapshotComparer } from './notebook/chatEditingModifiedNotebookSnapshot.js'; import { ChatEditingNewNotebookContentEdits } from './notebook/chatEditingNewNotebookContentEdits.js'; @@ -53,7 +54,6 @@ import { ChatEditingNotebookDiffEditorIntegration, ChatEditingNotebookEditorInte import { ChatEditingNotebookFileSystemProvider } from './notebook/chatEditingNotebookFileSystemProvider.js'; import { adjustCellDiffAndOriginalModelBasedOnCellAddDelete, adjustCellDiffAndOriginalModelBasedOnCellMovements, adjustCellDiffForKeepingAnInsertedCell, adjustCellDiffForRevertingADeletedCell, adjustCellDiffForRevertingAnInsertedCell, calculateNotebookRewriteRatio, getCorrespondingOriginalCellIndex, isTransientIPyNbExtensionEvent } from './notebook/helpers.js'; import { countChanges, ICellDiffInfo, sortCellChanges } from './notebook/notebookCellChanges.js'; -import { IAiEditTelemetryService } from '../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js'; const SnapshotLanguageId = 'VSCodeChatNotebookSnapshotLanguage'; @@ -201,10 +201,6 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie this._register(this.modifiedModel.onDidChangeContent(this.mirrorNotebookEdits, this)); } - public override hasModificationAt(location: Location): boolean { - return this.cellEntryMap.get(location.uri)?.hasModificationAt(location.range) ?? false; - } - initializeModelsFromDiffImpl(cellsDiffInfo: CellDiffInfo[]) { this.cellEntryMap.forEach(entry => entry.dispose()); this.cellEntryMap.clear(); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts index 09e4e25ab33..40fc87fb900 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts @@ -221,7 +221,8 @@ export class ChatEditingService extends Disposable implements IChatEditingServic // that are edit groups, and then this tracks the edit application for // each of them. Note that text edit groups can be updated // multiple times during the process of response streaming. - const editsSeen: ({ seen: number; streaming: IStreamingEdits } | undefined)[] = []; + const enum K { Stream, Workspace } + const editsSeen: ({ kind: K.Stream; seen: number; stream: IStreamingEdits } | { kind: K.Workspace })[] = []; let editorDidChange = false; const editorListener = Event.once(this._editorService.onDidActiveEditorChange)(() => { @@ -249,7 +250,9 @@ export class ChatEditingService extends Disposable implements IChatEditingServic const onResponseComplete = () => { for (const remaining of editsSeen) { - remaining?.streaming.complete(); + if (remaining?.kind === K.Stream) { + remaining.stream.complete(); + } } editsSeen.length = 0; @@ -271,6 +274,15 @@ export class ChatEditingService extends Disposable implements IChatEditingServic continue; } + if (part.kind === 'workspaceEdit') { + // Track if we've already started processing this workspace edit + if (!editsSeen[i]) { + editsSeen[i] = { kind: K.Workspace }; + session.applyWorkspaceEdit(part, responseModel, undoStop ?? responseModel.requestId); + } + continue; + } + if (part.kind !== 'textEditGroup' && part.kind !== 'notebookEditGroup') { continue; } @@ -287,10 +299,14 @@ export class ChatEditingService extends Disposable implements IChatEditingServic // get new edits and start editing session let entry = editsSeen[i]; if (!entry) { - entry = { seen: 0, streaming: session.startStreamingEdits(CellUri.parse(part.uri)?.notebook ?? part.uri, responseModel, undoStop) }; + entry = { kind: K.Stream, seen: 0, stream: session.startStreamingEdits(CellUri.parse(part.uri)?.notebook ?? part.uri, responseModel, undoStop) }; editsSeen[i] = entry; } + if (entry.kind !== K.Stream) { + continue; + } + const isFirst = entry.seen === 0; const newEdits = part.edits.slice(entry.seen); entry.seen = part.edits.length; @@ -301,21 +317,21 @@ export class ChatEditingService extends Disposable implements IChatEditingServic const done = part.done ? i === newEdits.length - 1 : false; if (isTextEditOperationArray(edit)) { - entry.streaming.pushText(edit, done); + entry.stream.pushText(edit, done); } else if (isCellTextEditOperationArray(edit)) { for (const edits of Object.values(groupBy(edit, e => e.uri.toString()))) { if (edits) { - entry.streaming.pushNotebookCellText(edits[0].uri, edits.map(e => e.edit), done); + entry.stream.pushNotebookCellText(edits[0].uri, edits.map(e => e.edit), done); } } } else { - entry.streaming.pushNotebook(edit, done); + entry.stream.pushNotebook(edit, done); } } } if (part.done) { - entry.streaming.complete(); + entry.stream.complete(); } } }; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts index a4a9e1ba750..5937e538fc4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts @@ -27,6 +27,7 @@ import { localize } from '../../../../../nls.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { EditorActivation } from '../../../../../platform/editor/common/editor.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js'; @@ -38,10 +39,11 @@ import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCo import { INotebookService } from '../../../notebook/common/notebookService.js'; import { chatEditingSessionIsReady, ChatEditingSessionState, ChatEditKind, getMultiDiffSourceUri, IChatEditingSession, IEditSessionEntryDiff, IModifiedEntryTelemetryInfo, IModifiedFileEntry, ISnapshotEntry, IStreamingEdits, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { IChatResponseModel } from '../../common/model/chatModel.js'; -import { IChatProgress } from '../../common/chatService/chatService.js'; +import { IChatProgress, IChatWorkspaceEdit } from '../../common/chatService/chatService.js'; import { ChatAgentLocation } from '../../common/constants.js'; import { IChatEditingCheckpointTimeline } from './chatEditingCheckpointTimeline.js'; import { ChatEditingCheckpointTimelineImpl, IChatEditingTimelineFsDelegate } from './chatEditingCheckpointTimelineImpl.js'; +import { ChatEditingDeletedFileEntry } from './chatEditingDeletedFileEntry.js'; import { ChatEditingModifiedDocumentEntry } from './chatEditingModifiedDocumentEntry.js'; import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js'; import { ChatEditingModifiedNotebookEntry } from './chatEditingModifiedNotebookEntry.js'; @@ -191,6 +193,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, @ILogService private readonly _logService: ILogService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IFileService private readonly _fileService: IFileService, ) { super(); this._timeline = this._instantiationService.createInstance( @@ -549,6 +552,85 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio }; } + startDeletion(resource: URI, responseModel: IChatResponseModel, undoStopId: string): void { + this._assertNotDisposed(); + + // Queue the deletion operation with proper locking + this._streamingEditLocks.queue(resource.toString(), async () => { + if (this.isDisposed) { + return; + } + + await chatEditingSessionIsReady(this); + + // Check if file exists + let fileContent: string; + try { + const content = await this._fileService.readFile(resource); + fileContent = content.value.toString(); + } catch (e) { + // File doesn't exist, nothing to delete + this._logService.warn(`Cannot delete file ${resource.toString()}: file does not exist`); + return; + } + + // Check if there's already an entry for this file + const existingEntry = this._getEntry(resource); + if (existingEntry) { + // If there's already an entry, we need to handle it differently + // For now, we'll just collapse it and proceed with deletion + existingEntry.dispose(); + const entries = this._entriesObs.get().filter(e => e !== existingEntry); + this._entriesObs.set(entries, undefined); + } + + // Store initial content for timeline restoration + if (!this._initialFileContents.has(resource)) { + this._initialFileContents.set(resource, fileContent); + } + + // Delete the file on disk + await this._bulkEditService.apply({ + edits: [{ oldResource: resource, options: { ignoreIfNotExists: true } }] + }); + + // Record the delete operation in the timeline + this._timeline.recordFileOperation({ + type: FileOperationType.Delete, + uri: resource, + requestId: responseModel.requestId, + epoch: this._timeline.incrementEpoch(), + finalContent: fileContent + }); + + // Create a deleted file entry + const telemetryInfo = this._getTelemetryInfoForModel(responseModel); + const languageSelection = this._languageService.createByFilepathOrFirstLine(resource); + const entry = this._instantiationService.createInstance( + ChatEditingDeletedFileEntry, + resource, + fileContent, + { collapse: (tx: ITransaction | undefined) => this._collapse(resource, tx) }, + telemetryInfo, + languageSelection.languageId + ); + + // Add entry to the entries observable + const entries = [...this._entriesObs.get(), entry]; + this._entriesObs.set(entries, undefined); + }); + } + + applyWorkspaceEdit(edit: IChatWorkspaceEdit, responseModel: IChatResponseModel, undoStopId: string): void { + for (const fileEdit of edit.edits) { + if (fileEdit.oldResource && !fileEdit.newResource) { + // File deletion + this.startDeletion(fileEdit.oldResource, responseModel, undoStopId); + } + // Future: handle file creations and renames + } + } + async startExternalEdits(responseModel: IChatResponseModel, operationId: number, resources: URI[], undoStopId: string): Promise { const snapshots = new ResourceMap(); const acquiredLockPromises: DeferredPromise[] = []; @@ -791,10 +873,28 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio const entriesArr: AbstractChatEditingModifiedFileEntry[] = []; // Restore all entries from the snapshot for (const snapshotEntry of entries.values()) { - const entry = await this._getOrCreateModifiedFileEntry(snapshotEntry.resource, NotExistBehavior.Abort, snapshotEntry.telemetryInfo); + let entry: AbstractChatEditingModifiedFileEntry | undefined; + + if (snapshotEntry.isDeleted) { + // Create a deleted file entry + entry = this._instantiationService.createInstance( + ChatEditingDeletedFileEntry, + snapshotEntry.resource, + snapshotEntry.original, // original content before deletion + { collapse: (tx: ITransaction | undefined) => this._collapse(snapshotEntry.resource, tx) }, + snapshotEntry.telemetryInfo, + snapshotEntry.languageId + ); + await entry.restoreFromSnapshot(snapshotEntry, false); + } else { + entry = await this._getOrCreateModifiedFileEntry(snapshotEntry.resource, NotExistBehavior.Abort, snapshotEntry.telemetryInfo); + if (entry) { + const restoreToDisk = snapshotEntry.state === ModifiedFileEntryState.Modified; + await entry.restoreFromSnapshot(snapshotEntry, restoreToDisk); + } + } + if (entry) { - const restoreToDisk = snapshotEntry.state === ModifiedFileEntryState.Modified; - await entry.restoreFromSnapshot(snapshotEntry, restoreToDisk); entriesArr.push(entry); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSessionStorage.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSessionStorage.ts index 0fb2c138a5b..0b8e1e0eff2 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSessionStorage.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSessionStorage.ts @@ -85,7 +85,8 @@ export class ChatEditingSessionStorage { modeId: entry.telemetryInfo.modeId, applyCodeBlockSuggestionId: entry.telemetryInfo.applyCodeBlockSuggestionId, feature: entry.telemetryInfo.feature, - } + }, + isDeleted: entry.isDeleted, } satisfies ISnapshotEntry; }; try { @@ -178,7 +179,8 @@ export class ChatEditingSessionStorage { currentHash: await addFileContent(entry.current), state: entry.state, snapshotUri: entry.snapshotUri.toString(), - telemetryInfo: { requestId: entry.telemetryInfo.requestId, agentId: entry.telemetryInfo.agentId, command: entry.telemetryInfo.command, modelId: entry.telemetryInfo.modelId, modeId: entry.telemetryInfo.modeId } + telemetryInfo: { requestId: entry.telemetryInfo.requestId, agentId: entry.telemetryInfo.agentId, command: entry.telemetryInfo.command, modelId: entry.telemetryInfo.modelId, modeId: entry.telemetryInfo.modeId }, + isDeleted: entry.isDeleted, }; }; @@ -255,6 +257,8 @@ interface ISnapshotEntryDTO { readonly state: ModifiedFileEntryState; readonly snapshotUri: string; readonly telemetryInfo: IModifiedEntryTelemetryInfoDTO; + /** True if this entry represents a deleted file */ + readonly isDeleted?: boolean; } interface IModifiedEntryTelemetryInfoDTO { diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookCellEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookCellEntry.ts index 3df305e8eb8..ad09e80fd34 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookCellEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookCellEntry.ts @@ -6,7 +6,6 @@ import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { IObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { IRange } from '../../../../../../editor/common/core/range.js'; import { IDocumentDiff } from '../../../../../../editor/common/diff/documentDiffProvider.js'; import { DetailedLineRangeMapping } from '../../../../../../editor/common/diff/rangeMapping.js'; import { TextEdit } from '../../../../../../editor/common/languages.js'; @@ -74,11 +73,6 @@ export class ChatEditingNotebookCellEntry extends Disposable { this._stateObs.set(ModifiedFileEntryState.Rejected, undefined); } })); - - } - - public hasModificationAt(range: IRange): boolean { - return this._textModelChangeService.hasHunkAt(range); } public clearCurrentEditLineDecoration() { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts index f13918be0b9..9492a9cab6f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts @@ -36,7 +36,7 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP private currentContent: IMarkdownString; constructor( - progress: IChatProgressMessage | IChatTask | IChatTaskSerialized, + progress: IChatProgressMessage | IChatTask | IChatTaskSerialized | { content: IMarkdownString }, private readonly chatContentMarkdownRenderer: IMarkdownRenderer, context: IChatContentPartRenderContext, forceShowSpinner: boolean | undefined, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatWorkspaceEditContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatWorkspaceEditContentPart.ts new file mode 100644 index 00000000000..5e86668a81f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatWorkspaceEditContentPart.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, append } from '../../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../nls.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IChatWorkspaceEdit } from '../../../common/chatService/chatService.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { ChatTreeItem } from '../../chat.js'; +import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; +import { renderFileWidgets } from './chatInlineAnchorWidget.js'; +import { ChatProgressSubPart } from './chatProgressContentPart.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; + +export class ChatWorkspaceEditContentPart extends Disposable implements IChatContentPart { + public readonly domNode: HTMLElement; + + constructor( + private readonly workspaceEdit: IChatWorkspaceEdit, + _context: IChatContentPartRenderContext, + chatContentMarkdownRenderer: IMarkdownRenderer, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IChatMarkdownAnchorService private readonly chatMarkdownAnchorService: IChatMarkdownAnchorService, + @ILabelService private readonly labelService: ILabelService, + ) { + super(); + + this.domNode = $('.chat-workspace-edit-content-part'); + + const renderEntry = (message: string, icon: ThemeIcon) => { + const result = this._register(chatContentMarkdownRenderer.render(new MarkdownString(message, { isTrusted: true }))); + result.element.classList.add('progress-step'); + renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._store); + const progressPart = this._register(this.instantiationService.createInstance(ChatProgressSubPart, result.element, icon, undefined)); + append(this.domNode, progressPart.domNode); + }; + + for (const edit of workspaceEdit.edits) { + if (edit.oldResource && !edit.newResource) { + // note: not linked because trying to open it would simply error + renderEntry(localize('deleted', "Deleted `{0}`", this.labelService.getUriBasenameLabel(edit.oldResource)), Codicon.trash); + } else if (!edit.oldResource && edit.newResource) { + renderEntry(localize('created', "Created []({0})", edit.newResource.toString()), Codicon.newFile); + } else if (edit.oldResource && edit.newResource) { + renderEntry(localize('renamedTo', "Renamed {0} to []({1})", this.labelService.getUriBasenameLabel(edit.oldResource), edit.newResource.toString()), Codicon.arrowRight); + } + } + } + + hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { + if (other.kind !== 'workspaceEdit') { + return false; + } + // Check if the edits are the same + if (other.edits.length !== this.workspaceEdit.edits.length) { + return false; + } + for (let i = 0; i < other.edits.length; i++) { + const a = other.edits[i]; + const b = this.workspaceEdit.edits[i]; + if (a.oldResource?.toString() !== b.oldResource?.toString() || + a.newResource?.toString() !== b.newResource?.toString()) { + return false; + } + } + return true; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index c5ad85e3af6..56e7bb5f028 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -91,6 +91,7 @@ import { ChatTextEditContentPart } from './chatContentParts/chatTextEditContentP import { ChatThinkingContentPart } from './chatContentParts/chatThinkingContentPart.js'; import { ChatSubagentContentPart } from './chatContentParts/chatSubagentContentPart.js'; import { ChatTreeContentPart, TreePool } from './chatContentParts/chatTreeContentPart.js'; +import { ChatWorkspaceEditContentPart } from './chatContentParts/chatWorkspaceEditContentPart.js'; import { ChatToolInvocationPart } from './chatContentParts/toolInvocationParts/chatToolInvocationPart.js'; import { ChatMarkdownDecorationsRenderer } from './chatContentParts/chatMarkdownDecorationsRenderer.js'; import { ChatEditorOptions } from './chatOptions.js'; @@ -1503,6 +1504,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer content.kind === other.kind); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index f93da24674a..3cd9c2bc297 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -2330,7 +2330,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge kind: 'reference', options: { status: undefined, - diffMeta: { added: linesAdded ?? 0, removed: linesRemoved ?? 0 } + diffMeta: { added: linesAdded ?? 0, removed: linesRemoved ?? 0 }, + isDeletion: !!entry.isDeletion, + originalUri: entry.isDeletion ? entry.originalURI : undefined, } }); } @@ -2542,6 +2544,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const modifiedFileUri = e.element.reference; const originalUri = e.element.options?.originalUri; + if (e.element.options?.isDeletion && originalUri) { + await this.editorService.openEditor({ + resource: originalUri, // instead of modified, because modified will not exist + options: e.editorOptions + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + return; + } + // If there's a originalUri, open as diff editor if (originalUri) { await this.editorService.openEditor({ diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index eb181252df9..5df7c58f631 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -129,6 +129,7 @@ export interface IChatContentReference { status?: { description: string; kind: ChatResponseReferencePartStatusKind }; diffMeta?: { added: number; removed: number }; originalUri?: URI; + isDeletion?: boolean; }; kind: 'reference'; } @@ -312,6 +313,16 @@ export interface IChatNotebookEdit { isExternalEdit?: boolean; } +export interface IChatWorkspaceFileEdit { + oldResource?: URI; + newResource?: URI; +} + +export interface IChatWorkspaceEdit { + kind: 'workspaceEdit'; + edits: IChatWorkspaceFileEdit[]; +} + export interface IChatConfirmation { title: string; message: string | IMarkdownString; @@ -839,6 +850,7 @@ export type IChatProgress = | IChatWarningMessage | IChatTextEdit | IChatNotebookEdit + | IChatWorkspaceEdit | IChatMoveMessage | IChatResponseCodeblockUriPart | IChatConfirmation diff --git a/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts b/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts index c5db2d08232..5ebf42b3497 100644 --- a/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts +++ b/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts @@ -12,7 +12,7 @@ import { autorunSelfDisposable, IObservable, IReader } from '../../../../../base import { hasKey } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { IDocumentDiff } from '../../../../../editor/common/diff/documentDiffProvider.js'; -import { Location, TextEdit } from '../../../../../editor/common/languages.js'; +import { TextEdit } from '../../../../../editor/common/languages.js'; import { ITextModel } from '../../../../../editor/common/model.js'; import { EditSuggestionId } from '../../../../../editor/common/textModelEditSource.js'; import { localize } from '../../../../../nls.js'; @@ -20,9 +20,9 @@ import { RawContextKey } from '../../../../../platform/contextkey/common/context import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IEditorPane } from '../../../../common/editor.js'; import { ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; -import { IChatAgentResult } from '../participants/chatAgents.js'; +import { IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatProgress, IChatWorkspaceEdit } from '../chatService/chatService.js'; import { ChatModel, IChatRequestDisablement, IChatResponseModel } from '../model/chatModel.js'; -import { IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatProgress } from '../chatService/chatService.js'; +import { IChatAgentResult } from '../participants/chatAgents.js'; export const IChatEditingService = createDecorator('chatEditingService'); @@ -110,6 +110,8 @@ export interface ISnapshotEntry { readonly current: string; readonly state: ModifiedFileEntryState; telemetryInfo: IModifiedEntryTelemetryInfo; + /** True if this entry represents a deleted file */ + readonly isDeleted?: boolean; } export interface IChatEditingSession extends IDisposable { @@ -160,6 +162,14 @@ export interface IChatEditingSession extends IDisposable { */ startStreamingEdits(resource: URI, responseModel: IChatResponseModel, inUndoStop: string | undefined): IStreamingEdits; + /** + * Applies a workspace edit (file deletions, creations, renames). + * @param edit The workspace edit containing file operations + * @param responseModel The response model making the edit + * @param undoStopId The undo stop ID for this edit + */ + applyWorkspaceEdit(edit: IChatWorkspaceEdit, responseModel: IChatResponseModel, undoStopId: string): void; + /** * Gets the document diff of a change made to a URI between one undo stop and * the next one. @@ -370,6 +380,7 @@ export interface IModifiedFileEntry { readonly entryId: string; readonly originalURI: URI; readonly modifiedURI: URI; + readonly isDeletion?: boolean; readonly lastModifyingRequestId: string; @@ -408,7 +419,6 @@ export interface IModifiedFileEntry { readonly linesRemoved?: IObservable; getEditorIntegration(editor: IEditorPane): IModifiedFileEntryEditorIntegration; - hasModificationAt(location: Location): boolean; /** * Gets the document diff info, waiting for any ongoing promises to flush. */ @@ -444,6 +454,7 @@ export const defaultChatEditingMaxFileLimit = 10; export const enum ChatEditKind { Created, Modified, + Deleted, } export interface IChatEditingActionContext { diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index ff97b0a75b4..466011ce28c 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -29,7 +29,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImplicitVariableEntry, isStringImplicitContextValue, isStringVariableEntry } from '../attachments/chatVariableEntries.js'; import { migrateLegacyTerminalToolSpecificData } from '../chat.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatService, IChatSessionContext, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsedContext, IChatWarningMessage, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatService, IChatSessionContext, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../constants.js'; import { IChatEditingService, IChatEditingSession, ModifiedFileEntryState } from '../editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../languageModels.js'; @@ -145,7 +145,8 @@ export type IChatProgressHistoryResponseContent = | IChatConfirmation | IChatExtensionsContent | IChatThinkingPart - | IChatPullRequestContent; + | IChatPullRequestContent + | IChatWorkspaceEdit; /** * "Normal" progress kinds that are rendered as parts of the stream of content. @@ -445,6 +446,7 @@ class AbstractResponse implements IResponse { case 'extensions': case 'pullRequest': case 'undoStop': + case 'workspaceEdit': case 'elicitation2': case 'elicitationSerialized': case 'thinking': diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts index d5ca52b3b9b..87b1641cdef 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts @@ -78,6 +78,7 @@ const responsePartSchema = Adapt.v { const ds = ensureNoDisposablesAreLeakedInTestSuite(); @@ -75,6 +76,9 @@ suite('ChatEditingSessionStorage', () => { await storage.storeState(original); const changer = (x: any) => { + if (typeof x === 'object' && x && hasKey(x, { isDeleted: true }) && x.isDeleted === undefined) { + delete x.isDeleted; + } return URI.isUri(x) ? x.toString() : x instanceof Map ? cloneAndChange([...x.values()], changer) : undefined; }; diff --git a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts index 6dbf88c6895..89da67be3e1 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts @@ -65,6 +65,29 @@ declare module 'vscode' { constructor(uri: Uri, edits: NotebookEdit | NotebookEdit[]); } + /** + * Represents a file-level edit (creation, deletion, or rename). + */ + export interface ChatWorkspaceFileEdit { + /** + * The original file URI (undefined for new files). + */ + oldResource?: Uri; + + /** + * The new file URI (undefined for deleted files). + */ + newResource?: Uri; + } + + /** + * Represents a workspace edit containing file-level operations. + */ + export class ChatResponseWorkspaceEditPart { + edits: ChatWorkspaceFileEdit[]; + constructor(edits: ChatWorkspaceFileEdit[]); + } + export class ChatResponseConfirmationPart { title: string; message: string | MarkdownString; @@ -179,7 +202,7 @@ declare module 'vscode' { constructor(uris: Uri[], callback: () => Thenable); } - export type ExtendedChatResponsePart = ChatResponsePart | ChatResponseTextEditPart | ChatResponseNotebookEditPart | ChatResponseConfirmationPart | ChatResponseCodeCitationPart | ChatResponseReferencePart2 | ChatResponseMovePart | ChatResponseExtensionsPart | ChatResponsePullRequestPart | ChatToolInvocationPart | ChatResponseMultiDiffPart | ChatResponseThinkingProgressPart | ChatResponseExternalEditPart; + export type ExtendedChatResponsePart = ChatResponsePart | ChatResponseTextEditPart | ChatResponseNotebookEditPart | ChatResponseWorkspaceEditPart | ChatResponseConfirmationPart | ChatResponseCodeCitationPart | ChatResponseReferencePart2 | ChatResponseMovePart | ChatResponseExtensionsPart | ChatResponsePullRequestPart | ChatToolInvocationPart | ChatResponseMultiDiffPart | ChatResponseThinkingProgressPart | ChatResponseExternalEditPart; export class ChatResponseWarningPart { value: MarkdownString; constructor(value: string | MarkdownString); @@ -313,6 +336,12 @@ declare module 'vscode' { notebookEdit(target: Uri, isDone: true): void; + /** + * Push a workspace edit containing file-level operations (create, delete, rename). + * @param edits Array of file-level edits to apply + */ + workspaceEdit(edits: ChatWorkspaceFileEdit[]): void; + /** * Makes an external edit to one or more resources. Changes to the * resources made within the `callback` and before it resolves will be