diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 42f2fd9..6c6fd5d 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -922,6 +922,105 @@ pub async fn load_session_history( Ok(messages) } +/// Saves a clipboard image to a temporary location for a session +/// +/// This command handles pasted images by saving them to a temp directory +/// within the project structure, making them accessible for Claude. +#[tauri::command] +pub async fn save_clipboard_image( + project_path: String, + session_id: String, + image_data: String, // Base64 encoded image data + mime_type: String, +) -> Result { + log::info!( + "Saving clipboard image for session: {} in project: {}", + session_id, + project_path + ); + + // Parse the base64 data (remove data URL prefix if present) + let base64_data = if image_data.starts_with("data:") { + image_data + .split(',') + .nth(1) + .ok_or("Invalid data URL format")? + } else { + &image_data + }; + + // Decode base64 to bytes + use base64::Engine; + let image_bytes = base64::engine::general_purpose::STANDARD + .decode(base64_data) + .map_err(|e| format!("Failed to decode base64 image: {}", e))?; + + // Determine file extension from MIME type + let extension = match mime_type.as_str() { + "image/png" => "png", + "image/jpeg" | "image/jpg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + "image/svg+xml" => "svg", + _ => "png", // Default to PNG + }; + + // Create temp directory for the project if it doesn't exist + let project_path_buf = PathBuf::from(&project_path); + let temp_dir = project_path_buf.join(".claude_temp").join(&session_id); + fs::create_dir_all(&temp_dir) + .map_err(|e| format!("Failed to create temp directory: {}", e))?; + + // Generate unique filename with timestamp + let timestamp = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let filename = format!("pasted_image_{}.{}", timestamp, extension); + let file_path = temp_dir.join(&filename); + + // Write image to file + fs::write(&file_path, image_bytes) + .map_err(|e| format!("Failed to write image file: {}", e))?; + + // Return the absolute path + let absolute_path = file_path + .canonicalize() + .map_err(|e| format!("Failed to get absolute path: {}", e))? + .to_string_lossy() + .to_string(); + + log::info!("Saved clipboard image to: {}", absolute_path); + Ok(absolute_path) +} + +/// Cleans up temporary images for a session +/// +/// This command removes the temporary directory created for pasted images +/// when a session ends to avoid accumulating temporary files. +#[tauri::command] +pub async fn cleanup_temp_images( + project_path: String, + session_id: String, +) -> Result<(), String> { + log::info!( + "Cleaning up temp images for session: {} in project: {}", + session_id, + project_path + ); + + let project_path_buf = PathBuf::from(&project_path); + let temp_dir = project_path_buf.join(".claude_temp").join(&session_id); + + if temp_dir.exists() { + fs::remove_dir_all(&temp_dir) + .map_err(|e| format!("Failed to remove temp directory: {}", e))?; + log::info!("Cleaned up temp directory: {}", temp_dir.display()); + } + + Ok(()) +} + /// Execute a new interactive Claude Code session with streaming output #[tauri::command] pub async fn execute_claude_code( diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c9f9cd1..668f5bf 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -18,13 +18,13 @@ use commands::agents::{ }; use commands::claude::{ cancel_claude_execution, check_auto_checkpoint, check_claude_version, cleanup_old_checkpoints, - clear_checkpoint_manager, continue_claude_code, create_checkpoint, execute_claude_code, + cleanup_temp_images, clear_checkpoint_manager, continue_claude_code, create_checkpoint, execute_claude_code, find_claude_md_files, fork_from_checkpoint, get_checkpoint_diff, get_checkpoint_settings, get_checkpoint_state_stats, get_claude_session_output, get_claude_settings, get_project_sessions, get_recently_modified_files, get_session_timeline, get_system_prompt, list_checkpoints, list_directory_contents, list_projects, list_running_claude_sessions, load_session_history, open_new_session, read_claude_md_file, restore_checkpoint, resume_claude_code, - save_claude_md_file, save_claude_settings, save_system_prompt, search_files, + save_claude_md_file, save_claude_settings, save_clipboard_image, save_system_prompt, search_files, track_checkpoint_message, track_session_messages, update_checkpoint_settings, get_hooks_config, update_hooks_config, validate_hook_command, ClaudeProcessState, @@ -101,6 +101,8 @@ fn main() { find_claude_md_files, read_claude_md_file, save_claude_md_file, + save_clipboard_image, + cleanup_temp_images, load_session_history, execute_claude_code, continue_claude_code, diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index f2589da..4b54f3a 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -20,6 +20,7 @@ import { api, type Session } from "@/lib/api"; import { cn } from "@/lib/utils"; import { open } from "@tauri-apps/plugin-dialog"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { invoke } from "@tauri-apps/api/core"; import { StreamMessage } from "./StreamMessage"; import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingPromptInput"; import { ErrorBoundary } from "./ErrorBoundary"; @@ -827,9 +828,19 @@ export const ClaudeCodeSession: React.FC = ({ api.clearCheckpointManager(effectiveSession.id).catch(err => { console.error("Failed to clear checkpoint manager:", err); }); + + // Clean up temporary images + if (projectPath) { + invoke('cleanup_temp_images', { + projectPath, + sessionId: effectiveSession.id + }).catch((err: any) => { + console.error("Failed to cleanup temp images:", err); + }); + } } }; - }, [effectiveSession]); + }, [effectiveSession, projectPath]); const messagesList = (
{ 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])); + + // Updated regex to handle both quoted and unquoted paths + // Pattern 1: @"path with spaces" - quoted paths + // Pattern 2: @path - unquoted paths (continues until @ or end) + const quotedRegex = /@"([^"]+)"/g; + const unquotedRegex = /@([^@\n\s]+)/g; + const pathsSet = new Set(); // Use Set to ensure uniqueness - + + // First, extract quoted paths + let matches = Array.from(text.matchAll(quotedRegex)); + console.log('[extractImagePaths] Quoted matches:', matches.map(m => m[0])); + for (const match of matches) { - const path = match[1]; - console.log('[extractImagePaths] Processing path:', path); + const path = match[1]; // No need to trim, quotes preserve exact path + console.log('[extractImagePaths] Processing quoted 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) + pathsSet.add(fullPath); + } + } + + // Remove quoted mentions from text to avoid double-matching + let textWithoutQuoted = text.replace(quotedRegex, ''); + + // Then extract unquoted paths + matches = Array.from(textWithoutQuoted.matchAll(unquotedRegex)); + console.log('[extractImagePaths] Unquoted matches:', matches.map(m => m[0])); + + for (const match of matches) { + const path = match[1].trim(); + console.log('[extractImagePaths] Processing unquoted 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); } } @@ -295,7 +327,14 @@ const FloatingPromptInputInner = ( return currentPrompt; // All dropped images are already in the prompt } - const mentionsToAdd = newPaths.map(p => `@${p}`).join(' '); + // Wrap paths with spaces in quotes for clarity + const mentionsToAdd = newPaths.map(p => { + // If path contains spaces, wrap in quotes + if (p.includes(' ')) { + return `@"${p}"`; + } + return `@${p}`; + }).join(' '); const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mentionsToAdd + ' '; setTimeout(() => { @@ -438,6 +477,60 @@ const FloatingPromptInputInner = ( } }; + const handlePaste = async (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items || !projectPath) return; + + for (const item of items) { + if (item.type.startsWith('image/')) { + e.preventDefault(); + + // Get the image blob + const blob = item.getAsFile(); + if (!blob) continue; + + try { + // Convert blob to base64 + const reader = new FileReader(); + reader.onload = async () => { + const base64Data = reader.result as string; + + // Generate a session-specific ID for the image + const sessionId = `paste-${Date.now()}`; + + // Save the image via Tauri command + const imagePath = await invoke('save_clipboard_image', { + projectPath, + sessionId, + imageData: base64Data, + mimeType: item.type + }); + + // Add the image path as a mention to the prompt + setPrompt(currentPrompt => { + // Wrap path in quotes if it contains spaces + const mention = imagePath.includes(' ') ? `@"${imagePath}"` : `@${imagePath}`; + const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mention + ' '; + + // Focus the textarea and move cursor to end + setTimeout(() => { + const target = isExpanded ? expandedTextareaRef.current : textareaRef.current; + target?.focus(); + target?.setSelectionRange(newPrompt.length, newPrompt.length); + }, 0); + + return newPrompt; + }); + }; + + reader.readAsDataURL(blob); + } catch (error) { + console.error('Failed to paste image:', error); + } + } + } + }; + // 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) => { @@ -455,9 +548,19 @@ const FloatingPromptInputInner = ( const handleRemoveImage = (index: number) => { // Remove the corresponding @mention from the prompt const imagePath = embeddedImages[index]; + const escapedPath = imagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escapedRelativePath = imagePath.replace(projectPath + '/', '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + // Create patterns for both quoted and unquoted mentions const patterns = [ - new RegExp(`@${imagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g'), - new RegExp(`@${imagePath.replace(projectPath + '/', '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g') + // Quoted full path + new RegExp(`@"${escapedPath}"\\s?`, 'g'), + // Unquoted full path + new RegExp(`@${escapedPath}\\s?`, 'g'), + // Quoted relative path + new RegExp(`@"${escapedRelativePath}"\\s?`, 'g'), + // Unquoted relative path + new RegExp(`@${escapedRelativePath}\\s?`, 'g') ]; let newPrompt = prompt; @@ -514,6 +617,7 @@ const FloatingPromptInputInner = ( ref={expandedTextareaRef} value={prompt} onChange={handleTextChange} + onPaste={handlePaste} placeholder="Type your prompt here..." className="min-h-[200px] resize-none" disabled={disabled} @@ -756,6 +860,7 @@ const FloatingPromptInputInner = ( value={prompt} onChange={handleTextChange} onKeyDown={handleKeyDown} + onPaste={handlePaste} placeholder={dragActive ? "Drop images here..." : "Ask Claude anything..."} disabled={disabled} className={cn( @@ -808,7 +913,7 @@ const FloatingPromptInputInner = (
- Press Enter to send, Shift+Enter for new line{projectPath?.trim() && ", @ to mention files, drag & drop images"} + Press Enter to send, Shift+Enter for new line{projectPath?.trim() && ", @ to mention files, drag & drop or paste images"}