Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatDragAndDrop.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 { DataTransfers } from '../../../../base/browser/dnd.js';
7
import { $, DragAndDropObserver } from '../../../../base/browser/dom.js';
8
import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
9
import { coalesce } from '../../../../base/common/arrays.js';
10
import { CancellationToken } from '../../../../base/common/cancellation.js';
11
import { Codicon } from '../../../../base/common/codicons.js';
12
import { UriList } from '../../../../base/common/dataTransfer.js';
13
import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
14
import { Mimes } from '../../../../base/common/mime.js';
15
import { URI } from '../../../../base/common/uri.js';
16
import { localize } from '../../../../nls.js';
17
import { CodeDataTransfers, containsDragType, extractEditorsDropData, extractMarkerDropData, extractNotebookCellOutputDropData, extractSymbolDropData } from '../../../../platform/dnd/browser/dnd.js';
18
import { ILogService } from '../../../../platform/log/common/log.js';
19
import { IThemeService, Themable } from '../../../../platform/theme/common/themeService.js';
20
import { ISharedWebContentExtractorService } from '../../../../platform/webContentExtractor/common/webContentExtractor.js';
21
import { IExtensionService, isProposedApiEnabled } from '../../../services/extensions/common/extensions.js';
22
import { IChatRequestVariableEntry } from '../common/chatVariableEntries.js';
23
import { IChatWidgetService } from './chat.js';
24
import { IChatAttachmentResolveService, ImageTransferData } from './chatAttachmentResolveService.js';
25
import { ChatAttachmentModel } from './chatAttachmentModel.js';
26
import { IChatInputStyles } from './chatInputPart.js';
27
import { convertStringToUInt8Array } from './imageUtils.js';
28
import { extractSCMHistoryItemDropData } from '../../scm/browser/scmHistoryChatContext.js';
29
30
enum ChatDragAndDropType {
31
FILE_INTERNAL,
32
FILE_EXTERNAL,
33
FOLDER,
34
IMAGE,
35
SYMBOL,
36
HTML,
37
MARKER,
38
NOTEBOOK_CELL_OUTPUT,
39
SCM_HISTORY_ITEM
40
}
41
42
const IMAGE_DATA_REGEX = /^data:image\/[a-z]+;base64,/;
43
const URL_REGEX = /^https?:\/\/.+/;
44
45
export class ChatDragAndDrop extends Themable {
46
47
private readonly overlays: Map<HTMLElement, { overlay: HTMLElement; disposable: IDisposable }> = new Map();
48
private overlayText?: HTMLElement;
49
private overlayTextBackground: string = '';
50
private disableOverlay: boolean = false;
51
52
constructor(
53
private readonly attachmentModel: ChatAttachmentModel,
54
private readonly styles: IChatInputStyles,
55
@IThemeService themeService: IThemeService,
56
@IExtensionService private readonly extensionService: IExtensionService,
57
@ISharedWebContentExtractorService private readonly webContentExtractorService: ISharedWebContentExtractorService,
58
@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,
59
@ILogService private readonly logService: ILogService,
60
@IChatAttachmentResolveService private readonly chatAttachmentResolveService: IChatAttachmentResolveService
61
) {
62
super(themeService);
63
64
this.updateStyles();
65
66
this._register(toDisposable(() => {
67
this.overlays.forEach(({ overlay, disposable }) => {
68
disposable.dispose();
69
overlay.remove();
70
});
71
72
this.overlays.clear();
73
this.currentActiveTarget = undefined;
74
this.overlayText?.remove();
75
this.overlayText = undefined;
76
}));
77
}
78
79
addOverlay(target: HTMLElement, overlayContainer: HTMLElement): void {
80
this.removeOverlay(target);
81
82
const { overlay, disposable } = this.createOverlay(target, overlayContainer);
83
this.overlays.set(target, { overlay, disposable });
84
}
85
86
removeOverlay(target: HTMLElement): void {
87
if (this.currentActiveTarget === target) {
88
this.currentActiveTarget = undefined;
89
}
90
91
const existingOverlay = this.overlays.get(target);
92
if (existingOverlay) {
93
existingOverlay.overlay.remove();
94
existingOverlay.disposable.dispose();
95
this.overlays.delete(target);
96
}
97
}
98
99
setDisabledOverlay(disable: boolean) {
100
this.disableOverlay = disable;
101
}
102
103
private currentActiveTarget: HTMLElement | undefined = undefined;
104
private createOverlay(target: HTMLElement, overlayContainer: HTMLElement): { overlay: HTMLElement; disposable: IDisposable } {
105
const overlay = document.createElement('div');
106
overlay.classList.add('chat-dnd-overlay');
107
this.updateOverlayStyles(overlay);
108
overlayContainer.appendChild(overlay);
109
110
const disposable = new DragAndDropObserver(target, {
111
onDragOver: (e) => {
112
if (this.disableOverlay) {
113
return;
114
}
115
116
e.stopPropagation();
117
e.preventDefault();
118
119
if (target === this.currentActiveTarget) {
120
return;
121
}
122
123
if (this.currentActiveTarget) {
124
this.setOverlay(this.currentActiveTarget, undefined);
125
}
126
127
this.currentActiveTarget = target;
128
129
this.onDragEnter(e, target);
130
131
},
132
onDragLeave: (e) => {
133
if (this.disableOverlay) {
134
return;
135
}
136
if (target === this.currentActiveTarget) {
137
this.currentActiveTarget = undefined;
138
}
139
140
this.onDragLeave(e, target);
141
},
142
onDrop: (e) => {
143
if (this.disableOverlay) {
144
return;
145
}
146
e.stopPropagation();
147
e.preventDefault();
148
149
if (target !== this.currentActiveTarget) {
150
return;
151
}
152
153
this.currentActiveTarget = undefined;
154
this.onDrop(e, target);
155
},
156
});
157
158
return { overlay, disposable };
159
}
160
161
private onDragEnter(e: DragEvent, target: HTMLElement): void {
162
const estimatedDropType = this.guessDropType(e);
163
this.updateDropFeedback(e, target, estimatedDropType);
164
}
165
166
private onDragLeave(e: DragEvent, target: HTMLElement): void {
167
this.updateDropFeedback(e, target, undefined);
168
}
169
170
private onDrop(e: DragEvent, target: HTMLElement): void {
171
this.updateDropFeedback(e, target, undefined);
172
this.drop(e);
173
}
174
175
private async drop(e: DragEvent): Promise<void> {
176
const contexts = await this.resolveAttachmentsFromDragEvent(e);
177
if (contexts.length === 0) {
178
return;
179
}
180
181
this.attachmentModel.addContext(...contexts);
182
}
183
184
private updateDropFeedback(e: DragEvent, target: HTMLElement, dropType: ChatDragAndDropType | undefined): void {
185
const showOverlay = dropType !== undefined;
186
if (e.dataTransfer) {
187
e.dataTransfer.dropEffect = showOverlay ? 'copy' : 'none';
188
}
189
190
this.setOverlay(target, dropType);
191
}
192
193
private guessDropType(e: DragEvent): ChatDragAndDropType | undefined {
194
// This is an estimation based on the datatransfer types/items
195
if (containsDragType(e, CodeDataTransfers.NOTEBOOK_CELL_OUTPUT)) {
196
return ChatDragAndDropType.NOTEBOOK_CELL_OUTPUT;
197
} else if (containsDragType(e, CodeDataTransfers.SCM_HISTORY_ITEM)) {
198
return ChatDragAndDropType.SCM_HISTORY_ITEM;
199
} else if (containsImageDragType(e)) {
200
return this.extensionService.extensions.some(ext => isProposedApiEnabled(ext, 'chatReferenceBinaryData')) ? ChatDragAndDropType.IMAGE : undefined;
201
} else if (containsDragType(e, 'text/html')) {
202
return ChatDragAndDropType.HTML;
203
} else if (containsDragType(e, CodeDataTransfers.SYMBOLS)) {
204
return ChatDragAndDropType.SYMBOL;
205
} else if (containsDragType(e, CodeDataTransfers.MARKERS)) {
206
return ChatDragAndDropType.MARKER;
207
} else if (containsDragType(e, DataTransfers.FILES)) {
208
return ChatDragAndDropType.FILE_EXTERNAL;
209
} else if (containsDragType(e, CodeDataTransfers.EDITORS)) {
210
return ChatDragAndDropType.FILE_INTERNAL;
211
} else if (containsDragType(e, Mimes.uriList, CodeDataTransfers.FILES, DataTransfers.RESOURCES, DataTransfers.INTERNAL_URI_LIST)) {
212
return ChatDragAndDropType.FOLDER;
213
}
214
215
return undefined;
216
}
217
218
private isDragEventSupported(e: DragEvent): boolean {
219
// if guessed drop type is undefined, it means the drop is not supported
220
const dropType = this.guessDropType(e);
221
return dropType !== undefined;
222
}
223
224
private getDropTypeName(type: ChatDragAndDropType): string {
225
switch (type) {
226
case ChatDragAndDropType.FILE_INTERNAL: return localize('file', 'File');
227
case ChatDragAndDropType.FILE_EXTERNAL: return localize('file', 'File');
228
case ChatDragAndDropType.FOLDER: return localize('folder', 'Folder');
229
case ChatDragAndDropType.IMAGE: return localize('image', 'Image');
230
case ChatDragAndDropType.SYMBOL: return localize('symbol', 'Symbol');
231
case ChatDragAndDropType.MARKER: return localize('problem', 'Problem');
232
case ChatDragAndDropType.HTML: return localize('url', 'URL');
233
case ChatDragAndDropType.NOTEBOOK_CELL_OUTPUT: return localize('notebookOutput', 'Output');
234
case ChatDragAndDropType.SCM_HISTORY_ITEM: return localize('scmHistoryItem', 'Change');
235
}
236
}
237
238
private async resolveAttachmentsFromDragEvent(e: DragEvent): Promise<IChatRequestVariableEntry[]> {
239
if (!this.isDragEventSupported(e)) {
240
return [];
241
}
242
243
if (containsDragType(e, CodeDataTransfers.NOTEBOOK_CELL_OUTPUT)) {
244
const notebookOutputData = extractNotebookCellOutputDropData(e);
245
if (notebookOutputData) {
246
return this.chatAttachmentResolveService.resolveNotebookOutputAttachContext(notebookOutputData);
247
}
248
}
249
250
if (containsDragType(e, CodeDataTransfers.SCM_HISTORY_ITEM)) {
251
const scmHistoryItemData = extractSCMHistoryItemDropData(e);
252
if (scmHistoryItemData) {
253
return this.chatAttachmentResolveService.resolveSourceControlHistoryItemAttachContext(scmHistoryItemData);
254
}
255
}
256
257
const markerData = extractMarkerDropData(e);
258
if (markerData) {
259
return this.chatAttachmentResolveService.resolveMarkerAttachContext(markerData);
260
}
261
262
if (containsDragType(e, CodeDataTransfers.SYMBOLS)) {
263
const symbolsData = extractSymbolDropData(e);
264
return this.chatAttachmentResolveService.resolveSymbolsAttachContext(symbolsData);
265
}
266
267
const editorDragData = extractEditorsDropData(e);
268
if (editorDragData.length > 0) {
269
return coalesce(await Promise.all(editorDragData.map(editorInput => {
270
return this.chatAttachmentResolveService.resolveEditorAttachContext(editorInput);
271
})));
272
}
273
274
const internal = e.dataTransfer?.getData(DataTransfers.INTERNAL_URI_LIST);
275
if (internal) {
276
const uriList = UriList.parse(internal);
277
if (uriList.length) {
278
return coalesce(await Promise.all(
279
uriList.map(uri => this.chatAttachmentResolveService.resolveEditorAttachContext({ resource: URI.parse(uri) }))
280
));
281
}
282
}
283
284
if (!containsDragType(e, DataTransfers.INTERNAL_URI_LIST) && containsDragType(e, Mimes.uriList) && ((containsDragType(e, Mimes.html) || containsDragType(e, Mimes.text) /* Text mime needed for safari support */))) {
285
return this.resolveHTMLAttachContext(e);
286
}
287
288
return [];
289
}
290
291
private async downloadImageAsUint8Array(url: string): Promise<Uint8Array | undefined> {
292
try {
293
const extractedImages = await this.webContentExtractorService.readImage(URI.parse(url), CancellationToken.None);
294
if (extractedImages) {
295
return extractedImages.buffer;
296
}
297
} catch (error) {
298
this.logService.warn('Fetch failed:', error);
299
}
300
301
// TODO: use dnd provider to insert text @justschen
302
const selection = this.chatWidgetService.lastFocusedWidget?.inputEditor.getSelection();
303
if (selection && this.chatWidgetService.lastFocusedWidget) {
304
this.chatWidgetService.lastFocusedWidget.inputEditor.executeEdits('chatInsertUrl', [{ range: selection, text: url }]);
305
}
306
307
this.logService.warn(`Image URLs must end in .jpg, .png, .gif, .webp, or .bmp. Failed to fetch image from this URL: ${url}`);
308
return undefined;
309
}
310
311
private async resolveHTMLAttachContext(e: DragEvent): Promise<IChatRequestVariableEntry[]> {
312
const existingAttachmentNames = new Set<string>(this.attachmentModel.attachments.map(attachment => attachment.name));
313
const createDisplayName = (): string => {
314
const baseName = localize('dragAndDroppedImageName', 'Image from URL');
315
let uniqueName = baseName;
316
let baseNameInstance = 1;
317
318
while (existingAttachmentNames.has(uniqueName)) {
319
uniqueName = `${baseName} ${++baseNameInstance}`;
320
}
321
322
existingAttachmentNames.add(uniqueName);
323
return uniqueName;
324
};
325
326
const getImageTransferDataFromUrl = async (url: string): Promise<ImageTransferData | undefined> => {
327
const resource = URI.parse(url);
328
329
if (IMAGE_DATA_REGEX.test(url)) {
330
return { data: convertStringToUInt8Array(url), name: createDisplayName(), resource };
331
}
332
333
if (URL_REGEX.test(url)) {
334
const data = await this.downloadImageAsUint8Array(url);
335
if (data) {
336
return { data, name: createDisplayName(), resource, id: url };
337
}
338
}
339
340
return undefined;
341
};
342
343
const getImageTransferDataFromFile = async (file: File): Promise<ImageTransferData | undefined> => {
344
try {
345
const buffer = await file.arrayBuffer();
346
return { data: new Uint8Array(buffer), name: createDisplayName() };
347
} catch (error) {
348
this.logService.error('Error reading file:', error);
349
}
350
351
return undefined;
352
};
353
354
const imageTransferData: ImageTransferData[] = [];
355
356
// Image Web File Drag and Drop
357
const imageFiles = extractImageFilesFromDragEvent(e);
358
if (imageFiles.length) {
359
const imageTransferDataFromFiles = await Promise.all(imageFiles.map(file => getImageTransferDataFromFile(file)));
360
imageTransferData.push(...imageTransferDataFromFiles.filter(data => !!data));
361
}
362
363
// Image Web URL Drag and Drop
364
const imageUrls = extractUrlsFromDragEvent(e);
365
if (imageUrls.length) {
366
const imageTransferDataFromUrl = await Promise.all(imageUrls.map(getImageTransferDataFromUrl));
367
imageTransferData.push(...imageTransferDataFromUrl.filter(data => !!data));
368
}
369
370
return await this.chatAttachmentResolveService.resolveImageAttachContext(imageTransferData);
371
}
372
373
private setOverlay(target: HTMLElement, type: ChatDragAndDropType | undefined): void {
374
// Remove any previous overlay text
375
this.overlayText?.remove();
376
this.overlayText = undefined;
377
378
const { overlay } = this.overlays.get(target)!;
379
if (type !== undefined) {
380
// Render the overlay text
381
382
const iconAndtextElements = renderLabelWithIcons(`$(${Codicon.attach.id}) ${this.getOverlayText(type)}`);
383
const htmlElements = iconAndtextElements.map(element => {
384
if (typeof element === 'string') {
385
return $('span.overlay-text', undefined, element);
386
}
387
return element;
388
});
389
390
this.overlayText = $('span.attach-context-overlay-text', undefined, ...htmlElements);
391
this.overlayText.style.backgroundColor = this.overlayTextBackground;
392
overlay.appendChild(this.overlayText);
393
}
394
395
overlay.classList.toggle('visible', type !== undefined);
396
}
397
398
private getOverlayText(type: ChatDragAndDropType): string {
399
const typeName = this.getDropTypeName(type);
400
return localize('attacAsContext', 'Attach {0} as Context', typeName);
401
}
402
403
private updateOverlayStyles(overlay: HTMLElement): void {
404
overlay.style.backgroundColor = this.getColor(this.styles.overlayBackground) || '';
405
overlay.style.color = this.getColor(this.styles.listForeground) || '';
406
}
407
408
override updateStyles(): void {
409
this.overlays.forEach(overlay => this.updateOverlayStyles(overlay.overlay));
410
this.overlayTextBackground = this.getColor(this.styles.listBackground) || '';
411
}
412
}
413
414
function containsImageDragType(e: DragEvent): boolean {
415
// Image detection should not have false positives, only false negatives are allowed
416
if (containsDragType(e, 'image')) {
417
return true;
418
}
419
420
if (containsDragType(e, DataTransfers.FILES)) {
421
const files = e.dataTransfer?.files;
422
if (files && files.length > 0) {
423
return Array.from(files).some(file => file.type.startsWith('image/'));
424
}
425
426
const items = e.dataTransfer?.items;
427
if (items && items.length > 0) {
428
return Array.from(items).some(item => item.type.startsWith('image/'));
429
}
430
}
431
432
return false;
433
}
434
435
function extractUrlsFromDragEvent(e: DragEvent, logService?: ILogService): string[] {
436
const textUrl = e.dataTransfer?.getData('text/uri-list');
437
if (textUrl) {
438
try {
439
const urls = UriList.parse(textUrl);
440
if (urls.length > 0) {
441
return urls;
442
}
443
} catch (error) {
444
logService?.error('Error parsing URI list:', error);
445
return [];
446
}
447
}
448
449
return [];
450
}
451
452
function extractImageFilesFromDragEvent(e: DragEvent): File[] {
453
const files = e.dataTransfer?.files;
454
if (!files) {
455
return [];
456
}
457
458
return Array.from(files).filter(file => file.type.startsWith('image/'));
459
}
460
461