Fix backend-switch AWS auth + add /workspace backup and terminal file drag-and-drop #8
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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[]) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user