Merge branch 'feat/file-manager' into integration/round-1
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user