diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 61419487cc..8ba0df7ab2 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -36,6 +36,26 @@ import { ZooCodeAuthBadge } from "./ZooCodeAuthBadge" import { usePromptHistory } from "./hooks/usePromptHistory" import { disabledChatControlClassName, enabledChatControlClassName } from "./chatControlStyles" +// Data transfer types that signal a file-oriented drag rather than an in-webview +// text selection drag. "Files" covers drags from the OS file manager; the +// remaining types are set by VS Code when dragging resources from the Explorer +// or editor tabs, in local and remote workspaces alike (see fillEditorsDragData +// in VS Code's workbench drag-and-drop code). Bare text/uri-list also +// identifies browser link drags, so it must retain native drop behavior. +const FILE_DRAG_DATA_TRANSFER_TYPES = [ + "Files", + "ResourceURLs", + "CodeEditors", + "CodeFiles", + "application/vnd.code.uri-list", +] + +// Chromium lowercases custom drag types; compare without relying on casing. +const isFileDrag = (dataTransfer: DataTransfer): boolean => + dataTransfer.types.some((type) => + FILE_DRAG_DATA_TRANSFER_TYPES.some((fileType) => fileType.toLowerCase() === type.toLowerCase()), + ) + interface ChatTextAreaProps { inputValue: string setInputValue: (value: string) => void @@ -967,8 +987,11 @@ export const ChatTextArea = forwardRef( className={cn("chat-text-area", !isEditMode && "relative", "flex", "flex-col", "outline-none")} onDrop={handleDrop} onDragOver={(e) => { - // Only allowed to drop images/files on shift key pressed. - if (!e.shiftKey) { + // Accept file drags from the OS file manager, the VS Code + // Explorer, and editor tabs without requiring Shift. In-webview + // text drags, such as moving a selection inside the input, keep + // the browser's native drop behavior unless Shift is held. + if (!e.shiftKey && !isFileDrag(e.dataTransfer)) { setIsDraggingOver(false) return } diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx index 5cced6a92f..4668c39f71 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx @@ -521,6 +521,66 @@ describe("ChatTextArea", () => { expect(setInputValue).not.toHaveBeenCalled() }) + describe("dragover acceptance", () => { + // jsdom has no DragEvent constructor, so build a plain cancelable + // Event and attach the drag payload the React handler expects. + const fireDragOver = (element: Element, types: string[], shiftKey = false) => { + const event = new Event("dragover", { bubbles: true, cancelable: true }) + const dataTransfer = { types, files: [], dropEffect: "none" } + Object.defineProperty(event, "dataTransfer", { value: dataTransfer }) + Object.defineProperty(event, "shiftKey", { value: shiftKey }) + fireEvent(element, event) + return { event, dataTransfer } + } + + it.each([ + "Files", + "resourceurls", + "codeeditors", + "codefiles", + "application/vnd.code.uri-list", + "RESOURCEURLS", + ])("accepts %s drags without Shift", (type) => { + const { container } = render() + + const { event, dataTransfer } = fireDragOver(container.querySelector(".chat-text-area")!, [type]) + + expect(event.defaultPrevented).toBe(true) + expect(dataTransfer.dropEffect).toBe("copy") + expect(container.querySelector(".border-dashed")).not.toBeNull() + }) + + it.each([["text/plain"], ["text/uri-list"], ["text/plain", "text/uri-list"]])( + "keeps native behavior and clears the highlight for drag types %j without Shift", + (...types) => { + const { container } = render() + const target = container.querySelector(".chat-text-area")! + fireDragOver(target, ["Files"]) + expect(container.querySelector(".border-dashed")).not.toBeNull() + + const { event, dataTransfer } = fireDragOver(target, types) + + expect(event.defaultPrevented).toBe(false) + expect(dataTransfer.dropEffect).toBe("none") + expect(container.querySelector(".border-dashed")).toBeNull() + }, + ) + + it("still accepts plain text drags when Shift is held", () => { + const { container } = render() + + const { event, dataTransfer } = fireDragOver( + container.querySelector(".chat-text-area")!, + ["text/plain"], + true, + ) + + expect(event.defaultPrevented).toBe(true) + expect(dataTransfer.dropEffect).toBe("copy") + expect(container.querySelector(".border-dashed")).not.toBeNull() + }) + }) + describe("prompt history navigation", () => { const mockClineMessages = [ { type: "say", say: "user_feedback", text: "First prompt", ts: 1000 },