Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts
13401 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 { DragAndDropObserver } from '../../../../base/browser/dom.js';
8
import { Codicon } from '../../../../base/common/codicons.js';
9
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
10
import { URI } from '../../../../base/common/uri.js';
11
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
12
import { Emitter } from '../../../../base/common/event.js';
13
import { renderIcon, renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
14
import { localize } from '../../../../nls.js';
15
import { ThemeIcon } from '../../../../base/common/themables.js';
16
import { registerOpenEditorListeners } from '../../../../platform/editor/browser/editor.js';
17
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
18
import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js';
19
import { IChatImageCarouselService } from '../../../../workbench/contrib/chat/browser/chatImageCarouselService.js';
20
import { coerceImageBuffer } from '../../../../workbench/contrib/chat/common/chatImageExtraction.js';
21
22
import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js';
23
import { ITextModelService } from '../../../../editor/common/services/resolverService.js';
24
import { FileKind, IFileService } from '../../../../platform/files/common/files.js';
25
import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js';
26
import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';
27
import { ILabelService } from '../../../../platform/label/common/label.js';
28
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
29
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
30
import { IModelService } from '../../../../editor/common/services/model.js';
31
import { ILanguageService } from '../../../../editor/common/languages/language.js';
32
import { getIconClasses } from '../../../../editor/common/services/getIconClasses.js';
33
import { basename } from '../../../../base/common/resources.js';
34
import { Schemas } from '../../../../base/common/network.js';
35
import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../workbench/browser/labels.js';
36
37
import { IChatRequestVariableEntry, OmittedState } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js';
38
import { isLocation } from '../../../../editor/common/languages.js';
39
import { resizeImage } from '../../../../workbench/contrib/chat/browser/chatImageUtils.js';
40
import { imageToHash, isImage } from '../../../../workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.js';
41
import { CodeDataTransfers, containsDragType, extractEditorsDropData, getPathForFile } from '../../../../platform/dnd/browser/dnd.js';
42
import { DataTransfers } from '../../../../base/browser/dnd.js';
43
import { getExcludes, ISearchConfiguration, ISearchService, QueryType } from '../../../../workbench/services/search/common/search.js';
44
45
/**
46
* Manages context attachments for the sessions new-chat widget.
47
*
48
* Supports:
49
* - File picker via quick access ("Files and Open Folders...")
50
* - Image from Clipboard
51
* - Drag and drop files
52
* - Paste images from clipboard (Ctrl/Cmd+V)
53
*/
54
export class NewChatContextAttachments extends Disposable {
55
56
private readonly _attachedContext: IChatRequestVariableEntry[] = [];
57
private _container: HTMLElement | undefined;
58
private readonly _renderDisposables = this._register(new DisposableStore());
59
60
private readonly _onDidChangeContext = this._register(new Emitter<void>());
61
readonly onDidChangeContext = this._onDidChangeContext.event;
62
63
get attachments(): readonly IChatRequestVariableEntry[] {
64
return this._attachedContext;
65
}
66
67
setAttachments(entries: readonly IChatRequestVariableEntry[]): void {
68
this._attachedContext.length = 0;
69
this._attachedContext.push(...entries);
70
this._updateRendering();
71
this._onDidChangeContext.fire();
72
}
73
74
private readonly _resourceLabels: ResourceLabels;
75
76
constructor(
77
@IQuickInputService private readonly quickInputService: IQuickInputService,
78
@ITextModelService private readonly textModelService: ITextModelService,
79
@IFileService private readonly fileService: IFileService,
80
@IClipboardService private readonly clipboardService: IClipboardService,
81
@IFileDialogService private readonly fileDialogService: IFileDialogService,
82
@ILabelService private readonly labelService: ILabelService,
83
@ISearchService private readonly searchService: ISearchService,
84
@IConfigurationService private readonly configurationService: IConfigurationService,
85
@IOpenerService private readonly openerService: IOpenerService,
86
@IInstantiationService private readonly instantiationService: IInstantiationService,
87
@IModelService private readonly modelService: IModelService,
88
@ILanguageService private readonly languageService: ILanguageService,
89
@IChatImageCarouselService private readonly chatImageCarouselService: IChatImageCarouselService,
90
) {
91
super();
92
this._resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER));
93
}
94
95
// --- Rendering ---
96
97
renderAttachedContext(container: HTMLElement): void {
98
this._container = container;
99
this._updateRendering();
100
}
101
102
private _updateRendering(): void {
103
if (!this._container) {
104
return;
105
}
106
107
this._renderDisposables.clear();
108
this._resourceLabels.clear();
109
dom.clearNode(this._container);
110
111
if (this._attachedContext.length === 0) {
112
this._container.style.display = 'none';
113
return;
114
}
115
116
this._container.style.display = '';
117
this._container.classList.add('show-file-icons');
118
119
for (const entry of this._attachedContext) {
120
const pill = dom.append(this._container, dom.$('.sessions-chat-attachment-pill'));
121
pill.tabIndex = 0;
122
pill.role = 'button';
123
const resource = URI.isUri(entry.value) ? entry.value : isLocation(entry.value) ? entry.value.uri : undefined;
124
if (entry.kind === 'image') {
125
dom.append(pill, renderIcon(Codicon.fileMedia));
126
dom.append(pill, dom.$('span.sessions-chat-attachment-name', undefined, entry.name));
127
} else {
128
const label = this._resourceLabels.create(pill, { supportIcons: true });
129
this._renderDisposables.add(label);
130
if (resource) {
131
label.setFile(resource, {
132
fileKind: entry.kind === 'directory' ? FileKind.FOLDER : FileKind.FILE,
133
hidePath: true,
134
});
135
} else {
136
label.setLabel(entry.name);
137
}
138
}
139
140
// Click to open the resource or image
141
const imageData = entry.kind === 'image' ? coerceImageBuffer(entry.value) : undefined;
142
if (imageData) {
143
pill.style.cursor = 'pointer';
144
this._renderDisposables.add(registerOpenEditorListeners(pill, async () => {
145
if (this.configurationService.getValue<boolean>(ChatConfiguration.ImageCarouselEnabled)) {
146
const imageResource = resource ?? URI.from({ scheme: 'data', path: entry.name });
147
await this.chatImageCarouselService.openCarouselAtResource(imageResource, imageData);
148
} else if (resource) {
149
await this.openerService.open(resource, { fromUserGesture: true });
150
}
151
}));
152
} else if (resource) {
153
pill.style.cursor = 'pointer';
154
this._renderDisposables.add(registerOpenEditorListeners(pill, async () => {
155
await this.openerService.open(resource, { fromUserGesture: true });
156
}));
157
}
158
159
const removeButton = dom.append(pill, dom.$('.sessions-chat-attachment-remove'));
160
removeButton.title = localize('removeAttachment', "Remove");
161
removeButton.tabIndex = -1;
162
dom.append(removeButton, renderIcon(Codicon.close));
163
this._renderDisposables.add(dom.addDisposableListener(removeButton, dom.EventType.CLICK, (e) => {
164
e.stopPropagation();
165
this._removeAttachment(entry.id);
166
}));
167
}
168
}
169
170
// --- Drag and drop ---
171
172
registerDropTarget(dndContainer: HTMLElement): void {
173
const overlay = dom.append(dndContainer, dom.$('.sessions-chat-dnd-overlay'));
174
let overlayText: HTMLElement | undefined;
175
176
const isDropSupported = (e: DragEvent): boolean => {
177
return containsDragType(e, DataTransfers.FILES, CodeDataTransfers.EDITORS, CodeDataTransfers.FILES, DataTransfers.RESOURCES, DataTransfers.INTERNAL_URI_LIST);
178
};
179
180
const showOverlay = () => {
181
overlay.classList.add('visible');
182
if (!overlayText) {
183
const label = localize('attachAsContext', "Attach as Context");
184
const iconAndTextElements = renderLabelWithIcons(`$(${Codicon.attach.id}) ${label}`);
185
const htmlElements = iconAndTextElements.map(element => {
186
if (typeof element === 'string') {
187
return dom.$('span.overlay-text', undefined, element);
188
}
189
return element;
190
});
191
overlayText = dom.$('span.attach-context-overlay-text', undefined, ...htmlElements);
192
overlay.appendChild(overlayText);
193
}
194
};
195
196
const hideOverlay = () => {
197
overlay.classList.remove('visible');
198
overlayText?.remove();
199
overlayText = undefined;
200
};
201
202
this._register(new DragAndDropObserver(dndContainer, {
203
onDragOver: (e) => {
204
if (isDropSupported(e)) {
205
e.preventDefault();
206
e.stopPropagation();
207
if (e.dataTransfer) {
208
e.dataTransfer.dropEffect = 'copy';
209
}
210
showOverlay();
211
}
212
},
213
onDragLeave: () => {
214
hideOverlay();
215
},
216
onDrop: async (e) => {
217
e.preventDefault();
218
e.stopPropagation();
219
hideOverlay();
220
221
// Extract editor data from VS Code internal drags (e.g., explorer view)
222
const editorDropData = extractEditorsDropData(e);
223
if (editorDropData.length > 0) {
224
for (const editor of editorDropData) {
225
if (editor.resource) {
226
await this._attachFileUri(editor.resource, basename(editor.resource));
227
}
228
}
229
return;
230
}
231
232
// Fallback: try native file items
233
const items = e.dataTransfer?.items;
234
if (items) {
235
for (const item of Array.from(items)) {
236
if (item.kind === 'file') {
237
const file = item.getAsFile();
238
if (!file) {
239
continue;
240
}
241
const filePath = getPathForFile(file);
242
if (!filePath) {
243
continue;
244
}
245
const uri = URI.file(filePath);
246
await this._attachFileUri(uri, file.name);
247
}
248
}
249
}
250
},
251
}));
252
}
253
254
// --- Paste ---
255
256
registerPasteHandler(element: HTMLElement): void {
257
const supportedMimeTypes = [
258
'image/png',
259
'image/jpeg',
260
'image/jpg',
261
'image/bmp',
262
'image/gif',
263
'image/tiff'
264
];
265
266
this._register(dom.addDisposableListener(element, dom.EventType.PASTE, async (e: ClipboardEvent) => {
267
const items = e.clipboardData?.items;
268
if (!items) {
269
return;
270
}
271
272
// Check synchronously for image data before any async work
273
// so preventDefault stops the editor from inserting text.
274
let imageFile: File | undefined;
275
for (const item of Array.from(items)) {
276
if (!item.type.startsWith('image/') || !supportedMimeTypes.includes(item.type)) {
277
continue;
278
}
279
const file = item.getAsFile();
280
if (file) {
281
imageFile = file;
282
break;
283
}
284
}
285
286
if (!imageFile) {
287
return;
288
}
289
290
e.preventDefault();
291
e.stopPropagation();
292
293
const arrayBuffer = await imageFile.arrayBuffer();
294
const data = new Uint8Array(arrayBuffer);
295
if (!isImage(data)) {
296
return;
297
}
298
299
const resizedData = await resizeImage(data, imageFile.type);
300
const displayName = this._getUniqueImageName();
301
302
this._addAttachments({
303
id: await imageToHash(resizedData),
304
name: displayName,
305
fullName: displayName,
306
value: resizedData,
307
kind: 'image',
308
});
309
}, true));
310
}
311
312
// --- Picker ---
313
314
showPicker(folderUri?: URI): void {
315
const picker = this.quickInputService.createQuickPick<IQuickPickItem>({ useSeparators: true });
316
const disposables = new DisposableStore();
317
picker.placeholder = localize('chatContext.attach.placeholder', "Attach as context...");
318
picker.matchOnDescription = true;
319
picker.sortByLabel = false;
320
321
const staticPicks: (IQuickPickItem | IQuickPickSeparator)[] = [
322
{
323
label: localize('files', "Files..."),
324
iconClass: ThemeIcon.asClassName(Codicon.file),
325
id: 'sessions.filesAndFolders',
326
},
327
{
328
label: localize('imageFromClipboard', "Image from Clipboard"),
329
iconClass: ThemeIcon.asClassName(Codicon.fileMedia),
330
id: 'sessions.imageFromClipboard',
331
},
332
];
333
334
picker.items = staticPicks;
335
picker.show();
336
337
if (folderUri) {
338
let searchCts: CancellationTokenSource | undefined;
339
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
340
341
const runSearch = (filePattern?: string) => {
342
searchCts?.dispose(true);
343
searchCts = new CancellationTokenSource();
344
const token = searchCts.token;
345
346
picker.busy = true;
347
this._collectFilePicks(folderUri, filePattern, token).then(filePicks => {
348
if (token.isCancellationRequested) {
349
return;
350
}
351
picker.busy = false;
352
if (filePicks.length > 0) {
353
picker.items = [
354
...staticPicks,
355
{ type: 'separator', label: basename(folderUri) },
356
...filePicks,
357
];
358
} else {
359
picker.items = staticPicks;
360
}
361
});
362
};
363
364
// Initial search (no filter)
365
runSearch();
366
367
// Re-search on user input with debounce
368
disposables.add(picker.onDidChangeValue(value => {
369
if (debounceTimer) {
370
clearTimeout(debounceTimer);
371
}
372
debounceTimer = setTimeout(() => runSearch(value || undefined), 200);
373
}));
374
375
disposables.add({ dispose: () => { searchCts?.dispose(true); if (debounceTimer) { clearTimeout(debounceTimer); } } });
376
}
377
378
disposables.add(picker.onDidAccept(async () => {
379
const [selected] = picker.selectedItems;
380
if (!selected) {
381
picker.hide();
382
return;
383
}
384
385
picker.hide();
386
387
if (selected.id === 'sessions.filesAndFolders') {
388
await this._handleFileDialog();
389
} else if (selected.id === 'sessions.imageFromClipboard') {
390
await this._handleClipboardImage();
391
} else if (selected.id) {
392
await this._attachFileUri(URI.parse(selected.id), selected.label);
393
}
394
}));
395
396
disposables.add(picker.onDidHide(() => {
397
picker.dispose();
398
disposables.dispose();
399
}));
400
}
401
402
private async _collectFilePicks(rootUri: URI, filePattern?: string, token?: CancellationToken): Promise<IQuickPickItem[]> {
403
const maxFiles = 200;
404
405
// For local file:// URIs, use the search service which respects .gitignore and excludes
406
if (rootUri.scheme === Schemas.file || rootUri.scheme === Schemas.vscodeRemote) {
407
return this._collectFilePicksViaSearch(rootUri, maxFiles, filePattern, token);
408
}
409
410
// For virtual filesystems (e.g. github-remote-file://), walk the tree via IFileService
411
return this._collectFilePicksViaFileService(rootUri, maxFiles, filePattern);
412
}
413
414
private async _collectFilePicksViaSearch(rootUri: URI, maxFiles: number, filePattern?: string, token?: CancellationToken): Promise<IQuickPickItem[]> {
415
const excludePattern = getExcludes(this.configurationService.getValue<ISearchConfiguration>({ resource: rootUri }));
416
417
try {
418
const searchResult = await this.searchService.fileSearch({
419
folderQueries: [{
420
folder: rootUri,
421
disregardIgnoreFiles: false,
422
}],
423
type: QueryType.File,
424
filePattern: filePattern || '',
425
excludePattern,
426
sortByScore: true,
427
maxResults: maxFiles,
428
}, token);
429
430
return searchResult.results.map(result => ({
431
label: basename(result.resource),
432
description: this.labelService.getUriLabel(result.resource, { relative: true }),
433
iconClasses: getIconClasses(this.modelService, this.languageService, result.resource, FileKind.FILE),
434
id: result.resource.toString(),
435
} satisfies IQuickPickItem));
436
} catch {
437
return [];
438
}
439
}
440
441
private async _collectFilePicksViaFileService(rootUri: URI, maxFiles: number, filePattern?: string): Promise<IQuickPickItem[]> {
442
const picks: IQuickPickItem[] = [];
443
const patternLower = filePattern?.toLowerCase();
444
const maxDepth = 10;
445
446
const collect = async (uri: URI, depth: number): Promise<void> => {
447
if (picks.length >= maxFiles || depth > maxDepth) {
448
return;
449
}
450
451
try {
452
const stat = await this.fileService.resolve(uri);
453
if (!stat.children) {
454
return;
455
}
456
457
const children = stat.children.slice().sort((a, b) => {
458
if (a.isDirectory !== b.isDirectory) {
459
return a.isDirectory ? -1 : 1;
460
}
461
return a.name.localeCompare(b.name);
462
});
463
464
for (const child of children) {
465
if (picks.length >= maxFiles) {
466
break;
467
}
468
if (child.isDirectory) {
469
await collect(child.resource, depth + 1);
470
} else {
471
if (patternLower && !child.name.toLowerCase().includes(patternLower)) {
472
continue;
473
}
474
picks.push({
475
label: child.name,
476
description: this.labelService.getUriLabel(child.resource, { relative: true }),
477
iconClasses: getIconClasses(this.modelService, this.languageService, child.resource, FileKind.FILE),
478
id: child.resource.toString(),
479
});
480
}
481
}
482
} catch {
483
// ignore errors for individual directories
484
}
485
};
486
487
await collect(rootUri, 0);
488
return picks;
489
}
490
491
private async _handleFileDialog(): Promise<void> {
492
const selected = await this.fileDialogService.showOpenDialog({
493
canSelectFiles: true,
494
canSelectFolders: true,
495
canSelectMany: true,
496
title: localize('selectFilesOrFolders', "Select Files or Folders"),
497
});
498
if (!selected) {
499
return;
500
}
501
502
for (const uri of selected) {
503
await this._attachFileUri(uri, basename(uri));
504
}
505
}
506
507
private async _attachFileUri(uri: URI, name: string): Promise<void> {
508
let stat;
509
try {
510
stat = await this.fileService.stat(uri);
511
} catch {
512
return;
513
}
514
515
if (stat.isDirectory) {
516
this._addAttachments({
517
kind: 'directory',
518
id: uri.toString(),
519
value: uri,
520
name,
521
});
522
return;
523
}
524
525
if (/\.(png|jpg|jpeg|bmp|gif|tiff)$/i.test(uri.path)) {
526
const readFile = await this.fileService.readFile(uri);
527
const resizedImage = await resizeImage(readFile.value.buffer);
528
this._addAttachments({
529
id: uri.toString(),
530
name,
531
fullName: name,
532
value: resizedImage,
533
kind: 'image',
534
references: [{ reference: uri, kind: 'reference' }]
535
});
536
} else {
537
let omittedState = OmittedState.NotOmitted;
538
try {
539
const ref = await this.textModelService.createModelReference(uri);
540
ref.dispose();
541
} catch {
542
omittedState = OmittedState.Full;
543
}
544
545
this._addAttachments({
546
kind: 'file',
547
id: uri.toString(),
548
value: uri,
549
name,
550
omittedState,
551
});
552
}
553
}
554
555
private async _handleClipboardImage(): Promise<void> {
556
const imageData = await this.clipboardService.readImage();
557
if (!isImage(imageData)) {
558
return;
559
}
560
561
const displayName = this._getUniqueImageName();
562
563
this._addAttachments({
564
id: await imageToHash(imageData),
565
name: displayName,
566
fullName: displayName,
567
value: imageData,
568
kind: 'image',
569
});
570
}
571
572
// --- State management ---
573
574
private _getUniqueImageName(): string {
575
const baseName = localize('pastedImage', "Pasted Image");
576
let name = baseName;
577
for (let i = 2; this._attachedContext.some(a => a.name === name); i++) {
578
name = `${baseName} ${i}`;
579
}
580
return name;
581
}
582
583
addAttachments(...entries: IChatRequestVariableEntry[]): void {
584
this._addAttachments(...entries);
585
}
586
587
private _addAttachments(...entries: IChatRequestVariableEntry[]): void {
588
for (const entry of entries) {
589
if (!this._attachedContext.some(e => e.id === entry.id)) {
590
this._attachedContext.push(entry);
591
}
592
}
593
this._updateRendering();
594
this._onDidChangeContext.fire();
595
}
596
597
private _removeAttachment(id: string): void {
598
const index = this._attachedContext.findIndex(e => e.id === id);
599
if (index >= 0) {
600
this._attachedContext.splice(index, 1);
601
this._updateRendering();
602
this._onDidChangeContext.fire();
603
}
604
}
605
606
clear(): void {
607
this._attachedContext.length = 0;
608
this._updateRendering();
609
this._onDidChangeContext.fire();
610
}
611
}
612
613