Add file drag-and-drop onto the terminal
Build App / compute-version (pull_request) Successful in 6s
Build Container / build-container (pull_request) Successful in 1m25s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-windows (pull_request) Successful in 4m14s
Build App / build-linux (pull_request) Successful in 4m54s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped

Drop files onto a terminal pane and they're copied into the container and
their in-container paths typed into the prompt, so Claude Code can read
them for reference — mirroring the existing image-paste flow.

Backend: upload_host_file_to_terminal reads the dropped host file and
writes it under /tmp/triple-c-drops/<name> in the session's container,
returning that path. Rejects directories and unreadable paths.

Frontend: TerminalView subscribes to Tauri's webview onDragDropEvent
(OS file drops are intercepted at the webview level, so HTML5 ondrop
wouldn't expose paths). The window-wide event is guarded by the pane's
`active` flag plus a bounds hit-test so a drop only affects the terminal
it landed on; multiple files are uploaded and their paths inserted
space-separated (quoted when they contain spaces).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 14:11:09 -07:00
parent 10e689eaa6
commit 84e0bdf7b4
4 changed files with 101 additions and 3 deletions
+63 -3
View File
@@ -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) {
+2
View File
@@ -55,6 +55,8 @@ export const closeTerminalSession = (sessionId: string) =>
invoke<void>("close_terminal_session", { sessionId });
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
export const uploadHostFileToTerminal = (sessionId: string, hostPath: string) =>
invoke<string>("upload_host_file_to_terminal", { sessionId, hostPath });
export const startAudioBridge = (sessionId: string) =>
invoke<void>("start_audio_bridge", { sessionId });
export const sendAudioData = (sessionId: string, data: number[]) =>