Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/test/simulation/fixtures/edit-single-line-await-issue-3702/interactiveEditorWidget.ts
13399 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 'vs/css!./interactiveEditor';
7
import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation';
8
import { DisposableStore, combinedDisposable, toDisposable } from 'vs/base/common/lifecycle';
9
import { IActiveCodeEditor, ICodeEditor } from 'vs/editor/browser/editorBrowser';
10
import { EditorLayoutInfo, EditorOption } from 'vs/editor/common/config/editorOptions';
11
import { Range } from 'vs/editor/common/core/range';
12
import { IEditorContribution, IEditorDecorationsCollection, ScrollType } from 'vs/editor/common/editorCommon';
13
import { localize } from 'vs/nls';
14
import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
15
import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
16
import { ZoneWidget } from 'vs/editor/contrib/zoneWidget/browser/zoneWidget';
17
import { assertType } from 'vs/base/common/types';
18
import { IInteractiveEditorResponse, IInteractiveEditorService, CTX_INTERACTIVE_EDITOR_FOCUSED, CTX_INTERACTIVE_EDITOR_HAS_ACTIVE_REQUEST, CTX_INTERACTIVE_EDITOR_INNER_CURSOR_FIRST, CTX_INTERACTIVE_EDITOR_INNER_CURSOR_LAST, CTX_INTERACTIVE_EDITOR_EMPTY, CTX_INTERACTIVE_EDITOR_OUTER_CURSOR_POSITION, CTX_INTERACTIVE_EDITOR_VISIBLE, MENU_INTERACTIVE_EDITOR_WIDGET, IInteractiveEditorRequest, IInteractiveEditorSession, IInteractiveEditorSlashCommand } from 'vs/workbench/contrib/interactiveEditor/common/interactiveEditor';
19
import { EditOperation } from 'vs/editor/common/core/editOperation';
20
import { Iterable } from 'vs/base/common/iterator';
21
import { ICursorStateComputer, IModelDecorationOptions, IModelDeltaDecoration, ITextModel, IValidEditOperation } from 'vs/editor/common/model';
22
import { ModelDecorationOptions } from 'vs/editor/common/model/textModel';
23
import { Dimension, addDisposableListener, getTotalHeight, getTotalWidth, h, reset } from 'vs/base/browser/dom';
24
import { Emitter, Event } from 'vs/base/common/event';
25
import { IEditorConstructionOptions } from 'vs/editor/browser/config/editorConfiguration';
26
import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditorWidget';
27
import { EditorExtensionsRegistry } from 'vs/editor/browser/editorExtensions';
28
import { SnippetController2 } from 'vs/editor/contrib/snippet/browser/snippetController2';
29
import { IModelService } from 'vs/editor/common/services/model';
30
import { URI } from 'vs/base/common/uri';
31
import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget';
32
import { GhostTextController } from 'vs/editor/contrib/inlineCompletions/browser/ghostTextController';
33
import { MenuWorkbenchToolBar, WorkbenchToolBar } from 'vs/platform/actions/browser/toolbar';
34
import { ProgressBar } from 'vs/base/browser/ui/progressbar/progressbar';
35
import { SuggestController } from 'vs/editor/contrib/suggest/browser/suggestController';
36
import { IPosition, Position } from 'vs/editor/common/core/position';
37
import { Selection } from 'vs/editor/common/core/selection';
38
import { raceCancellationError } from 'vs/base/common/async';
39
import { isCancellationError } from 'vs/base/common/errors';
40
import { IEditorWorkerService } from 'vs/editor/common/services/editorWorker';
41
import { ILogService } from 'vs/platform/log/common/log';
42
import { StopWatch } from 'vs/base/common/stopwatch';
43
import { Action, IAction, Separator } from 'vs/base/common/actions';
44
import { Codicon } from 'vs/base/common/codicons';
45
import { ThemeIcon } from 'vs/base/common/themables';
46
import { LRUCache } from 'vs/base/common/map';
47
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
48
import { IBulkEditService } from 'vs/editor/browser/services/bulkEditService';
49
import { toErrorMessage } from 'vs/base/common/errorMessage';
50
import { IInteractiveSessionWidgetService } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget';
51
import { IViewsService } from 'vs/workbench/common/views';
52
import { IInteractiveSessionContributionService } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionContributionService';
53
import { InteractiveSessionViewPane } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionSidebar';
54
import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures';
55
import { Command, CompletionContext, CompletionItem, CompletionItemInsertTextRule, CompletionItemKind, CompletionItemProvider, CompletionList, ProviderResult } from 'vs/editor/common/languages';
56
import { LanguageSelector } from 'vs/editor/common/languageSelector';
57
import { DEFAULT_FONT_FAMILY } from 'vs/workbench/browser/style';
58
import { ICommandService } from 'vs/platform/commands/common/commands';
59
60
class InteractiveEditorWidget {
61
62
private static _modelPool: number = 1;
63
64
private static _noop = () => { };
65
66
private readonly _elements = h(
67
'div.interactive-editor@root',
68
[
69
h('div.body', [
70
h('div.content@content', [
71
h('div.input@input', [
72
h('div.editor-placeholder@placeholder'),
73
h('div.editor-container@editor'),
74
]),
75
h('div.toolbar@rhsToolbar'),
76
]),
77
]),
78
h('div.progress@progress'),
79
h('div.status.hidden@status'),
80
]
81
);
82
83
private readonly _store = new DisposableStore();
84
private readonly _historyStore = new DisposableStore();
85
86
readonly inputEditor: ICodeEditor;
87
private readonly _inputModel: ITextModel;
88
private readonly _ctxInputEmpty: IContextKey<boolean>;
89
90
private readonly _progressBar: ProgressBar;
91
92
private readonly _onDidChangeHeight = new Emitter<void>();
93
readonly onDidChangeHeight: Event<void> = Event.filter(this._onDidChangeHeight.event, _ => !this._isLayouting);
94
95
private _editorDim: Dimension | undefined;
96
private _isLayouting: boolean = false;
97
98
public acceptInput: (preview: boolean) => void = InteractiveEditorWidget._noop;
99
private _cancelInput: () => void = InteractiveEditorWidget._noop;
100
101
constructor(
102
parentEditor: ICodeEditor | undefined,
103
@IModelService private readonly _modelService: IModelService,
104
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
105
@IInstantiationService private readonly _instantiationService: IInstantiationService,
106
) {
107
108
// editor logic
109
const editorOptions: IEditorConstructionOptions = {
110
ariaLabel: localize('aria-label', "Interactive Editor Input"),
111
fontFamily: DEFAULT_FONT_FAMILY,
112
fontSize: 13,
113
lineHeight: 20,
114
padding: { top: 3, bottom: 2 },
115
wordWrap: 'on',
116
overviewRulerLanes: 0,
117
glyphMargin: false,
118
lineNumbers: 'off',
119
folding: false,
120
selectOnLineNumbers: false,
121
hideCursorInOverviewRuler: true,
122
selectionHighlight: false,
123
scrollbar: {
124
useShadows: false,
125
vertical: 'hidden',
126
horizontal: 'auto',
127
// alwaysConsumeMouseWheel: false
128
},
129
lineDecorationsWidth: 0,
130
overviewRulerBorder: false,
131
scrollBeyondLastLine: false,
132
renderLineHighlight: 'none',
133
fixedOverflowWidgets: true,
134
dragAndDrop: false,
135
revealHorizontalRightPadding: 5,
136
minimap: { enabled: false },
137
guides: { indentation: false },
138
cursorWidth: 1,
139
wrappingStrategy: 'advanced',
140
wrappingIndent: 'none',
141
renderWhitespace: 'none',
142
dropIntoEditor: { enabled: true },
143
144
quickSuggestions: false,
145
suggest: {
146
showIcons: false,
147
showSnippets: false,
148
}
149
};
150
151
const codeEditorWidgetOptions: ICodeEditorWidgetOptions = {
152
isSimpleWidget: true,
153
contributions: EditorExtensionsRegistry.getSomeEditorContributions([
154
SnippetController2.ID,
155
GhostTextController.ID,
156
SuggestController.ID
157
])
158
};
159
160
this.inputEditor = parentEditor
161
? this._instantiationService.createInstance(EmbeddedCodeEditorWidget, this._elements.editor, editorOptions, codeEditorWidgetOptions, parentEditor)
162
: this._instantiationService.createInstance(CodeEditorWidget, this._elements.editor, editorOptions, codeEditorWidgetOptions);
163
this._store.add(this.inputEditor);
164
165
const uri = URI.from({ scheme: 'vscode', authority: 'interactive-editor', path: `/interactive-editor/model${InteractiveEditorWidget._modelPool++}.txt` });
166
this._inputModel = this._modelService.getModel(uri) ?? this._modelService.createModel('', null, uri);
167
this.inputEditor.setModel(this._inputModel);
168
169
// show/hide placeholder depending on text model being empty
170
// content height
171
172
const currentContentHeight = 0;
173
174
this._ctxInputEmpty = CTX_INTERACTIVE_EDITOR_EMPTY.bindTo(this._contextKeyService);
175
const togglePlaceholder = () => {
176
const hasText = this._inputModel.getValueLength() > 0;
177
this._elements.placeholder.classList.toggle('hidden', hasText);
178
this._ctxInputEmpty.set(!hasText);
179
180
const contentHeight = this.inputEditor.getContentHeight();
181
if (contentHeight !== currentContentHeight && this._editorDim) {
182
this._editorDim = this._editorDim.with(undefined, contentHeight);
183
this.inputEditor.layout(this._editorDim);
184
this._onDidChangeHeight.fire();
185
}
186
};
187
this._store.add(this._inputModel.onDidChangeContent(togglePlaceholder));
188
togglePlaceholder();
189
190
this._store.add(addDisposableListener(this._elements.placeholder, 'click', () => this.inputEditor.focus()));
191
192
193
const toolbar = this._instantiationService.createInstance(MenuWorkbenchToolBar, this._elements.rhsToolbar, MENU_INTERACTIVE_EDITOR_WIDGET, {
194
telemetrySource: 'interactiveEditorWidget-toolbar',
195
toolbarOptions: { primaryGroup: 'main' }
196
});
197
this._store.add(toolbar);
198
199
this._progressBar = new ProgressBar(this._elements.progress);
200
this._store.add(this._progressBar);
201
}
202
203
dispose(): void {
204
this._store.dispose();
205
this._historyStore.dispose();
206
this._ctxInputEmpty.reset();
207
}
208
209
get domNode(): HTMLElement {
210
return this._elements.root;
211
}
212
213
layout(dim: Dimension) {
214
this._isLayouting = true;
215
try {
216
const innerEditorWidth = Math.min(
217
Number.MAX_SAFE_INTEGER, // TODO@jrieken define max width?
218
dim.width - (getTotalWidth(this._elements.rhsToolbar) + 12 /* L/R-padding */)
219
);
220
const newDim = new Dimension(innerEditorWidth, this.inputEditor.getContentHeight());
221
if (!this._editorDim || !Dimension.equals(this._editorDim, newDim)) {
222
this._editorDim = newDim;
223
this.inputEditor.layout(this._editorDim);
224
225
this._elements.placeholder.style.width = `${innerEditorWidth - 4 /* input-padding*/}px`;
226
}
227
} finally {
228
this._isLayouting = false;
229
}
230
}
231
232
getHeight(): number {
233
const base = getTotalHeight(this._elements.progress) + getTotalHeight(this._elements.status);
234
const editorHeight = this.inputEditor.getContentHeight() + 6 /* padding and border */;
235
return base + editorHeight + 12 /* padding */;
236
}
237
238
updateProgress(show: boolean) {
239
if (show) {
240
this._progressBar.infinite();
241
} else {
242
this._progressBar.stop();
243
}
244
}
245
246
getInput(placeholder: string, value: string, token: CancellationToken): Promise<{ value: string; preview: boolean } | undefined> {
247
248
this._elements.placeholder.innerText = placeholder;
249
this._elements.placeholder.style.fontSize = `${this.inputEditor.getOption(EditorOption.fontSize)}px`;
250
this._elements.placeholder.style.lineHeight = `${this.inputEditor.getOption(EditorOption.lineHeight)}px`;
251
252
this._inputModel.setValue(value);
253
this.inputEditor.setSelection(this._inputModel.getFullModelRange());
254
255
const disposeOnDone = new DisposableStore();
256
257
disposeOnDone.add(this.inputEditor.onDidLayoutChange(() => this._onDidChangeHeight.fire()));
258
259
const ctxInnerCursorFirst = CTX_INTERACTIVE_EDITOR_INNER_CURSOR_FIRST.bindTo(this._contextKeyService);
260
const ctxInnerCursorLast = CTX_INTERACTIVE_EDITOR_INNER_CURSOR_LAST.bindTo(this._contextKeyService);
261
const ctxInputEditorFocused = CTX_INTERACTIVE_EDITOR_FOCUSED.bindTo(this._contextKeyService);
262
263
return new Promise<{ value: string; preview: boolean } | undefined>(resolve => {
264
265
this._cancelInput = () => {
266
this.acceptInput = InteractiveEditorWidget._noop;
267
this._cancelInput = InteractiveEditorWidget._noop;
268
resolve(undefined);
269
return true;
270
};
271
272
this.acceptInput = (preview) => {
273
const newValue = this.inputEditor.getModel()!.getValue();
274
if (newValue.trim().length === 0) {
275
// empty or whitespace only
276
this._cancelInput();
277
return;
278
}
279
280
this.acceptInput = InteractiveEditorWidget._noop;
281
this._cancelInput = InteractiveEditorWidget._noop;
282
resolve({ value: newValue, preview });
283
};
284
285
disposeOnDone.add(token.onCancellationRequested(() => this._cancelInput()));
286
287
// CONTEXT KEYS
288
289
// (1) inner cursor position (last/first line selected)
290
const updateInnerCursorFirstLast = () => {
291
if (!this.inputEditor.hasModel()) {
292
return;
293
}
294
const { lineNumber } = this.inputEditor.getPosition();
295
ctxInnerCursorFirst.set(lineNumber === 1);
296
ctxInnerCursorLast.set(lineNumber === this.inputEditor.getModel().getLineCount());
297
};
298
disposeOnDone.add(this.inputEditor.onDidChangeCursorPosition(updateInnerCursorFirstLast));
299
updateInnerCursorFirstLast();
300
301
// (2) input editor focused or not
302
const updateFocused = () => {
303
const hasFocus = this.inputEditor.hasWidgetFocus();
304
ctxInputEditorFocused.set(hasFocus);
305
this._elements.content.classList.toggle('synthetic-focus', hasFocus);
306
};
307
disposeOnDone.add(this.inputEditor.onDidFocusEditorWidget(updateFocused));
308
disposeOnDone.add(this.inputEditor.onDidBlurEditorWidget(updateFocused));
309
updateFocused();
310
311
this.focus();
312
313
}).finally(() => {
314
disposeOnDone.dispose();
315
316
ctxInnerCursorFirst.reset();
317
ctxInnerCursorLast.reset();
318
ctxInputEditorFocused.reset();
319
});
320
}
321
322
populateInputField(value: string) {
323
this._inputModel.setValue(value.trim());
324
this.inputEditor.setSelection(this._inputModel.getFullModelRange());
325
}
326
327
createStatusEntry() {
328
const { root, label, actions } = h('div.status-item@item', [
329
h('div.label@label'),
330
h('div.actions@actions'),
331
]);
332
333
const toolbar = this._instantiationService.createInstance(WorkbenchToolBar, actions, {});
334
this._historyStore.add(toolbar);
335
336
reset(this._elements.status, root);
337
this._onDidChangeHeight.fire();
338
339
let oldClasses: string[] = [];
340
341
return {
342
update: (update: { message?: string; actions?: IAction[]; classes?: string[] }) => {
343
if (update.message) {
344
label.innerText = update.message;
345
this._elements.status.classList.remove('hidden');
346
}
347
if (update.actions) {
348
toolbar.setActions(update.actions);
349
}
350
if (update.classes) {
351
oldClasses.forEach(value => root.classList.remove(value));
352
oldClasses = update.classes.slice();
353
root.classList.add(...update.classes);
354
}
355
},
356
updateMessage(message: string) {
357
label.innerText = message;
358
},
359
updateActions(actions: IAction[]) {
360
toolbar.setActions(actions);
361
},
362
updateClasses: (classes: string[]) => {
363
root.classList.add(...classes);
364
},
365
remove: () => {
366
root.remove();
367
toolbar.dispose();
368
this._elements.status.classList.add('hidden');
369
this._onDidChangeHeight.fire();
370
}
371
};
372
}
373
374
reset() {
375
this._ctxInputEmpty.reset();
376
reset(this._elements.status);
377
}
378
379
focus() {
380
this.inputEditor.focus();
381
}
382
}
383
384
export class InteractiveEditorZoneWidget extends ZoneWidget {
385
386
readonly widget: InteractiveEditorWidget;
387
388
private readonly _ctxVisible: IContextKey<boolean>;
389
private readonly _ctxCursorPosition: IContextKey<'above' | 'below' | ''>;
390
391
constructor(
392
editor: ICodeEditor,
393
@IInstantiationService private readonly _instaService: IInstantiationService,
394
@IContextKeyService contextKeyService: IContextKeyService,
395
) {
396
super(editor, { showFrame: false, showArrow: false, isAccessible: true, className: 'interactive-editor-widget', keepEditorSelection: true });
397
398
this._ctxVisible = CTX_INTERACTIVE_EDITOR_VISIBLE.bindTo(contextKeyService);
399
this._ctxCursorPosition = CTX_INTERACTIVE_EDITOR_OUTER_CURSOR_POSITION.bindTo(contextKeyService);
400
401
this._disposables.add(toDisposable(() => {
402
this._ctxVisible.reset();
403
this._ctxCursorPosition.reset();
404
}));
405
406
this.widget = this._instaService.createInstance(InteractiveEditorWidget, this.editor);
407
this._disposables.add(this.widget.onDidChangeHeight(() => this._relayout()));
408
this._disposables.add(this.widget);
409
this.create();
410
411
412
// todo@jrieken listen ONLY when showing
413
const updateCursorIsAboveContextKey = () => {
414
if (!this.position || !this.editor.hasModel()) {
415
this._ctxCursorPosition.reset();
416
} else if (this.position.lineNumber === this.editor.getPosition().lineNumber) {
417
this._ctxCursorPosition.set('above');
418
} else if (this.position.lineNumber + 1 === this.editor.getPosition().lineNumber) {
419
this._ctxCursorPosition.set('below');
420
} else {
421
this._ctxCursorPosition.reset();
422
}
423
};
424
this._disposables.add(this.editor.onDidChangeCursorPosition(e => updateCursorIsAboveContextKey()));
425
this._disposables.add(this.editor.onDidFocusEditorText(e => updateCursorIsAboveContextKey()));
426
updateCursorIsAboveContextKey();
427
}
428
429
protected override _fillContainer(container: HTMLElement): void {
430
container.appendChild(this.widget.domNode);
431
}
432
433
protected override _getWidth(info: EditorLayoutInfo): number {
434
// TODO@jrieken
435
// makes the zone widget wider than wanted but this aligns
436
// it with wholeLine decorations that are added above
437
return info.width;
438
}
439
440
private _dimension?: Dimension;
441
442
protected override _onWidth(widthInPixel: number): void {
443
if (this._dimension) {
444
this._doLayout(this._dimension.height, widthInPixel);
445
}
446
}
447
448
protected override _doLayout(heightInPixel: number, widthInPixel: number): void {
449
450
const info = this.editor.getLayoutInfo();
451
const spaceLeft = info.lineNumbersWidth + info.glyphMarginWidth + info.decorationsWidth;
452
const spaceRight = info.minimap.minimapWidth + info.verticalScrollbarWidth;
453
const inputLeftPadding = 4;
454
const inputRightPadding = 4;
455
456
const width = widthInPixel - (spaceLeft + spaceRight + inputLeftPadding + inputRightPadding);
457
this._dimension = new Dimension(width, heightInPixel);
458
this.widget.domNode.style.marginLeft = `${spaceLeft + inputLeftPadding}px`;
459
this.widget.domNode.style.marginRight = `${spaceRight + inputRightPadding}px`;
460
this.widget.layout(this._dimension);
461
}
462
463
private _computeHeightInLines(): number {
464
const lineHeight = this.editor.getOption(EditorOption.lineHeight);
465
return this.widget.getHeight() / lineHeight;
466
}
467
468
protected override _relayout() {
469
super._relayout(this._computeHeightInLines());
470
}
471
472
async getInput(where: IPosition, placeholder: string, value: string, token: CancellationToken): Promise<{ value: string; preview: boolean } | undefined> {
473
assertType(this.editor.hasModel());
474
super.show(where, this._computeHeightInLines());
475
this._ctxVisible.set(true);
476
477
const task = this.widget.getInput(placeholder, value, token);
478
const result = await task;
479
return result;
480
}
481
482
updatePosition(where: IPosition) {
483
// todo@jrieken
484
// UGYLY: we need to restore focus because showing the zone removes and adds it and that
485
// means we loose focus for a bit
486
const hasFocusNow = this.widget.inputEditor.hasWidgetFocus();
487
super.show(where, this._computeHeightInLines());
488
if (hasFocusNow) {
489
this.widget.inputEditor.focus();
490
}
491
}
492
493
protected override revealRange(_range: Range, _isLastLine: boolean) {
494
// disabled
495
}
496
497
override hide(): void {
498
this._ctxVisible.reset();
499
this._ctxCursorPosition.reset();
500
this.widget.reset();
501
super.hide();
502
}
503
}
504
505
class CommandAction extends Action {
506
507
constructor(command: Command, @ICommandService commandService: ICommandService) {
508
const icon = ThemeIcon.fromString(command.title);
509
super(command.id, icon ? command.tooltip : command.title, icon ? ThemeIcon.asClassName(icon) : undefined, true, () => commandService.executeCommand(command.id, ...(command.arguments ?? [])));
510
}
511
}
512
513
class ToggleInlineDiff extends Action {
514
515
constructor(private readonly _inlineDiff: InlineDiffDecorations) {
516
super('diff', localize('toggleInlineDiff', "Toggle Inline Diff"), ThemeIcon.asClassName(Codicon.diff), true);
517
this.checked = _inlineDiff.visible;
518
}
519
520
override async run(): Promise<void> {
521
this._inlineDiff.visible = !this._inlineDiff.visible;
522
this.checked = this._inlineDiff.visible;
523
}
524
}
525
526
class UndoAction extends Action {
527
528
private readonly _myAlternativeVersionId: number;
529
530
constructor(private readonly _model: ITextModel) {
531
super('undo', localize('undo', "Undo"), ThemeIcon.asClassName(Codicon.discard), false);
532
this._myAlternativeVersionId = _model.getAlternativeVersionId();
533
534
const update = () => {
535
this.enabled = this._myAlternativeVersionId === this._model.getAlternativeVersionId();
536
};
537
this._store.add(_model.onDidChangeContent(() => update()));
538
update();
539
}
540
541
override async run(): Promise<void> {
542
if (this._myAlternativeVersionId === this._model.getAlternativeVersionId()) {
543
this._model.undo();
544
}
545
}
546
}
547
548
type Exchange = { req: IInteractiveEditorRequest; res: IInteractiveEditorResponse };
549
export type Recording = { when: Date; session: IInteractiveEditorSession; value: string; exchanges: Exchange[] };
550
551
class SessionRecorder {
552
553
private readonly _data = new LRUCache<IInteractiveEditorSession, Recording>(3);
554
555
add(session: IInteractiveEditorSession, model: ITextModel) {
556
this._data.set(session, { when: new Date(), session, value: model.getValue(), exchanges: [] });
557
}
558
559
addExchange(session: IInteractiveEditorSession, req: IInteractiveEditorRequest, res: IInteractiveEditorResponse) {
560
this._data.get(session)?.exchanges.push({ req, res });
561
}
562
563
getAll(): Recording[] {
564
return [...this._data.values()];
565
}
566
}
567
568
type TelemetryData = {
569
extension: string;
570
rounds: string;
571
undos: string;
572
edits: boolean;
573
terminalEdits: boolean;
574
startTime: string;
575
endTime: string;
576
};
577
578
type TelemetryDataClassification = {
579
owner: 'jrieken';
580
comment: 'Data about an interaction editor session';
581
extension: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension providing the data' };
582
rounds: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of request that were made' };
583
undos: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Requests that have been undone' };
584
edits: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Did edits happen while the session was active' };
585
terminalEdits: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Did edits terminal the session' };
586
startTime: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'When the session started' };
587
endTime: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'When the session ended' };
588
};
589
590
class InlineDiffDecorations {
591
592
private readonly _collection: IEditorDecorationsCollection;
593
594
private _data: { tracking: IModelDeltaDecoration; decorating: IModelDecorationOptions }[] = [];
595
private _visible: boolean = false;
596
597
constructor(editor: ICodeEditor, visible: boolean = false) {
598
this._collection = editor.createDecorationsCollection();
599
this._visible = visible;
600
}
601
602
get visible() {
603
return this._visible;
604
}
605
606
set visible(value: boolean) {
607
this._visible = value;
608
this.update();
609
}
610
611
clear() {
612
this._collection.clear();
613
this._data.length = 0;
614
}
615
616
collectEditOperation(op: IValidEditOperation) {
617
this._data.push(InlineDiffDecorations._asDecorationData(op));
618
}
619
620
update() {
621
this._collection.set(this._data.map(d => {
622
const res = { ...d.tracking };
623
if (this._visible) {
624
res.options = { ...res.options, ...d.decorating };
625
}
626
return res;
627
}));
628
}
629
630
private static _asDecorationData(edit: IValidEditOperation): { tracking: IModelDeltaDecoration; decorating: IModelDecorationOptions } {
631
let content = edit.text;
632
if (content.length > 12) {
633
content = content.substring(0, 12) + '…';
634
}
635
const tracking: IModelDeltaDecoration = {
636
range: edit.range,
637
options: {
638
description: 'interactive-editor-inline-diff',
639
}
640
};
641
642
const decorating: IModelDecorationOptions = {
643
description: 'interactive-editor-inline-diff',
644
className: 'interactive-editor-lines-inserted-range',
645
before: {
646
content,
647
inlineClassName: 'interactive-editor-lines-deleted-range-inline',
648
attachedData: edit
649
}
650
};
651
652
return { tracking, decorating };
653
}
654
}
655
656
export class InteractiveEditorController implements IEditorContribution {
657
658
static ID = 'interactiveEditor';
659
660
static get(editor: ICodeEditor) {
661
return editor.getContribution<InteractiveEditorController>(InteractiveEditorController.ID);
662
}
663
664
private static _decoBlock = ModelDecorationOptions.register({
665
description: 'interactive-editor',
666
blockClassName: 'interactive-editor-block',
667
blockDoesNotCollapse: true,
668
blockPadding: [4, 0, 1, 4]
669
});
670
671
private static _decoWholeRange = ModelDecorationOptions.register({
672
description: 'interactive-editor-marker'
673
});
674
675
private static _promptHistory: string[] = [];
676
private _historyOffset: number = -1;
677
678
private readonly _store = new DisposableStore();
679
private readonly _recorder = new SessionRecorder();
680
private readonly _zone: InteractiveEditorZoneWidget;
681
private readonly _ctxHasActiveRequest: IContextKey<boolean>;
682
private _inlineDiffEnabled: boolean = false;
683
684
private _ctsSession: CancellationTokenSource = new CancellationTokenSource();
685
private _ctsRequest?: CancellationTokenSource;
686
687
constructor(
688
private readonly _editor: ICodeEditor,
689
@IInstantiationService private readonly _instaService: IInstantiationService,
690
@IContextKeyService contextKeyService: IContextKeyService,
691
@IInteractiveEditorService private readonly _interactiveEditorService: IInteractiveEditorService,
692
@IBulkEditService private readonly _bulkEditService: IBulkEditService,
693
@IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService,
694
@ILogService private readonly _logService: ILogService,
695
@ITelemetryService private readonly _telemetryService: ITelemetryService
696
) {
697
this._zone = this._store.add(_instaService.createInstance(InteractiveEditorZoneWidget, this._editor));
698
this._ctxHasActiveRequest = CTX_INTERACTIVE_EDITOR_HAS_ACTIVE_REQUEST.bindTo(contextKeyService);
699
}
700
701
dispose(): void {
702
this._store.dispose();
703
this._ctsSession.dispose(true);
704
this._ctsSession.dispose();
705
}
706
707
getId(): string {
708
return InteractiveEditorController.ID;
709
}
710
711
async run(initialRange?: Range): Promise<void> {
712
713
this._ctsSession.dispose(true);
714
715
if (!this._editor.hasModel()) {
716
return;
717
}
718
719
const provider = Iterable.first(this._interactiveEditorService.getAllProvider());
720
if (!provider) {
721
this._logService.trace('[IE] NO provider found');
722
return;
723
}
724
725
const thisSession = this._ctsSession = new CancellationTokenSource();
726
const textModel = this._editor.getModel();
727
const selection = this._editor.getSelection();
728
const session = await provider.prepareInteractiveEditorSession(textModel, selection, this._ctsSession.token);
729
if (!session) {
730
this._logService.trace('[IE] NO session', provider.debugName);
731
return;
732
}
733
this._recorder.add(session, textModel);
734
this._logService.trace('[IE] NEW session', provider.debugName);
735
736
const data: TelemetryData = {
737
extension: provider.debugName,
738
startTime: new Date().toISOString(),
739
endTime: new Date().toISOString(),
740
edits: false,
741
terminalEdits: false,
742
rounds: '',
743
undos: ''
744
};
745
746
const statusWidget = this._zone.widget.createStatusEntry();
747
const inlineDiffDecorations = new InlineDiffDecorations(this._editor, this._inlineDiffEnabled);
748
749
const blockDecoration = this._editor.createDecorationsCollection();
750
const wholeRangeDecoration = this._editor.createDecorationsCollection();
751
752
if (!initialRange) {
753
initialRange = session.wholeRange ? Range.lift(session.wholeRange) : selection;
754
}
755
if (initialRange.isEmpty()) {
756
initialRange = new Range(
757
initialRange.startLineNumber, 1,
758
initialRange.startLineNumber, textModel.getLineMaxColumn(initialRange.startLineNumber)
759
);
760
}
761
wholeRangeDecoration.set([{
762
range: initialRange,
763
options: InteractiveEditorController._decoWholeRange
764
}]);
765
766
767
let placeholder = session.placeholder ?? '';
768
let value = '';
769
770
const store = new DisposableStore();
771
772
if (session.slashCommands) {
773
store.add(this._instaService.invokeFunction(installSlashCommandSupport, this._zone.widget.inputEditor as IActiveCodeEditor, session.slashCommands));
774
}
775
776
// CANCEL when input changes
777
this._editor.onDidChangeModel(this._ctsSession.cancel, this._ctsSession, store);
778
779
// REposition the zone widget whenever the block decoration changes
780
let lastPost: Position | undefined;
781
wholeRangeDecoration.onDidChange(e => {
782
const range = wholeRangeDecoration.getRange(0);
783
if (range && (!lastPost || !lastPost.equals(range.getEndPosition()))) {
784
lastPost = range.getEndPosition();
785
this._zone.updatePosition(lastPost);
786
}
787
}, undefined, store);
788
789
let ignoreModelChanges = false;
790
this._editor.onDidChangeModelContent(e => {
791
if (!ignoreModelChanges) {
792
793
// remove inline diff when the model changes
794
inlineDiffDecorations.clear();
795
796
// note when "other" edits happen
797
data.edits = true;
798
799
// CANCEL if the document has changed outside the current range
800
const wholeRange = wholeRangeDecoration.getRange(0);
801
if (!wholeRange) {
802
this._ctsSession.cancel();
803
this._logService.trace('[IE] ABORT wholeRange seems gone/collapsed');
804
return;
805
}
806
for (const change of e.changes) {
807
if (!Range.areIntersectingOrTouching(wholeRange, change.range)) {
808
this._ctsSession.cancel();
809
this._logService.trace('[IE] CANCEL because of model change OUTSIDE range');
810
data.terminalEdits = true;
811
break;
812
}
813
}
814
}
815
816
}, undefined, store);
817
818
let round = 0;
819
const roundStore = new DisposableStore();
820
store.add(roundStore);
821
822
do {
823
824
round += 1;
825
826
const wholeRange = wholeRangeDecoration.getRange(0);
827
if (!wholeRange) {
828
// nuked whole file contents?
829
this._logService.trace('[IE] ABORT wholeRange seems gone/collapsed');
830
break;
831
}
832
833
// visuals: add block decoration
834
blockDecoration.set([{
835
range: wholeRange,
836
options: InteractiveEditorController._decoBlock
837
}]);
838
839
this._ctsRequest?.dispose(true);
840
this._ctsRequest = new CancellationTokenSource(this._ctsSession.token);
841
842
this._historyOffset = -1;
843
this._editor.revealRange(wholeRange, ScrollType.Smooth);
844
const input = await this._zone.getInput(wholeRange.getEndPosition(), placeholder, value, this._ctsRequest.token);
845
roundStore.clear();
846
847
if (!input || !input.value) {
848
continue;
849
}
850
851
const refer = session.slashCommands?.some(value => value.refer && input.value.startsWith(`/${value.command}`));
852
if (refer) {
853
this._logService.info('[IE] seeing refer command, continuing outside editor', provider.debugName);
854
this._editor.setSelection(wholeRange);
855
this._instaService.invokeFunction(showMessageResponse, input.value);
856
continue;
857
}
858
859
const sw = StopWatch.create();
860
const request: IInteractiveEditorRequest = {
861
prompt: input.value,
862
selection: this._editor.getSelection(),
863
wholeRange
864
};
865
const task = provider.provideResponse(session, request, this._ctsRequest.token);
866
this._logService.trace('[IE] request started', provider.debugName, session, request);
867
868
let reply: IInteractiveEditorResponse | null | undefined;
869
try {
870
this._zone.widget.updateProgress(true);
871
this._ctxHasActiveRequest.set(true);
872
reply = await raceCancellationError(Promise.resolve(task), this._ctsRequest.token);
873
874
} catch (e) {
875
if (!isCancellationError(e)) {
876
this._logService.error('[IE] ERROR during request', provider.debugName);
877
this._logService.error(e);
878
// this._zone.widget.showMessage(toErrorMessage(e));
879
statusWidget.update({ message: toErrorMessage(e), classes: ['error'], actions: [] });
880
// statusWidget
881
continue;
882
}
883
} finally {
884
this._ctxHasActiveRequest.set(false);
885
this._zone.widget.updateProgress(false);
886
this._logService.trace('[IE] request took', sw.elapsed(), provider.debugName);
887
}
888
889
if (this._ctsRequest.token.isCancellationRequested) {
890
this._logService.trace('[IE] request CANCELED', provider.debugName);
891
value = input.value;
892
continue;
893
}
894
895
if (!reply) {
896
this._logService.trace('[IE] NO reply or edits', provider.debugName);
897
value = input.value;
898
statusWidget.update({ message: localize('empty', "No results, tweak your input and try again."), classes: ['warn'], actions: [] });
899
continue;
900
}
901
902
if (reply.type === 'bulkEdit') {
903
this._logService.info('[IE] performaing a BULK EDIT, exiting interactive editor', provider.debugName);
904
this._bulkEditService.apply(reply.edits, { editor: this._editor, label: localize('ie', "{0}", input.value), showPreview: true });
905
// todo@jrieken preview bulk edit?
906
// todo@jrieken keep interactive editor?
907
break;
908
}
909
910
if (reply.type === 'message') {
911
this._logService.info('[IE] received a MESSAGE, continuing outside editor', provider.debugName);
912
this._editor.setSelection(reply.wholeRange ?? wholeRange);
913
this._instaService.invokeFunction(showMessageResponse, request.prompt);
914
continue;
915
}
916
917
// make edits more minimal
918
const moreMinimalEdits = (await this._editorWorkerService.computeMoreMinimalEdits(textModel.uri, reply.edits, true));
919
this._logService.trace('[IE] edits from PROVIDER and after making them MORE MINIMAL', provider.debugName, reply.edits, moreMinimalEdits);
920
this._recorder.addExchange(session, request, reply);
921
922
// inline diff
923
inlineDiffDecorations.clear();
924
925
// use whole range from reply
926
if (reply.wholeRange) {
927
wholeRangeDecoration.set([{
928
range: reply.wholeRange,
929
options: InteractiveEditorController._decoWholeRange
930
}]);
931
}
932
933
try {
934
ignoreModelChanges = true;
935
936
const cursorStateComputerAndInlineDiffCollection: ICursorStateComputer = (undoEdits) => {
937
let last: Position | null = null;
938
for (const edit of undoEdits) {
939
last = !last || last.isBefore(edit.range.getEndPosition()) ? edit.range.getEndPosition() : last;
940
inlineDiffDecorations.collectEditOperation(edit);
941
}
942
return last && [Selection.fromPositions(last)];
943
};
944
945
this._editor.pushUndoStop();
946
this._editor.executeEdits(
947
'interactive-editor',
948
(moreMinimalEdits ?? reply.edits).map(edit => EditOperation.replace(Range.lift(edit.range), edit.text)),
949
cursorStateComputerAndInlineDiffCollection
950
);
951
this._editor.pushUndoStop();
952
953
} finally {
954
ignoreModelChanges = false;
955
}
956
957
inlineDiffDecorations.update();
958
959
960
const replyActions: Action[] = reply.commands?.map(command => this._instaService.createInstance(CommandAction, command)) ?? [];
961
const fixedActions: Action[] = [new UndoAction(textModel), new ToggleInlineDiff(inlineDiffDecorations)];
962
roundStore.add(combinedDisposable(...replyActions, ...fixedActions));
963
964
const editsCount = (moreMinimalEdits ?? reply.edits).length;
965
966
statusWidget.update({
967
message: editsCount === 1 ? localize('edit.1', "Done, made 1 change") : localize('edit.N', "Done, made {0} changes", editsCount),
968
classes: [],
969
actions: Separator.join(replyActions, fixedActions),
970
});
971
972
if (!InteractiveEditorController._promptHistory.includes(input.value)) {
973
InteractiveEditorController._promptHistory.unshift(input.value);
974
}
975
placeholder = reply.placeholder ?? session.placeholder ?? '';
976
value = '';
977
data.rounds += round + '|';
978
979
} while (!thisSession.token.isCancellationRequested);
980
981
this._inlineDiffEnabled = inlineDiffDecorations.visible;
982
983
// done, cleanup
984
wholeRangeDecoration.clear();
985
blockDecoration.clear();
986
inlineDiffDecorations.clear();
987
988
store.dispose();
989
session.dispose?.();
990
991
992
this._zone.hide();
993
this._editor.focus();
994
995
this._logService.trace('[IE] session DONE', provider.debugName);
996
data.endTime = new Date().toISOString();
997
998
this._telemetryService.publicLog2<TelemetryData, TelemetryDataClassification>('interactiveEditor/session', data);
999
}
1000
1001
accept(preview: boolean): void {
1002
this._zone.widget.acceptInput(preview);
1003
}
1004
1005
cancelCurrentRequest(): void {
1006
this._ctsRequest?.cancel();
1007
}
1008
1009
cancelSession() {
1010
this._ctsSession.cancel();
1011
}
1012
1013
arrowOut(up: boolean): void {
1014
if (this._zone.position && this._editor.hasModel()) {
1015
const { column } = this._editor.getPosition();
1016
const { lineNumber } = this._zone.position;
1017
const newLine = up ? lineNumber : lineNumber + 1;
1018
this._editor.setPosition({ lineNumber: newLine, column });
1019
this._editor.focus();
1020
}
1021
}
1022
1023
focus(): void {
1024
this._zone.widget.focus();
1025
}
1026
1027
populateHistory(up: boolean) {
1028
const len = InteractiveEditorController._promptHistory.length;
1029
if (len === 0) {
1030
return;
1031
}
1032
const pos = (len + this._historyOffset + (up ? 1 : -1)) % len;
1033
const entry = InteractiveEditorController._promptHistory[pos];
1034
this._zone.widget.populateInputField(entry);
1035
this._historyOffset = pos;
1036
}
1037
1038
recordings() {
1039
return this._recorder.getAll();
1040
}
1041
}
1042
1043
function installSlashCommandSupport(accessor: ServicesAccessor, editor: IActiveCodeEditor, commands: IInteractiveEditorSlashCommand[]) {
1044
1045
const languageFeaturesService = accessor.get(ILanguageFeaturesService);
1046
1047
const store = new DisposableStore();
1048
const selector: LanguageSelector = { scheme: editor.getModel().uri.scheme, pattern: editor.getModel().uri.path, language: editor.getModel().getLanguageId() };
1049
store.add(languageFeaturesService.completionProvider.register(selector, new class implements CompletionItemProvider {
1050
1051
_debugDisplayName?: string = 'InteractiveEditorSlashCommandProvider';
1052
1053
readonly triggerCharacters?: string[] = ['/'];
1054
1055
provideCompletionItems(model: ITextModel, position: Position, context: CompletionContext, token: CancellationToken): ProviderResult<CompletionList> {
1056
if (position.lineNumber !== 1 && position.column !== 1) {
1057
return undefined;
1058
}
1059
1060
const suggestions: CompletionItem[] = commands.map(command => {
1061
1062
const withSlash = `/${command.command}`;
1063
1064
return {
1065
label: withSlash,
1066
insertText: `${withSlash} $0`,
1067
insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet,
1068
kind: CompletionItemKind.Text,
1069
range: new Range(1, 1, 1, 1),
1070
detail: command.detail
1071
};
1072
});
1073
1074
return { suggestions };
1075
}
1076
}));
1077
1078
const decorations = editor.createDecorationsCollection();
1079
1080
const updateSlashDecorations = () => {
1081
const newDecorations: IModelDeltaDecoration[] = [];
1082
for (const command of commands) {
1083
const withSlash = `/${command.command}`;
1084
const firstLine = editor.getModel().getLineContent(1);
1085
if (firstLine.startsWith(withSlash)) {
1086
newDecorations.push({
1087
range: new Range(1, 1, 1, withSlash.length + 1),
1088
options: {
1089
description: 'interactive-editor-slash-command',
1090
inlineClassName: 'interactive-editor-slash-command',
1091
}
1092
});
1093
1094
// inject detail when otherwise empty
1095
if (firstLine === `/${command.command} `) {
1096
newDecorations.push({
1097
range: new Range(1, withSlash.length + 1, 1, withSlash.length + 2),
1098
options: {
1099
description: 'interactive-editor-slash-command-detail',
1100
after: {
1101
content: `${command.detail}`,
1102
inlineClassName: 'interactive-editor-slash-command-detail'
1103
}
1104
}
1105
});
1106
}
1107
break;
1108
}
1109
}
1110
decorations.set(newDecorations);
1111
};
1112
1113
store.add(editor.onDidChangeModelContent(updateSlashDecorations));
1114
updateSlashDecorations();
1115
1116
return store;
1117
}
1118
1119
async function showMessageResponse(accessor: ServicesAccessor, query: string) {
1120
1121
1122
const widgetService = accessor.get(IInteractiveSessionWidgetService);
1123
const viewsService = accessor.get(IViewsService);
1124
const interactiveSessionContributionService = accessor.get(IInteractiveSessionContributionService);
1125
1126
if (widgetService.lastFocusedWidget && widgetService.lastFocusedWidget.viewId) {
1127
// option 1 - take the most recent view
1128
viewsService.openView(widgetService.lastFocusedWidget.viewId, true);
1129
widgetService.lastFocusedWidget.acceptInput(query);
1130
1131
} else {
1132
// fallback - take the first view that's openable
1133
for (const { id } of interactiveSessionContributionService.registeredProviders) {
1134
const viewId = interactiveSessionContributionService.getViewIdForProvider(id);
1135
const view = await viewsService.openView<InteractiveSessionViewPane>(viewId, true);
1136
if (view) {
1137
view.acceptInput(query);
1138
break;
1139
}
1140
}
1141
}
1142
}
1143
1144