Skip to content

Commit e5a7abb

Browse files
authored
Mirror user edits into base model (microsoft#202723)
1 parent 9a1baf2 commit e5a7abb

File tree

4 files changed

+291
-45
lines changed

4 files changed

+291
-45
lines changed

src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import { IChatService } from 'vs/workbench/contrib/chat/common/chatService';
4242
import { IInlineChatSavingService } from './inlineChatSavingService';
4343
import { EmptyResponse, ErrorResponse, ExpansionState, ReplyResponse, Session, SessionExchange, SessionPrompt } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession';
4444
import { IInlineChatSessionService } from './inlineChatSessionService';
45-
import { EditModeStrategy, LivePreviewStrategy, LiveStrategy, PreviewStrategy, ProgressingEditsOptions } from 'vs/workbench/contrib/inlineChat/browser/inlineChatStrategies';
45+
import { EditModeStrategy, IEditObserver, LivePreviewStrategy, LiveStrategy, PreviewStrategy, ProgressingEditsOptions } from 'vs/workbench/contrib/inlineChat/browser/inlineChatStrategies';
4646
import { IInlineChatMessageAppender, InlineChatZoneWidget } from 'vs/workbench/contrib/inlineChat/browser/inlineChatWidget';
4747
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';
4848
import { ICommandService } from 'vs/platform/commands/common/commands';
@@ -127,7 +127,6 @@ export class InlineChatController implements IEditorContribution {
127127

128128
private _session?: Session;
129129
private _strategy?: EditModeStrategy;
130-
private _ignoreModelContentChanged = false;
131130

132131
constructor(
133132
private readonly _editor: ICodeEditor,
@@ -390,11 +389,11 @@ export class InlineChatController implements IEditorContribution {
390389

391390
this._sessionStore.add(this._editor.onDidChangeModelContent(e => {
392391

393-
if (!this._ignoreModelContentChanged && this._strategy?.hasFocus()) {
392+
if (!this._session?.hunkData.ignoreTextModelNChanges && this._strategy?.hasFocus()) {
394393
this._ctxUserDidEdit.set(altVersionNow !== this._editor.getModel()?.getAlternativeVersionId());
395394
}
396395

397-
if (this._ignoreModelContentChanged || this._strategy?.hasFocus()) {
396+
if (this._session?.hunkData.ignoreTextModelNChanges || this._strategy?.hasFocus()) {
398397
return;
399398
}
400399

@@ -479,10 +478,10 @@ export class InlineChatController implements IEditorContribution {
479478
}
480479
if (lastExchange.response instanceof ReplyResponse) {
481480
try {
482-
this._ignoreModelContentChanged = true;
481+
this._session.hunkData.ignoreTextModelNChanges = true;
483482
await this._strategy.undoChanges(lastExchange.response.modelAltVersionId);
484483
} finally {
485-
this._ignoreModelContentChanged = false;
484+
this._session.hunkData.ignoreTextModelNChanges = false;
486485
}
487486
}
488487
return State.MAKE_REQUEST;
@@ -933,19 +932,20 @@ export class InlineChatController implements IEditorContribution {
933932
const actualEdits = !opts && moreMinimalEdits ? moreMinimalEdits : edits;
934933
const editOperations = actualEdits.map(TextEdit.asEditOperation);
935934

936-
try {
937-
this._ignoreModelContentChanged = true;
938-
this._inlineChatSavingService.markChanged(this._session);
939-
this._session.wholeRange.trackEdits(editOperations);
940-
if (opts) {
941-
await this._strategy.makeProgressiveChanges(editOperations, opts);
942-
} else {
943-
await this._strategy.makeChanges(editOperations);
944-
}
945-
this._ctxDidEdit.set(this._session.hasChangedText);
946-
} finally {
947-
this._ignoreModelContentChanged = false;
935+
const editsObserver: IEditObserver = {
936+
start: () => this._session!.hunkData.ignoreTextModelNChanges = true,
937+
stop: () => this._session!.hunkData.ignoreTextModelNChanges = false,
938+
};
939+
940+
this._inlineChatSavingService.markChanged(this._session);
941+
this._session.wholeRange.trackEdits(editOperations);
942+
if (opts) {
943+
await this._strategy.makeProgressiveChanges(editOperations, editsObserver, opts);
944+
} else {
945+
await this._strategy.makeChanges(editOperations, editsObserver);
948946
}
947+
this._ctxDidEdit.set(this._session.hasChangedText);
948+
949949
}
950950

951951
private _forcedPlaceholder: string | undefined = undefined;

src/vs/workbench/contrib/inlineChat/browser/inlineChatSession.ts

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { URI } from 'vs/base/common/uri';
77
import { Emitter, Event } from 'vs/base/common/event';
88
import { ResourceEdit, ResourceFileEdit, ResourceTextEdit } from 'vs/editor/browser/services/bulkEditService';
99
import { IWorkspaceTextEdit, TextEdit, WorkspaceEdit } from 'vs/editor/common/languages';
10-
import { IModelDecorationOptions, IModelDeltaDecoration, ITextModel } from 'vs/editor/common/model';
10+
import { IIdentifiedSingleEditOperation, IModelDecorationOptions, IModelDeltaDecoration, ITextModel, TrackedRangeStickiness } from 'vs/editor/common/model';
1111
import { EditMode, IInlineChatSessionProvider, IInlineChatSession, IInlineChatBulkEditResponse, IInlineChatEditResponse, InlineChatResponseType, InlineChatResponseTypes } from 'vs/workbench/contrib/inlineChat/common/inlineChat';
1212
import { IRange, Range } from 'vs/editor/common/core/range';
1313
import { ModelDecorationOptions } from 'vs/editor/common/model/textModel';
@@ -28,6 +28,8 @@ import { IEditorWorkerService } from 'vs/editor/common/services/editorWorker';
2828
import { asRange } from 'vs/workbench/contrib/inlineChat/browser/utils';
2929
import { coalesceInPlace } from 'vs/base/common/arrays';
3030
import { Iterable } from 'vs/base/common/iterator';
31+
import { IModelContentChangedEvent } from 'vs/editor/common/textModelEvents';
32+
import { DisposableStore } from 'vs/base/common/lifecycle';
3133

3234

3335
export type TelemetryData = {
@@ -409,17 +411,27 @@ export class HunkData {
409411

410412
private static readonly _HUNK_TRACKED_RANGE = ModelDecorationOptions.register({
411413
description: 'inline-chat-hunk-tracked-range',
414+
stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges
412415
});
413416

414417
private static readonly _HUNK_THRESHOLD = 8;
415418

419+
private readonly _store = new DisposableStore();
416420
private readonly _data = new Map<RawHunk, { textModelNDecorations: string[]; textModel0Decorations: string[]; state: HunkState }>();
421+
private _ignoreChanges: boolean = false;
417422

418423
constructor(
419424
@IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService,
420425
private readonly _textModel0: ITextModel,
421426
private readonly _textModelN: ITextModel,
422-
) { }
427+
) {
428+
429+
this._store.add(_textModelN.onDidChangeContent(e => {
430+
if (!this._ignoreChanges) {
431+
this._mirrorChanges(e);
432+
}
433+
}));
434+
}
423435

424436
dispose(): void {
425437
if (!this._textModelN.isDisposed()) {
@@ -436,6 +448,104 @@ export class HunkData {
436448
}
437449
});
438450
}
451+
this._data.clear();
452+
this._store.dispose();
453+
}
454+
455+
set ignoreTextModelNChanges(value: boolean) {
456+
this._ignoreChanges = value;
457+
}
458+
459+
get ignoreTextModelNChanges(): boolean {
460+
return this._ignoreChanges;
461+
}
462+
463+
private _mirrorChanges(event: IModelContentChangedEvent) {
464+
465+
// mirror textModelN changes to textModel0 execept for those that
466+
// overlap with a hunk
467+
468+
type HunkRangePair = { rangeN: Range; range0: Range };
469+
const hunkRanges: HunkRangePair[] = [];
470+
471+
const ranges0: Range[] = [];
472+
473+
for (const { textModelNDecorations, textModel0Decorations, state } of this._data.values()) {
474+
475+
if (state === HunkState.Pending) {
476+
// pending means the hunk's changes aren't "sync'd" yet
477+
for (let i = 1; i < textModelNDecorations.length; i++) {
478+
const rangeN = this._textModelN.getDecorationRange(textModelNDecorations[i]);
479+
const range0 = this._textModel0.getDecorationRange(textModel0Decorations[i]);
480+
if (rangeN && range0) {
481+
hunkRanges.push({ rangeN, range0 });
482+
}
483+
}
484+
485+
} else if (state === HunkState.Accepted) {
486+
// accepted means the hunk's changes are also in textModel0
487+
for (let i = 1; i < textModel0Decorations.length; i++) {
488+
const range = this._textModel0.getDecorationRange(textModel0Decorations[i]);
489+
if (range) {
490+
ranges0.push(range);
491+
}
492+
}
493+
}
494+
}
495+
496+
hunkRanges.sort((a, b) => Range.compareRangesUsingStarts(a.rangeN, b.rangeN));
497+
ranges0.sort(Range.compareRangesUsingStarts);
498+
499+
const edits: IIdentifiedSingleEditOperation[] = [];
500+
501+
for (const change of event.changes) {
502+
503+
let isOverlapping = false;
504+
505+
let pendingChangesLen = 0;
506+
507+
for (const { rangeN, range0 } of hunkRanges) {
508+
if (rangeN.getEndPosition().isBefore(Range.getStartPosition(change.range))) {
509+
// pending hunk _before_ this change. When projecting into textModel0 we need to
510+
// subtract that. Because diffing is relaxed it might include changes that are not
511+
// actual insertions/deletions. Therefore we need to take the length of the original
512+
// range into account.
513+
pendingChangesLen += this._textModelN.getValueLengthInRange(rangeN);
514+
pendingChangesLen -= this._textModel0.getValueLengthInRange(range0);
515+
516+
} else if (Range.areIntersectingOrTouching(rangeN, change.range)) {
517+
isOverlapping = true;
518+
break;
519+
520+
} else {
521+
// hunks past this change aren't relevant
522+
break;
523+
}
524+
}
525+
526+
if (isOverlapping) {
527+
// hunk overlaps, it grew
528+
continue;
529+
}
530+
531+
const offset0 = change.rangeOffset - pendingChangesLen;
532+
const start0 = this._textModel0.getPositionAt(offset0);
533+
534+
let acceptedChangesLen = 0;
535+
for (const range of ranges0) {
536+
if (range.getEndPosition().isBefore(start0)) {
537+
// accepted hunk _before_ this projected change. When projecting into textModel0
538+
// we need to add that
539+
acceptedChangesLen += this._textModel0.getValueLengthInRange(range);
540+
}
541+
}
542+
543+
const start = this._textModel0.getPositionAt(offset0 + acceptedChangesLen);
544+
const end = this._textModel0.getPositionAt(offset0 + acceptedChangesLen + change.rangeLength);
545+
edits.push(EditOperation.replace(Range.fromPositions(start, end), change.text));
546+
}
547+
548+
this._textModel0.pushEditOperations(null, edits, () => null);
439549
}
440550

441551
async recompute() {

src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ import { CTX_INLINE_CHAT_CHANGE_HAS_DIFF, CTX_INLINE_CHAT_CHANGE_SHOWS_DIFF, CTX
3939
import { HunkState } from './inlineChatSession';
4040
import { assertType } from 'vs/base/common/types';
4141

42+
export interface IEditObserver {
43+
start(): void;
44+
stop(): void;
45+
}
46+
4247
export abstract class EditModeStrategy {
4348

4449
protected static _decoBlock = ModelDecorationOptions.register({
@@ -80,9 +85,9 @@ export abstract class EditModeStrategy {
8085
this._onDidDiscard.fire();
8186
}
8287

83-
abstract makeProgressiveChanges(edits: ISingleEditOperation[], timings: ProgressingEditsOptions): Promise<void>;
88+
abstract makeProgressiveChanges(edits: ISingleEditOperation[], obs: IEditObserver, timings: ProgressingEditsOptions): Promise<void>;
8489

85-
abstract makeChanges(edits: ISingleEditOperation[]): Promise<void>;
90+
abstract makeChanges(edits: ISingleEditOperation[], obs: IEditObserver): Promise<void>;
8691

8792
abstract undoChanges(altVersionId: number): Promise<void>;
8893

@@ -239,7 +244,7 @@ export class LivePreviewStrategy extends EditModeStrategy {
239244
await undoModelUntil(modelN, targetAltVersion);
240245
}
241246

242-
override async makeChanges(edits: ISingleEditOperation[]): Promise<void> {
247+
override async makeChanges(edits: ISingleEditOperation[], obs: IEditObserver): Promise<void> {
243248
const cursorStateComputerAndInlineDiffCollection: ICursorStateComputer = (undoEdits) => {
244249
let last: Position | null = null;
245250
for (const edit of undoEdits) {
@@ -252,7 +257,9 @@ export class LivePreviewStrategy extends EditModeStrategy {
252257
if (++this._editCount === 1) {
253258
this._editor.pushUndoStop();
254259
}
260+
obs.start();
255261
this._editor.executeEdits('inline-chat-live', edits, cursorStateComputerAndInlineDiffCollection);
262+
obs.stop();
256263
}
257264

258265
override async undoChanges(altVersionId: number): Promise<void> {
@@ -261,7 +268,7 @@ export class LivePreviewStrategy extends EditModeStrategy {
261268
await this._updateDiffZones();
262269
}
263270

264-
override async makeProgressiveChanges(edits: ISingleEditOperation[], opts: ProgressingEditsOptions): Promise<void> {
271+
override async makeProgressiveChanges(edits: ISingleEditOperation[], obs: IEditObserver, opts: ProgressingEditsOptions): Promise<void> {
265272

266273
// push undo stop before first edit
267274
if (++this._editCount === 1) {
@@ -280,7 +287,7 @@ export class LivePreviewStrategy extends EditModeStrategy {
280287
const wordCount = countWords(edit.text ?? '');
281288
const speed = wordCount / durationInSec;
282289
// console.log({ durationInSec, wordCount, speed: wordCount / durationInSec });
283-
await performAsyncTextEdit(this._session.textModelN, asProgressiveEdit(new WindowIntervalTimer(this._zone.domNode), edit, speed, opts.token));
290+
await performAsyncTextEdit(this._session.textModelN, asProgressiveEdit(new WindowIntervalTimer(this._zone.domNode), edit, speed, opts.token), undefined, obs);
284291
}
285292

286293
await renderTask;
@@ -398,7 +405,7 @@ export interface AsyncTextEdit {
398405
readonly newText: AsyncIterable<string>;
399406
}
400407

401-
export async function performAsyncTextEdit(model: ITextModel, edit: AsyncTextEdit, progress?: IProgress<IValidEditOperation[]>) {
408+
export async function performAsyncTextEdit(model: ITextModel, edit: AsyncTextEdit, progress?: IProgress<IValidEditOperation[]>, obs?: IEditObserver) {
402409

403410
const [id] = model.deltaDecorations([], [{
404411
range: edit.range,
@@ -423,11 +430,12 @@ export async function performAsyncTextEdit(model: ITextModel, edit: AsyncTextEdi
423430
const edit = first
424431
? EditOperation.replace(range, part) // first edit needs to override the "anchor"
425432
: EditOperation.insert(range.getEndPosition(), part);
426-
433+
obs?.start();
427434
model.pushEditOperations(null, [edit], (undoEdits) => {
428435
progress?.report(undoEdits);
429436
return null;
430437
});
438+
obs?.stop();
431439
first = false;
432440
}
433441
}
@@ -578,15 +586,15 @@ export class LiveStrategy extends EditModeStrategy {
578586
await undoModelUntil(textModelN, altVersionId);
579587
}
580588

581-
override async makeChanges(edits: ISingleEditOperation[]): Promise<void> {
582-
return this._makeChanges(edits, undefined);
589+
override async makeChanges(edits: ISingleEditOperation[], obs: IEditObserver): Promise<void> {
590+
return this._makeChanges(edits, obs, undefined);
583591
}
584592

585-
override async makeProgressiveChanges(edits: ISingleEditOperation[], opts: ProgressingEditsOptions): Promise<void> {
586-
return this._makeChanges(edits, opts);
593+
override async makeProgressiveChanges(edits: ISingleEditOperation[], obs: IEditObserver, opts: ProgressingEditsOptions): Promise<void> {
594+
return this._makeChanges(edits, obs, opts);
587595
}
588596

589-
private async _makeChanges(edits: ISingleEditOperation[], opts: ProgressingEditsOptions | undefined): Promise<void> {
597+
private async _makeChanges(edits: ISingleEditOperation[], obs: IEditObserver, opts: ProgressingEditsOptions | undefined): Promise<void> {
590598

591599
// push undo stop before first edit
592600
if (++this._editCount === 1) {
@@ -619,15 +627,17 @@ export class LiveStrategy extends EditModeStrategy {
619627
const wordCount = countWords(edit.text ?? '');
620628
const speed = wordCount / durationInSec;
621629
// console.log({ durationInSec, wordCount, speed: wordCount / durationInSec });
622-
await performAsyncTextEdit(this._session.textModelN, asProgressiveEdit(new WindowIntervalTimer(this._zone.domNode), edit, speed, opts.token), progress);
630+
await performAsyncTextEdit(this._session.textModelN, asProgressiveEdit(new WindowIntervalTimer(this._zone.domNode), edit, speed, opts.token), progress, obs);
623631
}
624632

625633
} else {
626634
// SYNC
635+
obs.start();
627636
this._editor.executeEdits('inline-chat-live', edits, undoEdits => {
628637
progress.report(undoEdits);
629638
return null;
630639
});
640+
obs.stop();
631641
}
632642
}
633643

0 commit comments

Comments
 (0)