diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index dbc1b27..64e95a3 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -183,6 +183,41 @@ pub async fn paste_image_to_terminal( .await } +/// Copy a host file (e.g. dragged onto the terminal) into the container so +/// Claude Code can read it, and return the in-container path. Mirrors the +/// image-paste flow: the file is placed under /tmp/triple-c-drops/ keeping its +/// original name. Returns an error for paths that aren't readable regular files +/// (e.g. a dropped directory). +#[tauri::command] +pub async fn upload_host_file_to_terminal( + session_id: String, + host_path: String, + state: State<'_, AppState>, +) -> Result { + let container_id = state.exec_manager.get_container_id(&session_id).await?; + + let meta = std::fs::metadata(&host_path) + .map_err(|e| format!("Cannot access {}: {}", host_path, e))?; + if meta.is_dir() { + return Err(format!("{} is a directory — drop individual files", host_path)); + } + + let data = + std::fs::read(&host_path).map_err(|e| format!("Failed to read {}: {}", host_path, e))?; + + let base = std::path::Path::new(&host_path) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "dropped-file".to_string()); + + let file_name = format!("triple-c-drops/{}", base); + state + .exec_manager + .write_file_to_container(&container_id, &file_name, &data) + .await +} + #[tauri::command] pub async fn start_audio_bridge( session_id: String, diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 7d42655..527e9b5 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -178,6 +178,7 @@ pub fn run() { commands::terminal_commands::terminal_resize, commands::terminal_commands::close_terminal_session, commands::terminal_commands::paste_image_to_terminal, + commands::terminal_commands::upload_host_file_to_terminal, commands::terminal_commands::start_audio_bridge, commands::terminal_commands::send_audio_data, commands::terminal_commands::stop_audio_bridge, diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index ee871b6..8c682c6 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -7,7 +7,8 @@ import { openUrl } from "@tauri-apps/plugin-opener"; import "@xterm/xterm/css/xterm.css"; import { useTerminal } from "../../hooks/useTerminal"; import { useAppState } from "../../store/appState"; -import { awsSsoRefresh } from "../../lib/tauri-commands"; +import { awsSsoRefresh, uploadHostFileToTerminal } from "../../lib/tauri-commands"; +import { getCurrentWebview } from "@tauri-apps/api/webview"; import { UrlDetector } from "../../lib/urlDetector"; import UrlToast from "./UrlToast"; import { trimSelection } from "./trimSelection"; @@ -47,6 +48,67 @@ export default function TerminalView({ sessionId, active }: Props) { const autoFollowRef = useRef(true); const lastUserScrollTimeRef = useRef(0); + // Keep latest `active` readable inside long-lived listeners (drag-drop below, + // and the unmount-cleanup effect further down). + const activeRef = useRef(active); + activeRef.current = active; + + // File drag-and-drop: dropped files are copied into the container and their + // in-container paths typed into the prompt so Claude Code can read them. + // Tauri intercepts OS file drops at the webview level, so we use + // onDragDropEvent (HTML5 ondrop on the element wouldn't expose file paths). + // The listener is window-wide, so we guard on `active` + a hit-test against + // this terminal's bounds to ignore drops meant for another pane. + useEffect(() => { + let unlisten: (() => void) | undefined; + let cancelled = false; + + const insideThisTerminal = (pos: { x: number; y: number }): boolean => { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return false; + const dpr = window.devicePixelRatio || 1; + const x = pos.x / dpr; + const y = pos.y / dpr; + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + }; + + const quote = (p: string) => (/\s/.test(p) ? `'${p.replace(/'/g, "'\\''")}'` : p); + + (async () => { + const un = await getCurrentWebview().onDragDropEvent(async (event) => { + if (event.payload.type !== "drop") return; + if (!activeRef.current) return; + if (!insideThisTerminal(event.payload.position)) return; + + const paths = event.payload.paths ?? []; + if (paths.length === 0) return; + + setImagePasteMsg(`Adding ${paths.length} file${paths.length > 1 ? "s" : ""}…`); + const containerPaths: string[] = []; + for (const p of paths) { + try { + containerPaths.push(await uploadHostFileToTerminal(sessionId, p)); + } catch (err) { + console.error("File drop upload failed for", p, err); + } + } + if (containerPaths.length === 0) { + setImagePasteMsg("File drop failed"); + return; + } + sendInput(sessionId, containerPaths.map(quote).join(" ") + " "); + setImagePasteMsg(`Added ${containerPaths.length} file path${containerPaths.length > 1 ? "s" : ""}`); + }); + if (cancelled) un(); + else unlisten = un; + })(); + + return () => { + cancelled = true; + unlisten?.(); + }; + }, [sessionId, sendInput]); + useEffect(() => { if (!containerRef.current) return; @@ -403,8 +465,6 @@ export default function TerminalView({ sessionId, active }: Props) { // state so it doesn't point at a disposed terminal. (Tab switches don't // unmount — the deactivating terminal stays mounted but hidden — so this // only fires when the active session is actually closed.) - const activeRef = useRef(active); - activeRef.current = active; useEffect(() => { return () => { if (activeRef.current) { diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index c0f7a84..c894db2 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -55,6 +55,8 @@ export const closeTerminalSession = (sessionId: string) => invoke("close_terminal_session", { sessionId }); export const pasteImageToTerminal = (sessionId: string, imageData: number[]) => invoke("paste_image_to_terminal", { sessionId, imageData }); +export const uploadHostFileToTerminal = (sessionId: string, hostPath: string) => + invoke("upload_host_file_to_terminal", { sessionId, hostPath }); export const startAudioBridge = (sessionId: string) => invoke("start_audio_bridge", { sessionId }); export const sendAudioData = (sessionId: string, data: number[]) =>