Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { WindowIntervalTimer } from '../../../../base/browser/dom.js';
7
import { CancellationToken } from '../../../../base/common/cancellation.js';
8
import { Emitter, Event } from '../../../../base/common/event.js';
9
import { DisposableStore } from '../../../../base/common/lifecycle.js';
10
import { themeColorFromId, ThemeIcon } from '../../../../base/common/themables.js';
11
import { ICodeEditor, IViewZone, IViewZoneChangeAccessor } from '../../../../editor/browser/editorBrowser.js';
12
import { StableEditorScrollState } from '../../../../editor/browser/stableEditorScroll.js';
13
import { LineSource, RenderOptions, renderLines } from '../../../../editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.js';
14
import { ISingleEditOperation } from '../../../../editor/common/core/editOperation.js';
15
import { LineRange } from '../../../../editor/common/core/ranges/lineRange.js';
16
import { Position } from '../../../../editor/common/core/position.js';
17
import { Range } from '../../../../editor/common/core/range.js';
18
import { IEditorDecorationsCollection } from '../../../../editor/common/editorCommon.js';
19
import { IModelDecorationsChangeAccessor, IModelDeltaDecoration, IValidEditOperation, MinimapPosition, OverviewRulerLane, TrackedRangeStickiness } from '../../../../editor/common/model.js';
20
import { ModelDecorationOptions } from '../../../../editor/common/model/textModel.js';
21
import { IEditorWorkerService } from '../../../../editor/common/services/editorWorker.js';
22
import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
23
import { Progress } from '../../../../platform/progress/common/progress.js';
24
import { SaveReason } from '../../../common/editor.js';
25
import { countWords } from '../../chat/common/chatWordCounter.js';
26
import { HunkInformation, Session, HunkState } from './inlineChatSession.js';
27
import { InlineChatZoneWidget } from './inlineChatZoneWidget.js';
28
import { 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';
29
import { assertType } from '../../../../base/common/types.js';
30
import { performAsyncTextEdit, asProgressiveEdit } from './utils.js';
31
import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js';
32
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
33
import { ITextFileService } from '../../../services/textfile/common/textfiles.js';
34
import { IUntitledTextEditorModel } from '../../../services/untitled/common/untitledTextEditorModel.js';
35
import { Schemas } from '../../../../base/common/network.js';
36
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
37
import { DefaultChatTextEditor } from '../../chat/browser/codeBlockPart.js';
38
import { isEqual } from '../../../../base/common/resources.js';
39
import { Iterable } from '../../../../base/common/iterator.js';
40
import { ConflictActionsFactory, IContentWidgetAction } from '../../mergeEditor/browser/view/conflictActions.js';
41
import { observableValue } from '../../../../base/common/observable.js';
42
import { IMenuService, MenuItemAction } from '../../../../platform/actions/common/actions.js';
43
import { InlineDecoration, InlineDecorationType } from '../../../../editor/common/viewModel/inlineDecorations.js';
44
import { EditSources } from '../../../../editor/common/textModelEditSource.js';
45
import { VersionedExtensionId } from '../../../../editor/common/languages.js';
46
47
export interface IEditObserver {
48
start(): void;
49
stop(): void;
50
}
51
52
export const enum HunkAction {
53
Accept,
54
Discard,
55
MoveNext,
56
MovePrev,
57
ToggleDiff
58
}
59
60
export class LiveStrategy {
61
62
private readonly _decoInsertedText = ModelDecorationOptions.register({
63
description: 'inline-modified-line',
64
className: 'inline-chat-inserted-range-linehighlight',
65
isWholeLine: true,
66
overviewRuler: {
67
position: OverviewRulerLane.Full,
68
color: themeColorFromId(overviewRulerInlineChatDiffInserted),
69
},
70
minimap: {
71
position: MinimapPosition.Inline,
72
color: themeColorFromId(minimapInlineChatDiffInserted),
73
}
74
});
75
76
private readonly _decoInsertedTextRange = ModelDecorationOptions.register({
77
description: 'inline-chat-inserted-range-linehighlight',
78
className: 'inline-chat-inserted-range',
79
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
80
});
81
82
protected readonly _store = new DisposableStore();
83
protected readonly _onDidAccept = this._store.add(new Emitter<void>());
84
protected readonly _onDidDiscard = this._store.add(new Emitter<void>());
85
private readonly _ctxCurrentChangeHasDiff: IContextKey<boolean>;
86
private readonly _ctxCurrentChangeShowsDiff: IContextKey<boolean>;
87
private readonly _progressiveEditingDecorations: IEditorDecorationsCollection;
88
private readonly _lensActionsFactory: ConflictActionsFactory;
89
private _editCount: number = 0;
90
private readonly _hunkData = new Map<HunkInformation, HunkDisplayData>();
91
92
readonly onDidAccept: Event<void> = this._onDidAccept.event;
93
readonly onDidDiscard: Event<void> = this._onDidDiscard.event;
94
95
constructor(
96
protected readonly _session: Session,
97
protected readonly _editor: ICodeEditor,
98
protected readonly _zone: InlineChatZoneWidget,
99
private readonly _showOverlayToolbar: boolean,
100
@IContextKeyService contextKeyService: IContextKeyService,
101
@IEditorWorkerService protected readonly _editorWorkerService: IEditorWorkerService,
102
@IAccessibilityService private readonly _accessibilityService: IAccessibilityService,
103
@IConfigurationService private readonly _configService: IConfigurationService,
104
@IMenuService private readonly _menuService: IMenuService,
105
@IContextKeyService private readonly _contextService: IContextKeyService,
106
@ITextFileService private readonly _textFileService: ITextFileService,
107
@IInstantiationService protected readonly _instaService: IInstantiationService
108
) {
109
this._ctxCurrentChangeHasDiff = CTX_INLINE_CHAT_CHANGE_HAS_DIFF.bindTo(contextKeyService);
110
this._ctxCurrentChangeShowsDiff = CTX_INLINE_CHAT_CHANGE_SHOWS_DIFF.bindTo(contextKeyService);
111
112
this._progressiveEditingDecorations = this._editor.createDecorationsCollection();
113
this._lensActionsFactory = this._store.add(new ConflictActionsFactory(this._editor));
114
}
115
116
dispose(): void {
117
this._resetDiff();
118
this._store.dispose();
119
}
120
121
private _resetDiff(): void {
122
this._ctxCurrentChangeHasDiff.reset();
123
this._ctxCurrentChangeShowsDiff.reset();
124
this._zone.widget.updateStatus('');
125
this._progressiveEditingDecorations.clear();
126
127
128
for (const data of this._hunkData.values()) {
129
data.remove();
130
}
131
}
132
133
async apply() {
134
this._resetDiff();
135
if (this._editCount > 0) {
136
this._editor.pushUndoStop();
137
}
138
await this._doApplyChanges(true);
139
}
140
141
cancel() {
142
this._resetDiff();
143
return this._session.hunkData.discardAll();
144
}
145
146
async makeChanges(edits: ISingleEditOperation[], obs: IEditObserver, undoStopBefore: boolean, metadata: IInlineChatMetadata): Promise<void> {
147
return this._makeChanges(edits, obs, undefined, undefined, undoStopBefore, metadata);
148
}
149
150
async makeProgressiveChanges(edits: ISingleEditOperation[], obs: IEditObserver, opts: ProgressingEditsOptions, undoStopBefore: boolean, metadata: IInlineChatMetadata): Promise<void> {
151
152
// add decorations once per line that got edited
153
const progress = new Progress<IValidEditOperation[]>(edits => {
154
155
const newLines = new Set<number>();
156
for (const edit of edits) {
157
LineRange.fromRange(edit.range).forEach(line => newLines.add(line));
158
}
159
const existingRanges = this._progressiveEditingDecorations.getRanges().map(LineRange.fromRange);
160
for (const existingRange of existingRanges) {
161
existingRange.forEach(line => newLines.delete(line));
162
}
163
const newDecorations: IModelDeltaDecoration[] = [];
164
for (const line of newLines) {
165
newDecorations.push({ range: new Range(line, 1, line, Number.MAX_VALUE), options: this._decoInsertedText });
166
}
167
168
this._progressiveEditingDecorations.append(newDecorations);
169
});
170
return this._makeChanges(edits, obs, opts, progress, undoStopBefore, metadata);
171
}
172
173
private async _makeChanges(edits: ISingleEditOperation[], obs: IEditObserver, opts: ProgressingEditsOptions | undefined, progress: Progress<IValidEditOperation[]> | undefined, undoStopBefore: boolean, metadata: IInlineChatMetadata): Promise<void> {
174
175
// push undo stop before first edit
176
if (undoStopBefore) {
177
this._editor.pushUndoStop();
178
}
179
180
this._editCount++;
181
const editSource = EditSources.inlineChatApplyEdit({
182
modelId: metadata.modelId,
183
extensionId: metadata.extensionId,
184
requestId: metadata.requestId,
185
languageId: this._session.textModelN.getLanguageId(),
186
});
187
188
if (opts) {
189
// ASYNC
190
const durationInSec = opts.duration / 1000;
191
for (const edit of edits) {
192
const wordCount = countWords(edit.text ?? '');
193
const speed = wordCount / durationInSec;
194
// console.log({ durationInSec, wordCount, speed: wordCount / durationInSec });
195
const asyncEdit = asProgressiveEdit(new WindowIntervalTimer(this._zone.domNode), edit, speed, opts.token);
196
await performAsyncTextEdit(this._session.textModelN, asyncEdit, progress, obs, editSource);
197
}
198
199
} else {
200
// SYNC
201
obs.start();
202
this._session.textModelN.pushEditOperations(null, edits, (undoEdits) => {
203
progress?.report(undoEdits);
204
return null;
205
}, undefined, editSource);
206
obs.stop();
207
}
208
}
209
210
performHunkAction(hunk: HunkInformation | undefined, action: HunkAction) {
211
const displayData = this._findDisplayData(hunk);
212
213
if (!displayData) {
214
// no hunks (left or not yet) found, make sure to
215
// finish the sessions
216
if (action === HunkAction.Accept) {
217
this._onDidAccept.fire();
218
} else if (action === HunkAction.Discard) {
219
this._onDidDiscard.fire();
220
}
221
return;
222
}
223
224
if (action === HunkAction.Accept) {
225
displayData.acceptHunk();
226
} else if (action === HunkAction.Discard) {
227
displayData.discardHunk();
228
} else if (action === HunkAction.MoveNext) {
229
displayData.move(true);
230
} else if (action === HunkAction.MovePrev) {
231
displayData.move(false);
232
} else if (action === HunkAction.ToggleDiff) {
233
displayData.toggleDiff?.();
234
}
235
}
236
237
private _findDisplayData(hunkInfo?: HunkInformation) {
238
let result: HunkDisplayData | undefined;
239
if (hunkInfo) {
240
// use context hunk (from tool/buttonbar)
241
result = this._hunkData.get(hunkInfo);
242
}
243
244
if (!result && this._zone.position) {
245
// find nearest from zone position
246
const zoneLine = this._zone.position.lineNumber;
247
let distance: number = Number.MAX_SAFE_INTEGER;
248
for (const candidate of this._hunkData.values()) {
249
if (candidate.hunk.getState() !== HunkState.Pending) {
250
continue;
251
}
252
const hunkRanges = candidate.hunk.getRangesN();
253
if (hunkRanges.length === 0) {
254
// bogous hunk
255
continue;
256
}
257
const myDistance = zoneLine <= hunkRanges[0].startLineNumber
258
? hunkRanges[0].startLineNumber - zoneLine
259
: zoneLine - hunkRanges[0].endLineNumber;
260
261
if (myDistance < distance) {
262
distance = myDistance;
263
result = candidate;
264
}
265
}
266
}
267
268
if (!result) {
269
// fallback: first hunk that is pending
270
result = Iterable.first(Iterable.filter(this._hunkData.values(), candidate => candidate.hunk.getState() === HunkState.Pending));
271
}
272
return result;
273
}
274
275
async renderChanges() {
276
277
this._progressiveEditingDecorations.clear();
278
279
const renderHunks = () => {
280
281
let widgetData: HunkDisplayData | undefined;
282
283
changeDecorationsAndViewZones(this._editor, (decorationsAccessor, viewZoneAccessor) => {
284
285
const keysNow = new Set(this._hunkData.keys());
286
widgetData = undefined;
287
288
for (const hunkData of this._session.hunkData.getInfo()) {
289
290
keysNow.delete(hunkData);
291
292
const hunkRanges = hunkData.getRangesN();
293
let data = this._hunkData.get(hunkData);
294
if (!data) {
295
// first time -> create decoration
296
const decorationIds: string[] = [];
297
for (let i = 0; i < hunkRanges.length; i++) {
298
decorationIds.push(decorationsAccessor.addDecoration(hunkRanges[i], i === 0
299
? this._decoInsertedText
300
: this._decoInsertedTextRange)
301
);
302
}
303
304
const acceptHunk = () => {
305
hunkData.acceptChanges();
306
renderHunks();
307
};
308
309
const discardHunk = () => {
310
hunkData.discardChanges();
311
renderHunks();
312
};
313
314
// original view zone
315
const mightContainNonBasicASCII = this._session.textModel0.mightContainNonBasicASCII();
316
const mightContainRTL = this._session.textModel0.mightContainRTL();
317
const renderOptions = RenderOptions.fromEditor(this._editor);
318
const originalRange = hunkData.getRanges0()[0];
319
const source = new LineSource(
320
LineRange.fromRangeInclusive(originalRange).mapToLineArray(l => this._session.textModel0.tokenization.getLineTokens(l)),
321
[],
322
mightContainNonBasicASCII,
323
mightContainRTL,
324
);
325
const domNode = document.createElement('div');
326
domNode.className = 'inline-chat-original-zone2';
327
const result = renderLines(source, renderOptions, [new InlineDecoration(new Range(originalRange.startLineNumber, 1, originalRange.startLineNumber, 1), '', InlineDecorationType.Regular)], domNode);
328
const viewZoneData: IViewZone = {
329
afterLineNumber: -1,
330
heightInLines: result.heightInLines,
331
domNode,
332
ordinal: 50000 + 2 // more than https://github.com/microsoft/vscode/blob/bf52a5cfb2c75a7327c9adeaefbddc06d529dcad/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts#L42
333
};
334
335
const toggleDiff = () => {
336
const scrollState = StableEditorScrollState.capture(this._editor);
337
changeDecorationsAndViewZones(this._editor, (_decorationsAccessor, viewZoneAccessor) => {
338
assertType(data);
339
if (!data.diffViewZoneId) {
340
const [hunkRange] = hunkData.getRangesN();
341
viewZoneData.afterLineNumber = hunkRange.startLineNumber - 1;
342
data.diffViewZoneId = viewZoneAccessor.addZone(viewZoneData);
343
} else {
344
viewZoneAccessor.removeZone(data.diffViewZoneId!);
345
data.diffViewZoneId = undefined;
346
}
347
});
348
this._ctxCurrentChangeShowsDiff.set(typeof data?.diffViewZoneId === 'string');
349
scrollState.restore(this._editor);
350
};
351
352
353
let lensActions: DisposableStore | undefined;
354
const lensActionsViewZoneIds: string[] = [];
355
356
if (this._showOverlayToolbar && hunkData.getState() === HunkState.Pending) {
357
358
lensActions = new DisposableStore();
359
360
const menu = this._menuService.createMenu(MENU_INLINE_CHAT_ZONE, this._contextService);
361
const makeActions = () => {
362
const actions: IContentWidgetAction[] = [];
363
const tuples = menu.getActions({ arg: hunkData });
364
for (const [, group] of tuples) {
365
for (const item of group) {
366
if (item instanceof MenuItemAction) {
367
368
let text = item.label;
369
370
if (item.id === ACTION_TOGGLE_DIFF) {
371
text = item.checked ? 'Hide Changes' : 'Show Changes';
372
} else if (ThemeIcon.isThemeIcon(item.item.icon)) {
373
text = `$(${item.item.icon.id}) ${text}`;
374
}
375
376
actions.push({
377
text,
378
tooltip: item.tooltip,
379
action: async () => item.run(),
380
});
381
}
382
}
383
}
384
return actions;
385
};
386
387
const obs = observableValue(this, makeActions());
388
lensActions.add(menu.onDidChange(() => obs.set(makeActions(), undefined)));
389
lensActions.add(menu);
390
391
lensActions.add(this._lensActionsFactory.createWidget(viewZoneAccessor,
392
hunkRanges[0].startLineNumber - 1,
393
obs,
394
lensActionsViewZoneIds
395
));
396
}
397
398
const remove = () => {
399
changeDecorationsAndViewZones(this._editor, (decorationsAccessor, viewZoneAccessor) => {
400
assertType(data);
401
for (const decorationId of data.decorationIds) {
402
decorationsAccessor.removeDecoration(decorationId);
403
}
404
if (data.diffViewZoneId) {
405
viewZoneAccessor.removeZone(data.diffViewZoneId!);
406
}
407
data.decorationIds = [];
408
data.diffViewZoneId = undefined;
409
410
data.lensActionsViewZoneIds?.forEach(viewZoneAccessor.removeZone);
411
data.lensActionsViewZoneIds = undefined;
412
});
413
414
lensActions?.dispose();
415
};
416
417
const move = (next: boolean) => {
418
const keys = Array.from(this._hunkData.keys());
419
const idx = keys.indexOf(hunkData);
420
const nextIdx = (idx + (next ? 1 : -1) + keys.length) % keys.length;
421
if (nextIdx !== idx) {
422
const nextData = this._hunkData.get(keys[nextIdx])!;
423
this._zone.updatePositionAndHeight(nextData?.position);
424
renderHunks();
425
}
426
};
427
428
const zoneLineNumber = this._zone.position?.lineNumber ?? this._editor.getPosition()!.lineNumber;
429
const myDistance = zoneLineNumber <= hunkRanges[0].startLineNumber
430
? hunkRanges[0].startLineNumber - zoneLineNumber
431
: zoneLineNumber - hunkRanges[0].endLineNumber;
432
433
data = {
434
hunk: hunkData,
435
decorationIds,
436
diffViewZoneId: '',
437
diffViewZone: viewZoneData,
438
lensActionsViewZoneIds,
439
distance: myDistance,
440
position: hunkRanges[0].getStartPosition().delta(-1),
441
acceptHunk,
442
discardHunk,
443
toggleDiff: !hunkData.isInsertion() ? toggleDiff : undefined,
444
remove,
445
move,
446
};
447
448
this._hunkData.set(hunkData, data);
449
450
} else if (hunkData.getState() !== HunkState.Pending) {
451
data.remove();
452
453
} else {
454
// update distance and position based on modifiedRange-decoration
455
const zoneLineNumber = this._zone.position?.lineNumber ?? this._editor.getPosition()!.lineNumber;
456
const modifiedRangeNow = hunkRanges[0];
457
data.position = modifiedRangeNow.getStartPosition().delta(-1);
458
data.distance = zoneLineNumber <= modifiedRangeNow.startLineNumber
459
? modifiedRangeNow.startLineNumber - zoneLineNumber
460
: zoneLineNumber - modifiedRangeNow.endLineNumber;
461
}
462
463
if (hunkData.getState() === HunkState.Pending && (!widgetData || data.distance < widgetData.distance)) {
464
widgetData = data;
465
}
466
}
467
468
for (const key of keysNow) {
469
const data = this._hunkData.get(key);
470
if (data) {
471
this._hunkData.delete(key);
472
data.remove();
473
}
474
}
475
});
476
477
if (widgetData) {
478
this._zone.reveal(widgetData.position);
479
480
const mode = this._configService.getValue<'on' | 'off' | 'auto'>(InlineChatConfigKeys.AccessibleDiffView);
481
if (mode === 'on' || mode === 'auto' && this._accessibilityService.isScreenReaderOptimized()) {
482
this._zone.widget.showAccessibleHunk(this._session, widgetData.hunk);
483
}
484
485
this._ctxCurrentChangeHasDiff.set(Boolean(widgetData.toggleDiff));
486
487
} else if (this._hunkData.size > 0) {
488
// everything accepted or rejected
489
let oneAccepted = false;
490
for (const hunkData of this._session.hunkData.getInfo()) {
491
if (hunkData.getState() === HunkState.Accepted) {
492
oneAccepted = true;
493
break;
494
}
495
}
496
if (oneAccepted) {
497
this._onDidAccept.fire();
498
} else {
499
this._onDidDiscard.fire();
500
}
501
}
502
503
return widgetData;
504
};
505
506
return renderHunks()?.position;
507
}
508
509
getWholeRangeDecoration(): IModelDeltaDecoration[] {
510
// don't render the blue in live mode
511
return [];
512
}
513
514
private async _doApplyChanges(ignoreLocal: boolean): Promise<void> {
515
516
const untitledModels: IUntitledTextEditorModel[] = [];
517
518
const editor = this._instaService.createInstance(DefaultChatTextEditor);
519
520
521
for (const request of this._session.chatModel.getRequests()) {
522
523
if (!request.response?.response) {
524
continue;
525
}
526
527
for (const item of request.response.response.value) {
528
if (item.kind !== 'textEditGroup') {
529
continue;
530
}
531
if (ignoreLocal && isEqual(item.uri, this._session.textModelN.uri)) {
532
continue;
533
}
534
535
await editor.apply(request.response, item, undefined);
536
537
if (item.uri.scheme === Schemas.untitled) {
538
const untitled = this._textFileService.untitled.get(item.uri);
539
if (untitled) {
540
untitledModels.push(untitled);
541
}
542
}
543
}
544
}
545
546
for (const untitledModel of untitledModels) {
547
if (!untitledModel.isDisposed()) {
548
await untitledModel.resolve();
549
await untitledModel.save({ reason: SaveReason.EXPLICIT });
550
}
551
}
552
}
553
}
554
555
export interface ProgressingEditsOptions {
556
duration: number;
557
token: CancellationToken;
558
}
559
560
type HunkDisplayData = {
561
562
decorationIds: string[];
563
564
diffViewZoneId: string | undefined;
565
diffViewZone: IViewZone;
566
567
lensActionsViewZoneIds?: string[];
568
569
distance: number;
570
position: Position;
571
acceptHunk: () => void;
572
discardHunk: () => void;
573
toggleDiff?: () => any;
574
remove(): void;
575
move: (next: boolean) => void;
576
577
hunk: HunkInformation;
578
};
579
580
function changeDecorationsAndViewZones(editor: ICodeEditor, callback: (accessor: IModelDecorationsChangeAccessor, viewZoneAccessor: IViewZoneChangeAccessor) => void): void {
581
editor.changeDecorations(decorationsAccessor => {
582
editor.changeViewZones(viewZoneAccessor => {
583
callback(decorationsAccessor, viewZoneAccessor);
584
});
585
});
586
}
587
588
export interface IInlineChatMetadata {
589
modelId: string | undefined;
590
extensionId: VersionedExtensionId | undefined;
591
requestId: string | undefined;
592
}
593
594