From b8b5a4aad050f5ffabce934aa36f72b0f2c13665 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Tue, 16 Jan 2024 14:50:19 +0100 Subject: [PATCH 1/2] Move hunk computation into sessions, let strategies render them (#202578) * chore - move chat session service implementation and interface into their own files * chore - move chat saving service implementation and interface into their own files * - move hunks into session (instead of strategy) - recompute them after receiving AI changes - accept& discard moves hunks from textModelN to textModel0 and vice versa - service renames - tests * - session doesn't know about an editor, only service does - allow to "move" session to a different editor - let controller pickup session after move to its editor - session saving picks up orphand sessions * try to restore editors when group is still valid * ctrl - don't pause when cancellation happens during session create * fix tests --- .../emptyTextEditorHint.ts | 2 +- .../browser/inlineChat.contribution.ts | 10 +- .../inlineChat/browser/inlineChatActions.ts | 2 +- .../browser/inlineChatController.ts | 90 ++-- .../inlineChat/browser/inlineChatNotebook.ts | 2 +- .../browser/inlineChatSavingService.ts | 16 + ...ving.ts => inlineChatSavingServiceImpl.ts} | 157 ++++--- .../inlineChat/browser/inlineChatSession.ts | 374 +++++++-------- .../browser/inlineChatSessionService.ts | 53 +++ .../browser/inlineChatSessionServiceImpl.ts | 223 +++++++++ .../browser/inlineChatStrategies.ts | 434 ++++++------------ .../contrib/inlineChat/browser/utils.ts | 6 + .../test/browser/inlineChatController.test.ts | 8 +- .../test/browser/inlineChatSession.test.ts | 316 ++++++++++++- .../contrib/editorHint/emptyCellEditorHint.ts | 2 +- .../view/cellParts/chat/cellChatController.ts | 3 +- 16 files changed, 1116 insertions(+), 582 deletions(-) create mode 100644 src/vs/workbench/contrib/inlineChat/browser/inlineChatSavingService.ts rename src/vs/workbench/contrib/inlineChat/browser/{inlineChatSaving.ts => inlineChatSavingServiceImpl.ts} (52%) create mode 100644 src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts create mode 100644 src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts diff --git a/src/vs/workbench/contrib/codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint.ts b/src/vs/workbench/contrib/codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint.ts index ba47bfed2e7..c103f95c23a 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint.ts @@ -21,7 +21,7 @@ import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { IContentActionHandler, renderFormattedText } from 'vs/base/browser/formattedTextRenderer'; import { ApplyFileSnippetAction } from 'vs/workbench/contrib/snippets/browser/commands/fileTemplateSnippets'; -import { IInlineChatSessionService } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; +import { IInlineChatSessionService } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSessionService'; import { IInlineChatService, IInlineChatSessionProvider } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from 'vs/base/common/actions'; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts index bf3ff77ac18..d68c3dc3dc8 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts @@ -10,17 +10,19 @@ import * as InlineChatActions from 'vs/workbench/contrib/inlineChat/browser/inli import { IInlineChatService, INLINE_CHAT_ID, INTERACTIVE_EDITOR_ACCESSIBILITY_HELP_ID } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { InlineChatServiceImpl } from 'vs/workbench/contrib/inlineChat/common/inlineChatServiceImpl'; -import { IInlineChatSessionService, InlineChatSessionService } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; +import { InlineChatSessionServiceImpl } from './inlineChatSessionServiceImpl'; +import { IInlineChatSessionService } from './inlineChatSessionService'; import { Registry } from 'vs/platform/registry/common/platform'; import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; import { InlineChatNotebookContribution } from 'vs/workbench/contrib/inlineChat/browser/inlineChatNotebook'; import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from 'vs/workbench/common/contributions'; import { InlineChatAccessibleViewContribution } from './inlineChatAccessibleView'; -import { IInlineChatSavingService, InlineChatSavingService } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSaving'; +import { InlineChatSavingServiceImpl } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSavingServiceImpl'; +import { IInlineChatSavingService } from './inlineChatSavingService'; registerSingleton(IInlineChatService, InlineChatServiceImpl, InstantiationType.Delayed); -registerSingleton(IInlineChatSessionService, InlineChatSessionService, InstantiationType.Delayed); -registerSingleton(IInlineChatSavingService, InlineChatSavingService, InstantiationType.Delayed); +registerSingleton(IInlineChatSessionService, InlineChatSessionServiceImpl, InstantiationType.Delayed); +registerSingleton(IInlineChatSavingService, InlineChatSavingServiceImpl, InstantiationType.Delayed); registerEditorContribution(INLINE_CHAT_ID, InlineChatController, EditorContributionInstantiation.Eager); // EAGER because of notebook dispose/create of editors registerEditorContribution(INTERACTIVE_EDITOR_ACCESSIBILITY_HELP_ID, InlineChatActions.InlineAccessibilityHelpContribution, EditorContributionInstantiation.Eventually); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index ecca297e868..c16518017fb 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -22,7 +22,7 @@ import { IEditorService, SIDE_GROUP } from 'vs/workbench/services/editor/common/ import { IUntitledTextResourceEditorInput } from 'vs/workbench/common/editor'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; import { fromNow } from 'vs/base/common/date'; -import { IInlineChatSessionService, Recording } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; +import { IInlineChatSessionService, Recording } from './inlineChatSessionService'; import { runAccessibilityHelpAction } from 'vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp'; import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from 'vs/platform/accessibility/common/accessibility'; import { Disposable } from 'vs/base/common/lifecycle'; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index a6f3a10de37..91e8f5a8126 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -39,8 +39,9 @@ import { IChatAccessibilityService, IChatWidgetService } from 'vs/workbench/cont import { IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; import { chatAgentLeader, chatSubcommandLeader } from 'vs/workbench/contrib/chat/common/chatParserTypes'; import { IChatService } from 'vs/workbench/contrib/chat/common/chatService'; -import { IInlineChatSavingService } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSaving'; -import { EmptyResponse, ErrorResponse, ExpansionState, IInlineChatSessionService, ReplyResponse, Session, SessionExchange, SessionPrompt } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; +import { IInlineChatSavingService } from './inlineChatSavingService'; +import { EmptyResponse, ErrorResponse, ExpansionState, ReplyResponse, Session, SessionExchange, SessionPrompt } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; +import { IInlineChatSessionService } from './inlineChatSessionService'; import { EditModeStrategy, LivePreviewStrategy, LiveStrategy, PreviewStrategy, ProgressingEditsOptions } from 'vs/workbench/contrib/inlineChat/browser/inlineChatStrategies'; import { IInlineChatMessageAppender, InlineChatZoneWidget } from 'vs/workbench/contrib/inlineChat/browser/inlineChatWidget'; import { CTX_INLINE_CHAT_DID_EDIT, CTX_INLINE_CHAT_HAS_ACTIVE_REQUEST, CTX_INLINE_CHAT_LAST_FEEDBACK, CTX_INLINE_CHAT_RESPONSE_TYPES, CTX_INLINE_CHAT_SUPPORT_ISSUE_REPORTING, CTX_INLINE_CHAT_USER_DID_EDIT, EditMode, IInlineChatProgressItem, IInlineChatRequest, IInlineChatResponse, INLINE_CHAT_ID, InlineChatConfigKeys, InlineChatResponseFeedbackKind, InlineChatResponseTypes } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; @@ -122,7 +123,7 @@ export class InlineChatController implements IEditorContribution { readonly onDidCancelInput = Event.filter(this._messages.event, m => m === Message.CANCEL_INPUT || m === Message.CANCEL_SESSION, this._store); private readonly _sessionStore: DisposableStore = this._store.add(new DisposableStore()); - private readonly _pausedStrategies = new Map(); + private _session?: Session; private _strategy?: EditModeStrategy; private _ignoreModelContentChanged = false; @@ -161,17 +162,19 @@ export class InlineChatController implements IEditorContribution { return; } - this._log('session RESUMING', e); + this._log('session RESUMING after model change', e); await this.run({ existingSession }); - this._log('session done or paused'); })); - this._log('NEW controller'); - this._store.add(this._inlineChatSessionService.onDidEndSession(e => { - this._pausedStrategies.get(e.session)?.dispose(); - this._pausedStrategies.delete(e.session); + this._store.add(this._inlineChatSessionService.onDidMoveSession(async e => { + if (e.editor === this._editor) { + this._log('session RESUMING after move', e); + await this.run({ existingSession: e.session }); + } })); + this._log('NEW controller'); + InlineChatController._promptHistory = JSON.parse(_storageService.get(InlineChatController._storageKey, StorageScope.PROFILE, '[]')); this._historyUpdate = (prompt: string) => { const idx = InlineChatController._promptHistory.indexOf(prompt); @@ -264,7 +267,7 @@ export class InlineChatController implements IEditorContribution { } } - private async [State.CREATE_SESSION](options: InlineChatRunOptions): Promise { + private async [State.CREATE_SESSION](options: InlineChatRunOptions): Promise { assertType(this._session === undefined); assertType(this._editor.hasModel()); @@ -305,7 +308,10 @@ export class InlineChatController implements IEditorContribution { msgListener.dispose(); if (createSessionCts.token.isCancellationRequested) { - return State.PAUSE; + if (session) { + this._inlineChatSessionService.releaseSession(session); + } + return State.CANCEL; } } @@ -317,24 +323,18 @@ export class InlineChatController implements IEditorContribution { return State.CANCEL; } - if (this._pausedStrategies.has(session)) { - // maybe a strategy was previously paused, use it - this._strategy = this._pausedStrategies.get(session)!; - this._pausedStrategies.delete(session); - } else { - // create a new strategy - switch (session.editMode) { - case EditMode.Live: - this._strategy = this._instaService.createInstance(LiveStrategy, session, this._editor, this._zone.value); - break; - case EditMode.Preview: - this._strategy = this._instaService.createInstance(PreviewStrategy, session, this._zone.value); - break; - case EditMode.LivePreview: - default: - this._strategy = this._instaService.createInstance(LivePreviewStrategy, session, this._editor, this._zone.value); - break; - } + // create a new strategy + switch (session.editMode) { + case EditMode.Live: + this._strategy = this._instaService.createInstance(LiveStrategy, session, this._editor, this._zone.value); + break; + case EditMode.Preview: + this._strategy = this._instaService.createInstance(PreviewStrategy, session, this._zone.value); + break; + case EditMode.LivePreview: + default: + this._strategy = this._instaService.createInstance(LivePreviewStrategy, session, this._editor, this._zone.value); + break; } this._session = session; @@ -690,7 +690,13 @@ export class InlineChatController implements IEditorContribution { msgListener.dispose(); typeListener.dispose(); - if (request.live && !(response instanceof ReplyResponse)) { + if (response instanceof ReplyResponse) { + // update hunks after a reply response + await this._session.hunkData.recompute(); + + } else if (request.live) { + // undo changes that might have been made when not + // having a reply response this._strategy?.undoChanges(modelAltVersionIdNow); } @@ -803,7 +809,6 @@ export class InlineChatController implements IEditorContribution { this._resetWidget(); - this._pausedStrategies.set(this._session, this._strategy); this._strategy.pause?.(); this._session = undefined; } @@ -831,21 +836,22 @@ export class InlineChatController implements IEditorContribution { } private async[State.CANCEL]() { - assertType(this._session); - assertType(this._strategy); - this._sessionStore.clear(); + if (this._session) { + // assertType(this._session); + assertType(this._strategy); + this._sessionStore.clear(); - try { - await this._strategy.cancel(); - } catch (err) { - this._dialogService.error(localize('err.discard', "Failed to discard changes.", toErrorMessage(err))); - this._log('FAILED to discard changes'); - this._log(err); + try { + await this._strategy.cancel(); + } catch (err) { + this._dialogService.error(localize('err.discard', "Failed to discard changes.", toErrorMessage(err))); + this._log('FAILED to discard changes'); + this._log(err); + } + this._inlineChatSessionService.releaseSession(this._session); } this._resetWidget(); - this._inlineChatSessionService.releaseSession(this._session); - this._strategy?.dispose(); this._strategy = undefined; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatNotebook.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatNotebook.ts index 7846aa594a9..2a360f95b2a 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatNotebook.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatNotebook.ts @@ -9,7 +9,7 @@ import { Schemas } from 'vs/base/common/network'; import { isEqual } from 'vs/base/common/resources'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { InlineChatController } from 'vs/workbench/contrib/inlineChat/browser/inlineChatController'; -import { IInlineChatSessionService } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; +import { IInlineChatSessionService } from './inlineChatSessionService'; import { INotebookEditorService } from 'vs/workbench/contrib/notebook/browser/services/notebookEditorService'; import { CellUri } from 'vs/workbench/contrib/notebook/common/notebookCommon'; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSavingService.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSavingService.ts new file mode 100644 index 00000000000..0ed8719ecf8 --- /dev/null +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSavingService.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { Session } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; + + +export const IInlineChatSavingService = createDecorator('IInlineChatSavingService '); + +export interface IInlineChatSavingService { + _serviceBrand: undefined; + + markChanged(session: Session): void; + +} diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSaving.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSavingServiceImpl.ts similarity index 52% rename from src/vs/workbench/contrib/inlineChat/browser/inlineChatSaving.ts rename to src/vs/workbench/contrib/inlineChat/browser/inlineChatSavingServiceImpl.ts index c6f7dba516b..82effd20020 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSaving.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSavingServiceImpl.ts @@ -5,37 +5,31 @@ import { raceCancellation } from 'vs/base/common/async'; import { CancellationToken } from 'vs/base/common/cancellation'; -import { DisposableStore, MutableDisposable, dispose } from 'vs/base/common/lifecycle'; -import { getCodeEditor } from 'vs/editor/browser/editorBrowser'; +import { DisposableStore, IDisposable, MutableDisposable, dispose } from 'vs/base/common/lifecycle'; +import { ICodeEditor, isCodeEditor } from 'vs/editor/browser/editorBrowser'; import { ITextModel } from 'vs/editor/common/model'; import { localize } from 'vs/nls'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { IProgress, IProgressStep } from 'vs/platform/progress/common/progress'; import { DEFAULT_EDITOR_ASSOCIATION, SaveReason } from 'vs/workbench/common/editor'; -import { IInlineChatSessionService, Session } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; +import { Session } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; +import { IInlineChatSessionService } from './inlineChatSessionService'; import { InlineChatConfigKeys } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import { GroupsOrder, IEditorGroup, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; - -export const IInlineChatSavingService = createDecorator('IInlineChatSavingService '); - -export interface IInlineChatSavingService { - _serviceBrand: undefined; - - markChanged(session: Session): void; - -} +import { IInlineChatSavingService } from './inlineChatSavingService'; +import { Iterable } from 'vs/base/common/iterator'; +import { IResourceEditorInput } from 'vs/platform/editor/common/editor'; interface SessionData { readonly dispose: () => void; readonly session: Session; - readonly group: IEditorGroup; + readonly groupCandidate: IEditorGroup; } -export class InlineChatSavingService implements IInlineChatSavingService { +export class InlineChatSavingServiceImpl implements IInlineChatSavingService { declare readonly _serviceBrand: undefined; @@ -68,13 +62,12 @@ export class InlineChatSavingService implements IInlineChatSavingService { this._installSaveParticpant(); } - const disposable = this._fileConfigService.disableAutoSave(session.textModelN.uri); - const group = this._getEditorGroup(session); + const saveConfig = this._fileConfigService.disableAutoSave(session.textModelN.uri); this._sessionData.set(session, { + groupCandidate: this._editorGroupService.activeGroup, session, - group, dispose: () => { - disposable.dispose(); + saveConfig.dispose(); this._sessionData.delete(session); if (this._sessionData.size === 0) { this._saveParticipant.clear(); @@ -114,60 +107,104 @@ export class InlineChatSavingService implements IInlineChatSavingService { return; } - const store = new DisposableStore(); - - const allDone = new Promise(resolve => { - store.add(this._inlineChatSessionService.onDidEndSession(e => { - - const data = sessions.get(e.session); - if (!data) { - return; - } - - data.dispose(); - sessions.delete(e.session); - - if (sessions.size === 0) { - resolve(); // DONE, release save block! - } - })); - }); - progress.report({ message: sessions.size === 1 ? localize('inlineChat', "Waiting for Inline Chat changes to be Accepted or Discarded...") : localize('inlineChat.N', "Waiting for Inline Chat changes in {0} editors to be Accepted or Discarded...", sessions.size) }); - await this._revealInlineChatSessions(sessions.values()); - - try { - await raceCancellation(allDone, token); - } finally { - store.dispose(); - } - } - - private _getEditorGroup(session: Session): IEditorGroup { - const candidate = this._editorGroupService.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE).find(group => { - return getCodeEditor(group.activeEditorPane?.getControl()) === session.editor; + // reveal all sessions in order and also show dangling sessions + const { groups, orphans } = this._getGroupsAndOrphans(sessions.values()); + const editorsOpenedAndSessionsEnded = this._openAndWait(groups, token).then(() => { + if (token.isCancellationRequested) { + return; + } + return this._openAndWait(Iterable.map(orphans, s => [this._editorGroupService.activeGroup, s]), token); }); - return candidate ?? this._editorGroupService.activeGroup; + + // fallback: resolve when all sessions for this model have been resolved. this is independent of the editor opening + const allSessionsEnded = this._waitForSessions(Iterable.concat(groups.values(), orphans), token); + + await Promise.race([allSessionsEnded, editorsOpenedAndSessionsEnded]); } - private async _revealInlineChatSessions(sessions: Iterable): Promise { + private _getGroupsAndOrphans(sessions: Iterable) { + + const groupByEditor = new Map(); + for (const group of this._editorGroupService.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE)) { + const candidate = group.activeEditorPane?.getControl(); + if (isCodeEditor(candidate)) { + groupByEditor.set(candidate, group); + } + } + + const groups = new Map(); + const orphans = new Set(); for (const data of sessions) { - - const inputs = data.group - .findEditors(data.session.textModelN.uri) - .filter(input => input.editorId === DEFAULT_EDITOR_ASSOCIATION.id); - - if (inputs.length === 0) { - await this._editorService.openEditor({ resource: data.session.textModelN.uri }, data.group); + const editor = this._inlineChatSessionService.getCodeEditor(data.session); + const group = groupByEditor.get(editor); + if (group) { + // there is only one session per group because all sessions have the same model + // because we save one file. + groups.set(group, data); + } else if (this._editorGroupService.groups.includes(data.groupCandidate)) { + // the group candidate is still there. use it + groups.set(data.groupCandidate, data); } else { - await data.group.openEditor(inputs[0]); + orphans.add(data); } } + return { groups, orphans }; + } + + private async _openAndWait(groups: Iterable<[IEditorGroup, SessionData]>, token: CancellationToken) { + const sessions = new Set(); + for (const [group, data] of groups) { + const input: IResourceEditorInput = { resource: data.session.textModelN.uri, options: { override: DEFAULT_EDITOR_ASSOCIATION.id } }; + const pane = await this._editorService.openEditor(input, group); + const ctrl = pane?.getControl(); + if (!isCodeEditor(ctrl)) { + // PANIC + return; + } + this._inlineChatSessionService.moveSession(data.session, ctrl); + sessions.add(data); + } + await this._waitForSessions(sessions, token); + } + + private async _waitForSessions(iterable: Iterable, token: CancellationToken) { + + const sessions = new Map(); + for (const item of iterable) { + sessions.set(item.session, item); + } + + if (sessions.size === 0) { + // nothing to do + return; + } + + let listener: IDisposable | undefined; + + const whenEnded = new Promise(resolve => { + listener = this._inlineChatSessionService.onDidEndSession(e => { + const data = sessions.get(e.session); + if (data) { + data.dispose(); + sessions.delete(e.session); + if (sessions.size === 0) { + resolve(); // DONE, release waiting + } + } + }); + }); + + try { + await raceCancellation(whenEnded, token); + } finally { + listener?.dispose(); + } } } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSession.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSession.ts index 4ece029e74b..97c7d2cab7c 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSession.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSession.ts @@ -8,23 +8,13 @@ import { Emitter, Event } from 'vs/base/common/event'; import { ResourceEdit, ResourceFileEdit, ResourceTextEdit } from 'vs/editor/browser/services/bulkEditService'; import { IWorkspaceTextEdit, TextEdit, WorkspaceEdit } from 'vs/editor/common/languages'; import { IModelDecorationOptions, IModelDeltaDecoration, ITextModel } from 'vs/editor/common/model'; -import { EditMode, IInlineChatSessionProvider, IInlineChatSession, IInlineChatBulkEditResponse, IInlineChatEditResponse, IInlineChatResponse, IInlineChatService, InlineChatResponseType, InlineChatResponseTypes } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; +import { EditMode, IInlineChatSessionProvider, IInlineChatSession, IInlineChatBulkEditResponse, IInlineChatEditResponse, InlineChatResponseType, InlineChatResponseTypes } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import { IRange, Range } from 'vs/editor/common/core/range'; -import { IActiveCodeEditor, ICodeEditor } from 'vs/editor/browser/editorBrowser'; -import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; -import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; -import { IModelService } from 'vs/editor/common/services/model'; -import { ITextModelService } from 'vs/editor/common/services/resolverService'; -import { DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { ModelDecorationOptions, createTextBufferFactoryFromSnapshot } from 'vs/editor/common/model/textModel'; -import { ILogService } from 'vs/platform/log/common/log'; -import { CancellationToken } from 'vs/base/common/cancellation'; -import { Iterable } from 'vs/base/common/iterator'; +import { ModelDecorationOptions } from 'vs/editor/common/model/textModel'; import { toErrorMessage } from 'vs/base/common/errorMessage'; import { isCancellationError } from 'vs/base/common/errors'; import { EditOperation, ISingleEditOperation } from 'vs/editor/common/core/editOperation'; -import { raceCancellation } from 'vs/base/common/async'; -import { DetailedLineRangeMapping, LineRangeMapping } from 'vs/editor/common/diff/rangeMapping'; +import { DetailedLineRangeMapping, LineRangeMapping, RangeMapping } from 'vs/editor/common/diff/rangeMapping'; import { IMarkdownString } from 'vs/base/common/htmlContent'; import { IUntitledTextEditorModel } from 'vs/workbench/services/untitled/common/untitledTextEditorModel'; import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; @@ -32,14 +22,15 @@ import { ILanguageService } from 'vs/editor/common/languages/language'; import { ResourceMap } from 'vs/base/common/map'; import { Schemas } from 'vs/base/common/network'; import { isEqual } from 'vs/base/common/resources'; +import { Recording } from './inlineChatSessionService'; +import { LineRange } from 'vs/editor/common/core/lineRange'; +import { IEditorWorkerService } from 'vs/editor/common/services/editorWorker'; +import { asRange } from 'vs/workbench/contrib/inlineChat/browser/utils'; +import { coalesceInPlace } from 'vs/base/common/arrays'; +import { Iterable } from 'vs/base/common/iterator'; -export type Recording = { - when: Date; - session: IInlineChatSession; - exchanges: { prompt: string; res: IInlineChatResponse }[]; -}; -type TelemetryData = { +export type TelemetryData = { extension: string; rounds: string; undos: string; @@ -50,7 +41,7 @@ type TelemetryData = { editMode: string; }; -type TelemetryDataClassification = { +export type TelemetryDataClassification = { owner: 'jrieken'; comment: 'Data about an interaction editor session'; extension: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension providing the data' }; @@ -69,7 +60,7 @@ export enum ExpansionState { NOT_CROPPED = 'not_cropped' } -class SessionWholeRange { +export class SessionWholeRange { private static readonly _options: IModelDecorationOptions = ModelDecorationOptions.register({ description: 'inlineChat/session/wholeRange' }); @@ -149,12 +140,12 @@ export class Session { constructor( readonly editMode: EditMode, - readonly editor: ICodeEditor, readonly textModel0: ITextModel, readonly textModelN: ITextModel, readonly provider: IInlineChatSessionProvider, readonly session: IInlineChatSession, - readonly wholeRange: SessionWholeRange + readonly wholeRange: SessionWholeRange, + readonly hunkData: HunkData, ) { this.textModelNAltVersion = textModelN.getAlternativeVersionId(); this._teldata = { @@ -412,182 +403,201 @@ export class ReplyResponse { } } -export interface ISessionKeyComputer { - getComparisonKey(editor: ICodeEditor, uri: URI): string; -} +// --- -export const IInlineChatSessionService = createDecorator('IInlineChatSessionService'); +export class HunkData { -export interface IInlineChatSessionService { - _serviceBrand: undefined; + private static readonly _HUNK_TRACKED_RANGE = ModelDecorationOptions.register({ + description: 'inline-chat-hunk-tracked-range', + }); - onWillStartSession: Event; + private static readonly _HUNK_THRESHOLD = 8; - onDidEndSession: Event<{ editor: ICodeEditor; session: Session }>; - - createSession(editor: IActiveCodeEditor, options: { editMode: EditMode; wholeRange?: IRange }, token: CancellationToken): Promise; - - getSession(editor: ICodeEditor, uri: URI): Session | undefined; - - releaseSession(session: Session): void; - - registerSessionKeyComputer(scheme: string, value: ISessionKeyComputer): IDisposable; - - // - - recordings(): readonly Recording[]; - - dispose(): void; -} - -type SessionData = { - session: Session; - store: IDisposable; -}; - -export class InlineChatSessionService implements IInlineChatSessionService { - - declare _serviceBrand: undefined; - - private readonly _onWillStartSession = new Emitter(); - readonly onWillStartSession: Event = this._onWillStartSession.event; - - private readonly _onDidEndSession = new Emitter<{ editor: ICodeEditor; session: Session }>(); - readonly onDidEndSession: Event<{ editor: ICodeEditor; session: Session }> = this._onDidEndSession.event; - - private readonly _sessions = new Map(); - private readonly _keyComputers = new Map(); - private _recordings: Recording[] = []; + private readonly _data = new Map(); constructor( - @IInlineChatService private readonly _inlineChatService: IInlineChatService, - @ITelemetryService private readonly _telemetryService: ITelemetryService, - @IModelService private readonly _modelService: IModelService, - @ITextModelService private readonly _textModelService: ITextModelService, - @ILogService private readonly _logService: ILogService, + @IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService, + private readonly _textModel0: ITextModel, + private readonly _textModelN: ITextModel, ) { } - dispose() { - this._onWillStartSession.dispose(); - this._onDidEndSession.dispose(); - this._sessions.forEach(x => x.store.dispose()); - this._sessions.clear(); + dispose(): void { + if (!this._textModelN.isDisposed()) { + this._textModelN.changeDecorations(accessor => { + for (const { textModelNDecorations } of this._data.values()) { + textModelNDecorations.forEach(accessor.removeDecoration, accessor); + } + }); + } + if (!this._textModel0.isDisposed()) { + this._textModel0.changeDecorations(accessor => { + for (const { textModel0Decorations } of this._data.values()) { + textModel0Decorations.forEach(accessor.removeDecoration, accessor); + } + }); + } } - async createSession(editor: IActiveCodeEditor, options: { editMode: EditMode; wholeRange?: Range }, token: CancellationToken): Promise { + async recompute() { - const provider = Iterable.first(this._inlineChatService.getAllProvider()); - if (!provider) { - this._logService.trace('[IE] NO provider found'); - return undefined; + const diff = await this._editorWorkerService.computeDiff(this._textModel0.uri, this._textModelN.uri, { ignoreTrimWhitespace: false, maxComputationTimeMs: Number.MAX_SAFE_INTEGER, computeMoves: false }, 'advanced'); + + if (!diff || diff.changes.length === 0) { + // return new HunkData([], session); + return; } - this._onWillStartSession.fire(editor); - - const textModel = editor.getModel(); - const selection = editor.getSelection(); - let raw: IInlineChatSession | undefined | null; - try { - raw = await raceCancellation( - Promise.resolve(provider.prepareInlineChatSession(textModel, selection, token)), - token - ); - } catch (error) { - this._logService.error('[IE] FAILED to prepare session', provider.debugName); - this._logService.error(error); - return undefined; - } - if (!raw) { - this._logService.trace('[IE] NO session', provider.debugName); - return undefined; - } - this._logService.trace('[IE] NEW session', provider.debugName); - - this._logService.trace(`[IE] creating NEW session for ${editor.getId()}, ${provider.debugName}`); - const store = new DisposableStore(); - - // create: keep a reference to prevent disposal of the "actual" model - const refTextModelN = await this._textModelService.createModelReference(textModel.uri); - store.add(refTextModelN); - - // create: keep a snapshot of the "actual" model - const textModel0 = this._modelService.createModel( - createTextBufferFactoryFromSnapshot(textModel.createSnapshot()), - { languageId: textModel.getLanguageId(), onDidChange: Event.None }, - undefined, true - ); - store.add(textModel0); - - let wholeRange = options.wholeRange; - if (!wholeRange) { - wholeRange = raw.wholeRange ? Range.lift(raw.wholeRange) : editor.getSelection(); - } - - - // install managed-marker for the decoration range - const wholeRangeMgr = new SessionWholeRange(textModel, wholeRange); - store.add(wholeRangeMgr); - - const session = new Session(options.editMode, editor, textModel0, textModel, provider, raw, wholeRangeMgr); - - // store: key -> session - const key = this._key(editor, textModel.uri); - if (this._sessions.has(key)) { - store.dispose(); - throw new Error(`Session already stored for ${key}`); - } - this._sessions.set(key, { session, store }); - return session; - } - - releaseSession(session: Session): void { - - const { editor } = session; - - // cleanup - for (const [key, value] of this._sessions) { - if (value.session === session) { - value.store.dispose(); - this._sessions.delete(key); - this._logService.trace(`[IE] did RELEASED session for ${editor.getId()}, ${session.provider.debugName}`); - break; + // merge changes neighboring changes + const mergedChanges = [diff.changes[0]]; + for (let i = 1; i < diff.changes.length; i++) { + const lastChange = mergedChanges[mergedChanges.length - 1]; + const thisChange = diff.changes[i]; + if (thisChange.modified.startLineNumber - lastChange.modified.endLineNumberExclusive <= HunkData._HUNK_THRESHOLD) { + mergedChanges[mergedChanges.length - 1] = new DetailedLineRangeMapping( + lastChange.original.join(thisChange.original), + lastChange.modified.join(thisChange.modified), + (lastChange.innerChanges ?? []).concat(thisChange.innerChanges ?? []) + ); + } else { + mergedChanges.push(thisChange); } } - // keep recording - const newLen = this._recordings.unshift(session.asRecording()); - if (newLen > 5) { - this._recordings.pop(); + const hunks = mergedChanges.map(change => new RawHunk(change.original, change.modified, change.innerChanges ?? [])); + + this._textModelN.changeDecorations(accessorN => { + + this._textModel0.changeDecorations(accessor0 => { + + // clean up old decorations + for (const { textModelNDecorations, textModel0Decorations } of this._data.values()) { + textModelNDecorations.forEach(accessorN.removeDecoration, accessorN); + textModel0Decorations.forEach(accessor0.removeDecoration, accessor0); + } + + this._data.clear(); + + // add new decorations + for (const hunk of hunks) { + + const textModelNDecorations: string[] = []; + const textModel0Decorations: string[] = []; + + textModelNDecorations.push(accessorN.addDecoration(asRange(hunk.modified, this._textModelN), HunkData._HUNK_TRACKED_RANGE)); + textModel0Decorations.push(accessor0.addDecoration(asRange(hunk.original, this._textModel0), HunkData._HUNK_TRACKED_RANGE)); + + for (const change of hunk.changes) { + textModelNDecorations.push(accessorN.addDecoration(change.modifiedRange, HunkData._HUNK_TRACKED_RANGE)); + textModel0Decorations.push(accessor0.addDecoration(change.originalRange, HunkData._HUNK_TRACKED_RANGE)); + } + + this._data.set(hunk, { + textModelNDecorations, + textModel0Decorations, + state: HunkState.Pending + }); + } + }); + }); + } + + get size(): number { + return this._data.size; + } + + get pending(): number { + return Iterable.reduce(this._data.values(), (r, { state }) => r + (state === HunkState.Pending ? 1 : 0), 0); + } + + getInfo(): HunkInformation[] { + + const result: HunkInformation[] = []; + + for (const [hunk, data] of this._data.entries()) { + const item: HunkInformation = { + getState: () => { + return data.state; + }, + isInsertion: () => { + return hunk.original.isEmpty; + }, + getRangesN: () => { + const ranges = data.textModelNDecorations.map(id => this._textModelN.getDecorationRange(id)); + coalesceInPlace(ranges); + return ranges; + }, + getRanges0: () => { + const ranges = data.textModel0Decorations.map(id => this._textModel0.getDecorationRange(id)); + coalesceInPlace(ranges); + return ranges; + }, + discardChanges: () => { + // DISCARD: replace modified range with original value. The modified range is retrieved from a decoration + // which was created above so that typing in the editor keeps discard working. + if (data.state === HunkState.Pending) { + const edits: ISingleEditOperation[] = []; + const rangesN = item.getRangesN(); + const ranges0 = item.getRanges0(); + for (let i = 1; i < rangesN.length; i++) { + const modifiedRange = rangesN[i]; + const originalValue = this._textModel0.getValueInRange(ranges0[i]); + edits.push(EditOperation.replace(modifiedRange, originalValue)); + } + this._textModelN.pushEditOperations(null, edits, () => null); + data.state = HunkState.Rejected; + } + }, + acceptChanges: () => { + // ACCEPT: replace original range with modified value. The modified value is retrieved from the model via + // its decoration and the original range is retrieved from the hunk. + if (data.state === HunkState.Pending) { + const edits: ISingleEditOperation[] = []; + const rangesN = item.getRangesN(); + const ranges0 = item.getRanges0(); + for (let i = 1; i < ranges0.length; i++) { + const originalRange = ranges0[i]; + const modifiedValue = this._textModelN.getValueInRange(rangesN[i]); + edits.push(EditOperation.replace(originalRange, modifiedValue)); + } + this._textModel0.pushEditOperations(null, edits, () => null); + data.state = HunkState.Accepted; + } + } + }; + result.push(item); } - // send telemetry - this._telemetryService.publicLog2('interactiveEditor/session', session.asTelemetryData()); - - this._onDidEndSession.fire({ editor, session }); + return result; } - - getSession(editor: ICodeEditor, uri: URI): Session | undefined { - const key = this._key(editor, uri); - return this._sessions.get(key)?.session; - } - - private _key(editor: ICodeEditor, uri: URI): string { - const item = this._keyComputers.get(uri.scheme); - return item - ? item.getComparisonKey(editor, uri) - : `${editor.getId()}@${uri.toString()}`; - - } - - registerSessionKeyComputer(scheme: string, value: ISessionKeyComputer): IDisposable { - this._keyComputers.set(scheme, value); - return toDisposable(() => this._keyComputers.delete(scheme)); - } - - // --- debug - - recordings(): readonly Recording[] { - return this._recordings; - } - +} + +class RawHunk { + constructor( + readonly original: LineRange, + readonly modified: LineRange, + readonly changes: RangeMapping[] + ) { } +} + +export const enum HunkState { + Pending = 0, + Accepted = 1, + Rejected = 2 +} + +export interface HunkInformation { + /** + * The first element [0] is the whole modified range and subsequent elements are word-level changes + */ + getRangesN(): Range[]; + + getRanges0(): Range[]; + + isInsertion(): boolean; + + discardChanges(): void; + + acceptChanges(): void; + + getState(): HunkState; } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts new file mode 100644 index 00000000000..ee8de25c12d --- /dev/null +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { URI } from 'vs/base/common/uri'; +import { Event } from 'vs/base/common/event'; +import { EditMode, IInlineChatSession, IInlineChatResponse } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; +import { IRange } from 'vs/editor/common/core/range'; +import { IActiveCodeEditor, ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { IDisposable } from 'vs/base/common/lifecycle'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { Session } from './inlineChatSession'; + + +export type Recording = { + when: Date; + session: IInlineChatSession; + exchanges: { prompt: string; res: IInlineChatResponse }[]; +}; + +export interface ISessionKeyComputer { + getComparisonKey(editor: ICodeEditor, uri: URI): string; +} + +export const IInlineChatSessionService = createDecorator('IInlineChatSessionService'); + +export interface IInlineChatSessionService { + _serviceBrand: undefined; + + onWillStartSession: Event; + + onDidMoveSession: Event<{ editor: ICodeEditor; session: Session }>; + + onDidEndSession: Event<{ editor: ICodeEditor; session: Session }>; + + createSession(editor: IActiveCodeEditor, options: { editMode: EditMode; wholeRange?: IRange }, token: CancellationToken): Promise; + + moveSession(session: Session, newEditor: ICodeEditor): void; + + getCodeEditor(session: Session): ICodeEditor; + + getSession(editor: ICodeEditor, uri: URI): Session | undefined; + + releaseSession(session: Session): void; + + registerSessionKeyComputer(scheme: string, value: ISessionKeyComputer): IDisposable; + + // + recordings(): readonly Recording[]; + + dispose(): void; +} diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts new file mode 100644 index 00000000000..57e865322f9 --- /dev/null +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts @@ -0,0 +1,223 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { URI } from 'vs/base/common/uri'; +import { Emitter, Event } from 'vs/base/common/event'; +import { EditMode, IInlineChatSession, IInlineChatService } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; +import { Range } from 'vs/editor/common/core/range'; +import { IActiveCodeEditor, ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { IModelService } from 'vs/editor/common/services/model'; +import { ITextModelService } from 'vs/editor/common/services/resolverService'; +import { DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { createTextBufferFactoryFromSnapshot } from 'vs/editor/common/model/textModel'; +import { ILogService } from 'vs/platform/log/common/log'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { Iterable } from 'vs/base/common/iterator'; +import { raceCancellation } from 'vs/base/common/async'; +import { Recording, IInlineChatSessionService, ISessionKeyComputer } from './inlineChatSessionService'; +import { HunkData, Session, SessionWholeRange, TelemetryData, TelemetryDataClassification } from './inlineChatSession'; +import { IEditorWorkerService } from 'vs/editor/common/services/editorWorker'; + +type SessionData = { + editor: ICodeEditor; + session: Session; + store: IDisposable; +}; + +export class InlineChatSessionServiceImpl implements IInlineChatSessionService { + + declare _serviceBrand: undefined; + + private readonly _onWillStartSession = new Emitter(); + readonly onWillStartSession: Event = this._onWillStartSession.event; + + private readonly _onDidMoveSession = new Emitter<{ session: Session; editor: ICodeEditor }>(); + readonly onDidMoveSession: Event<{ session: Session; editor: ICodeEditor }> = this._onDidMoveSession.event; + + private readonly _onDidEndSession = new Emitter<{ editor: ICodeEditor; session: Session }>(); + readonly onDidEndSession: Event<{ editor: ICodeEditor; session: Session }> = this._onDidEndSession.event; + + private readonly _sessions = new Map(); + private readonly _keyComputers = new Map(); + private _recordings: Recording[] = []; + + constructor( + @IInlineChatService private readonly _inlineChatService: IInlineChatService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @IModelService private readonly _modelService: IModelService, + @ITextModelService private readonly _textModelService: ITextModelService, + @IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService, + @ILogService private readonly _logService: ILogService + ) { } + + dispose() { + this._onWillStartSession.dispose(); + this._onDidEndSession.dispose(); + this._sessions.forEach(x => x.store.dispose()); + this._sessions.clear(); + } + + async createSession(editor: IActiveCodeEditor, options: { editMode: EditMode; wholeRange?: Range }, token: CancellationToken): Promise { + + const provider = Iterable.first(this._inlineChatService.getAllProvider()); + if (!provider) { + this._logService.trace('[IE] NO provider found'); + return undefined; + } + + this._onWillStartSession.fire(editor); + + const textModel = editor.getModel(); + const selection = editor.getSelection(); + let raw: IInlineChatSession | undefined | null; + try { + raw = await raceCancellation( + Promise.resolve(provider.prepareInlineChatSession(textModel, selection, token)), + token + ); + } catch (error) { + this._logService.error('[IE] FAILED to prepare session', provider.debugName); + this._logService.error(error); + return undefined; + } + if (!raw) { + this._logService.trace('[IE] NO session', provider.debugName); + return undefined; + } + this._logService.trace('[IE] NEW session', provider.debugName); + + this._logService.trace(`[IE] creating NEW session for ${editor.getId()}, ${provider.debugName}`); + const store = new DisposableStore(); + + // create: keep a reference to prevent disposal of the "actual" model + const refTextModelN = await this._textModelService.createModelReference(textModel.uri); + store.add(refTextModelN); + + // create: keep a snapshot of the "actual" model + const textModel0 = this._modelService.createModel( + createTextBufferFactoryFromSnapshot(textModel.createSnapshot()), + { languageId: textModel.getLanguageId(), onDidChange: Event.None }, + undefined, true + ); + store.add(textModel0); + + let wholeRange = options.wholeRange; + if (!wholeRange) { + wholeRange = raw.wholeRange ? Range.lift(raw.wholeRange) : editor.getSelection(); + } + + + // install managed-marker for the decoration range + const wholeRangeMgr = new SessionWholeRange(textModel, wholeRange); + store.add(wholeRangeMgr); + + const hunkData = new HunkData(this._editorWorkerService, textModel0, textModel); + store.add(hunkData); + + const session = new Session(options.editMode, textModel0, textModel, provider, raw, wholeRangeMgr, hunkData); + + // store: key -> session + const key = this._key(editor, textModel.uri); + if (this._sessions.has(key)) { + store.dispose(); + throw new Error(`Session already stored for ${key}`); + } + this._sessions.set(key, { session, editor, store }); + return session; + } + + moveSession(session: Session, target: ICodeEditor): void { + const newKey = this._key(target, session.textModelN.uri); + const existing = this._sessions.get(newKey); + if (existing) { + if (existing.session !== session) { + throw new Error(`Cannot move session because the target editor already/still has one`); + } else { + // noop + return; + } + } + + let found = false; + for (const [oldKey, data] of this._sessions) { + if (data.session === session) { + found = true; + this._sessions.delete(oldKey); + this._sessions.set(newKey, { ...data, editor: target }); + this._logService.trace(`[IE] did MOVE session for ${data.editor.getId()} to NEW EDITOR ${target.getId()}, ${session.provider.debugName}`); + this._onDidMoveSession.fire({ session, editor: target }); + break; + } + } + if (!found) { + throw new Error(`Cannot move session because it is not stored`); + } + } + + releaseSession(session: Session): void { + + let data: SessionData | undefined; + + // cleanup + for (const [key, value] of this._sessions) { + if (value.session === session) { + data = value; + value.store.dispose(); + this._sessions.delete(key); + this._logService.trace(`[IE] did RELEASED session for ${value.editor.getId()}, ${session.provider.debugName}`); + break; + } + } + + if (!data) { + // double remove + return; + } + + // keep recording + const newLen = this._recordings.unshift(session.asRecording()); + if (newLen > 5) { + this._recordings.pop(); + } + + // send telemetry + this._telemetryService.publicLog2('interactiveEditor/session', session.asTelemetryData()); + + this._onDidEndSession.fire({ editor: data.editor, session }); + } + + getCodeEditor(session: Session): ICodeEditor { + for (const [, data] of this._sessions) { + if (data.session === session) { + return data.editor; + } + } + throw new Error('session not found'); + } + + getSession(editor: ICodeEditor, uri: URI): Session | undefined { + const key = this._key(editor, uri); + return this._sessions.get(key)?.session; + } + + private _key(editor: ICodeEditor, uri: URI): string { + const item = this._keyComputers.get(uri.scheme); + return item + ? item.getComparisonKey(editor, uri) + : `${editor.getId()}@${uri.toString()}`; + + } + + registerSessionKeyComputer(scheme: string, value: ISessionKeyComputer): IDisposable { + this._keyComputers.set(scheme, value); + return toDisposable(() => this._keyComputers.delete(scheme)); + } + + // --- debug + recordings(): readonly Recording[] { + return this._recordings; + } + +} diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts index 7fe41b4a244..f118144130e 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts @@ -8,9 +8,8 @@ import { coalesceInPlace, equals, tail } from 'vs/base/common/arrays'; import { AsyncIterableSource, IntervalTimer } from 'vs/base/common/async'; import { CancellationToken } from 'vs/base/common/cancellation'; import { Emitter, Event } from 'vs/base/common/event'; -import { Iterable } from 'vs/base/common/iterator'; import { Lazy } from 'vs/base/common/lazy'; -import { DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; import { themeColorFromId } from 'vs/base/common/themables'; import { ICodeEditor, IViewZone, IViewZoneChangeAccessor } from 'vs/editor/browser/editorBrowser'; import { StableEditorScrollState } from 'vs/editor/browser/stableEditorScroll'; @@ -20,7 +19,7 @@ import { LineRange } from 'vs/editor/common/core/lineRange'; import { Position } from 'vs/editor/common/core/position'; import { IRange, Range } from 'vs/editor/common/core/range'; import { Selection } from 'vs/editor/common/core/selection'; -import { DetailedLineRangeMapping, LineRangeMapping, RangeMapping } from 'vs/editor/common/diff/rangeMapping'; +import { LineRangeMapping } from 'vs/editor/common/diff/rangeMapping'; import { IEditorDecorationsCollection } from 'vs/editor/common/editorCommon'; import { TextEdit } from 'vs/editor/common/languages'; import { ICursorStateComputer, IIdentifiedSingleEditOperation, IModelDecorationsChangeAccessor, IModelDeltaDecoration, ITextModel, IValidEditOperation, OverviewRulerLane, TrackedRangeStickiness } from 'vs/editor/common/model'; @@ -34,9 +33,11 @@ import { IProgress, Progress } from 'vs/platform/progress/common/progress'; import { SaveReason } from 'vs/workbench/common/editor'; import { countWords, getNWords } from 'vs/workbench/contrib/chat/common/chatWordCounter'; import { InlineChatFileCreatePreviewWidget, InlineChatLivePreviewWidget } from 'vs/workbench/contrib/inlineChat/browser/inlineChatLivePreviewWidget'; -import { ReplyResponse, Session } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; +import { HunkInformation, ReplyResponse, Session } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; import { InlineChatZoneWidget } from 'vs/workbench/contrib/inlineChat/browser/inlineChatWidget'; import { CTX_INLINE_CHAT_CHANGE_HAS_DIFF, CTX_INLINE_CHAT_CHANGE_SHOWS_DIFF, CTX_INLINE_CHAT_DOCUMENT_CHANGED, overviewRulerInlineChatDiffInserted } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; +import { HunkState } from './inlineChatSession'; +import { assertType } from 'vs/base/common/types'; export abstract class EditModeStrategy { @@ -464,32 +465,8 @@ export function asProgressiveEdit(interval: IntervalTimer, edit: IIdentifiedSing } -// --- - -class Hunk { - constructor( - readonly original: LineRange, - readonly modified: LineRange, - readonly changes: RangeMapping[] - ) { } -} - -type HunkTrackedRange = { - /** - * The first element [0] is the whole modified range and subsequent elements are word-level changes - */ - getRanges(): Range[]; - - discardChanges(): void; -}; - -const enum HunkState { - Accepted = 1, - Rejected = 2, -} - type HunkDisplayData = { - acceptedOrRejected: HunkState | undefined; + decorationIds: string[]; viewZoneId: string | undefined; @@ -500,21 +477,12 @@ type HunkDisplayData = { acceptHunk: () => void; discardHunk: () => void; toggleDiff?: () => any; + remove(): void; }; -interface HunkDisplay { - renderHunks(): HunkDisplayData | undefined; - hideHunks(): void; - discardHunks(): void; -} export class LiveStrategy extends EditModeStrategy { - private readonly _decoTrackedRange = ModelDecorationOptions.register({ - description: 'inline-tracked-range', - className: 'inline-chat-tracked-range' - }); - private readonly _decoInsertedText = ModelDecorationOptions.register({ description: 'inline-modified-line', className: 'inline-chat-inserted-range-linehighlight', @@ -531,7 +499,6 @@ export class LiveStrategy extends EditModeStrategy { }); private readonly _store = new DisposableStore(); - private readonly _renderStore = new DisposableStore(); private readonly _previewZone: Lazy; private readonly _ctxCurrentChangeHasDiff: IContextKey; @@ -571,13 +538,16 @@ export class LiveStrategy extends EditModeStrategy { private _resetDiff(): void { this._ctxCurrentChangeHasDiff.reset(); this._ctxCurrentChangeShowsDiff.reset(); - this._renderStore.clear(); this._zone.widget.updateStatus(''); this._progressiveEditingDecorations.clear(); + + + for (const data of this._hunkDisplayData.values()) { + data.remove(); + } } override pause = () => { - this._hunkDisplay?.hideHunks(); this._ctxCurrentChangeShowsDiff.reset(); }; @@ -596,7 +566,9 @@ export class LiveStrategy extends EditModeStrategy { } async cancel() { - this._hunkDisplay?.discardHunks(); + for (const item of this._session.hunkData.getInfo()) { + item.discardChanges(); + } this._resetDiff(); } @@ -659,7 +631,7 @@ export class LiveStrategy extends EditModeStrategy { } } - private _hunkDisplay?: HunkDisplay; + private readonly _hunkDisplayData = new Map(); override async renderChanges(response: ReplyResponse) { @@ -671,271 +643,172 @@ export class LiveStrategy extends EditModeStrategy { this._progressiveEditingDecorations.clear(); - if (!this._hunkDisplay) { - - this._renderStore.add(toDisposable(() => { - this._hunkDisplay?.hideHunks(); - this._hunkDisplay = undefined; - })); - - const hunkTrackedRanges = new Map(); - const hunkDisplayData = new Map(); - - // (INIT) compute hunks - const hunks = await this._computeHunks(); - if (hunks.length === 0) { - this._hunkDisplay = { renderHunks() { return undefined; }, hideHunks() { }, discardHunks() { } }; - return undefined; - } - - // (INIT) add tracked ranges per hunk - const model = this._editor.getModel()!; - model.changeDecorations(accessor => { - for (const hunk of hunks) { - const decorationIds: string[] = []; - const modifiedRange = asRange(hunk.modified, this._session.textModelN); - decorationIds.push(accessor.addDecoration(modifiedRange, this._decoTrackedRange)); - for (const change of hunk.changes) { - decorationIds.push(accessor.addDecoration(change.modifiedRange, this._decoTrackedRange)); - } - const hunkLiveInfo: HunkTrackedRange = { - getRanges: () => { - const ranges = decorationIds.map(id => model.getDecorationRange(id)); - coalesceInPlace(ranges); - return ranges; - }, - discardChanges: () => { - const edits: ISingleEditOperation[] = []; - const ranges = hunkLiveInfo.getRanges(); - for (let i = 1; i < ranges.length; i++) { - // DISCARD: replace modified range with original value. The modified range is retrieved from a decoration - // which was created above so that typing in the editor keeps discard working. - const modifiedRange = ranges[i]; - const originalValue = this._session.textModel0.getValueInRange(hunk.changes[i - 1].originalRange); - edits.push(EditOperation.replace(modifiedRange, originalValue)); - } - this._session.textModelN.pushEditOperations(null, edits, () => null); - } - }; - hunkTrackedRanges.set(hunk, hunkLiveInfo); - this._renderStore.add(toDisposable(() => { - model.deltaDecorations(decorationIds, []); - })); - } - }); + const renderHunks = () => { let widgetData: HunkDisplayData | undefined; - const renderHunks = () => { + changeDecorationsAndViewZones(this._editor, (decorationsAccessor, viewZoneAccessor) => { - changeDecorationsAndViewZones(this._editor, (decorationsAccessor, viewZoneAccessor) => { + const keysNow = new Set(this._hunkDisplayData.keys()); + widgetData = undefined; - widgetData = undefined; + for (const hunkData of this._session.hunkData.getInfo()) { - for (const hunk of hunks) { + keysNow.delete(hunkData); - const hunkRanges = hunkTrackedRanges.get(hunk)!.getRanges(); - let data = hunkDisplayData.get(hunk); - if (!data) { - // first time -> create decoration - const decorationIds: string[] = []; - for (let i = 0; i < hunkRanges.length; i++) { - decorationIds.push(decorationsAccessor.addDecoration(hunkRanges[i], i === 0 - ? this._decoInsertedText - : this._decoInsertedTextRange) - ); - } - - const acceptHunk = () => { - // ACCEPT: stop rendering this as inserted - hunkDisplayData.get(hunk)!.acceptedOrRejected = HunkState.Accepted; - renderHunks(); - }; - - const discardHunk = () => { - const info = hunkTrackedRanges.get(hunk)!; - info.discardChanges(); - hunkDisplayData.get(hunk)!.acceptedOrRejected = HunkState.Rejected; - renderHunks(); - }; - - // original view zone - const mightContainNonBasicASCII = this._session.textModel0.mightContainNonBasicASCII() ?? false; - const mightContainRTL = this._session.textModel0.mightContainRTL() ?? false; - const renderOptions = RenderOptions.fromEditor(this._editor); - const source = new LineSource( - hunk.original.mapToLineArray(l => this._session.textModel0.tokenization.getLineTokens(l)), - [], - mightContainNonBasicASCII, - mightContainRTL, + const hunkRanges = hunkData.getRangesN(); + let data = this._hunkDisplayData.get(hunkData); + if (!data) { + // first time -> create decoration + const decorationIds: string[] = []; + for (let i = 0; i < hunkRanges.length; i++) { + decorationIds.push(decorationsAccessor.addDecoration(hunkRanges[i], i === 0 + ? this._decoInsertedText + : this._decoInsertedTextRange) ); - const domNode = document.createElement('div'); - domNode.className = 'inline-chat-original-zone2'; - const result = renderLines(source, renderOptions, [new InlineDecoration(new Range(hunk.original.startLineNumber, 1, hunk.original.startLineNumber, 1), '', InlineDecorationType.Regular)], domNode); - const viewZoneData: IViewZone = { - afterLineNumber: -1, - heightInLines: result.heightInLines, - domNode, - }; + } - const toggleDiff = () => { - const scrollState = StableEditorScrollState.capture(this._editor); - if (!data!.viewZoneId) { + const acceptHunk = () => { + hunkData.acceptChanges(); + renderHunks(); + }; - this._editor.changeViewZones(accessor => { - const [hunkRange] = hunkTrackedRanges.get(hunk)!.getRanges(); - viewZoneData.afterLineNumber = hunkRange.startLineNumber - 1; - data!.viewZoneId = accessor.addZone(viewZoneData); - }); - this._ctxCurrentChangeShowsDiff.set(true); + const discardHunk = () => { + hunkData.discardChanges(); + renderHunks(); + }; + + // original view zone + const mightContainNonBasicASCII = this._session.textModel0.mightContainNonBasicASCII(); + const mightContainRTL = this._session.textModel0.mightContainRTL(); + const renderOptions = RenderOptions.fromEditor(this._editor); + const originalRange = hunkData.getRanges0()[0]; + const source = new LineSource( + LineRange.fromRangeInclusive(originalRange).mapToLineArray(l => this._session.textModel0.tokenization.getLineTokens(l)), + [], + mightContainNonBasicASCII, + mightContainRTL, + ); + const domNode = document.createElement('div'); + domNode.className = 'inline-chat-original-zone2'; + const result = renderLines(source, renderOptions, [new InlineDecoration(new Range(originalRange.startLineNumber, 1, originalRange.startLineNumber, 1), '', InlineDecorationType.Regular)], domNode); + const viewZoneData: IViewZone = { + afterLineNumber: -1, + heightInLines: result.heightInLines, + domNode, + }; + + const toggleDiff = () => { + const scrollState = StableEditorScrollState.capture(this._editor); + changeDecorationsAndViewZones(this._editor, (_decorationsAccessor, viewZoneAccessor) => { + assertType(data); + if (!data.viewZone) { + const [hunkRange] = hunkData.getRangesN(); + viewZoneData.afterLineNumber = hunkRange.startLineNumber - 1; + data.viewZoneId = viewZoneAccessor.addZone(viewZoneData); } else { - this._editor.changeViewZones(accessor => { - accessor.removeZone(data!.viewZoneId!); - data!.viewZoneId = undefined; - }); - this._ctxCurrentChangeShowsDiff.set(false); + viewZoneAccessor.removeZone(data.viewZoneId!); + data.viewZoneId = undefined; } - scrollState.restore(this._editor); - }; + }); + this._ctxCurrentChangeShowsDiff.set(typeof data?.viewZoneId === 'number'); + scrollState.restore(this._editor); + }; - const zoneLineNumber = this._zone.position!.lineNumber; - const myDistance = zoneLineNumber <= hunkRanges[0].startLineNumber - ? hunkRanges[0].startLineNumber - zoneLineNumber - : zoneLineNumber - hunkRanges[0].endLineNumber; + const remove = () => { + changeDecorationsAndViewZones(this._editor, (decorationsAccessor, viewZoneAccessor) => { + assertType(data); + for (const decorationId of data.decorationIds) { + decorationsAccessor.removeDecoration(decorationId); + } + if (data.viewZoneId) { + viewZoneAccessor.removeZone(data.viewZoneId); + } + data.decorationIds = []; + data.viewZoneId = undefined; + }); + }; - data = { - acceptedOrRejected: undefined, - decorationIds, - viewZoneId: '', - viewZone: viewZoneData, - distance: myDistance, - position: hunkRanges[0].getStartPosition().delta(-1), - acceptHunk, - discardHunk, - toggleDiff: !hunk.original.isEmpty ? toggleDiff : undefined, - }; + const zoneLineNumber = this._zone.position!.lineNumber; + const myDistance = zoneLineNumber <= hunkRanges[0].startLineNumber + ? hunkRanges[0].startLineNumber - zoneLineNumber + : zoneLineNumber - hunkRanges[0].endLineNumber; - hunkDisplayData.set(hunk, data); + data = { + decorationIds, + viewZoneId: '', + viewZone: viewZoneData, + distance: myDistance, + position: hunkRanges[0].getStartPosition().delta(-1), + acceptHunk, + discardHunk, + toggleDiff: !hunkData.isInsertion() ? toggleDiff : undefined, + remove, + }; - } else if (data.acceptedOrRejected !== undefined) { - // accepted or rejected -> remove decoration - for (const decorationId of data.decorationIds) { - decorationsAccessor.removeDecoration(decorationId); - } - if (data.viewZoneId) { - viewZoneAccessor.removeZone(data.viewZoneId); - } + this._hunkDisplayData.set(hunkData, data); - data.decorationIds = []; - data.viewZoneId = undefined; + } else if (hunkData.getState() !== HunkState.Pending) { + data.remove(); - } else { - // update distance and position based on modifiedRange-decoration - const zoneLineNumber = this._zone.position!.lineNumber; - const modifiedRangeNow = hunkRanges[0]; - data.position = modifiedRangeNow.getStartPosition().delta(-1); - data.distance = zoneLineNumber <= modifiedRangeNow.startLineNumber - ? modifiedRangeNow.startLineNumber - zoneLineNumber - : zoneLineNumber - modifiedRangeNow.endLineNumber; - } - - if (!data.acceptedOrRejected) { - if (!widgetData || data.distance < widgetData.distance) { - widgetData = data; - } - } - } - }); - - if (widgetData) { - this._zone.updatePositionAndHeight(widgetData.position); - this._editor.revealPositionInCenterIfOutsideViewport(widgetData.position); - - const remainingHunks = Iterable.reduce(hunkDisplayData.values(), (p, c) => { return p + (c.acceptedOrRejected ? 0 : 1); }, 0); - this._updateSummaryMessage(remainingHunks); - - this._ctxCurrentChangeHasDiff.set(Boolean(widgetData.toggleDiff)); - this.toggleDiff = widgetData.toggleDiff; - this.acceptHunk = async () => widgetData!.acceptHunk(); - this.discardHunk = async () => widgetData!.discardHunk(); - - } else if (hunkDisplayData.size > 0) { - // everything accepted or rejected - let oneAccepted = false; - for (const data of hunkDisplayData.values()) { - if (data.acceptedOrRejected === HunkState.Accepted) { - oneAccepted = true; - break; - } - } - if (oneAccepted) { - this._onDidAccept.fire(); } else { - this._onDidDiscard.fire(); + // update distance and position based on modifiedRange-decoration + const zoneLineNumber = this._zone.position!.lineNumber; + const modifiedRangeNow = hunkRanges[0]; + data.position = modifiedRangeNow.getStartPosition().delta(-1); + data.distance = zoneLineNumber <= modifiedRangeNow.startLineNumber + ? modifiedRangeNow.startLineNumber - zoneLineNumber + : zoneLineNumber - modifiedRangeNow.endLineNumber; + } + + if (hunkData.getState() === HunkState.Pending && (!widgetData || data.distance < widgetData.distance)) { + widgetData = data; } } - return widgetData; - }; - - const hideHunks = () => { - changeDecorationsAndViewZones(this._editor, (decorationsAccessor, viewZoneAccessor) => { - for (const data of hunkDisplayData.values()) { - // remove decorations - for (const decorationId of data.decorationIds) { - decorationsAccessor.removeDecoration(decorationId); - } - // remove view zone - if (data.viewZoneId) { - viewZoneAccessor.removeZone(data.viewZoneId); - } - data.viewZone.domNode.remove(); + for (const key of keysNow) { + const data = this._hunkDisplayData.get(key); + if (data) { + this._hunkDisplayData.delete(key); + data.remove(); } - }); - hunkDisplayData.clear(); - }; - - const discardHunks = () => { - for (const data of hunkTrackedRanges.values()) { - data.discardChanges(); } - }; + }); - this._hunkDisplay = { renderHunks, hideHunks, discardHunks }; - } + if (widgetData) { + this._zone.updatePositionAndHeight(widgetData.position); + this._editor.revealPositionInCenterIfOutsideViewport(widgetData.position); - return this._hunkDisplay?.renderHunks()?.position; - } + const remainingHunks = this._session.hunkData.pending; + this._updateSummaryMessage(remainingHunks); - private static readonly HUNK_THRESHOLD = 8; + this._ctxCurrentChangeHasDiff.set(Boolean(widgetData.toggleDiff)); + this.toggleDiff = widgetData.toggleDiff; + this.acceptHunk = async () => widgetData!.acceptHunk(); + this.discardHunk = async () => widgetData!.discardHunk(); - private async _computeHunks(): Promise { - const diff = await this._editorWorkerService.computeDiff(this._session.textModel0.uri, this._session.textModelN.uri, { ignoreTrimWhitespace: false, maxComputationTimeMs: Number.MAX_SAFE_INTEGER, computeMoves: false }, 'advanced'); - - if (!diff || diff.changes.length === 0) { - return []; - } - - // merge changes neighboring changes - const mergedChanges = [diff.changes[0]]; - for (let i = 1; i < diff.changes.length; i++) { - const lastChange = mergedChanges[mergedChanges.length - 1]; - const thisChange = diff.changes[i]; - if (thisChange.modified.startLineNumber - lastChange.modified.endLineNumberExclusive <= LiveStrategy.HUNK_THRESHOLD) { - mergedChanges[mergedChanges.length - 1] = new DetailedLineRangeMapping( - lastChange.original.join(thisChange.original), - lastChange.modified.join(thisChange.modified), - (lastChange.innerChanges ?? []).concat(thisChange.innerChanges ?? []) - ); - } else { - mergedChanges.push(thisChange); + } else if (this._hunkDisplayData.size > 0) { + // everything accepted or rejected + let oneAccepted = false; + for (const hunkData of this._session.hunkData.getInfo()) { + if (hunkData.getState() === HunkState.Accepted) { + oneAccepted = true; + break; + } + } + if (oneAccepted) { + this._onDidAccept.fire(); + } else { + this._onDidDiscard.fire(); + } } - } - return mergedChanges.map(change => new Hunk(change.original, change.modified, change.innerChanges ?? [])); + return widgetData; + }; + + return renderHunks()?.position; } - protected _updateSummaryMessage(hunkCount: number) { let message: string; if (hunkCount === 0) { @@ -966,13 +839,6 @@ async function undoModelUntil(model: ITextModel, targetAltVersion: number): Prom } -function asRange(lineRange: LineRange, model: ITextModel): Range { - return lineRange.isEmpty - ? new Range(lineRange.startLineNumber, 1, lineRange.startLineNumber, model.getLineLength(lineRange.startLineNumber)) - : new Range(lineRange.startLineNumber, 1, lineRange.endLineNumberExclusive - 1, model.getLineLength(lineRange.endLineNumberExclusive - 1)); -} - - function changeDecorationsAndViewZones(editor: ICodeEditor, callback: (accessor: IModelDecorationsChangeAccessor, viewZoneAccessor: IViewZoneChangeAccessor) => void): void { editor.changeDecorations(decorationsAccessor => { editor.changeViewZones(viewZoneAccessor => { diff --git a/src/vs/workbench/contrib/inlineChat/browser/utils.ts b/src/vs/workbench/contrib/inlineChat/browser/utils.ts index a2a77ac6f2d..43364755deb 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/utils.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/utils.ts @@ -24,3 +24,9 @@ export function invertLineRange(range: LineRange, model: ITextModel): LineRange[ export function lineRangeAsRange(r: LineRange): Range { return new Range(r.startLineNumber, 1, r.endLineNumberExclusive - 1, 1); } + +export function asRange(lineRange: LineRange, model: ITextModel): Range { + return lineRange.isEmpty + ? new Range(lineRange.startLineNumber, 1, lineRange.startLineNumber, model.getLineLength(lineRange.startLineNumber)) + : new Range(lineRange.startLineNumber, 1, lineRange.endLineNumberExclusive - 1, model.getLineLength(lineRange.endLineNumberExclusive - 1)); +} diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts index 4219dd38c12..d2b9fe55c34 100644 --- a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts @@ -32,8 +32,10 @@ import { IAccessibleViewService } from 'vs/workbench/contrib/accessibility/brows import { IChatAccessibilityService } from 'vs/workbench/contrib/chat/browser/chat'; import { IChatResponseViewModel } from 'vs/workbench/contrib/chat/common/chatViewModel'; import { InlineChatController, InlineChatRunOptions, State } from 'vs/workbench/contrib/inlineChat/browser/inlineChatController'; -import { IInlineChatSavingService } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSaving'; -import { IInlineChatSessionService, InlineChatSessionService, Session } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; +import { IInlineChatSavingService } from '../../browser/inlineChatSavingService'; +import { Session } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; +import { InlineChatSessionServiceImpl } from '../../browser/inlineChatSessionServiceImpl'; +import { IInlineChatSessionService } from '../../browser/inlineChatSessionService'; import { EditMode, IInlineChatService, InlineChatConfigKeys, InlineChatResponseType } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import { InlineChatServiceImpl } from 'vs/workbench/contrib/inlineChat/common/inlineChatServiceImpl'; import { workbenchInstantiationService } from 'vs/workbench/test/browser/workbenchTestServices'; @@ -106,7 +108,7 @@ suite('InteractiveChatController', function () { [IContextKeyService, contextKeyService], [IInlineChatService, inlineChatService], [IDiffProviderFactoryService, new SyncDescriptor(TestDiffProviderFactoryService)], - [IInlineChatSessionService, new SyncDescriptor(InlineChatSessionService)], + [IInlineChatSessionService, new SyncDescriptor(InlineChatSessionServiceImpl)], [IInlineChatSavingService, new class extends mock() { override markChanged(session: Session): void { // noop diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSession.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSession.test.ts index 1242e625ef7..f5f42a2bac8 100644 --- a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSession.test.ts +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSession.test.ts @@ -3,14 +3,51 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; +import { DisposableStore } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; +import { Event } from 'vs/base/common/event'; import { mock } from 'vs/base/test/common/mock'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; +import { TestDiffProviderFactoryService } from 'vs/editor/browser/diff/testDiffProviderFactoryService'; +import { IActiveCodeEditor } from 'vs/editor/browser/editorBrowser'; +import { IDiffProviderFactoryService } from 'vs/editor/browser/widget/diffEditor/diffProviderFactoryService'; import { Range } from 'vs/editor/common/core/range'; import { ILanguageService } from 'vs/editor/common/languages/language'; -import { ReplyResponse } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; -import { IInlineChatEditResponse, InlineChatResponseType, InlineChatResponseTypes } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; +import { ITextModel } from 'vs/editor/common/model'; +import { IModelService } from 'vs/editor/common/services/model'; +import { instantiateTestCodeEditor } from 'vs/editor/test/browser/testCodeEditor'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; +import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; +import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; +import { MockContextKeyService } from 'vs/platform/keybinding/test/common/mockKeybindingService'; +import { IEditorProgressService, IProgressRunner } from 'vs/platform/progress/common/progress'; +import { IViewDescriptorService } from 'vs/workbench/common/views'; +import { AccessibilityVerbositySettingId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; +import { IAccessibleViewService } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; +import { IChatAccessibilityService } from 'vs/workbench/contrib/chat/browser/chat'; +import { IChatResponseViewModel } from 'vs/workbench/contrib/chat/common/chatViewModel'; +import { IInlineChatSavingService } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSavingService'; +import { HunkState, ReplyResponse, Session } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; +import { IInlineChatSessionService } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSessionService'; +import { InlineChatSessionServiceImpl } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl'; +import { EditMode, IInlineChatEditResponse, IInlineChatService, InlineChatResponseType, InlineChatResponseTypes } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; +import { workbenchInstantiationService } from 'vs/workbench/test/browser/workbenchTestServices'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { assertType } from 'vs/base/common/types'; +import { InlineChatServiceImpl } from 'vs/workbench/contrib/inlineChat/common/inlineChatServiceImpl'; +import { EditOperation } from 'vs/editor/common/core/editOperation'; +import { Position } from 'vs/editor/common/core/position'; +import { DiffAlgorithmName, IEditorWorkerService, ILineChange } from 'vs/editor/common/services/editorWorker'; +import { IDocumentDiff, IDocumentDiffProviderOptions } from 'vs/editor/common/diff/documentDiffProvider'; +import { EditorSimpleWorker } from 'vs/editor/common/services/editorSimpleWorker'; +import { LineRange } from 'vs/editor/common/core/lineRange'; +import { MovedText } from 'vs/editor/common/diff/linesDiffComputer'; +import { LineRangeMapping, DetailedLineRangeMapping, RangeMapping } from 'vs/editor/common/diff/rangeMapping'; + suite('ReplyResponse', function () { @@ -48,3 +85,278 @@ suite('ReplyResponse', function () { } }); }); + +class TestWorkerService extends mock() { + + private readonly _worker = new EditorSimpleWorker(null!, null); + + constructor(@IModelService private readonly _modelService: IModelService) { + super(); + } + + override async computeDiff(original: URI, modified: URI, options: IDocumentDiffProviderOptions, algorithm: DiffAlgorithmName): Promise { + + const originalModel = this._modelService.getModel(original); + const modifiedModel = this._modelService.getModel(modified); + + assertType(originalModel); + assertType(modifiedModel); + + this._worker.acceptNewModel({ + url: originalModel.uri.toString(), + versionId: originalModel.getVersionId(), + lines: originalModel.getLinesContent(), + EOL: originalModel.getEOL(), + }); + + this._worker.acceptNewModel({ + url: modifiedModel.uri.toString(), + versionId: modifiedModel.getVersionId(), + lines: modifiedModel.getLinesContent(), + EOL: modifiedModel.getEOL(), + }); + + const result = await this._worker.computeDiff(originalModel.uri.toString(), modifiedModel.uri.toString(), options, algorithm); + if (!result) { + return result; + } + // Convert from space efficient JSON data to rich objects. + const diff: IDocumentDiff = { + identical: result.identical, + quitEarly: result.quitEarly, + changes: toLineRangeMappings(result.changes), + moves: result.moves.map(m => new MovedText( + new LineRangeMapping(new LineRange(m[0], m[1]), new LineRange(m[2], m[3])), + toLineRangeMappings(m[4]) + )) + }; + return diff; + + function toLineRangeMappings(changes: readonly ILineChange[]): readonly DetailedLineRangeMapping[] { + return changes.map( + (c) => new DetailedLineRangeMapping( + new LineRange(c[0], c[1]), + new LineRange(c[2], c[3]), + c[4]?.map( + (c) => new RangeMapping( + new Range(c[0], c[1], c[2], c[3]), + new Range(c[4], c[5], c[6], c[7]) + ) + ) + ) + ); + } + } +} + +suite('InlineChatSession', function () { + + const store = new DisposableStore(); + let editor: IActiveCodeEditor; + let model: ITextModel; + let instaService: TestInstantiationService; + let inlineChatService: InlineChatServiceImpl; + + let inlineChatSessionService: IInlineChatSessionService; + + setup(function () { + const contextKeyService = new MockContextKeyService(); + inlineChatService = new InlineChatServiceImpl(contextKeyService); + + const serviceCollection = new ServiceCollection( + [IEditorWorkerService, new SyncDescriptor(TestWorkerService)], + [IInlineChatService, inlineChatService], + [IContextKeyService, contextKeyService], + [IDiffProviderFactoryService, new SyncDescriptor(TestDiffProviderFactoryService)], + [IInlineChatSessionService, new SyncDescriptor(InlineChatSessionServiceImpl)], + [IInlineChatSavingService, new class extends mock() { + override markChanged(session: Session): void { + // noop + } + }], + [IEditorProgressService, new class extends mock() { + override show(total: unknown, delay?: unknown): IProgressRunner { + return { + total() { }, + worked(value) { }, + done() { }, + }; + } + }], + [IChatAccessibilityService, new class extends mock() { + override acceptResponse(response: IChatResponseViewModel | undefined, requestId: number): void { } + override acceptRequest(): number { return -1; } + }], + [IAccessibleViewService, new class extends mock() { + override getOpenAriaHint(verbositySettingKey: AccessibilityVerbositySettingId): string | null { + return null; + } + }], + [IConfigurationService, new TestConfigurationService()], + [IViewDescriptorService, new class extends mock() { + override onDidChangeLocation = Event.None; + }] + ); + + store.add(inlineChatService.addProvider({ + debugName: 'Unit Test', + label: 'Unit Test', + prepareInlineChatSession() { + return { + id: Math.random() + }; + }, + provideResponse(session, request) { + return { + type: InlineChatResponseType.EditorEdit, + id: Math.random(), + edits: [{ + range: new Range(1, 1, 1, 1), + text: request.prompt + }] + }; + } + })); + + instaService = store.add(workbenchInstantiationService(undefined, store).createChild(serviceCollection)); + inlineChatSessionService = store.add(instaService.get(IInlineChatSessionService)); + + model = store.add(instaService.get(IModelService).createModel('one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven', null)); + editor = store.add(instantiateTestCodeEditor(instaService, model)); + }); + + teardown(function () { + store.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('Create, release', async function () { + + const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + assertType(session); + inlineChatSessionService.releaseSession(session); + }); + + test('HunkData, info', async function () { + + const decorationCountThen = model.getAllDecorations().length; + + const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + assertType(session); + assert.ok(session.textModelN === model); + + editor.executeEdits('test', [EditOperation.insert(new Position(1, 1), 'AI_EDIT\n')]); + + await session.hunkData.recompute(); + assert.strictEqual(session.hunkData.size, 1); + const [hunk] = session.hunkData.getInfo(); + assertType(hunk); + + assert.ok(!session.textModel0.equalsTextBuffer(session.textModelN.getTextBuffer())); + assert.strictEqual(hunk.getState(), HunkState.Pending); + assert.ok(hunk.getRangesN()[0].equalsRange({ startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 7 })); + + editor.executeEdits('test', [EditOperation.insert(new Position(1, 3), 'foobar')]); + assert.ok(hunk.getRangesN()[0].equalsRange({ startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 13 })); + + inlineChatSessionService.releaseSession(session); + + assert.strictEqual(model.getAllDecorations().length, decorationCountThen); // no leaked decorations! + }); + + test('HunkData, accept', async function () { + + const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + assertType(session); + + editor.executeEdits('test', [EditOperation.insert(new Position(1, 1), 'AI_EDIT\n'), EditOperation.insert(new Position(10, 1), 'AI_EDIT\n')]); + + await session.hunkData.recompute(); + assert.strictEqual(session.hunkData.size, 2); + assert.ok(!session.textModel0.equalsTextBuffer(session.textModelN.getTextBuffer())); + + for (const hunk of session.hunkData.getInfo()) { + assertType(hunk); + assert.strictEqual(hunk.getState(), HunkState.Pending); + hunk.acceptChanges(); + assert.strictEqual(hunk.getState(), HunkState.Accepted); + } + + assert.strictEqual(session.textModel0.getValue(), session.textModelN.getValue()); + inlineChatSessionService.releaseSession(session); + }); + + test('HunkData, reject', async function () { + + const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + assertType(session); + + editor.executeEdits('test', [EditOperation.insert(new Position(1, 1), 'AI_EDIT\n'), EditOperation.insert(new Position(10, 1), 'AI_EDIT\n')]); + + await session.hunkData.recompute(); + assert.strictEqual(session.hunkData.size, 2); + assert.ok(!session.textModel0.equalsTextBuffer(session.textModelN.getTextBuffer())); + + for (const hunk of session.hunkData.getInfo()) { + assertType(hunk); + assert.strictEqual(hunk.getState(), HunkState.Pending); + hunk.discardChanges(); + assert.strictEqual(hunk.getState(), HunkState.Rejected); + } + + assert.strictEqual(session.textModel0.getValue(), session.textModelN.getValue()); + inlineChatSessionService.releaseSession(session); + }); + + test('HunkData, N rounds', async function () { + + model.setValue('one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelwe\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\n'); + + const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + assertType(session); + + assert.ok(session.textModel0.equalsTextBuffer(session.textModelN.getTextBuffer())); + + assert.strictEqual(session.hunkData.size, 0); + + // ROUND #1 + editor.executeEdits('test', [ + EditOperation.insert(new Position(1, 1), 'AI1'), + EditOperation.insert(new Position(4, 1), 'AI2'), + EditOperation.insert(new Position(19, 1), 'AI3') + ]); + + await session.hunkData.recompute(); + assert.strictEqual(session.hunkData.size, 2); // AI1, AI2 are merged into one hunk, AI3 is a separate hunk + + let [first, second] = session.hunkData.getInfo(); + + assert.ok(model.getValueInRange(first.getRangesN()[0]).includes('AI1')); + assert.ok(model.getValueInRange(first.getRangesN()[0]).includes('AI2')); + assert.ok(model.getValueInRange(second.getRangesN()[0]).includes('AI3')); + + assert.ok(!session.textModel0.getValueInRange(first.getRangesN()[0]).includes('AI1')); + assert.ok(!session.textModel0.getValueInRange(first.getRangesN()[0]).includes('AI2')); + assert.ok(!session.textModel0.getValueInRange(second.getRangesN()[0]).includes('AI3')); + + first.acceptChanges(); + assert.ok(session.textModel0.getValueInRange(first.getRangesN()[0]).includes('AI1')); + assert.ok(session.textModel0.getValueInRange(first.getRangesN()[0]).includes('AI2')); + assert.ok(!session.textModel0.getValueInRange(second.getRangesN()[0]).includes('AI3')); + + + // ROUND #2 + editor.executeEdits('test', [ + EditOperation.insert(new Position(7, 1), 'AI4'), + ]); + await session.hunkData.recompute(); + assert.strictEqual(session.hunkData.size, 2); + + [first, second] = session.hunkData.getInfo(); + assert.ok(model.getValueInRange(first.getRangesN()[0]).includes('AI4')); // the new hunk (in line-order) + assert.ok(model.getValueInRange(second.getRangesN()[0]).includes('AI3')); // the previous hunk remains + + inlineChatSessionService.releaseSession(session); + }); +}); diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts b/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts index 974f005d284..8bbbe4c7753 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts @@ -12,7 +12,7 @@ import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IProductService } from 'vs/platform/product/common/productService'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { EmptyTextEditorHintContribution, IEmptyTextEditorHintOptions } from 'vs/workbench/contrib/codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint'; -import { IInlineChatSessionService } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; +import { IInlineChatSessionService } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSessionService'; import { IInlineChatService } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import { getNotebookEditorFromEditorPane } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/chat/cellChatController.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/chat/cellChatController.ts index fa7eb4cfc9a..cfc2b2fc2d3 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/chat/cellChatController.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/chat/cellChatController.ts @@ -29,7 +29,8 @@ import { AsyncProgress } from 'vs/platform/progress/common/progress'; import { SaveReason } from 'vs/workbench/common/editor'; import { countWords } from 'vs/workbench/contrib/chat/common/chatWordCounter'; import { InlineChatController } from 'vs/workbench/contrib/inlineChat/browser/inlineChatController'; -import { EmptyResponse, ErrorResponse, IInlineChatSessionService, ReplyResponse, Session, SessionExchange, SessionPrompt } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; +import { EmptyResponse, ErrorResponse, ReplyResponse, Session, SessionExchange, SessionPrompt } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; +import { IInlineChatSessionService } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSessionService'; import { ProgressingEditsOptions, asProgressiveEdit, performAsyncTextEdit } from 'vs/workbench/contrib/inlineChat/browser/inlineChatStrategies'; import { InlineChatWidget } from 'vs/workbench/contrib/inlineChat/browser/inlineChatWidget'; import { CTX_INLINE_CHAT_LAST_RESPONSE_TYPE, CTX_INLINE_CHAT_VISIBLE, EditMode, IInlineChatProgressItem, IInlineChatRequest, InlineChatResponseFeedbackKind, InlineChatResponseType } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; From 62cb62c07bd4646fa1fe9929e82fba32e81976a7 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 16 Jan 2024 19:21:50 +0530 Subject: [PATCH 2/2] fix #161906 (#202582) --- .../contrib/extensions/browser/extensionsViews.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts index db5a0672ed9..cd8a1b647cd 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts @@ -1004,7 +1004,7 @@ export class ExtensionsListView extends ViewPane { this.updateSize(); } - private updateSize() { + protected updateSize() { if (this.options.flexibleHeight) { this.maximumBodySize = this.list?.model.length ? Number.POSITIVE_INFINITY : 0; this.storageService.store(`${this.id}.size`, this.list?.model.length || 0, StorageScope.PROFILE, StorageTarget.MACHINE); @@ -1228,10 +1228,12 @@ export class OutdatedExtensionsView extends ExtensionsListView { if (ExtensionsListView.isSearchExtensionUpdatesQuery(query)) { query = query.replace('@updates', '@outdated'); } + return super.show(query.trim()); + } - const model = await super.show(query.trim()); - this.setExpanded(model.length > 0); - return model; + protected override updateSize() { + super.updateSize(); + this.setExpanded(this.count() > 0); } }