Path: blob/main/src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { WindowIntervalTimer } from '../../../../base/browser/dom.js';6import { CancellationToken } from '../../../../base/common/cancellation.js';7import { Emitter, Event } from '../../../../base/common/event.js';8import { DisposableStore } from '../../../../base/common/lifecycle.js';9import { themeColorFromId, ThemeIcon } from '../../../../base/common/themables.js';10import { ICodeEditor, IViewZone, IViewZoneChangeAccessor } from '../../../../editor/browser/editorBrowser.js';11import { StableEditorScrollState } from '../../../../editor/browser/stableEditorScroll.js';12import { LineSource, RenderOptions, renderLines } from '../../../../editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.js';13import { ISingleEditOperation } from '../../../../editor/common/core/editOperation.js';14import { LineRange } from '../../../../editor/common/core/ranges/lineRange.js';15import { Position } from '../../../../editor/common/core/position.js';16import { Range } from '../../../../editor/common/core/range.js';17import { IEditorDecorationsCollection } from '../../../../editor/common/editorCommon.js';18import { IModelDecorationsChangeAccessor, IModelDeltaDecoration, IValidEditOperation, MinimapPosition, OverviewRulerLane, TrackedRangeStickiness } from '../../../../editor/common/model.js';19import { ModelDecorationOptions } from '../../../../editor/common/model/textModel.js';20import { IEditorWorkerService } from '../../../../editor/common/services/editorWorker.js';21import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';22import { Progress } from '../../../../platform/progress/common/progress.js';23import { SaveReason } from '../../../common/editor.js';24import { countWords } from '../../chat/common/chatWordCounter.js';25import { HunkInformation, Session, HunkState } from './inlineChatSession.js';26import { InlineChatZoneWidget } from './inlineChatZoneWidget.js';27import { ACTION_TOGGLE_DIFF, CTX_INLINE_CHAT_CHANGE_HAS_DIFF, CTX_INLINE_CHAT_CHANGE_SHOWS_DIFF, InlineChatConfigKeys, MENU_INLINE_CHAT_ZONE, minimapInlineChatDiffInserted, overviewRulerInlineChatDiffInserted } from '../common/inlineChat.js';28import { assertType } from '../../../../base/common/types.js';29import { performAsyncTextEdit, asProgressiveEdit } from './utils.js';30import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js';31import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';32import { ITextFileService } from '../../../services/textfile/common/textfiles.js';33import { IUntitledTextEditorModel } from '../../../services/untitled/common/untitledTextEditorModel.js';34import { Schemas } from '../../../../base/common/network.js';35import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';36import { DefaultChatTextEditor } from '../../chat/browser/codeBlockPart.js';37import { isEqual } from '../../../../base/common/resources.js';38import { Iterable } from '../../../../base/common/iterator.js';39import { ConflictActionsFactory, IContentWidgetAction } from '../../mergeEditor/browser/view/conflictActions.js';40import { observableValue } from '../../../../base/common/observable.js';41import { IMenuService, MenuItemAction } from '../../../../platform/actions/common/actions.js';42import { InlineDecoration, InlineDecorationType } from '../../../../editor/common/viewModel/inlineDecorations.js';43import { EditSources } from '../../../../editor/common/textModelEditSource.js';44import { VersionedExtensionId } from '../../../../editor/common/languages.js';4546export interface IEditObserver {47start(): void;48stop(): void;49}5051export const enum HunkAction {52Accept,53Discard,54MoveNext,55MovePrev,56ToggleDiff57}5859export class LiveStrategy {6061private readonly _decoInsertedText = ModelDecorationOptions.register({62description: 'inline-modified-line',63className: 'inline-chat-inserted-range-linehighlight',64isWholeLine: true,65overviewRuler: {66position: OverviewRulerLane.Full,67color: themeColorFromId(overviewRulerInlineChatDiffInserted),68},69minimap: {70position: MinimapPosition.Inline,71color: themeColorFromId(minimapInlineChatDiffInserted),72}73});7475private readonly _decoInsertedTextRange = ModelDecorationOptions.register({76description: 'inline-chat-inserted-range-linehighlight',77className: 'inline-chat-inserted-range',78stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,79});8081protected readonly _store = new DisposableStore();82protected readonly _onDidAccept = this._store.add(new Emitter<void>());83protected readonly _onDidDiscard = this._store.add(new Emitter<void>());84private readonly _ctxCurrentChangeHasDiff: IContextKey<boolean>;85private readonly _ctxCurrentChangeShowsDiff: IContextKey<boolean>;86private readonly _progressiveEditingDecorations: IEditorDecorationsCollection;87private readonly _lensActionsFactory: ConflictActionsFactory;88private _editCount: number = 0;89private readonly _hunkData = new Map<HunkInformation, HunkDisplayData>();9091readonly onDidAccept: Event<void> = this._onDidAccept.event;92readonly onDidDiscard: Event<void> = this._onDidDiscard.event;9394constructor(95protected readonly _session: Session,96protected readonly _editor: ICodeEditor,97protected readonly _zone: InlineChatZoneWidget,98private readonly _showOverlayToolbar: boolean,99@IContextKeyService contextKeyService: IContextKeyService,100@IEditorWorkerService protected readonly _editorWorkerService: IEditorWorkerService,101@IAccessibilityService private readonly _accessibilityService: IAccessibilityService,102@IConfigurationService private readonly _configService: IConfigurationService,103@IMenuService private readonly _menuService: IMenuService,104@IContextKeyService private readonly _contextService: IContextKeyService,105@ITextFileService private readonly _textFileService: ITextFileService,106@IInstantiationService protected readonly _instaService: IInstantiationService107) {108this._ctxCurrentChangeHasDiff = CTX_INLINE_CHAT_CHANGE_HAS_DIFF.bindTo(contextKeyService);109this._ctxCurrentChangeShowsDiff = CTX_INLINE_CHAT_CHANGE_SHOWS_DIFF.bindTo(contextKeyService);110111this._progressiveEditingDecorations = this._editor.createDecorationsCollection();112this._lensActionsFactory = this._store.add(new ConflictActionsFactory(this._editor));113}114115dispose(): void {116this._resetDiff();117this._store.dispose();118}119120private _resetDiff(): void {121this._ctxCurrentChangeHasDiff.reset();122this._ctxCurrentChangeShowsDiff.reset();123this._zone.widget.updateStatus('');124this._progressiveEditingDecorations.clear();125126127for (const data of this._hunkData.values()) {128data.remove();129}130}131132async apply() {133this._resetDiff();134if (this._editCount > 0) {135this._editor.pushUndoStop();136}137await this._doApplyChanges(true);138}139140cancel() {141this._resetDiff();142return this._session.hunkData.discardAll();143}144145async makeChanges(edits: ISingleEditOperation[], obs: IEditObserver, undoStopBefore: boolean, metadata: IInlineChatMetadata): Promise<void> {146return this._makeChanges(edits, obs, undefined, undefined, undoStopBefore, metadata);147}148149async makeProgressiveChanges(edits: ISingleEditOperation[], obs: IEditObserver, opts: ProgressingEditsOptions, undoStopBefore: boolean, metadata: IInlineChatMetadata): Promise<void> {150151// add decorations once per line that got edited152const progress = new Progress<IValidEditOperation[]>(edits => {153154const newLines = new Set<number>();155for (const edit of edits) {156LineRange.fromRange(edit.range).forEach(line => newLines.add(line));157}158const existingRanges = this._progressiveEditingDecorations.getRanges().map(LineRange.fromRange);159for (const existingRange of existingRanges) {160existingRange.forEach(line => newLines.delete(line));161}162const newDecorations: IModelDeltaDecoration[] = [];163for (const line of newLines) {164newDecorations.push({ range: new Range(line, 1, line, Number.MAX_VALUE), options: this._decoInsertedText });165}166167this._progressiveEditingDecorations.append(newDecorations);168});169return this._makeChanges(edits, obs, opts, progress, undoStopBefore, metadata);170}171172private async _makeChanges(edits: ISingleEditOperation[], obs: IEditObserver, opts: ProgressingEditsOptions | undefined, progress: Progress<IValidEditOperation[]> | undefined, undoStopBefore: boolean, metadata: IInlineChatMetadata): Promise<void> {173174// push undo stop before first edit175if (undoStopBefore) {176this._editor.pushUndoStop();177}178179this._editCount++;180const editSource = EditSources.inlineChatApplyEdit({181modelId: metadata.modelId,182extensionId: metadata.extensionId,183requestId: metadata.requestId,184languageId: this._session.textModelN.getLanguageId(),185});186187if (opts) {188// ASYNC189const durationInSec = opts.duration / 1000;190for (const edit of edits) {191const wordCount = countWords(edit.text ?? '');192const speed = wordCount / durationInSec;193// console.log({ durationInSec, wordCount, speed: wordCount / durationInSec });194const asyncEdit = asProgressiveEdit(new WindowIntervalTimer(this._zone.domNode), edit, speed, opts.token);195await performAsyncTextEdit(this._session.textModelN, asyncEdit, progress, obs, editSource);196}197198} else {199// SYNC200obs.start();201this._session.textModelN.pushEditOperations(null, edits, (undoEdits) => {202progress?.report(undoEdits);203return null;204}, undefined, editSource);205obs.stop();206}207}208209performHunkAction(hunk: HunkInformation | undefined, action: HunkAction) {210const displayData = this._findDisplayData(hunk);211212if (!displayData) {213// no hunks (left or not yet) found, make sure to214// finish the sessions215if (action === HunkAction.Accept) {216this._onDidAccept.fire();217} else if (action === HunkAction.Discard) {218this._onDidDiscard.fire();219}220return;221}222223if (action === HunkAction.Accept) {224displayData.acceptHunk();225} else if (action === HunkAction.Discard) {226displayData.discardHunk();227} else if (action === HunkAction.MoveNext) {228displayData.move(true);229} else if (action === HunkAction.MovePrev) {230displayData.move(false);231} else if (action === HunkAction.ToggleDiff) {232displayData.toggleDiff?.();233}234}235236private _findDisplayData(hunkInfo?: HunkInformation) {237let result: HunkDisplayData | undefined;238if (hunkInfo) {239// use context hunk (from tool/buttonbar)240result = this._hunkData.get(hunkInfo);241}242243if (!result && this._zone.position) {244// find nearest from zone position245const zoneLine = this._zone.position.lineNumber;246let distance: number = Number.MAX_SAFE_INTEGER;247for (const candidate of this._hunkData.values()) {248if (candidate.hunk.getState() !== HunkState.Pending) {249continue;250}251const hunkRanges = candidate.hunk.getRangesN();252if (hunkRanges.length === 0) {253// bogous hunk254continue;255}256const myDistance = zoneLine <= hunkRanges[0].startLineNumber257? hunkRanges[0].startLineNumber - zoneLine258: zoneLine - hunkRanges[0].endLineNumber;259260if (myDistance < distance) {261distance = myDistance;262result = candidate;263}264}265}266267if (!result) {268// fallback: first hunk that is pending269result = Iterable.first(Iterable.filter(this._hunkData.values(), candidate => candidate.hunk.getState() === HunkState.Pending));270}271return result;272}273274async renderChanges() {275276this._progressiveEditingDecorations.clear();277278const renderHunks = () => {279280let widgetData: HunkDisplayData | undefined;281282changeDecorationsAndViewZones(this._editor, (decorationsAccessor, viewZoneAccessor) => {283284const keysNow = new Set(this._hunkData.keys());285widgetData = undefined;286287for (const hunkData of this._session.hunkData.getInfo()) {288289keysNow.delete(hunkData);290291const hunkRanges = hunkData.getRangesN();292let data = this._hunkData.get(hunkData);293if (!data) {294// first time -> create decoration295const decorationIds: string[] = [];296for (let i = 0; i < hunkRanges.length; i++) {297decorationIds.push(decorationsAccessor.addDecoration(hunkRanges[i], i === 0298? this._decoInsertedText299: this._decoInsertedTextRange)300);301}302303const acceptHunk = () => {304hunkData.acceptChanges();305renderHunks();306};307308const discardHunk = () => {309hunkData.discardChanges();310renderHunks();311};312313// original view zone314const mightContainNonBasicASCII = this._session.textModel0.mightContainNonBasicASCII();315const mightContainRTL = this._session.textModel0.mightContainRTL();316const renderOptions = RenderOptions.fromEditor(this._editor);317const originalRange = hunkData.getRanges0()[0];318const source = new LineSource(319LineRange.fromRangeInclusive(originalRange).mapToLineArray(l => this._session.textModel0.tokenization.getLineTokens(l)),320[],321mightContainNonBasicASCII,322mightContainRTL,323);324const domNode = document.createElement('div');325domNode.className = 'inline-chat-original-zone2';326const result = renderLines(source, renderOptions, [new InlineDecoration(new Range(originalRange.startLineNumber, 1, originalRange.startLineNumber, 1), '', InlineDecorationType.Regular)], domNode);327const viewZoneData: IViewZone = {328afterLineNumber: -1,329heightInLines: result.heightInLines,330domNode,331ordinal: 50000 + 2 // more than https://github.com/microsoft/vscode/blob/bf52a5cfb2c75a7327c9adeaefbddc06d529dcad/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts#L42332};333334const toggleDiff = () => {335const scrollState = StableEditorScrollState.capture(this._editor);336changeDecorationsAndViewZones(this._editor, (_decorationsAccessor, viewZoneAccessor) => {337assertType(data);338if (!data.diffViewZoneId) {339const [hunkRange] = hunkData.getRangesN();340viewZoneData.afterLineNumber = hunkRange.startLineNumber - 1;341data.diffViewZoneId = viewZoneAccessor.addZone(viewZoneData);342} else {343viewZoneAccessor.removeZone(data.diffViewZoneId!);344data.diffViewZoneId = undefined;345}346});347this._ctxCurrentChangeShowsDiff.set(typeof data?.diffViewZoneId === 'string');348scrollState.restore(this._editor);349};350351352let lensActions: DisposableStore | undefined;353const lensActionsViewZoneIds: string[] = [];354355if (this._showOverlayToolbar && hunkData.getState() === HunkState.Pending) {356357lensActions = new DisposableStore();358359const menu = this._menuService.createMenu(MENU_INLINE_CHAT_ZONE, this._contextService);360const makeActions = () => {361const actions: IContentWidgetAction[] = [];362const tuples = menu.getActions({ arg: hunkData });363for (const [, group] of tuples) {364for (const item of group) {365if (item instanceof MenuItemAction) {366367let text = item.label;368369if (item.id === ACTION_TOGGLE_DIFF) {370text = item.checked ? 'Hide Changes' : 'Show Changes';371} else if (ThemeIcon.isThemeIcon(item.item.icon)) {372text = `$(${item.item.icon.id}) ${text}`;373}374375actions.push({376text,377tooltip: item.tooltip,378action: async () => item.run(),379});380}381}382}383return actions;384};385386const obs = observableValue(this, makeActions());387lensActions.add(menu.onDidChange(() => obs.set(makeActions(), undefined)));388lensActions.add(menu);389390lensActions.add(this._lensActionsFactory.createWidget(viewZoneAccessor,391hunkRanges[0].startLineNumber - 1,392obs,393lensActionsViewZoneIds394));395}396397const remove = () => {398changeDecorationsAndViewZones(this._editor, (decorationsAccessor, viewZoneAccessor) => {399assertType(data);400for (const decorationId of data.decorationIds) {401decorationsAccessor.removeDecoration(decorationId);402}403if (data.diffViewZoneId) {404viewZoneAccessor.removeZone(data.diffViewZoneId!);405}406data.decorationIds = [];407data.diffViewZoneId = undefined;408409data.lensActionsViewZoneIds?.forEach(viewZoneAccessor.removeZone);410data.lensActionsViewZoneIds = undefined;411});412413lensActions?.dispose();414};415416const move = (next: boolean) => {417const keys = Array.from(this._hunkData.keys());418const idx = keys.indexOf(hunkData);419const nextIdx = (idx + (next ? 1 : -1) + keys.length) % keys.length;420if (nextIdx !== idx) {421const nextData = this._hunkData.get(keys[nextIdx])!;422this._zone.updatePositionAndHeight(nextData?.position);423renderHunks();424}425};426427const zoneLineNumber = this._zone.position?.lineNumber ?? this._editor.getPosition()!.lineNumber;428const myDistance = zoneLineNumber <= hunkRanges[0].startLineNumber429? hunkRanges[0].startLineNumber - zoneLineNumber430: zoneLineNumber - hunkRanges[0].endLineNumber;431432data = {433hunk: hunkData,434decorationIds,435diffViewZoneId: '',436diffViewZone: viewZoneData,437lensActionsViewZoneIds,438distance: myDistance,439position: hunkRanges[0].getStartPosition().delta(-1),440acceptHunk,441discardHunk,442toggleDiff: !hunkData.isInsertion() ? toggleDiff : undefined,443remove,444move,445};446447this._hunkData.set(hunkData, data);448449} else if (hunkData.getState() !== HunkState.Pending) {450data.remove();451452} else {453// update distance and position based on modifiedRange-decoration454const zoneLineNumber = this._zone.position?.lineNumber ?? this._editor.getPosition()!.lineNumber;455const modifiedRangeNow = hunkRanges[0];456data.position = modifiedRangeNow.getStartPosition().delta(-1);457data.distance = zoneLineNumber <= modifiedRangeNow.startLineNumber458? modifiedRangeNow.startLineNumber - zoneLineNumber459: zoneLineNumber - modifiedRangeNow.endLineNumber;460}461462if (hunkData.getState() === HunkState.Pending && (!widgetData || data.distance < widgetData.distance)) {463widgetData = data;464}465}466467for (const key of keysNow) {468const data = this._hunkData.get(key);469if (data) {470this._hunkData.delete(key);471data.remove();472}473}474});475476if (widgetData) {477this._zone.reveal(widgetData.position);478479const mode = this._configService.getValue<'on' | 'off' | 'auto'>(InlineChatConfigKeys.AccessibleDiffView);480if (mode === 'on' || mode === 'auto' && this._accessibilityService.isScreenReaderOptimized()) {481this._zone.widget.showAccessibleHunk(this._session, widgetData.hunk);482}483484this._ctxCurrentChangeHasDiff.set(Boolean(widgetData.toggleDiff));485486} else if (this._hunkData.size > 0) {487// everything accepted or rejected488let oneAccepted = false;489for (const hunkData of this._session.hunkData.getInfo()) {490if (hunkData.getState() === HunkState.Accepted) {491oneAccepted = true;492break;493}494}495if (oneAccepted) {496this._onDidAccept.fire();497} else {498this._onDidDiscard.fire();499}500}501502return widgetData;503};504505return renderHunks()?.position;506}507508getWholeRangeDecoration(): IModelDeltaDecoration[] {509// don't render the blue in live mode510return [];511}512513private async _doApplyChanges(ignoreLocal: boolean): Promise<void> {514515const untitledModels: IUntitledTextEditorModel[] = [];516517const editor = this._instaService.createInstance(DefaultChatTextEditor);518519520for (const request of this._session.chatModel.getRequests()) {521522if (!request.response?.response) {523continue;524}525526for (const item of request.response.response.value) {527if (item.kind !== 'textEditGroup') {528continue;529}530if (ignoreLocal && isEqual(item.uri, this._session.textModelN.uri)) {531continue;532}533534await editor.apply(request.response, item, undefined);535536if (item.uri.scheme === Schemas.untitled) {537const untitled = this._textFileService.untitled.get(item.uri);538if (untitled) {539untitledModels.push(untitled);540}541}542}543}544545for (const untitledModel of untitledModels) {546if (!untitledModel.isDisposed()) {547await untitledModel.resolve();548await untitledModel.save({ reason: SaveReason.EXPLICIT });549}550}551}552}553554export interface ProgressingEditsOptions {555duration: number;556token: CancellationToken;557}558559type HunkDisplayData = {560561decorationIds: string[];562563diffViewZoneId: string | undefined;564diffViewZone: IViewZone;565566lensActionsViewZoneIds?: string[];567568distance: number;569position: Position;570acceptHunk: () => void;571discardHunk: () => void;572toggleDiff?: () => any;573remove(): void;574move: (next: boolean) => void;575576hunk: HunkInformation;577};578579function changeDecorationsAndViewZones(editor: ICodeEditor, callback: (accessor: IModelDecorationsChangeAccessor, viewZoneAccessor: IViewZoneChangeAccessor) => void): void {580editor.changeDecorations(decorationsAccessor => {581editor.changeViewZones(viewZoneAccessor => {582callback(decorationsAccessor, viewZoneAccessor);583});584});585}586587export interface IInlineChatMetadata {588modelId: string | undefined;589extensionId: VersionedExtensionId | undefined;590requestId: string | undefined;591}592593594