Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/codeBlockPart.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 './codeBlockPart.css';
7
8
import * as dom from '../../../../base/browser/dom.js';
9
import { renderFormattedText } from '../../../../base/browser/formattedTextRenderer.js';
10
import { Button } from '../../../../base/browser/ui/button/button.js';
11
import { CancellationToken } from '../../../../base/common/cancellation.js';
12
import { Codicon } from '../../../../base/common/codicons.js';
13
import { Emitter, Event } from '../../../../base/common/event.js';
14
import { combinedDisposable, Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
15
import { Schemas } from '../../../../base/common/network.js';
16
import { isEqual } from '../../../../base/common/resources.js';
17
import { assertType } from '../../../../base/common/types.js';
18
import { URI, UriComponents } from '../../../../base/common/uri.js';
19
import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js';
20
import { IDiffEditor } from '../../../../editor/browser/editorBrowser.js';
21
import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js';
22
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
23
import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js';
24
import { DiffEditorWidget } from '../../../../editor/browser/widget/diffEditor/diffEditorWidget.js';
25
import { EDITOR_FONT_DEFAULTS, EditorOption, IEditorOptions } from '../../../../editor/common/config/editorOptions.js';
26
import { IRange, Range } from '../../../../editor/common/core/range.js';
27
import { ScrollType } from '../../../../editor/common/editorCommon.js';
28
import { TextEdit } from '../../../../editor/common/languages.js';
29
import { EndOfLinePreference, ITextModel } from '../../../../editor/common/model.js';
30
import { TextModelText } from '../../../../editor/common/model/textModelText.js';
31
import { IModelService } from '../../../../editor/common/services/model.js';
32
import { DefaultModelSHA1Computer } from '../../../../editor/common/services/modelService.js';
33
import { ITextModelContentProvider, ITextModelService } from '../../../../editor/common/services/resolverService.js';
34
import { BracketMatchingController } from '../../../../editor/contrib/bracketMatching/browser/bracketMatching.js';
35
import { ColorDetector } from '../../../../editor/contrib/colorPicker/browser/colorDetector.js';
36
import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js';
37
import { GotoDefinitionAtPositionEditorContribution } from '../../../../editor/contrib/gotoSymbol/browser/link/goToDefinitionAtPosition.js';
38
import { ContentHoverController } from '../../../../editor/contrib/hover/browser/contentHoverController.js';
39
import { GlyphHoverController } from '../../../../editor/contrib/hover/browser/glyphHoverController.js';
40
import { LinkDetector } from '../../../../editor/contrib/links/browser/links.js';
41
import { MessageController } from '../../../../editor/contrib/message/browser/messageController.js';
42
import { ViewportSemanticTokensContribution } from '../../../../editor/contrib/semanticTokens/browser/viewportSemanticTokens.js';
43
import { SmartSelectController } from '../../../../editor/contrib/smartSelect/browser/smartSelect.js';
44
import { WordHighlighterContribution } from '../../../../editor/contrib/wordHighlighter/browser/wordHighlighter.js';
45
import { localize } from '../../../../nls.js';
46
import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js';
47
import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
48
import { MenuId } from '../../../../platform/actions/common/actions.js';
49
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
50
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
51
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
52
import { FileKind } from '../../../../platform/files/common/files.js';
53
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
54
import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js';
55
import { ILabelService } from '../../../../platform/label/common/label.js';
56
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
57
import { ResourceLabel } from '../../../browser/labels.js';
58
import { ResourceContextKey } from '../../../common/contextkeys.js';
59
import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js';
60
import { InspectEditorTokensController } from '../../codeEditor/browser/inspectEditorTokens/inspectEditorTokens.js';
61
import { MenuPreventer } from '../../codeEditor/browser/menuPreventer.js';
62
import { SelectionClipboardContributionID } from '../../codeEditor/browser/selectionClipboard.js';
63
import { getSimpleEditorOptions } from '../../codeEditor/browser/simpleEditorOptions.js';
64
import { IMarkdownVulnerability } from '../common/annotations.js';
65
import { ChatContextKeys } from '../common/chatContextKeys.js';
66
import { IChatResponseModel, IChatTextEditGroup } from '../common/chatModel.js';
67
import { IChatResponseViewModel, isRequestVM, isResponseVM } from '../common/chatViewModel.js';
68
import { ChatTreeItem } from './chat.js';
69
import { IChatRendererDelegate } from './chatListRenderer.js';
70
import { ChatEditorOptions } from './chatOptions.js';
71
import { emptyProgressRunner, IEditorProgressService } from '../../../../platform/progress/common/progress.js';
72
import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js';
73
import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js';
74
75
const $ = dom.$;
76
77
export interface ICodeBlockData {
78
readonly codeBlockIndex: number;
79
readonly codeBlockPartIndex: number;
80
readonly element: unknown;
81
82
readonly textModel: Promise<ITextModel>;
83
readonly languageId: string;
84
85
readonly codemapperUri?: URI;
86
87
readonly vulns?: readonly IMarkdownVulnerability[];
88
readonly range?: Range;
89
90
readonly parentContextKeyService?: IContextKeyService;
91
readonly renderOptions?: ICodeBlockRenderOptions;
92
93
readonly chatSessionId: string;
94
}
95
96
/**
97
* Special markdown code block language id used to render a local file.
98
*
99
* The text of the code path should be a {@link LocalFileCodeBlockData} json object.
100
*/
101
export const localFileLanguageId = 'vscode-local-file';
102
103
104
export function parseLocalFileData(text: string) {
105
106
interface RawLocalFileCodeBlockData {
107
readonly uri: UriComponents;
108
readonly range?: IRange;
109
}
110
111
let data: RawLocalFileCodeBlockData;
112
try {
113
data = JSON.parse(text);
114
} catch (e) {
115
throw new Error('Could not parse code block local file data');
116
}
117
118
let uri: URI;
119
try {
120
uri = URI.revive(data?.uri);
121
} catch (e) {
122
throw new Error('Invalid code block local file data URI');
123
}
124
125
let range: IRange | undefined;
126
if (data.range) {
127
// Note that since this is coming from extensions, position are actually zero based and must be converted.
128
range = new Range(data.range.startLineNumber + 1, data.range.startColumn + 1, data.range.endLineNumber + 1, data.range.endColumn + 1);
129
}
130
131
return { uri, range };
132
}
133
134
export interface ICodeBlockActionContext {
135
readonly code: string;
136
readonly codemapperUri?: URI;
137
readonly languageId?: string;
138
readonly codeBlockIndex: number;
139
readonly element: unknown;
140
141
readonly chatSessionId: string | undefined;
142
}
143
144
export interface ICodeBlockRenderOptions {
145
hideToolbar?: boolean;
146
verticalPadding?: number;
147
reserveWidth?: number;
148
editorOptions?: IEditorOptions;
149
maxHeightInLines?: number;
150
}
151
152
const defaultCodeblockPadding = 10;
153
export class CodeBlockPart extends Disposable {
154
protected readonly _onDidChangeContentHeight = this._register(new Emitter<void>());
155
public readonly onDidChangeContentHeight = this._onDidChangeContentHeight.event;
156
157
public readonly editor: CodeEditorWidget;
158
protected readonly toolbar: MenuWorkbenchToolBar;
159
private readonly contextKeyService: IContextKeyService;
160
161
public readonly element: HTMLElement;
162
163
private readonly vulnsButton: Button;
164
private readonly vulnsListElement: HTMLElement;
165
166
private currentCodeBlockData: ICodeBlockData | undefined;
167
private currentScrollWidth = 0;
168
169
private isDisposed = false;
170
171
private resourceContextKey: ResourceContextKey;
172
173
private get verticalPadding(): number {
174
return this.currentCodeBlockData?.renderOptions?.verticalPadding ?? defaultCodeblockPadding;
175
}
176
177
constructor(
178
private readonly editorOptions: ChatEditorOptions,
179
readonly menuId: MenuId,
180
delegate: IChatRendererDelegate,
181
overflowWidgetsDomNode: HTMLElement | undefined,
182
private readonly isSimpleWidget: boolean = false,
183
@IInstantiationService instantiationService: IInstantiationService,
184
@IContextKeyService contextKeyService: IContextKeyService,
185
@IModelService protected readonly modelService: IModelService,
186
@IConfigurationService private readonly configurationService: IConfigurationService,
187
@IAccessibilityService private readonly accessibilityService: IAccessibilityService,
188
) {
189
super();
190
this.element = $('.interactive-result-code-block');
191
192
this.resourceContextKey = this._register(instantiationService.createInstance(ResourceContextKey));
193
this.contextKeyService = this._register(contextKeyService.createScoped(this.element));
194
const scopedInstantiationService = this._register(instantiationService.createChild(new ServiceCollection([IContextKeyService, this.contextKeyService])));
195
const editorElement = dom.append(this.element, $('.interactive-result-editor'));
196
this.editor = this.createEditor(scopedInstantiationService, editorElement, {
197
...getSimpleEditorOptions(this.configurationService),
198
readOnly: true,
199
lineNumbers: 'off',
200
selectOnLineNumbers: true,
201
scrollBeyondLastLine: false,
202
lineDecorationsWidth: 8,
203
dragAndDrop: false,
204
padding: { top: this.verticalPadding, bottom: this.verticalPadding },
205
mouseWheelZoom: false,
206
scrollbar: {
207
vertical: 'hidden',
208
alwaysConsumeMouseWheel: false
209
},
210
definitionLinkOpensInPeek: false,
211
gotoLocation: {
212
multiple: 'goto',
213
multipleDeclarations: 'goto',
214
multipleDefinitions: 'goto',
215
multipleImplementations: 'goto',
216
},
217
ariaLabel: localize('chat.codeBlockHelp', 'Code block'),
218
overflowWidgetsDomNode,
219
tabFocusMode: true,
220
...this.getEditorOptionsFromConfig(),
221
});
222
223
const toolbarElement = dom.append(this.element, $('.interactive-result-code-block-toolbar'));
224
const editorScopedService = this.editor.contextKeyService.createScoped(toolbarElement);
225
const editorScopedInstantiationService = this._register(scopedInstantiationService.createChild(new ServiceCollection([IContextKeyService, editorScopedService])));
226
this.toolbar = this._register(editorScopedInstantiationService.createInstance(MenuWorkbenchToolBar, toolbarElement, menuId, {
227
menuOptions: {
228
shouldForwardArgs: true
229
}
230
}));
231
232
const vulnsContainer = dom.append(this.element, $('.interactive-result-vulns'));
233
const vulnsHeaderElement = dom.append(vulnsContainer, $('.interactive-result-vulns-header', undefined));
234
this.vulnsButton = this._register(new Button(vulnsHeaderElement, {
235
buttonBackground: undefined,
236
buttonBorder: undefined,
237
buttonForeground: undefined,
238
buttonHoverBackground: undefined,
239
buttonSecondaryBackground: undefined,
240
buttonSecondaryForeground: undefined,
241
buttonSecondaryHoverBackground: undefined,
242
buttonSeparator: undefined,
243
supportIcons: true
244
}));
245
246
this.vulnsListElement = dom.append(vulnsContainer, $('ul.interactive-result-vulns-list'));
247
248
this._register(this.vulnsButton.onDidClick(() => {
249
const element = this.currentCodeBlockData!.element as IChatResponseViewModel;
250
element.vulnerabilitiesListExpanded = !element.vulnerabilitiesListExpanded;
251
this.vulnsButton.label = this.getVulnerabilitiesLabel();
252
this.element.classList.toggle('chat-vulnerabilities-collapsed', !element.vulnerabilitiesListExpanded);
253
this._onDidChangeContentHeight.fire();
254
// this.updateAriaLabel(collapseButton.element, referencesLabel, element.usedReferencesExpanded);
255
}));
256
257
this._register(this.toolbar.onDidChangeDropdownVisibility(e => {
258
toolbarElement.classList.toggle('force-visibility', e);
259
}));
260
261
this._configureForScreenReader();
262
this._register(this.accessibilityService.onDidChangeScreenReaderOptimized(() => this._configureForScreenReader()));
263
this._register(this.configurationService.onDidChangeConfiguration((e) => {
264
if (e.affectedKeys.has(AccessibilityVerbositySettingId.Chat)) {
265
this._configureForScreenReader();
266
}
267
}));
268
269
this._register(this.editorOptions.onDidChange(() => {
270
this.editor.updateOptions(this.getEditorOptionsFromConfig());
271
}));
272
273
this._register(this.editor.onDidScrollChange(e => {
274
this.currentScrollWidth = e.scrollWidth;
275
}));
276
this._register(this.editor.onDidContentSizeChange(e => {
277
if (e.contentHeightChanged) {
278
this._onDidChangeContentHeight.fire();
279
}
280
}));
281
this._register(this.editor.onDidBlurEditorWidget(() => {
282
this.element.classList.remove('focused');
283
WordHighlighterContribution.get(this.editor)?.stopHighlighting();
284
this.clearWidgets();
285
}));
286
this._register(this.editor.onDidFocusEditorWidget(() => {
287
this.element.classList.add('focused');
288
WordHighlighterContribution.get(this.editor)?.restoreViewState(true);
289
}));
290
this._register(Event.any(
291
this.editor.onDidChangeModel,
292
this.editor.onDidChangeModelContent
293
)(() => {
294
if (this.currentCodeBlockData) {
295
this.updateContexts(this.currentCodeBlockData);
296
}
297
}));
298
299
// Parent list scrolled
300
if (delegate.onDidScroll) {
301
this._register(delegate.onDidScroll(e => {
302
this.clearWidgets();
303
}));
304
}
305
}
306
307
override dispose() {
308
this.isDisposed = true;
309
super.dispose();
310
}
311
312
get uri(): URI | undefined {
313
return this.editor.getModel()?.uri;
314
}
315
316
private createEditor(instantiationService: IInstantiationService, parent: HTMLElement, options: Readonly<IEditorConstructionOptions>): CodeEditorWidget {
317
return this._register(instantiationService.createInstance(CodeEditorWidget, parent, options, {
318
isSimpleWidget: this.isSimpleWidget,
319
contributions: EditorExtensionsRegistry.getSomeEditorContributions([
320
MenuPreventer.ID,
321
SelectionClipboardContributionID,
322
ContextMenuController.ID,
323
324
WordHighlighterContribution.ID,
325
ViewportSemanticTokensContribution.ID,
326
BracketMatchingController.ID,
327
SmartSelectController.ID,
328
ContentHoverController.ID,
329
GlyphHoverController.ID,
330
MessageController.ID,
331
GotoDefinitionAtPositionEditorContribution.ID,
332
SuggestController.ID,
333
SnippetController2.ID,
334
ColorDetector.ID,
335
LinkDetector.ID,
336
337
InspectEditorTokensController.ID,
338
])
339
}));
340
}
341
342
focus(): void {
343
this.editor.focus();
344
}
345
346
private updatePaddingForLayout() {
347
// scrollWidth = "the width of the content that needs to be scrolled"
348
// contentWidth = "the width of the area where content is displayed"
349
const horizontalScrollbarVisible = this.currentScrollWidth > this.editor.getLayoutInfo().contentWidth;
350
const scrollbarHeight = this.editor.getLayoutInfo().horizontalScrollbarHeight;
351
const bottomPadding = horizontalScrollbarVisible ?
352
Math.max(this.verticalPadding - scrollbarHeight, 2) :
353
this.verticalPadding;
354
this.editor.updateOptions({ padding: { top: this.verticalPadding, bottom: bottomPadding } });
355
}
356
357
private _configureForScreenReader(): void {
358
const toolbarElt = this.toolbar.getElement();
359
if (this.accessibilityService.isScreenReaderOptimized()) {
360
toolbarElt.style.display = 'block';
361
} else {
362
toolbarElt.style.display = '';
363
}
364
}
365
366
private getEditorOptionsFromConfig(): IEditorOptions {
367
return {
368
wordWrap: this.editorOptions.configuration.resultEditor.wordWrap,
369
fontLigatures: this.editorOptions.configuration.resultEditor.fontLigatures,
370
bracketPairColorization: this.editorOptions.configuration.resultEditor.bracketPairColorization,
371
fontFamily: this.editorOptions.configuration.resultEditor.fontFamily === 'default' ?
372
EDITOR_FONT_DEFAULTS.fontFamily :
373
this.editorOptions.configuration.resultEditor.fontFamily,
374
fontSize: this.editorOptions.configuration.resultEditor.fontSize,
375
fontWeight: this.editorOptions.configuration.resultEditor.fontWeight,
376
lineHeight: this.editorOptions.configuration.resultEditor.lineHeight,
377
...this.currentCodeBlockData?.renderOptions?.editorOptions,
378
};
379
}
380
381
layout(width: number): void {
382
const contentHeight = this.getContentHeight();
383
384
let height = contentHeight;
385
if (this.currentCodeBlockData?.renderOptions?.maxHeightInLines) {
386
height = Math.min(contentHeight, this.editor.getOption(EditorOption.lineHeight) * this.currentCodeBlockData?.renderOptions?.maxHeightInLines);
387
}
388
389
const editorBorder = 2;
390
width = width - editorBorder - (this.currentCodeBlockData?.renderOptions?.reserveWidth ?? 0);
391
this.editor.layout({ width: isRequestVM(this.currentCodeBlockData?.element) ? width * 0.9 : width, height });
392
this.updatePaddingForLayout();
393
}
394
395
private getContentHeight() {
396
if (this.currentCodeBlockData?.range) {
397
const lineCount = this.currentCodeBlockData.range.endLineNumber - this.currentCodeBlockData.range.startLineNumber + 1;
398
const lineHeight = this.editor.getOption(EditorOption.lineHeight);
399
return lineCount * lineHeight;
400
}
401
return this.editor.getContentHeight();
402
}
403
404
async render(data: ICodeBlockData, width: number) {
405
this.currentCodeBlockData = data;
406
if (data.parentContextKeyService) {
407
this.contextKeyService.updateParent(data.parentContextKeyService);
408
}
409
410
if (this.getEditorOptionsFromConfig().wordWrap === 'on') {
411
// Initialize the editor with the new proper width so that getContentHeight
412
// will be computed correctly in the next call to layout()
413
this.layout(width);
414
}
415
416
const didUpdate = await this.updateEditor(data);
417
if (!didUpdate || this.isDisposed || this.currentCodeBlockData !== data) {
418
return;
419
}
420
421
this.editor.updateOptions({
422
...this.getEditorOptionsFromConfig(),
423
});
424
if (!this.editor.getOption(EditorOption.ariaLabel)) {
425
// Don't override the ariaLabel if it was set by the editor options
426
this.editor.updateOptions({
427
ariaLabel: localize('chat.codeBlockLabel', "Code block {0}", data.codeBlockIndex + 1),
428
});
429
}
430
this.layout(width);
431
this.toolbar.setAriaLabel(localize('chat.codeBlockToolbarLabel', "Code block {0}", data.codeBlockIndex + 1));
432
if (data.renderOptions?.hideToolbar) {
433
dom.hide(this.toolbar.getElement());
434
} else {
435
dom.show(this.toolbar.getElement());
436
}
437
438
if (data.vulns?.length && isResponseVM(data.element)) {
439
dom.clearNode(this.vulnsListElement);
440
this.element.classList.remove('no-vulns');
441
this.element.classList.toggle('chat-vulnerabilities-collapsed', !data.element.vulnerabilitiesListExpanded);
442
dom.append(this.vulnsListElement, ...data.vulns.map(v => $('li', undefined, $('span.chat-vuln-title', undefined, v.title), ' ' + v.description)));
443
this.vulnsButton.label = this.getVulnerabilitiesLabel();
444
} else {
445
this.element.classList.add('no-vulns');
446
}
447
}
448
449
reset() {
450
this.clearWidgets();
451
this.currentCodeBlockData = undefined;
452
}
453
454
private clearWidgets() {
455
ContentHoverController.get(this.editor)?.hideContentHover();
456
GlyphHoverController.get(this.editor)?.hideGlyphHover();
457
}
458
459
private async updateEditor(data: ICodeBlockData): Promise<boolean> {
460
const textModel = await data.textModel;
461
if (this.isDisposed || this.currentCodeBlockData !== data || textModel.isDisposed()) {
462
return false;
463
}
464
465
this.editor.setModel(textModel);
466
if (data.range) {
467
this.editor.setSelection(data.range);
468
this.editor.revealRangeInCenter(data.range, ScrollType.Immediate);
469
}
470
471
this.updateContexts(data);
472
473
return true;
474
}
475
476
private getVulnerabilitiesLabel(): string {
477
if (!this.currentCodeBlockData || !this.currentCodeBlockData.vulns) {
478
return '';
479
}
480
481
const referencesLabel = this.currentCodeBlockData.vulns.length > 1 ?
482
localize('vulnerabilitiesPlural', "{0} vulnerabilities", this.currentCodeBlockData.vulns.length) :
483
localize('vulnerabilitiesSingular', "{0} vulnerability", 1);
484
const icon = (element: IChatResponseViewModel) => element.vulnerabilitiesListExpanded ? Codicon.chevronDown : Codicon.chevronRight;
485
return `${referencesLabel} $(${icon(this.currentCodeBlockData.element as IChatResponseViewModel).id})`;
486
}
487
488
private updateContexts(data: ICodeBlockData) {
489
const textModel = this.editor.getModel();
490
if (!textModel) {
491
return;
492
}
493
494
this.toolbar.context = {
495
code: textModel.getTextBuffer().getValueInRange(data.range ?? textModel.getFullModelRange(), EndOfLinePreference.TextDefined),
496
codeBlockIndex: data.codeBlockIndex,
497
element: data.element,
498
languageId: textModel.getLanguageId(),
499
codemapperUri: data.codemapperUri,
500
chatSessionId: data.chatSessionId
501
} satisfies ICodeBlockActionContext;
502
this.resourceContextKey.set(textModel.uri);
503
}
504
}
505
506
export class ChatCodeBlockContentProvider extends Disposable implements ITextModelContentProvider {
507
508
constructor(
509
@ITextModelService textModelService: ITextModelService,
510
@IModelService private readonly _modelService: IModelService,
511
) {
512
super();
513
this._register(textModelService.registerTextModelContentProvider(Schemas.vscodeChatCodeBlock, this));
514
}
515
516
async provideTextContent(resource: URI): Promise<ITextModel | null> {
517
const existing = this._modelService.getModel(resource);
518
if (existing) {
519
return existing;
520
}
521
return this._modelService.createModel('', null, resource);
522
}
523
}
524
525
//
526
527
export interface ICodeCompareBlockActionContext {
528
readonly element: IChatResponseViewModel;
529
readonly diffEditor: IDiffEditor;
530
readonly edit: IChatTextEditGroup;
531
}
532
533
export interface ICodeCompareBlockDiffData {
534
modified: ITextModel;
535
original: ITextModel;
536
originalSha1: string;
537
}
538
539
export interface ICodeCompareBlockData {
540
readonly element: ChatTreeItem;
541
542
readonly edit: IChatTextEditGroup;
543
544
readonly diffData: Promise<ICodeCompareBlockDiffData | undefined>;
545
546
readonly parentContextKeyService?: IContextKeyService;
547
// readonly hideToolbar?: boolean;
548
}
549
550
551
// long-lived object that sits in the DiffPool and that gets reused
552
export class CodeCompareBlockPart extends Disposable {
553
protected readonly _onDidChangeContentHeight = this._register(new Emitter<void>());
554
public readonly onDidChangeContentHeight = this._onDidChangeContentHeight.event;
555
556
private readonly contextKeyService: IContextKeyService;
557
private readonly diffEditor: DiffEditorWidget;
558
private readonly resourceLabel: ResourceLabel;
559
private readonly toolbar: MenuWorkbenchToolBar;
560
readonly element: HTMLElement;
561
private readonly messageElement: HTMLElement;
562
563
private readonly _lastDiffEditorViewModel = this._store.add(new MutableDisposable());
564
private currentScrollWidth = 0;
565
566
constructor(
567
private readonly options: ChatEditorOptions,
568
readonly menuId: MenuId,
569
delegate: IChatRendererDelegate,
570
overflowWidgetsDomNode: HTMLElement | undefined,
571
private readonly isSimpleWidget: boolean = false,
572
@IInstantiationService instantiationService: IInstantiationService,
573
@IContextKeyService contextKeyService: IContextKeyService,
574
@IModelService protected readonly modelService: IModelService,
575
@IConfigurationService private readonly configurationService: IConfigurationService,
576
@IAccessibilityService private readonly accessibilityService: IAccessibilityService,
577
@ILabelService private readonly labelService: ILabelService,
578
@IOpenerService private readonly openerService: IOpenerService,
579
) {
580
super();
581
this.element = $('.interactive-result-code-block');
582
this.element.classList.add('compare');
583
584
this.messageElement = dom.append(this.element, $('.message'));
585
this.messageElement.setAttribute('role', 'status');
586
this.messageElement.tabIndex = 0;
587
588
this.contextKeyService = this._register(contextKeyService.createScoped(this.element));
589
const scopedInstantiationService = this._register(instantiationService.createChild(new ServiceCollection(
590
[IContextKeyService, this.contextKeyService],
591
[IEditorProgressService, new class implements IEditorProgressService {
592
_serviceBrand: undefined;
593
show(_total: unknown, _delay?: unknown) {
594
return emptyProgressRunner;
595
}
596
async showWhile(promise: Promise<unknown>, _delay?: number): Promise<void> {
597
await promise;
598
}
599
}],
600
)));
601
const editorHeader = dom.append(this.element, $('.interactive-result-header.show-file-icons'));
602
const editorElement = dom.append(this.element, $('.interactive-result-editor'));
603
this.diffEditor = this.createDiffEditor(scopedInstantiationService, editorElement, {
604
...getSimpleEditorOptions(this.configurationService),
605
lineNumbers: 'on',
606
selectOnLineNumbers: true,
607
scrollBeyondLastLine: false,
608
lineDecorationsWidth: 12,
609
dragAndDrop: false,
610
padding: { top: defaultCodeblockPadding, bottom: defaultCodeblockPadding },
611
mouseWheelZoom: false,
612
scrollbar: {
613
vertical: 'hidden',
614
alwaysConsumeMouseWheel: false
615
},
616
definitionLinkOpensInPeek: false,
617
gotoLocation: {
618
multiple: 'goto',
619
multipleDeclarations: 'goto',
620
multipleDefinitions: 'goto',
621
multipleImplementations: 'goto',
622
},
623
ariaLabel: localize('chat.codeBlockHelp', 'Code block'),
624
overflowWidgetsDomNode,
625
...this.getEditorOptionsFromConfig(),
626
});
627
628
this.resourceLabel = this._register(scopedInstantiationService.createInstance(ResourceLabel, editorHeader, { supportIcons: true }));
629
630
const editorScopedService = this.diffEditor.getModifiedEditor().contextKeyService.createScoped(editorHeader);
631
const editorScopedInstantiationService = this._register(scopedInstantiationService.createChild(new ServiceCollection([IContextKeyService, editorScopedService])));
632
this.toolbar = this._register(editorScopedInstantiationService.createInstance(MenuWorkbenchToolBar, editorHeader, menuId, {
633
menuOptions: {
634
shouldForwardArgs: true
635
}
636
}));
637
638
this._configureForScreenReader();
639
this._register(this.accessibilityService.onDidChangeScreenReaderOptimized(() => this._configureForScreenReader()));
640
this._register(this.configurationService.onDidChangeConfiguration((e) => {
641
if (e.affectedKeys.has(AccessibilityVerbositySettingId.Chat)) {
642
this._configureForScreenReader();
643
}
644
}));
645
646
this._register(this.options.onDidChange(() => {
647
this.diffEditor.updateOptions(this.getEditorOptionsFromConfig());
648
}));
649
650
this._register(this.diffEditor.getModifiedEditor().onDidScrollChange(e => {
651
this.currentScrollWidth = e.scrollWidth;
652
}));
653
this._register(this.diffEditor.onDidContentSizeChange(e => {
654
if (e.contentHeightChanged) {
655
this._onDidChangeContentHeight.fire();
656
}
657
}));
658
this._register(this.diffEditor.getModifiedEditor().onDidBlurEditorWidget(() => {
659
this.element.classList.remove('focused');
660
WordHighlighterContribution.get(this.diffEditor.getModifiedEditor())?.stopHighlighting();
661
this.clearWidgets();
662
}));
663
this._register(this.diffEditor.getModifiedEditor().onDidFocusEditorWidget(() => {
664
this.element.classList.add('focused');
665
WordHighlighterContribution.get(this.diffEditor.getModifiedEditor())?.restoreViewState(true);
666
}));
667
668
669
// Parent list scrolled
670
if (delegate.onDidScroll) {
671
this._register(delegate.onDidScroll(e => {
672
this.clearWidgets();
673
}));
674
}
675
}
676
677
get uri(): URI | undefined {
678
return this.diffEditor.getModifiedEditor().getModel()?.uri;
679
}
680
681
private createDiffEditor(instantiationService: IInstantiationService, parent: HTMLElement, options: Readonly<IEditorConstructionOptions>): DiffEditorWidget {
682
const widgetOptions: ICodeEditorWidgetOptions = {
683
isSimpleWidget: this.isSimpleWidget,
684
contributions: EditorExtensionsRegistry.getSomeEditorContributions([
685
MenuPreventer.ID,
686
SelectionClipboardContributionID,
687
ContextMenuController.ID,
688
689
WordHighlighterContribution.ID,
690
ViewportSemanticTokensContribution.ID,
691
BracketMatchingController.ID,
692
SmartSelectController.ID,
693
ContentHoverController.ID,
694
GlyphHoverController.ID,
695
GotoDefinitionAtPositionEditorContribution.ID,
696
])
697
};
698
699
return this._register(instantiationService.createInstance(DiffEditorWidget, parent, {
700
scrollbar: { useShadows: false, alwaysConsumeMouseWheel: false, ignoreHorizontalScrollbarInContentHeight: true, },
701
renderMarginRevertIcon: false,
702
diffCodeLens: false,
703
scrollBeyondLastLine: false,
704
stickyScroll: { enabled: false },
705
originalAriaLabel: localize('original', 'Original'),
706
modifiedAriaLabel: localize('modified', 'Modified'),
707
diffAlgorithm: 'advanced',
708
readOnly: false,
709
isInEmbeddedEditor: true,
710
useInlineViewWhenSpaceIsLimited: true,
711
experimental: {
712
useTrueInlineView: true,
713
},
714
renderSideBySideInlineBreakpoint: 300,
715
renderOverviewRuler: false,
716
compactMode: true,
717
hideUnchangedRegions: { enabled: true, contextLineCount: 1 },
718
renderGutterMenu: false,
719
lineNumbersMinChars: 1,
720
...options
721
}, { originalEditor: widgetOptions, modifiedEditor: widgetOptions }));
722
}
723
724
focus(): void {
725
this.diffEditor.focus();
726
}
727
728
private updatePaddingForLayout() {
729
// scrollWidth = "the width of the content that needs to be scrolled"
730
// contentWidth = "the width of the area where content is displayed"
731
const horizontalScrollbarVisible = this.currentScrollWidth > this.diffEditor.getModifiedEditor().getLayoutInfo().contentWidth;
732
const scrollbarHeight = this.diffEditor.getModifiedEditor().getLayoutInfo().horizontalScrollbarHeight;
733
const bottomPadding = horizontalScrollbarVisible ?
734
Math.max(defaultCodeblockPadding - scrollbarHeight, 2) :
735
defaultCodeblockPadding;
736
this.diffEditor.updateOptions({ padding: { top: defaultCodeblockPadding, bottom: bottomPadding } });
737
}
738
739
private _configureForScreenReader(): void {
740
const toolbarElt = this.toolbar.getElement();
741
if (this.accessibilityService.isScreenReaderOptimized()) {
742
toolbarElt.style.display = 'block';
743
toolbarElt.ariaLabel = localize('chat.codeBlock.toolbar', 'Code block toolbar');
744
} else {
745
toolbarElt.style.display = '';
746
}
747
}
748
749
private getEditorOptionsFromConfig(): IEditorOptions {
750
return {
751
wordWrap: this.options.configuration.resultEditor.wordWrap,
752
fontLigatures: this.options.configuration.resultEditor.fontLigatures,
753
bracketPairColorization: this.options.configuration.resultEditor.bracketPairColorization,
754
fontFamily: this.options.configuration.resultEditor.fontFamily === 'default' ?
755
EDITOR_FONT_DEFAULTS.fontFamily :
756
this.options.configuration.resultEditor.fontFamily,
757
fontSize: this.options.configuration.resultEditor.fontSize,
758
fontWeight: this.options.configuration.resultEditor.fontWeight,
759
lineHeight: this.options.configuration.resultEditor.lineHeight,
760
};
761
}
762
763
layout(width: number): void {
764
const editorBorder = 2;
765
766
const toolbar = dom.getTotalHeight(this.toolbar.getElement());
767
const content = this.diffEditor.getModel()
768
? this.diffEditor.getContentHeight()
769
: dom.getTotalHeight(this.messageElement);
770
771
const dimension = new dom.Dimension(width - editorBorder, toolbar + content);
772
this.element.style.height = `${dimension.height}px`;
773
this.element.style.width = `${dimension.width}px`;
774
this.diffEditor.layout(dimension.with(undefined, content - editorBorder));
775
this.updatePaddingForLayout();
776
}
777
778
779
async render(data: ICodeCompareBlockData, width: number, token: CancellationToken) {
780
if (data.parentContextKeyService) {
781
this.contextKeyService.updateParent(data.parentContextKeyService);
782
}
783
784
if (this.options.configuration.resultEditor.wordWrap === 'on') {
785
// Initialize the editor with the new proper width so that getContentHeight
786
// will be computed correctly in the next call to layout()
787
this.layout(width);
788
}
789
790
await this.updateEditor(data, token);
791
792
this.layout(width);
793
this.diffEditor.updateOptions({ ariaLabel: localize('chat.compareCodeBlockLabel', "Code Edits") });
794
795
this.resourceLabel.element.setFile(data.edit.uri, {
796
fileKind: FileKind.FILE,
797
fileDecorations: { colors: true, badges: false }
798
});
799
}
800
801
reset() {
802
this.clearWidgets();
803
}
804
805
private clearWidgets() {
806
ContentHoverController.get(this.diffEditor.getOriginalEditor())?.hideContentHover();
807
ContentHoverController.get(this.diffEditor.getModifiedEditor())?.hideContentHover();
808
GlyphHoverController.get(this.diffEditor.getOriginalEditor())?.hideGlyphHover();
809
GlyphHoverController.get(this.diffEditor.getModifiedEditor())?.hideGlyphHover();
810
}
811
812
private async updateEditor(data: ICodeCompareBlockData, token: CancellationToken): Promise<void> {
813
814
if (!isResponseVM(data.element)) {
815
return;
816
}
817
818
const isEditApplied = Boolean(data.edit.state?.applied ?? 0);
819
820
ChatContextKeys.editApplied.bindTo(this.contextKeyService).set(isEditApplied);
821
822
this.element.classList.toggle('no-diff', isEditApplied);
823
824
if (isEditApplied) {
825
assertType(data.edit.state?.applied);
826
827
const uriLabel = this.labelService.getUriLabel(data.edit.uri, { relative: true, noPrefix: true });
828
829
let template: string;
830
if (data.edit.state.applied === 1) {
831
template = localize('chat.edits.1', "Applied 1 change in [[``{0}``]]", uriLabel);
832
} else if (data.edit.state.applied < 0) {
833
template = localize('chat.edits.rejected', "Edits in [[``{0}``]] have been rejected", uriLabel);
834
} else {
835
template = localize('chat.edits.N', "Applied {0} changes in [[``{1}``]]", data.edit.state.applied, uriLabel);
836
}
837
838
const message = renderFormattedText(template, {
839
renderCodeSegments: true,
840
actionHandler: {
841
callback: () => {
842
this.openerService.open(data.edit.uri, { fromUserGesture: true, allowCommands: false });
843
},
844
disposables: this._store,
845
}
846
});
847
848
dom.reset(this.messageElement, message);
849
}
850
851
const diffData = await data.diffData;
852
853
if (!isEditApplied && diffData) {
854
const viewModel = this.diffEditor.createViewModel({
855
original: diffData.original,
856
modified: diffData.modified
857
});
858
859
await viewModel.waitForDiff();
860
861
if (token.isCancellationRequested) {
862
return;
863
}
864
865
const listener = Event.any(diffData.original.onWillDispose, diffData.modified.onWillDispose)(() => {
866
// this a bit weird and basically duplicates https://github.com/microsoft/vscode/blob/7cbcafcbcc88298cfdcd0238018fbbba8eb6853e/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts#L328
867
// which cannot call `setModel(null)` without first complaining
868
this.diffEditor.setModel(null);
869
});
870
this.diffEditor.setModel(viewModel);
871
this._lastDiffEditorViewModel.value = combinedDisposable(listener, viewModel);
872
873
} else {
874
this.diffEditor.setModel(null);
875
this._lastDiffEditorViewModel.value = undefined;
876
this._onDidChangeContentHeight.fire();
877
}
878
879
this.toolbar.context = {
880
edit: data.edit,
881
element: data.element,
882
diffEditor: this.diffEditor,
883
} satisfies ICodeCompareBlockActionContext;
884
}
885
}
886
887
export class DefaultChatTextEditor {
888
889
private readonly _sha1 = new DefaultModelSHA1Computer();
890
891
constructor(
892
@ITextModelService private readonly modelService: ITextModelService,
893
@ICodeEditorService private readonly editorService: ICodeEditorService,
894
@IDialogService private readonly dialogService: IDialogService,
895
) { }
896
897
async apply(response: IChatResponseModel | IChatResponseViewModel, item: IChatTextEditGroup, diffEditor: IDiffEditor | undefined): Promise<void> {
898
899
if (!response.response.value.includes(item)) {
900
// bogous item
901
return;
902
}
903
904
if (item.state?.applied) {
905
// already applied
906
return;
907
}
908
909
if (!diffEditor) {
910
for (const candidate of this.editorService.listDiffEditors()) {
911
if (!candidate.getContainerDomNode().isConnected) {
912
continue;
913
}
914
const model = candidate.getModel();
915
if (!model || !isEqual(model.original.uri, item.uri) || model.modified.uri.scheme !== Schemas.vscodeChatCodeCompareBlock) {
916
diffEditor = candidate;
917
break;
918
}
919
}
920
}
921
922
const edits = diffEditor
923
? await this._applyWithDiffEditor(diffEditor, item)
924
: await this._apply(item);
925
926
response.setEditApplied(item, edits);
927
}
928
929
private async _applyWithDiffEditor(diffEditor: IDiffEditor, item: IChatTextEditGroup) {
930
const model = diffEditor.getModel();
931
if (!model) {
932
return 0;
933
}
934
935
const diff = diffEditor.getDiffComputationResult();
936
if (!diff || diff.identical) {
937
return 0;
938
}
939
940
941
if (!await this._checkSha1(model.original, item)) {
942
return 0;
943
}
944
945
const modified = new TextModelText(model.modified);
946
const edits = diff.changes2.map(i => i.toRangeMapping().toTextEdit(modified).toSingleEditOperation());
947
948
model.original.pushStackElement();
949
model.original.pushEditOperations(null, edits, () => null);
950
model.original.pushStackElement();
951
952
return edits.length;
953
}
954
955
private async _apply(item: IChatTextEditGroup) {
956
const ref = await this.modelService.createModelReference(item.uri);
957
try {
958
959
if (!await this._checkSha1(ref.object.textEditorModel, item)) {
960
return 0;
961
}
962
963
ref.object.textEditorModel.pushStackElement();
964
let total = 0;
965
for (const group of item.edits) {
966
const edits = group.map(TextEdit.asEditOperation);
967
ref.object.textEditorModel.pushEditOperations(null, edits, () => null);
968
total += edits.length;
969
}
970
ref.object.textEditorModel.pushStackElement();
971
return total;
972
973
} finally {
974
ref.dispose();
975
}
976
}
977
978
private async _checkSha1(model: ITextModel, item: IChatTextEditGroup) {
979
if (item.state?.sha1 && this._sha1.computeSHA1(model) && this._sha1.computeSHA1(model) !== item.state.sha1) {
980
const result = await this.dialogService.confirm({
981
message: localize('interactive.compare.apply.confirm', "The original file has been modified."),
982
detail: localize('interactive.compare.apply.confirm.detail', "Do you want to apply the changes anyway?"),
983
});
984
985
if (!result.confirmed) {
986
return false;
987
}
988
}
989
return true;
990
}
991
992
discard(response: IChatResponseModel | IChatResponseViewModel, item: IChatTextEditGroup) {
993
if (!response.response.value.includes(item)) {
994
// bogous item
995
return;
996
}
997
998
if (item.state?.applied) {
999
// already applied
1000
return;
1001
}
1002
1003
response.setEditApplied(item, -1);
1004
}
1005
1006
1007
}
1008
1009