Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.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 * as dom from '../../../../../base/browser/dom.js';
7
import { IListRenderer, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js';
8
import { IListOptions } from '../../../../../base/browser/ui/list/listWidget.js';
9
import { coalesce } from '../../../../../base/common/arrays.js';
10
import { Codicon } from '../../../../../base/common/codicons.js';
11
import { Event } from '../../../../../base/common/event.js';
12
import { IMarkdownString } from '../../../../../base/common/htmlContent.js';
13
import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';
14
import { matchesSomeScheme, Schemas } from '../../../../../base/common/network.js';
15
import { basename } from '../../../../../base/common/path.js';
16
import { basenameOrAuthority, isEqualAuthority } from '../../../../../base/common/resources.js';
17
import { ThemeIcon } from '../../../../../base/common/themables.js';
18
import { URI } from '../../../../../base/common/uri.js';
19
import { IRange } from '../../../../../editor/common/core/range.js';
20
import { localize, localize2 } from '../../../../../nls.js';
21
import { getFlatContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js';
22
import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js';
23
import { Action2, IMenuService, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js';
24
import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js';
25
import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
26
import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js';
27
import { FileKind } from '../../../../../platform/files/common/files.js';
28
import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';
29
import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';
30
import { ILabelService } from '../../../../../platform/label/common/label.js';
31
import { WorkbenchList } from '../../../../../platform/list/browser/listService.js';
32
import { IOpenerService } from '../../../../../platform/opener/common/opener.js';
33
import { IProductService } from '../../../../../platform/product/common/productService.js';
34
import { IThemeService } from '../../../../../platform/theme/common/themeService.js';
35
import { fillEditorsDragData } from '../../../../browser/dnd.js';
36
import { IResourceLabel, IResourceLabelProps, ResourceLabels } from '../../../../browser/labels.js';
37
import { ColorScheme } from '../../../../browser/web.api.js';
38
import { ResourceContextKey } from '../../../../common/contextkeys.js';
39
import { SETTINGS_AUTHORITY } from '../../../../services/preferences/common/preferences.js';
40
import { createFileIconThemableTreeContainerScope } from '../../../files/browser/views/explorerView.js';
41
import { ExplorerFolderContext } from '../../../files/common/files.js';
42
import { chatEditingWidgetFileStateContextKey, ModifiedFileEntryState } from '../../common/chatEditingService.js';
43
import { ChatResponseReferencePartStatusKind, IChatContentReference, IChatWarningMessage } from '../../common/chatService.js';
44
import { IChatRendererContent, IChatResponseViewModel } from '../../common/chatViewModel.js';
45
import { ChatTreeItem, IChatWidgetService } from '../chat.js';
46
import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js';
47
import { IDisposableReference, ResourcePool } from './chatCollections.js';
48
import { IChatContentPartRenderContext } from './chatContentParts.js';
49
50
const $ = dom.$;
51
52
export interface IChatReferenceListItem extends IChatContentReference {
53
title?: string;
54
description?: string;
55
state?: ModifiedFileEntryState;
56
excluded?: boolean;
57
}
58
59
export type IChatCollapsibleListItem = IChatReferenceListItem | IChatWarningMessage;
60
61
export class ChatCollapsibleListContentPart extends ChatCollapsibleContentPart {
62
63
constructor(
64
private readonly data: ReadonlyArray<IChatCollapsibleListItem>,
65
labelOverride: IMarkdownString | string | undefined,
66
context: IChatContentPartRenderContext,
67
private readonly contentReferencesListPool: CollapsibleListPool,
68
@IOpenerService private readonly openerService: IOpenerService,
69
@IMenuService private readonly menuService: IMenuService,
70
@IInstantiationService private readonly instantiationService: IInstantiationService,
71
@IContextMenuService private readonly contextMenuService: IContextMenuService,
72
) {
73
super(labelOverride ?? (data.length > 1 ?
74
localize('usedReferencesPlural', "Used {0} references", data.length) :
75
localize('usedReferencesSingular', "Used {0} reference", 1)), context);
76
}
77
78
protected override initContent(): HTMLElement {
79
const ref = this._register(this.contentReferencesListPool.get());
80
const list = ref.object;
81
82
this._register(list.onDidOpen((e) => {
83
if (e.element && 'reference' in e.element && typeof e.element.reference === 'object') {
84
const uriOrLocation = 'variableName' in e.element.reference ? e.element.reference.value : e.element.reference;
85
const uri = URI.isUri(uriOrLocation) ? uriOrLocation :
86
uriOrLocation?.uri;
87
if (uri) {
88
this.openerService.open(
89
uri,
90
{
91
fromUserGesture: true,
92
editorOptions: {
93
...e.editorOptions,
94
...{
95
selection: uriOrLocation && 'range' in uriOrLocation ? uriOrLocation.range : undefined
96
}
97
}
98
});
99
}
100
}
101
}));
102
103
this._register(list.onContextMenu(e => {
104
dom.EventHelper.stop(e.browserEvent, true);
105
106
const uri = e.element && getResourceForElement(e.element);
107
if (!uri) {
108
return;
109
}
110
111
this.contextMenuService.showContextMenu({
112
getAnchor: () => e.anchor,
113
getActions: () => {
114
const menu = this.menuService.getMenuActions(MenuId.ChatAttachmentsContext, list.contextKeyService, { shouldForwardArgs: true, arg: uri });
115
return getFlatContextMenuActions(menu);
116
}
117
});
118
}));
119
120
const resourceContextKey = this._register(this.instantiationService.createInstance(ResourceContextKey));
121
this._register(list.onDidChangeFocus(e => {
122
resourceContextKey.reset();
123
const element = e.elements.length ? e.elements[0] : undefined;
124
const uri = element && getResourceForElement(element);
125
resourceContextKey.set(uri ?? null);
126
}));
127
128
const maxItemsShown = 6;
129
const itemsShown = Math.min(this.data.length, maxItemsShown);
130
const height = itemsShown * 22;
131
list.layout(height);
132
list.getHTMLElement().style.height = `${height}px`;
133
list.splice(0, list.length, this.data);
134
135
return list.getHTMLElement().parentElement!;
136
}
137
138
hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean {
139
return other.kind === 'references' && other.references.length === this.data.length && (!!followingContent.length === this.hasFollowingContent);
140
}
141
}
142
143
export interface IChatUsedReferencesListOptions {
144
expandedWhenEmptyResponse?: boolean;
145
}
146
147
export class ChatUsedReferencesListContentPart extends ChatCollapsibleListContentPart {
148
constructor(
149
data: ReadonlyArray<IChatCollapsibleListItem>,
150
labelOverride: IMarkdownString | string | undefined,
151
context: IChatContentPartRenderContext,
152
contentReferencesListPool: CollapsibleListPool,
153
private readonly options: IChatUsedReferencesListOptions,
154
@IOpenerService openerService: IOpenerService,
155
@IMenuService menuService: IMenuService,
156
@IInstantiationService instantiationService: IInstantiationService,
157
@IContextMenuService contextMenuService: IContextMenuService,
158
) {
159
super(data, labelOverride, context, contentReferencesListPool, openerService, menuService, instantiationService, contextMenuService);
160
if (data.length === 0) {
161
dom.hide(this.domNode);
162
}
163
}
164
165
protected override isExpanded(): boolean {
166
const element = this.context.element as IChatResponseViewModel;
167
return element.usedReferencesExpanded ?? !!(
168
this.options.expandedWhenEmptyResponse && element.response.value.length === 0
169
);
170
}
171
172
protected override setExpanded(value: boolean): void {
173
const element = this.context.element as IChatResponseViewModel;
174
element.usedReferencesExpanded = !this.isExpanded();
175
}
176
}
177
178
export class CollapsibleListPool extends Disposable {
179
private _pool: ResourcePool<WorkbenchList<IChatCollapsibleListItem>>;
180
181
public get inUse(): ReadonlySet<WorkbenchList<IChatCollapsibleListItem>> {
182
return this._pool.inUse;
183
}
184
185
constructor(
186
private _onDidChangeVisibility: Event<boolean>,
187
private readonly menuId: MenuId | undefined,
188
private readonly listOptions: IListOptions<IChatCollapsibleListItem> | undefined,
189
@IInstantiationService private readonly instantiationService: IInstantiationService,
190
@IThemeService private readonly themeService: IThemeService,
191
@ILabelService private readonly labelService: ILabelService,
192
) {
193
super();
194
this._pool = this._register(new ResourcePool(() => this.listFactory()));
195
}
196
197
private listFactory(): WorkbenchList<IChatCollapsibleListItem> {
198
const resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility }));
199
200
const container = $('.chat-used-context-list');
201
this._register(createFileIconThemableTreeContainerScope(container, this.themeService));
202
203
const list = this.instantiationService.createInstance(
204
WorkbenchList<IChatCollapsibleListItem>,
205
'ChatListRenderer',
206
container,
207
new CollapsibleListDelegate(),
208
[this.instantiationService.createInstance(CollapsibleListRenderer, resourceLabels, this.menuId)],
209
{
210
...this.listOptions,
211
alwaysConsumeMouseWheel: false,
212
accessibilityProvider: {
213
getAriaLabel: (element: IChatCollapsibleListItem) => {
214
if (element.kind === 'warning') {
215
return element.content.value;
216
}
217
const reference = element.reference;
218
if (typeof reference === 'string') {
219
return reference;
220
} else if ('variableName' in reference) {
221
return reference.variableName;
222
} else if (URI.isUri(reference)) {
223
return basename(reference.path);
224
} else {
225
return basename(reference.uri.path);
226
}
227
},
228
229
getWidgetAriaLabel: () => localize('chatCollapsibleList', "Collapsible Chat References List")
230
},
231
dnd: {
232
getDragURI: (element: IChatCollapsibleListItem) => getResourceForElement(element)?.toString() ?? null,
233
getDragLabel: (elements, originalEvent) => {
234
const uris: URI[] = coalesce(elements.map(getResourceForElement));
235
if (!uris.length) {
236
return undefined;
237
} else if (uris.length === 1) {
238
return this.labelService.getUriLabel(uris[0], { relative: true });
239
} else {
240
return `${uris.length}`;
241
}
242
},
243
dispose: () => { },
244
onDragOver: () => false,
245
drop: () => { },
246
onDragStart: (data, originalEvent) => {
247
try {
248
const elements = data.getData() as IChatCollapsibleListItem[];
249
const uris: URI[] = coalesce(elements.map(getResourceForElement));
250
this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, uris, originalEvent));
251
} catch {
252
// noop
253
}
254
},
255
},
256
});
257
258
return list;
259
}
260
261
get(): IDisposableReference<WorkbenchList<IChatCollapsibleListItem>> {
262
const object = this._pool.get();
263
let stale = false;
264
return {
265
object,
266
isStale: () => stale,
267
dispose: () => {
268
stale = true;
269
this._pool.release(object);
270
}
271
};
272
}
273
}
274
275
class CollapsibleListDelegate implements IListVirtualDelegate<IChatCollapsibleListItem> {
276
getHeight(element: IChatCollapsibleListItem): number {
277
return 22;
278
}
279
280
getTemplateId(element: IChatCollapsibleListItem): string {
281
return CollapsibleListRenderer.TEMPLATE_ID;
282
}
283
}
284
285
interface ICollapsibleListTemplate {
286
readonly contextKeyService?: IContextKeyService;
287
readonly label: IResourceLabel;
288
readonly templateDisposables: DisposableStore;
289
toolbar: MenuWorkbenchToolBar | undefined;
290
actionBarContainer?: HTMLElement;
291
fileDiffsContainer?: HTMLElement;
292
addedSpan?: HTMLElement;
293
removedSpan?: HTMLElement;
294
}
295
296
class CollapsibleListRenderer implements IListRenderer<IChatCollapsibleListItem, ICollapsibleListTemplate> {
297
static TEMPLATE_ID = 'chatCollapsibleListRenderer';
298
readonly templateId: string = CollapsibleListRenderer.TEMPLATE_ID;
299
300
constructor(
301
private labels: ResourceLabels,
302
private menuId: MenuId | undefined,
303
@IThemeService private readonly themeService: IThemeService,
304
@IProductService private readonly productService: IProductService,
305
@IInstantiationService private readonly instantiationService: IInstantiationService,
306
@IContextKeyService private readonly contextKeyService: IContextKeyService,
307
) { }
308
309
renderTemplate(container: HTMLElement): ICollapsibleListTemplate {
310
const templateDisposables = new DisposableStore();
311
const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true }));
312
313
const fileDiffsContainer = $('.working-set-line-counts');
314
const addedSpan = dom.$('.working-set-lines-added');
315
const removedSpan = dom.$('.working-set-lines-removed');
316
fileDiffsContainer.appendChild(addedSpan);
317
fileDiffsContainer.appendChild(removedSpan);
318
label.element.appendChild(fileDiffsContainer);
319
320
let toolbar;
321
let actionBarContainer;
322
let contextKeyService;
323
if (this.menuId) {
324
actionBarContainer = $('.chat-collapsible-list-action-bar');
325
contextKeyService = templateDisposables.add(this.contextKeyService.createScoped(actionBarContainer));
326
const scopedInstantiationService = templateDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService])));
327
toolbar = templateDisposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, this.menuId, { menuOptions: { shouldForwardArgs: true, arg: undefined } }));
328
label.element.appendChild(actionBarContainer);
329
}
330
331
return { templateDisposables, label, toolbar, actionBarContainer, contextKeyService, fileDiffsContainer, addedSpan, removedSpan };
332
}
333
334
335
private getReferenceIcon(data: IChatContentReference): URI | ThemeIcon | undefined {
336
if (ThemeIcon.isThemeIcon(data.iconPath)) {
337
return data.iconPath;
338
} else {
339
return this.themeService.getColorTheme().type === ColorScheme.DARK && data.iconPath?.dark
340
? data.iconPath?.dark
341
: data.iconPath?.light;
342
}
343
}
344
345
renderElement(data: IChatCollapsibleListItem, index: number, templateData: ICollapsibleListTemplate): void {
346
if (data.kind === 'warning') {
347
templateData.label.setResource({ name: data.content.value }, { icon: Codicon.warning });
348
return;
349
}
350
351
const reference = data.reference;
352
const icon = this.getReferenceIcon(data);
353
templateData.label.element.style.display = 'flex';
354
let arg: URI | undefined;
355
if (typeof reference === 'object' && 'variableName' in reference) {
356
if (reference.value) {
357
const uri = URI.isUri(reference.value) ? reference.value : reference.value.uri;
358
templateData.label.setResource(
359
{
360
resource: uri,
361
name: basenameOrAuthority(uri),
362
description: `#${reference.variableName}`,
363
range: 'range' in reference.value ? reference.value.range : undefined,
364
}, { icon, title: data.options?.status?.description ?? data.title });
365
} else if (reference.variableName.startsWith('kernelVariable')) {
366
const variable = reference.variableName.split(':')[1];
367
const asVariableName = `${variable}`;
368
const label = `Kernel variable`;
369
templateData.label.setLabel(label, asVariableName, { title: data.options?.status?.description });
370
} else {
371
// Nothing else is expected to fall into here
372
templateData.label.setLabel('Unknown variable type');
373
}
374
} else if (typeof reference === 'string') {
375
templateData.label.setLabel(reference, undefined, { iconPath: URI.isUri(icon) ? icon : undefined, title: data.options?.status?.description ?? data.title });
376
377
} else {
378
const uri = 'uri' in reference ? reference.uri : reference;
379
arg = uri;
380
const extraClasses = data.excluded ? ['excluded'] : [];
381
if (uri.scheme === 'https' && isEqualAuthority(uri.authority, 'github.com') && uri.path.includes('/tree/')) {
382
// Parse a nicer label for GitHub URIs that point at a particular commit + file
383
templateData.label.setResource(getResourceLabelForGithubUri(uri), { icon: Codicon.github, title: data.title, strikethrough: data.excluded, extraClasses });
384
} else if (uri.scheme === this.productService.urlProtocol && isEqualAuthority(uri.authority, SETTINGS_AUTHORITY)) {
385
// a nicer label for settings URIs
386
const settingId = uri.path.substring(1);
387
templateData.label.setResource({ resource: uri, name: settingId }, { icon: Codicon.settingsGear, title: localize('setting.hover', "Open setting '{0}'", settingId), strikethrough: data.excluded, extraClasses });
388
} else if (matchesSomeScheme(uri, Schemas.mailto, Schemas.http, Schemas.https)) {
389
templateData.label.setResource({ resource: uri, name: uri.toString() }, { icon: icon ?? Codicon.globe, title: data.options?.status?.description ?? data.title ?? uri.toString(), strikethrough: data.excluded, extraClasses });
390
} else {
391
templateData.label.setFile(uri, {
392
fileKind: FileKind.FILE,
393
// Should not have this live-updating data on a historical reference
394
fileDecorations: undefined,
395
range: 'range' in reference ? reference.range : undefined,
396
title: data.options?.status?.description ?? data.title,
397
strikethrough: data.excluded,
398
extraClasses
399
});
400
}
401
}
402
403
for (const selector of ['.monaco-icon-suffix-container', '.monaco-icon-name-container']) {
404
const element = templateData.label.element.querySelector(selector);
405
if (element) {
406
if (data.options?.status?.kind === ChatResponseReferencePartStatusKind.Omitted || data.options?.status?.kind === ChatResponseReferencePartStatusKind.Partial) {
407
element.classList.add('warning');
408
} else {
409
element.classList.remove('warning');
410
}
411
}
412
}
413
414
if (data.state !== undefined) {
415
if (templateData.actionBarContainer) {
416
if (data.state === ModifiedFileEntryState.Modified && !templateData.actionBarContainer.classList.contains('modified')) {
417
const diffMeta = data?.options?.diffMeta;
418
if (diffMeta) {
419
if (!templateData.fileDiffsContainer || !templateData.addedSpan || !templateData.removedSpan) {
420
return;
421
}
422
templateData.addedSpan.textContent = `+${diffMeta.added}`;
423
templateData.removedSpan.textContent = `-${diffMeta.removed}`;
424
templateData.fileDiffsContainer.setAttribute('aria-label', localize('chatEditingSession.fileCounts', '{0} lines added, {1} lines removed', diffMeta.added, diffMeta.removed));
425
}
426
templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.add('modified');
427
} else if (data.state !== ModifiedFileEntryState.Modified) {
428
templateData.actionBarContainer.classList.remove('modified');
429
templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.remove('modified');
430
}
431
}
432
if (templateData.toolbar) {
433
templateData.toolbar.context = arg;
434
}
435
if (templateData.contextKeyService) {
436
if (data.state !== undefined) {
437
chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(data.state);
438
}
439
}
440
}
441
}
442
443
disposeTemplate(templateData: ICollapsibleListTemplate): void {
444
templateData.templateDisposables.dispose();
445
}
446
}
447
448
function getResourceLabelForGithubUri(uri: URI): IResourceLabelProps {
449
const repoPath = uri.path.split('/').slice(1, 3).join('/');
450
const filePath = uri.path.split('/').slice(5);
451
const fileName = filePath.at(-1);
452
const range = getLineRangeFromGithubUri(uri);
453
return {
454
resource: uri,
455
name: fileName ?? filePath.join('/'),
456
description: [repoPath, ...filePath.slice(0, -1)].join('/'),
457
range
458
};
459
}
460
461
function getLineRangeFromGithubUri(uri: URI): IRange | undefined {
462
if (!uri.fragment) {
463
return undefined;
464
}
465
466
// Extract the line range from the fragment
467
// Github line ranges are 1-based
468
const match = uri.fragment.match(/\bL(\d+)(?:-L(\d+))?/);
469
if (!match) {
470
return undefined;
471
}
472
473
const startLine = parseInt(match[1]);
474
if (isNaN(startLine)) {
475
return undefined;
476
}
477
478
const endLine = match[2] ? parseInt(match[2]) : startLine;
479
if (isNaN(endLine)) {
480
return undefined;
481
}
482
483
return {
484
startLineNumber: startLine,
485
startColumn: 1,
486
endLineNumber: endLine,
487
endColumn: 1
488
};
489
}
490
491
function getResourceForElement(element: IChatCollapsibleListItem): URI | null {
492
if (element.kind === 'warning') {
493
return null;
494
}
495
const { reference } = element;
496
if (typeof reference === 'string' || 'variableName' in reference) {
497
return null;
498
} else if (URI.isUri(reference)) {
499
return reference;
500
} else {
501
return reference.uri;
502
}
503
}
504
505
//#region Resource context menu
506
507
registerAction2(class AddToChatAction extends Action2 {
508
509
static readonly id = 'workbench.action.chat.addToChatAction';
510
511
constructor() {
512
super({
513
id: AddToChatAction.id,
514
title: {
515
...localize2('addToChat', "Add File to Chat"),
516
},
517
f1: false,
518
menu: [{
519
id: MenuId.ChatAttachmentsContext,
520
group: 'chat',
521
order: 1,
522
when: ContextKeyExpr.and(ResourceContextKey.IsFileSystemResource, ExplorerFolderContext.negate()),
523
}]
524
});
525
}
526
527
override async run(accessor: ServicesAccessor, resource: URI): Promise<void> {
528
const chatWidgetService = accessor.get(IChatWidgetService);
529
if (!resource) {
530
return;
531
}
532
533
const widget = chatWidgetService.lastFocusedWidget;
534
if (widget) {
535
widget.attachmentModel.addFile(resource);
536
}
537
}
538
});
539
540
registerAction2(class OpenChatReferenceLinkAction extends Action2 {
541
542
static readonly id = 'workbench.action.chat.copyLink';
543
544
constructor() {
545
super({
546
id: OpenChatReferenceLinkAction.id,
547
title: {
548
...localize2('copyLink', "Copy Link"),
549
},
550
f1: false,
551
menu: [{
552
id: MenuId.ChatAttachmentsContext,
553
group: 'chat',
554
order: 0,
555
when: ContextKeyExpr.or(ResourceContextKey.Scheme.isEqualTo(Schemas.http), ResourceContextKey.Scheme.isEqualTo(Schemas.https)),
556
}]
557
});
558
}
559
560
override async run(accessor: ServicesAccessor, resource: URI): Promise<void> {
561
await accessor.get(IClipboardService).writeResources([resource]);
562
}
563
});
564
565
//#endregion
566
567