Path: blob/main/src/vs/workbench/contrib/chat/browser/chatDragAndDrop.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { DataTransfers } from '../../../../base/browser/dnd.js';6import { $, DragAndDropObserver } from '../../../../base/browser/dom.js';7import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js';8import { coalesce } from '../../../../base/common/arrays.js';9import { CancellationToken } from '../../../../base/common/cancellation.js';10import { Codicon } from '../../../../base/common/codicons.js';11import { UriList } from '../../../../base/common/dataTransfer.js';12import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';13import { Mimes } from '../../../../base/common/mime.js';14import { URI } from '../../../../base/common/uri.js';15import { localize } from '../../../../nls.js';16import { CodeDataTransfers, containsDragType, extractEditorsDropData, extractMarkerDropData, extractNotebookCellOutputDropData, extractSymbolDropData } from '../../../../platform/dnd/browser/dnd.js';17import { ILogService } from '../../../../platform/log/common/log.js';18import { IThemeService, Themable } from '../../../../platform/theme/common/themeService.js';19import { ISharedWebContentExtractorService } from '../../../../platform/webContentExtractor/common/webContentExtractor.js';20import { IExtensionService, isProposedApiEnabled } from '../../../services/extensions/common/extensions.js';21import { IChatRequestVariableEntry } from '../common/chatVariableEntries.js';22import { IChatWidgetService } from './chat.js';23import { IChatAttachmentResolveService, ImageTransferData } from './chatAttachmentResolveService.js';24import { ChatAttachmentModel } from './chatAttachmentModel.js';25import { IChatInputStyles } from './chatInputPart.js';26import { convertStringToUInt8Array } from './imageUtils.js';27import { extractSCMHistoryItemDropData } from '../../scm/browser/scmHistoryChatContext.js';2829enum ChatDragAndDropType {30FILE_INTERNAL,31FILE_EXTERNAL,32FOLDER,33IMAGE,34SYMBOL,35HTML,36MARKER,37NOTEBOOK_CELL_OUTPUT,38SCM_HISTORY_ITEM39}4041const IMAGE_DATA_REGEX = /^data:image\/[a-z]+;base64,/;42const URL_REGEX = /^https?:\/\/.+/;4344export class ChatDragAndDrop extends Themable {4546private readonly overlays: Map<HTMLElement, { overlay: HTMLElement; disposable: IDisposable }> = new Map();47private overlayText?: HTMLElement;48private overlayTextBackground: string = '';49private disableOverlay: boolean = false;5051constructor(52private readonly attachmentModel: ChatAttachmentModel,53private readonly styles: IChatInputStyles,54@IThemeService themeService: IThemeService,55@IExtensionService private readonly extensionService: IExtensionService,56@ISharedWebContentExtractorService private readonly webContentExtractorService: ISharedWebContentExtractorService,57@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,58@ILogService private readonly logService: ILogService,59@IChatAttachmentResolveService private readonly chatAttachmentResolveService: IChatAttachmentResolveService60) {61super(themeService);6263this.updateStyles();6465this._register(toDisposable(() => {66this.overlays.forEach(({ overlay, disposable }) => {67disposable.dispose();68overlay.remove();69});7071this.overlays.clear();72this.currentActiveTarget = undefined;73this.overlayText?.remove();74this.overlayText = undefined;75}));76}7778addOverlay(target: HTMLElement, overlayContainer: HTMLElement): void {79this.removeOverlay(target);8081const { overlay, disposable } = this.createOverlay(target, overlayContainer);82this.overlays.set(target, { overlay, disposable });83}8485removeOverlay(target: HTMLElement): void {86if (this.currentActiveTarget === target) {87this.currentActiveTarget = undefined;88}8990const existingOverlay = this.overlays.get(target);91if (existingOverlay) {92existingOverlay.overlay.remove();93existingOverlay.disposable.dispose();94this.overlays.delete(target);95}96}9798setDisabledOverlay(disable: boolean) {99this.disableOverlay = disable;100}101102private currentActiveTarget: HTMLElement | undefined = undefined;103private createOverlay(target: HTMLElement, overlayContainer: HTMLElement): { overlay: HTMLElement; disposable: IDisposable } {104const overlay = document.createElement('div');105overlay.classList.add('chat-dnd-overlay');106this.updateOverlayStyles(overlay);107overlayContainer.appendChild(overlay);108109const disposable = new DragAndDropObserver(target, {110onDragOver: (e) => {111if (this.disableOverlay) {112return;113}114115e.stopPropagation();116e.preventDefault();117118if (target === this.currentActiveTarget) {119return;120}121122if (this.currentActiveTarget) {123this.setOverlay(this.currentActiveTarget, undefined);124}125126this.currentActiveTarget = target;127128this.onDragEnter(e, target);129130},131onDragLeave: (e) => {132if (this.disableOverlay) {133return;134}135if (target === this.currentActiveTarget) {136this.currentActiveTarget = undefined;137}138139this.onDragLeave(e, target);140},141onDrop: (e) => {142if (this.disableOverlay) {143return;144}145e.stopPropagation();146e.preventDefault();147148if (target !== this.currentActiveTarget) {149return;150}151152this.currentActiveTarget = undefined;153this.onDrop(e, target);154},155});156157return { overlay, disposable };158}159160private onDragEnter(e: DragEvent, target: HTMLElement): void {161const estimatedDropType = this.guessDropType(e);162this.updateDropFeedback(e, target, estimatedDropType);163}164165private onDragLeave(e: DragEvent, target: HTMLElement): void {166this.updateDropFeedback(e, target, undefined);167}168169private onDrop(e: DragEvent, target: HTMLElement): void {170this.updateDropFeedback(e, target, undefined);171this.drop(e);172}173174private async drop(e: DragEvent): Promise<void> {175const contexts = await this.resolveAttachmentsFromDragEvent(e);176if (contexts.length === 0) {177return;178}179180this.attachmentModel.addContext(...contexts);181}182183private updateDropFeedback(e: DragEvent, target: HTMLElement, dropType: ChatDragAndDropType | undefined): void {184const showOverlay = dropType !== undefined;185if (e.dataTransfer) {186e.dataTransfer.dropEffect = showOverlay ? 'copy' : 'none';187}188189this.setOverlay(target, dropType);190}191192private guessDropType(e: DragEvent): ChatDragAndDropType | undefined {193// This is an estimation based on the datatransfer types/items194if (containsDragType(e, CodeDataTransfers.NOTEBOOK_CELL_OUTPUT)) {195return ChatDragAndDropType.NOTEBOOK_CELL_OUTPUT;196} else if (containsDragType(e, CodeDataTransfers.SCM_HISTORY_ITEM)) {197return ChatDragAndDropType.SCM_HISTORY_ITEM;198} else if (containsImageDragType(e)) {199return this.extensionService.extensions.some(ext => isProposedApiEnabled(ext, 'chatReferenceBinaryData')) ? ChatDragAndDropType.IMAGE : undefined;200} else if (containsDragType(e, 'text/html')) {201return ChatDragAndDropType.HTML;202} else if (containsDragType(e, CodeDataTransfers.SYMBOLS)) {203return ChatDragAndDropType.SYMBOL;204} else if (containsDragType(e, CodeDataTransfers.MARKERS)) {205return ChatDragAndDropType.MARKER;206} else if (containsDragType(e, DataTransfers.FILES)) {207return ChatDragAndDropType.FILE_EXTERNAL;208} else if (containsDragType(e, CodeDataTransfers.EDITORS)) {209return ChatDragAndDropType.FILE_INTERNAL;210} else if (containsDragType(e, Mimes.uriList, CodeDataTransfers.FILES, DataTransfers.RESOURCES, DataTransfers.INTERNAL_URI_LIST)) {211return ChatDragAndDropType.FOLDER;212}213214return undefined;215}216217private isDragEventSupported(e: DragEvent): boolean {218// if guessed drop type is undefined, it means the drop is not supported219const dropType = this.guessDropType(e);220return dropType !== undefined;221}222223private getDropTypeName(type: ChatDragAndDropType): string {224switch (type) {225case ChatDragAndDropType.FILE_INTERNAL: return localize('file', 'File');226case ChatDragAndDropType.FILE_EXTERNAL: return localize('file', 'File');227case ChatDragAndDropType.FOLDER: return localize('folder', 'Folder');228case ChatDragAndDropType.IMAGE: return localize('image', 'Image');229case ChatDragAndDropType.SYMBOL: return localize('symbol', 'Symbol');230case ChatDragAndDropType.MARKER: return localize('problem', 'Problem');231case ChatDragAndDropType.HTML: return localize('url', 'URL');232case ChatDragAndDropType.NOTEBOOK_CELL_OUTPUT: return localize('notebookOutput', 'Output');233case ChatDragAndDropType.SCM_HISTORY_ITEM: return localize('scmHistoryItem', 'Change');234}235}236237private async resolveAttachmentsFromDragEvent(e: DragEvent): Promise<IChatRequestVariableEntry[]> {238if (!this.isDragEventSupported(e)) {239return [];240}241242if (containsDragType(e, CodeDataTransfers.NOTEBOOK_CELL_OUTPUT)) {243const notebookOutputData = extractNotebookCellOutputDropData(e);244if (notebookOutputData) {245return this.chatAttachmentResolveService.resolveNotebookOutputAttachContext(notebookOutputData);246}247}248249if (containsDragType(e, CodeDataTransfers.SCM_HISTORY_ITEM)) {250const scmHistoryItemData = extractSCMHistoryItemDropData(e);251if (scmHistoryItemData) {252return this.chatAttachmentResolveService.resolveSourceControlHistoryItemAttachContext(scmHistoryItemData);253}254}255256const markerData = extractMarkerDropData(e);257if (markerData) {258return this.chatAttachmentResolveService.resolveMarkerAttachContext(markerData);259}260261if (containsDragType(e, CodeDataTransfers.SYMBOLS)) {262const symbolsData = extractSymbolDropData(e);263return this.chatAttachmentResolveService.resolveSymbolsAttachContext(symbolsData);264}265266const editorDragData = extractEditorsDropData(e);267if (editorDragData.length > 0) {268return coalesce(await Promise.all(editorDragData.map(editorInput => {269return this.chatAttachmentResolveService.resolveEditorAttachContext(editorInput);270})));271}272273const internal = e.dataTransfer?.getData(DataTransfers.INTERNAL_URI_LIST);274if (internal) {275const uriList = UriList.parse(internal);276if (uriList.length) {277return coalesce(await Promise.all(278uriList.map(uri => this.chatAttachmentResolveService.resolveEditorAttachContext({ resource: URI.parse(uri) }))279));280}281}282283if (!containsDragType(e, DataTransfers.INTERNAL_URI_LIST) && containsDragType(e, Mimes.uriList) && ((containsDragType(e, Mimes.html) || containsDragType(e, Mimes.text) /* Text mime needed for safari support */))) {284return this.resolveHTMLAttachContext(e);285}286287return [];288}289290private async downloadImageAsUint8Array(url: string): Promise<Uint8Array | undefined> {291try {292const extractedImages = await this.webContentExtractorService.readImage(URI.parse(url), CancellationToken.None);293if (extractedImages) {294return extractedImages.buffer;295}296} catch (error) {297this.logService.warn('Fetch failed:', error);298}299300// TODO: use dnd provider to insert text @justschen301const selection = this.chatWidgetService.lastFocusedWidget?.inputEditor.getSelection();302if (selection && this.chatWidgetService.lastFocusedWidget) {303this.chatWidgetService.lastFocusedWidget.inputEditor.executeEdits('chatInsertUrl', [{ range: selection, text: url }]);304}305306this.logService.warn(`Image URLs must end in .jpg, .png, .gif, .webp, or .bmp. Failed to fetch image from this URL: ${url}`);307return undefined;308}309310private async resolveHTMLAttachContext(e: DragEvent): Promise<IChatRequestVariableEntry[]> {311const existingAttachmentNames = new Set<string>(this.attachmentModel.attachments.map(attachment => attachment.name));312const createDisplayName = (): string => {313const baseName = localize('dragAndDroppedImageName', 'Image from URL');314let uniqueName = baseName;315let baseNameInstance = 1;316317while (existingAttachmentNames.has(uniqueName)) {318uniqueName = `${baseName} ${++baseNameInstance}`;319}320321existingAttachmentNames.add(uniqueName);322return uniqueName;323};324325const getImageTransferDataFromUrl = async (url: string): Promise<ImageTransferData | undefined> => {326const resource = URI.parse(url);327328if (IMAGE_DATA_REGEX.test(url)) {329return { data: convertStringToUInt8Array(url), name: createDisplayName(), resource };330}331332if (URL_REGEX.test(url)) {333const data = await this.downloadImageAsUint8Array(url);334if (data) {335return { data, name: createDisplayName(), resource, id: url };336}337}338339return undefined;340};341342const getImageTransferDataFromFile = async (file: File): Promise<ImageTransferData | undefined> => {343try {344const buffer = await file.arrayBuffer();345return { data: new Uint8Array(buffer), name: createDisplayName() };346} catch (error) {347this.logService.error('Error reading file:', error);348}349350return undefined;351};352353const imageTransferData: ImageTransferData[] = [];354355// Image Web File Drag and Drop356const imageFiles = extractImageFilesFromDragEvent(e);357if (imageFiles.length) {358const imageTransferDataFromFiles = await Promise.all(imageFiles.map(file => getImageTransferDataFromFile(file)));359imageTransferData.push(...imageTransferDataFromFiles.filter(data => !!data));360}361362// Image Web URL Drag and Drop363const imageUrls = extractUrlsFromDragEvent(e);364if (imageUrls.length) {365const imageTransferDataFromUrl = await Promise.all(imageUrls.map(getImageTransferDataFromUrl));366imageTransferData.push(...imageTransferDataFromUrl.filter(data => !!data));367}368369return await this.chatAttachmentResolveService.resolveImageAttachContext(imageTransferData);370}371372private setOverlay(target: HTMLElement, type: ChatDragAndDropType | undefined): void {373// Remove any previous overlay text374this.overlayText?.remove();375this.overlayText = undefined;376377const { overlay } = this.overlays.get(target)!;378if (type !== undefined) {379// Render the overlay text380381const iconAndtextElements = renderLabelWithIcons(`$(${Codicon.attach.id}) ${this.getOverlayText(type)}`);382const htmlElements = iconAndtextElements.map(element => {383if (typeof element === 'string') {384return $('span.overlay-text', undefined, element);385}386return element;387});388389this.overlayText = $('span.attach-context-overlay-text', undefined, ...htmlElements);390this.overlayText.style.backgroundColor = this.overlayTextBackground;391overlay.appendChild(this.overlayText);392}393394overlay.classList.toggle('visible', type !== undefined);395}396397private getOverlayText(type: ChatDragAndDropType): string {398const typeName = this.getDropTypeName(type);399return localize('attacAsContext', 'Attach {0} as Context', typeName);400}401402private updateOverlayStyles(overlay: HTMLElement): void {403overlay.style.backgroundColor = this.getColor(this.styles.overlayBackground) || '';404overlay.style.color = this.getColor(this.styles.listForeground) || '';405}406407override updateStyles(): void {408this.overlays.forEach(overlay => this.updateOverlayStyles(overlay.overlay));409this.overlayTextBackground = this.getColor(this.styles.listBackground) || '';410}411}412413function containsImageDragType(e: DragEvent): boolean {414// Image detection should not have false positives, only false negatives are allowed415if (containsDragType(e, 'image')) {416return true;417}418419if (containsDragType(e, DataTransfers.FILES)) {420const files = e.dataTransfer?.files;421if (files && files.length > 0) {422return Array.from(files).some(file => file.type.startsWith('image/'));423}424425const items = e.dataTransfer?.items;426if (items && items.length > 0) {427return Array.from(items).some(item => item.type.startsWith('image/'));428}429}430431return false;432}433434function extractUrlsFromDragEvent(e: DragEvent, logService?: ILogService): string[] {435const textUrl = e.dataTransfer?.getData('text/uri-list');436if (textUrl) {437try {438const urls = UriList.parse(textUrl);439if (urls.length > 0) {440return urls;441}442} catch (error) {443logService?.error('Error parsing URI list:', error);444return [];445}446}447448return [];449}450451function extractImageFilesFromDragEvent(e: DragEvent): File[] {452const files = e.dataTransfer?.files;453if (!files) {454return [];455}456457return Array.from(files).filter(file => file.type.startsWith('image/'));458}459460461