From 15e05e21975f3e0e937331b55c7c416090f21743 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 23 Aug 2026 08:30:48 -0700 Subject: [PATCH] Turn the Files tab into a real file manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename, an in-app viewer for text and images, host-to-container drag and drop, New folder, keyboard operation — plus the pre-existing bugs the new surface would otherwise have been built on top of. New Tauri commands (file_commands.rs, registered in lib.rs): * rename_container_path — `mv -n -- /` through exec_oneshot_as, so the *exit code* is checked. exec_oneshot discards the status and interleaves stderr into stdout, which would have made a permission failure look like a success. `mv -n` on its own is not enough either: GNU coreutils makes its refusal to clobber silent and exits 0, so an explicit `test -e` on the destination is what turns a name clash into an error the user sees. `mv`'s own words are surfaced, since renames outside /workspace legitimately fail on permissions. The new name is validated in Rust (no `/`, no NUL, not "." / ".." / empty, ≤255 bytes) — it is user text going into argv, and a name with a separator would be a move rather than a rename. * read_container_file — exact bytes via Docker's archive endpoint, returned as base64. Deliberately not exec_oneshot, which runs every chunk through String::from_utf8_lossy and merges stderr, so it would corrupt any non-UTF-8 file and could splice diagnostics into content. Base64 rather than Vec because Tauri serialises a byte vec as a JSON number array. Capped and truncation-reporting; the caller picks the cap (images get 5 MiB against text's 1 MiB, being the kind that blows a text-sized budget) and Rust clamps it to 8 MiB regardless. * create_container_directory — `mkdir` without -p, so a clash is an error rather than a silent success. Named for its siblings rather than the bare `create_directory` in the brief. The tar-extraction half of download_container_file is now the shared fetch_container_file() both commands use, and it abandons the transfer once a capped read has what it needs. Frontend: * Single click selects, double click opens. Directory navigation moved onto double click too — a single click used to navigate, which made it impossible to select a directory in order to rename it. Rows are now focusable and the table is a real `grid`: Enter opens, F2 renames, arrows walk the rows. No outline suppression; the global :focus-visible ring is what shows focus. * FileViewerModal (built on ui/Modal, the only correct dialog) renders text in a
 and images from a revocable blob: URL. tauri.conf.json's
  img-src had neither `data:` nor `blob:`, so an in-app image was blocked by
  CSP; `blob:` is added — revocable, and no megabytes of base64 in the DOM.
  The asset protocol stays disabled. Anything else gets a "Save to host"
  state instead of a broken preview, decided by extension and then by
  sniffing the bytes for NUL.
* Host drag-and-drop uses Tauri's native onDragDropEvent, mirroring
  TerminalView: HTML5 ondrop carries no paths and is blocked in the webview
  on Windows by dragDropEnabled, which the terminal needs. The listener is
  window-wide, so it routes by hit-testing the payload position (physical
  pixels, hence the devicePixelRatio divide) against the pane's rect — a
  hidden pane has a zero-size rect and never matches, which is what keeps
  this and the terminal's listener apart. enter/over/leave drive a drop
  highlight.
* Per-row Download is now "Save to host…"; directories no longer offer it.

Pre-existing bugs fixed:

* Uploaded files landed root:root with a 1970 mtime. tar::Header::new_gnu()
  zeroes uid/gid/mtime and Docker honours the header verbatim, so uploads
  were not writable by `claude`. All four single-file tar builds now go
  through build_single_file_tar() with the container user's ids, read from
  the container because entrypoint.sh remaps them to the host user on Unix
  and deliberately does not on Windows.
* Symlinked directories could not be opened: `find -printf '%y'` reports `l`.
  The listing now prints `%Y` as well, so is_directory dereferences and a
  new is_symlink carries what `%y` used to say. The row labels the link.
* upload_file_to_container had no size cap and did a synchronous fs::read on
  an async worker. Now 256 MiB (matching the terminal drop path) with the
  read and tar build in spawn_blocking, and the host mtime preserved.
* A directory passed to upload reached fs::read and produced an opaque "Is a
  directory". Rejected with an explanation instead — recursive upload is a
  larger feature than this panel needs.
* download_container_file wrote the *first tar entry*, so downloading a
  directory silently produced a garbage file. Non-regular entries are now an
  explicit error.

Tests: 46 new (33 frontend across FilesTab, useFileManager and filePreview;
12 Rust covering the find-output parser and the rename validator, neither of
which had any). 405 frontend / 297 Rust, both green.

No drag-out dependency was added — tauri-plugin-drag is not introduced and
OS drag-out is not attempted; that stays deferred, with "Save to host…" as
the way files leave the container.

Co-Authored-By: Claude Opus 5 (1M context) 
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
---
 app/src-tauri/src/commands/file_commands.rs   | 551 ++++++++++++++++--
 app/src-tauri/src/docker/exec.rs              | 124 ++--
 app/src-tauri/src/lib.rs                      |   3 +
 app/src-tauri/tauri.conf.json                 |   2 +-
 .../projects/home/FileViewerModal.tsx         | 157 +++++
 .../projects/home/FilesTab.test.tsx           | 350 +++++++++++
 app/src/components/projects/home/FilesTab.tsx | 380 ++++++++++--
 .../projects/home/filePreview.test.ts         |  78 +++
 .../components/projects/home/filePreview.ts   | 107 ++++
 app/src/hooks/useFileManager.test.ts          | 211 +++++++
 app/src/hooks/useFileManager.ts               |  85 ++-
 app/src/lib/tauri-commands.ts                 |   9 +-
 app/src/lib/types.ts                          |  12 +
 13 files changed, 1916 insertions(+), 153 deletions(-)
 create mode 100644 app/src/components/projects/home/FileViewerModal.tsx
 create mode 100644 app/src/components/projects/home/FilesTab.test.tsx
 create mode 100644 app/src/components/projects/home/filePreview.test.ts
 create mode 100644 app/src/components/projects/home/filePreview.ts
 create mode 100644 app/src/hooks/useFileManager.test.ts

diff --git a/app/src-tauri/src/commands/file_commands.rs b/app/src-tauri/src/commands/file_commands.rs
index 37f670f..d5a1232 100644
--- a/app/src-tauri/src/commands/file_commands.rs
+++ b/app/src-tauri/src/commands/file_commands.rs
@@ -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` 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 {
     let mut entries: Vec = output
         .lines()
         .filter(|line| !line.trim().is_empty())
         .filter_map(|line| {
             let parts: Vec<&str> = line.split('\t').collect();
-            if parts.len() < 5 {
+            if parts.len() < 6 {
                 return None;
             }
             let name = parts[0].to_string();
-            let is_directory = parts[1] == "d";
-            let size = parts[2].parse::().unwrap_or(0);
-            let modified_epoch = parts[3].parse::().unwrap_or(0.0);
-            let permissions = parts[4].to_string();
+            let is_symlink = parts[1] == "l";
+            let is_directory = parts[2] == "d";
+            let size = parts[3].parse::().unwrap_or(0);
+            let modified_epoch = parts[4].parse::().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,
+    /// 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,
+) -> Result {
     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 = 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,
+    state: State<'_, AppState>,
+) -> Result {
+    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 {
+    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 {
+    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, 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);
+    }
+}
diff --git a/app/src-tauri/src/docker/exec.rs b/app/src-tauri/src/docker/exec.rs
index 9095150..2e6951d 100644
--- a/app/src-tauri/src/docker/exec.rs
+++ b/app/src-tauri/src/docker/exec.rs
@@ -301,21 +301,10 @@ impl ExecSessionManager {
     ) -> Result {
         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, 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 {
     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, 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::().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.
 ///
diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs
index a918c19..1bd04d3 100644
--- a/app/src-tauri/src/lib.rs
+++ b/app/src-tauri/src/lib.rs
@@ -475,6 +475,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
diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json
index 6332887..09593f6 100644
--- a/app/src-tauri/tauri.conf.json
+++ b/app/src-tauri/tauri.conf.json
@@ -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": {
diff --git a/app/src/components/projects/home/FileViewerModal.tsx b/app/src/components/projects/home/FileViewerModal.tsx
new file mode 100644
index 0000000..213bf15
--- /dev/null
+++ b/app/src/components/projects/home/FileViewerModal.tsx
@@ -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({ 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 = (
+    <>
+      
+      
+    
+  );
+
+  return (
+    
+      {preview.kind === "loading" && (
+        

Loading…

+ )} + + {preview.kind === "error" && ( +

+ {preview.message} +

+ )} + + {preview.kind === "too-large" && ( +

+ This file is {formatBytes(entry.size)} — too large to preview in the app. Save it + to the host to open it there. +

+ )} + + {preview.kind === "unsupported" && ( +

+ There is no preview for this file type. Save it to the host to open it there. +

+ )} + + {preview.kind === "text" && ( + <> + {preview.truncated && ( +

+ Showing the first {formatBytes(preview.shownBytes)} of {formatBytes(preview.trueSize)}. +

+ )} +
+            {preview.text}
+          
+ + )} + + {preview.kind === "image" && ( + {entry.name} + )} +
+ ); +} diff --git a/app/src/components/projects/home/FilesTab.test.tsx b/app/src/components/projects/home/FilesTab.test.tsx new file mode 100644 index 0000000..9470d20 --- /dev/null +++ b/app/src/components/projects/home/FilesTab.test.tsx @@ -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) | 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 => ({ + 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 => ({ + contents_base64: btoa(text), + truncated: false, + size: text.length, + ...extra, +}); + +async function renderTab() { + const view = render(); + 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(); + 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(); + }); +}); diff --git a/app/src/components/projects/home/FilesTab.tsx b/app/src/components/projects/home/FilesTab.tsx index f8e2f8f..8c6c85b 100644 --- a/app/src/components/projects/home/FilesTab.tsx +++ b/app/src/components/projects/home/FilesTab.tsx @@ -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(null); + const [renaming, setRenaming] = useState(null); + const [renameDraft, setRenameDraft] = useState(""); + const [creatingFolder, setCreatingFolder] = useState(false); + const [folderDraft, setFolderDraft] = useState(""); + const [viewing, setViewing] = useState(null); + /** A host drag is currently over this pane. */ + const [dragOver, setDragOver] = useState(false); + + const paneRef = useRef(null); + const renameInputRef = useRef(null); + const folderInputRef = useRef(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('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 ( -
+
- + {busy && ( + + {busy} + + )} + + @@ -91,61 +254,156 @@ export default function FilesTab({ project }: Props) { Loading…
) : ( - +
- {currentPath !== "/" && ( - - - + )} - {entries.map((entry) => ( + {currentPath !== "/" && ( 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" > - - - - + - ))} + )} + {entries.map((entry) => { + const isSelected = selected === entry.name; + const isRenaming = renaming === entry.name; + return ( + 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)} + > + + + + + + ); + })} {entries.length === 0 && !loading && (
.. + {creatingFolder && ( +
+ 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)]" + /> +
- - {entry.is_directory ? "📁 " : ""} - {entry.name} - - - {!entry.is_directory && formatBytes(entry.size)} - - {entry.modified} - - {!entry.is_directory && ( - - )} + + ..
+ {isRenaming ? ( + 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)]" + /> + ) : ( + + {entry.is_directory && } + {entry.name} + {entry.is_symlink && ( + + ↗ link + + )} + + )} + + {!entry.is_directory && formatBytes(entry.size)} + + {entry.modified} + + {!isRenaming && ( + <> + + {!entry.is_directory && ( + + )} + + )} +
@@ -157,6 +415,28 @@ export default function FilesTab({ project }: Props) {
)}
+ + {/* Drop hint. Purely decorative — the native listener is what accepts the + drop, so this must never intercept pointer events. */} + {dragOver && ( + + )} + + {viewing && ( + setViewing(null)} + onSaveToHost={downloadFile} + /> + )}
); } diff --git a/app/src/components/projects/home/filePreview.test.ts b/app/src/components/projects/home/filePreview.test.ts new file mode 100644 index 0000000..3dbf0c8 --- /dev/null +++ b/app/src/components/projects/home/filePreview.test.ts @@ -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); + }); +}); diff --git a/app/src/components/projects/home/filePreview.ts b/app/src/components/projects/home/filePreview.ts new file mode 100644 index 0000000..831b8e5 --- /dev/null +++ b/app/src/components/projects/home/filePreview.ts @@ -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 `` 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 = { + 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 ``: 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 { + 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; +} diff --git a/app/src/hooks/useFileManager.test.ts b/app/src/hooks/useFileManager.test.ts new file mode 100644 index 0000000..ba49ee3 --- /dev/null +++ b/app/src/hooks/useFileManager.test.ts @@ -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 => ({ + 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"); + }); +}); diff --git a/app/src/hooks/useFileManager.ts b/app/src/hooks/useFileManager.ts index f344d71..a7f978d 100644 --- a/app/src/hooks/useFileManager.ts +++ b/app/src/hooks/useFileManager.ts @@ -8,6 +8,8 @@ export function useFileManager(projectId: string) { const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + /** Transient "uploading 3 files…" style note, shown beside the breadcrumb. */ + const [busy, setBusy] = useState(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, }; } diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index f80607e..a7de256 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -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("check_docker"); @@ -77,6 +77,13 @@ export const downloadContainerBackup = (projectId: string, hostPath: string, con invoke("download_container_backup", { projectId, hostPath, containerPath }); export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) => invoke("upload_file_to_container", { projectId, hostPath, containerDir }); +export const readContainerFile = (projectId: string, path: string, maxBytes?: number) => + invoke("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("rename_container_path", { projectId, fromPath, toPath }); +export const createContainerDirectory = (projectId: string, parentPath: string, name: string) => + invoke("create_container_directory", { projectId, parentPath, name }); // Updates export const getAppVersion = () => invoke("get_app_version"); diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index b4eec82..ff96e8b 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -322,12 +322,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;