Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.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*--------------------------------------------------------------------------------------------*/45import { RunOnceScheduler } from '../../../../../base/common/async.js';6import { Emitter } from '../../../../../base/common/event.js';7import { Disposable, DisposableMap, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';8import { Schemas } from '../../../../../base/common/network.js';9import { clamp } from '../../../../../base/common/numbers.js';10import { autorun, derived, IObservable, ITransaction, observableValue, observableValueOpts, transaction } from '../../../../../base/common/observable.js';11import { URI } from '../../../../../base/common/uri.js';12import { TextEdit } from '../../../../../editor/common/languages.js';13import { EditDeltaInfo } from '../../../../../editor/common/textModelEditSource.js';14import { localize } from '../../../../../nls.js';15import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';16import { IFileService } from '../../../../../platform/files/common/files.js';17import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';18import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js';19import { editorBackground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js';20import { IUndoRedoElement, IUndoRedoService } from '../../../../../platform/undoRedo/common/undoRedo.js';21import { IEditorPane } from '../../../../common/editor.js';22import { IFilesConfigurationService } from '../../../../services/filesConfiguration/common/filesConfigurationService.js';23import { IAiEditTelemetryService } from '../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js';24import { ICellEditOperation } from '../../../notebook/common/notebookCommon.js';25import { ChatUserAction, IChatService } from '../../common/chatService/chatService.js';26import { ChatEditKind, IModifiedEntryTelemetryInfo, IModifiedFileEntry, IModifiedFileEntryEditorIntegration, ISnapshotEntry, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js';27import { IChatResponseModel } from '../../common/model/chatModel.js';2829class AutoAcceptControl {30constructor(31readonly total: number,32readonly remaining: number,33readonly cancel: () => void34) { }35}3637export const pendingRewriteMinimap = registerColor('minimap.chatEditHighlight',38transparent(editorBackground, 0.6),39localize('editorSelectionBackground', "Color of pending edit regions in the minimap"));404142export abstract class AbstractChatEditingModifiedFileEntry extends Disposable implements IModifiedFileEntry {4344static readonly scheme = 'modified-file-entry';4546private static lastEntryId = 0;4748readonly entryId = `${AbstractChatEditingModifiedFileEntry.scheme}::${++AbstractChatEditingModifiedFileEntry.lastEntryId}`;4950protected readonly _onDidDelete = this._register(new Emitter<void>());51readonly onDidDelete = this._onDidDelete.event;5253protected readonly _stateObs = observableValue<ModifiedFileEntryState>(this, ModifiedFileEntryState.Modified);54readonly state: IObservable<ModifiedFileEntryState> = this._stateObs;5556protected readonly _waitsForLastEdits = observableValue<boolean>(this, false);57readonly waitsForLastEdits: IObservable<boolean> = this._waitsForLastEdits;5859protected readonly _isCurrentlyBeingModifiedByObs = observableValue<{ responseModel: IChatResponseModel; undoStopId: string | undefined } | undefined>(this, undefined);60readonly isCurrentlyBeingModifiedBy: IObservable<{ responseModel: IChatResponseModel; undoStopId: string | undefined } | undefined> = this._isCurrentlyBeingModifiedByObs;6162/**63* Flag to track if we're currently in an external edit operation.64* When true, file system changes should be treated as agent edits, not user edits.65*/66protected _isExternalEditInProgress = false;6768protected readonly _lastModifyingResponseObs = observableValueOpts<IChatResponseModel | undefined>({ equalsFn: (a, b) => a?.requestId === b?.requestId }, undefined);69readonly lastModifyingResponse: IObservable<IChatResponseModel | undefined> = this._lastModifyingResponseObs;7071protected readonly _lastModifyingResponseInProgressObs = this._lastModifyingResponseObs.map((value, r) => {72return value?.isInProgress.read(r) ?? false;73});7475protected readonly _rewriteRatioObs = observableValue<number>(this, 0);76readonly rewriteRatio: IObservable<number> = this._rewriteRatioObs;7778private readonly _reviewModeTempObs = observableValue<true | undefined>(this, undefined);79readonly reviewMode: IObservable<boolean>;8081private readonly _autoAcceptCtrl = observableValue<AutoAcceptControl | undefined>(this, undefined);82readonly autoAcceptController: IObservable<AutoAcceptControl | undefined> = this._autoAcceptCtrl;8384protected readonly _autoAcceptTimeout: IObservable<number>;8586get telemetryInfo(): IModifiedEntryTelemetryInfo {87return this._telemetryInfo;88}8990readonly createdInRequestId: string | undefined;9192get lastModifyingRequestId() {93return this._telemetryInfo.requestId;94}9596private _refCounter: number = 1;9798readonly abstract originalURI: URI;99100protected readonly _userEditScheduler = this._register(new RunOnceScheduler(() => this._notifySessionAction('userModified'), 1000));101102constructor(103readonly modifiedURI: URI,104protected _telemetryInfo: IModifiedEntryTelemetryInfo,105kind: ChatEditKind,106@IConfigurationService configService: IConfigurationService,107@IFilesConfigurationService protected _fileConfigService: IFilesConfigurationService,108@IChatService protected readonly _chatService: IChatService,109@IFileService protected readonly _fileService: IFileService,110@IUndoRedoService private readonly _undoRedoService: IUndoRedoService,111@IInstantiationService protected readonly _instantiationService: IInstantiationService,112@IAiEditTelemetryService private readonly _aiEditTelemetryService: IAiEditTelemetryService,113) {114super();115116if (kind === ChatEditKind.Created) {117this.createdInRequestId = this._telemetryInfo.requestId;118}119120if (this.modifiedURI.scheme !== Schemas.untitled && this.modifiedURI.scheme !== Schemas.vscodeNotebookCell) {121this._register(this._fileService.watch(this.modifiedURI));122this._register(this._fileService.onDidFilesChange(e => {123if (e.affects(this.modifiedURI) && kind === ChatEditKind.Created && e.gotDeleted()) {124this._onDidDelete.fire();125}126}));127}128129// review mode depends on setting and temporary override130const autoAcceptRaw = observableConfigValue('chat.editing.autoAcceptDelay', 0, configService);131this._autoAcceptTimeout = derived(r => {132const value = autoAcceptRaw.read(r);133return clamp(value, 0, 100);134});135this.reviewMode = derived(r => {136const configuredValue = this._autoAcceptTimeout.read(r);137const tempValue = this._reviewModeTempObs.read(r);138return tempValue ?? configuredValue === 0;139});140141this._store.add(toDisposable(() => this._lastModifyingResponseObs.set(undefined, undefined)));142143const autoSaveOff = this._store.add(new MutableDisposable());144this._store.add(autorun(r => {145if (this._waitsForLastEdits.read(r)) {146autoSaveOff.value = _fileConfigService.disableAutoSave(this.modifiedURI);147} else {148autoSaveOff.clear();149}150}));151152this._store.add(autorun(r => {153const inProgress = this._lastModifyingResponseInProgressObs.read(r);154if (inProgress === false && !this.reviewMode.read(r)) {155// AUTO accept mode (when request is done)156157const acceptTimeout = this._autoAcceptTimeout.read(undefined) * 1000;158const future = Date.now() + acceptTimeout;159const update = () => {160161const reviewMode = this.reviewMode.read(undefined);162if (reviewMode) {163// switched back to review mode164this._autoAcceptCtrl.set(undefined, undefined);165return;166}167168const remain = Math.round(future - Date.now());169if (remain <= 0) {170this.accept();171} else {172const handle = setTimeout(update, 100);173this._autoAcceptCtrl.set(new AutoAcceptControl(acceptTimeout, remain, () => {174clearTimeout(handle);175this._autoAcceptCtrl.set(undefined, undefined);176}), undefined);177}178};179update();180}181}));182}183184override dispose(): void {185if (--this._refCounter === 0) {186super.dispose();187}188}189190acquire() {191this._refCounter++;192return this;193}194195enableReviewModeUntilSettled(): void {196197if (this.state.get() !== ModifiedFileEntryState.Modified) {198// nothing to do199return;200}201202this._reviewModeTempObs.set(true, undefined);203204const cleanup = autorun(r => {205// reset config when settled206const resetConfig = this.state.read(r) !== ModifiedFileEntryState.Modified;207if (resetConfig) {208this._store.delete(cleanup);209this._reviewModeTempObs.set(undefined, undefined);210}211});212213this._store.add(cleanup);214}215216updateTelemetryInfo(telemetryInfo: IModifiedEntryTelemetryInfo) {217this._telemetryInfo = telemetryInfo;218}219220async accept(): Promise<void> {221const callback = await this.acceptDeferred();222if (callback) {223transaction(callback);224}225}226227/** Accepts and returns a function used to transition the state. This MUST be called by the consumer. */228async acceptDeferred(): Promise<((tx: ITransaction) => void) | undefined> {229if (this._stateObs.get() !== ModifiedFileEntryState.Modified) {230// already accepted or rejected231return;232}233234await this._doAccept();235236return (tx: ITransaction) => {237this._stateObs.set(ModifiedFileEntryState.Accepted, tx);238this._autoAcceptCtrl.set(undefined, tx);239this._notifySessionAction('accepted');240};241}242243protected abstract _doAccept(): Promise<void>;244245async reject(): Promise<void> {246const callback = await this.rejectDeferred();247if (callback) {248transaction(callback);249}250}251252/** Rejects and returns a function used to transition the state. This MUST be called by the consumer. */253async rejectDeferred(): Promise<((tx: ITransaction) => void) | undefined> {254if (this._stateObs.get() !== ModifiedFileEntryState.Modified) {255// already accepted or rejected256return undefined;257}258259this._notifySessionAction('rejected');260await this._doReject();261262return (tx: ITransaction) => {263this._stateObs.set(ModifiedFileEntryState.Rejected, tx);264this._autoAcceptCtrl.set(undefined, tx);265};266}267268protected abstract _doReject(): Promise<void>;269270protected _notifySessionAction(outcome: 'accepted' | 'rejected' | 'userModified') {271this._notifyAction({ kind: 'chatEditingSessionAction', uri: this.modifiedURI, hasRemainingEdits: false, outcome });272}273274protected _notifyAction(action: ChatUserAction) {275if (action.kind === 'chatEditingHunkAction') {276this._aiEditTelemetryService.handleCodeAccepted({277suggestionId: undefined, // TODO@hediet try to figure this out278acceptanceMethod: 'accept',279presentation: 'highlightedEdit',280modelId: this._telemetryInfo.modelId,281modeId: this._telemetryInfo.modeId,282applyCodeBlockSuggestionId: this._telemetryInfo.applyCodeBlockSuggestionId,283editDeltaInfo: new EditDeltaInfo(284action.linesAdded,285action.linesRemoved,286-1,287-1,288),289feature: this._telemetryInfo.feature,290languageId: action.languageId,291source: undefined,292});293}294295this._chatService.notifyUserAction({296action,297agentId: this._telemetryInfo.agentId,298modelId: this._telemetryInfo.modelId,299modeId: this._telemetryInfo.modeId,300command: this._telemetryInfo.command,301sessionResource: this._telemetryInfo.sessionResource,302requestId: this._telemetryInfo.requestId,303result: this._telemetryInfo.result304});305}306307private readonly _editorIntegrations = this._register(new DisposableMap<IEditorPane, IModifiedFileEntryEditorIntegration>());308309getEditorIntegration(pane: IEditorPane): IModifiedFileEntryEditorIntegration {310let value = this._editorIntegrations.get(pane);311if (!value) {312value = this._createEditorIntegration(pane);313this._editorIntegrations.set(pane, value);314}315return value;316}317318/**319* Create the editor integration for this entry and the given editor pane. This will only be called320* once (and cached) per pane. The integration is meant to be scoped to this entry only and when the321* passed pane/editor changes input, then the editor integration must handle that, e.g use default/null322* values323*/324protected abstract _createEditorIntegration(editor: IEditorPane): IModifiedFileEntryEditorIntegration;325326abstract readonly changesCount: IObservable<number>;327328acceptStreamingEditsStart(responseModel: IChatResponseModel, undoStopId: string | undefined, tx: ITransaction | undefined) {329this._resetEditsState(tx);330this._isCurrentlyBeingModifiedByObs.set({ responseModel, undoStopId }, tx);331this._lastModifyingResponseObs.set(responseModel, tx);332this._autoAcceptCtrl.get()?.cancel();333334const undoRedoElement = this._createUndoRedoElement(responseModel);335if (undoRedoElement) {336this._undoRedoService.pushElement(undoRedoElement);337}338}339340protected abstract _createUndoRedoElement(response: IChatResponseModel): IUndoRedoElement | undefined;341342abstract acceptAgentEdits(uri: URI, edits: (TextEdit | ICellEditOperation)[], isLastEdits: boolean, responseModel: IChatResponseModel | undefined): Promise<void>;343344async acceptStreamingEditsEnd() {345this._resetEditsState(undefined);346347if (await this._areOriginalAndModifiedIdentical()) {348// ACCEPT if identical349await this.accept();350}351}352353protected abstract _areOriginalAndModifiedIdentical(): Promise<boolean>;354355protected _resetEditsState(tx: ITransaction | undefined): void {356this._isCurrentlyBeingModifiedByObs.set(undefined, tx);357this._rewriteRatioObs.set(0, tx);358this._waitsForLastEdits.set(false, tx);359}360361// --- snapshot362363abstract createSnapshot(chatSessionResource: URI, requestId: string | undefined, undoStop: string | undefined): ISnapshotEntry;364365abstract equalsSnapshot(snapshot: ISnapshotEntry | undefined): boolean;366367abstract restoreFromSnapshot(snapshot: ISnapshotEntry, restoreToDisk?: boolean): Promise<void>;368369// --- inital content370371abstract resetToInitialContent(): Promise<void>;372373abstract initialContent: string;374375/**376* Computes the edits between two snapshots of the file content.377* @param beforeSnapshot The content before the changes378* @param afterSnapshot The content after the changes379* @returns Array of text edits or cell edit operations380*/381abstract computeEditsFromSnapshots(beforeSnapshot: string, afterSnapshot: string): Promise<(TextEdit | ICellEditOperation)[]>;382383/**384* Marks the start of an external edit operation.385* File system changes will be treated as agent edits until stopExternalEdit is called.386*/387startExternalEdit(): void {388this._isExternalEditInProgress = true;389}390391/**392* Marks the end of an external edit operation.393*/394stopExternalEdit(): void {395this._isExternalEditInProgress = false;396}397398/**399* Saves the current model state to disk.400*/401abstract save(): Promise<void>;402403/**404* Reloads the model from disk to ensure it's in sync with file system changes.405*/406abstract revertToDisk(): Promise<void>;407}408409410