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
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:
@@ -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<String, String> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[]) =>
|
||||
|
||||
Reference in New Issue
Block a user