Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts
5251 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 { compareBy, delta } from '../../../../../base/common/arrays.js';6import { CancellationToken } from '../../../../../base/common/cancellation.js';7import { Codicon } from '../../../../../base/common/codicons.js';8import { groupBy } from '../../../../../base/common/collections.js';9import { ErrorNoTelemetry } from '../../../../../base/common/errors.js';10import { Emitter, Event } from '../../../../../base/common/event.js';11import { Iterable } from '../../../../../base/common/iterator.js';12import { Disposable, DisposableStore, dispose, IDisposable } from '../../../../../base/common/lifecycle.js';13import { LinkedList } from '../../../../../base/common/linkedList.js';14import { ResourceMap } from '../../../../../base/common/map.js';15import { Schemas } from '../../../../../base/common/network.js';16import { derived, IObservable, observableValueOpts, runOnChange, ValueWithChangeEventFromObservable } from '../../../../../base/common/observable.js';17import { isEqual } from '../../../../../base/common/resources.js';18import { compare } from '../../../../../base/common/strings.js';19import { ThemeIcon } from '../../../../../base/common/themables.js';20import { assertType } from '../../../../../base/common/types.js';21import { URI } from '../../../../../base/common/uri.js';22import { TextEdit } from '../../../../../editor/common/languages.js';23import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';24import { localize } from '../../../../../nls.js';25import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';26import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';27import { IFileService } from '../../../../../platform/files/common/files.js';28import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';29import { ILogService } from '../../../../../platform/log/common/log.js';30import { IProductService } from '../../../../../platform/product/common/productService.js';31import { IStorageService } from '../../../../../platform/storage/common/storage.js';32import { IDecorationData, IDecorationsProvider, IDecorationsService } from '../../../../services/decorations/common/decorations.js';33import { IEditorService } from '../../../../services/editor/common/editorService.js';34import { IExtensionService } from '../../../../services/extensions/common/extensions.js';35import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js';36import { IMultiDiffSourceResolver, IMultiDiffSourceResolverService, IResolvedMultiDiffSource, MultiDiffEditorItem } from '../../../multiDiffEditor/browser/multiDiffSourceResolverService.js';37import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCommon.js';38import { INotebookService } from '../../../notebook/common/notebookService.js';39import { CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingAgentSupportsReadonlyReferencesContextKey, chatEditingResourceContextKey, ChatEditingSessionState, IChatEditingService, IChatEditingSession, IModifiedFileEntry, inChatEditingSessionContextKey, IStreamingEdits, ModifiedFileEntryState, parseChatMultiDiffUri } from '../../common/editing/chatEditingService.js';40import { ChatModel, ICellTextEditOperation, IChatResponseModel, isCellTextEditOperationArray } from '../../common/model/chatModel.js';41import { IChatService } from '../../common/chatService/chatService.js';42import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js';43import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js';44import { ChatEditingSession } from './chatEditingSession.js';45import { ChatEditingSnapshotTextModelContentProvider, ChatEditingTextModelContentProvider } from './chatEditingTextModelContentProviders.js';4647export class ChatEditingService extends Disposable implements IChatEditingService {4849_serviceBrand: undefined;505152private readonly _sessionsObs = observableValueOpts<LinkedList<ChatEditingSession>>({ equalsFn: (a, b) => false }, new LinkedList());5354readonly editingSessionsObs: IObservable<readonly IChatEditingSession[]> = derived(r => {55const result = Array.from(this._sessionsObs.read(r));56return result;57});5859constructor(60@IInstantiationService private readonly _instantiationService: IInstantiationService,61@IMultiDiffSourceResolverService multiDiffSourceResolverService: IMultiDiffSourceResolverService,62@ITextModelService textModelService: ITextModelService,63@IContextKeyService contextKeyService: IContextKeyService,64@IChatService private readonly _chatService: IChatService,65@IEditorService private readonly _editorService: IEditorService,66@IDecorationsService decorationsService: IDecorationsService,67@IFileService private readonly _fileService: IFileService,68@ILifecycleService private readonly lifecycleService: ILifecycleService,69@IStorageService storageService: IStorageService,70@ILogService logService: ILogService,71@IExtensionService extensionService: IExtensionService,72@IProductService productService: IProductService,73@INotebookService private readonly notebookService: INotebookService,74@IConfigurationService private readonly _configurationService: IConfigurationService,75) {76super();77this._register(decorationsService.registerDecorationsProvider(_instantiationService.createInstance(ChatDecorationsProvider, this.editingSessionsObs)));78this._register(multiDiffSourceResolverService.registerResolver(_instantiationService.createInstance(ChatEditingMultiDiffSourceResolver, this.editingSessionsObs)));7980// TODO@jrieken81// some ugly casting so that this service can pass itself as argument instad as service dependeny82// eslint-disable-next-line local/code-no-any-casts, @typescript-eslint/no-explicit-any83this._register(textModelService.registerTextModelContentProvider(ChatEditingTextModelContentProvider.scheme, _instantiationService.createInstance(ChatEditingTextModelContentProvider as any, this)));84// eslint-disable-next-line local/code-no-any-casts, @typescript-eslint/no-explicit-any85this._register(textModelService.registerTextModelContentProvider(Schemas.chatEditingSnapshotScheme, _instantiationService.createInstance(ChatEditingSnapshotTextModelContentProvider as any, this)));8687this._register(this._chatService.onDidDisposeSession((e) => {88if (e.reason === 'cleared') {89for (const resource of e.sessionResource) {90this.getEditingSession(resource)?.stop();91}92}93}));9495// todo@connor4312: temporary until chatReadonlyPromptReference proposal is finalized96const readonlyEnabledContextKey = chatEditingAgentSupportsReadonlyReferencesContextKey.bindTo(contextKeyService);97const setReadonlyFilesEnabled = () => {98const enabled = productService.quality !== 'stable' && extensionService.extensions.some(e => e.enabledApiProposals?.includes('chatReadonlyPromptReference'));99readonlyEnabledContextKey.set(enabled);100};101setReadonlyFilesEnabled();102this._register(extensionService.onDidRegisterExtensions(setReadonlyFilesEnabled));103this._register(extensionService.onDidChangeExtensions(setReadonlyFilesEnabled));104105106// eslint-disable-next-line @typescript-eslint/no-explicit-any107let storageTask: Promise<any> | undefined;108109this._register(storageService.onWillSaveState(() => {110// eslint-disable-next-line @typescript-eslint/no-explicit-any111const tasks: Promise<any>[] = [];112113for (const session of this.editingSessionsObs.get()) {114if (!session.isGlobalEditingSession) {115continue;116}117tasks.push((session as ChatEditingSession).storeState());118}119120storageTask = Promise.resolve(storageTask)121.then(() => Promise.all(tasks))122.finally(() => storageTask = undefined);123}));124125this._register(this.lifecycleService.onWillShutdown(e => {126if (!storageTask) {127return;128}129e.join(storageTask, {130id: 'join.chatEditingSession',131label: localize('join.chatEditingSession', "Saving chat edits history")132});133}));134}135136override dispose(): void {137dispose(this._sessionsObs.get());138super.dispose();139}140141startOrContinueGlobalEditingSession(chatModel: ChatModel): IChatEditingSession {142return this.getEditingSession(chatModel.sessionResource) || this.createEditingSession(chatModel, true);143}144145private _lookupEntry(uri: URI): AbstractChatEditingModifiedFileEntry | undefined {146147for (const item of Iterable.concat(this.editingSessionsObs.get())) {148const candidate = item.getEntry(uri);149if (candidate instanceof AbstractChatEditingModifiedFileEntry) {150// make sure to ref-count this object151return candidate.acquire();152}153}154return undefined;155}156157getEditingSession(chatSessionResource: URI): IChatEditingSession | undefined {158return this.editingSessionsObs.get()159.find(candidate => isEqual(candidate.chatSessionResource, chatSessionResource));160}161162createEditingSession(chatModel: ChatModel, global: boolean = false): IChatEditingSession {163return this._createEditingSession(chatModel, global, undefined);164}165166transferEditingSession(chatModel: ChatModel, session: IChatEditingSession): IChatEditingSession {167return this._createEditingSession(chatModel, session.isGlobalEditingSession, session);168}169170private _createEditingSession(chatModel: ChatModel, global: boolean, initFrom: IChatEditingSession | undefined): IChatEditingSession {171172assertType(this.getEditingSession(chatModel.sessionResource) === undefined, 'CANNOT have more than one editing session per chat session');173174const session = this._instantiationService.createInstance(ChatEditingSession, chatModel.sessionResource, global, this._lookupEntry.bind(this), initFrom);175176const list = this._sessionsObs.get();177const removeSession = list.unshift(session);178179const store = new DisposableStore();180this._store.add(store);181182store.add(this.installAutoApplyObserver(session, chatModel));183184store.add(session.onDidDispose(e => {185removeSession();186this._sessionsObs.set(list, undefined);187this._store.delete(store);188}));189190this._sessionsObs.set(list, undefined);191192return session;193}194195private installAutoApplyObserver(session: ChatEditingSession, chatModel: ChatModel): IDisposable {196if (!chatModel) {197throw new ErrorNoTelemetry(`Edit session was created for a non-existing chat session: ${session.chatSessionResource}`);198}199200const observerDisposables = new DisposableStore();201202observerDisposables.add(chatModel.onDidChange(async e => {203if (e.kind !== 'addRequest') {204return;205}206session.createSnapshot(e.request.id, undefined);207const responseModel = e.request.response;208if (responseModel) {209this.observerEditsInResponse(e.request.id, responseModel, session, observerDisposables);210}211}));212observerDisposables.add(chatModel.onDidDispose(() => observerDisposables.dispose()));213return observerDisposables;214}215216private observerEditsInResponse(requestId: string, responseModel: IChatResponseModel, session: ChatEditingSession, observerDisposables: DisposableStore) {217// Sparse array: the indicies are indexes of `responseModel.response.value`218// that are edit groups, and then this tracks the edit application for219// each of them. Note that text edit groups can be updated220// multiple times during the process of response streaming.221const enum K { Stream, Workspace }222const editsSeen: ({ kind: K.Stream; seen: number; stream: IStreamingEdits } | { kind: K.Workspace })[] = [];223224let editorDidChange = false;225const editorListener = Event.once(this._editorService.onDidActiveEditorChange)(() => {226editorDidChange = true;227});228const editorOpenPromises = new ResourceMap<Promise<void>>();229const openChatEditedFiles = this._configurationService.getValue('accessibility.openChatEditedFiles');230231const ensureEditorOpen = (partUri: URI) => {232const uri = CellUri.parse(partUri)?.notebook ?? partUri;233if (editorOpenPromises.has(uri)) {234return;235}236editorOpenPromises.set(uri, (async () => {237if (this.notebookService.getNotebookTextModel(uri) || uri.scheme === Schemas.untitled || await this._fileService.exists(uri).catch(() => false)) {238const activeUri = this._editorService.activeEditorPane?.input.resource;239const inactive = editorDidChange240|| this._editorService.activeEditorPane?.input instanceof ChatEditorInput && isEqual(this._editorService.activeEditorPane.input.sessionResource, session.chatSessionResource)241|| Boolean(activeUri && session.entries.get().find(entry => isEqual(activeUri, entry.modifiedURI)));242243this._editorService.openEditor({ resource: uri, options: { inactive, preserveFocus: true, pinned: true } });244}245})());246};247248const onResponseComplete = () => {249for (const remaining of editsSeen) {250if (remaining?.kind === K.Stream) {251remaining.stream.complete();252}253}254255editsSeen.length = 0;256editorOpenPromises.clear();257editorListener.dispose();258};259260const handleResponseParts = async () => {261if (responseModel.isCanceled) {262return;263}264265let undoStop: undefined | string;266for (let i = 0; i < responseModel.response.value.length; i++) {267const part = responseModel.response.value[i];268269if (part.kind === 'undoStop') {270undoStop = part.id;271continue;272}273274if (part.kind === 'workspaceEdit') {275// Track if we've already started processing this workspace edit276if (!editsSeen[i]) {277editsSeen[i] = { kind: K.Workspace };278session.applyWorkspaceEdit(part, responseModel, undoStop ?? responseModel.requestId);279}280continue;281}282283if (part.kind !== 'textEditGroup' && part.kind !== 'notebookEditGroup') {284continue;285}286287// Skip external edits - they're already applied on disk288if (part.isExternalEdit) {289continue;290}291292if (openChatEditedFiles) {293ensureEditorOpen(part.uri);294}295296// get new edits and start editing session297let entry = editsSeen[i];298if (!entry) {299entry = { kind: K.Stream, seen: 0, stream: session.startStreamingEdits(CellUri.parse(part.uri)?.notebook ?? part.uri, responseModel, undoStop) };300editsSeen[i] = entry;301}302303if (entry.kind !== K.Stream) {304continue;305}306307const isFirst = entry.seen === 0;308const newEdits = part.edits.slice(entry.seen);309entry.seen = part.edits.length;310311if (newEdits.length > 0 || isFirst) {312for (let i = 0; i < newEdits.length; i++) {313const edit = newEdits[i];314const done = part.done ? i === newEdits.length - 1 : false;315316if (isTextEditOperationArray(edit)) {317entry.stream.pushText(edit, done);318} else if (isCellTextEditOperationArray(edit)) {319for (const edits of Object.values(groupBy(edit, e => e.uri.toString()))) {320if (edits) {321entry.stream.pushNotebookCellText(edits[0].uri, edits.map(e => e.edit), done);322}323}324} else {325entry.stream.pushNotebook(edit, done);326}327}328}329330if (part.done) {331entry.stream.complete();332}333}334};335336if (responseModel.isComplete) {337handleResponseParts().then(() => {338onResponseComplete();339});340} else {341const disposable = observerDisposables.add(responseModel.onDidChange(e2 => {342if (e2.reason === 'undoStop') {343session.createSnapshot(requestId, e2.id);344} else {345handleResponseParts().then(() => {346if (responseModel.isComplete) {347onResponseComplete();348observerDisposables.delete(disposable);349}350});351}352}));353}354}355}356357/**358* Emits an event containing the added or removed elements of the observable.359*/360function observeArrayChanges<T>(obs: IObservable<T[]>, compare: (a: T, b: T) => number, store: DisposableStore): Event<T[]> {361const emitter = store.add(new Emitter<T[]>());362store.add(runOnChange(obs, (newArr, oldArr) => {363const change = delta(oldArr || [], newArr, compare);364const changedElements = ([] as T[]).concat(change.added).concat(change.removed);365emitter.fire(changedElements);366}));367return emitter.event;368}369370class ChatDecorationsProvider extends Disposable implements IDecorationsProvider {371372readonly label: string = localize('chat', "Chat Editing");373374private readonly _currentEntries = derived<readonly IModifiedFileEntry[]>(this, (r) => {375const sessions = this._sessions.read(r);376if (!sessions) {377return [];378}379const result: IModifiedFileEntry[] = [];380for (const session of sessions) {381if (session.state.read(r) !== ChatEditingSessionState.Disposed) {382const entries = session.entries.read(r);383result.push(...entries);384}385}386return result;387});388389private readonly _currentlyEditingUris = derived<URI[]>(this, (r) => {390const uri = this._currentEntries.read(r);391return uri.filter(entry => entry.isCurrentlyBeingModifiedBy.read(r)).map(entry => entry.modifiedURI);392});393394private readonly _modifiedUris = derived<URI[]>(this, (r) => {395const uri = this._currentEntries.read(r);396return uri.filter(entry => !entry.isCurrentlyBeingModifiedBy.read(r) && entry.state.read(r) === ModifiedFileEntryState.Modified).map(entry => entry.modifiedURI);397});398399readonly onDidChange: Event<URI[]>;400401constructor(402private readonly _sessions: IObservable<readonly IChatEditingSession[]>403) {404super();405this.onDidChange = Event.any(406observeArrayChanges(this._currentlyEditingUris, compareBy(uri => uri.toString(), compare), this._store),407observeArrayChanges(this._modifiedUris, compareBy(uri => uri.toString(), compare), this._store),408);409}410411provideDecorations(uri: URI, _token: CancellationToken): IDecorationData | undefined {412const isCurrentlyBeingModified = this._currentlyEditingUris.get().some(e => e.toString() === uri.toString());413if (isCurrentlyBeingModified) {414return {415weight: 1000,416letter: ThemeIcon.modify(Codicon.loading, 'spin'),417bubble: false418};419}420const isModified = this._modifiedUris.get().some(e => e.toString() === uri.toString());421if (isModified) {422return {423weight: 1000,424letter: Codicon.diffModified,425tooltip: localize('chatEditing.modified2', "Pending changes from chat"),426bubble: true427};428}429return undefined;430}431}432433export class ChatEditingMultiDiffSourceResolver implements IMultiDiffSourceResolver {434435constructor(436private readonly _editingSessionsObs: IObservable<readonly IChatEditingSession[]>,437@IInstantiationService private readonly _instantiationService: IInstantiationService,438) { }439440canHandleUri(uri: URI): boolean {441return uri.scheme === CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME;442}443444async resolveDiffSource(uri: URI): Promise<IResolvedMultiDiffSource> {445446const parsed = parseChatMultiDiffUri(uri);447const thisSession = derived(this, r => {448return this._editingSessionsObs.read(r).find(candidate => isEqual(candidate.chatSessionResource, parsed.chatSessionResource));449});450451return this._instantiationService.createInstance(ChatEditingMultiDiffSource, thisSession, parsed.showPreviousChanges);452}453}454455class ChatEditingMultiDiffSource implements IResolvedMultiDiffSource {456private readonly _resources = derived<readonly MultiDiffEditorItem[]>(this, (reader) => {457const currentSession = this._currentSession.read(reader);458if (!currentSession) {459return [];460}461const entries = currentSession.entries.read(reader);462return entries.map((entry) => {463if (this._showPreviousChanges) {464const entryDiffObs = currentSession.getEntryDiffBetweenStops(entry.modifiedURI, undefined, undefined);465const entryDiff = entryDiffObs?.read(reader);466if (entryDiff) {467return new MultiDiffEditorItem(468entryDiff.originalURI,469entryDiff.modifiedURI,470undefined,471undefined,472{473[chatEditingResourceContextKey.key]: entry.entryId,474},475);476}477}478479return new MultiDiffEditorItem(480entry.originalURI,481entry.modifiedURI,482undefined,483undefined,484{485[chatEditingResourceContextKey.key]: entry.entryId,486// [inChatEditingSessionContextKey.key]: true487},488);489});490});491readonly resources = new ValueWithChangeEventFromObservable(this._resources);492493readonly contextKeys = {494[inChatEditingSessionContextKey.key]: true495};496497constructor(498private readonly _currentSession: IObservable<IChatEditingSession | undefined>,499private readonly _showPreviousChanges: boolean500) { }501}502503function isTextEditOperationArray(value: TextEdit[] | ICellTextEditOperation[] | ICellEditOperation[]): value is TextEdit[] {504return value.some(e => TextEdit.isTextEdit(e));505}506507508