Fix backend-switch AWS auth + add /workspace backup and terminal file drag-and-drop #8

Merged
jknapp merged 9 commits from fix/backend-switch-aws-creds into main 2026-06-30 22:20:00 +00:00
4 changed files with 101 additions and 3 deletions
Showing only changes of commit 84e0bdf7b4 - Show all commits
@@ -183,6 +183,41 @@ pub async fn paste_image_to_terminal(
.await .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] #[tauri::command]
pub async fn start_audio_bridge( pub async fn start_audio_bridge(
session_id: String, session_id: String,
+1
View File
@@ -178,6 +178,7 @@ pub fn run() {
commands::terminal_commands::terminal_resize, commands::terminal_commands::terminal_resize,
commands::terminal_commands::close_terminal_session, commands::terminal_commands::close_terminal_session,
commands::terminal_commands::paste_image_to_terminal, commands::terminal_commands::paste_image_to_terminal,
commands::terminal_commands::upload_host_file_to_terminal,
commands::terminal_commands::start_audio_bridge, commands::terminal_commands::start_audio_bridge,
commands::terminal_commands::send_audio_data, commands::terminal_commands::send_audio_data,
commands::terminal_commands::stop_audio_bridge, commands::terminal_commands::stop_audio_bridge,
+63 -3
View File
@@ -7,7 +7,8 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState"; 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 { UrlDetector } from "../../lib/urlDetector";
import UrlToast from "./UrlToast"; import UrlToast from "./UrlToast";
import { trimSelection } from "./trimSelection"; import { trimSelection } from "./trimSelection";
@@ -47,6 +48,67 @@ export default function TerminalView({ sessionId, active }: Props) {
const autoFollowRef = useRef(true); const autoFollowRef = useRef(true);
const lastUserScrollTimeRef = useRef(0); 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(() => { useEffect(() => {
if (!containerRef.current) return; 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 // state so it doesn't point at a disposed terminal. (Tab switches don't
// unmount — the deactivating terminal stays mounted but hidden — so this // unmount — the deactivating terminal stays mounted but hidden — so this
// only fires when the active session is actually closed.) // only fires when the active session is actually closed.)
const activeRef = useRef(active);
activeRef.current = active;
useEffect(() => { useEffect(() => {
return () => { return () => {
if (activeRef.current) { if (activeRef.current) {
+2
View File
@@ -55,6 +55,8 @@ export const closeTerminalSession = (sessionId: string) =>
invoke<void>("close_terminal_session", { sessionId }); invoke<void>("close_terminal_session", { sessionId });
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) => export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
invoke<string>("paste_image_to_terminal", { sessionId, imageData }); 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) => export const startAudioBridge = (sessionId: string) =>
invoke<void>("start_audio_bridge", { sessionId }); invoke<void>("start_audio_bridge", { sessionId });
export const sendAudioData = (sessionId: string, data: number[]) => export const sendAudioData = (sessionId: string, data: number[]) =>