diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index f5e587f..62fe95a 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -72,3 +72,23 @@ pub async fn close_terminal_session( state.exec_manager.close_session(&session_id).await; Ok(()) } + +#[tauri::command] +pub async fn paste_image_to_terminal( + session_id: String, + image_data: Vec, + state: State<'_, AppState>, +) -> Result { + let container_id = state.exec_manager.get_container_id(&session_id).await?; + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let file_name = format!("clipboard_{}.png", timestamp); + + state + .exec_manager + .write_file_to_container(&container_id, &file_name, &image_data) + .await +} diff --git a/app/src-tauri/src/docker/exec.rs b/app/src-tauri/src/docker/exec.rs index 104bc29..cdb9ac8 100644 --- a/app/src-tauri/src/docker/exec.rs +++ b/app/src-tauri/src/docker/exec.rs @@ -1,3 +1,4 @@ +use bollard::container::UploadToContainerOptions; use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults}; use futures_util::StreamExt; use std::collections::HashMap; @@ -212,4 +213,51 @@ impl ExecSessionManager { session.shutdown(); } } + + pub async fn get_container_id(&self, session_id: &str) -> Result { + let sessions = self.sessions.lock().await; + let session = sessions + .get(session_id) + .ok_or_else(|| format!("Session {} not found", session_id))?; + Ok(session.container_id.clone()) + } + + pub async fn write_file_to_container( + &self, + container_id: &str, + file_name: &str, + data: &[u8], + ) -> Result { + let docker = get_docker()?; + + // Build a tar archive in memory containing the file + let mut tar_buf = Vec::new(); + { + let mut builder = tar::Builder::new(&mut tar_buf); + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data(&mut header, file_name, data) + .map_err(|e| format!("Failed to create tar entry: {}", e))?; + builder + .finish() + .map_err(|e| format!("Failed to finalize tar: {}", e))?; + } + + docker + .upload_to_container( + container_id, + Some(UploadToContainerOptions { + path: "/tmp".to_string(), + ..Default::default() + }), + tar_buf.into(), + ) + .await + .map_err(|e| format!("Failed to upload file to container: {}", e))?; + + Ok(format!("/tmp/{}", file_name)) + } } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index e8a9d74..3507b58 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -90,6 +90,7 @@ pub fn run() { commands::terminal_commands::terminal_input, commands::terminal_commands::terminal_resize, commands::terminal_commands::close_terminal_session, + commands::terminal_commands::paste_image_to_terminal, // Updates commands::update_commands::get_app_version, commands::update_commands::check_for_updates, diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index 1b54e5a..6725dc4 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -21,9 +21,10 @@ export default function TerminalView({ sessionId, active }: Props) { const fitRef = useRef(null); const webglRef = useRef(null); const detectorRef = useRef(null); - const { sendInput, resize, onOutput, onExit } = useTerminal(); + const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal(); const [detectedUrl, setDetectedUrl] = useState(null); + const [imagePasteMsg, setImagePasteMsg] = useState(null); useEffect(() => { if (!containerRef.current) return; @@ -85,6 +86,40 @@ export default function TerminalView({ sessionId, active }: Props) { sendInput(sessionId, data); }); + // Handle image paste: intercept paste events with image data, + // upload to the container, and inject the file path into terminal input. + const handlePaste = (e: ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + + for (const item of Array.from(items)) { + if (item.type.startsWith("image/")) { + e.preventDefault(); + e.stopPropagation(); + + const blob = item.getAsFile(); + if (!blob) return; + + blob.arrayBuffer().then(async (buf) => { + try { + setImagePasteMsg("Uploading image..."); + const data = new Uint8Array(buf); + const filePath = await pasteImage(sessionId, data); + // Inject the file path into terminal stdin + sendInput(sessionId, filePath); + setImagePasteMsg(`Image saved to ${filePath}`); + } catch (err) { + console.error("Image paste failed:", err); + setImagePasteMsg("Image paste failed"); + } + }); + return; // Only handle the first image + } + } + }; + + containerRef.current.addEventListener("paste", handlePaste, { capture: true }); + // Handle backend output -> terminal let aborted = false; @@ -129,6 +164,7 @@ export default function TerminalView({ sessionId, active }: Props) { detector.dispose(); detectorRef.current = null; inputDisposable.dispose(); + containerRef.current?.removeEventListener("paste", handlePaste, { capture: true }); outputPromise.then((fn) => fn?.()); exitPromise.then((fn) => fn?.()); if (resizeRafId !== null) cancelAnimationFrame(resizeRafId); @@ -179,6 +215,13 @@ export default function TerminalView({ sessionId, active }: Props) { return () => clearTimeout(timer); }, [detectedUrl]); + // Auto-dismiss image paste message after 3 seconds + useEffect(() => { + if (!imagePasteMsg) return; + const timer = setTimeout(() => setImagePasteMsg(null), 3_000); + return () => clearTimeout(timer); + }, [imagePasteMsg]); + const handleOpenUrl = useCallback(() => { if (detectedUrl) { openUrl(detectedUrl).catch((e) => @@ -200,6 +243,14 @@ export default function TerminalView({ sessionId, active }: Props) { onDismiss={() => setDetectedUrl(null)} /> )} + {imagePasteMsg && ( +
setImagePasteMsg(null)} + > + {imagePasteMsg} +
+ )}
{ + const bytes = Array.from(imageData); + return commands.pasteImageToTerminal(sessionId, bytes); + }, + [], + ); + const onOutput = useCallback( (sessionId: string, callback: (data: Uint8Array) => void) => { const eventName = `terminal-output-${sessionId}`; @@ -76,6 +84,7 @@ export function useTerminal() { open, close, sendInput, + pasteImage, resize, onOutput, onExit, diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 88f25bd..6197a50 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -47,6 +47,8 @@ export const terminalResize = (sessionId: string, cols: number, rows: number) => invoke("terminal_resize", { sessionId, cols, rows }); export const closeTerminalSession = (sessionId: string) => invoke("close_terminal_session", { sessionId }); +export const pasteImageToTerminal = (sessionId: string, imageData: number[]) => + invoke("paste_image_to_terminal", { sessionId, imageData }); // Updates export const getAppVersion = () => invoke("get_app_version");