diff --git a/app/src-tauri/src/commands/file_commands.rs b/app/src-tauri/src/commands/file_commands.rs new file mode 100644 index 0000000..196e50a --- /dev/null +++ b/app/src-tauri/src/commands/file_commands.rs @@ -0,0 +1,212 @@ +use bollard::container::{DownloadFromContainerOptions, UploadToContainerOptions}; +use futures_util::StreamExt; +use serde::Serialize; +use tauri::State; + +use crate::docker::client::get_docker; +use crate::docker::exec::exec_oneshot; +use crate::AppState; + +#[derive(Debug, Serialize)] +pub struct FileEntry { + pub name: String, + pub path: String, + pub is_directory: bool, + pub size: u64, + pub modified: String, + pub permissions: String, +} + +#[tauri::command] +pub async fn list_container_files( + project_id: String, + path: String, + state: State<'_, AppState>, +) -> Result, String> { + let project = state + .projects_store + .get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + + let container_id = project + .container_id + .as_ref() + .ok_or_else(|| "Container not running".to_string())?; + + let cmd = vec![ + "find".to_string(), + path.clone(), + "-maxdepth".to_string(), + "1".to_string(), + "-not".to_string(), + "-name".to_string(), + ".".to_string(), + "-printf".to_string(), + "%f\t%y\t%s\t%T@\t%m\n".to_string(), + ]; + + let output = exec_oneshot(container_id, cmd).await?; + + let mut entries: Vec = output + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| { + let parts: Vec<&str> = line.split('\t').collect(); + if parts.len() < 5 { + return None; + } + let name = parts[0].to_string(); + let is_directory = parts[1] == "d"; + let size = parts[2].parse::().unwrap_or(0); + let modified_epoch = parts[3].parse::().unwrap_or(0.0); + let permissions = parts[4].to_string(); + + // Convert epoch to ISO-ish string + let modified = { + let secs = modified_epoch as i64; + let dt = chrono::DateTime::from_timestamp(secs, 0) + .unwrap_or_default(); + dt.format("%Y-%m-%d %H:%M:%S").to_string() + }; + + let entry_path = if path.ends_with('/') { + format!("{}{}", path, name) + } else { + format!("{}/{}", path, name) + }; + + Some(FileEntry { + name, + path: entry_path, + is_directory, + size, + modified, + permissions, + }) + }) + .collect(); + + // Sort: directories first, then alphabetical + entries.sort_by(|a, b| { + b.is_directory + .cmp(&a.is_directory) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + + Ok(entries) +} + +#[tauri::command] +pub async fn download_container_file( + project_id: String, + container_path: String, + host_path: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let project = state + .projects_store + .get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + + let container_id = project + .container_id + .as_ref() + .ok_or_else(|| "Container not running".to_string())?; + + let docker = get_docker()?; + + let mut stream = docker.download_from_container( + container_id, + Some(DownloadFromContainerOptions { + path: container_path.clone(), + }), + ); + + let mut tar_bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("Failed to download file: {}", e))?; + tar_bytes.extend_from_slice(&chunk); + } + + // Extract single file from tar archive + let mut archive = tar::Archive::new(&tar_bytes[..]); + let mut found = false; + for entry in archive + .entries() + .map_err(|e| format!("Failed to read tar entries: {}", e))? + { + let mut entry = entry.map_err(|e| format!("Failed to read tar entry: {}", e))?; + let mut contents = Vec::new(); + std::io::Read::read_to_end(&mut entry, &mut contents) + .map_err(|e| format!("Failed to read file contents: {}", e))?; + std::fs::write(&host_path, &contents) + .map_err(|e| format!("Failed to write file to host: {}", e))?; + found = true; + break; + } + + if !found { + return Err("File not found in tar archive".to_string()); + } + + Ok(()) +} + +#[tauri::command] +pub async fn upload_file_to_container( + project_id: String, + host_path: String, + container_dir: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let project = state + .projects_store + .get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + + let container_id = project + .container_id + .as_ref() + .ok_or_else(|| "Container not running".to_string())?; + + let docker = get_docker()?; + + let file_data = std::fs::read(&host_path) + .map_err(|e| format!("Failed to read host file: {}", e))?; + + let file_name = std::path::Path::new(&host_path) + .file_name() + .ok_or_else(|| "Invalid file path".to_string())? + .to_string_lossy() + .to_string(); + + // Build tar archive in memory + 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(file_data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data(&mut header, &file_name, &file_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: container_dir, + ..Default::default() + }), + tar_buf.into(), + ) + .await + .map_err(|e| format!("Failed to upload file to container: {}", e))?; + + Ok(()) +} diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index 6dd8ec6..6f0d0bd 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod docker_commands; +pub mod file_commands; pub mod mcp_commands; pub mod project_commands; pub mod settings_commands; diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index a23604d..aede054 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -73,6 +73,7 @@ exec claude --dangerously-skip-permissions pub async fn open_terminal_session( project_id: String, session_id: String, + session_type: Option, app_handle: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { @@ -86,7 +87,10 @@ pub async fn open_terminal_session( .as_ref() .ok_or_else(|| "Container not running".to_string())?; - let cmd = build_terminal_cmd(&project, &state); + let cmd = match session_type.as_deref() { + Some("bash") => vec!["bash".to_string(), "-l".to_string()], + _ => build_terminal_cmd(&project, &state), + }; let output_event = format!("terminal-output-{}", session_id); let exit_event = format!("terminal-exit-{}", session_id); diff --git a/app/src-tauri/src/docker/exec.rs b/app/src-tauri/src/docker/exec.rs index 21f6b39..4ece369 100644 --- a/app/src-tauri/src/docker/exec.rs +++ b/app/src-tauri/src/docker/exec.rs @@ -277,3 +277,41 @@ impl ExecSessionManager { Ok(format!("/tmp/{}", file_name)) } } + +/// Run a one-shot (non-interactive) exec command in a container and collect stdout. +pub async fn exec_oneshot(container_id: &str, cmd: Vec) -> Result { + let docker = get_docker()?; + + let exec = docker + .create_exec( + container_id, + CreateExecOptions { + attach_stdout: Some(true), + attach_stderr: Some(true), + cmd: Some(cmd), + user: Some("claude".to_string()), + ..Default::default() + }, + ) + .await + .map_err(|e| format!("Failed to create exec: {}", e))?; + + let result = docker + .start_exec(&exec.id, None) + .await + .map_err(|e| format!("Failed to start exec: {}", e))?; + + match result { + StartExecResults::Attached { mut output, .. } => { + let mut stdout = String::new(); + while let Some(msg) = output.next().await { + match msg { + Ok(data) => stdout.push_str(&String::from_utf8_lossy(&data.into_bytes())), + Err(e) => return Err(format!("Exec output error: {}", e)), + } + } + Ok(stdout) + } + StartExecResults::Detached => Err("Exec started in detached mode".to_string()), + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 46c5948..4844975 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -104,6 +104,10 @@ pub fn run() { commands::terminal_commands::start_audio_bridge, commands::terminal_commands::send_audio_data, commands::terminal_commands::stop_audio_bridge, + // Files + commands::file_commands::list_container_files, + commands::file_commands::download_container_file, + commands::file_commands::upload_file_to_container, // MCP commands::mcp_commands::list_mcp_servers, commands::mcp_commands::add_mcp_server, diff --git a/app/src/components/projects/FileManagerModal.tsx b/app/src/components/projects/FileManagerModal.tsx new file mode 100644 index 0000000..642aec2 --- /dev/null +++ b/app/src/components/projects/FileManagerModal.tsx @@ -0,0 +1,197 @@ +import { useEffect, useRef, useCallback } from "react"; +import { useFileManager } from "../../hooks/useFileManager"; + +interface Props { + projectId: string; + projectName: string; + onClose: () => void; +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +export default function FileManagerModal({ projectId, projectName, onClose }: Props) { + const { + currentPath, + entries, + loading, + error, + navigate, + goUp, + refresh, + downloadFile, + uploadFile, + } = useFileManager(projectId); + const overlayRef = useRef(null); + + // Load initial directory + useEffect(() => { + navigate("/workspace"); + }, [navigate]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + const handleOverlayClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === overlayRef.current) onClose(); + }, + [onClose], + ); + + // Build breadcrumbs from current path + const breadcrumbs = currentPath === "/" + ? [{ label: "/", path: "/" }] + : currentPath.split("/").reduce<{ label: string; path: string }[]>((acc, part, i) => { + if (i === 0) { + acc.push({ label: "/", path: "/" }); + } else if (part) { + const parentPath = acc[acc.length - 1].path; + const fullPath = parentPath === "/" ? `/${part}` : `${parentPath}/${part}`; + acc.push({ label: part, path: fullPath }); + } + return acc; + }, []); + + return ( +
+
+ {/* Header */} +
+

Files — {projectName}

+ +
+ + {/* Path bar */} +
+ {breadcrumbs.map((crumb, i) => ( + + {i > 0 && /} + + + ))} +
+ +
+ + {/* Content */} +
+ {error && ( +
{error}
+ )} + + {loading && entries.length === 0 ? ( +
+ Loading... +
+ ) : ( + + + {/* Go up entry */} + {currentPath !== "/" && ( + goUp()} + className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors" + > + + + + + + )} + {entries.map((entry) => ( + entry.is_directory && navigate(entry.path)} + className={`${ + entry.is_directory ? "cursor-pointer" : "" + } hover:bg-[var(--bg-tertiary)] transition-colors`} + > + + + + + + ))} + {entries.length === 0 && !loading && ( + + + + )} + +
..
+ + {entry.is_directory ? "📁 " : ""}{entry.name} + + + {!entry.is_directory && formatSize(entry.size)} + + {entry.modified} + + {!entry.is_directory && ( + + )} +
+ Empty directory +
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/app/src/components/projects/ProjectCard.tsx b/app/src/components/projects/ProjectCard.tsx index 0339938..5b7a648 100644 --- a/app/src/components/projects/ProjectCard.tsx +++ b/app/src/components/projects/ProjectCard.tsx @@ -10,6 +10,7 @@ import EnvVarsModal from "./EnvVarsModal"; import PortMappingsModal from "./PortMappingsModal"; import ClaudeInstructionsModal from "./ClaudeInstructionsModal"; import ContainerProgressModal from "./ContainerProgressModal"; +import FileManagerModal from "./FileManagerModal"; interface Props { project: Project; @@ -27,6 +28,7 @@ export default function ProjectCard({ project }: Props) { const [showEnvVarsModal, setShowEnvVarsModal] = useState(false); const [showPortMappingsModal, setShowPortMappingsModal] = useState(false); const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false); + const [showFileManager, setShowFileManager] = useState(false); const [progressMsg, setProgressMsg] = useState(null); const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null); const [operationCompleted, setOperationCompleted] = useState(false); @@ -139,6 +141,14 @@ export default function ProjectCard({ project }: Props) { } }; + const handleOpenBashShell = async () => { + try { + await openTerminal(project.id, project.name, "bash"); + } catch (e) { + setError(String(e)); + } + }; + const handleForceStop = async () => { try { await stop(project.id); @@ -409,6 +419,8 @@ export default function ProjectCard({ project }: Props) { <> + + setShowFileManager(true)} disabled={loading} label="Files" /> ) : ( <> @@ -905,6 +917,14 @@ export default function ProjectCard({ project }: Props) { /> )} + {showFileManager && ( + setShowFileManager(false)} + /> + )} + {activeOperation && ( - {session.projectName} + + {session.projectName}{session.sessionType === "bash" ? " (bash)" : ""} +