feat: Add clipboard image paste support and fix image previews in CC sessions
- Add save_clipboard_image and cleanup_temp_images commands in Rust backend - Implement paste event handler in FloatingPromptInput to capture pasted images - Save pasted images to .claude_temp/session_id/ directory - Add automatic cleanup of temp images when session ends - Fix image preview display for file paths containing spaces - Update regex patterns to handle both quoted (@"path with spaces") and unquoted (@path) mentions - Automatically wrap paths with spaces in quotes when inserting - Update remove handler to properly handle both quoted and unquoted paths Users can now paste images directly from clipboard (e.g., screenshots) and see proper previews for all image files regardless of filename format.
This commit is contained in:
@@ -922,6 +922,105 @@ pub async fn load_session_history(
|
|||||||
Ok(messages)
|
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<String, String> {
|
||||||
|
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
|
/// Execute a new interactive Claude Code session with streaming output
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn execute_claude_code(
|
pub async fn execute_claude_code(
|
||||||
|
@@ -18,13 +18,13 @@ use commands::agents::{
|
|||||||
};
|
};
|
||||||
use commands::claude::{
|
use commands::claude::{
|
||||||
cancel_claude_execution, check_auto_checkpoint, check_claude_version, cleanup_old_checkpoints,
|
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,
|
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_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,
|
get_recently_modified_files, get_session_timeline, get_system_prompt, list_checkpoints,
|
||||||
list_directory_contents, list_projects, list_running_claude_sessions, load_session_history,
|
list_directory_contents, list_projects, list_running_claude_sessions, load_session_history,
|
||||||
open_new_session, read_claude_md_file, restore_checkpoint, resume_claude_code,
|
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,
|
track_checkpoint_message, track_session_messages, update_checkpoint_settings,
|
||||||
get_hooks_config, update_hooks_config, validate_hook_command,
|
get_hooks_config, update_hooks_config, validate_hook_command,
|
||||||
ClaudeProcessState,
|
ClaudeProcessState,
|
||||||
@@ -101,6 +101,8 @@ fn main() {
|
|||||||
find_claude_md_files,
|
find_claude_md_files,
|
||||||
read_claude_md_file,
|
read_claude_md_file,
|
||||||
save_claude_md_file,
|
save_claude_md_file,
|
||||||
|
save_clipboard_image,
|
||||||
|
cleanup_temp_images,
|
||||||
load_session_history,
|
load_session_history,
|
||||||
execute_claude_code,
|
execute_claude_code,
|
||||||
continue_claude_code,
|
continue_claude_code,
|
||||||
|
@@ -20,6 +20,7 @@ import { api, type Session } from "@/lib/api";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { StreamMessage } from "./StreamMessage";
|
import { StreamMessage } from "./StreamMessage";
|
||||||
import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingPromptInput";
|
import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingPromptInput";
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
import { ErrorBoundary } from "./ErrorBoundary";
|
||||||
@@ -827,9 +828,19 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
api.clearCheckpointManager(effectiveSession.id).catch(err => {
|
api.clearCheckpointManager(effectiveSession.id).catch(err => {
|
||||||
console.error("Failed to clear checkpoint manager:", 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 = (
|
const messagesList = (
|
||||||
<div
|
<div
|
||||||
|
@@ -19,6 +19,7 @@ import { FilePicker } from "./FilePicker";
|
|||||||
import { ImagePreview } from "./ImagePreview";
|
import { ImagePreview } from "./ImagePreview";
|
||||||
import { type FileEntry } from "@/lib/api";
|
import { type FileEntry } from "@/lib/api";
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
interface FloatingPromptInputProps {
|
interface FloatingPromptInputProps {
|
||||||
/**
|
/**
|
||||||
@@ -199,7 +200,8 @@ const FloatingPromptInputInner = (
|
|||||||
return currentPrompt; // Image already added
|
return currentPrompt; // Image already added
|
||||||
}
|
}
|
||||||
|
|
||||||
const mention = `@${imagePath}`;
|
// Wrap path in quotes if it contains spaces
|
||||||
|
const mention = imagePath.includes(' ') ? `@"${imagePath}"` : `@${imagePath}`;
|
||||||
const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mention + ' ';
|
const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mention + ' ';
|
||||||
|
|
||||||
// Focus the textarea
|
// Focus the textarea
|
||||||
@@ -225,19 +227,49 @@ const FloatingPromptInputInner = (
|
|||||||
// Extract image paths from prompt text
|
// Extract image paths from prompt text
|
||||||
const extractImagePaths = (text: string): string[] => {
|
const extractImagePaths = (text: string): string[] => {
|
||||||
console.log('[extractImagePaths] Input text:', text);
|
console.log('[extractImagePaths] Input text:', text);
|
||||||
const regex = /@([^\s]+)/g;
|
|
||||||
const matches = Array.from(text.matchAll(regex));
|
// Updated regex to handle both quoted and unquoted paths
|
||||||
console.log('[extractImagePaths] Regex matches:', matches.map(m => m[0]));
|
// 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<string>(); // Use Set to ensure uniqueness
|
const pathsSet = new Set<string>(); // 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) {
|
for (const match of matches) {
|
||||||
const path = match[1];
|
const path = match[1]; // No need to trim, quotes preserve exact path
|
||||||
console.log('[extractImagePaths] Processing path:', path);
|
console.log('[extractImagePaths] Processing quoted path:', path);
|
||||||
|
|
||||||
// Convert relative path to absolute if needed
|
// Convert relative path to absolute if needed
|
||||||
const fullPath = path.startsWith('/') ? path : (projectPath ? `${projectPath}/${path}` : path);
|
const fullPath = path.startsWith('/') ? path : (projectPath ? `${projectPath}/${path}` : path);
|
||||||
console.log('[extractImagePaths] Full path:', fullPath, 'Is image:', isImageFile(fullPath));
|
console.log('[extractImagePaths] Full path:', fullPath, 'Is image:', isImageFile(fullPath));
|
||||||
|
|
||||||
if (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
|
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 + ' ';
|
const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mentionsToAdd + ' ';
|
||||||
|
|
||||||
setTimeout(() => {
|
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<string>('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
|
// Browser drag and drop handlers - just prevent default behavior
|
||||||
// Actual file handling is done via Tauri's window-level drag-drop events
|
// Actual file handling is done via Tauri's window-level drag-drop events
|
||||||
const handleDrag = (e: React.DragEvent) => {
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
@@ -455,9 +548,19 @@ const FloatingPromptInputInner = (
|
|||||||
const handleRemoveImage = (index: number) => {
|
const handleRemoveImage = (index: number) => {
|
||||||
// Remove the corresponding @mention from the prompt
|
// Remove the corresponding @mention from the prompt
|
||||||
const imagePath = embeddedImages[index];
|
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 = [
|
const patterns = [
|
||||||
new RegExp(`@${imagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g'),
|
// Quoted full path
|
||||||
new RegExp(`@${imagePath.replace(projectPath + '/', '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g')
|
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;
|
let newPrompt = prompt;
|
||||||
@@ -514,6 +617,7 @@ const FloatingPromptInputInner = (
|
|||||||
ref={expandedTextareaRef}
|
ref={expandedTextareaRef}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={handleTextChange}
|
onChange={handleTextChange}
|
||||||
|
onPaste={handlePaste}
|
||||||
placeholder="Type your prompt here..."
|
placeholder="Type your prompt here..."
|
||||||
className="min-h-[200px] resize-none"
|
className="min-h-[200px] resize-none"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -756,6 +860,7 @@ const FloatingPromptInputInner = (
|
|||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={handleTextChange}
|
onChange={handleTextChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
placeholder={dragActive ? "Drop images here..." : "Ask Claude anything..."}
|
placeholder={dragActive ? "Drop images here..." : "Ask Claude anything..."}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -808,7 +913,7 @@ const FloatingPromptInputInner = (
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 text-xs text-muted-foreground">
|
<div className="mt-2 text-xs text-muted-foreground">
|
||||||
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"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Reference in New Issue
Block a user