From 93ce8b656fbeb3b3dee5981a4305e8c5994fcb50 Mon Sep 17 00:00:00 2001 From: Mufeed VH Date: Sun, 22 Jun 2025 01:50:29 +0530 Subject: [PATCH] feat: add image preview with drag-and-drop support - Add ImagePreview component for displaying embedded image thumbnails - Enable drag-and-drop functionality for images in FloatingPromptInput - Configure Tauri asset protocol to properly serve local image files - Support image mentions via @path syntax in prompts - Add visual feedback for drag hover states - Implement full-size image preview dialog with navigation - Handle duplicate images and limit preview to 10 thumbnails --- src-tauri/Cargo.lock | 7 + src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 6 +- src/components/FloatingPromptInput.tsx | 375 ++++++++++++++++++------- src/components/ImagePreview.tsx | 167 +++++++++++ 5 files changed, 457 insertions(+), 100 deletions(-) create mode 100644 src/components/ImagePreview.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2577818..8292b58 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1768,6 +1768,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -4075,6 +4081,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "http-range", "jni", "libc", "log", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 782bf4e..a92e55a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["protocol-asset"] } tauri-plugin-opener = "2" tauri-plugin-shell = "2" tauri-plugin-dialog = "2.0.3" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9319714..5342cbc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -18,7 +18,11 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'", + "assetProtocol": { + "enable": true, + "scope": ["**"] + } } }, "plugins": { diff --git a/src/components/FloatingPromptInput.tsx b/src/components/FloatingPromptInput.tsx index d606321..b5d7f98 100644 --- a/src/components/FloatingPromptInput.tsx +++ b/src/components/FloatingPromptInput.tsx @@ -13,7 +13,9 @@ import { Button } from "@/components/ui/button"; import { Popover } from "@/components/ui/popover"; import { Textarea } from "@/components/ui/textarea"; import { FilePicker } from "./FilePicker"; +import { ImagePreview } from "./ImagePreview"; import { type FileEntry } from "@/lib/api"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; interface FloatingPromptInputProps { /** @@ -88,9 +90,121 @@ export const FloatingPromptInput: React.FC = ({ const [showFilePicker, setShowFilePicker] = useState(false); const [filePickerQuery, setFilePickerQuery] = useState(""); const [cursorPosition, setCursorPosition] = useState(0); + const [embeddedImages, setEmbeddedImages] = useState([]); + const [dragActive, setDragActive] = useState(false); const textareaRef = useRef(null); const expandedTextareaRef = useRef(null); + const unlistenDragDropRef = useRef<(() => void) | null>(null); + + // Helper function to check if a file is an image + const isImageFile = (path: string): boolean => { + const ext = path.split('.').pop()?.toLowerCase(); + return ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'].includes(ext || ''); + }; + + // Extract image paths from prompt text + const extractImagePaths = (text: string): string[] => { + console.log('[extractImagePaths] Input text:', text); + const regex = /@([^\s]+)/g; + const matches = Array.from(text.matchAll(regex)); + console.log('[extractImagePaths] Regex matches:', matches.map(m => m[0])); + const pathsSet = new Set(); // Use Set to ensure uniqueness + + for (const match of matches) { + const path = match[1]; + console.log('[extractImagePaths] Processing path:', path); + // Convert relative path to absolute if needed + const fullPath = path.startsWith('/') ? path : (projectPath ? `${projectPath}/${path}` : path); + console.log('[extractImagePaths] Full path:', fullPath, 'Is image:', isImageFile(fullPath)); + if (isImageFile(fullPath)) { + pathsSet.add(fullPath); // Add to Set (automatically handles duplicates) + } + } + + const uniquePaths = Array.from(pathsSet); // Convert Set back to Array + console.log('[extractImagePaths] Final extracted paths (unique):', uniquePaths); + return uniquePaths; + }; + + // Update embedded images when prompt changes + useEffect(() => { + console.log('[useEffect] Prompt changed:', prompt); + const imagePaths = extractImagePaths(prompt); + console.log('[useEffect] Setting embeddedImages to:', imagePaths); + setEmbeddedImages(imagePaths); + }, [prompt, projectPath]); + + // Set up Tauri drag-drop event listener + useEffect(() => { + // This effect runs only once on component mount to set up the listener. + let lastDropTime = 0; + + const setupListener = async () => { + try { + // If a listener from a previous mount/render is still around, clean it up. + if (unlistenDragDropRef.current) { + unlistenDragDropRef.current(); + } + + const webview = getCurrentWebviewWindow(); + unlistenDragDropRef.current = await webview.onDragDropEvent((event) => { + if (event.payload.type === 'enter' || event.payload.type === 'over') { + setDragActive(true); + } else if (event.payload.type === 'leave') { + setDragActive(false); + } else if (event.payload.type === 'drop' && event.payload.paths) { + setDragActive(false); + + const currentTime = Date.now(); + if (currentTime - lastDropTime < 200) { + // This debounce is crucial to handle the storm of drop events + // that Tauri/OS can fire for a single user action. + return; + } + lastDropTime = currentTime; + + const droppedPaths = event.payload.paths as string[]; + const imagePaths = droppedPaths.filter(isImageFile); + + if (imagePaths.length > 0) { + setPrompt(currentPrompt => { + const existingPaths = extractImagePaths(currentPrompt); + const newPaths = imagePaths.filter(p => !existingPaths.includes(p)); + + if (newPaths.length === 0) { + return currentPrompt; // All dropped images are already in the prompt + } + + const mentionsToAdd = newPaths.map(p => `@${p}`).join(' '); + const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mentionsToAdd + ' '; + + setTimeout(() => { + const target = isExpanded ? expandedTextareaRef.current : textareaRef.current; + target?.focus(); + target?.setSelectionRange(newPrompt.length, newPrompt.length); + }, 0); + + return newPrompt; + }); + } + } + }); + } catch (error) { + console.error('Failed to set up Tauri drag-drop listener:', error); + } + }; + + setupListener(); + + return () => { + // On unmount, ensure we clean up the listener. + if (unlistenDragDropRef.current) { + unlistenDragDropRef.current(); + unlistenDragDropRef.current = null; + } + }; + }, []); // Empty dependency array ensures this runs only on mount/unmount. useEffect(() => { // Focus the appropriate textarea when expanded state changes @@ -105,6 +219,7 @@ export const FloatingPromptInput: React.FC = ({ if (prompt.trim() && !isLoading && !disabled) { onSend(prompt.trim(), selectedModel); setPrompt(""); + setEmbeddedImages([]); } }; @@ -196,6 +311,36 @@ export const FloatingPromptInput: React.FC = ({ } }; + // Browser drag and drop handlers - just prevent default behavior + // Actual file handling is done via Tauri's window-level drag-drop events + const handleDrag = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + // Visual feedback is handled by Tauri events + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + // File processing is handled by Tauri's onDragDropEvent + }; + + const handleRemoveImage = (index: number) => { + // Remove the corresponding @mention from the prompt + const imagePath = embeddedImages[index]; + const patterns = [ + new RegExp(`@${imagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g'), + new RegExp(`@${imagePath.replace(projectPath + '/', '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g') + ]; + + let newPrompt = prompt; + for (const pattern of patterns) { + newPrompt = newPrompt.replace(pattern, ''); + } + + setPrompt(newPrompt.trim()); + }; + const selectedModelData = MODELS.find(m => m.id === selectedModel) || MODELS[0]; return ( @@ -229,6 +374,15 @@ export const FloatingPromptInput: React.FC = ({ + {/* Image previews in expanded mode */} + {embeddedImages.length > 0 && ( + + )} +