Merge branch 'feat/file-manager' into integration/round-1

This commit is contained in:
2026-08-23 08:35:51 -07:00
13 changed files with 1916 additions and 153 deletions
+499 -52
View File
@@ -1,3 +1,5 @@
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use bollard::container::{DownloadFromContainerOptions, LogOutput, UploadToContainerOptions};
use bollard::exec::{CreateExecOptions, StartExecResults};
use futures_util::StreamExt;
@@ -5,19 +7,49 @@ use serde::Serialize;
use tauri::State;
use crate::docker::client::get_docker;
use crate::docker::exec::exec_oneshot;
use crate::docker::exec::{
build_single_file_tar, container_user_ids, exec_oneshot, exec_oneshot_as, now_epoch_secs,
};
use crate::AppState;
#[derive(Debug, Serialize)]
#[derive(Debug, PartialEq, Serialize)]
pub struct FileEntry {
pub name: String,
pub path: String,
/// Whether the entry behaves as a directory — *dereferenced*, so a symlink
/// pointing at one is navigable rather than a dead row.
pub is_directory: bool,
/// Whether the entry itself is a symlink, which `is_directory` no longer
/// tells you now that it follows the link.
pub is_symlink: bool,
pub size: u64,
pub modified: String,
pub permissions: String,
}
/// What a viewer read out of the container.
#[derive(Debug, Serialize)]
pub struct FileContents {
/// Base64 rather than a byte vec: Tauri serialises `Vec<u8>` over IPC as a
/// JSON array of numbers, which is roughly 4x the bytes and pathological at
/// MB scale.
pub contents_base64: String,
/// True when the file is larger than the cap and only a prefix came back.
pub truncated: bool,
/// The file's real size, from the tar header — not the length of what was
/// returned.
pub size: u64,
}
/// Hard ceiling on a single viewer read, whatever the caller asks for. The tar
/// path buffers the whole payload in host RAM, so a caller-supplied cap is not
/// something to take on trust.
const MAX_READ_BYTES: u64 = 8 * 1024 * 1024;
/// Ceiling on a single upload, mirroring the terminal drop path's guard. The
/// file is packed into an in-memory tar before it goes anywhere.
const MAX_UPLOAD_BYTES: u64 = 256 * 1024 * 1024;
#[tauri::command]
pub async fn list_container_files(
project_id: String,
@@ -34,6 +66,11 @@ pub async fn list_container_files(
.as_ref()
.ok_or_else(|| "Container not running".to_string())?;
// `%y` is the entry's own type, `%Y` the type it *dereferences* to. Both
// are printed: `%Y` is what decides navigability (a symlinked directory
// reports `l` under `%y`, which used to make it an unopenable row), while
// `%y` is the only way left to tell the user it is a link at all. `%Y` is
// `N` for a broken link and `L` for a loop, neither of which is `d`.
let cmd = vec![
"find".to_string(),
path.clone(),
@@ -42,24 +79,33 @@ pub async fn list_container_files(
"-maxdepth".to_string(),
"1".to_string(),
"-printf".to_string(),
"%f\t%y\t%s\t%T@\t%m\n".to_string(),
"%f\t%y\t%Y\t%s\t%T@\t%m\n".to_string(),
];
let output = exec_oneshot(container_id, cmd).await?;
Ok(parse_find_output(&path, &output))
}
/// Turn `find -printf '%f\t%y\t%Y\t%s\t%T@\t%m\n'` output into sorted entries.
///
/// Split out from the command so it can be tested without a container: it is
/// the half where a format change silently mis-types every row.
fn parse_find_output(dir: &str, output: &str) -> Vec<FileEntry> {
let mut entries: Vec<FileEntry> = output
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 5 {
if parts.len() < 6 {
return None;
}
let name = parts[0].to_string();
let is_directory = parts[1] == "d";
let size = parts[2].parse::<u64>().unwrap_or(0);
let modified_epoch = parts[3].parse::<f64>().unwrap_or(0.0);
let permissions = parts[4].to_string();
let is_symlink = parts[1] == "l";
let is_directory = parts[2] == "d";
let size = parts[3].parse::<u64>().unwrap_or(0);
let modified_epoch = parts[4].parse::<f64>().unwrap_or(0.0);
let permissions = parts[5].to_string();
// Convert epoch to ISO-ish string
let modified = {
@@ -69,16 +115,11 @@ pub async fn list_container_files(
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,
name: name.clone(),
path: join_path(dir, &name),
is_directory,
is_symlink,
size,
modified,
permissions,
@@ -93,7 +134,54 @@ pub async fn list_container_files(
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
Ok(entries)
entries
}
/// Join a container directory and a child name without doubling the separator.
fn join_path(dir: &str, name: &str) -> String {
if dir.ends_with('/') {
format!("{}{}", dir, name)
} else {
format!("{}/{}", dir, name)
}
}
/// The directory holding `path`. `/` is its own parent.
fn parent_dir(path: &str) -> String {
let trimmed = path.trim_end_matches('/');
match trimmed.rfind('/') {
None | Some(0) => "/".to_string(),
Some(i) => trimmed[..i].to_string(),
}
}
/// Validate the *new name* half of a rename, or a new folder's name.
///
/// This is user-typed text that ends up in `mv`/`mkdir` argv, and the operation
/// is deliberately a rename rather than a move: a name carrying `/` would
/// relocate the entry, and `..` would walk it out of the directory entirely.
/// A leading `-` is left alone because every call site passes `--` first.
fn validate_entry_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("Name cannot be empty".to_string());
}
if name.contains('/') {
return Err(
"Name cannot contain '/' — this renames inside the folder, it does not move."
.to_string(),
);
}
// Can't survive argv anyway; caught here so the failure is legible.
if name.contains('\0') {
return Err("Name cannot contain a null byte".to_string());
}
if name == "." || name == ".." {
return Err("\".\" and \"..\" are not valid names".to_string());
}
if name.len() > 255 {
return Err("Name is too long (255 bytes maximum)".to_string());
}
Ok(())
}
#[tauri::command]
@@ -113,43 +201,257 @@ pub async fn download_container_file(
.as_ref()
.ok_or_else(|| "Container not running".to_string())?;
let fetched = fetch_container_file(container_id, &container_path, None).await?;
tokio::fs::write(&host_path, &fetched.bytes)
.await
.map_err(|e| format!("Failed to write file to host: {}", e))?;
Ok(())
}
/// One regular file's bytes, pulled out of a container.
struct FetchedFile {
bytes: Vec<u8>,
/// The size the tar header declared, i.e. the file's real size — which is
/// not `bytes.len()` once `max_bytes` has cut the read short.
size: u64,
truncated: bool,
}
/// Fetch a single regular file from a container as exact bytes.
///
/// Shared by the "Save to host…" download and the viewer, so both get the same
/// answer. It deliberately goes through Docker's archive endpoint rather than
/// `exec_oneshot`: that reader runs every chunk through `String::from_utf8_lossy`
/// and merges stderr into stdout, so it would both corrupt any non-UTF-8 file
/// and be able to splice diagnostics into what the caller believes is content.
///
/// With `max_bytes` set the transfer is abandoned once the cap (plus enough
/// slack for the tar framing) is in hand, so previewing a huge file does not
/// pull the whole thing across the socket.
async fn fetch_container_file(
container_id: &str,
container_path: &str,
max_bytes: Option<u64>,
) -> Result<FetchedFile, String> {
let docker = get_docker()?;
let mut stream = docker.download_from_container(
container_id,
Some(DownloadFromContainerOptions {
path: container_path.clone(),
path: container_path.to_string(),
}),
);
let mut tar_bytes = Vec::new();
// A tar member is a 512-byte header plus payload padded to 512. 8 KiB of
// slack past the payload cap guarantees the header and the whole capped
// prefix are present even with the stream cut short.
const TAR_SLACK: u64 = 8 * 1024;
let stop_after = max_bytes.map(|m| m.saturating_add(TAR_SLACK));
let mut tar_bytes: Vec<u8> = 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);
if stop_after.is_some_and(|cap| tar_bytes.len() as u64 >= cap) {
// Dropping the stream cancels the rest of the transfer.
break;
}
}
// Extract single file from tar archive
let mut archive = tar::Archive::new(&tar_bytes[..]);
let mut found = false;
for entry in archive
let mut entries = 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;
.map_err(|e| format!("Failed to read tar entries: {}", e))?;
let mut entry = match entries.next() {
Some(entry) => entry.map_err(|e| format!("Failed to read tar entry: {}", e))?,
None => return Err(format!("{} not found in the container", container_path)),
};
// Docker tars whatever the path names, so a directory arrives as a whole
// tree. Reading only its first member used to write a silently wrong file;
// say so instead.
let entry_type = entry.header().entry_type();
if entry_type.is_dir() {
return Err(format!(
"{} is a folder — download its files individually, or use Backup to archive a whole tree.",
container_path
));
}
if entry_type.is_symlink() || entry_type.is_hard_link() {
return Err(format!("{} is a link — open its target instead.", container_path));
}
if !entry_type.is_file() {
return Err(format!("{} is not a regular file.", container_path));
}
if !found {
return Err("File not found in tar archive".to_string());
let size = entry.header().size().unwrap_or(0);
let truncated = max_bytes.is_some_and(|cap| size > cap);
let want = max_bytes.map(|cap| cap.min(size)).unwrap_or(size);
let mut bytes = Vec::with_capacity(want.min(1024 * 1024) as usize);
std::io::Read::read_to_end(&mut std::io::Read::take(&mut entry, want), &mut bytes)
.map_err(|e| format!("Failed to read file contents: {}", e))?;
Ok(FetchedFile {
bytes,
size,
truncated,
})
}
/// Read a file out of the container for the in-app viewer.
///
/// `max_bytes` is the caller's ceiling (the viewer asks for more when it is
/// about to decode an image, which is what usually goes over a text-sized cap);
/// it is clamped to [`MAX_READ_BYTES`] regardless, because the whole payload is
/// buffered in host RAM on the way through.
#[tauri::command]
pub async fn read_container_file(
project_id: String,
path: String,
max_bytes: Option<u64>,
state: State<'_, AppState>,
) -> Result<FileContents, 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 cap = max_bytes.unwrap_or(MAX_READ_BYTES).min(MAX_READ_BYTES);
let fetched = fetch_container_file(container_id, &path, Some(cap)).await?;
Ok(FileContents {
contents_base64: BASE64.encode(&fetched.bytes),
truncated: fetched.truncated,
size: fetched.size,
})
}
/// Rename an entry in place. `to_path` is the **new name**, not a destination
/// path — moving between directories is deliberately not offered here, so the
/// name is validated to carry no `/`.
///
/// Runs through `exec_oneshot_as` rather than `exec_oneshot` because the exit
/// code is the only reliable signal: `exec_oneshot` discards the status, so a
/// permission failure (renaming under `/etc` or `/usr`, which the container
/// user genuinely cannot do) would return `Ok` with the error text as its
/// "output". Returns the new full path.
#[tauri::command]
pub async fn rename_container_path(
project_id: String,
from_path: String,
to_path: String,
state: State<'_, AppState>,
) -> Result<String, 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 new_name = to_path.trim();
validate_entry_name(new_name)?;
let dest = join_path(&parent_dir(&from_path), new_name);
if dest == from_path {
return Ok(dest);
}
Ok(())
// `mv -n` refuses to clobber, but GNU coreutils makes that refusal *silent*
// and exits 0 — so `-n` on its own would report a rename that never
// happened. The existence check is what turns it into an error the user
// sees; `-n` stays as the belt-and-braces against the race between them.
let (_, exists) = exec_oneshot_as(
container_id,
"claude",
vec!["test".to_string(), "-e".to_string(), dest.clone()],
Vec::new(),
)
.await?;
if exists == 0 {
return Err(format!("\"{}\" already exists in this folder", new_name));
}
let (output, code) = exec_oneshot_as(
container_id,
"claude",
vec![
"mv".to_string(),
"-n".to_string(),
"--".to_string(),
from_path.clone(),
dest.clone(),
],
Vec::new(),
)
.await?;
if code != 0 {
// Surface `mv`'s own words: "Permission denied" is the common case
// outside /workspace and a generic message would hide why.
let detail = output.trim();
return Err(if detail.is_empty() {
format!("Rename failed (exit {})", code)
} else {
detail.to_string()
});
}
Ok(dest)
}
/// Create a directory under `parent_path`. Fails rather than succeeding
/// silently if the name is taken — `mkdir` without `-p` is what gives that.
#[tauri::command]
pub async fn create_container_directory(
project_id: String,
parent_path: String,
name: String,
state: State<'_, AppState>,
) -> Result<String, 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 name = name.trim();
validate_entry_name(name)?;
let dest = join_path(&parent_path, name);
let (output, code) = exec_oneshot_as(
container_id,
"claude",
vec!["mkdir".to_string(), "--".to_string(), dest.clone()],
Vec::new(),
)
.await?;
if code != 0 {
let detail = output.trim();
return Err(if detail.is_empty() {
format!("Could not create folder (exit {})", code)
} else {
detail.to_string()
});
}
Ok(dest)
}
/// Create a `.tar.gz` backup of the container and stream it to a host file.
@@ -364,8 +666,26 @@ pub async fn upload_file_to_container(
let docker = get_docker()?;
let file_data = std::fs::read(&host_path)
.map_err(|e| format!("Failed to read host file: {}", e))?;
let meta = tokio::fs::metadata(&host_path)
.await
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
// A directory here used to reach `std::fs::read`, whose "Is a directory"
// error says nothing about what to do. Recursive upload is a bigger feature
// than this panel needs; refuse clearly instead.
if meta.is_dir() {
return Err(format!(
"{} is a folder — drop or upload its files individually.",
host_path
));
}
if meta.len() > MAX_UPLOAD_BYTES {
return Err(format!(
"File too large to upload ({:.0} MB; limit {} MB). Mount it into the project instead.",
meta.len() as f64 / (1024.0 * 1024.0),
MAX_UPLOAD_BYTES / (1024 * 1024)
));
}
let file_name = std::path::Path::new(&host_path)
.file_name()
@@ -373,21 +693,28 @@ pub async fn upload_file_to_container(
.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))?;
}
// Own the file as the container user and keep the host's mtime. A default
// tar header would land it root:root with a 1970-01-01 timestamp — i.e.
// not editable by Claude Code, and misleading in the listing.
let (uid, gid) = container_user_ids(container_id).await;
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or_else(now_epoch_secs);
// `std::fs::read` plus the tar build are synchronous and can be hundreds of
// MB, so they run on a blocking thread rather than stalling an async worker
// (the same discipline as `upload_host_file_to_container`).
let read_path = host_path.clone();
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
let file_data = std::fs::read(&read_path)
.map_err(|e| format!("Failed to read host file: {}", e))?;
build_single_file_tar(&file_name, &file_data[..], 0o644, uid, gid, mtime)
})
.await
.map_err(|e| format!("Upload task panicked: {}", e))??;
docker
.upload_to_container(
@@ -403,3 +730,123 @@ pub async fn upload_file_to_container(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// A line as `find -printf '%f\t%y\t%Y\t%s\t%T@\t%m\n'` emits it.
fn line(name: &str, own: &str, deref: &str, size: &str) -> String {
format!("{}\t{}\t{}\t{}\t1700000000.0000000000\t644", name, own, deref, size)
}
#[test]
fn parses_a_plain_file_row() {
let entries = parse_find_output("/workspace", &line("notes.txt", "f", "f", "42"));
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "notes.txt");
assert_eq!(entries[0].path, "/workspace/notes.txt");
assert!(!entries[0].is_directory);
assert!(!entries[0].is_symlink);
assert_eq!(entries[0].size, 42);
assert_eq!(entries[0].permissions, "644");
assert_eq!(entries[0].modified, "2023-11-14 22:13:20");
}
#[test]
fn a_symlink_to_a_directory_is_navigable_and_still_flagged_as_a_link() {
// The bug this guards: `%y` reports `l`, so keying `is_directory` off it
// made every symlinked directory an unopenable row.
let entries = parse_find_output("/workspace", &line("app", "l", "d", "12"));
assert!(entries[0].is_directory);
assert!(entries[0].is_symlink);
}
#[test]
fn a_broken_symlink_is_not_a_directory() {
// `%Y` is `N` when the target is missing, `L` on a loop.
for deref in ["N", "L", "?"] {
let entries = parse_find_output("/workspace", &line("dangling", "l", deref, "9"));
assert!(!entries[0].is_directory, "deref type {} became a directory", deref);
assert!(entries[0].is_symlink);
}
}
#[test]
fn directories_sort_first_then_case_insensitively() {
let output = [
line("Zeta", "f", "f", "1"),
line("alpha", "f", "f", "1"),
line("src", "d", "d", "4096"),
]
.join("\n");
let entries = parse_find_output("/workspace", &output);
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["src", "alpha", "Zeta"]);
}
#[test]
fn short_and_blank_rows_are_dropped_rather_than_mis_parsed() {
let output = format!("\n \nbroken\ttoo\tshort\n{}\n", line("ok", "f", "f", "1"));
let entries = parse_find_output("/workspace", &output);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "ok");
}
#[test]
fn the_root_directory_does_not_get_a_doubled_separator() {
let entries = parse_find_output("/", &line("etc", "d", "d", "4096"));
assert_eq!(entries[0].path, "/etc");
}
#[test]
fn unparseable_size_and_mtime_fall_back_instead_of_dropping_the_row() {
let output = "weird\tf\tf\t-\t-\t644";
let entries = parse_find_output("/workspace", output);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].size, 0);
}
#[test]
fn parent_dir_walks_up_one_level_and_stops_at_root() {
assert_eq!(parent_dir("/workspace/app/src"), "/workspace/app");
assert_eq!(parent_dir("/workspace/app/src/"), "/workspace/app");
assert_eq!(parent_dir("/workspace"), "/");
assert_eq!(parent_dir("/"), "/");
}
#[test]
fn a_rename_target_may_not_relocate_the_entry() {
// The whole point of the validator: this is argv for `mv`, and a name
// with a separator in it would be a move, not a rename.
assert!(validate_entry_name("sub/dir").is_err());
assert!(validate_entry_name("../escape").is_err());
assert!(validate_entry_name("/etc/passwd").is_err());
}
#[test]
fn dot_and_dotdot_and_empty_are_refused() {
assert!(validate_entry_name("").is_err());
assert!(validate_entry_name(".").is_err());
assert!(validate_entry_name("..").is_err());
assert!(validate_entry_name("\0").is_err());
assert!(validate_entry_name(&"x".repeat(256)).is_err());
}
#[test]
fn ordinary_names_including_awkward_ones_are_allowed() {
// Nothing goes through a shell, so metacharacters are just characters —
// and a leading `-` is safe because every call site passes `--` first.
for name in [".hidden", "a b.txt", "$(whoami)", "it's", "-rf", "…unicode…"] {
assert!(validate_entry_name(name).is_ok(), "{} was refused", name);
}
}
#[test]
fn the_viewer_cap_is_never_larger_than_the_hard_ceiling() {
// The frontend picks a cap per file type; Rust still gets the last word
// because the whole payload is buffered in host RAM.
assert_eq!(Some(u64::MAX).unwrap().min(MAX_READ_BYTES), MAX_READ_BYTES);
assert!(MAX_READ_BYTES < MAX_UPLOAD_BYTES);
}
}
+79 -45
View File
@@ -301,21 +301,10 @@ impl ExecSessionManager {
) -> Result<String, String> {
let docker = get_docker()?;
// Build a tar archive in memory containing the file
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(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, file_name, data)
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
builder
.finish()
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
}
// Owned by the container user, stamped now: a default tar header would
// land it as root:root/1970 and Claude Code could not rewrite it.
let (uid, gid) = container_user_ids(container_id).await;
let tar_buf = build_single_file_tar(file_name, data, 0o644, uid, gid, now_epoch_secs())?;
docker
.upload_to_container(
@@ -347,26 +336,13 @@ pub async fn upload_host_file_to_container(
let host_path = host_path.to_string();
let dest_name = dest_name.to_string();
let dest_for_blk = dest_name.clone();
let (uid, gid) = container_user_ids(container_id).await;
let mtime = now_epoch_secs();
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
let data = std::fs::read(&host_path)
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
{
let mut builder = tar::Builder::new(&mut tar_buf);
let mut header = tar::Header::new_gnu();
// Size comes from the bytes in hand, so header and payload can't disagree.
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, &dest_for_blk, &data[..])
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
builder
.finish()
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
}
Ok(tar_buf)
build_single_file_tar(&dest_for_blk, &data[..], 0o644, uid, gid, mtime)
})
.await
.map_err(|e| format!("Upload task panicked: {}", e))??;
@@ -402,20 +378,10 @@ pub async fn upload_bytes_to_container(
) -> Result<String, String> {
let docker = get_docker()?;
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
{
let mut builder = tar::Builder::new(&mut tar_buf);
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(mode);
header.set_cksum();
builder
.append_data(&mut header, file_name, data)
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
builder
.finish()
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
}
// Root-owned on purpose: the only caller is migration, whose `tar -T` list
// is read back as root. The mtime still gets stamped so the file doesn't
// read as 1970.
let tar_buf = build_single_file_tar(file_name, data, mode, 0, 0, now_epoch_secs())?;
docker
.upload_to_container(
@@ -432,6 +398,74 @@ pub async fn upload_bytes_to_container(
Ok(format!("{}/{}", dest_dir.trim_end_matches('/'), file_name))
}
/// Build an in-memory tar archive holding a single regular file.
///
/// The uid/gid/mtime arguments exist because `tar::Header::new_gnu()` zeroes
/// them and Docker's archive extractor honours the header verbatim: a header
/// left at the defaults lands the file inside the container as `root:root`
/// with a 1970-01-01 mtime — not writable by `claude`, and confusing in any
/// listing. Callers that upload on a user's behalf should pass the container
/// user's ids from [`container_user_ids`].
pub fn build_single_file_tar(
file_name: &str,
data: &[u8],
mode: u32,
uid: u64,
gid: u64,
mtime: u64,
) -> Result<Vec<u8>, String> {
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
{
let mut builder = tar::Builder::new(&mut tar_buf);
let mut header = tar::Header::new_gnu();
// Size comes from the bytes in hand, so header and payload can't disagree.
header.set_size(data.len() as u64);
header.set_mode(mode);
header.set_uid(uid);
header.set_gid(gid);
header.set_mtime(mtime);
header.set_cksum();
builder
.append_data(&mut header, file_name, data)
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
builder
.finish()
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
}
Ok(tar_buf)
}
/// Seconds since the Unix epoch, for a tar header mtime.
pub fn now_epoch_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// The numeric uid/gid of the container's `claude` user.
///
/// It is not a constant: `entrypoint.sh` remaps `claude` to the *host* user's
/// ids on Unix so bind-mounted project files stay writable, and deliberately
/// does not on Windows. So the only reliable answer comes from asking the
/// container. Falls back to 1000:1000 (the image's build-time ids) if the exec
/// fails, which is strictly better than the 0:0 a default tar header carries.
pub async fn container_user_ids(container_id: &str) -> (u64, u64) {
let out = exec_oneshot_limited(
container_id,
vec!["sh".to_string(), "-c".to_string(), "id -u; id -g".to_string()],
256,
)
.await
.unwrap_or_default();
let mut ids = out.lines().filter_map(|l| l.trim().parse::<u64>().ok());
match (ids.next(), ids.next()) {
(Some(uid), Some(gid)) => (uid, gid),
_ => (1000, 1000),
}
}
/// Ceiling on how much container output a one-shot exec will buffer into the
/// host process.
///
+3
View File
@@ -499,6 +499,9 @@ pub fn run() {
commands::file_commands::download_container_file,
commands::file_commands::download_container_backup,
commands::file_commands::upload_file_to_container,
commands::file_commands::read_container_file,
commands::file_commands::rename_container_path,
commands::file_commands::create_container_directory,
// AWS
commands::aws_commands::aws_sso_refresh,
// Updates
+1 -1
View File
@@ -22,7 +22,7 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; frame-src http://127.0.0.1:47820 http://127.0.0.1:47821 http://127.0.0.1:47822 http://127.0.0.1:47823 http://127.0.0.1:47824 http://127.0.0.1:47825 http://127.0.0.1:47826 http://127.0.0.1:47827"
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; frame-src http://127.0.0.1:47820 http://127.0.0.1:47821 http://127.0.0.1:47822 http://127.0.0.1:47823 http://127.0.0.1:47824 http://127.0.0.1:47825 http://127.0.0.1:47826 http://127.0.0.1:47827"
}
},
"bundle": {
@@ -0,0 +1,157 @@
import { useEffect, useState } from "react";
import type { FileEntry } from "../../../lib/types";
import { readContainerFile } from "../../../lib/tauri-commands";
import Button from "../../ui/Button";
import Modal from "../../ui/Modal";
import { formatBytes } from "./format";
import {
decodeBase64,
imageMimeFor,
looksBinary,
previewKind,
previewLimit,
} from "./filePreview";
interface Props {
projectId: string;
entry: FileEntry;
onClose: () => void;
/** "Save to host…" — the way out for anything the viewer can't render. */
onSaveToHost: (entry: FileEntry) => void;
}
type Preview =
| { kind: "loading" }
| { kind: "error"; message: string }
/** Too big to render whole — offered as a download rather than a half-file. */
| { kind: "too-large" }
| { kind: "text"; text: string; truncated: boolean; shownBytes: number; trueSize: number }
| { kind: "image"; url: string }
| { kind: "unsupported" };
/**
* Read-only preview of one container file.
*
* Images are rendered from a `blob:` URL rather than a `data:` one — the object
* URL is revocable (so the bytes are released the moment the modal closes) and
* keeps a multi-megabyte base64 string out of the DOM. `blob:` is in the app's
* `img-src` for exactly this; the asset protocol deliberately is not enabled.
*/
export default function FileViewerModal({ projectId, entry, onClose, onSaveToHost }: Props) {
const [preview, setPreview] = useState<Preview>({ kind: "loading" });
useEffect(() => {
let cancelled = false;
// Tracked separately from `preview` so cleanup can revoke it without
// depending on which state the component ended up in.
let objectUrl: string | null = null;
(async () => {
try {
const wantImage = previewKind(entry.name) === "image";
const result = await readContainerFile(projectId, entry.path, previewLimit(entry.name));
if (cancelled) return;
const bytes = decodeBase64(result.contents_base64);
if (wantImage) {
// A truncated image is not a smaller image, it is a broken one.
if (result.truncated) {
setPreview({ kind: "too-large" });
return;
}
const blob = new Blob([bytes], { type: imageMimeFor(entry.name) ?? "image/png" });
objectUrl = URL.createObjectURL(blob);
setPreview({ kind: "image", url: objectUrl });
return;
}
if (looksBinary(bytes)) {
setPreview({ kind: "unsupported" });
return;
}
setPreview({
kind: "text",
text: new TextDecoder().decode(bytes),
truncated: result.truncated,
shownBytes: bytes.length,
trueSize: result.size,
});
} catch (e) {
if (!cancelled) setPreview({ kind: "error", message: String(e) });
}
})();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [projectId, entry.name, entry.path]);
const footer = (
<>
<Button
size="md"
onClick={() => {
onSaveToHost(entry);
}}
>
Save to host
</Button>
<Button size="md" variant="primary" onClick={onClose}>
Close
</Button>
</>
);
return (
<Modal
title={entry.name}
description={`${entry.path} · ${formatBytes(entry.size)}`}
onClose={onClose}
footer={footer}
widthClassName="w-[52rem]"
>
{preview.kind === "loading" && (
<p className="text-[13px] text-[var(--text-secondary)]">Loading</p>
)}
{preview.kind === "error" && (
<p role="alert" className="text-[13px] text-[var(--error)]">
{preview.message}
</p>
)}
{preview.kind === "too-large" && (
<p className="text-[13px] text-[var(--text-secondary)]">
This file is {formatBytes(entry.size)} too large to preview in the app. Save it
to the host to open it there.
</p>
)}
{preview.kind === "unsupported" && (
<p className="text-[13px] text-[var(--text-secondary)]">
There is no preview for this file type. Save it to the host to open it there.
</p>
)}
{preview.kind === "text" && (
<>
{preview.truncated && (
<p className="mb-2 text-xs text-[var(--warning)]">
Showing the first {formatBytes(preview.shownBytes)} of {formatBytes(preview.trueSize)}.
</p>
)}
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-[var(--text-primary)]">
{preview.text}
</pre>
</>
)}
{preview.kind === "image" && (
<img src={preview.url} alt={entry.name} className="max-w-full mx-auto" />
)}
</Modal>
);
}
@@ -0,0 +1,350 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
import FilesTab from "./FilesTab";
import type { FileContents, FileEntry, Project } from "../../../lib/types";
const listContainerFiles = vi.fn();
const downloadContainerFile = vi.fn(async () => {});
const uploadFileToContainer = vi.fn(async () => {});
const renameContainerPath = vi.fn(async () => "");
const createContainerDirectory = vi.fn(async () => "");
const readContainerFile = vi.fn();
vi.mock("../../../lib/tauri-commands", () => ({
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
uploadFileToContainer: (p: string, h: string, d: string) => uploadFileToContainer(p, h, d),
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
createContainerDirectory: (p: string, parent: string, n: string) =>
createContainerDirectory(p, parent, n),
readContainerFile: (p: string, path: string, max?: number) => readContainerFile(p, path, max),
}));
const save = vi.fn(async () => "/host/out");
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: (o: unknown) => save(o),
open: vi.fn(async () => null),
}));
/** The webview's window-wide native drag-drop listener, captured for driving. */
type DragPayload =
| { type: "enter" | "over"; position: { x: number; y: number }; paths: string[] }
| { type: "leave" }
| { type: "drop"; position: { x: number; y: number }; paths: string[] };
let dragHandler: ((e: { payload: DragPayload }) => void | Promise<void>) | null = null;
const unlistenDrag = vi.fn();
vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: () => ({
onDragDropEvent: async (cb: (e: { payload: DragPayload }) => void) => {
dragHandler = cb;
return unlistenDrag;
},
}),
}));
const project = { id: "p1", name: "api", status: "running" } as unknown as Project;
const entry = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
name,
path: `/workspace/${name}`,
is_directory: false,
is_symlink: false,
size: 12,
modified: "2024-05-01 10:00:00",
permissions: "644",
...extra,
});
const contents = (text: string, extra: Partial<FileContents> = {}): FileContents => ({
contents_base64: btoa(text),
truncated: false,
size: text.length,
...extra,
});
async function renderTab() {
const view = render(<FilesTab project={project} />);
await act(async () => {
await Promise.resolve();
});
return view;
}
/** Fire the native drop payload at a point inside the pane's stubbed rect. */
async function drop(paths: string[], position = { x: 100, y: 100 }) {
await act(async () => {
await dragHandler?.({ payload: { type: "drop", position, paths } });
});
}
beforeEach(() => {
vi.clearAllMocks();
dragHandler = null;
listContainerFiles.mockResolvedValue([
entry("src", { is_directory: true, path: "/workspace/src" }),
entry("notes.txt"),
]);
// jsdom lays nothing out, so the pane's hit-test rect has to be supplied.
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600,
toJSON: () => ({}),
} as DOMRect);
// Not implemented in jsdom; the image preview needs both halves.
URL.createObjectURL = vi.fn(() => "blob:mock-url");
URL.revokeObjectURL = vi.fn();
});
describe("FilesTab listing", () => {
it("lists /workspace once the container is running", async () => {
await renderTab();
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
expect(screen.getByText("notes.txt")).toBeTruthy();
});
it("says nothing about files while the container is stopped", async () => {
render(<FilesTab project={{ ...project, status: "stopped" } as Project} />);
expect(screen.getByText(/Start the container/)).toBeTruthy();
expect(listContainerFiles).not.toHaveBeenCalled();
});
it("labels a symlink, which no longer masquerades as a plain file", async () => {
listContainerFiles.mockResolvedValue([
entry("app", { is_directory: true, is_symlink: true }),
]);
await renderTab();
expect(screen.getByTitle("Symbolic link")).toBeTruthy();
});
});
describe("FilesTab open semantics", () => {
it("selects on a single click without navigating", async () => {
await renderTab();
listContainerFiles.mockClear();
fireEvent.click(screen.getByText("src"));
expect(listContainerFiles).not.toHaveBeenCalled();
expect(screen.getByText("src").closest("tr")?.getAttribute("aria-selected")).toBe("true");
});
it("navigates a directory on double click", async () => {
await renderTab();
listContainerFiles.mockClear();
await act(async () => {
fireEvent.doubleClick(screen.getByText("src"));
});
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace/src");
});
it("walks the rows with the arrow keys, which is what makes the grid role honest", async () => {
await renderTab();
const first = screen.getByText("src").closest("tr")!;
first.focus();
fireEvent.keyDown(first, { key: "ArrowDown" });
expect(document.activeElement).toBe(screen.getByText("notes.txt").closest("tr"));
fireEvent.keyDown(document.activeElement!, { key: "ArrowUp" });
expect(document.activeElement).toBe(first);
});
it("opens a directory from the keyboard with Enter", async () => {
await renderTab();
listContainerFiles.mockClear();
const row = screen.getByText("src").closest("tr")!;
expect(row.getAttribute("tabindex")).toBe("0");
await act(async () => {
fireEvent.keyDown(row, { key: "Enter" });
});
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace/src");
});
});
describe("FilesTab viewer", () => {
it("shows a text file's contents in a dialog", async () => {
readContainerFile.mockResolvedValue(contents("hello from the container"));
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("notes.txt"));
});
const dialog = await screen.findByRole("dialog");
expect(dialog).toBeTruthy();
expect(await screen.findByText("hello from the container")).toBeTruthy();
// A text file gets the text-sized budget, not the image one.
expect(readContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", 1024 * 1024);
});
it("renders an image through a revocable blob URL, not a data URI", async () => {
// `data:` is absent from the app's img-src on purpose; `blob:` is what was
// added, and the object URL has to be released when the dialog closes.
listContainerFiles.mockResolvedValue([entry("logo.png", { size: 4 })]);
readContainerFile.mockResolvedValue(contents("\x89PNG"));
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("logo.png"));
});
const img = (await screen.findByAltText("logo.png")) as HTMLImageElement;
expect(img.getAttribute("src")).toBe("blob:mock-url");
expect(readContainerFile).toHaveBeenCalledWith("p1", "/workspace/logo.png", 5 * 1024 * 1024);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await waitFor(() => expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url"));
});
it("refuses an oversized image rather than drawing a half-decoded one", async () => {
listContainerFiles.mockResolvedValue([entry("huge.png", { size: 40 * 1024 * 1024 })]);
readContainerFile.mockResolvedValue(contents("\x89PNG", { truncated: true, size: 40 * 1024 * 1024 }));
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("huge.png"));
});
expect(await screen.findByText(/too large to preview/)).toBeTruthy();
expect(screen.queryByAltText("huge.png")).toBeNull();
expect(screen.getByRole("button", { name: "Save to host…" })).toBeTruthy();
});
it("says so in words when only a prefix of a big text file came back", async () => {
readContainerFile.mockResolvedValue(
contents("first megabyte", { truncated: true, size: 5 * 1024 * 1024 }),
);
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("notes.txt"));
});
expect(await screen.findByText(/Showing the first/)).toBeTruthy();
expect(screen.getByText("first megabyte")).toBeTruthy();
});
it("offers Save to host for a file it cannot render", async () => {
listContainerFiles.mockResolvedValue([entry("blob.bin")]);
readContainerFile.mockResolvedValue(contents("a\x00b"));
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("blob.bin"));
});
expect(await screen.findByText(/no preview for this file type/)).toBeTruthy();
});
});
describe("FilesTab rename", () => {
it("commits an inline rename on Enter and re-lists", async () => {
await renderTab();
fireEvent.click(screen.getByRole("button", { name: "Rename notes.txt" }));
const input = screen.getByLabelText("New name for notes.txt") as HTMLInputElement;
fireEvent.change(input, { target: { value: "renamed.txt" } });
await act(async () => {
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.blur(input);
});
expect(renameContainerPath).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "renamed.txt");
});
it("abandons the rename on Escape", async () => {
await renderTab();
fireEvent.click(screen.getByRole("button", { name: "Rename notes.txt" }));
const input = screen.getByLabelText("New name for notes.txt");
fireEvent.change(input, { target: { value: "nope.txt" } });
await act(async () => {
fireEvent.keyDown(input, { key: "Escape" });
});
expect(renameContainerPath).not.toHaveBeenCalled();
expect(screen.queryByLabelText("New name for notes.txt")).toBeNull();
});
it("starts a rename from the keyboard with F2", async () => {
await renderTab();
const row = screen.getByText("notes.txt").closest("tr")!;
fireEvent.keyDown(row, { key: "F2" });
expect(screen.getByLabelText("New name for notes.txt")).toBeTruthy();
});
it("shows what the container said when a rename is refused", async () => {
renameContainerPath.mockRejectedValue("mv: cannot move '/etc/hosts': Permission denied");
await renderTab();
fireEvent.click(screen.getByRole("button", { name: "Rename notes.txt" }));
const input = screen.getByLabelText("New name for notes.txt");
fireEvent.change(input, { target: { value: "x" } });
await act(async () => {
fireEvent.blur(input);
});
expect(screen.getByRole("alert").textContent).toContain("Permission denied");
});
});
describe("FilesTab new folder", () => {
it("creates a folder under the current directory", async () => {
await renderTab();
fireEvent.click(screen.getByRole("button", { name: "New folder" }));
const input = screen.getByLabelText("New folder name");
fireEvent.change(input, { target: { value: "assets" } });
await act(async () => {
fireEvent.blur(input);
});
expect(createContainerDirectory).toHaveBeenCalledWith("p1", "/workspace", "assets");
});
});
describe("FilesTab host drag-and-drop", () => {
it("uploads dropped paths into the directory on screen, then re-lists", async () => {
await renderTab();
listContainerFiles.mockClear();
await drop(["/host/a.png", "/host/b.png"]);
expect(uploadFileToContainer).toHaveBeenNthCalledWith(1, "p1", "/host/a.png", "/workspace");
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.png", "/workspace");
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
});
it("drops into the directory the user has navigated to", async () => {
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("src"));
});
await drop(["/host/a.png"]);
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.png", "/workspace/src");
});
it("ignores a drop outside the pane — the listener is window-wide", async () => {
// This is the whole routing discipline: the terminal's listener is live at
// the same time, and only the hit-test keeps them apart.
await renderTab();
await drop(["/host/a.png"], { x: 5000, y: 5000 });
expect(uploadFileToContainer).not.toHaveBeenCalled();
});
it("divides the payload position by devicePixelRatio", async () => {
// The native payload is in physical pixels; the rect is in CSS pixels.
// At dpr 2 a physical (900, 900) is a CSS (450, 450) — inside an 800x600 pane.
const original = window.devicePixelRatio;
Object.defineProperty(window, "devicePixelRatio", { value: 2, configurable: true });
await renderTab();
await drop(["/host/a.png"], { x: 900, y: 900 });
expect(uploadFileToContainer).toHaveBeenCalled();
Object.defineProperty(window, "devicePixelRatio", { value: original, configurable: true });
});
it("highlights the pane while a drag hovers it, and drops the highlight on leave", async () => {
await renderTab();
await act(async () => {
await dragHandler?.({
payload: { type: "over", position: { x: 100, y: 100 }, paths: [] },
});
});
expect(screen.getByText(/Drop files into \/workspace/)).toBeTruthy();
await act(async () => {
await dragHandler?.({ payload: { type: "leave" } });
});
expect(screen.queryByText(/Drop files into/)).toBeNull();
});
});
describe("FilesTab save to host", () => {
it("copies a file out to the path the user picks", async () => {
await renderTab();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Save notes.txt to host" }));
});
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "/host/out");
});
it("does not offer a directory download, which cannot work", async () => {
await renderTab();
expect(screen.queryByRole("button", { name: "Save src to host" })).toBeNull();
});
});
+330 -50
View File
@@ -1,34 +1,175 @@
import { useEffect } from "react";
import type { Project } from "../../../lib/types";
import { useCallback, useEffect, useRef, useState } from "react";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import type { FileEntry, Project } from "../../../lib/types";
import { useFileManager } from "../../../hooks/useFileManager";
import Button from "../../ui/Button";
import FileViewerModal from "./FileViewerModal";
import { formatBytes } from "./format";
interface Props {
project: Project;
}
/** The old 42rem FileManager popup, now a main-area section. */
/**
* The project's file manager.
*
* Interaction model, chosen to match every desktop file manager rather than
* the old half-and-half: **single click selects, double click opens**. That
* moved directory navigation onto double click too — a single click used to
* navigate, which made it impossible to select a directory in order to rename
* it. Keyboard mirrors it exactly: Enter opens, F2 renames.
*/
export default function FilesTab({ project }: Props) {
const {
currentPath,
entries,
loading,
error,
busy,
navigate,
goUp,
refresh,
downloadFile,
uploadFile,
uploadPaths,
renameEntry,
createFolder,
} = useFileManager(project.id);
const running = project.status === "running";
/** The row the user has selected, by name — names are unique in a directory. */
const [selected, setSelected] = useState<string | null>(null);
const [renaming, setRenaming] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState("");
const [creatingFolder, setCreatingFolder] = useState(false);
const [folderDraft, setFolderDraft] = useState("");
const [viewing, setViewing] = useState<FileEntry | null>(null);
/** A host drag is currently over this pane. */
const [dragOver, setDragOver] = useState(false);
const paneRef = useRef<HTMLDivElement>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (running) navigate("/workspace");
// Re-list when the container comes up.
}, [navigate, running]);
// Leaving a directory invalidates every in-flight row interaction.
useEffect(() => {
setSelected(null);
setRenaming(null);
}, [currentPath]);
useEffect(() => {
if (renaming) {
renameInputRef.current?.focus();
renameInputRef.current?.select();
}
}, [renaming]);
useEffect(() => {
if (creatingFolder) folderInputRef.current?.focus();
}, [creatingFolder]);
const startRename = useCallback((entry: FileEntry) => {
setSelected(entry.name);
setRenameDraft(entry.name);
setRenaming(entry.name);
}, []);
const commitRename = useCallback(
async (entry: FileEntry) => {
const done = await renameEntry(entry, renameDraft);
if (done) setRenaming(null);
},
[renameEntry, renameDraft],
);
const commitFolder = useCallback(async () => {
const done = await createFolder(folderDraft);
if (done) {
setCreatingFolder(false);
setFolderDraft("");
}
}, [createFolder, folderDraft]);
/**
* Arrow keys walk the rows. `aria-selected` is only meaningful on a row
* inside a `grid`, and a grid is expected to be arrow-navigable — so the
* roles below and this handler come as a pair.
*/
const moveFocus = useCallback((from: HTMLElement, delta: 1 | -1) => {
const rows = Array.from(
paneRef.current?.querySelectorAll<HTMLElement>('tr[tabindex="0"]') ?? [],
);
const i = rows.indexOf(from);
const next = rows[i + delta];
next?.focus();
}, []);
/** Double click / Enter: directories navigate, files open the viewer. */
const openEntry = useCallback(
(entry: FileEntry) => {
if (entry.is_directory) navigate(entry.path);
else setViewing(entry);
},
[navigate],
);
// Host → container drag and drop.
//
// This is Tauri's *native* drag-drop event, not HTML5 `ondrop`, for the same
// reason `TerminalView` uses it: `dragDropEnabled` is on (the terminal needs
// it), which blocks HTML5 drag inside the webview on Windows, and only the
// native payload carries real file *paths*. The listener is window-wide, so
// routing is a hit-test of the physical-pixel payload position against this
// pane's rect — a hidden pane has a zero-size rect and never matches, which
// is what keeps this and the terminal's listener from both firing.
useEffect(() => {
if (!running) return;
let unlisten: (() => void) | undefined;
let cancelled = false;
const insideThisPane = (pos: { x: number; y: number }): boolean => {
const rect = paneRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0 || rect.height === 0) 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;
};
(async () => {
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
const payload = event.payload;
if (payload.type === "leave") {
setDragOver(false);
return;
}
if (payload.type === "enter" || payload.type === "over") {
setDragOver(insideThisPane(payload.position));
return;
}
if (payload.type !== "drop") return;
setDragOver(false);
if (!insideThisPane(payload.position)) return;
const paths = payload.paths ?? [];
if (paths.length === 0) return;
await uploadPaths(paths);
});
if (cancelled) un();
else unlisten = un;
})();
return () => {
cancelled = true;
unlisten?.();
};
}, [running, uploadPaths]);
const breadcrumbs =
currentPath === "/"
? [{ label: "/", path: "/" }]
@@ -55,8 +196,15 @@ export default function FilesTab({ project }: Props) {
);
}
const rowClass = (isSelected: boolean) =>
`cursor-pointer transition-colors ${
isSelected
? "bg-[var(--bg-tertiary)]"
: "hover:bg-[var(--bg-tertiary)]"
}`;
return (
<div className="flex flex-col h-full min-h-0">
<div ref={paneRef} className="relative flex flex-col h-full min-h-0">
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
<nav aria-label="Path" className="flex items-center gap-1">
{breadcrumbs.map((crumb, i) => (
@@ -73,7 +221,22 @@ export default function FilesTab({ project }: Props) {
))}
</nav>
<div className="flex-1" />
<Button onClick={uploadFile}>Upload file</Button>
{busy && (
<span role="status" className="mr-2 text-[var(--text-secondary)] whitespace-nowrap">
{busy}
</span>
)}
<Button
onClick={() => {
setFolderDraft("");
setCreatingFolder(true);
}}
>
New folder
</Button>
<Button onClick={uploadFile} className="ml-1">
Upload file
</Button>
<Button onClick={refresh} disabled={loading} className="ml-1">
Refresh
</Button>
@@ -91,61 +254,156 @@ export default function FilesTab({ project }: Props) {
Loading
</div>
) : (
<table className="w-full text-xs">
<table role="grid" aria-label="Files" className="w-full text-xs">
<tbody>
{currentPath !== "/" && (
<tr
onClick={goUp}
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
>
<td className="px-4 py-1.5 text-[var(--text-primary)] font-mono">..</td>
<td colSpan={3} />
{creatingFolder && (
<tr>
<td role="gridcell" className="px-4 py-1.5" colSpan={4}>
<input
ref={folderInputRef}
value={folderDraft}
aria-label="New folder name"
placeholder="Folder name"
onChange={(e) => setFolderDraft(e.target.value)}
onBlur={commitFolder}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
if (e.key === "Escape") {
setCreatingFolder(false);
setFolderDraft("");
}
}}
className="w-64 px-1 py-0 select-text bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs font-mono text-[var(--text-primary)]"
/>
</td>
</tr>
)}
{entries.map((entry) => (
{currentPath !== "/" && (
<tr
key={entry.name}
onClick={() => entry.is_directory && navigate(entry.path)}
className={`${
entry.is_directory ? "cursor-pointer" : ""
} hover:bg-[var(--bg-tertiary)] transition-colors`}
tabIndex={0}
aria-label="Parent directory"
onDoubleClick={goUp}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
goUp();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
moveFocus(e.currentTarget, e.key === "ArrowDown" ? 1 : -1);
}
}}
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
>
<td className="px-4 py-1.5">
<span
className={`font-mono ${
entry.is_directory
? "text-[var(--accent)]"
: "text-[var(--text-primary)]"
}`}
>
{entry.is_directory ? "📁 " : ""}
{entry.name}
</span>
</td>
<td className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap tabular-nums">
{!entry.is_directory && formatBytes(entry.size)}
</td>
<td className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
{entry.modified}
</td>
<td className="px-2 py-1.5 text-right">
{!entry.is_directory && (
<Button
aria-label={`Download ${entry.name}`}
onClick={(e) => {
e.stopPropagation();
downloadFile(entry);
}}
>
Download
</Button>
)}
<td role="gridcell" className="px-4 py-1.5 text-[var(--text-primary)] font-mono">
..
</td>
<td role="gridcell" colSpan={3} />
</tr>
))}
)}
{entries.map((entry) => {
const isSelected = selected === entry.name;
const isRenaming = renaming === entry.name;
return (
<tr
key={entry.name}
tabIndex={0}
aria-selected={isSelected}
onClick={() => setSelected(entry.name)}
onDoubleClick={() => openEntry(entry)}
onKeyDown={(e) => {
if (isRenaming) return;
if (e.key === "Enter") {
e.preventDefault();
setSelected(entry.name);
openEntry(entry);
} else if (e.key === "F2") {
e.preventDefault();
startRename(entry);
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
moveFocus(e.currentTarget, e.key === "ArrowDown" ? 1 : -1);
}
}}
className={rowClass(isSelected)}
>
<td role="gridcell" className="px-4 py-1.5">
{isRenaming ? (
<input
ref={renameInputRef}
value={renameDraft}
aria-label={`New name for ${entry.name}`}
onChange={(e) => setRenameDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
onBlur={() => commitRename(entry)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
if (e.key === "Escape") setRenaming(null);
}}
className="w-64 px-1 py-0 select-text bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs font-mono text-[var(--text-primary)]"
/>
) : (
<span
className={`font-mono ${
entry.is_directory
? "text-[var(--accent)]"
: "text-[var(--text-primary)]"
}`}
>
{entry.is_directory && <span aria-hidden="true">📁 </span>}
<span>{entry.name}</span>
{entry.is_symlink && (
<span
className="ml-1 text-[var(--text-secondary)]"
title="Symbolic link"
>
link
</span>
)}
</span>
)}
</td>
<td role="gridcell" className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap tabular-nums">
{!entry.is_directory && formatBytes(entry.size)}
</td>
<td role="gridcell" className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
{entry.modified}
</td>
<td role="gridcell" className="px-2 py-1.5 text-right whitespace-nowrap">
{!isRenaming && (
<>
<Button
aria-label={`Rename ${entry.name}`}
onClick={(e) => {
e.stopPropagation();
startRename(entry);
}}
>
Rename
</Button>
{!entry.is_directory && (
<Button
aria-label={`Save ${entry.name} to host`}
className="ml-1"
onClick={(e) => {
e.stopPropagation();
downloadFile(entry);
}}
>
Save to host
</Button>
)}
</>
)}
</td>
</tr>
);
})}
{entries.length === 0 && !loading && (
<tr>
<td
role="gridcell"
colSpan={4}
className="px-4 py-8 text-center text-[var(--text-secondary)]"
>
@@ -157,6 +415,28 @@ export default function FilesTab({ project }: Props) {
</table>
)}
</div>
{/* Drop hint. Purely decorative — the native listener is what accepts the
drop, so this must never intercept pointer events. */}
{dragOver && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-center border-2 border-dashed border-[var(--accent)] bg-[var(--bg-primary)]/70"
>
<span className="text-[13px] font-medium text-[var(--text-primary)]">
Drop files into {currentPath}
</span>
</div>
)}
{viewing && (
<FileViewerModal
projectId={project.id}
entry={viewing}
onClose={() => setViewing(null)}
onSaveToHost={downloadFile}
/>
)}
</div>
);
}
@@ -0,0 +1,78 @@
import { describe, it, expect } from "vitest";
import {
IMAGE_PREVIEW_LIMIT,
TEXT_PREVIEW_LIMIT,
decodeBase64,
extensionOf,
imageMimeFor,
looksBinary,
previewKind,
previewLimit,
} from "./filePreview";
describe("extensionOf", () => {
it("lowercases, and takes only the last segment", () => {
expect(extensionOf("Photo.PNG")).toBe("png");
expect(extensionOf("archive.tar.gz")).toBe("gz");
expect(extensionOf("/workspace/app/main.rs")).toBe("rs");
});
it("treats a leading dot as hidden, not as an extension", () => {
// `.gitignore` is a text file called `.gitignore`, not one of type "gitignore".
expect(extensionOf(".gitignore")).toBe("");
expect(extensionOf("Makefile")).toBe("");
});
});
describe("previewKind", () => {
it("recognises images by extension, with a MIME the Blob can use", () => {
expect(previewKind("logo.png")).toBe("image");
expect(imageMimeFor("logo.PNG")).toBe("image/png");
expect(imageMimeFor("photo.jpeg")).toBe("image/jpeg");
expect(imageMimeFor("icon.svg")).toBe("image/svg+xml");
expect(imageMimeFor("notes.txt")).toBeNull();
});
it("recognises known text extensions and conventional extensionless names", () => {
expect(previewKind("main.rs")).toBe("text");
expect(previewKind("config.yaml")).toBe("text");
expect(previewKind("Dockerfile")).toBe("text");
expect(previewKind("README")).toBe("text");
expect(previewKind(".gitignore")).toBe("text");
});
it("leaves anything else undecided rather than refusing it outright", () => {
// `unknown` means "read it and sniff the bytes" — a .bak of a config file
// should still preview.
expect(previewKind("dump.bak")).toBe("unknown");
expect(previewKind("app.wasm")).toBe("unknown");
});
});
describe("previewLimit", () => {
it("gives images the bigger budget, since they are what blows a text cap", () => {
expect(previewLimit("photo.jpg")).toBe(IMAGE_PREVIEW_LIMIT);
expect(previewLimit("notes.md")).toBe(TEXT_PREVIEW_LIMIT);
expect(previewLimit("mystery.bin")).toBe(TEXT_PREVIEW_LIMIT);
expect(IMAGE_PREVIEW_LIMIT).toBeGreaterThan(TEXT_PREVIEW_LIMIT);
});
});
describe("decodeBase64 / looksBinary", () => {
it("round-trips bytes that are not valid UTF-8", () => {
// The reason the backend returns base64 at all: these bytes must survive.
const bytes = decodeBase64(btoa("\xff\xd8\xff\xe0"));
expect(Array.from(bytes)).toEqual([0xff, 0xd8, 0xff, 0xe0]);
});
it("calls a NUL-bearing prefix binary and plain text text", () => {
expect(looksBinary(new Uint8Array([0x68, 0x69, 0x0a]))).toBe(false);
expect(looksBinary(new Uint8Array([0x68, 0x00, 0x69]))).toBe(true);
});
it("only sniffs the first 8 KB, so a NUL deep in a big file is ignored", () => {
const bytes = new Uint8Array(20000).fill(0x61);
bytes[9000] = 0;
expect(looksBinary(bytes)).toBe(false);
});
});
@@ -0,0 +1,107 @@
/**
* What the Files viewer can show, and how much of it to ask for.
*
* Pure helpers, deliberately separate from the modal: the type sniffing is
* where a preview quietly turns into a screenful of mojibake, and it is worth
* testing without a container.
*/
/**
* Extension → MIME, for the raster/vector types an `<img>` actually renders.
* The MIME matters because the bytes are handed to the DOM as a `Blob`, and a
* blob with the wrong (or empty) type will not decode.
*/
const IMAGE_MIME: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
ico: "image/x-icon",
avif: "image/avif",
// Safe in an `<img>`: that context cannot run the script an SVG may carry.
svg: "image/svg+xml",
};
/** Extensions we are confident are text, so no byte sniffing is needed. */
const TEXT_EXTENSIONS = new Set([
"txt", "md", "markdown", "rst", "log", "csv", "tsv",
"json", "jsonc", "yaml", "yml", "toml", "ini", "cfg", "conf", "env", "properties",
"js", "jsx", "mjs", "cjs", "ts", "tsx", "rs", "py", "rb", "go", "java", "kt",
"c", "h", "cc", "cpp", "hpp", "cs", "php", "swift", "scala", "lua", "pl", "r",
"sh", "bash", "zsh", "fish", "ps1", "bat",
"html", "htm", "xml", "svelte", "vue", "css", "scss", "sass", "less",
"sql", "graphql", "gql", "proto", "diff", "patch", "lock", "gitignore",
"dockerfile", "makefile", "cmake", "gradle", "tf", "tfvars",
]);
/** Extensionless files that are text by convention. */
const TEXT_BASENAMES = new Set([
"dockerfile", "makefile", "readme", "license", "licence", "changelog",
"authors", "notice", "copying", "procfile", "rakefile", "gemfile", "vagrantfile",
// Dotfiles: the leading dot is stripped before the lookup.
"gitignore", "gitattributes", "gitmodules", "dockerignore", "npmrc", "nvmrc",
"editorconfig", "bashrc", "zshrc", "profile", "env",
]);
/** 1 MiB of text is already far more than anyone reads in a modal. */
export const TEXT_PREVIEW_LIMIT = 1024 * 1024;
/**
* Images get five times the budget: they are the file kind that routinely
* blows past a text-sized cap, and a half-read image is not a preview at all —
* it either decodes whole or it does not.
*/
export const IMAGE_PREVIEW_LIMIT = 5 * 1024 * 1024;
/** Lowercased extension, or "" for an extensionless name. */
export function extensionOf(name: string): string {
const base = name.slice(name.lastIndexOf("/") + 1);
const dot = base.lastIndexOf(".");
// A leading dot is "hidden file", not "extension" (`.gitignore`).
if (dot <= 0) return "";
return base.slice(dot + 1).toLowerCase();
}
/** The MIME to build the Blob with, or null if this is not a previewable image. */
export function imageMimeFor(name: string): string | null {
return IMAGE_MIME[extensionOf(name)] ?? null;
}
export type PreviewKind = "image" | "text" | "unknown";
/**
* A first guess from the name alone. `unknown` is not a refusal — the viewer
* reads the bytes and falls back to sniffing them, so a `.bak` of a config
* file still previews.
*/
export function previewKind(name: string): PreviewKind {
if (imageMimeFor(name)) return "image";
const ext = extensionOf(name);
if (ext) return TEXT_EXTENSIONS.has(ext) ? "text" : "unknown";
const base = name.slice(name.lastIndexOf("/") + 1).replace(/^\./, "").toLowerCase();
return TEXT_BASENAMES.has(base) ? "text" : "unknown";
}
/** How many bytes to ask the backend for, given what we expect to render. */
export function previewLimit(name: string): number {
return previewKind(name) === "image" ? IMAGE_PREVIEW_LIMIT : TEXT_PREVIEW_LIMIT;
}
/** Base64 → bytes. `atob` yields a binary string; widen it one char at a time. */
export function decodeBase64(base64: string): Uint8Array<ArrayBuffer> {
const binary = atob(base64);
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
/**
* The classic heuristic: a NUL byte early on means this is not text. Cheap,
* and it is what `git` and `grep` use to decide the same question.
*/
export function looksBinary(bytes: Uint8Array): boolean {
const limit = Math.min(bytes.length, 8000);
for (let i = 0; i < limit; i++) if (bytes[i] === 0) return true;
return false;
}
+211
View File
@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useFileManager } from "./useFileManager";
import type { FileEntry } from "../lib/types";
const listContainerFiles = vi.fn();
const downloadContainerFile = vi.fn();
const uploadFileToContainer = vi.fn();
const renameContainerPath = vi.fn();
const createContainerDirectory = vi.fn();
vi.mock("../lib/tauri-commands", () => ({
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
uploadFileToContainer: (p: string, h: string, d: string) => uploadFileToContainer(p, h, d),
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
createContainerDirectory: (p: string, parent: string, n: string) =>
createContainerDirectory(p, parent, n),
readContainerFile: vi.fn(),
}));
const save = vi.fn();
const openDialog = vi.fn();
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: (opts: unknown) => save(opts),
open: (opts: unknown) => openDialog(opts),
}));
const file = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
name,
path: `/workspace/${name}`,
is_directory: false,
is_symlink: false,
size: 10,
modified: "2024-01-01 00:00:00",
permissions: "644",
...extra,
});
beforeEach(() => {
vi.clearAllMocks();
listContainerFiles.mockResolvedValue([file("a.txt")]);
});
describe("useFileManager navigation", () => {
it("lists a directory and remembers where it is", async () => {
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.navigate("/workspace/app");
});
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace/app");
expect(result.current.currentPath).toBe("/workspace/app");
expect(result.current.entries).toHaveLength(1);
});
it("surfaces a listing failure rather than showing a stale directory", async () => {
listContainerFiles.mockRejectedValueOnce("Permission denied");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.navigate("/root");
});
expect(result.current.error).toContain("Permission denied");
expect(result.current.currentPath).toBe("/workspace");
});
it("goes up one level, and stops at the root", async () => {
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.navigate("/workspace/app/src");
});
await act(async () => {
result.current.goUp();
});
await waitFor(() => expect(result.current.currentPath).toBe("/workspace/app"));
await act(async () => {
await result.current.navigate("/");
});
listContainerFiles.mockClear();
await act(async () => {
result.current.goUp();
});
expect(listContainerFiles).not.toHaveBeenCalled();
});
});
describe("useFileManager uploads", () => {
it("uploads every dropped path into the current directory, then re-lists once", async () => {
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.navigate("/workspace/app");
});
listContainerFiles.mockClear();
await act(async () => {
await result.current.uploadPaths(["/host/a.png", "/host/b.png"]);
});
expect(uploadFileToContainer).toHaveBeenNthCalledWith(1, "p1", "/host/a.png", "/workspace/app");
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.png", "/workspace/app");
// One refresh for the batch, not one per file.
expect(listContainerFiles).toHaveBeenCalledTimes(1);
});
it("reports a failed upload but still lists whatever did land", async () => {
uploadFileToContainer.mockResolvedValueOnce(undefined);
uploadFileToContainer.mockRejectedValueOnce("File too large to upload (900 MB; limit 256 MB)");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.uploadPaths(["/host/ok.txt", "/host/huge.bin"]);
});
expect(result.current.error).toContain("too large");
expect(listContainerFiles).toHaveBeenCalled();
});
it("does nothing when the file picker is cancelled", async () => {
openDialog.mockResolvedValue(null);
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.uploadFile();
});
expect(uploadFileToContainer).not.toHaveBeenCalled();
});
});
describe("useFileManager rename and mkdir", () => {
it("sends the bare new name, never a path, and re-lists on success", async () => {
renameContainerPath.mockResolvedValue("/workspace/renamed.txt");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.navigate("/workspace");
});
listContainerFiles.mockClear();
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.renameEntry(file("a.txt"), " renamed.txt ");
});
expect(ok).toBe(true);
expect(renameContainerPath).toHaveBeenCalledWith("p1", "/workspace/a.txt", "renamed.txt");
expect(listContainerFiles).toHaveBeenCalledTimes(1);
});
it("keeps the editor open and shows what the container said when a rename fails", async () => {
// Renames outside /workspace legitimately fail; the user needs mv's words.
renameContainerPath.mockRejectedValue("mv: cannot move '/etc/hosts': Permission denied");
const { result } = renderHook(() => useFileManager("p1"));
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.renameEntry(file("hosts"), "hosts.bak");
});
expect(ok).toBe(false);
expect(result.current.error).toContain("Permission denied");
});
it("treats an unchanged name as a no-op rather than a round trip", async () => {
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.renameEntry(file("a.txt"), "a.txt");
});
expect(renameContainerPath).not.toHaveBeenCalled();
});
it("creates a folder under the current directory", async () => {
createContainerDirectory.mockResolvedValue("/workspace/app/new");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.navigate("/workspace/app");
});
await act(async () => {
await result.current.createFolder(" new ");
});
expect(createContainerDirectory).toHaveBeenCalledWith("p1", "/workspace/app", "new");
});
it("surfaces a clash instead of silently doing nothing", async () => {
createContainerDirectory.mockRejectedValue("mkdir: cannot create directory 'src': File exists");
const { result } = renderHook(() => useFileManager("p1"));
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.createFolder("src");
});
expect(ok).toBe(false);
expect(result.current.error).toContain("File exists");
});
});
describe("useFileManager save to host", () => {
it("writes to the path the user picked", async () => {
save.mockResolvedValue("/host/Downloads/a.txt");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.downloadFile(file("a.txt"));
});
expect(downloadContainerFile).toHaveBeenCalledWith(
"p1",
"/workspace/a.txt",
"/host/Downloads/a.txt",
);
});
it("reports a refused download — a directory is no longer written as garbage", async () => {
save.mockResolvedValue("/host/Downloads/src");
downloadContainerFile.mockRejectedValue("/workspace/src is a folder — download its files individually");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.downloadFile(file("src", { is_directory: true }));
});
expect(result.current.error).toContain("is a folder");
});
});
+81 -4
View File
@@ -8,6 +8,8 @@ export function useFileManager(projectId: string) {
const [entries, setEntries] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/** Transient "uploading 3 files…" style note, shown beside the breadcrumb. */
const [busy, setBusy] = useState<string | null>(null);
const navigate = useCallback(
async (path: string) => {
@@ -36,11 +38,13 @@ export function useFileManager(projectId: string) {
navigate(currentPath);
}, [currentPath, navigate]);
/** Copy an entry out to a host path the user picks. */
const downloadFile = useCallback(
async (entry: FileEntry) => {
try {
const hostPath = await save({ defaultPath: entry.name });
if (!hostPath) return;
setError(null);
await commands.downloadContainerFile(projectId, entry.path, hostPath);
} catch (e) {
setError(String(e));
@@ -49,26 +53,99 @@ export function useFileManager(projectId: string) {
[projectId],
);
/**
* Copy host files into the current directory. Shared by the Upload button and
* the native drag-drop listener, so a dropped file and a picked one take the
* same path — including the one refresh at the end rather than one per file.
*/
const uploadPaths = useCallback(
async (hostPaths: string[]) => {
if (hostPaths.length === 0) return;
setError(null);
setBusy(`Uploading ${hostPaths.length} item${hostPaths.length > 1 ? "s" : ""}`);
const failures: string[] = [];
try {
for (const hostPath of hostPaths) {
try {
await commands.uploadFileToContainer(projectId, hostPath, currentPath);
} catch (e) {
failures.push(String(e));
}
}
} finally {
setBusy(null);
}
// Re-list first: `navigate` clears the error, so reporting before it
// would wipe the very message the user needs.
await navigate(currentPath);
if (failures.length > 0) setError(failures.join(" · "));
},
[projectId, currentPath, navigate],
);
const uploadFile = useCallback(async () => {
try {
const selected = await openDialog({ multiple: false, directory: false });
const selected = await openDialog({ multiple: true, directory: false });
if (!selected) return;
await commands.uploadFileToContainer(projectId, selected as string, currentPath);
await navigate(currentPath);
await uploadPaths(Array.isArray(selected) ? selected : [selected as string]);
} catch (e) {
setError(String(e));
}
}, [projectId, currentPath, navigate]);
}, [uploadPaths]);
/**
* Rename in place. `newName` is a bare name — Rust rejects anything with a
* `/` in it, so this can never turn into a move. Resolves true on success so
* the caller knows whether to leave edit mode.
*/
const renameEntry = useCallback(
async (entry: FileEntry, newName: string) => {
const trimmed = newName.trim();
if (!trimmed || trimmed === entry.name) return true;
try {
setError(null);
await commands.renameContainerPath(projectId, entry.path, trimmed);
await navigate(currentPath);
return true;
} catch (e) {
setError(String(e));
return false;
}
},
[projectId, currentPath, navigate],
);
const createFolder = useCallback(
async (name: string) => {
const trimmed = name.trim();
if (!trimmed) return true;
try {
setError(null);
await commands.createContainerDirectory(projectId, currentPath, trimmed);
await navigate(currentPath);
return true;
} catch (e) {
setError(String(e));
return false;
}
},
[projectId, currentPath, navigate],
);
return {
currentPath,
entries,
loading,
error,
busy,
setError,
navigate,
goUp,
refresh,
downloadFile,
uploadFile,
uploadPaths,
renameEntry,
createFolder,
};
}
+8 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -77,6 +77,13 @@ export const downloadContainerBackup = (projectId: string, hostPath: string, con
invoke<number>("download_container_backup", { projectId, hostPath, containerPath });
export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) =>
invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir });
export const readContainerFile = (projectId: string, path: string, maxBytes?: number) =>
invoke<FileContents>("read_container_file", { projectId, path, maxBytes });
/** `toPath` is the new *name*, not a destination — renames never move. */
export const renameContainerPath = (projectId: string, fromPath: string, toPath: string) =>
invoke<string>("rename_container_path", { projectId, fromPath, toPath });
export const createContainerDirectory = (projectId: string, parentPath: string, name: string) =>
invoke<string>("create_container_directory", { projectId, parentPath, name });
// Updates
export const getAppVersion = () => invoke<string>("get_app_version");
+12
View File
@@ -329,12 +329,24 @@ export interface ImageUpdateInfo {
export interface FileEntry {
name: string;
path: string;
/** Dereferenced: a symlink pointing at a directory reads as one. */
is_directory: boolean;
is_symlink: boolean;
size: number;
modified: string;
permissions: string;
}
/** A file read out of the container for the in-app viewer. */
export interface FileContents {
/** Base64 — a byte array would cross IPC as JSON numbers. */
contents_base64: string;
/** The file was larger than the cap; only a prefix came back. */
truncated: boolean;
/** The file's real size, not the length of what was returned. */
size: number;
}
export interface InstallOptions {
os: "linux" | "macos" | "windows" | "unknown";
product_name: string;