Path: blob/main/src/vs/workbench/contrib/chat/browser/widget/chatDragAndDrop.ts
4780 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 { extractSCMHistoryItemDropData } from '../../../scm/browser/scmHistoryChatContext.js';22import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js';23import { IChatWidget } from '../chat.js';24import { ChatAttachmentModel } from '../attachments/chatAttachmentModel.js';25import { IChatAttachmentResolveService, ImageTransferData } from '../attachments/chatAttachmentResolveService.js';26import { IChatInputStyles } from './input/chatInputPart.js';27import { convertStringToUInt8Array } from '../chatImageUtils.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 widgetRef: () => IChatWidget | undefined,53private readonly attachmentModel: ChatAttachmentModel,54private readonly styles: IChatInputStyles,55@IThemeService themeService: IThemeService,56@IExtensionService private readonly extensionService: IExtensionService,57@ISharedWebContentExtractorService private readonly webContentExtractorService: ISharedWebContentExtractorService,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 widget = this.widgetRef();302const selection = widget?.inputEditor.getSelection();303if (selection && widget) {304widget.inputEditor.executeEdits('chatInsertUrl', [{ range: selection, text: url }]);305}306307this.logService.warn(`Image URLs must end in .jpg, .png, .gif, .webp, or .bmp. Failed to fetch image from this URL: ${url}`);308return undefined;309}310311private async resolveHTMLAttachContext(e: DragEvent): Promise<IChatRequestVariableEntry[]> {312const existingAttachmentNames = new Set<string>(this.attachmentModel.attachments.map(attachment => attachment.name));313const createDisplayName = (): string => {314const baseName = localize('dragAndDroppedImageName', 'Image from URL');315let uniqueName = baseName;316let baseNameInstance = 1;317318while (existingAttachmentNames.has(uniqueName)) {319uniqueName = `${baseName} ${++baseNameInstance}`;320}321322existingAttachmentNames.add(uniqueName);323return uniqueName;324};325326const getImageTransferDataFromUrl = async (url: string): Promise<ImageTransferData | undefined> => {327const resource = URI.parse(url);328329if (IMAGE_DATA_REGEX.test(url)) {330return { data: convertStringToUInt8Array(url), name: createDisplayName(), resource };331}332333if (URL_REGEX.test(url)) {334const data = await this.downloadImageAsUint8Array(url);335if (data) {336return { data, name: createDisplayName(), resource, id: url };337}338}339340return undefined;341};342343const getImageTransferDataFromFile = async (file: File): Promise<ImageTransferData | undefined> => {344try {345const buffer = await file.arrayBuffer();346return { data: new Uint8Array(buffer), name: createDisplayName() };347} catch (error) {348this.logService.error('Error reading file:', error);349}350351return undefined;352};353354const imageTransferData: ImageTransferData[] = [];355356// Image Web File Drag and Drop357const imageFiles = extractImageFilesFromDragEvent(e);358if (imageFiles.length) {359const imageTransferDataFromFiles = await Promise.all(imageFiles.map(file => getImageTransferDataFromFile(file)));360imageTransferData.push(...imageTransferDataFromFiles.filter(data => !!data));361}362363// Image Web URL Drag and Drop364const imageUrls = extractUrlsFromDragEvent(e);365if (imageUrls.length) {366const imageTransferDataFromUrl = await Promise.all(imageUrls.map(getImageTransferDataFromUrl));367imageTransferData.push(...imageTransferDataFromUrl.filter(data => !!data));368}369370return await this.chatAttachmentResolveService.resolveImageAttachContext(imageTransferData);371}372373private setOverlay(target: HTMLElement, type: ChatDragAndDropType | undefined): void {374// Remove any previous overlay text375this.overlayText?.remove();376this.overlayText = undefined;377378const { overlay } = this.overlays.get(target)!;379if (type !== undefined) {380// Render the overlay text381382const iconAndtextElements = renderLabelWithIcons(`$(${Codicon.attach.id}) ${this.getOverlayText(type)}`);383const htmlElements = iconAndtextElements.map(element => {384if (typeof element === 'string') {385return $('span.overlay-text', undefined, element);386}387return element;388});389390this.overlayText = $('span.attach-context-overlay-text', undefined, ...htmlElements);391this.overlayText.style.backgroundColor = this.overlayTextBackground;392overlay.appendChild(this.overlayText);393}394395overlay.classList.toggle('visible', type !== undefined);396}397398private getOverlayText(type: ChatDragAndDropType): string {399const typeName = this.getDropTypeName(type);400return localize('attacAsContext', 'Attach {0} as Context', typeName);401}402403private updateOverlayStyles(overlay: HTMLElement): void {404overlay.style.backgroundColor = this.getColor(this.styles.overlayBackground) || '';405overlay.style.color = this.getColor(this.styles.listForeground) || '';406}407408override updateStyles(): void {409this.overlays.forEach(overlay => this.updateOverlayStyles(overlay.overlay));410this.overlayTextBackground = this.getColor(this.styles.listBackground) || '';411}412}413414function containsImageDragType(e: DragEvent): boolean {415// Image detection should not have false positives, only false negatives are allowed416if (containsDragType(e, 'image')) {417return true;418}419420if (containsDragType(e, DataTransfers.FILES)) {421const files = e.dataTransfer?.files;422if (files && files.length > 0) {423return Array.from(files).some(file => file.type.startsWith('image/'));424}425426const items = e.dataTransfer?.items;427if (items && items.length > 0) {428return Array.from(items).some(item => item.type.startsWith('image/'));429}430}431432return false;433}434435function extractUrlsFromDragEvent(e: DragEvent, logService?: ILogService): string[] {436const textUrl = e.dataTransfer?.getData('text/uri-list');437if (textUrl) {438try {439const urls = UriList.parse(textUrl);440if (urls.length > 0) {441return urls;442}443} catch (error) {444logService?.error('Error parsing URI list:', error);445return [];446}447}448449return [];450}451452function extractImageFilesFromDragEvent(e: DragEvent): File[] {453const files = e.dataTransfer?.files;454if (!files) {455return [];456}457458return Array.from(files).filter(file => file.type.startsWith('image/'));459}460461462