diff --git a/src/components/ai-elements/file-tree.tsx b/src/components/ai-elements/file-tree.tsx index b2b7015b..18c57211 100644 --- a/src/components/ai-elements/file-tree.tsx +++ b/src/components/ai-elements/file-tree.tsx @@ -24,6 +24,9 @@ import { useState, } from "react"; +const TREE_PATH_MIME = "application/x-codepilot-path"; +const TREE_PATH_FALLBACK_MIME = "text/x-codepilot-path"; + interface FileTreeContextType { expandedPaths: Set; togglePath: (path: string) => void; @@ -132,6 +135,17 @@ export const FileTreeFolder = ({ togglePath(path); }, [togglePath, path]); + const handleDragStart = useCallback( + (e: React.DragEvent) => { + const payload = JSON.stringify({ path, name, type: "directory" }); + e.dataTransfer.setData(TREE_PATH_MIME, payload); + e.dataTransfer.setData(TREE_PATH_FALLBACK_MIME, payload); + e.dataTransfer.setData("text/plain", path); + e.dataTransfer.effectAllowed = "copy"; + }, + [path, name] + ); + const folderContextValue = useMemo( () => ({ isExpanded, name, path }), [isExpanded, name, path] @@ -148,6 +162,8 @@ export const FileTreeFolder = ({ >
+ + ); + })} +
+ ); +} + export function MessageInput({ onSend, onImageGenerate, @@ -380,6 +526,7 @@ export function MessageInput({ const popoverRef = useRef(null); const searchInputRef = useRef(null); const modelMenuRef = useRef(null); + const dropZoneRef = useRef(null); const [popoverMode, setPopoverMode] = useState(null); const [popoverItems, setPopoverItems] = useState([]); @@ -395,6 +542,28 @@ export function MessageInput({ const [aiSearchLoading, setAiSearchLoading] = useState(false); const aiSearchAbortRef = useRef(null); const aiSearchTimerRef = useRef | null>(null); + const [isDragOver, setIsDragOver] = useState(false); + const [contextMentions, setContextMentions] = useState([]); + + const removeContextMention = useCallback((id: string) => { + setContextMentions((prev) => prev.filter((m) => m.id !== id)); + }, []); + + const addContextMention = useCallback((path: string, name: string, type: 'file' | 'directory') => { + setContextMentions((prev) => { + if (prev.some((m) => m.path === path)) return prev; + return [...prev, { id: nanoid(), path, name, type }]; + }); + setTimeout(() => textareaRef.current?.focus(), 0); + }, []); + + const appendPathMention = useCallback((path: string) => { + setInputValue((prev) => { + const suffix = `@${path} `; + return prev ? `${prev}${suffix}` : suffix; + }); + setTimeout(() => textareaRef.current?.focus(), 0); + }, []); // Fetch provider groups from API const fetchProviderModels = useCallback(() => { @@ -602,7 +771,11 @@ export function MessageInput({ const handleSubmit = useCallback(async (msg: { text: string; files: Array<{ type: string; url: string; filename?: string; mediaType?: string }> }, e: FormEvent) => { e.preventDefault(); - const content = inputValue.trim(); + const rawContent = inputValue.trim(); + const mentionPrefix = contextMentions.length > 0 + ? contextMentions.map((m) => `@${m.path}`).join(' ') + ' ' + : ''; + const content = mentionPrefix + rawContent; closePopover(); @@ -679,6 +852,7 @@ export function MessageInput({ const files = await convertFiles(); setBadge(null); + setContextMentions([]); setInputValue(''); onSend(finalPrompt, files.length > 0 ? files : undefined); return; @@ -687,7 +861,7 @@ export function MessageInput({ const files = await convertFiles(); const hasFiles = files.length > 0; - if ((!content && !hasFiles) || disabled || isStreaming) return; + if ((!content && !hasFiles && contextMentions.length === 0) || disabled || isStreaming) return; // Check if it's a direct slash command typed in the input if (content.startsWith('/') && !hasFiles) { @@ -724,8 +898,9 @@ export function MessageInput({ } onSend(content || 'Please review the attached file(s).', hasFiles ? files : undefined); + setContextMentions([]); setInputValue(''); - }, [inputValue, onSend, onImageGenerate, onCommand, disabled, isStreaming, closePopover, badge, imageGen]); + }, [inputValue, onSend, onImageGenerate, onCommand, disabled, isStreaming, closePopover, badge, contextMentions, imageGen]); const filteredItems = popoverItems.filter((item) => { const q = popoverFilter.toLowerCase(); @@ -905,10 +1080,57 @@ export function MessageInput({ // Map isStreaming to ChatStatus for PromptInputSubmit const chatStatus: ChatStatus = isStreaming ? 'streaming' : 'ready'; + // Handle file tree drag-and-drop onto the input area using native DOM events. + // Native listeners on the wrapper div fire before React's delegated handlers, + // ensuring preventDefault() reliably blocks the browser's default text-insert + // behaviour on the textarea (which caused file paths to leak into the input). + useEffect(() => { + const el = dropZoneRef.current; + if (!el) return; + + const onDragOver = (e: DragEvent) => { + const isTreeDrag = hasDragType(e.dataTransfer, FILE_TREE_DRAG_MIME) + || hasDragType(e.dataTransfer, FILE_TREE_DRAG_FALLBACK_MIME); + + if (isTreeDrag) { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + setIsDragOver(true); + } + }; + + const onDragLeave = (e: DragEvent) => { + if (el.contains(e.relatedTarget as Node)) return; + setIsDragOver(false); + }; + + const onDrop = (e: DragEvent) => { + setIsDragOver(false); + const data = readFileTreeDropData(e.dataTransfer); + if (!data) return; + e.preventDefault(); + e.stopPropagation(); + addContextMention(data.path, data.name, data.type as 'file' | 'directory'); + }; + + el.addEventListener('dragover', onDragOver); + el.addEventListener('dragleave', onDragLeave); + el.addEventListener('drop', onDrop); + return () => { + el.removeEventListener('dragover', onDragOver); + el.removeEventListener('dragleave', onDragLeave); + el.removeEventListener('drop', onDrop); + }; + }, [addContextMention]); + return (
-
+
{/* Popover */} {popoverMode && (allDisplayedItems.length > 0 || aiSearchLoading) && (() => { const builtInItems = filteredItems.filter(item => item.builtIn); @@ -1069,7 +1291,7 @@ export function MessageInput({ multiple > {/* Bridge: listens for file tree "+" button events */} - + {/* Command badge */} {badge && (
@@ -1092,6 +1314,8 @@ export function MessageInput({ )} {/* File attachment capsules */} + {/* Context mention chips (dragged files/folders) */} + 0} />