Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { coalesce, compareBy, delta } from '../../../../../base/common/arrays.js';6import { CancellationToken } from '../../../../../base/common/cancellation.js';7import { Codicon } from '../../../../../base/common/codicons.js';8import { ErrorNoTelemetry } from '../../../../../base/common/errors.js';9import { Emitter, Event } from '../../../../../base/common/event.js';10import { Iterable } from '../../../../../base/common/iterator.js';11import { Disposable, DisposableStore, dispose, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';12import { LinkedList } from '../../../../../base/common/linkedList.js';13import { ResourceMap } from '../../../../../base/common/map.js';14import { Schemas } from '../../../../../base/common/network.js';15import { derived, IObservable, observableValueOpts, runOnChange, ValueWithChangeEventFromObservable } from '../../../../../base/common/observable.js';16import { isEqual } from '../../../../../base/common/resources.js';17import { compare } from '../../../../../base/common/strings.js';18import { ThemeIcon } from '../../../../../base/common/themables.js';19import { assertType } from '../../../../../base/common/types.js';20import { URI } from '../../../../../base/common/uri.js';21import { TextEdit } from '../../../../../editor/common/languages.js';22import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';23import { localize } from '../../../../../nls.js';24import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';25import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';26import { IFileService } from '../../../../../platform/files/common/files.js';27import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';28import { ILogService } from '../../../../../platform/log/common/log.js';29import { IProductService } from '../../../../../platform/product/common/productService.js';30import { IStorageService } from '../../../../../platform/storage/common/storage.js';31import { IDecorationData, IDecorationsProvider, IDecorationsService } from '../../../../services/decorations/common/decorations.js';32import { IEditorService } from '../../../../services/editor/common/editorService.js';33import { IExtensionService } from '../../../../services/extensions/common/extensions.js';34import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js';35import { IMultiDiffSourceResolver, IMultiDiffSourceResolverService, IResolvedMultiDiffSource, MultiDiffEditorItem } from '../../../multiDiffEditor/browser/multiDiffSourceResolverService.js';36import { CellUri } from '../../../notebook/common/notebookCommon.js';37import { INotebookService } from '../../../notebook/common/notebookService.js';38import { IChatAgentService } from '../../common/chatAgents.js';39import { CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingAgentSupportsReadonlyReferencesContextKey, chatEditingResourceContextKey, ChatEditingSessionState, IChatEditingService, IChatEditingSession, IChatRelatedFile, IChatRelatedFilesProvider, IModifiedFileEntry, inChatEditingSessionContextKey, IStreamingEdits, ModifiedFileEntryState, parseChatMultiDiffUri } from '../../common/chatEditingService.js';40import { ChatModel, IChatResponseModel, isCellTextEditOperation } from '../../common/chatModel.js';41import { IChatService } from '../../common/chatService.js';42import { ChatAgentLocation } from '../../common/constants.js';43import { ChatEditorInput } from '../chatEditorInput.js';44import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js';45import { ChatEditingSession } from './chatEditingSession.js';46import { ChatEditingSnapshotTextModelContentProvider, ChatEditingTextModelContentProvider } from './chatEditingTextModelContentProviders.js';4748export class ChatEditingService extends Disposable implements IChatEditingService {4950_serviceBrand: undefined;515253private readonly _sessionsObs = observableValueOpts<LinkedList<ChatEditingSession>>({ equalsFn: (a, b) => false }, new LinkedList());5455readonly editingSessionsObs: IObservable<readonly IChatEditingSession[]> = derived(r => {56const result = Array.from(this._sessionsObs.read(r));57return result;58});5960private _restoringEditingSession: Promise<any> | undefined;6162private _chatRelatedFilesProviders = new Map<number, IChatRelatedFilesProvider>();6364constructor(65@IInstantiationService private readonly _instantiationService: IInstantiationService,66@IMultiDiffSourceResolverService multiDiffSourceResolverService: IMultiDiffSourceResolverService,67@ITextModelService textModelService: ITextModelService,68@IContextKeyService contextKeyService: IContextKeyService,69@IChatService private readonly _chatService: IChatService,70@IEditorService private readonly _editorService: IEditorService,71@IDecorationsService decorationsService: IDecorationsService,72@IFileService private readonly _fileService: IFileService,73@ILifecycleService private readonly lifecycleService: ILifecycleService,74@IStorageService storageService: IStorageService,75@ILogService logService: ILogService,76@IExtensionService extensionService: IExtensionService,77@IProductService productService: IProductService,78@INotebookService private readonly notebookService: INotebookService,79@IConfigurationService private readonly _configurationService: IConfigurationService,80) {81super();82this._register(decorationsService.registerDecorationsProvider(_instantiationService.createInstance(ChatDecorationsProvider, this.editingSessionsObs)));83this._register(multiDiffSourceResolverService.registerResolver(_instantiationService.createInstance(ChatEditingMultiDiffSourceResolver, this.editingSessionsObs)));8485// TODO@jrieken86// some ugly casting so that this service can pass itself as argument instad as service dependeny87this._register(textModelService.registerTextModelContentProvider(ChatEditingTextModelContentProvider.scheme, _instantiationService.createInstance(ChatEditingTextModelContentProvider as any, this)));88this._register(textModelService.registerTextModelContentProvider(Schemas.chatEditingSnapshotScheme, _instantiationService.createInstance(ChatEditingSnapshotTextModelContentProvider as any, this)));8990this._register(this._chatService.onDidDisposeSession((e) => {91if (e.reason === 'cleared') {92this.getEditingSession(e.sessionId)?.stop();93}94}));9596// todo@connor4312: temporary until chatReadonlyPromptReference proposal is finalized97const readonlyEnabledContextKey = chatEditingAgentSupportsReadonlyReferencesContextKey.bindTo(contextKeyService);98const setReadonlyFilesEnabled = () => {99const enabled = productService.quality !== 'stable' && extensionService.extensions.some(e => e.enabledApiProposals?.includes('chatReadonlyPromptReference'));100readonlyEnabledContextKey.set(enabled);101};102setReadonlyFilesEnabled();103this._register(extensionService.onDidRegisterExtensions(setReadonlyFilesEnabled));104this._register(extensionService.onDidChangeExtensions(setReadonlyFilesEnabled));105106107let storageTask: Promise<any> | undefined;108109this._register(storageService.onWillSaveState(() => {110const tasks: Promise<any>[] = [];111112for (const session of this.editingSessionsObs.get()) {113if (!session.isGlobalEditingSession) {114continue;115}116tasks.push((session as ChatEditingSession).storeState());117}118119storageTask = Promise.resolve(storageTask)120.then(() => Promise.all(tasks))121.finally(() => storageTask = undefined);122}));123124this._register(this.lifecycleService.onWillShutdown(e => {125if (!storageTask) {126return;127}128e.join(storageTask, {129id: 'join.chatEditingSession',130label: localize('join.chatEditingSession', "Saving chat edits history")131});132}));133}134135override dispose(): void {136dispose(this._sessionsObs.get());137super.dispose();138}139140async startOrContinueGlobalEditingSession(chatModel: ChatModel, waitForRestore = true): Promise<IChatEditingSession> {141if (waitForRestore) {142await this._restoringEditingSession;143}144145const session = this.getEditingSession(chatModel.sessionId);146if (session) {147return session;148}149const result = await this.createEditingSession(chatModel, true);150return result;151}152153154private _lookupEntry(uri: URI): AbstractChatEditingModifiedFileEntry | undefined {155156for (const item of Iterable.concat(this.editingSessionsObs.get())) {157const candidate = item.getEntry(uri);158if (candidate instanceof AbstractChatEditingModifiedFileEntry) {159// make sure to ref-count this object160return candidate.acquire();161}162}163return undefined;164}165166getEditingSession(chatSessionId: string): IChatEditingSession | undefined {167return this.editingSessionsObs.get()168.find(candidate => candidate.chatSessionId === chatSessionId);169}170171async createEditingSession(chatModel: ChatModel, global: boolean = false): Promise<IChatEditingSession> {172173assertType(this.getEditingSession(chatModel.sessionId) === undefined, 'CANNOT have more than one editing session per chat session');174175const session = this._instantiationService.createInstance(ChatEditingSession, chatModel.sessionId, global, this._lookupEntry.bind(this));176await session.init();177178const list = this._sessionsObs.get();179const removeSession = list.unshift(session);180181const store = new DisposableStore();182this._store.add(store);183184store.add(this.installAutoApplyObserver(session, chatModel));185186store.add(session.onDidDispose(e => {187removeSession();188this._sessionsObs.set(list, undefined);189this._store.delete(store);190}));191192this._sessionsObs.set(list, undefined);193194return session;195}196197private installAutoApplyObserver(session: ChatEditingSession, chatModel: ChatModel): IDisposable {198if (!chatModel) {199throw new ErrorNoTelemetry(`Edit session was created for a non-existing chat session: ${session.chatSessionId}`);200}201202const observerDisposables = new DisposableStore();203204observerDisposables.add(chatModel.onDidChange(async e => {205if (e.kind !== 'addRequest') {206return;207}208session.createSnapshot(e.request.id, undefined);209const responseModel = e.request.response;210if (responseModel) {211this.observerEditsInResponse(e.request.id, responseModel, session, observerDisposables);212}213}));214observerDisposables.add(chatModel.onDidDispose(() => observerDisposables.dispose()));215return observerDisposables;216}217218private observerEditsInResponse(requestId: string, responseModel: IChatResponseModel, session: ChatEditingSession, observerDisposables: DisposableStore) {219// Sparse array: the indicies are indexes of `responseModel.response.value`220// that are edit groups, and then this tracks the edit application for221// each of them. Note that text edit groups can be updated222// multiple times during the process of response streaming.223const editsSeen: ({ seen: number; streaming: IStreamingEdits } | undefined)[] = [];224225let editorDidChange = false;226const editorListener = Event.once(this._editorService.onDidActiveEditorChange)(() => {227editorDidChange = true;228});229230const editedFilesExist = new ResourceMap<Promise<void>>();231const ensureEditorOpen = (partUri: URI) => {232const uri = CellUri.parse(partUri)?.notebook ?? partUri;233if (editedFilesExist.has(uri)) {234return;235}236237const fileExists = this.notebookService.getNotebookTextModel(uri) ? Promise.resolve(true) : this._fileService.exists(uri);238editedFilesExist.set(uri, fileExists.then((e) => {239if (!e) {240return;241}242const activeUri = this._editorService.activeEditorPane?.input.resource;243const inactive = editorDidChange244|| this._editorService.activeEditorPane?.input instanceof ChatEditorInput && this._editorService.activeEditorPane.input.sessionId === session.chatSessionId245|| Boolean(activeUri && session.entries.get().find(entry => isEqual(activeUri, entry.modifiedURI)));246if (this._configurationService.getValue('accessibility.openChatEditedFiles')) {247this._editorService.openEditor({ resource: uri, options: { inactive, preserveFocus: true, pinned: true } });248}249}));250};251252const onResponseComplete = () => {253for (const remaining of editsSeen) {254remaining?.streaming.complete();255}256257editsSeen.length = 0;258editedFilesExist.clear();259editorListener.dispose();260};261262const handleResponseParts = async () => {263if (responseModel.isCanceled) {264return;265}266267let undoStop: undefined | string;268for (let i = 0; i < responseModel.response.value.length; i++) {269const part = responseModel.response.value[i];270271if (part.kind === 'undoStop') {272undoStop = part.id;273continue;274}275276if (part.kind !== 'textEditGroup' && part.kind !== 'notebookEditGroup') {277continue;278}279280ensureEditorOpen(part.uri);281282// get new edits and start editing session283let entry = editsSeen[i];284if (!entry) {285entry = { seen: 0, streaming: session.startStreamingEdits(CellUri.parse(part.uri)?.notebook ?? part.uri, responseModel, undoStop) };286editsSeen[i] = entry;287}288289const isFirst = entry.seen === 0;290const newEdits = part.edits.slice(entry.seen).flat();291entry.seen = part.edits.length;292293if (newEdits.length > 0 || isFirst) {294if (part.kind === 'notebookEditGroup') {295newEdits.forEach((edit, idx) => {296const done = part.done ? idx === newEdits.length - 1 : false;297if (TextEdit.isTextEdit(edit)) {298// Not possible, as Notebooks would have a different type.299return;300} else if (isCellTextEditOperation(edit)) {301entry.streaming.pushNotebookCellText(edit.uri, [edit.edit], done);302} else {303entry.streaming.pushNotebook([edit], done);304}305});306} else if (part.kind === 'textEditGroup') {307entry.streaming.pushText(newEdits as TextEdit[], part.done ?? false);308}309}310311if (part.done) {312entry.streaming.complete();313}314}315};316317if (responseModel.isComplete) {318handleResponseParts().then(() => {319onResponseComplete();320});321} else {322const disposable = observerDisposables.add(responseModel.onDidChange(e2 => {323if (e2.reason === 'undoStop') {324session.createSnapshot(requestId, e2.id);325} else {326handleResponseParts().then(() => {327if (responseModel.isComplete) {328onResponseComplete();329observerDisposables.delete(disposable);330}331});332}333}));334}335}336337hasRelatedFilesProviders(): boolean {338return this._chatRelatedFilesProviders.size > 0;339}340341registerRelatedFilesProvider(handle: number, provider: IChatRelatedFilesProvider): IDisposable {342this._chatRelatedFilesProviders.set(handle, provider);343return toDisposable(() => {344this._chatRelatedFilesProviders.delete(handle);345});346}347348async getRelatedFiles(chatSessionId: string, prompt: string, files: URI[], token: CancellationToken): Promise<{ group: string; files: IChatRelatedFile[] }[] | undefined> {349const providers = Array.from(this._chatRelatedFilesProviders.values());350const result = await Promise.all(providers.map(async provider => {351try {352const relatedFiles = await provider.provideRelatedFiles({ prompt, files }, token);353if (relatedFiles?.length) {354return { group: provider.description, files: relatedFiles };355}356return undefined;357} catch (e) {358return undefined;359}360}));361362return coalesce(result);363}364}365366/**367* Emits an event containing the added or removed elements of the observable.368*/369function observeArrayChanges<T>(obs: IObservable<T[]>, compare: (a: T, b: T) => number, store: DisposableStore): Event<T[]> {370const emitter = store.add(new Emitter<T[]>());371store.add(runOnChange(obs, (newArr, oldArr) => {372const change = delta(oldArr || [], newArr, compare);373const changedElements = ([] as T[]).concat(change.added).concat(change.removed);374emitter.fire(changedElements);375}));376return emitter.event;377}378379class ChatDecorationsProvider extends Disposable implements IDecorationsProvider {380381readonly label: string = localize('chat', "Chat Editing");382383private readonly _currentEntries = derived<readonly IModifiedFileEntry[]>(this, (r) => {384const sessions = this._sessions.read(r);385if (!sessions) {386return [];387}388const result: IModifiedFileEntry[] = [];389for (const session of sessions) {390if (session.state.read(r) !== ChatEditingSessionState.Disposed) {391const entries = session.entries.read(r);392result.push(...entries);393}394}395return result;396});397398private readonly _currentlyEditingUris = derived<URI[]>(this, (r) => {399const uri = this._currentEntries.read(r);400return uri.filter(entry => entry.isCurrentlyBeingModifiedBy.read(r)).map(entry => entry.modifiedURI);401});402403private readonly _modifiedUris = derived<URI[]>(this, (r) => {404const uri = this._currentEntries.read(r);405return uri.filter(entry => !entry.isCurrentlyBeingModifiedBy.read(r) && entry.state.read(r) === ModifiedFileEntryState.Modified).map(entry => entry.modifiedURI);406});407408readonly onDidChange: Event<URI[]>;409410constructor(411private readonly _sessions: IObservable<readonly IChatEditingSession[]>,412@IChatAgentService private readonly _chatAgentService: IChatAgentService413) {414super();415this.onDidChange = Event.any(416observeArrayChanges(this._currentlyEditingUris, compareBy(uri => uri.toString(), compare), this._store),417observeArrayChanges(this._modifiedUris, compareBy(uri => uri.toString(), compare), this._store),418);419}420421provideDecorations(uri: URI, _token: CancellationToken): IDecorationData | undefined {422const isCurrentlyBeingModified = this._currentlyEditingUris.get().some(e => e.toString() === uri.toString());423if (isCurrentlyBeingModified) {424return {425weight: 1000,426letter: ThemeIcon.modify(Codicon.loading, 'spin'),427bubble: false428};429}430const isModified = this._modifiedUris.get().some(e => e.toString() === uri.toString());431if (isModified) {432const defaultAgentName = this._chatAgentService.getDefaultAgent(ChatAgentLocation.Panel)?.fullName;433return {434weight: 1000,435letter: Codicon.diffModified,436tooltip: defaultAgentName ? localize('chatEditing.modified', "Pending changes from {0}", defaultAgentName) : localize('chatEditing.modified2', "Pending changes from chat"),437bubble: true438};439}440return undefined;441}442}443444export class ChatEditingMultiDiffSourceResolver implements IMultiDiffSourceResolver {445446constructor(447private readonly _editingSessionsObs: IObservable<readonly IChatEditingSession[]>,448@IInstantiationService private readonly _instantiationService: IInstantiationService,449) { }450451canHandleUri(uri: URI): boolean {452return uri.scheme === CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME;453}454455async resolveDiffSource(uri: URI): Promise<IResolvedMultiDiffSource> {456457const parsed = parseChatMultiDiffUri(uri);458const thisSession = derived(this, r => {459return this._editingSessionsObs.read(r).find(candidate => candidate.chatSessionId === parsed.chatSessionId);460});461462return this._instantiationService.createInstance(ChatEditingMultiDiffSource, thisSession, parsed.showPreviousChanges);463}464}465466class ChatEditingMultiDiffSource implements IResolvedMultiDiffSource {467private readonly _resources = derived<readonly MultiDiffEditorItem[]>(this, (reader) => {468const currentSession = this._currentSession.read(reader);469if (!currentSession) {470return [];471}472const entries = currentSession.entries.read(reader);473return entries.map((entry) => {474if (this._showPreviousChanges) {475const entryDiffObs = currentSession.getEntryDiffBetweenStops(entry.modifiedURI, undefined, undefined);476const entryDiff = entryDiffObs?.read(reader);477if (entryDiff) {478return new MultiDiffEditorItem(479entryDiff.originalURI,480entryDiff.modifiedURI,481undefined,482undefined,483{484[chatEditingResourceContextKey.key]: entry.entryId,485},486);487}488}489490return new MultiDiffEditorItem(491entry.originalURI,492entry.modifiedURI,493undefined,494undefined,495{496[chatEditingResourceContextKey.key]: entry.entryId,497// [inChatEditingSessionContextKey.key]: true498},499);500});501});502readonly resources = new ValueWithChangeEventFromObservable(this._resources);503504readonly contextKeys = {505[inChatEditingSessionContextKey.key]: true506};507508constructor(509private readonly _currentSession: IObservable<IChatEditingSession | undefined>,510private readonly _showPreviousChanges: boolean511) { }512}513514515