From 15e05e21975f3e0e937331b55c7c416090f21743 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 23 Aug 2026 08:30:48 -0700 Subject: [PATCH 01/43] 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; -- 2.52.0 From 22d142c70ddbf7aa14819cc5a6d5bde23a270619 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 23 Aug 2026 08:31:39 -0700 Subject: [PATCH 02/43] Shift+Enter newline, OAuth URL truncation, and the auth bridge toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes that all land on the same journey: sign in, paste a prompt, and have the terminal behave the way every other Claude Code host does. Shift+Enter inserts a newline ----------------------------- xterm.js does not consult `shiftKey` for Enter (`Keyboard.ts`, case 13), so Shift+Enter was byte-identical to Enter and submitted the prompt. Both terminals now send `\x1b\r` (ESC+CR) instead, which Claude Code parses as return+meta — the same bytes its own `/terminal-setup` writes into the VS Code, Cursor, Alacritty and Zed keymaps, so this is in-band rather than a guess. Not `\n`: Claude Code accepts it, but a shell would run the line, so the two session types would diverge. Bound in Claude sessions only for that reason. `entrypoint.sh` sets `shiftEnterKeyBindingInstalled` in `~/.claude.json` so the CLI stops printing its "run /terminal-setup" tip. Purely cosmetic — the decoding is unconditional either way. Alt+Enter has always done the same thing (xterm ESC-prefixes on altKey) and was simply never documented. It is now, along with the rest. OAuth login URL truncation -------------------------- Two producers wrote one toast slot, last-writer-wins. The OSC 7777 relay delivers the URL base64-encoded and therefore exact; ~300 ms later the screen-scraper's debounce fired and overwrote it with a truncated guess at the same link — a URL that parses, points at the right host, and authorises nothing. The user is the one who has to notice. Why the scraper truncated: `ANSI_RE` strips OSC sequences wholesale, including the OSC 8 hyperlink whose parameter carries the complete URL. Claude Code slices the *visible* text of that hyperlink to the terminal width while every emission carries the whole URL in its parameter. The backend already knew this (`commands/auth_token_commands.rs`); the frontend did not. - `urlDetector` now reads OSC 8 targets out of the raw buffer before stripping, filtered by a port of `usable_sign_in_link`, and tags every candidate with its provenance. - The prompt slot gained `supersedes`: better provenance always wins, worse never does, and between equals only a candidate that *extends* what is showing may replace it. That last rule is `extendsUrl`, factored out of `pickSignInUrl` rather than copied — same rule, same reason, one implementation. - `flatten` splits on a bare `\r` as well as on `\r?\n`, so a `\r`-repainted TUI frame no longer inflates a line past the width and suppresses a join that should have happened; and the width is now sampled at `feed()` rather than read at `scan()`, so a resize inside the 300 ms debounce cannot reassemble 80-column text against a 120-column rule. Also corrects the comment claiming `acquire_claude_token` enables the auth bridge. It deliberately does not, and the module comment in `auth_token_commands.rs` explains at length why not. The auth bridge toggle ---------------------- `setAuthBridgeEnabled` and `getAuthBridgeStatus` had zero call sites: the Rust was complete, the IPC wrapper shipped, and there was nowhere to click — so the docs told users to "enable the Auth Bridge" for a switch that did not exist. `AuthBridgeRow` is that switch, in Config → Runtime. It deliberately does not go through the tab's stopped-only save: the dedicated command exists so the bridge can be flipped while a login is hanging in a running container, which is the only moment anyone reaches for it. It also subscribes to `auth-bridge-changed`, which the poller has been emitting to nobody — so a host port the bridge could not take was a completely silent failure, indistinguishable from a login that hung. `tunnel.rs` promotes the best-effort `::1` bind failure from debug to a warning recorded on the port. Half-bound is the failure mode that looks like success: the status says bridged, and a client that resolves `localhost` to `::1` without falling back is still refused. Finally, for a recognised Anthropic sign-in URL the toast now leads with "In container" and demotes the host "Open". The callback listener is inside the container, so the container-side browser closes the loop with no host round trip and no auth bridge; the host button stays as the fallback. Ordinary URLs are unchanged. Tests: 402 frontend (was 359), 285 Rust (unchanged). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc --- HOW-TO-USE.md | 44 +++- README.md | 17 +- app/src-tauri/src/auth_bridge/mod.rs | 5 + app/src-tauri/src/auth_bridge/tunnel.rs | 49 +++- app/src-tauri/src/web_terminal/terminal.html | 33 ++- app/src/components/layout/StatusBar.tsx | 13 + .../home/config/AuthBridgeRow.test.tsx | 178 ++++++++++++++ .../projects/home/config/AuthBridgeRow.tsx | 198 ++++++++++++++++ .../home/config/RuntimeSection.test.tsx | 39 ++- .../projects/home/config/RuntimeSection.tsx | 7 + .../components/terminal/TerminalView.test.tsx | 224 ++++++++++++++++++ app/src/components/terminal/TerminalView.tsx | 148 ++++++++++-- app/src/components/terminal/UrlToast.test.tsx | 75 ++++++ app/src/components/terminal/UrlToast.tsx | 161 +++++++++---- app/src/hooks/useClaudeAuth.ts | 7 +- app/src/lib/tauri-commands.ts | 9 +- app/src/lib/types.ts | 5 + app/src/lib/urlDetector.test.ts | 194 ++++++++++++++- app/src/lib/urlDetector.ts | 162 ++++++++++++- app/src/lib/urlRelay.ts | 36 +++ container/entrypoint.sh | 23 ++ 21 files changed, 1529 insertions(+), 98 deletions(-) create mode 100644 app/src/components/projects/home/config/AuthBridgeRow.test.tsx create mode 100644 app/src/components/projects/home/config/AuthBridgeRow.tsx create mode 100644 app/src/components/terminal/TerminalView.test.tsx diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index 643b022..df4a4e0 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -128,8 +128,11 @@ Anthropic-backend project uses that token without its own login. See 2. Claude prints an OAuth URL. Triple-C detects long URLs and shows a clickable toast at the top of the terminal — click **Open** to open it in your browser. 3. Complete the login in your browser. The token is saved and persists across container stops, starts and recreations. A **Reset** deletes it — see below. -> If the login hangs after the browser step, the callback could not reach the container. Enable the -> [Auth Bridge](#browser-logins-inside-the-container-auth-bridge) for that project. +> If the login hangs after the browser step, the callback could not reach the container. Either +> click **In container** on the toast instead of **Open** — the callback then never has to leave the +> container at all — or turn on the +> [Auth Bridge](#browser-logins-inside-the-container-auth-bridge) in the project's +> **Config → Runtime** section. **AWS Bedrock:** @@ -789,6 +792,19 @@ web server they started on `localhost`. `claude login`, `aws sso login` and Conc The **Auth Bridge** fixes this. It is **opt-in per project** and **off by default**. +### Where the switch is + +Project Home → **Config** → **Runtime** → **Auth bridge**. + +Unlike the rest of that tab, it is **not** greyed out while the container is running — it is a +host-side feature that recreates nothing, and the moment you want it is usually the moment a login +is already hanging in a running container. Switch it on, then retry the login. + +Beside the switch is its live state: **Off**, **Watching** (on, nothing to bridge yet — normal, +there is only something to bridge while a login is waiting), **Bridging *n* ports**, **IPv4 only**, +or **Port conflict** with the port and the reason. A conflict means the host port was already taken +and the callback will not arrive; free the port, or use **In container** instead. + ### What it does - Every couple of seconds it looks inside the container for programs listening on the container's @@ -1293,9 +1309,19 @@ triple-c-scheduler add --name "test" --schedule "0 */6 * * *" --prompt "Run test | **Ctrl+Shift+V** | Paste | | **Ctrl+V** | Paste an image from the clipboard into the container | | **Ctrl+Shift+M** | Toggle speech-to-text recording (when enabled) | +| **Shift+Enter** | Insert a newline in Claude Code's prompt instead of submitting it | +| **Alt+Enter** | The same thing, and it has always worked — it was simply never written down | Everything else goes straight through to the program running in the container. +> **Shift+Enter** sends `ESC` + `CR`, the same bytes Claude Code's own `/terminal-setup` installs +> for VS Code, Cursor, Alacritty and Zed — so there is nothing to run and no tip to follow. It is +> bound in **Claude** tabs only: in a **bash** tab that sequence means nothing to readline, and +> Shift+Enter there submits the line as it always has. +> +> In the [Web Terminal](#web-terminal-remote-access) the same chord works, and there is an **↵+** +> key beside **Enter** on the mobile key row for devices with no Shift. + --- ## What's Inside the Container @@ -1402,8 +1428,18 @@ your machine (anything that isn't `http`/`https`). You opened the URL, signed in successfully, and the CLI in the terminal is still waiting. The callback from your browser is landing on your host's `localhost` while the CLI is listening on the -*container's*. Enable the -[Auth Bridge](#browser-logins-inside-the-container-auth-bridge) for that project and try again. +*container's*. + +Two ways out, in order of least effort: + +1. Dismiss and re-trigger the login, then click **In container** on the toast rather than **Open**. + The page opens in a browser *inside* the container, so the callback never has to cross to the + host. This needs no auth bridge — only a running container with Playwright installed (Project + Home → **Browser**). For a recognised Anthropic sign-in link this is already the default button. +2. Turn on the [Auth Bridge](#browser-logins-inside-the-container-auth-bridge) — Project Home → + **Config** → **Runtime** → **Auth bridge** — and try again. It can be switched on while the + container is running. Check the indicator beside it: **Port conflict** means the host port was + already taken and the callback still will not arrive. For Claude specifically, the simpler answer is usually [Shared Claude Authentication](#shared-claude-authentication), which finishes on an Anthropic-hosted diff --git a/README.md b/README.md index 61359a2..4845abf 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,21 @@ Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase): `Ctrl+W` is deliberately **not** bound: it is readline's `kill-word`, used constantly in the terminal this app is built around. Plain `Ctrl+←/→` is readline's word-wise cursor motion, which is -why moving a tab takes Shift as well. Terminal-scoped keys (`Ctrl+Shift+C`, `Ctrl+Shift+Alt+C`, -`Ctrl+Shift+M`) are handled in `TerminalView.tsx`. +why moving a tab takes Shift as well. + +Terminal-scoped keys are handled in `TerminalView.tsx`: + +| Shortcut | Action | +|---|---| +| `Ctrl+Shift+C` / `Ctrl+Shift+Alt+C` | Copy the selection, trimmed / exactly as-is | +| `Ctrl+Shift+M` | Toggle speech-to-text recording | +| `Shift+Enter` | Insert a newline in Claude Code's prompt instead of submitting | +| `Alt+Enter` | The same thing — xterm.js already ESC-prefixes on Alt, so this has always worked | + +`Shift+Enter` sends `ESC` + `CR`, which is what Claude Code's own `/terminal-setup` installs for +VS Code, Cursor, Alacritty and Zed. It is bound in Claude sessions only: in a bash tab those bytes +are unbound in readline. The web terminal does the same, and adds an `↵+` key beside Enter for +devices with no Shift. ### Project Home diff --git a/app/src-tauri/src/auth_bridge/mod.rs b/app/src-tauri/src/auth_bridge/mod.rs index 3f89ba3..bc2b7e0 100644 --- a/app/src-tauri/src/auth_bridge/mod.rs +++ b/app/src-tauri/src/auth_bridge/mod.rs @@ -82,6 +82,10 @@ pub struct BridgedPort { pub family: PortFamily, /// RFC 3339 timestamp of when the host listener was bound. pub bridged_at: String, + /// Set when only the IPv4 half of the host listener could be bound. The + /// port still works, but not for a client that insists on `::1` — see + /// [`tunnel::PortForward::ipv6_warning`]. + pub ipv6_warning: Option, } /// A loopback listener that was discovered but could not be bridged. @@ -132,6 +136,7 @@ impl BridgeState { port: f.port, family: f.family, bridged_at: f.bridged_at.clone(), + ipv6_warning: f.ipv6_warning.clone(), }) .collect(), conflicts: self diff --git a/app/src-tauri/src/auth_bridge/tunnel.rs b/app/src-tauri/src/auth_bridge/tunnel.rs index 0ee6ecf..3882597 100644 --- a/app/src-tauri/src/auth_bridge/tunnel.rs +++ b/app/src-tauri/src/auth_bridge/tunnel.rs @@ -52,6 +52,15 @@ pub struct PortForward { pub port: u16, pub family: PortFamily, pub bridged_at: String, + /// Why `[::1]` could not be taken alongside `127.0.0.1`, if it could not. + /// + /// A half-bound forward is the one failure mode that looks like a success: + /// the status says the port is bridged, and a browser that resolves + /// `localhost` to `::1` and does not fall back still gets a refused + /// connection. It is not a conflict — the IPv4 half really is carrying + /// traffic — so it rides along with the port it belongs to and the UI says + /// so, rather than being logged at debug where nobody sees it. + pub ipv6_warning: Option, task: JoinHandle<()>, } @@ -86,18 +95,33 @@ impl PortForward { // first, so a v4-only host listener would miss those callbacks. This is // best-effort: if ::1 is unavailable (no IPv6, or that half is taken) // the v4 listener alone still works, so it is not treated as a conflict. - let v6 = match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await { - Ok(l) => Some(l), - Err(e) => { - log::debug!( - "Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only", - port, - port, - e - ); - None - } - }; + let (v6, ipv6_warning) = + match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await { + Ok(l) => (Some(l), None), + Err(e) => { + // Warn, not debug. Best-effort is about whether to *fail*, + // not about whether to say anything: on a host where + // `localhost` resolves to `::1` and the client does not + // fall back to IPv4, the callback is refused while the + // bridge reports itself healthy — a silent failure with no + // thread back to this line. + log::warn!( + "Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only; \ + a client that resolves localhost to ::1 without falling back will not reach it", + port, + port, + e + ); + ( + None, + Some(format!( + "IPv4 only — [::1]:{} could not be bound ({}). A browser that resolves \ + localhost to ::1 without falling back will not reach this port.", + port, e + )), + ) + } + }; let target = family.socat_target(port); let task = tokio::spawn(accept_loop(container_id, port, target, v4, v6)); @@ -106,6 +130,7 @@ impl PortForward { port, family, bridged_at: chrono::Utc::now().to_rfc3339(), + ipv6_warning, task, }) } diff --git a/app/src-tauri/src/web_terminal/terminal.html b/app/src-tauri/src/web_terminal/terminal.html index cd06179..1ca555d 100644 --- a/app/src-tauri/src/web_terminal/terminal.html +++ b/app/src-tauri/src/web_terminal/terminal.html @@ -334,6 +334,9 @@ autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" enterkeyhint="send" inputmode="text"> + +
@@ -360,6 +363,7 @@ const emptyState = document.getElementById('emptyState'); const mobileInput = document.getElementById('mobileInput'); const btnEnter = document.getElementById('btnEnter'); + const btnNewline = document.getElementById('btnNewline'); const btnTab = document.getElementById('btnTab'); const btnCtrlC = document.getElementById('btnCtrlC'); const scrollBottomBtn = document.getElementById('scrollBottomBtn'); @@ -647,6 +651,28 @@ }); }); + // Shift+Enter inserts a newline in Claude Code's prompt instead of + // submitting it. xterm.js does not consult `shiftKey` for Enter, so + // without this Shift+Enter is byte-identical to Enter. + // + // `\x1b\r` — ESC then CR — is what Claude Code parses as `return` with + // meta, and it is the same sequence its own `/terminal-setup` installs for + // VS Code, Cursor, Alacritty and Zed. Do not "simplify" it to `\n`: that + // also works in Claude Code, but a shell would *run* the line, so the two + // session types would diverge. Claude sessions only, for that reason — + // `bash -l` has no readline binding for `\e\r`. + term.attachCustomKeyEventHandler(e => { + if ( + e.type === 'keydown' && e.key === 'Enter' && e.shiftKey && + !e.ctrlKey && !e.altKey && !e.metaKey && !e.isComposing && + sessionType === 'claude' + ) { + sendTerminalInput('\x1b\r'); + return false; // xterm must not also send a bare CR, which submits + } + return true; + }); + // Track scroll position for scroll-to-bottom button term.onScroll(() => updateScrollButton()); @@ -799,7 +825,11 @@ sendTerminalInput(val); mobileInput.value = ''; } - sendTerminalInput('\r'); + // Shift+Enter is a newline, not a submit — same bytes, and the same + // reasoning, as the terminal's own key handler above. A hardware + // keyboard on a tablet is the only way to reach this; the phone case is + // the dedicated newline button beside Enter. + sendTerminalInput(e.shiftKey ? '\x1b\r' : '\r'); } else if (e.key === 'Tab') { e.preventDefault(); sendTerminalInput('\t'); @@ -807,6 +837,7 @@ }); btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); }; + btnNewline.onclick = () => { sendTerminalInput('\x1b\r'); mobileInput.focus(); }; btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); }; btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); }; diff --git a/app/src/components/layout/StatusBar.tsx b/app/src/components/layout/StatusBar.tsx index d52cca8..4b10d86 100644 --- a/app/src/components/layout/StatusBar.tsx +++ b/app/src/components/layout/StatusBar.tsx @@ -23,6 +23,11 @@ export default function StatusBar({ stt }: Props) { })) ); const running = projects.filter((p) => p.status === "running").length; + // Only in a Claude tab: the chord is bound there and nowhere else, and a hint + // for a key that does nothing is worse than no hint. + const inClaudeSession = sessions.some( + (s) => s.id === activeSessionId && s.sessionType === "claude", + ); return (
@@ -45,6 +50,14 @@ export default function StatusBar({ stt }: Props) { )} + {!terminalHasSelection && inClaudeSession && ( + <> + | + + Shift+Enter: newline + + + )} {/* Right-aligned controls: Jump to Current + STT mic */}
{activeSessionId && !terminalAtBottom && ( diff --git a/app/src/components/projects/home/config/AuthBridgeRow.test.tsx b/app/src/components/projects/home/config/AuthBridgeRow.test.tsx new file mode 100644 index 0000000..12e0199 --- /dev/null +++ b/app/src/components/projects/home/config/AuthBridgeRow.test.tsx @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import AuthBridgeRow, { bridgeIndicator } from "./AuthBridgeRow"; +import type { AuthBridgeStatus, Project } from "../../../../lib/types"; + +/** + * The bridge shipped with a working backend, a typed IPC wrapper, and no way to + * reach either: `setAuthBridgeEnabled` had zero call sites, and the + * `auth-bridge-changed` event had no listener — so a host port the bridge could + * not take was a silent failure that presented as a login that simply hung. + * These tests hold both halves down. + */ + +const getAuthBridgeStatus = vi.fn<() => Promise>(); +const setAuthBridgeEnabled = vi.fn<(id: string, on: boolean) => Promise>(); + +vi.mock("../../../../lib/tauri-commands", () => ({ + getAuthBridgeStatus: () => getAuthBridgeStatus(), + setAuthBridgeEnabled: (id: string, on: boolean) => setAuthBridgeEnabled(id, on), +})); + +/** Captured so a test can push an `auth-bridge-changed` payload by hand. */ +let emit: ((payload: unknown) => void) | null = null; + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async (_name: string, handler: (e: { payload: unknown }) => void) => { + emit = (payload) => handler({ payload }); + return () => { + emit = null; + }; + }), +})); + +const OFF: AuthBridgeStatus = { enabled: false, active_ports: [], conflicts: [] }; + +const project = { + id: "p1", + name: "api", + status: "running", + auth_bridge_enabled: false, +} as unknown as Project; + +beforeEach(() => { + vi.clearAllMocks(); + getAuthBridgeStatus.mockResolvedValue(OFF); + setAuthBridgeEnabled.mockResolvedValue({ ...OFF, enabled: true }); +}); + +describe("AuthBridgeRow", () => { + it("turns the bridge on through its own command, not the project save", async () => { + // The dedicated command exists so this can be flipped while the container + // runs — which is exactly when a user discovers they need it. Routing it + // through the Config tab's stopped-only save would make it unreachable at + // the only moment it matters. + render(); + await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled()); + + fireEvent.click(screen.getByRole("switch", { name: "Auth bridge" })); + + await waitFor(() => + expect(setAuthBridgeEnabled).toHaveBeenCalledWith("p1", true), + ); + }); + + it("stays usable while the container is running", async () => { + render(); + await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled()); + expect(screen.getByRole("switch", { name: "Auth bridge" })).not.toBeDisabled(); + }); + + it("reports a port conflict the poller emitted", async () => { + getAuthBridgeStatus.mockResolvedValue({ ...OFF, enabled: true }); + render(); + await waitFor(() => expect(emit).not.toBeNull()); + + emit!({ + project_id: "p1", + status: { + enabled: true, + active_ports: [], + conflicts: [ + { port: 54545, reason: "Host port 54545 is already in use (…); not bridged." }, + ], + }, + }); + + expect(await screen.findByText(/Port 54545/)).toBeInTheDocument(); + expect(screen.getByText("Port conflict")).toBeInTheDocument(); + }); + + it("ignores an event for a different project", async () => { + getAuthBridgeStatus.mockResolvedValue({ ...OFF, enabled: true }); + render(); + await waitFor(() => expect(emit).not.toBeNull()); + + emit!({ + project_id: "other", + status: { enabled: true, active_ports: [], conflicts: [{ port: 1, reason: "nope" }] }, + }); + + expect(screen.queryByText(/Port 1:/)).not.toBeInTheDocument(); + }); + + it("puts the switch back if the command rejects", async () => { + setAuthBridgeEnabled.mockRejectedValue("Project p1 not found"); + render(); + await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled()); + + fireEvent.click(screen.getByRole("switch", { name: "Auth bridge" })); + + expect(await screen.findByText(/not found/)).toBeInTheDocument(); + expect(screen.getByRole("switch", { name: "Auth bridge" })).not.toBeChecked(); + }); +}); + +describe("bridgeIndicator", () => { + // Every branch is a glyph plus a word — status is never colour alone. + it("says nothing is on when it is off", () => { + expect(bridgeIndicator(OFF, true)).toEqual({ tone: "off", label: "Off" }); + }); + + it("puts a conflict ahead of everything else", () => { + expect( + bridgeIndicator( + { + enabled: true, + active_ports: [ + { port: 1, family: "v4", bridged_at: "", ipv6_warning: null }, + ], + conflicts: [{ port: 2, reason: "taken" }], + }, + true, + ).tone, + ).toBe("error"); + }); + + it("flags a port that only took the IPv4 half", () => { + // Node resolves `localhost` to IPv6 first on Linux, so a v4-only listener + // is a callback that never arrives in front of a bridge reporting healthy. + expect( + bridgeIndicator( + { + enabled: true, + active_ports: [ + { port: 1, family: "v6", bridged_at: "", ipv6_warning: "no ::1" }, + ], + conflicts: [], + }, + true, + ).label, + ).toBe("IPv4 only"); + }); + + it("counts the ports it is holding", () => { + expect( + bridgeIndicator( + { + enabled: true, + active_ports: [ + { port: 1, family: "v4", bridged_at: "", ipv6_warning: null }, + { port: 2, family: "v4", bridged_at: "", ipv6_warning: null }, + ], + conflicts: [], + }, + true, + ).label, + ).toBe("Bridging 2 ports"); + }); + + it("says it is waiting when the container is not running", () => { + // Enabled and holding nothing is normal; enabled with no container is a + // different thing, and saying so stops it reading as a failure. + expect(bridgeIndicator({ ...OFF, enabled: true }, false).label).toBe( + "Waiting for the container", + ); + expect(bridgeIndicator({ ...OFF, enabled: true }, true).label).toBe("Watching"); + }); +}); diff --git a/app/src/components/projects/home/config/AuthBridgeRow.tsx b/app/src/components/projects/home/config/AuthBridgeRow.tsx new file mode 100644 index 0000000..ea8bc92 --- /dev/null +++ b/app/src/components/projects/home/config/AuthBridgeRow.tsx @@ -0,0 +1,198 @@ +import { useCallback, useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { + getAuthBridgeStatus, + setAuthBridgeEnabled, +} from "../../../../lib/tauri-commands"; +import type { + AuthBridgeChangedEvent, + AuthBridgeStatus, + Project, +} from "../../../../lib/types"; +import { SwitchRow } from "../../../ui/Field"; +import StatusIndicator, { type StatusTone } from "../../../ui/StatusIndicator"; +import Toggle from "../../../ui/Toggle"; + +/** Emitted by `auth_bridge/mod.rs` whenever the port or conflict set changes. */ +const AUTH_BRIDGE_EVENT = "auth-bridge-changed"; + +const LABEL = "Auth bridge"; + +/** + * What the indicator beside the switch says. + * + * Split out so the interesting part — that a conflict is a *visible* failure — + * can be tested without a container. Every branch pairs a glyph with a word; + * none of them are distinguished by colour alone. + */ +export function bridgeIndicator( + status: AuthBridgeStatus | null, + containerRunning: boolean, +): { tone: StatusTone; label: string } { + if (!status) return { tone: "unknown", label: "Checking" }; + if (!status.enabled) return { tone: "off", label: "Off" }; + // A conflict means a login is in progress and its port could not be taken — + // the one state where doing nothing is the wrong answer, and until now the + // one state nothing in the app reported at all. + if (status.conflicts.length > 0) { + return { tone: "error", label: "Port conflict" }; + } + if (status.active_ports.some((p) => p.ipv6_warning)) { + return { tone: "busy", label: "IPv4 only" }; + } + if (status.active_ports.length > 0) { + const n = status.active_ports.length; + return { tone: "running", label: `Bridging ${n} port${n === 1 ? "" : "s"}` }; + } + // Enabled but holding nothing. Normal: there is only something to bridge + // while a login is actually waiting for a callback. + if (!containerRunning) { + return { tone: "stopped", label: "Waiting for the container" }; + } + return { tone: "ok", label: "Watching" }; +} + +/** + * The switch for `auth_bridge_enabled`, and the only place it can be changed. + * + * Two things here are deliberate and easy to undo by accident: + * + * - **It does not go through the Config tab's `save`.** That path is gated on + * a stopped container, because almost everything else in the tab is baked + * into the container at creation. This is not: the bridge is entirely + * host-side, and `set_auth_bridge_enabled` exists precisely so it can be + * flipped *while a login is hanging*, which is when the user finds out they + * need it. Routing it through the generic save would make it unreachable at + * the only moment it matters. + * - **It subscribes to `auth-bridge-changed`.** The poller already emits the + * bridged-port and conflict sets on every change and, before this, nothing + * listened — so a host port the bridge could not take was a completely + * silent failure, indistinguishable from a login that simply hung. + */ +export default function AuthBridgeRow({ project }: { project: Project }) { + const projectId = project.id; + const containerRunning = project.status === "running"; + + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setStatus(null); + setError(null); + getAuthBridgeStatus(projectId) + .then((s) => { + if (!cancelled) setStatus(s); + }) + .catch((e) => { + if (!cancelled) setError(String(e)); + }); + return () => { + cancelled = true; + }; + }, [projectId]); + + useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | undefined; + listen(AUTH_BRIDGE_EVENT, (event) => { + if (event.payload.project_id !== projectId) return; + setStatus(event.payload.status); + }) + .then((un) => { + if (cancelled) un(); + else unlisten = un; + }) + .catch((e) => console.error("Auth bridge event subscription failed:", e)); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [projectId]); + + const toggle = useCallback( + async (next: boolean) => { + setBusy(true); + setError(null); + // Optimistic, so the switch responds even though enabling has to await a + // container probe. The command's return value replaces it either way. + setStatus((s) => (s ? { ...s, enabled: next } : s)); + try { + setStatus(await setAuthBridgeEnabled(projectId, next)); + } catch (e) { + setStatus((s) => (s ? { ...s, enabled: !next } : s)); + setError(String(e)); + } finally { + setBusy(false); + } + }, + [projectId], + ); + + // Fall back to the persisted flag until the first status arrives, so the + // switch never renders in the wrong position. + const enabled = status?.enabled ?? project.auth_bridge_enabled; + const indicator = bridgeIndicator(status, containerRunning); + + return ( + + Mirrors a port a program inside the container is listening on onto the + host's 127.0.0.1, so a browser OAuth callback can reach + the listener waiting inside the container —{" "} + claude login, aws sso login and{" "} + gh auth login all work this way, and without it the + browser calls back into nothing and the login hangs. Host-side only: + it never recreates the container, and it can be switched on while one + is running. A bridged port is unauthenticated and reachable by any + local process for as long as the in-container listener exists, so + leave it off unless you need it. + + + {status?.active_ports.map((p) => ( + + 127.0.0.1:{p.port} + {p.ipv6_warning ? " (IPv4 only)" : ""} + + ))} + + {status?.conflicts.map((c) => ( + + Port {c.port}: {c.reason} + + ))} + {status?.active_ports + .filter((p) => p.ipv6_warning) + .map((p) => ( + + Port {p.port}: {p.ipv6_warning} + + ))} + {error && ( + {error} + )} + + } + control={ + + } + /> + ); +} diff --git a/app/src/components/projects/home/config/RuntimeSection.test.tsx b/app/src/components/projects/home/config/RuntimeSection.test.tsx index 8d3ab45..e8b7201 100644 --- a/app/src/components/projects/home/config/RuntimeSection.test.tsx +++ b/app/src/components/projects/home/config/RuntimeSection.test.tsx @@ -1,7 +1,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import RuntimeSection from "./RuntimeSection"; -import type { Project } from "../../../../lib/types"; +import type { AuthBridgeStatus, Project } from "../../../../lib/types"; + +// The auth-bridge row owns its own IPC — see `AuthBridgeRow.tsx` for why it +// does not go through `save`. +const OFF_BRIDGE: AuthBridgeStatus = { enabled: false, active_ports: [], conflicts: [] }; +const setAuthBridgeEnabled = vi.fn(async () => ({ ...OFF_BRIDGE, enabled: true })); + +vi.mock("../../../../lib/tauri-commands", () => ({ + getAuthBridgeStatus: vi.fn(async () => OFF_BRIDGE), + setAuthBridgeEnabled: (id: string, on: boolean) => setAuthBridgeEnabled(id, on), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async () => () => {}), +})); const baseProject: Project = { id: "p1", @@ -92,3 +106,24 @@ describe("RuntimeSection — VPN support toggle", () => { ).toBeInTheDocument(); }); }); + +describe("RuntimeSection — auth bridge toggle", () => { + beforeEach(() => vi.clearAllMocks()); + + it("is reachable while the container is running", async () => { + // The rest of the tab is gated on a stopped container because those + // settings are baked in at creation. This one is host-side and has its own + // command, and the moment a user needs it is the moment a login is hanging + // in a *running* container — so the tab's `disabled` must not reach it. + renderSection({ status: "running" }, true); + + const toggle = screen.getByRole("switch", { name: "Auth bridge" }); + await waitFor(() => expect(toggle).not.toBeDisabled()); + + fireEvent.click(toggle); + await waitFor(() => expect(setAuthBridgeEnabled).toHaveBeenCalledWith("p1", true)); + // And never through the generic project save, which would drop it on the + // floor while the container runs. + expect(save).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/projects/home/config/RuntimeSection.tsx b/app/src/components/projects/home/config/RuntimeSection.tsx index f971c56..d9c80e1 100644 --- a/app/src/components/projects/home/config/RuntimeSection.tsx +++ b/app/src/components/projects/home/config/RuntimeSection.tsx @@ -4,6 +4,7 @@ import { ConfigGroup, SwitchRow } from "../../../ui/Field"; import PermissionModeControl, { permissionModePatch } from "../../PermissionModeControl"; import ClaudeInstructionsEditor from "../../ClaudeInstructionsEditor"; import ClaudeCodeSettingsEditor from "../../ClaudeCodeSettingsEditor"; +import AuthBridgeRow from "./AuthBridgeRow"; interface Props { project: Project; @@ -70,6 +71,12 @@ export default function RuntimeSection({ } /> + {/* Not gated on `disabled`: the bridge is host-side and has its own + command, so it can be switched on while a login is hanging — which + is the only moment anyone reaches for it. It owns its state rather + than going through `save`. */} + + {}); + +vi.mock("../../lib/tauri-commands", () => ({ + terminalInput: (sessionId: string, bytes: number[]) => + terminalInput(sessionId, bytes), + terminalResize: vi.fn(async () => {}), + pasteImageToTerminal: vi.fn(async () => ""), + openTerminalSession: vi.fn(async () => {}), + closeTerminalSession: vi.fn(async () => {}), + updateProject: vi.fn(async () => ({})), + awsSsoRefresh: vi.fn(async () => {}), + openPageInContainerBrowser: vi.fn(async () => ({ error: null })), + uploadHostFileToTerminal: vi.fn(async () => ""), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async () => () => {}), +})); + +vi.mock("@tauri-apps/plugin-opener", () => ({ + openUrl: vi.fn(async () => {}), +})); + +vi.mock("@tauri-apps/api/webview", () => ({ + getCurrentWebview: () => ({ onDragDropEvent: vi.fn(async () => () => {}) }), +})); + +/** jsdom has no ResizeObserver, and the mount effect installs one. */ +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +/** What `sendInput` put on the wire, decoded back to a string. */ +function sent(): string[] { + return terminalInput.mock.calls.map((call) => + new TextDecoder().decode(new Uint8Array((call as unknown as [string, number[]])[1])), + ); +} + +function mountSession(sessionType: "claude" | "bash") { + useAppState.setState({ + sessions: [ + { + id: "s1", + projectId: "p1", + projectName: "api", + sessionType, + sessionName: null, + }, + ], + }); + return render(); +} + +/** The hidden textarea xterm binds its keyboard handling to. */ +function helperTextarea(container: HTMLElement): HTMLTextAreaElement { + const el = container.querySelector( + "textarea.xterm-helper-textarea", + ); + if (!el) throw new Error("xterm helper textarea not found"); + return el; +} + +beforeEach(() => { + vi.stubGlobal("ResizeObserver", NoopResizeObserver); + // xterm's renderer asks the window for its device pixel ratio on open. + vi.stubGlobal( + "matchMedia", + (query: string) => ({ + matches: false, + media: query, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + onchange: null, + dispatchEvent: () => false, + }), + ); + terminalInput.mockClear(); + useAppState.setState({ sessions: [] }); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("TerminalView — Shift+Enter", () => { + it("sends ESC+CR and nothing else in a Claude session", () => { + const { container } = mountSession("claude"); + + fireEvent.keyDown(helperTextarea(container), { + key: "Enter", + keyCode: 13, + shiftKey: true, + }); + + // The bytes `/terminal-setup` installs for every other editor. + expect(sent()).toEqual(["\x1b\r"]); + // And specifically not the bare CR that would have submitted the prompt. + expect(sent()).not.toContain("\r"); + }); + + it("leaves a plain Enter alone", () => { + const { container } = mountSession("claude"); + + fireEvent.keyDown(helperTextarea(container), { key: "Enter", keyCode: 13 }); + + expect(sent()).toEqual(["\r"]); + }); + + it("does not bind it in a bash session", () => { + // `bash -l` runs readline, which has no binding for `\e\r`: it would answer + // with a bell and swallow the Enter the user actually pressed. + const { container } = mountSession("bash"); + + fireEvent.keyDown(helperTextarea(container), { + key: "Enter", + keyCode: 13, + shiftKey: true, + }); + + expect(sent()).toEqual(["\r"]); + }); + + it("leaves a modified Shift+Enter to xterm", () => { + // Adding Ctrl is not the chord this binds; whatever xterm does with it is + // xterm's business. + const { container } = mountSession("claude"); + + fireEvent.keyDown(helperTextarea(container), { + key: "Enter", + keyCode: 13, + shiftKey: true, + ctrlKey: true, + }); + + expect(sent()).not.toContain("\x1b\r"); + }); + + it("Alt+Enter already produced ESC+CR without any handler", () => { + // Pinned because it is the reason Shift+Enter was the only gap: xterm + // ESC-prefixes on `altKey` by itself, so Alt+Enter has always inserted a + // newline in Claude Code. It was simply undocumented. + const { container } = mountSession("bash"); // no custom branch involved + + fireEvent.keyDown(helperTextarea(container), { + key: "Enter", + keyCode: 13, + altKey: true, + }); + + expect(sent()).toEqual(["\x1b\r"]); + }); +}); + +describe("supersedes — who owns the prompt slot", () => { + const relay = (url: string) => ({ url, source: "relay" as const }); + const osc8 = (url: string) => ({ url, source: "osc8" as const }); + const guess = (url: string) => ({ url, source: "heuristic" as const }); + + const COMPLETE = + "https://claude.ai/oauth/authorize?code=true&client_id=abc123&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference"; + // What the screen-scraper reconstructs from the visible text: parses, points + // at the right host, authorises nothing. + const TRUNCATED = COMPLETE.slice(0, 80); + + it("fills an empty slot from anywhere", () => { + expect(supersedes(guess(TRUNCATED), null)).toBe(true); + }); + + it("refuses to let a truncated guess replace the exact copy", () => { + // The whole bug: the relay lands first with the complete URL, and 300 ms + // later the detector's debounce fires with a prefix of it. + expect(supersedes(guess(TRUNCATED), relay(COMPLETE))).toBe(false); + expect(supersedes(guess(TRUNCATED), osc8(COMPLETE))).toBe(false); + }); + + it("lets a better source take over from a worse one", () => { + expect(supersedes(osc8(COMPLETE), guess(TRUNCATED))).toBe(true); + expect(supersedes(relay(COMPLETE), guess(TRUNCATED))).toBe(true); + }); + + it("lets a scraped candidate grow into the complete link", () => { + // A repaint can land the truncated copy first. Extending it is safe: a + // longer string with the same prefix has the same origin. + expect(supersedes(guess(COMPLETE), guess(TRUNCATED))).toBe(true); + }); + + it("does not let an unrelated scrape displace what is on screen", () => { + // Longest-wins without the prefix test hands the choice to whoever pads + // their URL the most. + expect( + supersedes(guess("https://evil.tld/" + "a".repeat(400)), guess(COMPLETE)), + ).toBe(false); + }); + + it("lets a second explicit relay request through", () => { + // Each OSC 7777 is a fresh deliberate ask, not another view of the last + // one — a second `gh auth login` must be able to replace the first. + expect( + supersedes(relay("https://github.com/login/device"), relay(COMPLETE)), + ).toBe(true); + }); +}); diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index a151b76..0ffc40d 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -13,10 +13,11 @@ import { uploadHostFileToTerminal, } from "../../lib/tauri-commands"; import { getCurrentWebview } from "@tauri-apps/api/webview"; -import { UrlDetector } from "../../lib/urlDetector"; +import { UrlDetector, type UrlSource } from "../../lib/urlDetector"; import { RelayRateLimiter, URL_RELAY_OSC, + extendsUrl, parseUrlRelayOsc, sanitizeRelayUrl, } from "../../lib/urlRelay"; @@ -29,6 +30,58 @@ interface Props { active: boolean; } +/** + * Where a prompted URL came from. + * + * `relay` is the container asking explicitly, over OSC 7777, with the URL + * base64-encoded — exact by construction. `osc8` is lifted verbatim out of a + * hyperlink parameter — also exact, but nobody asked for it. `heuristic` was + * reassembled from painted text and is the only one that can be a *truncated + * guess* at the link it is showing. + */ +export type PromptSource = "relay" | UrlSource; + +/** Higher wins. Provenance, not recency. */ +const SOURCE_RANK: Record = { + heuristic: 0, + osc8: 1, + relay: 2, +}; + +/** + * Whether `next` may take over the prompt slot from `current`. + * + * The bug this exists for: `claude login` relays its OAuth URL over OSC 7777, + * base64-encoded and therefore complete; the screen-scraper's 300 ms debounce + * then fires, finds the same link cut into terminal-width pieces, and — under + * the old last-writer-wins slot — replaced the good URL with a truncated one + * that still parses, still points at the right host, and cannot authorise + * anything. The user is the one who has to notice. + * + * Two rules, in order: + * + * - Better provenance always wins, worse provenance never does. A scraped + * guess cannot displace an exact copy. + * - Between equals, only an *extension* of what is showing may replace it. + * That is {@link extendsUrl}, the same rule and the same reasoning as + * `pickSignInUrl` in `hooks/useClaudeAuth.ts`: a repaint can land a + * truncated copy before the complete one, and a longer string sharing a + * prefix cannot move the origin. The relay is exempt because each OSC 7777 + * is a fresh deliberate request rather than another view of the last one — + * a second `gh auth login` must be able to replace the first. + */ +export function supersedes( + next: { url: string; source: PromptSource }, + current: { url: string; source: PromptSource } | null, +): boolean { + if (!current) return true; + if (SOURCE_RANK[next.source] !== SOURCE_RANK[current.source]) { + return SOURCE_RANK[next.source] > SOURCE_RANK[current.source]; + } + if (next.source === "relay") return true; + return extendsUrl(next.url, current.url); +} + export default function TerminalView({ sessionId, active }: Props) { const containerRef = useRef(null); const terminalContainerRef = useRef(null); @@ -47,13 +100,24 @@ export default function TerminalView({ sessionId, active }: Props) { (s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId ); - // One toast slot, two producers: the heuristic long-URL detector and the - // container's explicit "open this in the host browser" relay (OSC 7777). - // Sharing the slot keeps them from stacking on top of each other. + // Which program is on the other end of the PTY. Read through a ref because + // the key handler is registered once, in the mount effect keyed on + // `sessionId`, and a value captured there would go stale if the session + // record arrived after the first render. + const sessionType = useAppState( + (s) => s.sessions.find((sess) => sess.id === sessionId)?.sessionType + ); + const sessionTypeRef = useRef(sessionType); + sessionTypeRef.current = sessionType; + + // One toast slot, three producers: the container's explicit "open this in the + // host browser" relay (OSC 7777), OSC 8 hyperlink targets, and the heuristic + // long-URL detector. Sharing the slot keeps them from stacking on top of each + // other. // - // Both producers read the container's PTY output, so both are untrusted, and - // both must go through `sanitizeRelayUrl` before anything is stored here — - // see `promptUrl` below, which is the only writer. + // All three read the container's PTY output, so all three are untrusted, and + // all three must go through `sanitizeRelayUrl` before anything is stored here + // — see `promptUrl` below, which is the only writer. // // `seq` exists because the slot is shared and long-lived: a second prompt // replacing a first would otherwise mutate the toast in place, swapping the @@ -62,6 +126,7 @@ export default function TerminalView({ sessionId, active }: Props) { const [urlPrompt, setUrlPrompt] = useState<{ url: string; label: string; + source: PromptSource; seq: number; } | null>(null); const promptSeqRef = useRef(0); @@ -72,16 +137,27 @@ export default function TerminalView({ sessionId, active }: Props) { * found: the OSC relay branch has already been through `parseUrlRelayOsc`, * but the heuristic detector branch has been through nothing at all, and a * raw regex match is exactly the input `sanitizeRelayUrl` exists to refuse. + * + * Last-writer-wins is what this used to be, and it lost the OAuth URL every + * time: the relay delivers the link base64-encoded and therefore exact, and + * ~300 ms later the screen-scraper's debounce fired and overwrote it with a + * truncated guess at the same link. `supersedes` is the fix — see there. */ - const promptUrl = useCallback((raw: string, label: string) => { - const url = sanitizeRelayUrl(raw); - if (!url) { - console.warn("Refusing to prompt for a URL that failed validation"); - return; - } - promptSeqRef.current += 1; - setUrlPrompt({ url, label, seq: promptSeqRef.current }); - }, []); + const promptUrl = useCallback( + (raw: string, label: string, source: PromptSource) => { + const url = sanitizeRelayUrl(raw); + if (!url) { + console.warn("Refusing to prompt for a URL that failed validation"); + return; + } + setUrlPrompt((current) => { + if (!supersedes({ url, source }, current)) return current; + promptSeqRef.current += 1; + return { url, label, source, seq: promptSeqRef.current }; + }); + }, + [], + ); const [imagePasteMsg, setImagePasteMsg] = useState(null); const [isAtBottom, setIsAtBottom] = useState(true); const [isAutoFollow, setIsAutoFollow] = useState(true); @@ -234,6 +310,34 @@ export default function TerminalView({ sessionId, active }: Props) { useAppState.getState().sttToggle(); return false; } + // Shift+Enter inserts a newline in Claude Code's prompt instead of + // submitting it. xterm.js does not consult `shiftKey` for Enter + // (`Keyboard.ts`, `case 13`), so without this branch Shift+Enter is + // byte-identical to Enter and submits. + // + // `\x1b\r` — ESC then CR — is what Claude Code parses as `return` with + // meta, and it is exactly what its own `/terminal-setup` writes into the + // VS Code, Cursor, Alacritty and Zed keymaps. These are the in-band + // bytes, not a guess, which is why this must NOT be "simplified" to + // `\n`: Claude Code accepts `\n` too, but a shell would *run* the line, + // so the two session types would quietly diverge. + // + // Scoped to Claude sessions for the same reason. A bash tab runs + // `bash -l`, where readline has no binding for `\e\r` and answers with a + // bell — harmless, but there is nothing to gain from sending it. + if ( + event.type === "keydown" && + event.key === "Enter" && + event.shiftKey && + !event.ctrlKey && + !event.altKey && + !event.metaKey && + !event.isComposing && + sessionTypeRef.current === "claude" + ) { + sendInput(sessionId, "\x1b\r"); + return false; // xterm must not also send a bare CR, which submits + } return true; }); @@ -287,7 +391,7 @@ export default function TerminalView({ sessionId, active }: Props) { console.warn("URL relay: rate-limited", url); return true; } - promptUrl(url, "Container asked to open a URL"); + promptUrl(url, "Container asked to open a URL", "relay"); return true; }); @@ -374,11 +478,17 @@ export default function TerminalView({ sessionId, active }: Props) { // Handle backend output -> terminal let aborted = false; - // The width is read per scan, not captured: only a break the terminal + // The detector samples this getter on every `feed`, so what it reassembles + // with is the width the bytes were *printed* at — only a break the terminal // itself inserted may be deleted, and where that is moves with every // resize. const detector = new UrlDetector( - (url) => promptUrl(url, "Long URL detected"), + (url, source) => + promptUrl( + url, + source === "osc8" ? "Link detected" : "Long URL detected", + source, + ), () => termRef.current?.cols ?? 0, ); detectorRef.current = detector; diff --git a/app/src/components/terminal/UrlToast.test.tsx b/app/src/components/terminal/UrlToast.test.tsx index 0660396..a991ef6 100644 --- a/app/src/components/terminal/UrlToast.test.tsx +++ b/app/src/components/terminal/UrlToast.test.tsx @@ -58,4 +58,79 @@ describe("UrlToast", () => { screen.getByRole("button", { name: "Open" }).click(); expect(onOpen).toHaveBeenCalledTimes(1); }); + + describe("Anthropic sign-in links", () => { + // The callback listener a `claude login` is waiting on is *inside* the + // container. Sending the user to their host browser completes the sign-in + // and then posts the result where nothing is listening, and the terminal + // hangs to its timeout — so for these, and only these, the container-side + // browser leads. + const SIGN_IN = + "https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code"; + + function actions() { + return screen + .getAllByRole("button") + .map((b) => b.textContent) + .filter((t) => t === "Open" || t === "In container"); + } + + it("puts the container browser first", () => { + render( + , + ); + expect(actions()).toEqual(["In container", "Open"]); + expect(screen.getByTestId("url-toast-signin-hint")).toHaveTextContent( + /callback listener is inside the container/i, + ); + }); + + it("keeps the host browser available as a fallback", () => { + const onOpen = vi.fn(); + render( + , + ); + screen.getByRole("button", { name: "Open" }).click(); + expect(onOpen).toHaveBeenCalledTimes(1); + }); + + it("leaves an ordinary URL alone", () => { + // A `gh auth login` device code, a docs page, a preview build — the host + // browser is the right answer for all of them and stays the default. + render( + , + ); + expect(actions()).toEqual(["Open", "In container"]); + expect(screen.queryByTestId("url-toast-signin-hint")).not.toBeInTheDocument(); + }); + + it("is not fooled by a lookalike host", () => { + // `isAnthropicSignInUrl` uses the same allowlist the sign-in flow does, + // so a URL that merely says "claude.ai" somewhere is not one. + render( + , + ); + expect(actions()).toEqual(["Open", "In container"]); + }); + }); }); diff --git a/app/src/components/terminal/UrlToast.tsx b/app/src/components/terminal/UrlToast.tsx index 6874d31..0c17737 100644 --- a/app/src/components/terminal/UrlToast.tsx +++ b/app/src/components/terminal/UrlToast.tsx @@ -1,4 +1,5 @@ -import { urlOrigin } from "../../lib/urlRelay"; +import type { CSSProperties, MouseEvent } from "react"; +import { isAnthropicSignInUrl, urlOrigin } from "../../lib/urlRelay"; interface Props { /** Already validated by `sanitizeRelayUrl` — this component never opens it. */ @@ -28,6 +29,18 @@ interface Props { * is shared and long-lived, so without one React mutates the node in place: the * text swaps with no animation, and a user reading URL A can click Open on URL * B that arrived a second later. + * + * ## Anthropic sign-in links default to the container's browser + * + * For an ordinary URL the host browser is the right answer and stays the + * default. For a sign-in it is the *wrong* one: the callback listener the CLI + * is waiting on is inside the container, so a host browser completes the sign-in + * and then posts the result somewhere nothing is listening, and the terminal + * hangs until it times out. Making the host button primary there was quietly + * steering every user into that. The container-side browser closes the loop + * with no host round trip and no auth bridge, so it leads — and the host button + * stays, because a user who has the auth bridge on, or who wants their existing + * browser session, still needs it. */ export default function UrlToast({ url, @@ -38,6 +51,81 @@ export default function UrlToast({ }: Props) { const origin = urlOrigin(url); const rest = origin && url.startsWith(origin) ? url.slice(origin.length) : url; + // Only when there is somewhere to send it: without `onOpenInContainer` the + // host button is the only action there is, so it stays primary. + const signIn = !!onOpenInContainer && isAnthropicSignInUrl(url); + + // Filled uses `--accent-emphasis`, never `--accent` — the latter is the + // foreground/link accent and fails WCAG AA behind white text. + const primaryStyle: CSSProperties = { + padding: "4px 12px", + fontSize: 12, + fontWeight: 600, + color: "#fff", + background: "var(--accent-emphasis)", + border: "1px solid transparent", + borderRadius: 4, + cursor: "pointer", + whiteSpace: "nowrap", + flexShrink: 0, + }; + const secondaryStyle: CSSProperties = { + padding: "4px 10px", + fontSize: 12, + fontWeight: 600, + color: "var(--text-primary)", + background: "transparent", + border: "1px solid var(--border-color)", + borderRadius: 4, + cursor: "pointer", + whiteSpace: "nowrap", + flexShrink: 0, + }; + + /** Hover feedback for whichever button is currently the filled one. */ + const hover = (primary: boolean) => + primary + ? { + onMouseEnter: (e: MouseEvent) => + (e.currentTarget.style.background = "var(--accent-emphasis-hover)"), + onMouseLeave: (e: MouseEvent) => + (e.currentTarget.style.background = "var(--accent-emphasis)"), + } + : { + onMouseEnter: (e: MouseEvent) => + (e.currentTarget.style.background = "var(--bg-tertiary)"), + onMouseLeave: (e: MouseEvent) => + (e.currentTarget.style.background = "transparent"), + }; + + const hostButton = ( + + ); + + const containerButton = onOpenInContainer && ( + // A sign-in completed in the *container's* browser lands its callback on + // the container's own loopback, which is where the tool waiting for it is + // listening — no host round trip, no auth bridge. + + ); return (
+ {signIn && ( +
+ Sign-in link — the callback listener is inside the container. + Opening it there closes the loop; the host browser needs the auth + bridge. +
+ )}
- - - {onOpenInContainer && ( - // A sign-in completed in the *container's* browser lands its callback - // on the container's own loopback, which is where the tool waiting for - // it is listening — no host round trip, no auth bridge. - + {signIn ? ( + <> + {containerButton} + {hostButton} + + ) : ( + <> + {hostButton} + {containerButton} + )}
{task && ( + /* + An edit is `add` then `remove` (see `update_scheduled_task`), and + `triple-c-scheduler`'s remove now reaps the task's log directory — + so on a current container the old logs are gone, not merely filed + under the old id, which is what this used to promise. + + It is deliberately not stated as a certainty. `/usr/local/bin` only + changes on base-image migration or Reset, so a project still running + an older base image carries the older scheduler, whose remove leaves + the log directory behind. "Assume they go with it" is true in both + worlds and spares the user a paragraph about which one they are in. + */

The scheduler has no edit command, so saving re-creates this task under a new id and - removes {task.id}. Its previous run logs stay under - the old id. + removes {task.id}. Assume its earlier run logs go + with it.

)} diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index b4eec82..3510ecd 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -134,12 +134,19 @@ export interface OpenAiCompatibleConfig { } export interface ClaudeCodeSettings { + /** `null` = let Claude Code choose the renderer; `"default"` = classic, `"fullscreen"` = alt-screen. */ tui_mode: string | null; + /** `null` = unset, else `"low" | "medium" | "high" | "xhigh"`. Written as `effortLevel`. */ effort: string | null; auto_scroll_disabled: boolean; + /** Written as `viewMode: "focus"`. */ focus_mode: boolean; show_thinking_summaries: boolean; - enable_session_recap: boolean; + /** + * Turns the session recap **off**. Held in the disabled sense because Claude + * Code's recap is on by default — see the Rust doc on `ClaudeCodeSettings`. + */ + session_recap_disabled: boolean; env_scrub: boolean; prompt_caching_1h: boolean; } diff --git a/container/Dockerfile b/container/Dockerfile index 8b4cc41..fc81ee9 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -1,5 +1,24 @@ FROM ubuntu:24.04 +# ── Provenance labels ──────────────────────────────────────────────────────── +# Without these the base image carries no labels at all, and +# `sweep_orphaned_snapshots` (app/src-tauri/src/docker/container.rs) filters on +# `dangling=true` **and** `triple-c.managed=true` — so a superseded base image, +# left untagged when a newer build claims `triple-c-sandbox:latest`, could never +# match and was never collected. ~11.9 GB of stranded base images was measured +# on one developer's daemon this way. +# +# `triple-c.managed=true` is what makes them sweepable. Note that Docker merges +# an image's labels into the containers created from it and `docker commit` +# copies a container's labels onto the image, so this value also arrives on +# every container and every snapshot — which is harmless, because +# `create_container` writes the same key explicitly anyway. +# +# `triple-c.base=true` marks *this* image specifically, so a base image can be +# told apart from a project snapshot without parsing repository names. +LABEL triple-c.managed=true +LABEL triple-c.base=true + # Multi-arch: builds for linux/amd64 and linux/arm64 (Apple Silicon) # Avoid interactive prompts during package install ENV DEBIAN_FRONTEND=noninteractive diff --git a/container/entrypoint.sh b/container/entrypoint.sh index 2e859b2..9bc0c8e 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -405,22 +405,37 @@ install_feature_skill pia-vpn "${VPN_SUPPORT_ENABLED:-0}" unset VPN_SUPPORT_ENABLED # ── Claude Code settings ──────────────────────────────────────────────────── -# Merge Claude Code settings into ~/.claude/settings.json (preserves existing -# keys). Creates the file if it doesn't exist. These control TUI mode, effort -# level, focus mode, thinking summaries, and other CLI behavior. +# Apply the managed Claude Code settings to ~/.claude/settings.json, keeping +# every key the user set inside the container. +# +# `settings.json` lives on the persisted triple-c-claude-config-{id} volume, so +# it outlives the container and a plain `.[0] * .[1]` merge could only ever +# *add*. That is what made every one of these settings one-way: switching one +# off in Triple-C omitted its key, the merge preserved the old on-value, and the +# setting stayed on until a destructive Reset. So the payload from Rust states +# the whole managed key set on every start, and a JSON **null** in it means +# "delete this key" rather than "merge a null" — which is how a setting whose +# neutral state is *unset* (`tui`, `effortLevel`, `viewMode`, +# `awaySummaryEnabled`) is turned back off without pinning a stand-in value. +# See `build_claude_code_settings_json` in app/src-tauri/src/docker/container.rs. if [ -n "$CLAUDE_CODE_SETTINGS_JSON" ]; then SETTINGS_FILE="/home/claude/.claude/settings.json" mkdir -p /home/claude/.claude - if [ -f "$SETTINGS_FILE" ]; then - # Merge: existing settings + new settings (new keys override on conflict) - MERGED=$(jq -s '.[0] * .[1]' "$SETTINGS_FILE" <(printf '%s' "$CLAUDE_CODE_SETTINGS_JSON") 2>/dev/null) - if [ -n "$MERGED" ]; then - printf '%s\n' "$MERGED" > "$SETTINGS_FILE" - else - echo "entrypoint: warning — failed to merge Claude Code settings into $SETTINGS_FILE" - fi + # One code path for "file exists" and "file doesn't": seeding an empty + # object means the null-deleting merge below runs in both cases, so a fresh + # container never gets a settings.json with literal nulls written into it. + [ -f "$SETTINGS_FILE" ] || printf '{}\n' > "$SETTINGS_FILE" + MERGED=$(jq -s ' + .[0] as $current + | .[1] as $managed + | ($managed | with_entries(select(.value != null))) as $set + | ($managed | to_entries | map(select(.value == null) | [.key])) as $clear + | ($current * $set) | delpaths($clear) + ' "$SETTINGS_FILE" <(printf '%s' "$CLAUDE_CODE_SETTINGS_JSON") 2>/dev/null) + if [ -n "$MERGED" ]; then + printf '%s\n' "$MERGED" > "$SETTINGS_FILE" else - printf '%s\n' "$CLAUDE_CODE_SETTINGS_JSON" > "$SETTINGS_FILE" + echo "entrypoint: warning — failed to merge Claude Code settings into $SETTINGS_FILE" fi chown claude:claude "$SETTINGS_FILE" chmod 600 "$SETTINGS_FILE" diff --git a/container/triple-c-scheduler b/container/triple-c-scheduler index e75fcae..46d1e38 100644 --- a/container/triple-c-scheduler +++ b/container/triple-c-scheduler @@ -20,6 +20,27 @@ generate_id() { head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n' } +# Delete a task's log directory, called wherever a task stops existing. +# +# The task file is the only index of a task, so a log directory that outlives +# it is unreachable — `logs --id` needs an id nothing can hand you any more — +# and it sits on the home volume for the life of the project. The moment of +# removal is the last point at which we still know what to delete. +# +# The `rm -rf` deserves paranoia, so the id is re-validated here rather than +# trusted from the caller: the pattern rejects an empty id (which would expand +# to $LOGS_DIR itself), anything containing `/` or `.` (which could climb out +# of $LOGS_DIR), and a leading `-`. It matches validate_task_id() in +# app/src-tauri/src/commands/inspect_commands.rs. Always one literal path, +# never a glob. +reap_task_logs() { + local id="${1:-}" + [[ "$id" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || return 0 + local dir="${LOGS_DIR:?}/${id}" + [ -d "$dir" ] || return 0 + rm -rf -- "$dir" +} + # Live run state for a task: prints "pidstarted_epochlog" and returns # 0 when the task is genuinely running, returns 1 otherwise. # @@ -292,6 +313,7 @@ cmd_remove() { local name name=$(jq -r '.name' "$task_file") rm -f "$task_file" + reap_task_logs "$id" rebuild_crontab echo "Removed task '$name' ($id)" } diff --git a/container/triple-c-task-runner b/container/triple-c-task-runner index 79ffa60..7ce583b 100644 --- a/container/triple-c-task-runner +++ b/container/triple-c-task-runner @@ -125,6 +125,37 @@ fi echo "=== Exit code: $EXIT_CODE ===" } >> "$LOG_FILE" +# ── Cap the size of this run's log ────────────────────────────────────────── +# `claude -p` output is unbounded — a task told to walk a large tree can emit +# hundreds of megabytes in one run — and the pruning below counts *files*, not +# bytes, so twenty logs of any size are twenty logs. One chatty task can +# therefore fill the home volume, which is also where ~/.claude and the OAuth +# credential live. +# +# The tail is the half worth keeping: `claude -p` writes its answer at the end, +# and the footer just appended carries the exit code that `status` and the app +# both grep for. So an oversize log is rewritten as a marker line plus its last +# MAX_LOG_BYTES rather than being deleted or capped from the front. This runs +# before the notification below so the summary is taken from the capped file. +# +# Best effort throughout: the run's real result is already recorded, so a +# failure here must not change the exit status. Note that `run` may be tailing +# this file — it has already streamed everything up to here, and nothing is +# appended after this point, so replacing the inode is invisible to it. +MAX_LOG_BYTES=$(( 5 * 1024 * 1024 )) +LOG_BYTES=$(wc -c < "$LOG_FILE" 2>/dev/null || echo 0) +if [ "${LOG_BYTES:-0}" -gt "$MAX_LOG_BYTES" ]; then + TRUNC_FILE="${LOG_FILE}.trunc" + if { + echo "=== Log truncated: $(( LOG_BYTES - MAX_LOG_BYTES )) bytes dropped from the start (cap ${MAX_LOG_BYTES} bytes) ===" + tail -c "$MAX_LOG_BYTES" "$LOG_FILE" + } > "$TRUNC_FILE" 2>/dev/null; then + mv -f "$TRUNC_FILE" "$LOG_FILE" 2>/dev/null || rm -f "$TRUNC_FILE" + else + rm -f "$TRUNC_FILE" + fi +fi + # ── Write notification ────────────────────────────────────────────────────── mkdir -p "$NOTIFICATIONS_DIR" NOTIFY_FILE="${NOTIFICATIONS_DIR}/${TASK_ID}_${TIMESTAMP}.notify" @@ -176,6 +207,35 @@ if [ "$LOG_COUNT" -gt 20 ]; then find "$TASK_LOG_DIR" -name "*.log" -type f | sort | head -n $((LOG_COUNT - 20)) | xargs rm -f fi +# ── Reap log dirs of tasks that no longer exist ───────────────────────────── +# `triple-c-scheduler remove` deletes a task's log dir with the task, but a +# one-time task deletes its own task file above, so `remove` can never be run +# for it — nothing knows the id any more — and its directory would sit on the +# home volume forever. This is the sweep for that case. +# +# Deliberately delayed rather than done in the cleanup above: the run that just +# finished has only just written the sole record of itself, `run` and the app's +# Automation tab may still be tailing it, and `logs --id` keeps working for a +# task whose file is gone. So a dir is reaped only once nothing in it has been +# touched for LOG_RETENTION_DAYS, and never while a run is publishing state for +# that id. The sweep rides on task runs, so a container whose only task was +# one-time keeps that one directory until something else runs. +# +# Same paranoia as reap_task_logs() in triple-c-scheduler: the id comes from a +# directory name and is re-validated before it is used to build an `rm -rf` +# path, so no empty or path-bearing name can reach beyond $LOGS_DIR. +LOG_RETENTION_DAYS=7 +for ORPHAN_DIR in "$LOGS_DIR"/*/; do + [ -d "$ORPHAN_DIR" ] || continue + ORPHAN_ID=$(basename "$ORPHAN_DIR") + [[ "$ORPHAN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || continue + [ -f "${TASKS_DIR}/${ORPHAN_ID}.json" ] && continue + [ -f "${RUNNING_DIR}/${ORPHAN_ID}.json" ] && continue + # Anything modified inside the window keeps the whole directory. + [ -n "$(find "$ORPHAN_DIR" -mmin "-$(( LOG_RETENTION_DAYS * 1440 ))" -print -quit 2>/dev/null)" ] && continue + rm -rf -- "${LOGS_DIR:?}/${ORPHAN_ID}" +done + # ── Prune old notifications (keep 50 total) ───────────────────────────────── NOTIFY_COUNT=$(find "$NOTIFICATIONS_DIR" -name "*.notify" -type f 2>/dev/null | wc -l) if [ "$NOTIFY_COUNT" -gt 50 ]; then -- 2.52.0 From 2ca86bb5d808be122bcef129fcb69e2cc6d1da1a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 23 Aug 2026 08:37:56 -0700 Subject: [PATCH 04/43] Scrub the writable layer on the migration path too, and de-duplicate CLAUDE_JSON Two integration fixes after merging the three feature branches. `scrub_writable_layer` is a `docker exec`, so it only works while the container runs. `migrate_project_to_base` stops its container one line before the pre-swap commit, which meant the single largest snapshot Triple-C ever takes was the one path that committed unscrubbed. Call the scrub explicitly before the stop instead of relying on the call inside `commit_container_snapshot`. Also drop a duplicate `CLAUDE_JSON=` assignment in entrypoint.sh. The Shift+Enter block re-declared it defensively to avoid a merge conflict with the awsAuthRefresh block; the conflict did not materialise. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc --- .gitignore | 7 +++++++ app/src-tauri/src/commands/migration_commands.rs | 7 +++++++ app/src-tauri/src/docker/container.rs | 7 ++++--- container/entrypoint.sh | 3 ++- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 3043098..470d651 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,10 @@ app/dist/ app/src-tauri/target/ Screenshot*.png code-review.md + +# Windows NTFS alternate-data-stream artifacts, created when files arrive +# through the WSL/host bind mount. +*:Zone.Identifier + +# Local bug-report screenshots, same spirit as Screenshot*.png above. +screenshot_for_fix/ diff --git a/app/src-tauri/src/commands/migration_commands.rs b/app/src-tauri/src/commands/migration_commands.rs index 907b0ed..1327f15 100644 --- a/app/src-tauri/src/commands/migration_commands.rs +++ b/app/src-tauri/src/commands/migration_commands.rs @@ -460,6 +460,13 @@ async fn fresh_migration( } } + // Scrub *before* the stop, not inside the commit below. `scrub_writable_layer` + // is a `docker exec`, which only works on a running container — and the + // pre-swap commit is the single largest snapshot Triple-C ever takes, so + // letting this one path commit unscrubbed is what the scrub exists to + // prevent. Failure is swallowed inside; it must never block a migration. + docker::scrub_writable_layer(&container_id).await; + emit_progress(&app_handle, &project_id, "Stopping the container..."); let _ = state .projects_store diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 0799b44..9bdc011 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -2047,9 +2047,10 @@ fn parse_scrub_total(output: &str) -> Option { /// **Never fails the caller, by design.** A scrub is an optimisation; a commit /// is the only copy of the user's system layer. Losing some disk is a strictly /// better outcome than refusing to snapshot, so every failure here is a log -/// line and nothing more. Note that one caller (the pre-swap commit in -/// `migrate_project_to_base`) has already *stopped* the container, so `docker -/// exec` legitimately fails there — that path simply commits unscrubbed. +/// line and nothing more. Note that this is a `docker exec` and therefore only +/// works while the container runs: `migrate_project_to_base` stops its +/// container before the pre-swap commit, so it calls this itself beforehand +/// rather than relying on the call inside [`commit_container_snapshot`]. pub async fn scrub_writable_layer(container_id: &str) -> u64 { let script = snapshot_scrub_script(); let cmd = vec!["/bin/sh".to_string(), "-c".to_string(), script]; diff --git a/container/entrypoint.sh b/container/entrypoint.sh index 8c71fc9..6dd22c7 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -480,7 +480,8 @@ fi # already in place, so it keeps printing its "run /terminal-setup" tip. This # flag is what that tip is gated on: purely cosmetic, and it changes nothing # about how the sequence is decoded. -CLAUDE_JSON="/home/claude/.claude.json" +# +# $CLAUDE_JSON is already set by the awsAuthRefresh block above. if [ -f "$CLAUDE_JSON" ]; then # Only rewrite when the value isn't already true, to avoid a needless jq # reformat of ~/.claude.json on every single start. -- 2.52.0 From bb41275cea99945ad931328b3b021f21a29b83e1 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 23 Aug 2026 08:40:27 -0700 Subject: [PATCH 05/43] Keep the managed settings payload safe for un-migrated projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The null-means-delete convention is only understood by the entrypoint.sh shipped alongside this code. An existing project recreates from its own snapshot image, which carries whatever entrypoint it was built with, and an older one merges with a plain `.[0] * .[1]` — so the literal nulls would land in the user's settings.json rather than clearing the keys. Verified against jq: that produces `"tui": null, "effortLevel": null, "viewMode": null, "awaySummaryEnabled": null`, risking the whole file being rejected and taking the user's own `model` and `statusLine` with it. Split the payload instead. CLAUDE_CODE_SETTINGS_JSON now carries only keys that have a value and is safe under either merge; the new CLAUDE_CODE_SETTINGS_CLEAR carries the key names to delete and is ignored by an entrypoint that predates it. Such a project keeps the old sticky behaviour until it is migrated or Reset, which is the pre-existing state rather than a regression. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc --- app/src-tauri/src/docker/container.rs | 122 +++++++++++++++++++++++++- container/entrypoint.sh | 31 ++++--- 2 files changed, 136 insertions(+), 17 deletions(-) diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 9bdc011..3ccdfba 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -238,6 +238,7 @@ const RESERVED_ENV_EXACT: &[&str] = &[ "CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", + "CLAUDE_CODE_SETTINGS_CLEAR", "MISSION_CONTROL_ENABLED", "VPN_SUPPORT_ENABLED", "TRIPLE_C_PERMISSION_MODE", @@ -867,6 +868,48 @@ fn build_claude_code_settings_json( serde_json::Value::Object(map).to_string() } +/// Split the managed payload into the half that is safe to merge anywhere and +/// the list of keys to delete. +/// +/// The null-means-delete convention is only understood by the `entrypoint.sh` +/// shipped alongside this code — and an existing project recreates from *its +/// own snapshot image*, which carries whatever entrypoint it was built with. An +/// older one merges with a plain `.[0] * .[1]`, which would write the literal +/// `null`s straight into the user's `settings.json` rather than clearing the +/// keys. A settings file Claude Code then rejects would take the user's own +/// `model`, `statusLine` and everything else in it down with it. +/// +/// So the nulls never leave Rust. `CLAUDE_CODE_SETTINGS_JSON` carries only real +/// values and stays safe under either merge; `CLAUDE_CODE_SETTINGS_CLEAR` +/// carries the key names to delete and is simply ignored by an entrypoint that +/// predates it. Such a project keeps the old sticky behaviour until it is +/// migrated or Reset — which is the pre-existing state, not a regression. +pub(crate) fn split_claude_code_settings_payload(payload: &str) -> (String, String) { + let parsed: serde_json::Value = match serde_json::from_str(payload) { + Ok(v) => v, + // Not our business to fix; hand it through and let the merge fail loudly. + Err(_) => return (payload.to_string(), "[]".to_string()), + }; + let Some(obj) = parsed.as_object() else { + return (payload.to_string(), "[]".to_string()); + }; + + let mut set = serde_json::Map::new(); + let mut clear: Vec = Vec::new(); + for (k, v) in obj { + if v.is_null() { + clear.push(serde_json::json!(k)); + } else { + set.insert(k.clone(), v.clone()); + } + } + + ( + serde_json::Value::Object(set).to_string(), + serde_json::Value::Array(clear).to_string(), + ) +} + pub async fn find_existing_container(project: &Project) -> Result, String> { let docker = get_docker()?; let container_name = project.container_name(); @@ -1471,10 +1514,14 @@ pub async fn create_container( // even with no `ClaudeCodeSettings` struct present: the payload asserts the // *whole* managed key set, so "no settings" still has to be stated over a // settings.json left behind on the config volume by a previous config. - env_vars.push(format!( - "CLAUDE_CODE_SETTINGS_JSON={}", - build_claude_code_settings_json(merged_cc_settings.as_ref(), project.sandbox_mode_enabled) - )); + // Split so the payload is safe under an older entrypoint that has never + // heard of the null-means-delete convention — see + // `split_claude_code_settings_payload`. + let (cc_settings_set, cc_settings_clear) = split_claude_code_settings_payload( + &build_claude_code_settings_json(merged_cc_settings.as_ref(), project.sandbox_mode_enabled), + ); + env_vars.push(format!("CLAUDE_CODE_SETTINGS_JSON={}", cc_settings_set)); + env_vars.push(format!("CLAUDE_CODE_SETTINGS_CLEAR={}", cc_settings_clear)); let mut mounts: Vec = Vec::new(); @@ -3628,6 +3675,73 @@ mod tests { "sandbox", ]; + #[test] + fn split_payload_never_emits_a_null_to_an_older_entrypoint() { + // An existing project recreates from its own snapshot, which carries + // whatever entrypoint it was built with. An older one merges with a + // plain `.[0] * .[1]`, so a null reaching it would be written into the + // user's settings.json verbatim instead of clearing the key. + let payload = build_claude_code_settings_json(None, false); + assert!( + payload.contains("null"), + "precondition: the unsplit payload uses null to mean delete" + ); + + let (set, clear) = split_claude_code_settings_payload(&payload); + + let set_val: serde_json::Value = serde_json::from_str(&set).unwrap(); + for (k, v) in set_val.as_object().unwrap() { + assert!(!v.is_null(), "key {} reached the merge half as a null", k); + } + + let clear_val: serde_json::Value = serde_json::from_str(&clear).unwrap(); + let cleared: Vec<&str> = clear_val + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + // The four whose neutral state is genuinely "unset". + for k in ["tui", "effortLevel", "viewMode", "awaySummaryEnabled"] { + assert!(cleared.contains(&k), "{} should be cleared, not pinned", k); + } + } + + #[test] + fn split_payload_keeps_every_key_exactly_once() { + // Nothing may be dropped or duplicated between the two halves, or a + // managed key would silently stop being asserted. + let settings = ClaudeCodeSettings { + tui_mode: Some("fullscreen".to_string()), + focus_mode: true, + ..Default::default() + }; + let payload = build_claude_code_settings_json(Some(&settings), true); + let original: serde_json::Value = serde_json::from_str(&payload).unwrap(); + + let (set, clear) = split_claude_code_settings_payload(&payload); + let set_val: serde_json::Value = serde_json::from_str(&set).unwrap(); + let clear_val: serde_json::Value = serde_json::from_str(&clear).unwrap(); + + let mut seen: Vec = set_val + .as_object() + .unwrap() + .keys() + .cloned() + .chain( + clear_val + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()), + ) + .collect(); + seen.sort(); + let mut expected: Vec = original.as_object().unwrap().keys().cloned().collect(); + expected.sort(); + assert_eq!(seen, expected); + } + #[test] fn turning_a_setting_off_clears_it_rather_than_omitting_it() { // This is the whole bug. `~/.claude/settings.json` lives on a persisted diff --git a/container/entrypoint.sh b/container/entrypoint.sh index 6dd22c7..f1c6249 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -412,25 +412,29 @@ unset VPN_SUPPORT_ENABLED # it outlives the container and a plain `.[0] * .[1]` merge could only ever # *add*. That is what made every one of these settings one-way: switching one # off in Triple-C omitted its key, the merge preserved the old on-value, and the -# setting stayed on until a destructive Reset. So the payload from Rust states -# the whole managed key set on every start, and a JSON **null** in it means -# "delete this key" rather than "merge a null" — which is how a setting whose -# neutral state is *unset* (`tui`, `effortLevel`, `viewMode`, -# `awaySummaryEnabled`) is turned back off without pinning a stand-in value. +# setting stayed on until a destructive Reset. So Rust states the whole managed +# key set on every start, in two parts: CLAUDE_CODE_SETTINGS_JSON holds the keys +# that have a value, and CLAUDE_CODE_SETTINGS_CLEAR is a JSON array of the key +# names whose neutral state is *unset* (`tui`, `effortLevel`, `viewMode`, +# `awaySummaryEnabled`) and which must therefore be deleted rather than pinned +# to a stand-in value. +# +# The two are kept apart rather than using a null-means-delete payload because +# a project recreates from its own snapshot image, which carries whatever +# entrypoint it was built with. An older one merges with a plain `.[0] * .[1]` +# and would write literal nulls into the user's settings.json; it ignores +# CLAUDE_CODE_SETTINGS_CLEAR instead, keeping the old sticky behaviour until the +# project is migrated or Reset. # See `build_claude_code_settings_json` in app/src-tauri/src/docker/container.rs. if [ -n "$CLAUDE_CODE_SETTINGS_JSON" ]; then SETTINGS_FILE="/home/claude/.claude/settings.json" mkdir -p /home/claude/.claude - # One code path for "file exists" and "file doesn't": seeding an empty - # object means the null-deleting merge below runs in both cases, so a fresh - # container never gets a settings.json with literal nulls written into it. + # One code path for "file exists" and "file doesn't". [ -f "$SETTINGS_FILE" ] || printf '{}\n' > "$SETTINGS_FILE" - MERGED=$(jq -s ' + MERGED=$(jq -s --argjson clear "${CLAUDE_CODE_SETTINGS_CLEAR:-[]}" ' .[0] as $current - | .[1] as $managed - | ($managed | with_entries(select(.value != null))) as $set - | ($managed | to_entries | map(select(.value == null) | [.key])) as $clear - | ($current * $set) | delpaths($clear) + | .[1] as $set + | ($current * $set) | delpaths($clear | map([.])) ' "$SETTINGS_FILE" <(printf '%s' "$CLAUDE_CODE_SETTINGS_JSON") 2>/dev/null) if [ -n "$MERGED" ]; then printf '%s\n' "$MERGED" > "$SETTINGS_FILE" @@ -440,6 +444,7 @@ if [ -n "$CLAUDE_CODE_SETTINGS_JSON" ]; then chown claude:claude "$SETTINGS_FILE" chmod 600 "$SETTINGS_FILE" unset CLAUDE_CODE_SETTINGS_JSON + unset CLAUDE_CODE_SETTINGS_CLEAR fi # ── AWS SSO auth refresh command ────────────────────────────────────────────── -- 2.52.0 From 0a022dfcf0ee283708df14167b6cf88d72a367b3 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 23 Aug 2026 09:05:39 -0700 Subject: [PATCH 06/43] Let a project turn a globally-enabled Claude Code setting back off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six boolean settings were plain `bool`s merged with `if p.x { true } else { g.x }`, so a project could only ever add to the global set. There was no project value that produced `false` — turning a switch off at project level simply fell through to the global value and the control did nothing. Widen them to `Option`. `None` means "not set at this level": inherit the global on a project, leave Claude Code's own default alone globally. `Some(false)` is a deliberate off and wins outright. The fingerprint now formats with `{:?}` rather than `{}` — `None` and `Some(false)` mean different things, and conflating them would leave the container un-recreated when a project switched from inherit to off. The project editor grows a third "Global" state per switch; the global editor has nothing to inherit from, so it stays a plain toggle and keeps collapsing to null at the default. Its three existing tests passed unchanged and caught a first attempt that rendered unset as off, which would have told every user their session recap was disabled. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc --- app/src-tauri/src/docker/container.rs | 119 +++++++++++++----- app/src-tauri/src/models/project.rs | 20 ++- .../ClaudeCodeSettingsEditor.test.tsx | 65 +++++++++- .../projects/ClaudeCodeSettingsEditor.tsx | 116 +++++++++++++---- .../projects/home/config/RuntimeSection.tsx | 3 +- app/src/lib/types.ts | 18 ++- 6 files changed, 268 insertions(+), 73 deletions(-) diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 3ccdfba..71dbe84 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -631,8 +631,14 @@ fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String { sha256_hex(&joined) } -/// Merge global and per-project ClaudeCodeSettings. -/// Per-project fields override global fields when set (non-default). +/// Merge global and per-project `ClaudeCodeSettings`. +/// +/// A project field that is `Some` wins outright — **including `Some(false)`**. +/// That is the point of the widening: these used to be plain `bool`s ORed +/// together (`if p.x { true } else { g.x }`), so a project could only ever add +/// to the global set and never turn a globally-enabled setting off. `None` at +/// project level means "inherit", which is now a state the project can +/// actually be in rather than the only state an off switch could produce. fn merge_claude_code_settings( global: Option<&ClaudeCodeSettings>, project: Option<&ClaudeCodeSettings>, @@ -642,16 +648,15 @@ fn merge_claude_code_settings( (Some(g), None) => Some(g.clone()), (None, Some(p)) => Some(p.clone()), (Some(g), Some(p)) => { - // Project overrides global for each field when the project value is non-default Some(ClaudeCodeSettings { tui_mode: p.tui_mode.clone().or_else(|| g.tui_mode.clone()), effort: p.effort.clone().or_else(|| g.effort.clone()), - auto_scroll_disabled: if p.auto_scroll_disabled { true } else { g.auto_scroll_disabled }, - focus_mode: if p.focus_mode { true } else { g.focus_mode }, - show_thinking_summaries: if p.show_thinking_summaries { true } else { g.show_thinking_summaries }, - session_recap_disabled: if p.session_recap_disabled { true } else { g.session_recap_disabled }, - env_scrub: if p.env_scrub { true } else { g.env_scrub }, - prompt_caching_1h: if p.prompt_caching_1h { true } else { g.prompt_caching_1h }, + auto_scroll_disabled: p.auto_scroll_disabled.or(g.auto_scroll_disabled), + focus_mode: p.focus_mode.or(g.focus_mode), + show_thinking_summaries: p.show_thinking_summaries.or(g.show_thinking_summaries), + session_recap_disabled: p.session_recap_disabled.or(g.session_recap_disabled), + env_scrub: p.env_scrub.or(g.env_scrub), + prompt_caching_1h: p.prompt_caching_1h.or(g.prompt_caching_1h), }) } } @@ -673,12 +678,16 @@ fn compute_claude_code_settings_fingerprint( let parts = vec![ s.tui_mode.as_deref().unwrap_or("").to_string(), s.effort.as_deref().unwrap_or("").to_string(), - format!("{}", s.auto_scroll_disabled), - format!("{}", s.focus_mode), - format!("{}", s.show_thinking_summaries), - format!("{}", s.session_recap_disabled), - format!("{}", s.env_scrub), - format!("{}", s.prompt_caching_1h), + // `{:?}` rather than `{}` so `None` and `Some(false)` produce + // different text. They mean different things — inherit versus a + // deliberate off — and a fingerprint that conflated them would + // leave the container un-recreated on a real change. + format!("{:?}", s.auto_scroll_disabled), + format!("{:?}", s.focus_mode), + format!("{:?}", s.show_thinking_summaries), + format!("{:?}", s.session_recap_disabled), + format!("{:?}", s.env_scrub), + format!("{:?}", s.prompt_caching_1h), ]; sha256_hex(&parts.join("|")) } @@ -738,15 +747,15 @@ fn claude_code_env_vars(settings: Option<&ClaudeCodeSettings>) -> Vec { ), format!( "CLAUDE_CODE_ENABLE_AWAY_SUMMARY={}", - if s.session_recap_disabled { "0" } else { "" } + if s.session_recap_disabled.unwrap_or(false) { "0" } else { "" } ), format!( "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB={}", - if s.env_scrub { "1" } else { "0" } + if s.env_scrub.unwrap_or(false) { "1" } else { "0" } ), format!( "ENABLE_PROMPT_CACHING_1H={}", - if s.prompt_caching_1h { "1" } else { "0" } + if s.prompt_caching_1h.unwrap_or(false) { "1" } else { "0" } ), ] } @@ -817,12 +826,12 @@ fn build_claude_code_settings_json( // Documented default `true`, so the neutral value is a value. map.insert( "autoScrollEnabled".to_string(), - serde_json::json!(!s.auto_scroll_disabled), + serde_json::json!(!s.auto_scroll_disabled.unwrap_or(false)), ); // Documented default `false`. map.insert( "showThinkingSummaries".to_string(), - serde_json::json!(s.show_thinking_summaries), + serde_json::json!(s.show_thinking_summaries.unwrap_or(false)), ); // `viewMode: "focus"` is the real setting behind what the UI calls focus // mode — "collapses tool output to one-line summaries" is that key's @@ -830,7 +839,7 @@ fn build_claude_code_settings_json( // did nothing. map.insert( "viewMode".to_string(), - if s.focus_mode { + if s.focus_mode.unwrap_or(false) { serde_json::json!("focus") } else { serde_json::Value::Null @@ -842,7 +851,7 @@ fn build_claude_code_settings_json( // is here so the container's settings.json does not contradict it. map.insert( "awaySummaryEnabled".to_string(), - if s.session_recap_disabled { + if s.session_recap_disabled.unwrap_or(false) { serde_json::json!(false) } else { serde_json::Value::Null @@ -3675,6 +3684,52 @@ mod tests { "sandbox", ]; + #[test] + fn a_project_can_turn_a_globally_enabled_setting_back_off() { + // The whole reason the booleans are `Option`. Under the old + // `if p.x { true } else { g.x }` merge there was no project value that + // could produce `false` here. + let global = ClaudeCodeSettings { + focus_mode: Some(true), + env_scrub: Some(true), + prompt_caching_1h: Some(true), + ..Default::default() + }; + let project = ClaudeCodeSettings { + focus_mode: Some(false), + ..Default::default() + }; + + let merged = merge_claude_code_settings(Some(&global), Some(&project)).unwrap(); + + assert_eq!(merged.focus_mode, Some(false), "project off must win"); + // Untouched project fields still inherit. + assert_eq!(merged.env_scrub, Some(true)); + assert_eq!(merged.prompt_caching_1h, Some(true)); + + // And it has to survive into what the container actually receives. + let payload = build_claude_code_settings_json(Some(&merged), false); + let v: serde_json::Value = serde_json::from_str(&payload).unwrap(); + assert!( + v.get("viewMode").is_some_and(|m| m.is_null()), + "viewMode should be cleared, got {:?}", + v.get("viewMode") + ); + } + + #[test] + fn inherit_and_deliberate_off_fingerprint_differently() { + // If these collided, switching a project from "inherit" to an explicit + // "off" would not recreate the container and the change would silently + // not apply. + let inherit = ClaudeCodeSettings { focus_mode: None, ..Default::default() }; + let off = ClaudeCodeSettings { focus_mode: Some(false), ..Default::default() }; + assert_ne!( + compute_claude_code_settings_fingerprint(Some(&inherit), false), + compute_claude_code_settings_fingerprint(Some(&off), false), + ); + } + #[test] fn split_payload_never_emits_a_null_to_an_older_entrypoint() { // An existing project recreates from its own snapshot, which carries @@ -3713,7 +3768,7 @@ mod tests { // managed key would silently stop being asserted. let settings = ClaudeCodeSettings { tui_mode: Some("fullscreen".to_string()), - focus_mode: true, + focus_mode: Some(true), ..Default::default() }; let payload = build_claude_code_settings_json(Some(&settings), true); @@ -3752,10 +3807,10 @@ mod tests { let on = ClaudeCodeSettings { tui_mode: Some("fullscreen".to_string()), effort: Some("xhigh".to_string()), - auto_scroll_disabled: true, - focus_mode: true, - show_thinking_summaries: true, - session_recap_disabled: true, + auto_scroll_disabled: Some(true), + focus_mode: Some(true), + show_thinking_summaries: Some(true), + session_recap_disabled: Some(true), ..Default::default() }; let hot = settings_json(Some(&on), true); @@ -3801,7 +3856,7 @@ mod tests { fn the_settings_payload_uses_the_key_names_claude_code_actually_reads() { let s = ClaudeCodeSettings { effort: Some("high".to_string()), - focus_mode: true, + focus_mode: Some(true), ..Default::default() }; let json = settings_json(Some(&s), false); @@ -3835,7 +3890,7 @@ mod tests { // has to be "don't interfere". Getting this backwards would have // silently disabled recaps for every existing project. let untouched = ClaudeCodeSettings::default(); - assert!(!untouched.session_recap_disabled); + assert_eq!(untouched.session_recap_disabled, None); assert_eq!( settings_json(Some(&untouched), false)["awaySummaryEnabled"], serde_json::Value::Null @@ -3847,7 +3902,7 @@ mod tests { // `container_needs_recreation` is label-based and never diffs env, so // the settings only reach a container if the fingerprint moves. let on = ClaudeCodeSettings { - focus_mode: true, + focus_mode: Some(true), ..Default::default() }; let off = ClaudeCodeSettings::default(); @@ -3856,7 +3911,7 @@ mod tests { compute_claude_code_settings_fingerprint(Some(&off), false), ); let recap_off = ClaudeCodeSettings { - session_recap_disabled: true, + session_recap_disabled: Some(true), ..Default::default() }; assert_ne!( @@ -3917,7 +3972,7 @@ mod tests { // The whole point of B3: `=1` when enabled was a no-op against a // feature that was already on, and there was no off path at all. let off = ClaudeCodeSettings { - session_recap_disabled: true, + session_recap_disabled: Some(true), ..Default::default() }; assert!(claude_code_env_vars(Some(&off)) diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index c0a8f30..45022be 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -85,6 +85,14 @@ impl PermissionMode { /// Settings for Claude Code CLI behavior inside the container. /// These map to Claude Code env vars and ~/.claude/settings.json entries. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +/// Every field is three-state, and the third state is load-bearing. +/// +/// `None` means "not set at this level". For a *project* that is "inherit +/// whatever the global settings say"; for the *global* settings it is "leave +/// Claude Code's own default alone". `Some(false)` is a deliberate off, which +/// is what lets a project turn a globally-enabled setting back off — with a +/// plain `bool` there is no value that can express that, which is why these +/// were widened from `bool`. pub struct ClaudeCodeSettings { /// TUI renderer. `None` leaves settings.json's `tui` key unset, which is /// what lets Claude Code pick the renderer itself; `Some("default")` pins @@ -101,14 +109,14 @@ pub struct ClaudeCodeSettings { /// because Claude Code's `autoScrollEnabled` defaults to `true`, so the /// zero value of this field has to mean "leave it on". #[serde(default)] - pub auto_scroll_disabled: bool, + pub auto_scroll_disabled: Option, /// Collapse tool output to one-line summaries. Written to settings.json as /// `viewMode: "focus"`; there is no `focusMode` key in Claude Code. #[serde(default)] - pub focus_mode: bool, + pub focus_mode: Option, /// Show thinking summaries in responses #[serde(default)] - pub show_thinking_summaries: bool, + pub show_thinking_summaries: Option, /// Turn the session recap **off**. /// /// Held in the disabled sense for the same reason as `auto_scroll_disabled`, @@ -121,13 +129,13 @@ pub struct ClaudeCodeSettings { /// silently disabled it for all of them. A new name lets the old key be /// ignored, which lands every existing project on the correct default. #[serde(default)] - pub session_recap_disabled: bool, + pub session_recap_disabled: Option, /// Strip credentials from subprocess environments #[serde(default)] - pub env_scrub: bool, + pub env_scrub: Option, /// Enable 1-hour prompt cache TTL (vs default 5-minute) #[serde(default)] - pub prompt_caching_1h: bool, + pub prompt_caching_1h: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/app/src/components/projects/ClaudeCodeSettingsEditor.test.tsx b/app/src/components/projects/ClaudeCodeSettingsEditor.test.tsx index 63f1803..e7d61a3 100644 --- a/app/src/components/projects/ClaudeCodeSettingsEditor.test.tsx +++ b/app/src/components/projects/ClaudeCodeSettingsEditor.test.tsx @@ -3,10 +3,18 @@ import { render, screen, fireEvent } from "@testing-library/react"; import ClaudeCodeSettingsEditor, { CLAUDE_CODE_DEFAULTS } from "./ClaudeCodeSettingsEditor"; import type { ClaudeCodeSettings } from "../../lib/types"; -function renderEditor(settings: ClaudeCodeSettings | null) { +function renderEditor( + settings: ClaudeCodeSettings | null, + scope: "global" | "project" = "global", +) { const onSave = vi.fn().mockResolvedValue(undefined); render( - , + , ); return onSave; } @@ -59,4 +67,57 @@ describe("ClaudeCodeSettingsEditor", () => { ).map((o) => o.getAttribute("value")), ).toEqual(["", "low", "medium", "high", "xhigh"]); }); + + describe("project scope", () => { + it("offers Global as a third state so a project can decline to have an opinion", () => { + renderEditor(null, "project"); + const focus = screen.getByLabelText("Focus mode"); + expect( + Array.from(focus.querySelectorAll("option")).map((o) => o.getAttribute("value")), + ).toEqual(["global", "off", "on"]); + expect((focus as HTMLSelectElement).value).toBe("global"); + }); + + it("stores a deliberate false so the project can turn a global On back off", () => { + // The reason the field widened from boolean to boolean|null. Under the + // old merge there was no project value that could produce this. + const onSave = renderEditor(null, "project"); + fireEvent.change(screen.getByLabelText("Focus mode"), { target: { value: "off" } }); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ focus_mode: false }), + ); + }); + + it("does not collapse a deliberate off to null", () => { + // `null` means inherit. Collapsing here would silently hand the setting + // straight back to the global value the user just overrode. + const onSave = renderEditor(null, "project"); + fireEvent.change(screen.getByLabelText("Focus mode"), { target: { value: "off" } }); + expect(onSave).not.toHaveBeenCalledWith(null); + }); + + it("round-trips the inverted fields through the disabled sense", () => { + // Session recap stores `session_recap_disabled`, so choosing "off" has to + // store `true` and choosing "on" has to store `false`. + const onSave = renderEditor(null, "project"); + const recap = screen.getByLabelText("Session recap"); + + fireEvent.change(recap, { target: { value: "off" } }); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ session_recap_disabled: true }), + ); + + fireEvent.change(recap, { target: { value: "on" } }); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ session_recap_disabled: false }), + ); + }); + + it("shows a stored override rather than the inherited state", () => { + renderEditor({ ...CLAUDE_CODE_DEFAULTS, session_recap_disabled: true }, "project"); + expect((screen.getByLabelText("Session recap") as HTMLSelectElement).value).toBe( + "off", + ); + }); + }); }); diff --git a/app/src/components/projects/ClaudeCodeSettingsEditor.tsx b/app/src/components/projects/ClaudeCodeSettingsEditor.tsx index caa9a71..ff9f3d4 100644 --- a/app/src/components/projects/ClaudeCodeSettingsEditor.tsx +++ b/app/src/components/projects/ClaudeCodeSettingsEditor.tsx @@ -8,29 +8,44 @@ interface Props { disabled: boolean; disabledReason?: string; onSave: (settings: ClaudeCodeSettings | null) => Promise; + /** + * `"project"` adds a third "Global" state to every switch, because a project + * has somewhere to inherit *from*. The global editor has no such fallback — + * unset there just means Claude Code's own default — so it stays a plain + * on/off and never renders the extra choice. + */ + scope?: "global" | "project"; } export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = { tui_mode: null, effort: null, - auto_scroll_disabled: false, - focus_mode: false, - show_thinking_summaries: false, - session_recap_disabled: false, - env_scrub: false, - prompt_caching_1h: false, + auto_scroll_disabled: null, + focus_mode: null, + show_thinking_summaries: null, + session_recap_disabled: null, + env_scrub: null, + prompt_caching_1h: null, }; +/** + * "Nothing is set at this level", which is saved as `null` rather than as a + * struct of nulls so that a project with no opinion is indistinguishable from + * one that never opened this editor. + * + * Note `false` is *not* a default any more: it is a deliberate off that + * overrides a global on, so a settings object holding one has to be persisted. + */ function isAllDefaults(s: ClaudeCodeSettings): boolean { return ( s.tui_mode === null && s.effort === null && - s.auto_scroll_disabled === false && - s.focus_mode === false && - s.show_thinking_summaries === false && - s.session_recap_disabled === false && - s.env_scrub === false && - s.prompt_caching_1h === false + s.auto_scroll_disabled === null && + s.focus_mode === null && + s.show_thinking_summaries === null && + s.session_recap_disabled === null && + s.env_scrub === null && + s.prompt_caching_1h === null ); } @@ -83,6 +98,7 @@ export default function ClaudeCodeSettingsEditor({ disabled, disabledReason, onSave, + scope = "global", }: Props) { const [local, setLocal] = useState( settings ?? { ...CLAUDE_CODE_DEFAULTS }, @@ -151,23 +167,71 @@ export default function ClaudeCodeSettingsEditor({ } /> - {BOOLEAN_FIELDS.map(({ key, label, hint, invert }) => ( - { + const stored = local[key]; + + if (scope === "global") { + // No level above this one to inherit from, so "unset" and "off" are + // the same instruction here and a plain switch is the honest control. + // Unset therefore has to *display* as Claude Code's own default — + // which for the two inverted fields is on, not off. + const checked = invert ? stored !== true : stored === true; + return ( + - apply({ [key]: invert ? !v : v } as Partial) + hint={hint} + control={ + { + // Collapse back to null at the default rather than storing + // a redundant `false`, so an untouched global stays + // indistinguishable from one that was never opened. + const atDefault = invert ? v : !v; + apply({ + [key]: atDefault ? null : invert ? !v : v, + } as Partial); + }} + /> } /> - } - /> - ))} + ); + } + + // `stored` holds the deviation from Claude Code's default, so an + // inverted field reads back the other way round — see BOOLEAN_FIELDS. + const selected = + stored === null ? "global" : (invert ? !stored : stored) ? "on" : "off"; + + return ( + { + const choice = e.target.value; + const next = + choice === "global" ? null : invert ? choice === "off" : choice === "on"; + apply({ [key]: next } as Partial); + }} + className={selectClass} + > + + + + + } + /> + ); + })} ); } diff --git a/app/src/components/projects/home/config/RuntimeSection.tsx b/app/src/components/projects/home/config/RuntimeSection.tsx index d9c80e1..c60868e 100644 --- a/app/src/components/projects/home/config/RuntimeSection.tsx +++ b/app/src/components/projects/home/config/RuntimeSection.tsx @@ -109,9 +109,10 @@ export default function RuntimeSection({ Date: Sun, 23 Aug 2026 09:12:59 -0700 Subject: [PATCH 07/43] Drag a file out of the Files tab onto the host desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Files tab could accept a drop but never produce one: getting a file out meant "Save to host…" and a file picker. This adds the other direction. Two constraints shape it. `dragDropEnabled` is on — TerminalView needs it, since the native drag-drop event is the only one carrying dropped file paths — and it blocks HTML5 drag inside the webview, so `draggable` plus `DataTransfer.setData("DownloadURL", …)` was never available. The gesture is therefore pointer events into `tauri-plugin-drag`, the same shape and the same reason as the tab strip's drag. And the file being dragged does not exist on the host at all: it lives in a container, and the OS can only drag a real host path. So a drag-out is a copy first and a drag second. `stage_container_file_for_drag` materialises the file into `/triple-c-drag-out///` through the same `fetch_container_file` the download and the viewer use, keeps the original filename (a dropped `tmp1234` is not a file anyone wants), and caps at the 256 MiB an upload already caps at, naming "Save to host…" in the refusal. The path comes from Tauri's path API rather than `/tmp`, because on Windows it is neither. The staging directory has a lifecycle, because whole files accumulating in the host temp dir would be the disk problem this project just fixed, in a new place: cleared on exit inside the existing teardown (still guarded on the main window), and reaped at startup for whatever a crash left behind. The copy is also an async gap in the middle of a gesture that feels instantaneous, and the OS only adopts a drag while the button is still down. Small files beat the pointer; large ones do not — so the staged path is cached per entry (keyed on size and mtime, so an edited file re-stages) and the pane says the copy is ready and to drag again, which is an instruction rather than an apology because the retry is immediate. A per-file slot keeps `a/notes.txt` and `b/notes.txt` from becoming the same host path. "Save to host…" stays exactly as it was. Drag-out is the enhancement; a platform that refuses `startDrag` says so and points back at it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc --- CLAUDE.md | 16 + app/package-lock.json | 9 + app/package.json | 1 + app/src-tauri/Cargo.lock | 210 ++++++++++- app/src-tauri/Cargo.toml | 1 + app/src-tauri/capabilities/default.json | 4 +- app/src-tauri/gen/schemas/acl-manifests.json | 2 +- app/src-tauri/gen/schemas/capabilities.json | 2 +- app/src-tauri/gen/schemas/desktop-schema.json | 18 + app/src-tauri/gen/schemas/linux-schema.json | 18 + app/src-tauri/src/commands/file_commands.rs | 349 +++++++++++++++++- app/src-tauri/src/lib.rs | 31 +- .../projects/home/FilesTab.test.tsx | 152 ++++++++ app/src/components/projects/home/FilesTab.tsx | 125 +++++++ .../projects/home/dragPreview.test.ts | 79 ++++ .../components/projects/home/dragPreview.ts | 85 +++++ app/src/hooks/useFileManager.test.ts | 81 ++++ app/src/hooks/useFileManager.ts | 41 +- app/src/lib/tauri-commands.ts | 7 + 19 files changed, 1205 insertions(+), 26 deletions(-) create mode 100644 app/src/components/projects/home/dragPreview.test.ts create mode 100644 app/src/components/projects/home/dragPreview.ts diff --git a/CLAUDE.md b/CLAUDE.md index f8ef401..3c1e74b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,6 +79,22 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li - **`components/projects/home/`** — **Project Home**, the main-area view for a project: Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in modals — see "UI conventions" below. + - **Files moves in both directions and neither direction uses HTML5 drag.** Dropping *into* + the pane is Tauri's native `onDragDropEvent`, which is window-wide and therefore routed by + a hit-test of the physical-pixel payload position against the pane's rect ÷ + `devicePixelRatio` — a hidden pane has a zero-size rect, which is what stops it and + `TerminalView`'s listener both firing. Dragging *out* is pointer events into + `tauri-plugin-drag`, for the same `dragDropEnabled` reason the tab strip is pointer-driven. + - **A drag-out is a copy first and a drag second.** The OS can only drag a path that exists + on the host, and these files are inside a container, so `stage_container_file_for_drag` + materialises one into `/triple-c-drag-out//` (via the shared + `fetch_container_file`, keeping the original filename, capped at the same 256 MiB as an + upload) and `startDrag` is handed *that*. Two consequences worth keeping: the copy is an + async gap inside a gesture that feels instantaneous, so the staged path is cached and the + UI says "drag it again" when the pointer came up first; and the staging directory is + cleared on exit **and** reaped at startup, because a drag-out quietly filling the host temp + dir with whole files would be the disk problem this project just fixed, in a new place. + "Save to host…" stays — `startDrag` is an enhancement and can fail per platform. - **`components/settings/`** — Host-level settings: Docker, AWS, Web Terminal, STT, shared auth - **`components/ui/`** — Shared primitives. **Use these; do not hand-roll replacements.** `Modal` (the only correct way to build a dialog — it supplies `role="dialog"`, `aria-modal`, diff --git a/app/package-lock.json b/app/package-lock.json index b35ded4..85a0dd3 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,6 +8,7 @@ "name": "triple-c", "version": "0.4.0", "dependencies": { + "@crabnebula/tauri-plugin-drag": "^2.1.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-opener": "^2.5.3", @@ -414,6 +415,14 @@ "specificity": "bin/cli.js" } }, + "node_modules/@crabnebula/tauri-plugin-drag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@crabnebula/tauri-plugin-drag/-/tauri-plugin-drag-2.1.0.tgz", + "integrity": "sha512-LnUXAZwQt1cdMoGDLJ6ogW9wFCYServCZXlGadS7CA+CZ9eXS7L+Q7QyQW6g/zGw9YI2MwKFqf1aSNBGyWw+OA==", + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", diff --git a/app/package.json b/app/package.json index 3c02859..8a1eae1 100644 --- a/app/package.json +++ b/app/package.json @@ -12,6 +12,7 @@ "test:watch": "vitest" }, "dependencies": { + "@crabnebula/tauri-plugin-drag": "^2.1.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 7efb86a..1e436fb 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -630,6 +630,19 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types", + "libc", +] + [[package]] name = "core-graphics" version = "0.25.0" @@ -1016,6 +1029,28 @@ dependencies = [ "serde", ] +[[package]] +name = "drag" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e90b4a25ace5ce0561534b073943594cbcd21af936e64d09aec444568411f8c" +dependencies = [ + "core-graphics 0.24.0", + "dunce", + "gdk", + "gdkx11", + "gtk", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "raw-window-handle", + "serde", + "thiserror 2.0.18", + "windows 0.52.0", + "windows-core 0.58.0", +] + [[package]] name = "dtoa" version = "1.0.11" @@ -1135,7 +1170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2647,9 +2682,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.0", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -2669,6 +2712,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ + "bitflags 2.11.0", "objc2", "objc2-foundation", ] @@ -2729,6 +2773,19 @@ dependencies = [ "objc2-core-graphics", ] +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -3394,7 +3451,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3743,7 +3800,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4400,7 +4457,7 @@ dependencies = [ "bitflags 2.11.0", "block2", "core-foundation 0.10.1", - "core-graphics", + "core-graphics 0.25.0", "crossbeam-channel", "dbus", "dispatch2", @@ -4425,7 +4482,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -4508,7 +4565,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -4608,6 +4665,21 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-drag" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "729ca0ce4b1169869d3405216d3c09a524f41ea5e2eec89f917cd6623f8a70ca" +dependencies = [ + "base64 0.22.1", + "drag", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-fs" version = "2.5.0" @@ -4650,7 +4722,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.18", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -4692,7 +4764,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -4717,7 +4789,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -4782,7 +4854,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5186,6 +5258,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-drag", "tauri-plugin-opener", "tauri-plugin-store", "tokio", @@ -5639,10 +5712,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -5663,7 +5736,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -5689,7 +5762,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5713,6 +5786,18 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-implement 0.52.0", + "windows-interface 0.52.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -5735,14 +5820,36 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -5754,8 +5861,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -5772,6 +5879,28 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12168c33176773b86799be25e2a2ba07c7aab9968b37541f1094dbd7a60c8946" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -5783,6 +5912,28 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d8dc32e0095a7eeccebd0e3f09e9509365ecb3fc6ac4d6f5f14a3f6392942d1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -5816,6 +5967,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5834,6 +5994,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -6261,7 +6431,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 67f1ea7..713a3a2 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -37,6 +37,7 @@ tower-http = { version = "0.6", features = ["cors"] } base64 = "0.22" rand = "0.9" local-ip-address = "0.6" +tauri-plugin-drag = "2.1" [dev-dependencies] # `test-util` (not part of tokio's `full`) lets the auto-start retry tests run diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index a6b9a19..702d43d 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -28,6 +28,8 @@ "store:allow-save", "store:allow-clear", "opener:default", - "opener:allow-open-url" + "opener:allow-open-url", + "drag:default", + "drag:allow-start-drag" ] } diff --git a/app/src-tauri/gen/schemas/acl-manifests.json b/app/src-tauri/gen/schemas/acl-manifests.json index 060cee7..472e051 100644 --- a/app/src-tauri/gen/schemas/acl-manifests.json +++ b/app/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"store":{"default_permission":{"identifier":"default","description":"This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n","permissions":["allow-load","allow-get-store","allow-set","allow-get","allow-has","allow-delete","allow-clear","allow-reset","allow-keys","allow-values","allow-entries","allow-length","allow-reload","allow-save"]},"permissions":{"allow-clear":{"identifier":"allow-clear","description":"Enables the clear command without any pre-configured scope.","commands":{"allow":["clear"],"deny":[]}},"allow-delete":{"identifier":"allow-delete","description":"Enables the delete command without any pre-configured scope.","commands":{"allow":["delete"],"deny":[]}},"allow-entries":{"identifier":"allow-entries","description":"Enables the entries command without any pre-configured scope.","commands":{"allow":["entries"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-get-store":{"identifier":"allow-get-store","description":"Enables the get_store command without any pre-configured scope.","commands":{"allow":["get_store"],"deny":[]}},"allow-has":{"identifier":"allow-has","description":"Enables the has command without any pre-configured scope.","commands":{"allow":["has"],"deny":[]}},"allow-keys":{"identifier":"allow-keys","description":"Enables the keys command without any pre-configured scope.","commands":{"allow":["keys"],"deny":[]}},"allow-length":{"identifier":"allow-length","description":"Enables the length command without any pre-configured scope.","commands":{"allow":["length"],"deny":[]}},"allow-load":{"identifier":"allow-load","description":"Enables the load command without any pre-configured scope.","commands":{"allow":["load"],"deny":[]}},"allow-reload":{"identifier":"allow-reload","description":"Enables the reload command without any pre-configured scope.","commands":{"allow":["reload"],"deny":[]}},"allow-reset":{"identifier":"allow-reset","description":"Enables the reset command without any pre-configured scope.","commands":{"allow":["reset"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"allow-set":{"identifier":"allow-set","description":"Enables the set command without any pre-configured scope.","commands":{"allow":["set"],"deny":[]}},"allow-values":{"identifier":"allow-values","description":"Enables the values command without any pre-configured scope.","commands":{"allow":["values"],"deny":[]}},"deny-clear":{"identifier":"deny-clear","description":"Denies the clear command without any pre-configured scope.","commands":{"allow":[],"deny":["clear"]}},"deny-delete":{"identifier":"deny-delete","description":"Denies the delete command without any pre-configured scope.","commands":{"allow":[],"deny":["delete"]}},"deny-entries":{"identifier":"deny-entries","description":"Denies the entries command without any pre-configured scope.","commands":{"allow":[],"deny":["entries"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-get-store":{"identifier":"deny-get-store","description":"Denies the get_store command without any pre-configured scope.","commands":{"allow":[],"deny":["get_store"]}},"deny-has":{"identifier":"deny-has","description":"Denies the has command without any pre-configured scope.","commands":{"allow":[],"deny":["has"]}},"deny-keys":{"identifier":"deny-keys","description":"Denies the keys command without any pre-configured scope.","commands":{"allow":[],"deny":["keys"]}},"deny-length":{"identifier":"deny-length","description":"Denies the length command without any pre-configured scope.","commands":{"allow":[],"deny":["length"]}},"deny-load":{"identifier":"deny-load","description":"Denies the load command without any pre-configured scope.","commands":{"allow":[],"deny":["load"]}},"deny-reload":{"identifier":"deny-reload","description":"Denies the reload command without any pre-configured scope.","commands":{"allow":[],"deny":["reload"]}},"deny-reset":{"identifier":"deny-reset","description":"Denies the reset command without any pre-configured scope.","commands":{"allow":[],"deny":["reset"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}},"deny-set":{"identifier":"deny-set","description":"Denies the set command without any pre-configured scope.","commands":{"allow":[],"deny":["set"]}},"deny-values":{"identifier":"deny-values","description":"Denies the values command without any pre-configured scope.","commands":{"allow":[],"deny":["values"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"drag":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin","permissions":["allow-start-drag"]},"permissions":{"allow-start-drag":{"identifier":"allow-start-drag","description":"Enables the start_drag command without any pre-configured scope.","commands":{"allow":["start_drag"],"deny":[]}},"deny-start-drag":{"identifier":"deny-start-drag","description":"Denies the start_drag command without any pre-configured scope.","commands":{"allow":[],"deny":["start_drag"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"store":{"default_permission":{"identifier":"default","description":"This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n","permissions":["allow-load","allow-get-store","allow-set","allow-get","allow-has","allow-delete","allow-clear","allow-reset","allow-keys","allow-values","allow-entries","allow-length","allow-reload","allow-save"]},"permissions":{"allow-clear":{"identifier":"allow-clear","description":"Enables the clear command without any pre-configured scope.","commands":{"allow":["clear"],"deny":[]}},"allow-delete":{"identifier":"allow-delete","description":"Enables the delete command without any pre-configured scope.","commands":{"allow":["delete"],"deny":[]}},"allow-entries":{"identifier":"allow-entries","description":"Enables the entries command without any pre-configured scope.","commands":{"allow":["entries"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-get-store":{"identifier":"allow-get-store","description":"Enables the get_store command without any pre-configured scope.","commands":{"allow":["get_store"],"deny":[]}},"allow-has":{"identifier":"allow-has","description":"Enables the has command without any pre-configured scope.","commands":{"allow":["has"],"deny":[]}},"allow-keys":{"identifier":"allow-keys","description":"Enables the keys command without any pre-configured scope.","commands":{"allow":["keys"],"deny":[]}},"allow-length":{"identifier":"allow-length","description":"Enables the length command without any pre-configured scope.","commands":{"allow":["length"],"deny":[]}},"allow-load":{"identifier":"allow-load","description":"Enables the load command without any pre-configured scope.","commands":{"allow":["load"],"deny":[]}},"allow-reload":{"identifier":"allow-reload","description":"Enables the reload command without any pre-configured scope.","commands":{"allow":["reload"],"deny":[]}},"allow-reset":{"identifier":"allow-reset","description":"Enables the reset command without any pre-configured scope.","commands":{"allow":["reset"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"allow-set":{"identifier":"allow-set","description":"Enables the set command without any pre-configured scope.","commands":{"allow":["set"],"deny":[]}},"allow-values":{"identifier":"allow-values","description":"Enables the values command without any pre-configured scope.","commands":{"allow":["values"],"deny":[]}},"deny-clear":{"identifier":"deny-clear","description":"Denies the clear command without any pre-configured scope.","commands":{"allow":[],"deny":["clear"]}},"deny-delete":{"identifier":"deny-delete","description":"Denies the delete command without any pre-configured scope.","commands":{"allow":[],"deny":["delete"]}},"deny-entries":{"identifier":"deny-entries","description":"Denies the entries command without any pre-configured scope.","commands":{"allow":[],"deny":["entries"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-get-store":{"identifier":"deny-get-store","description":"Denies the get_store command without any pre-configured scope.","commands":{"allow":[],"deny":["get_store"]}},"deny-has":{"identifier":"deny-has","description":"Denies the has command without any pre-configured scope.","commands":{"allow":[],"deny":["has"]}},"deny-keys":{"identifier":"deny-keys","description":"Denies the keys command without any pre-configured scope.","commands":{"allow":[],"deny":["keys"]}},"deny-length":{"identifier":"deny-length","description":"Denies the length command without any pre-configured scope.","commands":{"allow":[],"deny":["length"]}},"deny-load":{"identifier":"deny-load","description":"Denies the load command without any pre-configured scope.","commands":{"allow":[],"deny":["load"]}},"deny-reload":{"identifier":"deny-reload","description":"Denies the reload command without any pre-configured scope.","commands":{"allow":[],"deny":["reload"]}},"deny-reset":{"identifier":"deny-reset","description":"Denies the reset command without any pre-configured scope.","commands":{"allow":[],"deny":["reset"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}},"deny-set":{"identifier":"deny-set","description":"Denies the set command without any pre-configured scope.","commands":{"allow":[],"deny":["set"]}},"deny-values":{"identifier":"deny-values","description":"Denies the values command without any pre-configured scope.","commands":{"allow":[],"deny":["values"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/app/src-tauri/gen/schemas/capabilities.json b/app/src-tauri/gen/schemas/capabilities.json index 5dbd61e..a8b1260 100644 --- a/app/src-tauri/gen/schemas/capabilities.json +++ b/app/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capabilities for Triple-C","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit-to","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm","store:default","store:allow-get","store:allow-set","store:allow-delete","store:allow-keys","store:allow-values","store:allow-entries","store:allow-length","store:allow-load","store:allow-reset","store:allow-save","store:allow-clear","opener:default","opener:allow-open-url"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capabilities for Triple-C","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit-to","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm","store:default","store:allow-get","store:allow-set","store:allow-delete","store:allow-keys","store:allow-values","store:allow-entries","store:allow-length","store:allow-load","store:allow-reset","store:allow-save","store:allow-clear","opener:default","opener:allow-open-url","drag:default","drag:allow-start-drag"]}} \ No newline at end of file diff --git a/app/src-tauri/gen/schemas/desktop-schema.json b/app/src-tauri/gen/schemas/desktop-schema.json index b6ecf6c..2d0cfa5 100644 --- a/app/src-tauri/gen/schemas/desktop-schema.json +++ b/app/src-tauri/gen/schemas/desktop-schema.json @@ -2426,6 +2426,24 @@ "const": "dialog:deny-save", "markdownDescription": "Denies the save command without any pre-configured scope." }, + { + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`", + "type": "string", + "const": "drag:default", + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`" + }, + { + "description": "Enables the start_drag command without any pre-configured scope.", + "type": "string", + "const": "drag:allow-start-drag", + "markdownDescription": "Enables the start_drag command without any pre-configured scope." + }, + { + "description": "Denies the start_drag command without any pre-configured scope.", + "type": "string", + "const": "drag:deny-start-drag", + "markdownDescription": "Denies the start_drag command without any pre-configured scope." + }, { "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "type": "string", diff --git a/app/src-tauri/gen/schemas/linux-schema.json b/app/src-tauri/gen/schemas/linux-schema.json index b6ecf6c..2d0cfa5 100644 --- a/app/src-tauri/gen/schemas/linux-schema.json +++ b/app/src-tauri/gen/schemas/linux-schema.json @@ -2426,6 +2426,24 @@ "const": "dialog:deny-save", "markdownDescription": "Denies the save command without any pre-configured scope." }, + { + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`", + "type": "string", + "const": "drag:default", + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`" + }, + { + "description": "Enables the start_drag command without any pre-configured scope.", + "type": "string", + "const": "drag:allow-start-drag", + "markdownDescription": "Enables the start_drag command without any pre-configured scope." + }, + { + "description": "Denies the start_drag command without any pre-configured scope.", + "type": "string", + "const": "drag:deny-start-drag", + "markdownDescription": "Denies the start_drag command without any pre-configured scope." + }, { "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "type": "string", diff --git a/app/src-tauri/src/commands/file_commands.rs b/app/src-tauri/src/commands/file_commands.rs index d5a1232..e586f70 100644 --- a/app/src-tauri/src/commands/file_commands.rs +++ b/app/src-tauri/src/commands/file_commands.rs @@ -1,10 +1,14 @@ +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::{Duration, SystemTime}; + 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; use serde::Serialize; -use tauri::State; +use tauri::{AppHandle, Manager, State}; use crate::docker::client::get_docker; use crate::docker::exec::{ @@ -334,6 +338,239 @@ pub async fn read_container_file( }) } +// ───────────────────────────────────────────────────────────────────────────── +// Drag-out staging +// ───────────────────────────────────────────────────────────────────────────── +// +// Dragging a file onto the host desktop hands the OS a *host* path, and the +// files in this panel live inside a container, where nothing on the desktop can +// reach them. So a drag-out is really a copy-then-drag: materialise the file +// into a host temp directory first, then start the native drag on that copy. +// +// The copy is the reason this section carries a lifecycle. A staging directory +// nobody empties is a disk leak with a gesture attached to it, so there are two +// halves and both matter: `clear_drag_staging` on exit, and +// `reap_drag_staging` at startup for whatever a crash left behind. + +/// Ceiling on one staged copy. Deliberately the same 256 MiB as +/// [`MAX_UPLOAD_BYTES`] — it is the same whole-file-through-host-RAM round trip, +/// only in the other direction. +const MAX_DRAG_STAGE_BYTES: u64 = 256 * 1024 * 1024; + +/// Name of the app-owned directory inside the OS temp dir. Everything staged by +/// any Triple-C process lives under it, so housekeeping has exactly one place to +/// look and never walks the rest of the user's temp dir. +const DRAG_STAGE_DIR_NAME: &str = "triple-c-drag-out"; + +/// How long *another* process's leftover staging directory may sit before +/// startup housekeeping deletes it. +/// +/// Only ever applied to directories this process does not own (see +/// [`drag_stage_session_dir`]), so it is not a limit on how long a staged file +/// survives in a live session — it is the crash-recovery threshold, and it is +/// generous because a second Triple-C running right now would also look like a +/// leftover. +const DRAG_STAGE_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); + +/// This process's own sub-directory name, stable for the life of the process. +/// +/// Per-process rather than shared so exit cleanup can delete *ours* outright +/// without reaching into a directory another instance may be dragging out of. +fn drag_stage_session() -> &'static str { + static SESSION: OnceLock = OnceLock::new(); + SESSION.get_or_init(|| uuid::Uuid::new_v4().to_string()) +} + +/// The app-owned staging root inside `temp_dir`. +/// +/// Takes the temp dir rather than reading it, because on Windows it is neither +/// `/tmp` nor a constant — Tauri's path API is the only thing that knows it — +/// and because a pure function is what the tests can drive. +pub fn drag_stage_root(temp_dir: &Path) -> PathBuf { + temp_dir.join(DRAG_STAGE_DIR_NAME) +} + +/// This process's staging directory: `/triple-c-drag-out/`. +pub fn drag_stage_session_dir(temp_dir: &Path) -> PathBuf { + drag_stage_root(temp_dir).join(drag_stage_session()) +} + +/// The per-file sub-directory a staged copy lives in, derived from the +/// container path. +/// +/// Filenames are only unique within a directory, so `a/notes.txt` and +/// `b/notes.txt` would otherwise be the same host path — and the second drag +/// would silently rewrite the first one's contents under the first one's cached +/// path. A digest of the full container path separates them while staying +/// *deterministic*, so re-staging the same file reuses its slot instead of +/// growing a new one every drag. +fn drag_stage_slot(container_path: &str) -> String { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(container_path.as_bytes()); + digest[..8].iter().map(|b| format!("{:02x}", b)).collect() +} + +/// The name the staged copy is given on the host. +/// +/// The whole point is that what lands on the desktop is called `notes.txt` and +/// not `tmp1234`, so the container's basename is kept verbatim wherever it can +/// be. Only the characters Windows refuses outright are substituted — a Linux +/// file really can be called `a:b`, and the staged copy has to exist on NTFS. +/// A name that is not a filename at all (empty, `.`, `..`) is rejected rather +/// than invented: that means the caller passed something that never named a +/// file, and quietly inventing a name would stage the wrong thing. +fn stage_file_name(container_path: &str) -> Result { + let base = container_path + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or(""); + + let cleaned: String = base + .chars() + .map(|c| match c { + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_', + c if (c as u32) < 0x20 => '_', + c => c, + }) + .collect(); + // Windows also silently drops a trailing dot or space, which would make the + // path we hand back not the path that exists. + let cleaned = cleaned.trim_end_matches([' ', '.']); + + if cleaned.is_empty() || cleaned == "." || cleaned == ".." { + return Err(format!("{} does not name a file", container_path)); + } + Ok(cleaned.to_string()) +} + +/// Reject an oversize file *by its real size*, before anything is written. +/// +/// Split out so the ceiling and its wording are testable without a container. +/// The message names the fallback, because "too large" with no way forward is +/// the one thing a size cap must not be. +fn check_stage_size(size: u64) -> Result<(), String> { + if size > MAX_DRAG_STAGE_BYTES { + return Err(format!( + "{:.0} MB is too large to drag out (limit {} MB) — use \"Save to host…\" instead.", + size as f64 / (1024.0 * 1024.0), + MAX_DRAG_STAGE_BYTES / (1024 * 1024) + )); + } + Ok(()) +} + +/// Whether a leftover staging directory is old enough to delete. +/// +/// A modification time in the *future* (a clock step, a copied temp dir) makes +/// `duration_since` fail, and that answers "not stale" — housekeeping deleting +/// something it cannot date is worse than leaving it for the next startup. +fn drag_stage_is_stale(modified: SystemTime, now: SystemTime, max_age: Duration) -> bool { + now.duration_since(modified) + .map(|age| age >= max_age) + .unwrap_or(false) +} + +/// Delete every staging directory except this process's own, once it is older +/// than [`DRAG_STAGE_MAX_AGE`]. Called from startup housekeeping. +pub async fn reap_drag_staging(temp_dir: PathBuf) { + let root = drag_stage_root(&temp_dir); + let keep = drag_stage_session_dir(&temp_dir); + let now = SystemTime::now(); + + let mut dir = match tokio::fs::read_dir(&root).await { + Ok(dir) => dir, + // Nothing staged yet is the normal case, not a problem. + Err(_) => return, + }; + + let mut reaped = 0usize; + while let Ok(Some(entry)) = dir.next_entry().await { + let path = entry.path(); + if path == keep { + continue; + } + let stale = match entry.metadata().await.and_then(|m| m.modified()) { + Ok(modified) => drag_stage_is_stale(modified, now, DRAG_STAGE_MAX_AGE), + Err(_) => false, + }; + if !stale { + continue; + } + if tokio::fs::remove_dir_all(&path).await.is_ok() { + reaped += 1; + } + } + + if reaped > 0 { + log::info!("Startup housekeeping removed {} stale drag-out staging directory(ies)", reaped); + } +} + +/// Delete this process's staging directory. Called from the shutdown teardown. +pub async fn clear_drag_staging(temp_dir: PathBuf) { + let dir = drag_stage_session_dir(&temp_dir); + if let Err(e) = tokio::fs::remove_dir_all(&dir).await { + if e.kind() != std::io::ErrorKind::NotFound { + log::warn!("Failed to clear drag-out staging at {}: {}", dir.display(), e); + } + } + // Best effort: leave no empty root behind either. Fails harmlessly while + // another instance still has a directory in there. + let _ = tokio::fs::remove_dir(drag_stage_root(&temp_dir)).await; +} + +/// Copy a container file onto the host so it can be dragged to the desktop, and +/// return the absolute host path. +/// +/// Reuses [`fetch_container_file`] rather than extracting a second way, so a +/// dragged file, a downloaded file and a previewed file are byte-identical and +/// refuse folders and links with the same words. The fetch is capped at +/// [`MAX_DRAG_STAGE_BYTES`], so an oversize file is recognised from the tar +/// header without being pulled across the socket in full. +#[tauri::command] +pub async fn stage_container_file_for_drag( + app: AppHandle, + project_id: String, + 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())?; + + // Before the transfer: a path that cannot become a host filename is not + // worth a round trip. + let file_name = stage_file_name(&path)?; + + let fetched = fetch_container_file(container_id, &path, Some(MAX_DRAG_STAGE_BYTES)).await?; + // `size` is the tar header's, i.e. the file's real size, which is exactly + // what a truncated fetch does not tell you from `bytes.len()`. + check_stage_size(fetched.size)?; + + let temp_dir = app + .path() + .temp_dir() + .map_err(|e| format!("No host temporary directory available: {}", e))?; + let dir = drag_stage_session_dir(&temp_dir).join(drag_stage_slot(&path)); + tokio::fs::create_dir_all(&dir) + .await + .map_err(|e| format!("Failed to create the drag staging directory: {}", e))?; + + let dest = dir.join(&file_name); + tokio::fs::write(&dest, &fetched.bytes) + .await + .map_err(|e| format!("Failed to stage {} on the host: {}", file_name, e))?; + + Ok(dest.to_string_lossy().to_string()) +} + /// 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 `/`. @@ -849,4 +1086,114 @@ mod tests { assert_eq!(Some(u64::MAX).unwrap().min(MAX_READ_BYTES), MAX_READ_BYTES); assert!(MAX_READ_BYTES < MAX_UPLOAD_BYTES); } + + // ── Drag-out staging ──────────────────────────────────────────────────── + + #[test] + fn the_staging_path_is_built_under_the_supplied_temp_dir() { + // Never `/tmp`: on Windows the temp dir is per-user and nowhere near it, + // so the whole path has to be derived from what Tauri hands us. + let temp = Path::new("/somewhere/else"); + let root = drag_stage_root(temp); + assert_eq!(root, Path::new("/somewhere/else/triple-c-drag-out")); + + let session = drag_stage_session_dir(temp); + assert_eq!(session.parent(), Some(root.as_path())); + assert!(session.starts_with(root)); + } + + #[test] + fn every_call_in_a_process_stages_into_the_same_session_directory() { + // Exit cleanup deletes this directory by name rather than tracking what + // it wrote, which only works if the name does not move. + let temp = Path::new("/tmp-ish"); + assert_eq!(drag_stage_session_dir(temp), drag_stage_session_dir(temp)); + assert_ne!(drag_stage_session_dir(temp), drag_stage_root(temp)); + } + + #[test] + fn the_staged_copy_keeps_the_original_file_name() { + // The reason the feature stages into a per-session directory at all: a + // plain temp file would be dropped onto the desktop called `tmp1234`. + assert_eq!(stage_file_name("/workspace/notes.txt").unwrap(), "notes.txt"); + assert_eq!(stage_file_name("/workspace/a b/.env").unwrap(), ".env"); + assert_eq!(stage_file_name("report.pdf").unwrap(), "report.pdf"); + assert_eq!(stage_file_name("/workspace/über.md").unwrap(), "über.md"); + } + + #[test] + fn a_name_windows_cannot_hold_is_substituted_rather_than_dropped() { + // These are all legal on Linux and all refused by NTFS, and the staged + // copy has to exist on the host we are dragging onto. + assert_eq!(stage_file_name("/workspace/a:b.txt").unwrap(), "a_b.txt"); + assert_eq!(stage_file_name("/workspace/q?.log").unwrap(), "q_.log"); + assert_eq!(stage_file_name("/workspace/a\\b").unwrap(), "a_b"); + // A trailing dot or space is not refused, it is silently dropped — so + // the path we return would not be the path that exists. + assert_eq!(stage_file_name("/workspace/trailing. ").unwrap(), "trailing"); + } + + #[test] + fn a_path_that_does_not_name_a_file_is_refused_not_invented() { + assert!(stage_file_name("/").is_err()); + assert!(stage_file_name("").is_err()); + assert!(stage_file_name("/workspace/..").is_err()); + assert!(stage_file_name("/workspace/.").is_err()); + // Trims down to nothing, which is the same problem one step later. + assert!(stage_file_name("/workspace/...").is_err()); + } + + #[test] + fn two_files_with_the_same_name_stage_to_different_places() { + // Names are unique per directory, not per container — and the second + // drag would otherwise rewrite the first one's bytes under the path the + // first one is still cached at. + assert_ne!( + drag_stage_slot("/workspace/a/notes.txt"), + drag_stage_slot("/workspace/b/notes.txt") + ); + } + + #[test] + fn re_staging_the_same_file_reuses_its_slot() { + // Deterministic, so a file dragged repeatedly does not grow a new + // directory in the host temp dir every time. + assert_eq!( + drag_stage_slot("/workspace/notes.txt"), + drag_stage_slot("/workspace/notes.txt") + ); + // Short enough to keep the path sane, long enough not to collide. + assert_eq!(drag_stage_slot("/workspace/notes.txt").len(), 16); + } + + #[test] + fn the_drag_size_cap_matches_the_established_ceiling_and_names_the_fallback() { + assert_eq!(MAX_DRAG_STAGE_BYTES, MAX_UPLOAD_BYTES); + assert!(check_stage_size(MAX_DRAG_STAGE_BYTES).is_ok()); + + let err = check_stage_size(MAX_DRAG_STAGE_BYTES + 1).unwrap_err(); + assert!(err.contains("256 MB"), "{}", err); + // A size cap with no way forward is the one thing this must not be. + assert!(err.contains("Save to host"), "{}", err); + } + + #[test] + fn the_reaper_only_takes_entries_past_the_age_threshold() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + let age = Duration::from_secs(3_600); + + assert!(drag_stage_is_stale(now - Duration::from_secs(3_601), now, age)); + assert!(drag_stage_is_stale(now - age, now, age)); + assert!(!drag_stage_is_stale(now - Duration::from_secs(3_599), now, age)); + assert!(!drag_stage_is_stale(now, now, age)); + } + + #[test] + fn a_future_timestamp_is_left_alone_rather_than_reaped() { + // A clock step must not turn housekeeping into deletion of something it + // cannot date. + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + let age = Duration::from_secs(3_600); + assert!(!drag_stage_is_stale(now + Duration::from_secs(60), now, age)); + } } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 9c7a064..9a92154 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -215,6 +215,11 @@ pub fn run() { .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) + // Drag a file from the Files tab onto the host desktop. The gesture is + // pointer-driven for the same reason the tab drag is (see MainTabs): + // `dragDropEnabled` is on for the terminal's sake and blocks HTML5 drag + // inside the webview, so this plugin's native drag is the only route out. + .plugin(tauri_plugin_drag::init()) .manage(AppState { projects_store, settings_store, @@ -250,13 +255,22 @@ pub fn run() { // an image open and the sweep will not force; pins are untagged // second so the images they were holding are dangling by the time // the sweep lists them; the sweep runs last and collects both. - tauri::async_runtime::spawn(async { + // + // Drag-out staging is swept here too, and it is the *other* half of + // a lifecycle whose first half is the exit cleanup below: a run that + // crashed never got to clear its staged copies, and those are whole + // files, not metadata. + let drag_temp_dir = app.path().temp_dir().ok(); + tauri::async_runtime::spawn(async move { crate::docker::reap_probe_containers().await; let reaped = crate::docker::reap_stale_migration_pins().await; if reaped > 0 { log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped); } crate::docker::sweep_orphaned_snapshots_logged("startup").await; + if let Some(temp_dir) = drag_temp_dir { + commands::file_commands::reap_drag_staging(temp_dir).await; + } }); // Auto-start web terminal server if enabled in settings @@ -383,6 +397,10 @@ pub fn run() { let _ = window.emit("app-shutting-down", ()); let app_handle = window.app_handle().clone(); + // Resolved here rather than inside the teardown, which is + // already under a wall-clock budget and should not spend any of + // it asking where the temp dir is. + let drag_temp_dir = app_handle.path().temp_dir().ok(); tauri::async_runtime::spawn(async move { let teardown = async { // First: let the auto-starts unwind. Anything they are @@ -409,10 +427,20 @@ pub fn run() { log::warn!("Failed to stop the model gateway on exit: {}", e); } }; + // Whole files copied out of containers for drag-out. + // Left behind they are a disk leak with a gesture + // attached; startup housekeeping is the backstop for a + // run that never reaches this point. + let clear_drag_staging = async { + if let Some(temp_dir) = drag_temp_dir { + commands::file_commands::clear_drag_staging(temp_dir).await; + } + }; tokio::join!( web_terminal, stop_stt, stop_gateway, + clear_drag_staging, exec_manager.close_all_sessions(), auth_bridge.stop_all(), browser_view::manager().stop_all(), @@ -502,6 +530,7 @@ pub fn run() { commands::file_commands::read_container_file, commands::file_commands::rename_container_path, commands::file_commands::create_container_directory, + commands::file_commands::stage_container_file_for_drag, // AWS commands::aws_commands::aws_sso_refresh, // Updates diff --git a/app/src/components/projects/home/FilesTab.test.tsx b/app/src/components/projects/home/FilesTab.test.tsx index 9470d20..560c01b 100644 --- a/app/src/components/projects/home/FilesTab.test.tsx +++ b/app/src/components/projects/home/FilesTab.test.tsx @@ -9,6 +9,7 @@ const uploadFileToContainer = vi.fn(async () => {}); const renameContainerPath = vi.fn(async () => ""); const createContainerDirectory = vi.fn(async () => ""); const readContainerFile = vi.fn(); +const stageContainerFileForDrag = vi.fn(async () => "/tmp/triple-c-drag-out/s1/notes.txt"); vi.mock("../../../lib/tauri-commands", () => ({ listContainerFiles: (p: string, path: string) => listContainerFiles(p, path), @@ -18,6 +19,13 @@ vi.mock("../../../lib/tauri-commands", () => ({ createContainerDirectory: (p: string, parent: string, n: string) => createContainerDirectory(p, parent, n), readContainerFile: (p: string, path: string, max?: number) => readContainerFile(p, path, max), + stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path), +})); + +/** The OS-level drag. Nothing in jsdom can start one, so it is only observed. */ +const startDrag = vi.fn(async () => {}); +vi.mock("@crabnebula/tauri-plugin-drag", () => ({ + startDrag: (opts: unknown) => startDrag(opts), })); const save = vi.fn(async () => "/host/out"); @@ -78,9 +86,32 @@ async function drop(paths: string[], position = { x: 100, y: 100 }) { }); } +/** + * A pointer event carrying real coordinates. + * + * jsdom implements no `PointerEvent` and Testing Library's synthesized one has + * no coordinates — which is the whole gesture here, since the drag only starts + * once the pointer has travelled past the threshold. `MouseEvent` has them, and + * React dispatches on the type name either way. + */ +function pointer(el: Element, type: string, clientX: number, clientY: number) { + fireEvent( + el, + new MouseEvent(type, { bubbles: true, cancelable: true, clientX, clientY, button: 0 }), + ); +} + +/** Press on a row and move far enough to become a drag, leaving the button down. */ +function dragRow(el: Element) { + pointer(el, "pointerdown", 10, 10); + pointer(el, "pointermove", 60, 10); +} + beforeEach(() => { vi.clearAllMocks(); dragHandler = null; + stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/notes.txt"); + startDrag.mockResolvedValue(undefined); listContainerFiles.mockResolvedValue([ entry("src", { is_directory: true, path: "/workspace/src" }), entry("notes.txt"), @@ -93,6 +124,10 @@ beforeEach(() => { // Not implemented in jsdom; the image preview needs both halves. URL.createObjectURL = vi.fn(() => "blob:mock-url"); URL.revokeObjectURL = vi.fn(); + // Nor is canvas, which the drag preview draws on. Stubbed to the null jsdom + // would return anyway, minus the "not implemented" noise on every drag — + // `dragPreview.test.ts` covers what the fallback then produces. + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); }); describe("FilesTab listing", () => { @@ -348,3 +383,120 @@ describe("FilesTab save to host", () => { expect(screen.queryByRole("button", { name: "Save src to host" })).toBeNull(); }); }); + +describe("FilesTab drag-out", () => { + it("stages the file on the host and starts the native drag on that copy", async () => { + // The container path is not draggable — only the host copy is — so the + // thing handed to the OS must be what staging returned. + await renderTab(); + dragRow(screen.getByText("notes.txt").closest("tr")!); + + await waitFor(() => expect(startDrag).toHaveBeenCalled()); + expect(stageContainerFileForDrag).toHaveBeenCalledWith("p1", "/workspace/notes.txt"); + expect(startDrag).toHaveBeenCalledWith( + expect.objectContaining({ item: ["/tmp/triple-c-drag-out/s1/notes.txt"] }), + ); + }); + + it("never drags a directory, which cannot be staged as one file", async () => { + await renderTab(); + dragRow(screen.getByText("src").closest("tr")!); + + await act(async () => { + await Promise.resolve(); + }); + expect(stageContainerFileForDrag).not.toHaveBeenCalled(); + expect(startDrag).not.toHaveBeenCalled(); + }); + + it("stays a click until the pointer has actually travelled", async () => { + await renderTab(); + const row = screen.getByText("notes.txt").closest("tr")!; + pointer(row, "pointerdown", 10, 10); + pointer(row, "pointermove", 12, 11); + + await act(async () => { + await Promise.resolve(); + }); + expect(stageContainerFileForDrag).not.toHaveBeenCalled(); + }); + + it("says what went wrong instead of leaving a gesture that did nothing", async () => { + stageContainerFileForDrag.mockRejectedValue( + '900 MB is too large to drag out (limit 256 MB) — use "Save to host…" instead.', + ); + await renderTab(); + dragRow(screen.getByText("notes.txt").closest("tr")!); + + await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("too large")); + expect(startDrag).not.toHaveBeenCalled(); + }); + + it("points at the fallback when the platform refuses the drag itself", async () => { + startDrag.mockRejectedValue("drag image not found"); + await renderTab(); + dragRow(screen.getByText("notes.txt").closest("tr")!); + + await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("Save to host")); + }); + + it("tells the user the copy is ready when the drag outlived the gesture", async () => { + // Staging is a whole-file copy, and the OS only adopts a drag while the + // button is down. Releasing mid-copy used to be — and must not be — a + // gesture that did nothing and explained nothing. + let release: (path: string) => void = () => {}; + stageContainerFileForDrag.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + await renderTab(); + const row = screen.getByText("notes.txt").closest("tr")!; + dragRow(row); + pointer(row, "pointerup", 60, 10); + + await act(async () => { + release("/tmp/triple-c-drag-out/s1/notes.txt"); + await Promise.resolve(); + }); + + await waitFor(() => expect(screen.getByText(/is ready/).textContent).toContain("notes.txt")); + expect(startDrag).not.toHaveBeenCalled(); + }); + + it("drags immediately on the retry, reusing the copy it already made", async () => { + // The instruction "drag it again" is only honest if the second attempt does + // not repeat the copy that made the first one too slow. + await renderTab(); + const row = screen.getByText("notes.txt").closest("tr")!; + + dragRow(row); + await waitFor(() => expect(startDrag).toHaveBeenCalledTimes(1)); + pointer(row, "pointerup", 60, 10); + + dragRow(row); + await waitFor(() => expect(startDrag).toHaveBeenCalledTimes(2)); + expect(stageContainerFileForDrag).toHaveBeenCalledTimes(1); + }); + + it("leaves Save to host… working — drag-out is the enhancement, not the replacement", async () => { + await renderTab(); + await act(async () => { + fireEvent.click(screen.getByLabelText("Save notes.txt to host")); + }); + expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "/host/out"); + expect(startDrag).not.toHaveBeenCalled(); + }); + + it("still accepts a drop into the pane — the two directions coexist", async () => { + // The drag-out gesture is pointer-driven precisely so it does not need the + // HTML5 machinery that Tauri's native drop listener rules out. + await renderTab(); + dragRow(screen.getByText("notes.txt").closest("tr")!); + await waitFor(() => expect(startDrag).toHaveBeenCalled()); + + await drop(["/host/a.txt"]); + expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.txt", "/workspace"); + }); +}); diff --git a/app/src/components/projects/home/FilesTab.tsx b/app/src/components/projects/home/FilesTab.tsx index 8c6c85b..241a81b 100644 --- a/app/src/components/projects/home/FilesTab.tsx +++ b/app/src/components/projects/home/FilesTab.tsx @@ -1,15 +1,23 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { getCurrentWebview } from "@tauri-apps/api/webview"; +import { startDrag } from "@crabnebula/tauri-plugin-drag"; import type { FileEntry, Project } from "../../../lib/types"; import { useFileManager } from "../../../hooks/useFileManager"; import Button from "../../ui/Button"; import FileViewerModal from "./FileViewerModal"; +import { dragPreviewIcon } from "./dragPreview"; import { formatBytes } from "./format"; interface Props { project: Project; } +/** + * How far the pointer must travel before a press becomes a drag. Same few + * pixels of slop as the tab strip, so a click that trembles stays a click. + */ +const DRAG_THRESHOLD = 4; + /** * The project's file manager. * @@ -32,8 +40,10 @@ export default function FilesTab({ project }: Props) { downloadFile, uploadFile, uploadPaths, + stageForDrag, renameEntry, createFolder, + setError, } = useFileManager(project.id); const running = project.status === "running"; @@ -47,6 +57,8 @@ export default function FilesTab({ project }: Props) { const [viewing, setViewing] = useState(null); /** A host drag is currently over this pane. */ const [dragOver, setDragOver] = useState(false); + /** Name of a file staged for drag-out whose gesture did not reach the OS. */ + const [dragNotice, setDragNotice] = useState(null); const paneRef = useRef(null); const renameInputRef = useRef(null); @@ -61,6 +73,7 @@ export default function FilesTab({ project }: Props) { useEffect(() => { setSelected(null); setRenaming(null); + setDragNotice(null); }, [currentPath]); useEffect(() => { @@ -119,6 +132,112 @@ export default function FilesTab({ project }: Props) { [navigate], ); + // Container → host drag-out. + // + // The mirror image of the drop path below, and it has the same constraint + // pushing it: `dragDropEnabled` blocks HTML5 drag inside the webview, so + // `draggable` + `DataTransfer` is not available and the gesture is driven + // from pointer events into the native drag plugin — exactly the shape the tab + // strip uses, and for the same reason. + // + // What makes it more than a pointer gesture is that the file being dragged + // does not exist on the host at all: it lives in the container, and the OS + // can only drag a real host path. So every drag-out is a copy first (see + // `stageForDrag`) and a drag second, which is why the gesture has an async + // gap in the middle of something that feels instantaneous. + const dragOut = useRef<{ + path: string; + x: number; + y: number; + down: boolean; + started: boolean; + } | null>(null); + + // Pointer-up almost never lands on the row it started on — the pointer has + // moved off it by definition, and once the OS takes the drag the webview stops + // seeing the pointer at all, which is what makes a lost focus the only + // "the button came up" signal left. + useEffect(() => { + const release = () => { + if (dragOut.current) dragOut.current.down = false; + }; + window.addEventListener("pointerup", release); + window.addEventListener("pointercancel", release); + window.addEventListener("blur", release); + return () => { + window.removeEventListener("pointerup", release); + window.removeEventListener("pointercancel", release); + window.removeEventListener("blur", release); + }; + }, []); + + const beginDragOut = useCallback( + async (entry: FileEntry) => { + setDragNotice(null); + const staged = await stageForDrag(entry); + // `stageForDrag` has already put the reason in `error`. + if (!staged) return; + + // The OS only adopts a drag while the button is still down, and the copy + // that just ran can easily outlast a flick of the wrist. Say so rather + // than leaving a gesture that did nothing and explained nothing — and it + // is a real instruction, not an apology: the copy is kept, so the second + // attempt starts immediately. + if (dragOut.current?.path !== entry.path || !dragOut.current.down) { + setDragNotice(entry.name); + return; + } + + try { + await startDrag({ item: [staged.hostPath], icon: dragPreviewIcon(entry.name) }); + } catch (e) { + // Drag-out is the enhancement; "Save to host…" is the path that always + // works, so a platform that refuses the drag says where to go instead. + setError(`Could not start the drag: ${e}. Use "Save to host…" instead.`); + } + }, + [stageForDrag, setError], + ); + + /** + * Pointer wiring for one row. Directories get none of it: staging copies a + * single regular file, and a folder would only ever produce an error. + */ + const dragOutProps = (entry: FileEntry) => { + if (entry.is_directory) return {}; + return { + onPointerDown: (e: React.PointerEvent) => { + if (e.button !== 0 || renaming === entry.name) return; + // The row's own controls, and the rename input, where a drag is a text + // selection. + if ((e.target as HTMLElement).closest("button, input")) return; + dragOut.current = { + path: entry.path, + x: e.clientX, + y: e.clientY, + down: true, + started: false, + }; + // Deliberately no `setPointerCapture` — unlike the tab strip, which + // draws its own ghost. Here the OS has to take the pointer over, and a + // capture held in the webview is exactly what stops it. + }, + onPointerMove: (e: React.PointerEvent) => { + const gesture = dragOut.current; + if (!gesture || gesture.started || !gesture.down) return; + if (gesture.path !== entry.path) return; + if ( + Math.abs(e.clientX - gesture.x) < DRAG_THRESHOLD && + Math.abs(e.clientY - gesture.y) < DRAG_THRESHOLD + ) { + return; + } + gesture.started = true; + void beginDragOut(entry); + }, + }; + }; + // Host → container drag and drop. // // This is Tauri's *native* drag-drop event, not HTML5 `ondrop`, for the same @@ -226,6 +345,11 @@ export default function FilesTab({ project }: Props) { {busy} )} + {!busy && dragNotice && ( + + "{dragNotice}" is ready — drag it again to drop it on the desktop. + + )} + + + Reads the whole Docker store; takes a few seconds on a large one. + + + + {error && ( +

+ {error} +

+ )} + + {!report && !scanning && ( +

+ Nothing has been measured yet. Scanning is the only thing here that costs + anything, so it is never done for you. +

+ )} + + {report && ( + <> + {/* --- Windows / WSL2, mandatory when it applies ----------------- */} + {report.host.vhdx_applies && ( +
+ +

+ {report.host.vhdx_note} +

+

+ To actually give the space back to C:, run these in PowerShell as + administrator after reclaiming: +

+
+                {report.host.vhdx_fix.join("\n")}
+              
+

+ Or, without Hyper-V: {report.host.vhdx_fix_gui}. +

+
+ )} + + {/* --- Per-project table ---------------------------------------- */} +
+

+ By project +

+ +
+ + {/* --- Globals --------------------------------------------------- */} +
+

+ Shared and left over +

+
+
+ Base images ({report.base_images.length}) — shared by every project +
+
+ {formatBytes(report.base_images_bytes)} +
+ +
+ Superseded images from past recreations ({report.orphan_image_count}) +
+
+ {formatBytes(report.orphan_image_bytes)} +
+ +
+ Volumes with no matching project in Triple-C ( + {report.orphan_volumes.length}) +
+
+ {formatBytes(report.orphan_volume_bytes)} +
+ +
+ Build cache — whole daemon, + not just Triple-C{" "} + + (via {report.build_cache.source}) + +
+
+ {formatBytes(report.build_cache.reclaimable_bytes)} of{" "} + {formatBytes(report.build_cache.total_bytes)} +
+ +
+ Attributable to Triple-C +
+
+ {formatBytes(report.triple_c_total_bytes)} +
+ +
+ Everything on this daemon, yours included +
+
+ {formatBytes( + report.images_total_bytes + + report.containers_total_bytes + + report.volumes_total_bytes, + )} +
+
+ {report.orphan_volumes.length > 0 && ( +

+ That last figure means only that the volume’s project id is not in + your project list — it is not inferred from a project + being stopped or having no image. A project you have not opened in a + while has no container and no snapshot either, and that is normal, so + each of these is ticked individually and shows the date Docker created + it. +

+ )} +

+ Docker stores this at{" "} + {report.host.docker_root_dir || "an unknown path"} + {report.host.is_docker_desktop && " — a path inside the Docker Desktop VM, not on your filesystem"}. +

+
+ + {/* --- Store failure, if any ------------------------------------ */} + {report.orphan_volumes_unavailable && ( +
+ +

+ {report.orphan_volumes_unavailable} +

+
+ )} + + {/* --- Safe reclaim ---------------------------------------------- */} +
+

+ Safe to reclaim +

+ {safeItems.length === 0 ? ( +

+ Nothing here — no leftovers were found. +

+ ) : ( + <> +

+ None of this is reachable any more, or all of it regenerates on demand. + Nothing you have made is in this list. +

+
    + {safeItems.map((item) => { + const key = targetKey(item.target); + return ( +
  • + +
  • + ); + })} +
+
+ + + {selected.length === 0 + ? "Nothing ticked." + : `${selected.length} selected, ${formatBytes(selectedBytes)}.`} + +
+ + )} +
+ + {/* --- Semi-safe -------------------------------------------------- */} + {semiItems.length > 0 && ( +
+

+ Worth doing, one at a time +

+

+ Nothing here loses anything you have installed. Compacting rewrites a + project’s stacked layers into one; clearing caches deletes files + that refill themselves. Both take a moment and both are confirmed + separately. +

+
    + {semiItems.map((item) => ( +
  • + + {item.label} + + {item.detail} + + {item.blocked && ( + + {item.blocked} + + )} + + + + {/* A bound, not a measurement — rendered through a + different helper so it cannot read as a promise. */} + {item.bytes_are_exact + ? formatBytes(item.bytes) + : formatBytesCeiling(item.bytes)} + + + +
  • + ))} +
+
+ )} + + {/* --- Sweep ------------------------------------------------------ */} +
+ + + The same sweep that runs at startup and after every recreation — here you + can see what it found. + +
+ + )} + + {/* --- Outcome ------------------------------------------------------- */} + {outcome && ( +
+ r.ok) ? "ok" : "error"} + label={`Reclaimed ${formatBytes(outcome.total_freed_bytes)}`} + className="text-xs" + /> +
    + {outcome.results.map((result, index) => ( +
  • + {result.message} + {result.projected_bytes !== null && ( + <> + {" "} + + (projected {formatBytesCeiling(result.projected_bytes)}, actually{" "} + {formatBytes(result.freed_bytes)}) + + + )} +
  • + ))} +
+
+ )} + + {/* --- Semi-safe confirmation ---------------------------------------- */} + {confirming && ( + setConfirming(null)} + widthClassName="w-[30rem]" + footer={ + <> + + + + } + > +
+

{confirming.detail}

+ {confirming.target.kind === "compact_snapshot" && ( + <> +

+ The snapshot is rebuilt into a single layer while the old one is left + in place, so a failure at any point leaves this project exactly as it + is now. +

+

+ How much comes back depends on how much of those layers a later one + already replaced — it could be{" "} + {formatBytesCeiling(confirming.bytes)}, and it could be nothing at all. + You will be told the real figure when it finishes. +

+

+ One thing worth knowing: the rewritten image no longer shares the base + image with your other projects, so it carries its own copy of it. That + cost is already subtracted from the figure above, and if the rewrite + turns out not to come out ahead it is thrown away and the snapshot is + left exactly as it is. +

+ + )} + {confirming.target.kind === "clear_caches" && + confirming.target.include_rustup && ( +

+ Rust toolchains are included in this one. They are regenerable, but + getting them back is a download rather than a rebuild. +

+ )} +
+
+ )} + + {/* --- Destructive confirmation --------------------------------------- */} + {destroying && ( + setDestroying(null)} + onConfirm={(typed) => { + const target = destroying.target; + setDestroying(null); + void destroy(target, typed); + }} + > +

+ This removes{" "} + + {destroying.project_name} + + ’s {destroying.label.toLowerCase()}, freeing{" "} + {formatBytes(destroying.bytes)}. +

+

{destroying.loses}

+

+ Your mounted project folders live on the host and are not affected by this. +

+
+ )} + + ); +} diff --git a/app/src/components/settings/SettingsPanel.tsx b/app/src/components/settings/SettingsPanel.tsx index 062a9f4..8dce472 100644 --- a/app/src/components/settings/SettingsPanel.tsx +++ b/app/src/components/settings/SettingsPanel.tsx @@ -19,6 +19,7 @@ import WebTerminalSettings from "./WebTerminalSettings"; import SttSettings from "./SttSettings"; import SharedAuthSettings from "./SharedAuthSettings"; import CertificateSettings from "./CertificateSettings"; +import DiskSettings from "./DiskSettings"; export default function SettingsPanel() { const { appSettings, saveSettings } = useSettings(); @@ -173,6 +174,10 @@ export default function SettingsPanel() { + + + + diff --git a/app/src/components/ui/TypedConfirmModal.test.tsx b/app/src/components/ui/TypedConfirmModal.test.tsx new file mode 100644 index 0000000..bacaec3 --- /dev/null +++ b/app/src/components/ui/TypedConfirmModal.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import TypedConfirmModal from "./TypedConfirmModal"; + +const onConfirm = vi.fn(); +const onCancel = vi.fn(); + +function renderModal(props: Partial> = {}) { + render( + +

Everything goes.

+
, + ); + return { + input: screen.getByLabelText(/Type/), + confirm: screen.getByRole("button", { name: "Delete config volume" }), + }; +} + +beforeEach(() => vi.clearAllMocks()); + +describe("TypedConfirmModal", () => { + it("is a real dialog, from the Modal primitive", () => { + renderModal(); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-modal", "true"); + }); + + it("keeps the confirm button shut until the name is typed exactly", () => { + const { input, confirm } = renderModal(); + expect(confirm).toBeDisabled(); + + fireEvent.change(input, { target: { value: "wh" } }); + expect(confirm).toBeDisabled(); + + fireEvent.change(input, { target: { value: "whp" } }); + expect(confirm).toBeEnabled(); + fireEvent.click(confirm); + expect(onConfirm).toHaveBeenCalledWith("whp"); + }); + + it("is case-sensitive, because Api and api are different projects", () => { + // This gate is the only thing between a misclick on a sorted table of + // numbers and a project's transcripts, so a near-miss is a miss. + const { input, confirm } = renderModal({ expected: "Api" }); + fireEvent.change(input, { target: { value: "api" } }); + expect(confirm).toBeDisabled(); + fireEvent.change(input, { target: { value: "Api" } }); + expect(confirm).toBeEnabled(); + }); + + it("forgives surrounding whitespace from a paste", () => { + const { input, confirm } = renderModal(); + fireEvent.change(input, { target: { value: " whp " } }); + expect(confirm).toBeEnabled(); + }); + + it("announces the gate's state in words rather than only by the button fill", () => { + const { input } = renderModal(); + expect(screen.getByRole("status")).toHaveTextContent( + "Waiting for the exact project name.", + ); + fireEvent.change(input, { target: { value: "whp" } }); + expect(screen.getByRole("status")).toHaveTextContent("Name matches."); + }); + + it("spells out what is lost, from the caller's copy", () => { + renderModal(); + expect(screen.getByText("Everything goes.")).toBeInTheDocument(); + }); + + it("locks itself while the deletion is running", () => { + render( + +

Everything goes.

+
, + ); + // The confirm button reports the work in a word rather than only going + // grey, so it is found by its busy label, not its idle one. + expect(screen.getByLabelText(/Type/)).toBeDisabled(); + expect(screen.getByRole("button", { name: "Working…" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); + }); + + it("cancels without confirming", () => { + renderModal(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onCancel).toHaveBeenCalled(); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it("cannot be satisfied by an empty box when there is no name to type", () => { + const { confirm } = renderModal({ expected: "" }); + expect(confirm).toBeDisabled(); + }); +}); diff --git a/app/src/components/ui/TypedConfirmModal.tsx b/app/src/components/ui/TypedConfirmModal.tsx new file mode 100644 index 0000000..5a78278 --- /dev/null +++ b/app/src/components/ui/TypedConfirmModal.tsx @@ -0,0 +1,113 @@ +import { useRef, useState, type ReactNode } from "react"; +import Modal from "./Modal"; +import Button from "./Button"; +import { inputClass } from "./Field"; + +interface Props { + title: string; + /** What must be typed, verbatim, before the confirm button enables. */ + expected: string; + /** The verb on the confirm button. Repeat the action — never "OK". */ + confirmLabel: string; + /** What is about to be lost, in full. */ + children: ReactNode; + onConfirm: (typed: string) => void; + onCancel: () => void; + busy?: boolean; +} + +/** + * The confirmation gate for something that has no other copy. + * + * ## Why this exists when `ConfirmResetModal` already did + * + * Reset and Remove are reached from a project's own overflow menu, one project + * at a time, by a user who went looking for them. The Disk panel lists every + * project's volumes side by side in a table of numbers, sorted by size — which + * is exactly the layout that invites a misclick on the wrong row. A two-button + * dialog does not survive that, because the thing being confirmed (*which* + * project) is the thing the user got wrong. + * + * Typing the name fixes the failure mode rather than adding friction to it: the + * gate is not "are you sure", it is "name the project you mean". + * + * The comparison is `expected.trim() === typed.trim()` and **case-sensitive** — + * mirroring `confirmation_matches` in `docker/disk.rs`, which is the check that + * actually holds, since this one is only a UI affordance. The backend refuses a + * mismatch on its own. + */ +export default function TypedConfirmModal({ + title, + expected, + confirmLabel, + children, + onConfirm, + onCancel, + busy = false, +}: Props) { + const [typed, setTyped] = useState(""); + const inputRef = useRef(null); + const matches = expected.trim().length > 0 && typed.trim() === expected.trim(); + + return ( + + + + + } + > +
+ {children} +
+ + setTyped(e.target.value)} + disabled={busy} + autoComplete="off" + spellCheck={false} + className={`${inputClass} font-mono`} + /> + {/* Announced rather than only coloured — the gate's state has to be + readable without relying on the button's fill. */} +

+ {matches ? ( + Name matches. + ) : ( + + Waiting for the exact project name. + + )} +

+
+
+
+ ); +} diff --git a/app/src/hooks/useDiskUsage.test.tsx b/app/src/hooks/useDiskUsage.test.tsx new file mode 100644 index 0000000..0c21429 --- /dev/null +++ b/app/src/hooks/useDiskUsage.test.tsx @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useDiskUsage } from "./useDiskUsage"; +import type { DiskUsageReport } from "../lib/types"; + +const getDockerDiskUsage = vi.fn(); +const listReclaimable = vi.fn(); +const reclaim = vi.fn(); +const destroyProjectDiskObject = vi.fn(); + +vi.mock("../lib/tauri-commands", () => ({ + getDockerDiskUsage: () => getDockerDiskUsage(), + listReclaimable: (report: DiskUsageReport) => listReclaimable(report), + reclaim: (targets: unknown) => reclaim(targets), + destroyProjectDiskObject: (target: unknown, confirmation: string) => + destroyProjectDiskObject(target, confirmation), + sweepOrphanedSnapshots: vi.fn(), +})); + +const report = (scanned_at: string): DiskUsageReport => + ({ scanned_at, projects: [] }) as unknown as DiskUsageReport; + +const plan = { items: [], destructive: [], store_error: null }; + +beforeEach(() => { + vi.clearAllMocks(); + listReclaimable.mockResolvedValue(plan); + reclaim.mockResolvedValue({ results: [], total_freed_bytes: 0 }); +}); + +describe("useDiskUsage", () => { + it("holds no report until a scan is asked for", () => { + const { result } = renderHook(() => useDiskUsage()); + expect(result.current.report).toBeNull(); + expect(result.current.plan).toBeNull(); + expect(getDockerDiskUsage).not.toHaveBeenCalled(); + }); + + it("scans, then plans off the same report rather than scanning again", async () => { + getDockerDiskUsage.mockResolvedValue(report("first")); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.scan(); + }); + expect(getDockerDiskUsage).toHaveBeenCalledTimes(1); + expect(listReclaimable).toHaveBeenCalledWith(report("first")); + expect(result.current.report?.scanned_at).toBe("first"); + expect(result.current.plan).toEqual(plan); + }); + + it("lets the newest scan win when two are in flight", async () => { + // A user pressing Scan twice can have two `df()` calls outstanding, and + // the second is not necessarily the slower one. A stale response must not + // overwrite a fresher one. + let resolveFirst: (value: DiskUsageReport) => void = () => {}; + getDockerDiskUsage + .mockReturnValueOnce( + new Promise((r) => { + resolveFirst = r; + }), + ) + .mockResolvedValueOnce(report("second")); + + const { result } = renderHook(() => useDiskUsage()); + let firstScan: Promise = Promise.resolve(); + act(() => { + firstScan = result.current.scan(); + }); + await act(async () => { + await result.current.scan(); + }); + expect(result.current.report?.scanned_at).toBe("second"); + + // The slow first scan lands afterwards and is discarded. + await act(async () => { + resolveFirst(report("first")); + await firstScan; + }); + expect(result.current.report?.scanned_at).toBe("second"); + expect(result.current.scanning).toBe(false); + }); + + it("passes the ticked targets straight through", async () => { + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.runReclaim([ + { kind: "dangling_snapshots" }, + { kind: "build_cache", all: false }, + ]); + }); + expect(reclaim).toHaveBeenCalledWith([ + { kind: "dangling_snapshots" }, + { kind: "build_cache", all: false }, + ]); + }); + + it("does not call the backend for an empty selection", async () => { + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.runReclaim([]); + }); + expect(reclaim).not.toHaveBeenCalled(); + }); + + it("does not re-scan after a reclaim", async () => { + // Another `df()` costs seconds, and the outcome already carries measured + // bytes for every target. A user who wants fresh totals asks for them. + getDockerDiskUsage.mockResolvedValue(report("first")); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.scan(); + }); + await act(async () => { + await result.current.runReclaim([{ kind: "dangling_snapshots" }]); + }); + expect(getDockerDiskUsage).toHaveBeenCalledTimes(1); + }); + + it("clears the previous outcome when a new scan starts", async () => { + getDockerDiskUsage.mockResolvedValue(report("first")); + reclaim.mockResolvedValue({ results: [], total_freed_bytes: 42 }); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.runReclaim([{ kind: "dangling_snapshots" }]); + }); + expect(result.current.outcome?.total_freed_bytes).toBe(42); + await act(async () => { + await result.current.scan(); + }); + expect(result.current.outcome).toBeNull(); + }); + + it("forwards the typed confirmation verbatim", async () => { + destroyProjectDiskObject.mockResolvedValue({ + target: { kind: "dangling_snapshots" }, + ok: true, + freed_bytes: 100, + projected_bytes: null, + message: "gone", + }); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.destroy({ kind: "config_volume", project_id: "p1" }, "whp"); + }); + expect(destroyProjectDiskObject).toHaveBeenCalledWith( + { kind: "config_volume", project_id: "p1" }, + "whp", + ); + expect(result.current.outcome?.total_freed_bytes).toBe(100); + }); + + it("surfaces a failure rather than leaving a stale report on screen", async () => { + getDockerDiskUsage.mockRejectedValue("daemon unreachable"); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.scan(); + }); + await waitFor(() => expect(result.current.error).toMatch(/daemon unreachable/)); + expect(result.current.scanning).toBe(false); + }); +}); diff --git a/app/src/hooks/useDiskUsage.ts b/app/src/hooks/useDiskUsage.ts new file mode 100644 index 0000000..e3bf786 --- /dev/null +++ b/app/src/hooks/useDiskUsage.ts @@ -0,0 +1,114 @@ +import { useCallback, useRef, useState } from "react"; +import * as commands from "../lib/tauri-commands"; +import type { + DestructiveTarget, + DiskUsageReport, + ReclaimOutcome, + ReclaimPlan, + ReclaimTarget, +} from "../lib/types"; + +/** + * State for the Disk section. + * + * ## Why nothing here runs on mount + * + * A scan is `GET /system/df`, which walks every image, container and volume on + * the daemon and computes shared-layer sizes. On a 100 GB store that is + * seconds. `AccordionSection` unmounts its body when collapsed, so a + * `useEffect` scan would re-run every single time the user opened the section. + * The scan is therefore a `scan()` the Scan button calls and nothing else, and + * the result lives in this hook rather than in the component so that reopening + * the section shows the last result instead of paying again. + * + * ## The generation guard + * + * A user who hits Scan twice can have two `df()` calls in flight, and they can + * land out of order — the second one is not necessarily slower. Every async + * write checks it is still the newest before it lands, the same pattern + * `useContainerMigration` uses. + */ +export interface DiskUsageState { + report: DiskUsageReport | null; + plan: ReclaimPlan | null; + /** A scan is in flight. */ + scanning: boolean; + /** A reclaim or a destroy is in flight. */ + working: boolean; + error: string | null; + /** The outcome of the last reclaim, kept on screen until the next scan. */ + outcome: ReclaimOutcome | null; + scan: () => Promise; + runReclaim: (targets: ReclaimTarget[]) => Promise; + destroy: (target: DestructiveTarget, confirmation: string) => Promise; + clearOutcome: () => void; +} + +export function useDiskUsage(): DiskUsageState { + const [report, setReport] = useState(null); + const [plan, setPlan] = useState(null); + const [scanning, setScanning] = useState(false); + const [working, setWorking] = useState(false); + const [error, setError] = useState(null); + const [outcome, setOutcome] = useState(null); + const generation = useRef(0); + + const scan = useCallback(async () => { + const mine = ++generation.current; + setScanning(true); + setError(null); + // The previous outcome describes a state that no longer holds once a new + // scan starts, so it goes rather than sitting beside fresh numbers. + setOutcome(null); + try { + const next = await commands.getDockerDiskUsage(); + if (generation.current !== mine) return; + setReport(next); + // Planning is cheap and always wanted: the classification is what makes + // the numbers actionable, and it reuses the report rather than scanning + // again. + const nextPlan = await commands.listReclaimable(next); + if (generation.current !== mine) return; + setPlan(nextPlan); + } catch (e) { + if (generation.current !== mine) return; + setError(String(e)); + } finally { + if (generation.current === mine) setScanning(false); + } + }, []); + + const runReclaim = useCallback(async (targets: ReclaimTarget[]) => { + if (targets.length === 0) return; + setWorking(true); + setError(null); + try { + const result = await commands.reclaim(targets); + setOutcome(result); + // Deliberately no automatic re-scan. It costs another `df()`, and the + // outcome already reports measured bytes for every target — a user who + // wants the new totals asks for them. + } catch (e) { + setError(String(e)); + } finally { + setWorking(false); + } + }, []); + + const destroy = useCallback(async (target: DestructiveTarget, confirmation: string) => { + setWorking(true); + setError(null); + try { + const result = await commands.destroyProjectDiskObject(target, confirmation); + setOutcome({ results: [result], total_freed_bytes: result.freed_bytes }); + } catch (e) { + setError(String(e)); + } finally { + setWorking(false); + } + }, []); + + const clearOutcome = useCallback(() => setOutcome(null), []); + + return { report, plan, scanning, working, error, outcome, scan, runReclaim, destroy, clearOutcome }; +} diff --git a/app/src/lib/formatBytes.test.ts b/app/src/lib/formatBytes.test.ts new file mode 100644 index 0000000..f832838 --- /dev/null +++ b/app/src/lib/formatBytes.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { formatBytes, formatBytesCeiling, formatBytesDelta } from "./formatBytes"; + +describe("formatBytes", () => { + it("defaults to base 1000, because that is what Docker prints", () => { + // The Disk panel exists to explain `docker system df`, which formats with + // `units.HumanSize` — base 1000. Showing 26.1 GB against a terminal saying + // 28.0 GB for the same build cache reads as a bug in the panel. + expect(formatBytes(28_000_000_000)).toBe("28.0 GB"); + expect(formatBytes(1_000)).toBe("1.0 KB"); + expect(formatBytes(1_500_000)).toBe("1.5 MB"); + expect(formatBytes(12_273_392_374)).toBe("12.3 GB"); + }); + + it("leaves whole bytes without a decimal point", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(999)).toBe("999 B"); + }); + + it("reproduces the Project Home convention exactly under `binary`", () => { + // Three modules import `projects/home/format.ts#formatBytes`, which is now + // this function. Its output had to be byte-identical or re-pointing it + // would have quietly changed every file listing in the app. + expect(formatBytes(1023, { binary: true })).toBe("1023 B"); + expect(formatBytes(1024, { binary: true })).toBe("1.0 KB"); + expect(formatBytes(1024 * 1024, { binary: true })).toBe("1.0 MB"); + expect(formatBytes(1024 * 1024 * 1024, { binary: true })).toBe("1.0 GB"); + expect(formatBytes(1_610_612_736, { binary: true })).toBe("1.5 GB"); + }); + + it("reproduces the migration convention exactly by default", () => { + // `migrationCopy.formatDataSize` is now a call to this, and its output is + // asserted in MigrateContainerModal.test.tsx. + expect(formatBytes(41_000_000)).toBe("41.0 MB"); + expect(formatBytes(3_800_000_000)).toBe("3.8 GB"); + }); + + it("labels binary units honestly when asked to", () => { + expect(formatBytes(1024, { binary: true, iec: true })).toBe("1.0 KiB"); + expect(formatBytes(1024 ** 3, { binary: true, iec: true })).toBe("1.0 GiB"); + }); + + it("climbs to TB rather than showing five-digit gigabytes", () => { + expect(formatBytes(2_500_000_000_000)).toBe("2.5 TB"); + }); + + it("renders an em dash for a size the daemon did not compute", () => { + // Docker reports -1 for "not calculated" on shared sizes and volume ref + // counts. `NaN GB` in the middle of a table is worse than nothing. + expect(formatBytes(-1)).toBe("—"); + expect(formatBytes(NaN)).toBe("—"); + expect(formatBytes(Infinity)).toBe("—"); + }); + + it("honours a requested precision", () => { + expect(formatBytes(1_234_567_890, { precision: 2 })).toBe("1.23 GB"); + expect(formatBytes(1_234_567_890, { precision: 0 })).toBe("1 GB"); + }); +}); + +describe("formatBytesDelta", () => { + it("signs a figure that is being added rather than measured", () => { + // "Next commit adds +868.0 MB" — the sign is what makes it read as a cost + // about to be incurred rather than a size already on disk. + expect(formatBytesDelta(868_000_000)).toBe("+868.0 MB"); + expect(formatBytesDelta(0)).toBe("+0 B"); + }); + + it("does not sign an unknown", () => { + expect(formatBytesDelta(-1)).toBe("—"); + }); +}); + +describe("formatBytesCeiling", () => { + it("says 'up to', because a compaction's yield is a bound not a promise", () => { + // Every other figure in the Disk panel is measured. This one cannot be + // known until the rewrite runs, and rendering it through a separate + // function is what stops it being read as a guarantee. + expect(formatBytesCeiling(5_100_000_000)).toBe("up to 5.1 GB"); + }); + + it("refuses to imply a saving when there is no bound to give", () => { + expect(formatBytesCeiling(0)).toBe("an unknown amount"); + expect(formatBytesCeiling(-1)).toBe("an unknown amount"); + }); +}); diff --git a/app/src/lib/formatBytes.ts b/app/src/lib/formatBytes.ts new file mode 100644 index 0000000..6c2b0a1 --- /dev/null +++ b/app/src/lib/formatBytes.ts @@ -0,0 +1,86 @@ +/** + * The one byte formatter. + * + * Before this existed the app had four of them — `projects/home/format.ts`, + * `projects/migrationCopy.ts`, `settings/UpdateDialog.tsx` and an inline + * `toFixed(1)` in `useProjectActions.ts` — disagreeing about the divisor, the + * unit labels and the precision. They are now expressed in terms of this. + * + * ## Why the default is base 1000 + * + * The Disk panel exists to explain what `docker system df` reports, and Docker + * formats every size it prints with `units.HumanSize`, which is **base 1000**. + * A panel that showed 26.1 GB where the user's terminal said 28.0 GB for the + * same build cache would read as a bug in the panel. So decimal is the default + * and binary is opt-in, rather than the other way round. + * + * Both existing conventions are preserved exactly, so re-pointing the old + * call sites changed no rendered string: + * + * - `{ }` → `41.0 MB` (decimal, what migration used) + * - `{ binary: true }` → `1.5 GB` (÷1024 with decimal-style + * labels, what Project Home used + * — technically a misnomer, but + * it is the app's convention and + * changing it is not this + * feature's business) + * - `{ binary: true, iec: true }` → `1.5 GiB` (÷1024 labelled honestly) + */ + +const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"]; +const IEC_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + +export interface FormatBytesOptions { + /** Divide by 1024 instead of 1000. */ + binary?: boolean; + /** Label binary units as `KiB`/`MiB`/`GiB` rather than `KB`/`MB`/`GB`. */ + iec?: boolean; + /** Decimal places above `B`. Bytes are always whole. */ + precision?: number; +} + +export function formatBytes(bytes: number, options: FormatBytesOptions = {}): string { + const { binary = false, iec = false, precision = 1 } = options; + + // A negative or non-finite size is a bug upstream, not something to render as + // `NaN GB` in the middle of a table. Docker reports -1 for "not computed", + // and that is the case this actually catches. + if (!Number.isFinite(bytes) || bytes < 0) return "—"; + + const step = binary ? 1024 : 1000; + const units = binary && iec ? IEC_UNITS : DECIMAL_UNITS; + + let value = bytes; + let unit = 0; + while (value >= step && unit < units.length - 1) { + value /= step; + unit += 1; + } + // Whole bytes never get a decimal point: `512 B`, not `512.0 B`. + return unit === 0 + ? `${Math.round(bytes)} ${units[0]}` + : `${value.toFixed(precision)} ${units[unit]}`; +} + +/** + * `12.3 GB` → `+12.3 GB`, for a figure that is being *added* rather than + * measured. Used for "next commit adds …", which is the number that explains + * why a snapshot grows. + */ +export function formatBytesDelta(bytes: number, options?: FormatBytesOptions): string { + const formatted = formatBytes(bytes, options); + return formatted === "—" ? formatted : `+${formatted}`; +} + +/** + * `up to 12.3 GB` / `nothing` — for a bound rather than a measurement. + * + * The Disk panel is careful about this distinction: every figure it shows is + * measured except a compaction's yield, which cannot be known until it runs. + * Rendering that one through a different function is what stops it being read + * as a promise. + */ +export function formatBytesCeiling(bytes: number, options?: FormatBytesOptions): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "an unknown amount"; + return `up to ${formatBytes(bytes, options)}`; +} diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 27010f4..1a61bf3 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, 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"; +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, DiskUsageReport, ReclaimPlan, ReclaimTarget, ReclaimOutcome, ReclaimResult, DestructiveTarget, SnapshotSweepReport } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -349,3 +349,34 @@ export const rollbackMigration = (projectId: string) => * app crash shows up here as phase "interrupted". */ export const getMigrationState = (projectId: string) => invoke("get_migration_state", { projectId }); + +// Disk + +/** Measure where the daemon's bytes have gone. + * + * **Expensive — keep it behind an explicit Scan button.** This is + * `GET /system/df`, which walks every image, container and volume on the + * daemon to compute shared-layer sizes, plus an `image_history` per image. + * Seconds on a 100 GB store. Never call it on mount and never poll it. */ +export const getDockerDiskUsage = () => invoke("get_docker_disk_usage"); + +/** Classify what could be reclaimed, with measured bytes. Takes the report + * from `getDockerDiskUsage` so re-planning costs no second scan. */ +export const listReclaimable = (report: DiskUsageReport) => + invoke("list_reclaimable", { report }); + +/** Run the ticked targets. `ReclaimTarget` cannot name a destructive action, + * so no selection built here can delete a live project's data. */ +export const reclaim = (targets: ReclaimTarget[]) => + invoke("reclaim", { targets }); + +/** Delete one object that has no other copy. `confirmation` must be the + * project's name, typed by the user. One target per call, never bulk. */ +export const destroyProjectDiskObject = (target: DestructiveTarget, confirmation: string) => + invoke("destroy_project_disk_object", { target, confirmation }); + +/** Run the orphaned-snapshot sweep on demand and see its report — the same + * sweep that runs at startup and after every recreation, whose result every + * existing caller throws away. */ +export const sweepOrphanedSnapshots = () => + invoke("sweep_orphaned_snapshots"); diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 59813b7..5af5033 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -817,3 +817,198 @@ export interface MigrationState { options: MigrationOptions; plan: MigrationPlan | null; } + +// --------------------------------------------------------------------------- +// Disk +// --------------------------------------------------------------------------- +// +// Mirrors `app/src-tauri/src/docker/disk.rs`. Plain snake_case, like every +// other IPC struct in this app. + +/** One row of the per-project disk table. */ +export interface ProjectDiskRow { + project_id: string; + project_name: string; + snapshot_image: string; + snapshot_exists: boolean; + /** Total size of the snapshot image, base image included. */ + snapshot_bytes: number; + /** Bytes shared with another image — almost always the base. */ + snapshot_shared_bytes: number; + /** Layers stacked above the base image: **one per container recreation**. + * This is the number that explains why a snapshot grows. */ + snapshot_commit_layers: number; + /** Bytes those layers account for. `null` when the base image is gone and + * the split cannot be measured — never a guess. */ + snapshot_above_base_bytes: number | null; + container_exists: boolean; + container_running: boolean; + /** The writable layer, i.e. exactly what the next commit will add. */ + container_writable_bytes: number; + home_volume_bytes: number; + home_volume_present: boolean; + config_volume_bytes: number; + config_volume_present: boolean; + total_bytes: number; + migrating: boolean; +} + +export interface BaseImageRow { + reference: string; + bytes: number; + shared_bytes: number; + containers: number; + is_labelled_base: boolean; +} + +/** Where the daemon keeps its bytes, and the Windows/WSL2 caveat if it applies. + * The vhdx copy comes from Rust so the wording cannot drift from the + * constants its tests pin. */ +export interface HostStorage { + docker_root_dir: string; + operating_system: string; + is_docker_desktop: boolean; + is_windows_host: boolean; + vhdx_applies: boolean; + /** Empty unless `vhdx_applies`. */ + vhdx_note: string; + vhdx_fix: string[]; + vhdx_fix_gui: string; +} + +export interface BuildCacheUsage { + total_bytes: number; + reclaimable_bytes: number; + /** What a `--filter until=168h` prune would reach. */ + stale_bytes: number; + /** `"buildx du"` or `"system df"` — `docker system df` under-reports build + * cache, so which one produced the number is worth showing. */ + source: string; + cli_error: string | null; +} + +/** A per-project volume whose project id is not in Triple-C's project store. + * + * **Not "a volume with no container".** From the daemon's side an idle live + * project and a deleted one look identical — volumes present, no container, + * nothing running — so only the project store can tell them apart. */ +export interface OrphanVolume { + name: string; + project_id: string; + bytes: number; + /** `"home"` or `"config"`. */ + role: string; + /** When Docker created it. Evidence a user can recognise a project by; a + * size and a UUID identify nothing. From `df()` metadata — volumes are + * never mounted to inspect them, because `docker run -v` *creates* a + * volume that does not exist. */ + created_at: string | null; +} + +/** The result of one Scan. Expensive to produce — see `getDockerDiskUsage`. */ +export interface DiskUsageReport { + scanned_at: string; + projects: ProjectDiskRow[]; + base_images: BaseImageRow[]; + base_images_bytes: number; + orphan_image_bytes: number; + orphan_image_count: number; + orphan_volumes: OrphanVolume[]; + orphan_volume_bytes: number; + /** Why orphan detection was suppressed, when it was. */ + orphan_volumes_unavailable: string | null; + build_cache: BuildCacheUsage; + images_total_bytes: number; + containers_total_bytes: number; + volumes_total_bytes: number; + triple_c_total_bytes: number; + host: HostStorage; +} + +/** Mirrors Rust `Safety` (serde snake_case). */ +export type ReclaimSafety = "safe" | "semi_safe"; + +/** Mirrors Rust `ReclaimTarget`, an internally tagged enum. + * + * This type **cannot express a destructive action** — that is + * `DestructiveTarget`, and the Rust `reclaim` command cannot be handed one. + * The separation is structural on both sides on purpose. */ +export type ReclaimTarget = + | { kind: "dangling_snapshots" } + | { kind: "superseded_base_images" } + | { kind: "build_cache"; all: boolean } + | { kind: "migration_pins" } + | { kind: "migration_staging" } + | { kind: "probe_containers" } + | { kind: "scrub_containers" } + | { kind: "orphan_volume"; name: string } + | { kind: "compact_snapshot"; project_id: string } + | { kind: "clear_caches"; project_id: string; include_rustup: boolean }; + +/** Mirrors Rust `DestructiveTarget`. Every one of these deletes something with + * no other copy, and needs the project's name typed to confirm. */ +export type DestructiveTarget = + | { kind: "home_volume"; project_id: string } + | { kind: "config_volume"; project_id: string } + | { kind: "snapshot_image"; project_id: string } + | { kind: "rollback_pin"; project_id: string; tag: string }; + +export interface ReclaimItem { + target: ReclaimTarget; + safety: ReclaimSafety; + /** Reaches beyond Triple-C's own objects — true only for the build cache, + * and the UI must say so. */ + daemon_wide: boolean; + label: string; + detail: string; + bytes: number; + /** `false` means `bytes` is a bound, not a measurement. Render it as + * "up to …" — only snapshot compaction sets this. */ + bytes_are_exact: boolean; + bytes_floor: number | null; + /** Why this cannot run right now. */ + blocked: string | null; +} + +export interface DestructiveItem { + target: DestructiveTarget; + project_id: string; + project_name: string; + label: string; + /** Spelled out in full — this is the confirmation copy. */ + loses: string; + bytes: number; + blocked: string | null; +} + +export interface ReclaimPlan { + items: ReclaimItem[]; + /** Display only. `reclaim` cannot act on these. */ + destructive: DestructiveItem[]; + store_error: string | null; +} + +export interface ReclaimResult { + target: ReclaimTarget; + ok: boolean; + freed_bytes: number; + /** What was projected beforehand, for the one action that projects. */ + projected_bytes: number | null; + message: string; +} + +export interface ReclaimOutcome { + results: ReclaimResult[]; + total_freed_bytes: number; +} + +/** Mirrors Rust `SnapshotSweepReport`. Note `failed` is a list of + * `[reference, error]` pairs — a Rust tuple serialises as an array. */ +export interface SnapshotSweepReport { + removed: string[]; + reclaimed_bytes: number; + /** Refused because a container is still built from them. Normal. */ + in_use: number; + failed: [string, string][]; + unavailable: string | null; +} -- 2.52.0 From 611f67cca746a907fc964247b8cf20dbf4e295e8 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 23 Aug 2026 09:51:09 -0700 Subject: [PATCH 09/43] Fix what review found in the Disk section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Safety: - `destroy`'s rollback-pin arm took a tag over IPC and interpolated it straight into an image reference it then removed. `tag: "latest"` named the project's live snapshot, deleted under a dialog saying "rollback pin". It is the one destructive variant carrying a free-form string, so it now goes through `parse_rollback_tag`. - The compaction's scratch container was named `triple-c-scrub-*`, which is what the scrub reclaim bucket hunts and force-removes. A reclaim from a second window would have destroyed the container a running compaction was about to commit. It gets `triple-c-compact-*`, swept at the start of the next compaction rather than from a bucket anything else can fire. - Deleting a home or config volume only refused a *running* container, but a stopped one still pins its volumes — the resting state of every project ever started — so the user typed the project name and met a raw 409. The container is now removed first and `loses` says so. Correctness: - The compaction Dockerfile emitted no `LABEL`, so the flattened intermediate could never match the sweep's `dangling` + `triple-c.managed` filter that three cleanup paths rely on. Verified on Docker 29.7.2 that the label lands on the final stage, the build still yields one layer, and untagging the staging tag after the commit leaves the committed snapshot intact and startable. - `snapshot_commit_layers` silently meant something else when `triple-c.base-image-id` was absent — the normal case for a pre-label project — counting the base's own layers and letting a never-recreated project qualify for compaction. `base_lineage_known` now carries that, the column says "unknown", and the plan does not offer the rewrite. - `destroy` returned a `ReclaimResult` wearing a `ReclaimTarget` that named work it had not done (a home-volume deletion came back as `OrphanVolume`). Split into `target` / `destroyed`, exactly one set. - `formatBytes` ran `toFixed` after the divide loop, so 999,999 rendered as "1000.0 KB" — in the app's only byte formatter, in a panel full of near-boundary sizes. - `is_base_image_reference` split on the first colon, so a registry port ate the repo name. UI: - `snapshot_above_base_bytes: null` — deliberately unmeasurable — rendered as "0 B", the one guessed number in the table. - Layer count was flagged by colour alone; it now says "stacked". - The tick list survived a reclaim, so the same call could be re-fired at objects that no longer existed. The plan is dropped after any action and the panel says the totals predate it. - `setReport` landed before the plan call was awaited, so a plan failure rendered fresh totals above the previous scan's rows. - Both confirmation modals unmounted before awaiting, making the entire busy path dead code during multi-second work. - `buildx du` failures silently showed `docker system df`'s under-reported build-cache figure with no explanation. - Tooltip text reached no assistive tech, so two headers announced as "Help"; hardcoded input id; error-toned glyph in warning-toned panels; `sweepOrphanedSnapshots` and `clearOutcome` had no callers. - Four docstrings claimed things the code did not do, and two tests were named for behaviour they did not assert. Tests: 513 frontend (was 502), 370 Rust (was 365). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc --- app/src-tauri/src/docker/disk.rs | 229 +++++++++++++++--- app/src-tauri/src/docker/disk_tests.rs | 115 +++++++++ .../components/settings/DiskProjectTable.tsx | 56 +++-- .../components/settings/DiskSettings.test.tsx | 131 +++++++++- app/src/components/settings/DiskSettings.tsx | 109 ++++++--- app/src/components/ui/TypedConfirmModal.tsx | 9 +- app/src/hooks/useDiskUsage.test.tsx | 89 ++++++- app/src/hooks/useDiskUsage.ts | 100 +++++++- app/src/lib/formatBytes.test.ts | 18 ++ app/src/lib/formatBytes.ts | 29 ++- app/src/lib/types.ts | 14 +- 11 files changed, 799 insertions(+), 100 deletions(-) diff --git a/app/src-tauri/src/docker/disk.rs b/app/src-tauri/src/docker/disk.rs index ef0d818..43d7b65 100644 --- a/app/src-tauri/src/docker/disk.rs +++ b/app/src-tauri/src/docker/disk.rs @@ -97,7 +97,19 @@ pub struct ProjectDiskRow { pub snapshot_shared_bytes: i64, /// How many layers the snapshot has stacked **above its base image**. This /// is the number that explains the growth: one per recreation. + /// + /// Only means that when [`Self::base_lineage_known`] is true. Otherwise it + /// is every layer carrying bytes, base included — an upper bound, and a + /// misleading one to present as a recreation count. pub snapshot_commit_layers: u32, + /// Whether the base image this snapshot descends from could be identified. + /// + /// False when `triple-c.base-image-id` is absent, which is the **normal** + /// case for a project created before that label existed. The UI must not + /// present `snapshot_commit_layers` as a recreation count in that state, + /// and compaction is not offered, because a never-recreated project would + /// otherwise report its base's ~15 layers and qualify. + pub base_lineage_known: bool, /// Bytes those stacked layers account for. `None` when the base image the /// snapshot descends from is no longer on the daemon, so the split cannot /// be measured and must not be guessed. @@ -422,7 +434,18 @@ pub struct ReclaimOutcome { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ReclaimResult { - pub target: ReclaimTarget, + /// The reclaim target this reports on, or `None` when it reports a + /// [`destroy`]. + /// + /// Deliberately not reused to carry a destructive action: an earlier + /// version returned `OrphanVolume { name }` for a home-volume deletion, + /// which named a volume that was never an orphan and would attribute the + /// outcome to a plan row the user never ticked. `destroyed` carries it + /// instead, and exactly one of the two is ever set. + pub target: Option, + /// The destructive action this reports on, when it is one. + #[serde(default)] + pub destroyed: Option, pub ok: bool, /// Bytes actually freed, measured after the fact. pub freed_bytes: i64, @@ -763,6 +786,14 @@ pub fn parse_reclaimed_space(output: &str) -> i64 { /// [`restore_image_config`], which is why this function does not try to emit it /// as Dockerfile instructions. A multi-line `CLAUDE_INSTRUCTIONS` env var alone /// makes that escaping a bad bet. +/// +/// The one label it *does* emit is `triple-c.managed=true`, and it is not +/// decoration. Everything that cleans up after this build — the discard path +/// when the result is not smaller, the untag after a successful commit — relies +/// on `sweep_orphaned_snapshots` collecting the intermediate, and that sweep +/// filters on `dangling=true` **and** this label. Without it the sweep can +/// never match, and the flattened intermediate is left to whatever `untag_image` +/// happens to delete on its own. pub fn compaction_dockerfile(snapshot_ref: &str, scrub_script: &str) -> String { // The scrub script is multi-line shell. `RUN` takes it verbatim only if the // newlines are escaped, so it is folded onto one line with `;` separators — @@ -773,7 +804,8 @@ pub fn compaction_dockerfile(snapshot_ref: &str, scrub_script: &str) -> String { "FROM {snapshot_ref} AS src\n\ RUN {folded}\n\ FROM scratch\n\ - COPY --from=src / /\n" + COPY --from=src / /\n\ + LABEL {LABEL_MANAGED}=true\n" ) } @@ -1000,7 +1032,13 @@ fn projects_json_health() -> (bool, bool) { /// `LABEL` lines carries neither label, and a globals block that could not name /// the 4.7 GB image every project sits on would be missing the obvious. fn is_base_image_reference(reference: &str) -> bool { - let repo = reference.split(':').next().unwrap_or(reference); + // Split on the *tag*, not the first colon: `localhost:5000/triple-c-sandbox:latest` + // has a registry port, and splitting on the first colon would yield + // `localhost`. A tag never contains `/`, which is what tells the two apart. + let repo = match reference.rsplit_once(':') { + Some((repo, tag)) if !tag.contains('/') => repo, + _ => reference, + }; repo == "triple-c" || repo.ends_with("/triple-c-sandbox") || repo == "triple-c-sandbox" @@ -1081,6 +1119,7 @@ pub async fn scan(projects: &[Project]) -> Result { }; let mut stats = LayerStats::default(); + let mut base_lineage_known = false; if let Some(image) = snapshot { let history = docker .image_history(&image.id) @@ -1112,6 +1151,7 @@ pub async fn scan(projects: &[Project]) -> Result { }, None => None, }; + base_lineage_known = base_len.is_some(); stats = layer_stats(&history, base_len); } @@ -1140,6 +1180,7 @@ pub async fn scan(projects: &[Project]) -> Result { snapshot_bytes, snapshot_shared_bytes, snapshot_commit_layers: stats.commit_layers, + base_lineage_known, // Prefer the daemon's own measurement of what is unique to this // image over layer arithmetic; fall back to the layer sum when // `df()` did not compute a shared size. @@ -1209,6 +1250,12 @@ pub async fn scan(projects: &[Project]) -> Result { } } base_images.sort_by(|a, b| b.bytes.cmp(&a.bytes)); + // Full size per base, not `size - shared_size`: a base's shared bytes are + // shared with *its own snapshots*, so netting them out would report the + // 4.7 GB image every project sits on as ~0. The residual imprecision is two + // *different* bases that share lower layers with each other, whose common + // layers are counted twice here — worth knowing before treating this total + // as exact. let base_images_bytes = base_images.iter().map(|b| b.bytes).sum(); let (json_exists, json_parsed) = projects_json_health(); @@ -1663,7 +1710,11 @@ pub async fn list_reclaimable( ) }) .unwrap_or(0); - if row.snapshot_exists && row.snapshot_commit_layers > 1 && ceiling > 0 { + if row.snapshot_exists + && row.base_lineage_known + && row.snapshot_commit_layers > 1 + && ceiling > 0 + { items.push({ // Safety and reach are read off the target, never restated: a literal // here that disagreed with the classifier is exactly the drift this @@ -1763,7 +1814,9 @@ pub async fn list_reclaimable( project_name: row.project_name.clone(), label: "Home volume".to_string(), loses: "Shell history, dotfiles, every toolchain installed under $HOME, and any \ - Playwright browsers. Not recoverable." + Playwright browsers. Not recoverable. The project's container is removed \ + too, because a stopped container still holds its volumes open — it is \ + rebuilt from the snapshot on the next start." .to_string(), bytes: row.home_volume_bytes, blocked: blocked.clone(), @@ -1778,7 +1831,9 @@ pub async fn list_reclaimable( project_name: row.project_name.clone(), label: "Claude config volume".to_string(), loses: "The Claude login credential, installed plugins and skills, and EVERY \ - conversation transcript for this project. Not recoverable." + conversation transcript for this project. Not recoverable. The project's \ + container is removed too, because a stopped container still holds its \ + volumes open — it is rebuilt from the snapshot on the next start." .to_string(), bytes: row.config_volume_bytes, blocked: blocked.clone(), @@ -2112,7 +2167,8 @@ fn find_project<'a>(projects: &'a [Project], project_id: &str) -> Result<&'a Pro fn failed(target: ReclaimTarget, message: String) -> ReclaimResult { ReclaimResult { - target, + target: Some(target), + destroyed: None, ok: false, freed_bytes: 0, projected_bytes: None, @@ -2196,7 +2252,8 @@ async fn reclaim_dangling(target: &ReclaimTarget) -> ReclaimResult { message.push_str(&format!(" {} could not be removed; see the log.", errors)); } ReclaimResult { - target: target.clone(), + target: Some(target.clone()), + destroyed: None, ok: errors == 0, freed_bytes: freed, projected_bytes: None, @@ -2222,7 +2279,8 @@ async fn reclaim_build_cache(all: bool) -> ReclaimResult { } match docker_cli(&args).await { Ok(output) => ReclaimResult { - target, + target: Some(target), + destroyed: None, ok: true, freed_bytes: parse_reclaimed_space(&output), projected_bytes: None, @@ -2302,7 +2360,8 @@ async fn reclaim_migration_pins() -> ReclaimResult { sweep.reclaimed_bytes ); ReclaimResult { - target, + target: Some(target), + destroyed: None, ok: true, freed_bytes: sweep.reclaimed_bytes, projected_bytes: None, @@ -2352,7 +2411,8 @@ fn reclaim_migration_staging(projects: &[Project]) -> ReclaimResult { } } ReclaimResult { - target, + target: Some(target), + destroyed: None, ok: true, freed_bytes: freed, projected_bytes: None, @@ -2407,7 +2467,8 @@ async fn reclaim_containers( } } ReclaimResult { - target, + target: Some(target), + destroyed: None, ok: errors == 0, freed_bytes: freed, projected_bytes: None, @@ -2459,7 +2520,8 @@ async fn reclaim_orphan_volume(name: &str, projects: &[Project]) -> ReclaimResul match docker.remove_volume(name, None).await { Ok(()) => ReclaimResult { - target, + target: Some(target), + destroyed: None, ok: true, freed_bytes: volume.bytes, projected_bytes: None, @@ -2606,7 +2668,8 @@ pub async fn compact_snapshot(project: &Project) -> ReclaimResult { let _ = migration::untag_image(&staging_ref).await; let _ = container::sweep_orphaned_snapshots().await; return ReclaimResult { - target, + target: Some(target), + destroyed: None, ok: true, freed_bytes: 0, projected_bytes: projected, @@ -2647,7 +2710,8 @@ pub async fn compact_snapshot(project: &Project) -> ReclaimResult { sweep.removed.len() ); ReclaimResult { - target, + target: Some(target), + destroyed: None, ok: true, freed_bytes: freed, projected_bytes: projected, @@ -2726,6 +2790,59 @@ async fn build_from_dockerfile(dockerfile: &str, tag: &str) -> Result<(), String Ok(()) } +/// Name prefix for the throwaway container that replays a compacted image's +/// config. Distinct from `triple-c-scrub-*` on purpose — see +/// [`restore_image_config`]. +const COMPACTION_CONTAINER_PREFIX: &str = "triple-c-compact-"; + +/// Remove any container left behind by an interrupted compaction. +/// +/// Runs at the start of a compaction rather than from a reclaim bucket, so +/// nothing can ever remove the container of a compaction that is still running: +/// by the time this is called, this task owns the compaction path. +async fn remove_stale_compaction_containers() { + let Ok(docker) = get_docker() else { + return; + }; + let containers = docker + .list_containers(Some(ListContainersOptions { + all: true, + size: false, + filters: HashMap::from([( + "name".to_string(), + vec![COMPACTION_CONTAINER_PREFIX.to_string()], + )]), + ..Default::default() + })) + .await + .unwrap_or_default(); + for summary in containers { + // Docker's `name` filter is a substring match; the full name decides. + if !is_compaction_container(&summary) { + continue; + } + if let Some(id) = summary.id.as_deref() { + match container::remove_container(id).await { + Ok(()) => log::info!("Removed stale compaction container {}", id), + Err(e) => log::warn!("Could not remove stale compaction container {}: {}", id, e), + } + } + } +} + +/// Whether a container is one of ours from an interrupted compaction. +fn is_compaction_container(summary: &ContainerSummary) -> bool { + summary + .names + .as_deref() + .unwrap_or(&[]) + .iter() + .any(|name| { + name.trim_start_matches('/') + .starts_with(COMPACTION_CONTAINER_PREFIX) + }) +} + /// Put a captured image config back onto a flattened image, under the original /// tag. /// @@ -2748,12 +2865,20 @@ async fn restore_image_config( use bollard::image::CommitContainerOptions; let docker = get_docker()?; - // Deliberately the same `triple-c-scrub-*` name `rewrite_image_without_secrets` - // uses. If this process dies between the create and the remove below, the - // leftover is already covered by the "secret-scrub scratch containers" - // bucket in this very panel rather than needing a second reaper. The two - // never run at once: `reclaim` executes its targets in sequence. - let scratch_name = format!("triple-c-scrub-{}", uuid::Uuid::new_v4().simple()); + + // **Its own prefix, not `triple-c-scrub-*`.** An earlier version reused the + // secret-rewrite name on the grounds that the existing reclaim bucket would + // then collect any leftover. It would — including the live one: that bucket + // removes with `force: true`, so a "remove scrub containers" reclaim fired + // from a second window while a compaction was mid-flight would destroy the + // container the commit is about to run against. Sequential execution inside + // one `reclaim` call is not a guarantee when two can be in flight. + // + // Stale ones are instead swept here, at the start of the next compaction — + // a created-but-never-started container has no writable layer, so a + // leftover costs almost nothing until then. + remove_stale_compaction_containers().await; + let scratch_name = format!("{}{}", COMPACTION_CONTAINER_PREFIX, uuid::Uuid::new_v4().simple()); // `image` is the flat build; everything else is copied from the original so // the committed image is byte-for-byte the same configuration. @@ -2877,7 +3002,8 @@ pub async fn clear_caches(project: &Project, include_rustup: bool) -> ReclaimRes match super::exec::exec_oneshot_as(&container_id, "claude", cmd, Vec::new()).await { Ok((output, _exit)) => match parse_cache_total(&output) { Some(bytes) => ReclaimResult { - target, + target: Some(target), + destroyed: None, ok: true, freed_bytes: bytes as i64, projected_bytes: None, @@ -2934,8 +3060,9 @@ pub async fn destroy( // A running container holds all three of these open, and Docker's refusal // is not something to lean on for the volumes: it would happily leave a // half-removed project behind. - if let Ok(Some(container_id)) = container::find_existing_container(project).await { - if container::is_container_running(&container_id) + let existing_container = container::find_existing_container(project).await.ok().flatten(); + if let Some(container_id) = existing_container.as_deref() { + if container::is_container_running(container_id) .await .unwrap_or(false) { @@ -2953,13 +3080,37 @@ pub async fn destroy( }; // Size it before it goes, so the report is a measurement. let bytes = volume_size(&name).await; + + // **A stopped container still pins its volumes.** Docker refuses + // `remove_volume` with a 409 while any container references one, + // and every project that has ever been started has exactly that — + // a stopped container is the resting state, not an edge case. So + // the container is removed first rather than letting the user type + // a project name and then meet a raw 409. It is regenerable from + // the snapshot; `DestructiveItem::loses` says so. + if let Some(container_id) = existing_container.as_deref() { + container::remove_container(container_id).await.map_err(|e| { + format!( + "Could not remove this project's container, which still holds the volume \ + open: {}. Nothing was removed.", + e + ) + })?; + log::info!( + "Removed container {} so {} could be deleted", + container_id, + name + ); + } + docker .remove_volume(&name, None) .await .map_err(|e| format!("Could not remove volume {}: {}", name, e))?; log::info!("Removed volume {} on explicit confirmation", name); Ok(ReclaimResult { - target: ReclaimTarget::OrphanVolume { name: name.clone() }, + target: None, + destroyed: Some(target.clone()), ok: true, freed_bytes: bytes, projected_bytes: None, @@ -2974,7 +3125,8 @@ pub async fn destroy( container::remove_snapshot_image(project).await?; let sweep = container::sweep_orphaned_snapshots().await; Ok(ReclaimResult { - target: ReclaimTarget::DanglingSnapshots, + target: None, + destroyed: Some(target.clone()), ok: true, freed_bytes: bytes + sweep.reclaimed_bytes, projected_bytes: None, @@ -2985,6 +3137,19 @@ pub async fn destroy( }) } DestructiveTarget::RollbackPin { tag, .. } => { + // **The one destructive variant carrying a free-form string.** + // Every other arm builds its target from constants; this one takes + // a tag over IPC and interpolates it into an image reference that + // is then removed. Unvalidated, `tag: "latest"` names the project's + // live snapshot — deleted under a dialog that says "rollback pin". + // `parse_rollback_tag` accepts only `pre-migration-`, + // which is exactly what `rollback_tag` produces and nothing else. + if migration::parse_rollback_tag(tag).is_none() { + return Err(format!( + "{:?} is not a rollback pin tag. Nothing was removed.", + tag + )); + } let reference = format!("triple-c-snapshot-{}:{}", project.id, tag); migration::untag_image(&reference).await?; // Untagging only makes the image dangling. Whatever came back came @@ -2992,7 +3157,8 @@ pub async fn destroy( let sweep = container::sweep_orphaned_snapshots().await; log::info!("Dropped rollback pin {} on explicit confirmation", reference); Ok(ReclaimResult { - target: ReclaimTarget::MigrationPins, + target: None, + destroyed: Some(target.clone()), ok: true, freed_bytes: sweep.reclaimed_bytes, projected_bytes: None, @@ -3011,8 +3177,13 @@ pub async fn destroy( /// The plain `Size` from `inspect_image` includes the base, which several other /// projects are still built from and which is not going anywhere. Reporting it /// as freed would overstate a snapshot removal by ~4.7 GB every time. Only -/// `df()` computes `SharedSize`, so this costs one — acceptable on the -/// destructive path, which handles exactly one object per call. +/// `df()` computes `SharedSize`, so each call costs a full daemon walk. +/// +/// That is three `df()`s on a compaction (before, after, and the scan that +/// planned it) and one per destructive removal. Acceptable because both are +/// single-object, user-initiated actions that already take seconds to minutes — +/// but it is why nothing in the *scan* path calls this: `scan` gets shared +/// sizes from the one `df()` it already makes. async fn image_unique_bytes(reference: &str) -> i64 { let Ok(docker) = get_docker() else { return 0; diff --git a/app/src-tauri/src/docker/disk_tests.rs b/app/src-tauri/src/docker/disk_tests.rs index 0aae0b1..8ec636a 100644 --- a/app/src-tauri/src/docker/disk_tests.rs +++ b/app/src-tauri/src/docker/disk_tests.rs @@ -338,6 +338,26 @@ fn a_scrub_container_is_matched_on_its_whole_name_not_a_substring() { assert!(!is_scrub_container(&summary(&[], &[]))); } +#[test] +fn a_compaction_container_is_never_matched_by_the_scrub_bucket() { + // These had the same `triple-c-scrub-*` prefix once. The scrub bucket + // removes with `force: true`, so a reclaim fired from a second window while + // a compaction was mid-flight would have destroyed the container the commit + // was about to run against. Separate prefixes, and neither predicate may + // reach the other's containers. + let compaction = summary(&["/triple-c-compact-abc123"], &[]); + let scrub = summary(&["/triple-c-scrub-abc123"], &[]); + + assert!(is_compaction_container(&compaction)); + assert!(!is_scrub_container(&compaction), "the scrub bucket must not reach it"); + + assert!(is_scrub_container(&scrub)); + assert!(!is_compaction_container(&scrub)); + + // Same substring-filter hazard applies to the new prefix. + assert!(!is_compaction_container(&summary(&["/my-triple-c-compact-notes"], &[]))); +} + #[test] fn a_probe_container_is_matched_on_its_label_not_on_the_daemons_filter() { // The `label=triple-c.probe=migration` filter is an exact match and would @@ -627,6 +647,24 @@ fn the_compaction_dockerfile_reuses_the_one_scrub_list() { assert!(!run_lines[0].contains('\n')); } +#[test] +fn the_compaction_build_is_labelled_so_the_sweep_can_collect_it() { + // Everything that cleans up after this build — the discard path when the + // result is not smaller, the untag after a successful commit — leans on + // `sweep_orphaned_snapshots`, and that sweep filters on `dangling=true` + // AND `triple-c.managed=true`. Without the label it can never match, and + // the flattened intermediate is stranded. + let df = compaction_dockerfile("x:latest", &container::snapshot_scrub_script()); + assert!( + df.contains("LABEL triple-c.managed=true"), + "the sweep filters on this label and would never match: {}", + df + ); + // It has to be on the *final* stage, not the discarded `src` one. + let after_scratch = df.split("FROM scratch").nth(1).expect("no final stage"); + assert!(after_scratch.contains("LABEL triple-c.managed=true"), "{}", df); +} + #[test] fn the_compaction_dockerfile_never_reaches_a_bind_mount() { let df = compaction_dockerfile("x:latest", &container::snapshot_scrub_script()); @@ -730,6 +768,77 @@ fn a_typed_confirmation_must_match_the_project_name_exactly() { assert!(!confirmation_matches("", "")); } +#[test] +fn only_a_real_rollback_tag_can_name_an_image_to_delete() { + // `DestructiveTarget::RollbackPin` is the one destructive variant carrying + // a free-form string from the frontend, and `destroy` interpolates it into + // an image reference it then removes. Unguarded, `tag: "latest"` names the + // project's *live snapshot* — deleted under a dialog that says "rollback + // pin". The guard is `parse_rollback_tag`, so this pins what it accepts. + assert!(migration::parse_rollback_tag("pre-migration-20260101-101500").is_some()); + + for hostile in [ + "latest", + "", + "pre-migration-", + "pre-migration-notatimestamp", + "../latest", + "latest\npre-migration-20260101-101500", + ] { + assert!( + migration::parse_rollback_tag(hostile).is_none(), + "{:?} must not be accepted as a rollback pin tag", + hostile + ); + } +} + +#[test] +fn a_destroy_result_never_claims_to_be_reclaim_work() { + // An earlier version returned `OrphanVolume { name }` for a home-volume + // deletion — naming a volume that was never an orphan, and attributing the + // outcome to a plan row the user never ticked. Exactly one of the two + // fields is ever set. + let reclaim_shaped = ReclaimResult { + target: Some(ReclaimTarget::DanglingSnapshots), + destroyed: None, + ok: true, + freed_bytes: 1, + projected_bytes: None, + message: String::new(), + }; + let destroy_shaped = ReclaimResult { + target: None, + destroyed: Some(DestructiveTarget::HomeVolume { + project_id: "p1".to_string(), + }), + ..reclaim_shaped.clone() + }; + assert!(reclaim_shaped.target.is_some() != reclaim_shaped.destroyed.is_some()); + assert!(destroy_shaped.target.is_some() != destroy_shaped.destroyed.is_some()); + + // And both shapes survive the wire. + let json = serde_json::to_string(&destroy_shaped).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), destroy_shaped); +} + +#[test] +fn a_snapshot_with_no_known_base_is_not_offered_for_compaction() { + // With `triple-c.base-image-id` absent — the normal case for a project + // created before that label existed — `layer_stats` counts every layer that + // carries bytes, base included. A never-recreated project then reports ~15 + // "commit layers" and would sail past a `> 1` check. `base_lineage_known` + // is what stops the plan offering a rewrite sized from a number that does + // not mean what its name says. + let unknown = layer_stats(&[10, 20, 30, 40], None); + assert_eq!(unknown.commit_layers, 4); + assert_eq!(unknown.above_base_bytes, None, "the split must not be guessed"); + + let known = layer_stats(&[10, 20, 30, 40], Some(3)); + assert_eq!(known.commit_layers, 1); + assert_eq!(known.above_base_bytes, Some(10)); +} + // --------------------------------------------------------------------------- // Host detection // --------------------------------------------------------------------------- @@ -763,9 +872,14 @@ fn base_images_are_recognised_by_reference_for_display_only() { assert!(is_base_image_reference("triple-c-sandbox:latest")); assert!(is_base_image_reference("triple-c:latest")); + // A registry port must not be mistaken for a tag separator. + assert!(is_base_image_reference("localhost:5000/triple-c-sandbox:latest")); + assert!(is_base_image_reference("registry.example.com:8443/triple-c-sandbox")); + // A project's own snapshot is not a base image, and neither is anything of // the user's. assert!(!is_base_image_reference("triple-c-snapshot-abc:latest")); + assert!(!is_base_image_reference("localhost:5000/postgres:17")); assert!(!is_base_image_reference("triple-c-gateway:latest")); assert!(!is_base_image_reference("postgres:17-alpine")); } @@ -815,6 +929,7 @@ fn the_report_serialises_as_snake_case_like_every_other_ipc_struct() { }; let json = serde_json::to_value(&report).unwrap(); assert_eq!(json["projects"][0]["snapshot_commit_layers"], 14); + assert_eq!(json["projects"][0]["base_lineage_known"], false); assert_eq!(json["projects"][0]["container_writable_bytes"], 868_000_000i64); assert!(json["orphan_volumes_unavailable"].is_null()); // `Option` must reach the frontend as null, not be omitted — the TS diff --git a/app/src/components/settings/DiskProjectTable.tsx b/app/src/components/settings/DiskProjectTable.tsx index 891a200..0c9b9e3 100644 --- a/app/src/components/settings/DiskProjectTable.tsx +++ b/app/src/components/settings/DiskProjectTable.tsx @@ -11,6 +11,12 @@ interface Props { onDestroy: (item: DestructiveItem) => void; } +const LAYERS_HELP = + "Commit layers stacked above the base image — one for every time this project's container was recreated. Nothing merges them, so each one is paid for permanently until the snapshot is compacted."; + +const NEXT_COMMIT_HELP = + "The container's writable layer. This is exactly what the next recreation will stack onto the snapshot, and it never comes back after that."; + /** `—` for a column with nothing in it, so an empty cell never reads as zero. */ function cell(bytes: number, present: boolean) { return present ? formatBytes(bytes) : "—"; @@ -59,11 +65,18 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props Layers - + {/* `Tooltip` renders a portalled div with no `role` and no + `aria-describedby`, so its text reaches no assistive tech and + the trigger announces as "Help". These two headers are + meaningless without their explanation, so it is also emitted + as screen-reader-only text. */} + + — {LAYERS_HELP} Next commit adds - + + — {NEXT_COMMIT_HELP} Home vol @@ -104,7 +117,12 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props - {cell(row.snapshot_above_base_bytes ?? 0, row.snapshot_exists)} + {/* `null` means the split could not be measured. Rendering it + as 0 B would be the one guessed number in this table. */} + {cell( + row.snapshot_above_base_bytes ?? -1, + row.snapshot_exists && row.snapshot_above_base_bytes !== null, + )} {row.snapshot_exists && ( {/* The base is shared by every project, so charging it to @@ -117,18 +135,28 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props )} - {row.snapshot_exists ? ( - 5 - ? "text-[var(--warning)]" - : "text-[var(--text-primary)]" - } - > - {row.snapshot_commit_layers} - - ) : ( + {!row.snapshot_exists ? ( "—" + ) : !row.base_lineage_known ? ( + // The base this descends from is unknown, so the count + // includes the base's own layers and does not mean + // "recreations". Saying so beats printing a wrong number. + + unknown + + ) : ( + + {row.snapshot_commit_layers} + {/* Never colour alone: a count worth acting on says so in + a word, which is also what a screen reader gets. */} + {row.snapshot_commit_layers > 5 && ( + + stacked + + )} + )} diff --git a/app/src/components/settings/DiskSettings.test.tsx b/app/src/components/settings/DiskSettings.test.tsx index dad6168..07ec58d 100644 --- a/app/src/components/settings/DiskSettings.test.tsx +++ b/app/src/components/settings/DiskSettings.test.tsx @@ -35,6 +35,7 @@ const row = (over: Partial = {}): ProjectDiskRow => ({ snapshot_bytes: 12_273_392_374, snapshot_shared_bytes: 3_832_425_659, snapshot_commit_layers: 14, + base_lineage_known: true, snapshot_above_base_bytes: 8_440_966_715, container_exists: true, container_running: false, @@ -170,6 +171,36 @@ describe("DiskSettings", () => { expect(within(projectRow).getByText("14.6 GB")).toBeInTheDocument(); }); + it("refuses to present a layer count that does not mean recreations", async () => { + // Without `triple-c.base-image-id` — the normal case for a project created + // before that label existed — the count includes the base's own ~15 layers. + // Printing it beside a header that says "one per recreation" would be a + // wrong number in the column the table exists for. + getDockerDiskUsage.mockResolvedValue( + report({ projects: [row({ base_lineage_known: false, snapshot_commit_layers: 17 })] }), + ); + await renderAndScan(); + const projectRow = await screen.findByTestId("disk-row-p-whp"); + expect(within(projectRow).getByText("unknown")).toBeInTheDocument(); + expect(within(projectRow).queryByText("17")).not.toBeInTheDocument(); + }); + + it("renders an unmeasurable snapshot split as a dash, never as zero", async () => { + getDockerDiskUsage.mockResolvedValue( + report({ projects: [row({ snapshot_above_base_bytes: null })] }), + ); + await renderAndScan(); + const projectRow = await screen.findByTestId("disk-row-p-whp"); + expect(within(projectRow).queryByText("0 B")).not.toBeInTheDocument(); + expect(within(projectRow).getAllByText("—").length).toBeGreaterThan(0); + }); + + it("marks a heavily stacked snapshot with a word, not just a colour", async () => { + await renderAndScan(); + const projectRow = await screen.findByTestId("disk-row-p-whp"); + expect(within(projectRow).getByText("stacked")).toBeInTheDocument(); + }); + it("charges the shared base to the globals, not to every project row", async () => { // The base is one 4.7 GB image every project descends from. Counting it per // row would show it eight times and make the column meaningless. @@ -218,6 +249,45 @@ describe("DiskSettings", () => { expect(reclaim).toHaveBeenCalledWith([{ kind: "migration_staging" }]); }); + it("clears the tick list once the reclaim has run", async () => { + // The plan's rows describe objects the reclaim just removed; leaving them + // ticked lets the user fire the same call again against nothing. + await renderAndScan(); + await screen.findByTestId("disk-safe-bucket"); + fireEvent.click(screen.getAllByRole("checkbox")[0]); + expect(screen.getByText(/1 selected/)).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Reclaim" })); + }); + expect(screen.queryByTestId("disk-safe-bucket")).not.toBeInTheDocument(); + expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); + // And it says why the list is gone rather than claiming nothing was found. + expect(screen.getByTestId("disk-plan-stale").textContent).toMatch( + /measured before that last action/, + ); + }); + + it("says why the build-cache figure is the under-reporting one", async () => { + // Without this, a `buildx du` failure silently shows `docker system df`'s + // number, which under-reports what a prune would free. + getDockerDiskUsage.mockResolvedValue( + report({ + build_cache: { + total_bytes: 28_000_000_000, + reclaimable_bytes: 1_000_000, + stale_bytes: 0, + source: "system df", + cli_error: "`docker buildx du` failed: executable not found", + }, + }), + ); + await renderAndScan(); + const globals = await screen.findByTestId("disk-globals"); + expect(globals.textContent).toMatch(/under-reports what a prune would free/); + expect(globals.textContent).toMatch(/executable not found/); + }); + it("cannot reclaim with nothing ticked", async () => { await renderAndScan(); await screen.findByTestId("disk-safe-bucket"); @@ -380,9 +450,7 @@ describe("DiskSettings", () => { ); await renderAndScan(); const note = await screen.findByTestId("disk-vhdx-note"); - expect( - within(note).getByText("Reclaiming here will not shrink your C: drive"), - ).toBeInTheDocument(); + expect(note.textContent).toMatch(/Warning: reclaiming here will not shrink your C: drive/); expect(within(note).getByText(/wsl --shutdown/)).toBeInTheDocument(); expect(within(note).getByText(/Optimize-VHD/)).toBeInTheDocument(); expect(within(note).getByText(/Purge data/)).toBeInTheDocument(); @@ -415,7 +483,8 @@ describe("DiskSettings", () => { }), ); destroyProjectDiskObject.mockResolvedValue({ - target: { kind: "orphan_volume", name: "triple-c-claude-config-p-whp" }, + target: null, + destroyed: { kind: "config_volume", project_id: "p-whp" }, ok: true, freed_bytes: 427_000_000, projected_bytes: null, @@ -450,6 +519,57 @@ describe("DiskSettings", () => { ); }); + it("keeps the confirmation open and busy while the deletion runs", async () => { + // The modal used to be unmounted before the call was awaited, which made + // its whole busy path dead code and left a multi-second volume removal with + // no indication it was happening. + listReclaimable.mockResolvedValue( + plan({ + destructive: [ + { + target: { kind: "home_volume", project_id: "p-whp" }, + project_id: "p-whp", + project_name: "whp", + label: "Home volume", + loses: "Shell history and toolchains.", + bytes: 4_860_000_000, + blocked: null, + }, + ], + }), + ); + let finish: (value: unknown) => void = () => {}; + destroyProjectDiskObject.mockReturnValue(new Promise((r) => (finish = r))); + + await renderAndScan(); + await screen.findByTestId("disk-row-p-whp"); + fireEvent.click(screen.getByRole("button", { name: "Delete whp data" })); + await act(async () => { + fireEvent.click(screen.getByRole("menuitem", { name: /Delete home volume/ })); + }); + + const dialog = screen.getByRole("dialog"); + fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "whp" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete home volume" })); + + // Still open, and saying so. + await waitFor(() => + expect(screen.getByRole("button", { name: "Working…" })).toBeDisabled(), + ); + + await act(async () => { + finish({ + target: null, + destroyed: { kind: "home_volume", project_id: "p-whp" }, + ok: true, + freed_bytes: 4_860_000_000, + projected_bytes: null, + message: "Removed volume.", + }); + }); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + }); + it("never routes a destructive object through the bulk Reclaim button", async () => { listReclaimable.mockResolvedValue( plan({ @@ -479,6 +599,7 @@ describe("DiskSettings", () => { results: [ { target: { kind: "compact_snapshot", project_id: "p-whp" }, + destroyed: null, ok: true, freed_bytes: 5_100_000_000, projected_bytes: 7_000_000_000, @@ -499,7 +620,7 @@ describe("DiskSettings", () => { expect(within(outcome).getByText(/projected up to 7\.0 GB, actually 5\.1 GB/)).toBeInTheDocument(); }); - it("surfaces a scan failure rather than showing stale numbers", async () => { + it("surfaces a scan failure as an alert", async () => { getDockerDiskUsage.mockRejectedValue("Could not read Docker disk usage: no such host"); render(); await act(async () => { diff --git a/app/src/components/settings/DiskSettings.tsx b/app/src/components/settings/DiskSettings.tsx index e3a3992..720fcfc 100644 --- a/app/src/components/settings/DiskSettings.tsx +++ b/app/src/components/settings/DiskSettings.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import Button from "../ui/Button"; import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator"; import Modal from "../ui/Modal"; @@ -38,12 +38,29 @@ function targetKey(target: ReclaimTarget): string { * and the backend refuses it in bulk by taking a different type entirely. */ export default function DiskSettings() { - const { report, plan, scanning, working, error, outcome, scan, runReclaim, destroy } = - useDiskUsage(); + const { + report, + plan, + scanning, + working, + error, + outcome, + scan, + runReclaim, + destroy, + runSweep, + clearOutcome, + } = useDiskUsage(); const [ticked, setTicked] = useState>(new Set()); const [confirming, setConfirming] = useState(null); const [destroying, setDestroying] = useState(null); + // The plan is dropped after any reclaim, so a tick can never outlive the row + // it was made against and be re-fired at an object that is already gone. + useEffect(() => { + if (!plan) setTicked(new Set()); + }, [plan]); + const safeItems = plan?.items.filter((i) => i.safety === "safe") ?? []; const semiItems = plan?.items.filter((i) => i.safety === "semi_safe") ?? []; const selected = safeItems.filter( @@ -112,11 +129,14 @@ export default function DiskSettings() { className="border border-[var(--warning)]/40 bg-[var(--warning-muted)] rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2" data-testid="disk-vhdx-note" > - + {/* `StatusIndicator` has no warning tone — `error` would put a + red glyph in a warning-toned panel. This is advisory, so it + carries its own glyph beside the words rather than relying on + the panel's colour. */} +

+ Warning: reclaiming here will not + shrink your C: drive +

{report.host.vhdx_note}

@@ -203,11 +223,20 @@ export default function DiskSettings() { )} + {report.build_cache.cli_error && ( +

+ {/* Without this the panel silently shows `docker system df`'s + under-reported build-cache figure and the user has no way + to know why it disagrees with their terminal. */} + Build-cache figures fell back to docker system df, which + under-reports what a prune would free: {report.build_cache.cli_error} +

+ )} {report.orphan_volumes.length > 0 && (

- That last figure means only that the volume’s project id is not in - your project list — it is not inferred from a project - being stopped or having no image. A project you have not opened in a + “Volumes with no matching project” above means only that the + volume’s project id is not in your project list — it is{" "} + not inferred from a project being stopped or having no image. A project you have not opened in a while has no container and no snapshot either, and that is normal, so each of these is ticked individually and shows the date Docker created it. @@ -223,7 +252,7 @@ export default function DiskSettings() { {/* --- Store failure, if any ------------------------------------ */} {report.orphan_volumes_unavailable && (

)} + {/* --- The plan was dropped by a reclaim -------------------------- */} + {!plan && ( +

+ The totals above were measured before that last action. Scan again to see + what is left to reclaim. +

+ )} + {/* --- Safe reclaim ---------------------------------------------- */} + {plan && (

Safe to reclaim @@ -260,7 +298,10 @@ export default function DiskSettings() {

+ )} {/* --- Semi-safe -------------------------------------------------- */} {semiItems.length > 0 && ( @@ -371,16 +413,13 @@ export default function DiskSettings() { {/* --- Sweep ------------------------------------------------------ */}
- - The same sweep that runs at startup and after every recreation — here you - can see what it found. + The same sweep that runs at startup and after every recreation. Unlike the + tick above it also reports what it refused to remove, which is how + a superseded image pinned by a stopped project shows itself.
@@ -394,11 +433,16 @@ export default function DiskSettings() { aria-live="polite" data-testid="disk-outcome" > - r.ok) ? "ok" : "error"} - label={`Reclaimed ${formatBytes(outcome.total_freed_bytes)}`} - className="text-xs" - /> +
+ r.ok) ? "ok" : "error"} + label={`Reclaimed ${formatBytes(outcome.total_freed_bytes)}`} + className="text-xs" + /> + +
    {outcome.results.map((result, index) => (
  • @@ -433,10 +477,11 @@ export default function DiskSettings() { size="md" variant="primary" disabled={working} - onClick={() => { - const target = confirming.target; + onClick={async () => { + // Same reasoning as the destructive modal: a compaction takes + // minutes, and the dialog reporting it beats it vanishing. + await runReclaim([confirming.target]); setConfirming(null); - void runReclaim([target]); }} > {working ? "Working…" : "Run it"} @@ -487,10 +532,12 @@ export default function DiskSettings() { confirmLabel={`Delete ${destroying.label.toLowerCase()}`} busy={working} onCancel={() => setDestroying(null)} - onConfirm={(typed) => { - const target = destroying.target; + onConfirm={async (typed) => { + // The modal stays mounted until the call settles, so its `busy` + // state is what the user sees while a multi-second volume removal + // runs. Clearing it first made the whole busy path dead code. + await destroy(destroying.target, typed); setDestroying(null); - void destroy(target, typed); }} >

    diff --git a/app/src/components/ui/TypedConfirmModal.tsx b/app/src/components/ui/TypedConfirmModal.tsx index 5a78278..8c7f036 100644 --- a/app/src/components/ui/TypedConfirmModal.tsx +++ b/app/src/components/ui/TypedConfirmModal.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, type ReactNode } from "react"; +import { useId, useRef, useState, type ReactNode } from "react"; import Modal from "./Modal"; import Button from "./Button"; import { inputClass } from "./Field"; @@ -47,6 +47,9 @@ export default function TypedConfirmModal({ }: Props) { const [typed, setTyped] = useState(""); const inputRef = useRef(null); + // Every other `ui/` component uses `useId`; a hardcoded id breaks the + // label association as soon as two of these are mounted at once. + const inputId = useId(); const matches = expected.trim().length > 0 && typed.trim() === expected.trim(); return ( @@ -80,13 +83,13 @@ export default function TypedConfirmModal({ {children}

    setTyped(e.target.value)} diff --git a/app/src/hooks/useDiskUsage.test.tsx b/app/src/hooks/useDiskUsage.test.tsx index 0c21429..bda6e72 100644 --- a/app/src/hooks/useDiskUsage.test.tsx +++ b/app/src/hooks/useDiskUsage.test.tsx @@ -14,9 +14,11 @@ vi.mock("../lib/tauri-commands", () => ({ reclaim: (targets: unknown) => reclaim(targets), destroyProjectDiskObject: (target: unknown, confirmation: string) => destroyProjectDiskObject(target, confirmation), - sweepOrphanedSnapshots: vi.fn(), + sweepOrphanedSnapshots: () => sweepOrphanedSnapshots(), })); +const sweepOrphanedSnapshots = vi.fn(); + const report = (scanned_at: string): DiskUsageReport => ({ scanned_at, projects: [] }) as unknown as DiskUsageReport; @@ -149,13 +151,94 @@ describe("useDiskUsage", () => { expect(result.current.outcome?.total_freed_bytes).toBe(100); }); - it("surfaces a failure rather than leaving a stale report on screen", async () => { - getDockerDiskUsage.mockRejectedValue("daemon unreachable"); + it("reports a scan failure and keeps the last good measurement", async () => { + // The old report is still an accurate measurement of an earlier moment, + // and the error says the refresh failed. Blanking it would leave the panel + // with nothing while telling the user nothing more. + getDockerDiskUsage.mockResolvedValueOnce(report("first")); const { result } = renderHook(() => useDiskUsage()); await act(async () => { await result.current.scan(); }); + + getDockerDiskUsage.mockRejectedValueOnce("daemon unreachable"); + await act(async () => { + await result.current.scan(); + }); await waitFor(() => expect(result.current.error).toMatch(/daemon unreachable/)); + expect(result.current.report?.scanned_at).toBe("first"); expect(result.current.scanning).toBe(false); }); + + it("never shows fresh totals beside a stale tick list", async () => { + // `setReport` used to land before the plan call was awaited, so a plan + // failure rendered this scan's numbers above the previous scan's rows. + getDockerDiskUsage.mockResolvedValueOnce(report("first")); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.scan(); + }); + + getDockerDiskUsage.mockResolvedValueOnce(report("second")); + listReclaimable.mockRejectedValueOnce("planner exploded"); + await act(async () => { + await result.current.scan(); + }); + expect(result.current.error).toMatch(/planner exploded/); + expect(result.current.report?.scanned_at).toBe("first"); + }); + + it("drops the plan after a reclaim so ticks cannot be re-fired at nothing", async () => { + getDockerDiskUsage.mockResolvedValue(report("first")); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.scan(); + }); + expect(result.current.plan).toEqual(plan); + + await act(async () => { + await result.current.runReclaim([{ kind: "dangling_snapshots" }]); + }); + expect(result.current.plan).toBeNull(); + // The totals stay — they were measured before the reclaim and the outcome + // says what changed. + expect(result.current.report?.scanned_at).toBe("first"); + }); + + it("runs the sweep through its own command and reports what it refused", async () => { + // The sweep's `in_use` count — orphans Docker refused to delete because a + // stopped project still needs them — is invisible everywhere else in the + // app, because every other caller throws the report away. + sweepOrphanedSnapshots.mockResolvedValue({ + removed: ["sha256:a", "sha256:b"], + reclaimed_bytes: 11_900_000_000, + in_use: 3, + failed: [], + unavailable: null, + }); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.runSweep(); + }); + expect(sweepOrphanedSnapshots).toHaveBeenCalled(); + expect(result.current.outcome?.total_freed_bytes).toBe(11_900_000_000); + expect(result.current.outcome?.results[0].message).toMatch(/Swept 2 superseded image/); + expect(result.current.outcome?.results[0].message).toMatch(/3 were left alone/); + }); + + it("treats an unreachable daemon in the sweep report as an error", async () => { + sweepOrphanedSnapshots.mockResolvedValue({ + removed: [], + reclaimed_bytes: 0, + in_use: 0, + failed: [], + unavailable: "Could not reach the Docker engine", + }); + const { result } = renderHook(() => useDiskUsage()); + await act(async () => { + await result.current.runSweep(); + }); + expect(result.current.error).toMatch(/Could not reach the Docker engine/); + expect(result.current.outcome).toBeNull(); + }); }); diff --git a/app/src/hooks/useDiskUsage.ts b/app/src/hooks/useDiskUsage.ts index e3bf786..f82eb19 100644 --- a/app/src/hooks/useDiskUsage.ts +++ b/app/src/hooks/useDiskUsage.ts @@ -17,16 +17,24 @@ import type { * the daemon and computes shared-layer sizes. On a 100 GB store that is * seconds. `AccordionSection` unmounts its body when collapsed, so a * `useEffect` scan would re-run every single time the user opened the section. - * The scan is therefore a `scan()` the Scan button calls and nothing else, and - * the result lives in this hook rather than in the component so that reopening - * the section shows the last result instead of paying again. + * The scan is therefore only ever what the Scan button calls. + * + * Note what that does *not* buy: this hook lives inside `DiskSettings`, which + * the accordion unmounts on collapse, so its state goes with it and reopening + * the section shows an unscanned panel again. That is the honest behaviour — + * a stale total is worse than an absent one — but it means collapsing and + * reopening discards a scan the user paid for. Lifting the report into + * `appState` would fix that and is deliberately not done here: it would put a + * multi-megabyte, rapidly-stale blob into the app-wide store for one panel. * * ## The generation guard * * A user who hits Scan twice can have two `df()` calls in flight, and they can * land out of order — the second one is not necessarily slower. Every async - * write checks it is still the newest before it lands, the same pattern - * `useContainerMigration` uses. + * write in `scan` checks it is still the newest before it lands, the same + * pattern `useContainerMigration` uses. `runReclaim` and `destroy` do not need + * it: the UI disables their buttons while `working` is set, so there is never + * a second one to race. */ export interface DiskUsageState { report: DiskUsageReport | null; @@ -41,6 +49,8 @@ export interface DiskUsageState { scan: () => Promise; runReclaim: (targets: ReclaimTarget[]) => Promise; destroy: (target: DestructiveTarget, confirmation: string) => Promise; + /** Run the orphaned-snapshot sweep and report what it found *and refused*. */ + runSweep: () => Promise; clearOutcome: () => void; } @@ -63,16 +73,24 @@ export function useDiskUsage(): DiskUsageState { try { const next = await commands.getDockerDiskUsage(); if (generation.current !== mine) return; - setReport(next); // Planning is cheap and always wanted: the classification is what makes // the numbers actionable, and it reuses the report rather than scanning // again. const nextPlan = await commands.listReclaimable(next); if (generation.current !== mine) return; + // Both land together, or neither does. Setting the report before + // awaiting the plan would render this scan's totals above the *previous* + // scan's still-clickable tick list if the plan call failed. + setReport(next); setPlan(nextPlan); } catch (e) { if (generation.current !== mine) return; setError(String(e)); + // The old report is left on screen deliberately — it is still an + // accurate measurement of an earlier moment, and the error says the + // refresh failed. What must not survive is a plan describing a scan the + // user can no longer see the totals for, but that cannot happen: the two + // only ever move together. } finally { if (generation.current === mine) setScanning(false); } @@ -85,9 +103,16 @@ export function useDiskUsage(): DiskUsageState { try { const result = await commands.reclaim(targets); setOutcome(result); - // Deliberately no automatic re-scan. It costs another `df()`, and the + // **The plan is now stale and must not stay clickable.** Its rows + // describe objects this call just removed, so leaving them ticked lets + // the user fire the same reclaim again against nothing. Dropping the plan + // (not the report) leaves the totals on screen, marked as measured before + // the reclaim, with the tick list gone. + // + // Deliberately no automatic re-scan: it costs another `df()`, and the // outcome already reports measured bytes for every target — a user who // wants the new totals asks for them. + setPlan(null); } catch (e) { setError(String(e)); } finally { @@ -101,6 +126,53 @@ export function useDiskUsage(): DiskUsageState { try { const result = await commands.destroyProjectDiskObject(target, confirmation); setOutcome({ results: [result], total_freed_bytes: result.freed_bytes }); + // Same reasoning as `runReclaim`: the destructive list named an object + // that is now gone. + setPlan(null); + } catch (e) { + setError(String(e)); + } finally { + setWorking(false); + } + }, []); + + /** + * The startup sweep, on demand. + * + * Not the same as ticking "superseded snapshot layers", even though both end + * up removing the same images: this reports `in_use` — the orphans Docker + * *refused* to delete because a stopped project's container still needs + * them. That refusal is the sweep's third safety net and it is invisible + * everywhere else in the app, because every existing caller throws the + * report away. + */ + const runSweep = useCallback(async () => { + setWorking(true); + setError(null); + try { + const sweep = await commands.sweepOrphanedSnapshots(); + if (sweep.unavailable) { + setError(sweep.unavailable); + return; + } + const refused = + sweep.in_use > 0 + ? ` ${sweep.in_use} were left alone because a container is still built from them — start and stop, or recreate, that project and a later sweep gets them.` + : ""; + setOutcome({ + results: [ + { + target: { kind: "dangling_snapshots" }, + destroyed: null, + ok: sweep.failed.length === 0, + freed_bytes: sweep.reclaimed_bytes, + projected_bytes: null, + message: `Swept ${sweep.removed.length} superseded image(s).${refused}`, + }, + ], + total_freed_bytes: sweep.reclaimed_bytes, + }); + setPlan(null); } catch (e) { setError(String(e)); } finally { @@ -110,5 +182,17 @@ export function useDiskUsage(): DiskUsageState { const clearOutcome = useCallback(() => setOutcome(null), []); - return { report, plan, scanning, working, error, outcome, scan, runReclaim, destroy, clearOutcome }; + return { + report, + plan, + scanning, + working, + error, + outcome, + scan, + runReclaim, + destroy, + runSweep, + clearOutcome, + }; } diff --git a/app/src/lib/formatBytes.test.ts b/app/src/lib/formatBytes.test.ts index f832838..aabd3d1 100644 --- a/app/src/lib/formatBytes.test.ts +++ b/app/src/lib/formatBytes.test.ts @@ -45,6 +45,24 @@ describe("formatBytes", () => { expect(formatBytes(2_500_000_000_000)).toBe("2.5 TB"); }); + it("promotes the unit when rounding lands on a whole step", () => { + // `toFixed` runs after the divide loop, so a value just under a boundary + // rounds up into a unit the loop had already ruled out. This is the app's + // only byte formatter and the panel is full of near-boundary sizes. + expect(formatBytes(999_999)).toBe("1.0 MB"); + expect(formatBytes(999_999_999)).toBe("1.0 GB"); + expect(formatBytes(999_999_999_999)).toBe("1.0 TB"); + expect(formatBytes(1_048_575, { binary: true })).toBe("1.0 MB"); + + // Just below the rounding threshold it must NOT promote. + expect(formatBytes(999_949)).toBe("999.9 KB"); + expect(formatBytes(999_400, { precision: 0 })).toBe("999 KB"); + + // The top unit has nowhere to go: it renders a whole step rather than + // running off the end of the unit array. + expect(formatBytes(999_999_999_999_999_999)).toBe("1000.0 PB"); + }); + it("renders an em dash for a size the daemon did not compute", () => { // Docker reports -1 for "not calculated" on shared sizes and volume ref // counts. `NaN GB` in the middle of a table is worse than nothing. diff --git a/app/src/lib/formatBytes.ts b/app/src/lib/formatBytes.ts index 6c2b0a1..a66cb11 100644 --- a/app/src/lib/formatBytes.ts +++ b/app/src/lib/formatBytes.ts @@ -1,10 +1,14 @@ /** * The one byte formatter. * - * Before this existed the app had four of them — `projects/home/format.ts`, + * The app had four of them — `projects/home/format.ts`, * `projects/migrationCopy.ts`, `settings/UpdateDialog.tsx` and an inline * `toFixed(1)` in `useProjectActions.ts` — disagreeing about the divisor, the - * unit labels and the precision. They are now expressed in terms of this. + * unit labels and the precision. The first two now delegate here. + * + * The other two deliberately do not, yet: `UpdateDialog` renders KB at + * `toFixed(0)`, so re-pointing it would change what a download size reads as, + * and neither is on the Disk panel's path. They are the remaining copies. * * ## Why the default is base 1000 * @@ -14,8 +18,12 @@ * same build cache would read as a bug in the panel. So decimal is the default * and binary is opt-in, rather than the other way round. * - * Both existing conventions are preserved exactly, so re-pointing the old - * call sites changed no rendered string: + * Both existing conventions are preserved for every size either call site can + * realistically produce — a file size or a payload size, i.e. a non-negative + * finite number below a terabyte. Outside that range this deliberately differs + * from what it replaced: a negative or `NaN` input now renders `—` rather than + * `-1 B` or `NaN GB`, and the unit ladder continues past GB instead of + * stopping there. * * - `{ }` → `41.0 MB` (decimal, what migration used) * - `{ binary: true }` → `1.5 GB` (÷1024 with decimal-style @@ -56,6 +64,17 @@ export function formatBytes(bytes: number, options: FormatBytesOptions = {}): st value /= step; unit += 1; } + + // **Promote again if rounding pushed the value back up to a whole step.** + // `toFixed` runs after the loop, so 999,999 B divides to 999.999 KB and then + // renders as "1000.0 KB" — a unit the loop had already decided against. The + // same happens at every boundary (999,999,999 → "1000.0 MB", and 1,048,575 + // → "1024.0 KB" in binary). + if (unit < units.length - 1 && Number(value.toFixed(precision)) >= step) { + value /= step; + unit += 1; + } + // Whole bytes never get a decimal point: `512 B`, not `512.0 B`. return unit === 0 ? `${Math.round(bytes)} ${units[0]}` @@ -73,7 +92,7 @@ export function formatBytesDelta(bytes: number, options?: FormatBytesOptions): s } /** - * `up to 12.3 GB` / `nothing` — for a bound rather than a measurement. + * `up to 12.3 GB` — for a bound rather than a measurement. * * The Disk panel is careful about this distinction: every figure it shows is * measured except a compaction's yield, which cannot be known until it runs. diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 5af5033..fa97cd5 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -836,8 +836,14 @@ export interface ProjectDiskRow { /** Bytes shared with another image — almost always the base. */ snapshot_shared_bytes: number; /** Layers stacked above the base image: **one per container recreation**. - * This is the number that explains why a snapshot grows. */ + * This is the number that explains why a snapshot grows — but only when + * `base_lineage_known` is true. Otherwise it counts the base's layers too. */ snapshot_commit_layers: number; + /** Whether the base image this snapshot descends from could be identified. + * False is the normal case for a project created before the + * `triple-c.base-image-id` label existed; the layer count must not be + * presented as a recreation count then. */ + base_lineage_known: boolean; /** Bytes those layers account for. `null` when the base image is gone and * the split cannot be measured — never a guess. */ snapshot_above_base_bytes: number | null; @@ -989,7 +995,11 @@ export interface ReclaimPlan { } export interface ReclaimResult { - target: ReclaimTarget; + /** The reclaim target this reports on, or `null` when it reports a destroy. + * Exactly one of `target` / `destroyed` is ever set — a destroy used to come + * back wearing a `ReclaimTarget` that named work it had not done. */ + target: ReclaimTarget | null; + destroyed: DestructiveTarget | null; ok: boolean; freed_bytes: number; /** What was projected beforehand, for the one action that projects. */ -- 2.52.0 From d6f065a2b62be1ba13dae943e32530f9f240e04a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 23 Aug 2026 11:11:43 -0700 Subject: [PATCH 10/43] Fix HIGH and MEDIUM frontend defects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files pane - F16: a drag-out released back inside the app no longer re-imports its own staged copy over the container original. An in-flight flag (cleared from the drag plugin's `onEvent` channel, with a watchdog) suppresses the drop and the "Drop files into …" hint, and an exact staged-path filter is the second line of defence — the `path|size|modified` cache could otherwise write a minutes-old snapshot over a file an agent had since rewritten. - F17: a slow upload/rename no longer yanks the user back to the directory the operation started in. Every operation captures its target path and re-lists only if the user is still there; failures go to the toast host either way. - The grid keeps keyboard focus. Roving tabindex (one tab stop, not one per row) plus focus restore after navigation, rename commit/cancel and Escape. - Transient failures now surface in `ToastHost` (z-[60], persistent aria-live) instead of a `role="alert"` 300 rows down a scroller or behind a modal overlay. The inline error is kept only for the listing failure. - `navigate` is sequenced by generation; "Save to host…" sets `busy`. - Grid a11y: column headers, a text affordance for folder vs file, a live region that is mounted empty and announces completion, Label-in-Name fixed. - FileViewerModal: the blob URL is released only once its replacement exists; the preview is a focusable, named, scrollable region. Native drop routing - New `lib/dropTarget.ts`: the hit test now refuses a drop while any `[aria-modal="true"]` dialog or `[data-blocks-drop]` overlay is up, and checks z-order where the environment can answer it. Shared by FilesTab and TerminalView; App's shutdown overlay opts in. Disk - A partially failed reclaim says so in words ("… — 2 of 5 failed"), not by hue alone. - The scan/reclaim race is closed: every mutation retires an in-flight scan, so a scan can no longer repaint a pre-reclaim report plus a clickable plan of objects that are gone. Scan is disabled while working; the status is a live region; a failed destructive action keeps its dialog open and reports there. - The "unknown" layer count gets a screen-reader fallback; `--text-disabled` no longer carries live information. Terminal / OAuth - After the toast is dismissed, a truncated heuristic guess can no longer fill the slot that an exact OSC 8 or relay URL occupied — the detector remembers every exact URL and drops any candidate that is a strict prefix of one. - The prompt is reachable by keyboard: Ctrl+Shift+O jumps to the default action, Escape dismisses, focus returns to the terminal, and auto-dismiss holds off while focus is inside. It deliberately does not steal focus. - UrlToast renders through `ui/Button` and `--shadow-overlay`. Elsewhere - AuthBridgeRow: a pushed `auth-bridge-changed` status always outranks an older awaited toggle result. - The last two ad-hoc byte formatters route through `lib/formatBytes`. Contract for the backend agent: `upload_file_to_container` refusing to overwrite must satisfy `isFileExistsError` in `src/lib/uploadErrors.ts` (marker `FILE_EXISTS`) and accept an `overwrite` argument; the frontend turns that into an `ui/Modal` Replace/Skip prompt rather than a raw error string. Tests: 536 -> 627 passing. `npm run build` and `npx tsc --noEmit` green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc --- app/src/App.tsx | 3 + .../ClaudeCodeSettingsEditor.test.tsx | 73 ++++ .../projects/home/FileViewerModal.tsx | 58 ++- .../projects/home/FilesTab.test.tsx | 354 ++++++++++++++++- app/src/components/projects/home/FilesTab.tsx | 359 +++++++++++++++--- .../projects/home/OverwriteConfirmModal.tsx | 73 ++++ .../home/config/AuthBridgeRow.test.tsx | 74 ++++ .../projects/home/config/AuthBridgeRow.tsx | 46 ++- .../home/config/RuntimeSection.test.tsx | 52 +++ .../components/settings/DiskProjectTable.tsx | 28 +- .../components/settings/DiskSettings.test.tsx | 214 +++++++++++ app/src/components/settings/DiskSettings.tsx | 102 ++++- app/src/components/settings/UpdateDialog.tsx | 12 +- .../components/terminal/TerminalView.test.tsx | 205 +++++++++- app/src/components/terminal/TerminalView.tsx | 113 ++++-- app/src/components/terminal/UrlToast.test.tsx | 109 +++++- app/src/components/terminal/UrlToast.tsx | 156 ++++---- .../components/ui/TypedConfirmModal.test.tsx | 13 + app/src/components/ui/TypedConfirmModal.tsx | 20 +- app/src/hooks/useDiskUsage.test.tsx | 153 +++++++- app/src/hooks/useDiskUsage.ts | 142 ++++--- app/src/hooks/useFileManager.test.ts | 223 ++++++++++- app/src/hooks/useFileManager.ts | 300 +++++++++++++-- app/src/hooks/useProjectActions.ts | 10 +- app/src/lib/dropTarget.test.ts | 76 ++++ app/src/lib/dropTarget.ts | 81 ++++ app/src/lib/formatBytes.test.ts | 16 + app/src/lib/formatBytes.ts | 13 +- app/src/lib/tauri-commands.ts | 17 +- app/src/lib/uploadErrors.test.ts | 81 ++++ app/src/lib/uploadErrors.ts | 116 ++++++ app/src/lib/urlDetector.test.ts | 49 +++ app/src/lib/urlDetector.ts | 94 ++++- 33 files changed, 3120 insertions(+), 315 deletions(-) create mode 100644 app/src/components/projects/home/OverwriteConfirmModal.tsx create mode 100644 app/src/lib/dropTarget.test.ts create mode 100644 app/src/lib/dropTarget.ts create mode 100644 app/src/lib/uploadErrors.test.ts create mode 100644 app/src/lib/uploadErrors.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index 7245f4f..0226df1 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -156,6 +156,9 @@ export default function App() { className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg-primary)]/95 backdrop-blur-sm" role="status" aria-live="polite" + /* Covers the whole window, so no pane underneath may accept a + native file drop while it is up — see `lib/dropTarget.ts`. */ + data-blocks-drop="true" data-testid="shutdown-overlay" >
    diff --git a/app/src/components/projects/ClaudeCodeSettingsEditor.test.tsx b/app/src/components/projects/ClaudeCodeSettingsEditor.test.tsx index e7d61a3..4993a32 100644 --- a/app/src/components/projects/ClaudeCodeSettingsEditor.test.tsx +++ b/app/src/components/projects/ClaudeCodeSettingsEditor.test.tsx @@ -119,5 +119,78 @@ describe("ClaudeCodeSettingsEditor", () => { "off", ); }); + + /** + * Auto-scroll is the second inverted field and had no project-scope test at + * all — every assertion above rides on `session_recap_disabled`, so a + * `BOOLEAN_FIELDS` entry that lost its `invert` flag would be caught for + * one of the two and pass silently for the other. It is stored as + * `auto_scroll_disabled`, so every value here reads back the other way up. + */ + describe("auto-scroll", () => { + const AUTO = "Auto-scroll"; + + it("starts on Global, which is not the same as on", () => { + // Claude Code scrolls by default, so an inheriting project *behaves* + // as on — but it has taken no position, and rendering it as "On" would + // make a later global change look like it had no effect. + renderEditor(null, "project"); + expect((screen.getByLabelText(AUTO) as HTMLSelectElement).value).toBe("global"); + }); + + it("stores the disabled sense in both directions", () => { + const onSave = renderEditor(null, "project"); + const auto = screen.getByLabelText(AUTO); + + fireEvent.change(auto, { target: { value: "off" } }); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ auto_scroll_disabled: true }), + ); + + fireEvent.change(auto, { target: { value: "on" } }); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ auto_scroll_disabled: false }), + ); + }); + + it("hands the setting back to the global level when Global is chosen", () => { + // Back to no opinion, and with nothing else set that collapses the + // whole object to `null` — the value that means "adds nothing over the + // global settings". + const onSave = renderEditor( + { ...CLAUDE_CODE_DEFAULTS, auto_scroll_disabled: true }, + "project", + ); + fireEvent.change(screen.getByLabelText(AUTO), { target: { value: "global" } }); + expect(onSave).toHaveBeenCalledWith(null); + }); + + it("reads a stored override back the right way up", () => { + renderEditor({ ...CLAUDE_CODE_DEFAULTS, auto_scroll_disabled: true }, "project"); + expect((screen.getByLabelText(AUTO) as HTMLSelectElement).value).toBe("off"); + }); + }); + }); + + /** + * The inverted fields store a *deviation*, so a stored `false` is the one + * value that means "the user deliberately re-enabled the default". Nothing + * asserted it: every existing test drives the `true` (turned off) direction + * or the `null` (untouched) one, and both scopes would still read correctly + * if the inversion were dropped from the `false` branch alone. + */ + describe.each([ + ["session_recap_disabled", "Session recap"] as const, + ["auto_scroll_disabled", "Auto-scroll"] as const, + ])("a stored false on %s", (key, label) => { + it("reads as On at project scope, not as Off", () => { + renderEditor({ ...CLAUDE_CODE_DEFAULTS, [key]: false }, "project"); + expect((screen.getByLabelText(label) as HTMLSelectElement).value).toBe("on"); + }); + + it("reads as on at global scope, where the control is a switch", () => { + renderEditor({ ...CLAUDE_CODE_DEFAULTS, [key]: false }); + expect(screen.getByRole("switch", { name: label })).toBeChecked(); + }); }); }); diff --git a/app/src/components/projects/home/FileViewerModal.tsx b/app/src/components/projects/home/FileViewerModal.tsx index 213bf15..672ec88 100644 --- a/app/src/components/projects/home/FileViewerModal.tsx +++ b/app/src/components/projects/home/FileViewerModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { FileEntry } from "../../../lib/types"; import { readContainerFile } from "../../../lib/tauri-commands"; import Button from "../../ui/Button"; @@ -40,11 +40,28 @@ type Preview = export default function FileViewerModal({ projectId, entry, onClose, onSaveToHost }: Props) { const [preview, setPreview] = useState({ kind: "loading" }); + /** + * The object URL currently on screen. + * + * This used to be an effect-local variable revoked from the effect's own + * cleanup, which runs *before* the replacement effect body — so switching + * entries (or any re-run of the effect for the same entry) released the URL + * the `` was still pointing at, and a blank image was the result until + * the new read landed. If the new read failed, it stayed blank. So the + * hand-over is explicit instead: a URL is revoked only once its replacement + * exists, and unmount is what releases the last one. + */ + const objectUrlRef = useRef(null); + + /** Release the previous URL now that something else is on screen. */ + const replaceObjectUrl = (next: string | null) => { + const previous = objectUrlRef.current; + objectUrlRef.current = next; + if (previous && previous !== next) URL.revokeObjectURL(previous); + }; + 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 { @@ -58,16 +75,20 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos // A truncated image is not a smaller image, it is a broken one. if (result.truncated) { setPreview({ kind: "too-large" }); + replaceObjectUrl(null); return; } const blob = new Blob([bytes], { type: imageMimeFor(entry.name) ?? "image/png" }); - objectUrl = URL.createObjectURL(blob); - setPreview({ kind: "image", url: objectUrl }); + const url = URL.createObjectURL(blob); + // The replacement is in hand, so the previous one can go. + setPreview({ kind: "image", url }); + replaceObjectUrl(url); return; } if (looksBinary(bytes)) { setPreview({ kind: "unsupported" }); + replaceObjectUrl(null); return; } @@ -78,6 +99,7 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos shownBytes: bytes.length, trueSize: result.size, }); + replaceObjectUrl(null); } catch (e) { if (!cancelled) setPreview({ kind: "error", message: String(e) }); } @@ -85,10 +107,19 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos return () => { cancelled = true; - if (objectUrl) URL.revokeObjectURL(objectUrl); }; }, [projectId, entry.name, entry.path]); + // The bytes are released when the dialog goes, which is the whole reason the + // preview is a `blob:` URL rather than a `data:` one. + useEffect( + () => () => { + if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current); + objectUrlRef.current = null; + }, + [], + ); + const footer = ( <>
    + {/* The one failure that stays inline: it explains why the grid below is + empty, it is in context, and there are no rows for it to scroll + behind. Every *transient* failure — upload, rename, mkdir, + save-to-host, staging — goes to `ToastHost` instead, which is above + the file viewer's overlay and does not scroll away. */} {error && (
    {error} @@ -379,9 +563,25 @@ export default function FilesTab({ project }: Props) {
    ) : ( + + + + + + + + {creatingFolder && ( - + setSelected(entry.name)} + onClick={() => { + setSelected(entry.name); + setActiveRow(entry.name); + }} onDoubleClick={() => openEntry(entry)} {...dragOutProps(entry)} onKeyDown={(e) => { @@ -440,13 +652,17 @@ export default function FilesTab({ project }: Props) { if (e.key === "Enter") { e.preventDefault(); setSelected(entry.name); + setActiveRow(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); + moveActive(entry.name, e.key === "ArrowDown" ? 1 : -1); + } else if (e.key === "Home" || e.key === "End") { + e.preventDefault(); + moveActive(entry.name, e.key === "Home" ? "first" : "last"); } }} className={rowClass(isSelected)} @@ -476,6 +692,14 @@ export default function FilesTab({ project }: Props) { : "text-[var(--text-primary)]" }`} > + {/* Directory-ness was carried by hue and an + `aria-hidden` emoji, i.e. by nothing at all for a + screen reader. The emoji stays hidden — it reads + as "file folder" in some voices and as nothing in + others — and the word is what is announced. */} + + {entry.is_directory ? "Folder, " : "File, "} + {entry.is_directory && } {entry.name} {entry.is_symlink && ( @@ -498,8 +722,14 @@ export default function FilesTab({ project }: Props) { +
    + Name + + Size + + Modified + + Actions +
    setActiveRow(PARENT_ROW)} + onDoubleClick={openParent} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); - goUp(); + openParent(); } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); - moveFocus(e.currentTarget, e.key === "ArrowDown" ? 1 : -1); + moveActive(PARENT_ROW, e.key === "ArrowDown" ? 1 : -1); + } else if (e.key === "Home" || e.key === "End") { + e.preventDefault(); + moveActive(PARENT_ROW, e.key === "Home" ? "first" : "last"); } }} className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors" > + Folder, .. @@ -430,9 +637,14 @@ export default function FilesTab({ project }: Props) { return (
    {!isRenaming && ( <> + {/* WCAG 2.5.3: the accessible name has to *contain* + the visible label, so the row context is appended + rather than substituted. "Rename notes.txt" used + to be the whole name, which left a voice-control + user saying "click Rename" at a button that had + no such name. */} {!entry.is_directory && (
    )} + {conflict && ( + + )} + {viewing && ( void; +} + +/** + * "That name is taken — replace it?" + * + * This exists because the backend stopped overwriting silently, and a raw + * error string would have been a worse answer than the old silent clobber: it + * tells the user their drop failed without telling them it *can* succeed. The + * dialog names the file and the directory, because a drop is aimed with a + * mouse and "notes.txt" alone does not say which `notes.txt`. + * + * The blanket answers only appear when there is something to apply them to — a + * single-file drop with "Replace all" on it invites the reflex of clicking the + * widest button for no benefit. + * + * Dismissing (Escape, ✕, click-outside) is a **skip**, never a replace: the + * destructive answer has to be chosen explicitly. + */ +export default function OverwriteConfirmModal({ name, directory, remaining, onChoose }: Props) { + const footer = ( + <> + {remaining > 0 && ( + <> + + + + )} + + + + ); + + return ( + onChoose("skip")} + footer={footer} + widthClassName="w-[30rem]" + > +

    + {name} already exists in{" "} + {directory}. Replacing it overwrites the container's + copy, and that cannot be undone from here. +

    + {remaining > 0 && ( +

    + {remaining} more file{remaining === 1 ? "" : "s"} still to upload. +

    + )} +
    + ); +} diff --git a/app/src/components/projects/home/config/AuthBridgeRow.test.tsx b/app/src/components/projects/home/config/AuthBridgeRow.test.tsx index 12e0199..98f7eb5 100644 --- a/app/src/components/projects/home/config/AuthBridgeRow.test.tsx +++ b/app/src/components/projects/home/config/AuthBridgeRow.test.tsx @@ -101,6 +101,80 @@ describe("AuthBridgeRow", () => { expect(screen.queryByText(/Port 1:/)).not.toBeInTheDocument(); }); + /** + * The two halves of this row disagree about *when*, not about *what*. + * + * `set_auth_bridge_enabled` resolves with a status sampled as it returned; + * the poller's event carries one sampled afterwards. Writing the awaited + * value unconditionally therefore rolls the row back in time whenever the + * two overlap — the row says "Watching" while a port is bound, which is the + * exact silent failure the event subscription was added to end. These two + * hold the ordering down from both the resolve and the reject side. + */ + describe("a pushed event outranks an older awaited result", () => { + /** A toggle that will not settle until the test says so. */ + function deferToggle() { + let settle!: (s: AuthBridgeStatus) => void; + let fail!: (e: unknown) => void; + setAuthBridgeEnabled.mockImplementation( + () => + new Promise((resolve, reject) => { + settle = resolve; + fail = reject; + }), + ); + return { settle: (s: AuthBridgeStatus) => settle(s), fail: (e: unknown) => fail(e) }; + } + + const BRIDGING: AuthBridgeStatus = { + enabled: true, + active_ports: [{ port: 54545, family: "v4", bridged_at: "", ipv6_warning: null }], + conflicts: [], + }; + + async function startToggleThenPush() { + render(); + await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled()); + await waitFor(() => expect(emit).not.toBeNull()); + + fireEvent.click(screen.getByRole("switch", { name: "Auth bridge" })); + await waitFor(() => expect(setAuthBridgeEnabled).toHaveBeenCalledWith("p1", true)); + + // The poller binds a port while the command is still in flight. + emit!({ project_id: "p1", status: BRIDGING }); + expect(await screen.findByText("Bridging 1 port")).toBeInTheDocument(); + } + + it("keeps the newer state when the command settles with the older one", async () => { + const toggle = deferToggle(); + await startToggleThenPush(); + + // …and only now returns the snapshot it took *before* that port existed. + toggle.settle({ enabled: true, active_ports: [], conflicts: [] }); + + await waitFor(() => + expect(screen.getByRole("switch", { name: "Auth bridge" })).not.toBeDisabled(), + ); + expect(screen.getByText("Bridging 1 port")).toBeInTheDocument(); + expect(screen.getByText("127.0.0.1:54545")).toBeInTheDocument(); + expect(screen.queryByText("Watching")).not.toBeInTheDocument(); + }); + + it("does not let the rollback undo a status pushed while it was failing", async () => { + // The command failed, so the error belongs on screen — but the bridge + // demonstrably came up, and reverting the switch to off would contradict + // the port listed right beside it. + const toggle = deferToggle(); + await startToggleThenPush(); + + toggle.fail("bridge probe timed out"); + + expect(await screen.findByText(/probe timed out/)).toBeInTheDocument(); + expect(screen.getByText("Bridging 1 port")).toBeInTheDocument(); + expect(screen.getByRole("switch", { name: "Auth bridge" })).toBeChecked(); + }); + }); + it("puts the switch back if the command rejects", async () => { setAuthBridgeEnabled.mockRejectedValue("Project p1 not found"); render(); diff --git a/app/src/components/projects/home/config/AuthBridgeRow.tsx b/app/src/components/projects/home/config/AuthBridgeRow.tsx index ea8bc92..a8613e7 100644 --- a/app/src/components/projects/home/config/AuthBridgeRow.tsx +++ b/app/src/components/projects/home/config/AuthBridgeRow.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import { getAuthBridgeStatus, @@ -77,13 +77,35 @@ export default function AuthBridgeRow({ project }: { project: Project }) { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + /** + * Which write to `status` is the newest — the same "is this still mine?" + * guard `useDiskUsage` and `useContainerMigration` use around their async + * writes, and needed here for a reason that is easy to miss. + * + * There are two sources of truth for this row and only one of them is + * ordered. `set_auth_bridge_enabled` resolves with a status *sampled at the + * moment it returned*; the poller's `auth-bridge-changed` event carries one + * sampled later. Awaiting the command therefore hands back a value that may + * already be historical, and writing it unconditionally is how the row ends + * up saying "Watching" while a port is in fact bound — the failure mode the + * event subscription exists to prevent, reintroduced one line below it. + * + * So every write claims a generation and only lands if it still holds it. + * A pushed event always claims a fresh one, which is what makes it win over + * an older awaited result no matter which order the two arrive in. + */ + const generation = useRef(0); + useEffect(() => { + const mine = ++generation.current; let cancelled = false; setStatus(null); setError(null); getAuthBridgeStatus(projectId) .then((s) => { - if (!cancelled) setStatus(s); + // The initial fetch races the poller exactly like the toggle does: an + // event can land first and describe a bridge this reply predates. + if (!cancelled && generation.current === mine) setStatus(s); }) .catch((e) => { if (!cancelled) setError(String(e)); @@ -98,6 +120,9 @@ export default function AuthBridgeRow({ project }: { project: Project }) { let unlisten: (() => void) | undefined; listen(AUTH_BRIDGE_EVENT, (event) => { if (event.payload.project_id !== projectId) return; + // A pushed status is the most recent observation that exists, so it + // claims the newest generation and invalidates anything still in flight. + generation.current += 1; setStatus(event.payload.status); }) .then((un) => { @@ -116,13 +141,24 @@ export default function AuthBridgeRow({ project }: { project: Project }) { setBusy(true); setError(null); // Optimistic, so the switch responds even though enabling has to await a - // container probe. The command's return value replaces it either way. + // container probe. It claims a generation like every other write, so a + // pushed event that lands mid-flight supersedes it rather than being + // undone by the settle below. + const mine = ++generation.current; setStatus((s) => (s ? { ...s, enabled: next } : s)); try { - setStatus(await setAuthBridgeEnabled(projectId, next)); + const settled = await setAuthBridgeEnabled(projectId, next); + // Stale by the time it arrived: the poller has already told us + // something newer, and `settled` predates it. + if (generation.current !== mine) return; + setStatus(settled); } catch (e) { - setStatus((s) => (s ? { ...s, enabled: !next } : s)); + // The error is reported either way — the command really did fail — but + // the rollback must not resurrect the pre-toggle value over a status + // the poller pushed while the command was failing. setError(String(e)); + if (generation.current !== mine) return; + setStatus((s) => (s ? { ...s, enabled: !next } : s)); } finally { setBusy(false); } diff --git a/app/src/components/projects/home/config/RuntimeSection.test.tsx b/app/src/components/projects/home/config/RuntimeSection.test.tsx index e8b7201..4733c09 100644 --- a/app/src/components/projects/home/config/RuntimeSection.test.tsx +++ b/app/src/components/projects/home/config/RuntimeSection.test.tsx @@ -107,6 +107,58 @@ describe("RuntimeSection — VPN support toggle", () => { }); }); +/** + * `scope="project"` on the settings editor is one prop with no visible owner, + * and deleting it fails silently in the worst possible direction: the editor + * falls back to `"global"`, every three-state control collapses to an on/off + * switch, and a field the project is *inheriting* as on renders flat Off. The + * user then reads a lie and, worse, flipping that switch writes a deliberate + * `false` that overrides the global On they thought they were looking at. + * + * Nothing asserted the prop was passed, so these go through what is rendered + * rather than through props — a switch where a select belongs is exactly the + * regression, and it is visible from the outside. + */ +describe("RuntimeSection — Claude Code settings are edited at project scope", () => { + beforeEach(() => vi.clearAllMocks()); + + it("gives every setting the third Global state a project can inherit through", () => { + renderSection(); + const focus = screen.getByLabelText("Focus mode") as HTMLSelectElement; + expect( + Array.from(focus.querySelectorAll("option")).map((o) => o.getAttribute("value")), + ).toEqual(["global", "off", "on"]); + }); + + it("renders an untouched setting as inheriting, not as Off", () => { + // `claude_code_settings: null` means "this project has no opinion", which + // is not the same instruction as off. At global scope the same field is a + // plain unchecked switch — indistinguishable from a user who turned it + // off, and the reason the missing prop would never be noticed. + renderSection({ claude_code_settings: null }); + expect((screen.getByLabelText("Focus mode") as HTMLSelectElement).value).toBe( + "global", + ); + expect(screen.queryByRole("switch", { name: "Focus mode" })).not.toBeInTheDocument(); + }); + + it("keeps a stored project override visible over the inherited value", () => { + renderSection({ + claude_code_settings: { + tui_mode: null, + effort: null, + auto_scroll_disabled: null, + focus_mode: true, + show_thinking_summaries: null, + session_recap_disabled: null, + env_scrub: null, + prompt_caching_1h: null, + }, + }); + expect((screen.getByLabelText("Focus mode") as HTMLSelectElement).value).toBe("on"); + }); +}); + describe("RuntimeSection — auth bridge toggle", () => { beforeEach(() => vi.clearAllMocks()); diff --git a/app/src/components/settings/DiskProjectTable.tsx b/app/src/components/settings/DiskProjectTable.tsx index 0c9b9e3..a787fbf 100644 --- a/app/src/components/settings/DiskProjectTable.tsx +++ b/app/src/components/settings/DiskProjectTable.tsx @@ -17,6 +17,10 @@ const LAYERS_HELP = const NEXT_COMMIT_HELP = "The container's writable layer. This is exactly what the next recreation will stack onto the snapshot, and it never comes back after that."; +/** Why a layer count reads "unknown" rather than as a number. */ +const layersUnknownHelp = (layers: number) => + `${layers} layers in total, but this project predates the base-image label, so there is no way to tell which of them are commits. Migrating it to the current base restores the count.`; + /** `—` for a column with nothing in it, so an empty cell never reads as zero. */ function cell(bytes: number, present: boolean) { return present ? formatBytes(bytes) : "—"; @@ -141,11 +145,25 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props // The base this descends from is unknown, so the count // includes the base's own layers and does not mean // "recreations". Saying so beats printing a wrong number. - - unknown - + // + // The explanation is the only thing standing between + // "unknown" and reading as a bug, so it cannot live in the + // tooltip alone: `Tooltip` portals a plain div with no + // `role` and no `aria-describedby`, and wrapped around + // children it has no focus handlers either — so on hover- + // less input it is unreachable and to a screen reader it + // does not exist. Same treatment as the column headers + // above: tooltip for the mouse, `sr-only` text for + // everything else. + <> + + unknown + + + {" "} + — {layersUnknownHelp(row.snapshot_commit_layers)} + + ) : ( {row.snapshot_commit_layers} diff --git a/app/src/components/settings/DiskSettings.test.tsx b/app/src/components/settings/DiskSettings.test.tsx index 07ec58d..f2777b3 100644 --- a/app/src/components/settings/DiskSettings.test.tsx +++ b/app/src/components/settings/DiskSettings.test.tsx @@ -6,6 +6,7 @@ import type { ProjectDiskRow, ReclaimItem, ReclaimPlan, + ReclaimResult, ReclaimTarget, } from "../../lib/types"; @@ -104,6 +105,16 @@ const item = (over: Partial = {}): ReclaimItem => ({ ...over, }); +const result = (over: Partial = {}): ReclaimResult => ({ + target: { kind: "dangling_snapshots" }, + destroyed: null, + ok: true, + freed_bytes: 0, + projected_bytes: null, + message: "Removed 3 images.", + ...over, +}); + const plan = (over: Partial = {}): ReclaimPlan => ({ items: [item()], destructive: [], @@ -620,6 +631,209 @@ describe("DiskSettings", () => { expect(within(outcome).getByText(/projected up to 7\.0 GB, actually 5\.1 GB/)).toBeInTheDocument(); }); + // ------------------------------------------------------------------------- + // Failure has to reach the words, and the place the user is looking + // ------------------------------------------------------------------------- + + it("puts a partial failure in the headline, not only in the glyph's hue", async () => { + // This panel is where the "never encode status in colour alone" rule is + // documented, and the outcome headline used to say "Reclaimed 1.2 GB" for + // a run where most of the targets threw — only the glyph and its colour + // changed, which is exactly nothing to a screen reader or to anyone who + // does not read red as bad. + reclaim.mockResolvedValue({ + results: [ + result({ freed_bytes: 1_200_000_000 }), + result({ target: { kind: "migration_pins" } }), + result({ target: { kind: "probe_containers" } }), + result({ target: { kind: "build_cache", all: true }, ok: false }), + result({ target: { kind: "orphan_volume", name: "v" }, ok: false }), + ], + total_freed_bytes: 1_200_000_000, + }); + await renderAndScan(); + await screen.findByTestId("disk-safe-bucket"); + fireEvent.click(screen.getAllByRole("checkbox")[0]); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Reclaim" })); + }); + + const outcome = await screen.findByTestId("disk-outcome"); + expect( + within(outcome).getByText("Reclaimed 1.2 GB — 2 of 5 failed"), + ).toBeInTheDocument(); + }); + + it("keeps the plain wording when every target succeeded", async () => { + reclaim.mockResolvedValue({ + results: [result({ freed_bytes: 1_200_000_000 }), result()], + total_freed_bytes: 1_200_000_000, + }); + await renderAndScan(); + await screen.findByTestId("disk-safe-bucket"); + fireEvent.click(screen.getAllByRole("checkbox")[0]); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Reclaim" })); + }); + + const outcome = await screen.findByTestId("disk-outcome"); + expect(within(outcome).getByText("Reclaimed 1.2 GB")).toBeInTheDocument(); + expect(outcome.textContent).not.toMatch(/failed/); + }); + + it("keeps the typed confirmation open, and says why, when the deletion fails", async () => { + // The dialog used to close regardless, leaving the failure in a line at + // the very top of a panel the user had scrolled past to reach the row. + listReclaimable.mockResolvedValue( + plan({ + destructive: [ + { + target: { kind: "home_volume", project_id: "p-whp" }, + project_id: "p-whp", + project_name: "whp", + label: "Home volume", + loses: "Shell history and toolchains.", + bytes: 4_860_000_000, + blocked: null, + }, + ], + }), + ); + destroyProjectDiskObject.mockRejectedValue( + "volume triple-c-home-p-whp is in use by a running container", + ); + + await renderAndScan(); + await screen.findByTestId("disk-row-p-whp"); + fireEvent.click(screen.getByRole("button", { name: "Delete whp data" })); + await act(async () => { + fireEvent.click(screen.getByRole("menuitem", { name: /Delete home volume/ })); + }); + + const dialog = screen.getByRole("dialog"); + fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "whp" } }); + await act(async () => { + fireEvent.click(within(dialog).getByRole("button", { name: "Delete home volume" })); + }); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(within(screen.getByRole("dialog")).getByRole("alert")).toHaveTextContent( + /in use by a running container/, + ); + }); + + it("keeps the semi-safe confirmation open when the action fails", async () => { + listReclaimable.mockResolvedValue( + plan({ + items: [ + item({ + target: { kind: "compact_snapshot", project_id: "p-whp" }, + safety: "semi_safe", + label: "Compact whp's snapshot", + bytes: 5_100_000_000, + bytes_are_exact: false, + bytes_floor: 0, + }), + ], + }), + ); + reclaim.mockRejectedValue("compaction failed: no space left on device"); + + await renderAndScan(); + const semi = await screen.findByTestId("disk-semi-bucket"); + await act(async () => { + fireEvent.click(within(semi).getByRole("button", { name: "Run…" })); + }); + await act(async () => { + fireEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: "Run it" }), + ); + }); + + const dialog = screen.getByRole("dialog"); + expect(dialog).toBeInTheDocument(); + expect(within(dialog).getByRole("alert")).toHaveTextContent(/no space left on device/); + }); + + it("closes the confirmation once the action succeeds", async () => { + listReclaimable.mockResolvedValue( + plan({ + items: [ + item({ + target: { kind: "clear_caches", project_id: "p-whp", include_rustup: false }, + safety: "semi_safe", + label: "Clear whp's caches", + }), + ], + }), + ); + await renderAndScan(); + const semi = await screen.findByTestId("disk-semi-bucket"); + await act(async () => { + fireEvent.click(within(semi).getByRole("button", { name: "Run…" })); + }); + await act(async () => { + fireEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: "Run it" }), + ); + }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // Scan status: announced, and not startable mid-mutation + // ------------------------------------------------------------------------- + + it("announces the scan status through a live region", async () => { + // The status flips between three states with no other signal; without a + // live region wrapping it the change is silent. + render(); + const live = screen.getByRole("status"); + expect(live).toHaveAttribute("aria-live", "polite"); + expect(live).toHaveTextContent("Not scanned"); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Scan" })); + }); + // The glyph is `aria-hidden` but still part of `textContent`. + expect(screen.getByRole("status")).toHaveTextContent(/Scanned \d/); + }); + + it("cannot start a scan while a reclaim is still running", async () => { + // A scan launched on top of a mutation measures a daemon that is being + // changed underneath it — the hook can only throw such a result away, so + // the seconds are better not spent. + let finish: (value: unknown) => void = () => {}; + reclaim.mockReturnValue(new Promise((r) => (finish = r))); + + await renderAndScan(); + await screen.findByTestId("disk-safe-bucket"); + fireEvent.click(screen.getAllByRole("checkbox")[0]); + fireEvent.click(screen.getByRole("button", { name: "Reclaim" })); + + await waitFor(() => + expect(screen.getByRole("button", { name: "Scan again" })).toBeDisabled(), + ); + + await act(async () => { + finish({ results: [], total_freed_bytes: 0 }); + }); + expect(screen.getByRole("button", { name: "Scan again" })).toBeEnabled(); + }); + + it("gives the unknown layer count its explanation without a hover", async () => { + // The tooltip portals a div with no role and no `aria-describedby`, and + // wrapped around children it has no focus handlers either — so without the + // sr-only copy "unknown" reads as a bug to everyone not using a mouse. + getDockerDiskUsage.mockResolvedValue( + report({ projects: [row({ base_lineage_known: false, snapshot_commit_layers: 17 })] }), + ); + await renderAndScan(); + const projectRow = await screen.findByTestId("disk-row-p-whp"); + expect(projectRow.textContent).toMatch(/predates the base-image label/); + expect(projectRow.textContent).toMatch(/Migrating it to the current base restores the count/); + }); + it("surfaces a scan failure as an alert", async () => { getDockerDiskUsage.mockRejectedValue("Could not read Docker disk usage: no such host"); render(); diff --git a/app/src/components/settings/DiskSettings.tsx b/app/src/components/settings/DiskSettings.tsx index 720fcfc..cc21ba4 100644 --- a/app/src/components/settings/DiskSettings.tsx +++ b/app/src/components/settings/DiskSettings.tsx @@ -54,6 +54,13 @@ export default function DiskSettings() { const [ticked, setTicked] = useState>(new Set()); const [confirming, setConfirming] = useState(null); const [destroying, setDestroying] = useState(null); + // A dialog whose action failed stays open and says so *inside itself*. The + // hook's `error` is rendered at the top of a panel that is metres of scroll + // long, so a user who reached a project row through the table would have + // watched the dialog vanish and seen nothing take its place. This flag is + // what distinguishes "this dialog's action just failed" from a stale scan + // error that happened to still be sitting in `error` when it opened. + const [actionFailed, setActionFailed] = useState(false); // The plan is dropped after any reclaim, so a tick can never outlive the row // it was made against and be re-fired at an object that is already gone. @@ -68,6 +75,25 @@ export default function DiskSettings() { ); const selectedBytes = selected.reduce((sum, i) => sum + i.bytes, 0); + // Opening or closing either dialog clears the in-dialog failure with it, so + // one never starts out showing the previous attempt's error. + const openConfirming = (item: ReclaimItem) => { + setConfirming(item); + setActionFailed(false); + }; + const openDestroying = (item: DestructiveItem) => { + setDestroying(item); + setActionFailed(false); + }; + const closeConfirming = () => { + setConfirming(null); + setActionFailed(false); + }; + const closeDestroying = () => { + setDestroying(null); + setActionFailed(false); + }; + const toggle = (item: ReclaimItem) => { setTicked((prev) => { const next = new Set(prev); @@ -78,6 +104,10 @@ export default function DiskSettings() { }); }; + // Counted from the per-result list rather than from a flag: a reclaim of + // five targets can come back with two failures and a real byte total. + const failedCount = outcome?.results.filter((r) => !r.ok).length ?? 0; + const tone: StatusTone = scanning ? "unknown" : report ? "ok" : "off"; const statusLabel = scanning ? "Scanning" @@ -99,10 +129,21 @@ export default function DiskSettings() { {/* --- Scan --------------------------------------------------------- */}
    - - + {/* The status flips between "Scanning", "Scanned HH:MM:SS" and "Not + scanned" with no other signal. The live region is mounted here + unconditionally — wrapping it around the indicator only once there + is something to say would make the region *appear* already + populated, which is the one shape assistive tech does not announce. */} + + + Reads the whole Docker store; takes a few seconds on a large one. @@ -161,7 +202,7 @@ export default function DiskSettings() { @@ -196,7 +237,10 @@ export default function DiskSettings() {
    Build cache — whole daemon, not just Triple-C{" "} - + {/* Live information about where the figure came from, not a + disabled control — `--text-disabled` is ~4.1:1 and fails AA + at this size. */} + (via {report.build_cache.source})
    @@ -400,7 +444,7 @@ export default function DiskSettings() { @@ -434,9 +478,18 @@ export default function DiskSettings() { data-testid="disk-outcome" >
    + {/* The headline has to carry the failure in words. A partial + reclaim that freed something still has a byte figure worth + printing, so the count is appended to it rather than replacing + it — and the per-result lines below say *which* ones and why, + so this stops at how many. */} r.ok) ? "ok" : "error"} - label={`Reclaimed ${formatBytes(outcome.total_freed_bytes)}`} + tone={failedCount === 0 ? "ok" : "error"} + label={ + failedCount === 0 + ? `Reclaimed ${formatBytes(outcome.total_freed_bytes)}` + : `Reclaimed ${formatBytes(outcome.total_freed_bytes)} — ${failedCount} of ${outcome.results.length} failed` + } className="text-xs" /> ))} diff --git a/app/src/components/terminal/TerminalView.test.tsx b/app/src/components/terminal/TerminalView.test.tsx index 166dd21..3d646d3 100644 --- a/app/src/components/terminal/TerminalView.test.tsx +++ b/app/src/components/terminal/TerminalView.test.tsx @@ -1,7 +1,24 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, fireEvent, cleanup } from "@testing-library/react"; +import { render, fireEvent, cleanup, act } from "@testing-library/react"; import TerminalView, { supersedes } from "./TerminalView"; import { useAppState } from "../../store/appState"; +import { uploadHostFileToTerminal } from "../../lib/tauri-commands"; + +/** + * The window-wide native drag-drop listener, captured at registration. + * + * Tauri routes *every* file drop to *every* listener, which is the whole reason + * `TerminalView` hit-tests one — so a test that wants to know what the hit test + * decides has to be able to fire the event itself. + */ +const dragDrop = vi.hoisted(() => ({ + handler: null as null | ((event: unknown) => unknown), +})); + +/** The `terminal-output-{id}` listeners, so a test can be the PTY. */ +const ptyOutput = vi.hoisted(() => ({ + listeners: new Map void>(), +})); /** * Shift+Enter has to reach the container as ESC+CR. @@ -30,7 +47,10 @@ vi.mock("../../lib/tauri-commands", () => ({ })); vi.mock("@tauri-apps/api/event", () => ({ - listen: vi.fn(async () => () => {}), + listen: async (event: string, cb: (e: { payload: number[] }) => void) => { + ptyOutput.listeners.set(event, cb); + return () => ptyOutput.listeners.delete(event); + }, })); vi.mock("@tauri-apps/plugin-opener", () => ({ @@ -38,7 +58,14 @@ vi.mock("@tauri-apps/plugin-opener", () => ({ })); vi.mock("@tauri-apps/api/webview", () => ({ - getCurrentWebview: () => ({ onDragDropEvent: vi.fn(async () => () => {}) }), + getCurrentWebview: () => ({ + onDragDropEvent: async (cb: (event: unknown) => unknown) => { + dragDrop.handler = cb; + return () => { + dragDrop.handler = null; + }; + }, + }), })); /** jsdom has no ResizeObserver, and the mount effect installs one. */ @@ -96,6 +123,11 @@ beforeEach(() => { }), ); terminalInput.mockClear(); + vi.mocked(uploadHostFileToTerminal).mockClear(); + vi.mocked(uploadHostFileToTerminal).mockResolvedValue("/workspace/api/dropped.txt"); + dragDrop.handler = null; + ptyOutput.listeners.clear(); + document.body.innerHTML = ""; useAppState.setState({ sessions: [] }); }); @@ -200,6 +232,14 @@ describe("supersedes — who owns the prompt slot", () => { expect(supersedes(relay(COMPLETE), guess(TRUNCATED))).toBe(true); }); + it("refuses to let a truncated guess replace another guess it truncates", () => { + // The same rule one rank down. Both are scrapes of the same repainting + // frame, so recency says the newer one wins and recency is wrong: a + // repaint that lands a *shorter* view of the link already on screen is + // showing less of it, not something new. + expect(supersedes(guess(TRUNCATED), guess(COMPLETE))).toBe(false); + }); + it("lets a scraped candidate grow into the complete link", () => { // A repaint can land the truncated copy first. Extending it is safe: a // longer string with the same prefix has the same origin. @@ -222,3 +262,162 @@ describe("supersedes — who owns the prompt slot", () => { ).toBe(true); }); }); + +describe("TerminalView — where a dropped file lands", () => { + /** Mount, let the async drag-drop registration settle, and give the pane a + * rect — jsdom has no layout, so every element is 0×0 and would be rejected + * as a hidden pane. */ + async function mountWithLayout() { + const view = mountSession("bash"); + await act(async () => {}); + const pane = view.container.querySelector(".xterm")?.parentElement; + if (!pane) throw new Error("terminal host element not found"); + pane.getBoundingClientRect = () => + ({ + left: 0, + top: 0, + right: 800, + bottom: 600, + width: 800, + height: 600, + x: 0, + y: 0, + toJSON: () => ({}), + }) as DOMRect; + return view; + } + + async function drop(x: number, y: number) { + if (!dragDrop.handler) throw new Error("no drag-drop listener registered"); + await act(async () => { + await dragDrop.handler!({ + payload: { type: "drop", position: { x, y }, paths: ["/host/dropped.txt"] }, + }); + }); + } + + it("uploads a file dropped onto the pane", async () => { + await mountWithLayout(); + await drop(400, 300); + expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledWith( + "s1", + "/host/dropped.txt", + ); + }); + + it("ignores a drop that lands outside the pane", async () => { + await mountWithLayout(); + await drop(4000, 300); + expect(vi.mocked(uploadHostFileToTerminal)).not.toHaveBeenCalled(); + }); + + it("ignores a drop released onto an open modal", async () => { + // The hit test used to be purely geometric, and a `Modal` is a + // `fixed inset-0 z-50` portal painted *over* the whole window — so the pane + // underneath still had its rect and happily uploaded the file into the + // directory the dialog was covering. Same for the shutdown overlay, which is + // up precisely while nothing should be accepting work. + await mountWithLayout(); + const dialog = document.createElement("div"); + dialog.setAttribute("role", "dialog"); + dialog.setAttribute("aria-modal", "true"); + document.body.appendChild(dialog); + + await drop(400, 300); + + expect(vi.mocked(uploadHostFileToTerminal)).not.toHaveBeenCalled(); + + // …and it is the modal, not the mount, that is refusing: close it and the + // very same drop goes through. + dialog.remove(); + await drop(400, 300); + expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledTimes(1); + }); +}); + +describe("TerminalView — reaching the URL prompt without a mouse", () => { + // This toast is the only route to completing a sign-in started in a terminal. + // It used to be mouse-only: nothing moved focus to it, nothing dismissed it + // from the keyboard, and xterm's helper textarea eats Tab, so its buttons + // could not be reached at all. + const SIGN_IN = + "https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code"; + + /** What `container/triple-c-open` writes to its controlling terminal. */ + function relaySequence(url: string): number[] { + const payload = btoa(url); + return Array.from( + new TextEncoder().encode(`\x1b]7777;open;${payload}\x07`), + ); + } + + /** Mount, and let the container ask for a URL to be opened. */ + async function mountWithPrompt() { + const view = mountSession("claude"); + await act(async () => {}); + const emit = ptyOutput.listeners.get("terminal-output-s1"); + if (!emit) throw new Error("no terminal-output listener registered"); + await act(async () => { + emit({ payload: relaySequence(SIGN_IN) }); + // xterm parses on its own write queue. + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + }); + return view; + } + + function primaryAction(): HTMLElement { + const el = document.querySelector('[data-url-toast-primary="true"]'); + if (!el) throw new Error("toast default action not found"); + return el; + } + + it("does not take focus away from the terminal when the prompt appears", async () => { + // Deliberate: the terminal is live, and the default action opens a URL the + // *container* chose. A focused button is one stray Enter from doing it. + const { container } = await mountWithPrompt(); + expect(document.querySelector('[data-testid="url-toast"]')).not.toBeNull(); + expect(document.activeElement).toBe(helperTextarea(container)); + }); + + it("jumps to the default action on Ctrl+Shift+O", async () => { + const { container } = await mountWithPrompt(); + + fireEvent.keyDown(helperTextarea(container), { + key: "O", + ctrlKey: true, + shiftKey: true, + }); + + expect(document.activeElement).toBe(primaryAction()); + }); + + it("dismisses on Escape and hands focus back to the terminal", async () => { + // Not back to `document.body`, where the next keystroke goes nowhere. + const { container } = await mountWithPrompt(); + fireEvent.keyDown(helperTextarea(container), { + key: "O", + ctrlKey: true, + shiftKey: true, + }); + + fireEvent.keyDown(document.activeElement!, { key: "Escape" }); + + expect(document.querySelector('[data-testid="url-toast"]')).toBeNull(); + expect(document.activeElement).toBe(helperTextarea(container)); + }); + + it("leaves Ctrl+Shift+O to the terminal when there is no prompt", async () => { + const { container } = mountSession("claude"); + await act(async () => {}); + const before = document.activeElement; + + fireEvent.keyDown(helperTextarea(container), { + key: "O", + ctrlKey: true, + shiftKey: true, + }); + + expect(document.activeElement).toBe(before); + }); +}); diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index 0ffc40d..149b6c3 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -21,7 +21,12 @@ import { parseUrlRelayOsc, sanitizeRelayUrl, } from "../../lib/urlRelay"; -import UrlToast from "./UrlToast"; +import { isDropTarget } from "../../lib/dropTarget"; +import UrlToast, { + URL_TOAST_PRIMARY_SELECTOR, + URL_TOAST_SELECTOR, + URL_TOAST_SHORTCUT, +} from "./UrlToast"; import { trimSelection } from "./trimSelection"; import TerminalContextMenu from "./TerminalContextMenu"; @@ -131,6 +136,26 @@ export default function TerminalView({ sessionId, active }: Props) { } | null>(null); const promptSeqRef = useRef(0); const relayLimiterRef = useRef(new RelayRateLimiter()); + // Read by the long-lived keyboard listener below, which is registered once + // and would otherwise close over the prompt as it was at mount. + const urlPromptRef = useRef<{ url: string } | null>(null); + + /** + * Empty the prompt slot, and put focus somewhere real if it was inside the + * toast. + * + * The toast never *takes* focus — see the note in `UrlToast` — but a keyboard + * user who jumped into it with {@link URL_TOAST_SHORTCUT} is standing on a + * node that is about to unmount, and React does not rehome focus: it lands on + * `document.body`, where the terminal receives nothing and the next keystroke + * goes nowhere. Every route out of the toast goes through here for that + * reason — Open, In container, ✕, Escape and the auto-dismiss alike. + */ + const dismissUrlPrompt = useCallback(() => { + const wasInside = !!document.activeElement?.closest(URL_TOAST_SELECTOR); + setUrlPrompt(null); + if (wasInside) termRef.current?.focus(); + }, []); /** * The only writer of the prompt slot. Re-validates whatever the caller @@ -158,6 +183,38 @@ export default function TerminalView({ sessionId, active }: Props) { }, [], ); + useEffect(() => { + urlPromptRef.current = urlPrompt; + }, [urlPrompt]); + + /** + * The keyboard route into the toast. + * + * Registered on `document` in the capture phase for the same reason + * `useKeyboardShortcuts` does it there: xterm would otherwise forward the + * chord to the shell. It is *not* added to that hook because the target is + * this pane's own toast — the hook has no way to name it, and only one pane + * is on screen at a time, which is what `activeRef` checks. + * + * Nothing is swallowed unless there is a prompt to jump to, so Ctrl+Shift+O + * reaches the terminal untouched the rest of the time. + */ + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return; + if (e.key !== "o" && e.key !== "O") return; + if (!activeRef.current || !urlPromptRef.current) return; + const primary = terminalContainerRef.current?.querySelector( + `${URL_TOAST_SELECTOR} ${URL_TOAST_PRIMARY_SELECTOR}`, + ); + if (!primary) return; + e.preventDefault(); + e.stopPropagation(); + primary.focus(); + }; + document.addEventListener("keydown", onKeyDown, true); + return () => document.removeEventListener("keydown", onKeyDown, true); + }, []); const [imagePasteMsg, setImagePasteMsg] = useState(null); const [isAtBottom, setIsAtBottom] = useState(true); const [isAutoFollow, setIsAutoFollow] = useState(true); @@ -177,24 +234,21 @@ export default function TerminalView({ sessionId, active }: Props) { // in-container paths typed into the prompt so Claude Code can read them. // Tauri intercepts OS file drops at the webview level, so we use // onDragDropEvent (HTML5 ondrop on the element wouldn't expose file paths). - // The listener is window-wide, so we route purely by a hit-test against this - // terminal's bounds: the pane the drop lands on handles it. Inactive panes are - // `display:none` (zero-size rect) so they never match — this works for the - // current tabbed layout and would also do the right thing with split panes. + // + // The listener is window-wide, so every pane decides for itself whether a + // drop was meant for it. `isDropTarget` is that decision, shared with the + // Files pane: the physical-pixel position ÷ `devicePixelRatio` against this + // pane's rect (a hidden pane is `display:none`, so its zero-size rect is what + // stops two panes both claiming the drop), plus z-order — which a rect alone + // cannot see. An open `Modal` is a `fixed inset-0` portal painted *over* the + // window and the pane underneath still has its rect, so the geometric test + // that used to live here uploaded files into the directory a dialog was + // covering. Same for the shutdown overlay, which is on screen precisely while + // nothing should be accepting work at all. useEffect(() => { let unlisten: (() => void) | undefined; let cancelled = false; - const insideThisTerminal = (pos: { x: number; y: number }): boolean => { - const rect = containerRef.current?.getBoundingClientRect(); - // A hidden (display:none) pane has a zero-size rect — never a drop target. - 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; - }; - // Always single-quote: a dropped filename can contain shell metacharacters // ($(), &&, ', spaces) even with no whitespace, and this path is typed into // a live shell. Single-quoting with '\'' escaping neutralizes all of them. @@ -203,7 +257,7 @@ export default function TerminalView({ sessionId, active }: Props) { (async () => { const un = await getCurrentWebview().onDragDropEvent(async (event) => { if (event.payload.type !== "drop") return; - if (!insideThisTerminal(event.payload.position)) return; + if (!isDropTarget(containerRef.current, event.payload.position)) return; const paths = event.payload.paths ?? []; if (paths.length === 0) return; @@ -391,6 +445,10 @@ export default function TerminalView({ sessionId, active }: Props) { console.warn("URL relay: rate-limited", url); return true; } + // Exact by construction (base64 over OSC 7777), and the detector never + // sees it — so tell it, or a truncated scrape of the same link could + // still fill the slot once this prompt is dismissed. + detectorRef.current?.noteExactUrl(url); promptUrl(url, "Container asked to open a URL", "relay"); return true; }); @@ -619,12 +677,19 @@ export default function TerminalView({ sessionId, active }: Props) { } }, [active]); - // Auto-dismiss toast after 30 seconds + // Auto-dismiss toast after 30 seconds — unless the user is standing in it. + // A keyboard user who has just jumped into the toast is mid-decision, and + // pulling it out from under them costs them the only route to finishing a + // sign-in. It goes when they act on it, which is the same thing a mouse user + // does by clicking. useEffect(() => { if (!urlPrompt) return; - const timer = setTimeout(() => setUrlPrompt(null), 30_000); + const timer = setTimeout(() => { + if (document.activeElement?.closest(URL_TOAST_SELECTOR)) return; + dismissUrlPrompt(); + }, 30_000); return () => clearTimeout(timer); - }, [urlPrompt]); + }, [urlPrompt, dismissUrlPrompt]); // Auto-dismiss image paste message after 3 seconds useEffect(() => { @@ -639,13 +704,13 @@ export default function TerminalView({ sessionId, active }: Props) { // sanitizes, so this can only fail if that invariant is broken — which is // precisely when it matters that the last thing before `openUrl` checks. const safe = sanitizeRelayUrl(urlPrompt.url); - setUrlPrompt(null); + dismissUrlPrompt(); if (!safe) { console.warn("Refusing to open a URL that failed validation"); return; } openUrl(safe).catch((e) => console.error("Failed to open URL:", e)); - }, [urlPrompt]); + }, [urlPrompt, dismissUrlPrompt]); /** * Open the prompted URL in the container's own browser instead of the host's. @@ -658,7 +723,7 @@ export default function TerminalView({ sessionId, active }: Props) { const handleOpenUrlInContainer = useCallback(() => { if (!urlPrompt) return; const safe = sanitizeRelayUrl(urlPrompt.url); - setUrlPrompt(null); + dismissUrlPrompt(); if (!safe) { console.warn("Refusing to open a URL that failed validation"); return; @@ -690,7 +755,7 @@ export default function TerminalView({ sessionId, active }: Props) { detail: String(e), }), ); - }, [urlPrompt, projectId]); + }, [urlPrompt, projectId, dismissUrlPrompt]); const handleScrollToBottom = useCallback(() => { const term = termRef.current; @@ -770,7 +835,7 @@ export default function TerminalView({ sessionId, active }: Props) { label={urlPrompt.label} onOpen={handleOpenUrl} onOpenInContainer={handleOpenUrlInContainer} - onDismiss={() => setUrlPrompt(null)} + onDismiss={dismissUrlPrompt} /> )} {imagePasteMsg && ( diff --git a/app/src/components/terminal/UrlToast.test.tsx b/app/src/components/terminal/UrlToast.test.tsx index a991ef6..ad705d0 100644 --- a/app/src/components/terminal/UrlToast.test.tsx +++ b/app/src/components/terminal/UrlToast.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import UrlToast from "./UrlToast"; +import { fireEvent, render, screen } from "@testing-library/react"; +import UrlToast, { URL_TOAST_PRIMARY_SELECTOR } from "./UrlToast"; /** * The toast is the *only* thing standing between a container-chosen URL and @@ -59,6 +59,111 @@ describe("UrlToast", () => { expect(onOpen).toHaveBeenCalledTimes(1); }); + describe("keyboard", () => { + // This toast is the only route to completing a sign-in started in a + // terminal, and xterm's helper textarea swallows Tab — so without these it + // is unreachable for a keyboard-only user. + const SIGN_IN = + "https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code"; + + it("does not take focus from the live terminal when it appears", () => { + // Deliberate. The user may be mid-command, and the default action opens a + // URL the *container* chose — a focused button is one stray Enter away + // from doing it. The shortcut hint below is what makes that affordable. + render( + , + ); + expect(document.activeElement).toBe(document.body); + }); + + it("says how to reach it, since nothing announces a shortcut by itself", () => { + render( + , + ); + expect(screen.getByTestId("url-toast-shortcut")).toHaveTextContent( + "Ctrl+Shift+O", + ); + }); + + it("marks the default action so the shortcut has somewhere to land", () => { + // Which button that is depends on the URL, so the marker moves with the + // decision rather than the owner having to repeat it. + const { rerender } = render( + , + ); + expect( + document.querySelector(URL_TOAST_PRIMARY_SELECTOR), + ).toHaveTextContent("Open"); + + rerender( + , + ); + expect( + document.querySelector(URL_TOAST_PRIMARY_SELECTOR), + ).toHaveTextContent("In container"); + }); + + it("dismisses on Escape from anywhere inside it", () => { + const onDismiss = vi.fn(); + render( + , + ); + fireEvent.keyDown(screen.getByRole("button", { name: "Open" }), { + key: "Escape", + }); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it("does not answer Escape pressed outside it", () => { + // Escape belongs to whatever is running in the terminal — vim, above all. + // A document-level binding would break it for everyone who never looked + // at this toast. + const onDismiss = vi.fn(); + render( + , + ); + fireEvent.keyDown(document.body, { key: "Escape" }); + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it("gives every action a real button, so Tab reaches all three", () => { + render( + , + ); + const names = screen + .getAllByRole("button") + .map((b) => b.getAttribute("aria-label") ?? b.textContent); + expect(names).toEqual(["In container", "Open", "Dismiss"]); + // Nothing is taken out of the tab order. + for (const b of screen.getAllByRole("button")) { + expect(b).not.toHaveAttribute("tabindex", "-1"); + } + }); + }); + describe("Anthropic sign-in links", () => { // The callback listener a `claude login` is waiting on is *inside* the // container. Sending the user to their host browser completes the sign-in diff --git a/app/src/components/terminal/UrlToast.tsx b/app/src/components/terminal/UrlToast.tsx index 0c17737..ddad469 100644 --- a/app/src/components/terminal/UrlToast.tsx +++ b/app/src/components/terminal/UrlToast.tsx @@ -1,5 +1,32 @@ -import type { CSSProperties, MouseEvent } from "react"; +import type { KeyboardEvent } from "react"; import { isAnthropicSignInUrl, urlOrigin } from "../../lib/urlRelay"; +import Button from "../ui/Button"; + +/** + * Marks the toast's subtree. `TerminalView` uses it to answer "is focus inside + * the thing I am about to unmount?", which is what decides whether dismissing + * has to hand focus back to the terminal. + */ +export const URL_TOAST_SELECTOR = '[data-testid="url-toast"]'; + +/** + * The chord that jumps from the terminal into this toast. + * + * Bound in `TerminalView` on `document` in the capture phase, the same way + * `useKeyboardShortcuts` binds the app's other chords, because xterm would + * otherwise forward it to the shell. Shift is what keeps it clear of the + * terminal: plain Ctrl+O is readline's `operate-and-get-next`. + */ +export const URL_TOAST_SHORTCUT = "Ctrl+Shift+O"; + +/** + * Marks the *default* action inside the toast, so the owner can put focus + * there without a ref threaded through `ui/Button` — which is a plain function + * component and not this file's to change. Which button it is depends on the + * URL (see the sign-in note below), so the attribute moves with the decision + * rather than the caller having to repeat it. + */ +export const URL_TOAST_PRIMARY_SELECTOR = '[data-url-toast-primary="true"]'; interface Props { /** Already validated by `sanitizeRelayUrl` — this component never opens it. */ @@ -41,6 +68,28 @@ interface Props { * with no host round trip and no auth bridge, so it leads — and the host button * stays, because a user who has the auth bridge on, or who wants their existing * browser session, still needs it. + * + * ## Reachable without a mouse, and it does not take focus to manage it + * + * This toast is the only route to completing a sign-in started in a terminal, + * and it used to be mouse-only: xterm's helper textarea swallows Tab, so there + * was no way to reach these buttons at all from the keyboard. + * + * The obvious fix — focus the default action when the toast appears — was + * rejected on two counts. The terminal underneath is *live*: the user may be + * mid-command, and every keystroke after the steal would go to a button instead + * of the shell. Worse, the default action opens a URL chosen by the untrusted + * side of the sandbox, and a focused button is one stray Space or Enter away + * from doing it. This prompt exists precisely to make that a deliberate act. + * + * So focus stays where the user put it and the toast is reachable on demand: + * {@link URL_TOAST_SHORTCUT} jumps to the default action (the hint is on + * screen, next to the label, because a shortcut nobody is told about is not a + * route), Tab then moves between the actions normally — this subtree is not + * inside xterm — and Escape dismisses. Escape is handled *here*, on the + * toast's own subtree, rather than globally: Escape belongs to whatever is + * running in the terminal, and a document-level binding for it would break vim + * for everyone who never looked at this toast. */ export default function UrlToast({ url, @@ -55,82 +104,56 @@ export default function UrlToast({ // host button is the only action there is, so it stays primary. const signIn = !!onOpenInContainer && isAnthropicSignInUrl(url); - // Filled uses `--accent-emphasis`, never `--accent` — the latter is the + // `Button` already owns the filled/outlined variants — including the rule + // that filled uses `--accent-emphasis` and never `--accent`, which is the // foreground/link accent and fails WCAG AA behind white text. - const primaryStyle: CSSProperties = { - padding: "4px 12px", - fontSize: 12, - fontWeight: 600, - color: "#fff", - background: "var(--accent-emphasis)", - border: "1px solid transparent", - borderRadius: 4, - cursor: "pointer", - whiteSpace: "nowrap", - flexShrink: 0, - }; - const secondaryStyle: CSSProperties = { - padding: "4px 10px", - fontSize: 12, - fontWeight: 600, - color: "var(--text-primary)", - background: "transparent", - border: "1px solid var(--border-color)", - borderRadius: 4, - cursor: "pointer", - whiteSpace: "nowrap", - flexShrink: 0, - }; - - /** Hover feedback for whichever button is currently the filled one. */ - const hover = (primary: boolean) => - primary - ? { - onMouseEnter: (e: MouseEvent) => - (e.currentTarget.style.background = "var(--accent-emphasis-hover)"), - onMouseLeave: (e: MouseEvent) => - (e.currentTarget.style.background = "var(--accent-emphasis)"), - } - : { - onMouseEnter: (e: MouseEvent) => - (e.currentTarget.style.background = "var(--bg-tertiary)"), - onMouseLeave: (e: MouseEvent) => - (e.currentTarget.style.background = "transparent"), - }; - const hostButton = ( - + ); const containerButton = onOpenInContainer && ( // A sign-in completed in the *container's* browser lands its callback on // the container's own loopback, which is where the tool waiting for it is // listening — no host round trip, no auth bridge. - + ); + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + // Scoped to this subtree, so the terminal's own Escape is untouched. + e.preventDefault(); + e.stopPropagation(); + onDismiss(); + }; + return (
    @@ -157,6 +180,11 @@ export default function UrlToast({ }} > {label} + {" · "} + + {URL_TOAST_SHORTCUT} + {" "} + to reach the buttons, Esc to dismiss
    )} - +
    ); } diff --git a/app/src/components/ui/TypedConfirmModal.test.tsx b/app/src/components/ui/TypedConfirmModal.test.tsx index bacaec3..851043d 100644 --- a/app/src/components/ui/TypedConfirmModal.test.tsx +++ b/app/src/components/ui/TypedConfirmModal.test.tsx @@ -103,6 +103,19 @@ describe("TypedConfirmModal", () => { expect(onConfirm).not.toHaveBeenCalled(); }); + it("carries a failed attempt inside the dialog, as an alert", () => { + // The caller keeps this dialog open when the deletion fails, because the + // panel behind it is several screens long and its error line sits at the + // top — nowhere near the row this was opened from. + renderModal({ error: "volume triple-c-home-p-whp is in use by a running container" }); + expect(screen.getByRole("alert")).toHaveTextContent(/in use by a running container/); + }); + + it("says nothing about failure when there has been none", () => { + renderModal(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + it("cannot be satisfied by an empty box when there is no name to type", () => { const { confirm } = renderModal({ expected: "" }); expect(confirm).toBeDisabled(); diff --git a/app/src/components/ui/TypedConfirmModal.tsx b/app/src/components/ui/TypedConfirmModal.tsx index 8c7f036..40ed6ee 100644 --- a/app/src/components/ui/TypedConfirmModal.tsx +++ b/app/src/components/ui/TypedConfirmModal.tsx @@ -14,6 +14,13 @@ interface Props { onConfirm: (typed: string) => void; onCancel: () => void; busy?: boolean; + /** + * Why the last attempt did not happen. The caller keeps the dialog open when + * its action fails, so the failure has to be readable *here* — the panel + * behind this one is several screens long and its error line is at the top + * of it, which is not where the user is looking. + */ + error?: string | null; } /** @@ -44,6 +51,7 @@ export default function TypedConfirmModal({ onConfirm, onCancel, busy = false, + error = null, }: Props) { const [typed, setTyped] = useState(""); const inputRef = useRef(null); @@ -104,12 +112,22 @@ export default function TypedConfirmModal({ {matches ? ( Name matches. ) : ( - + // Not disabled content — the gate is live and waiting on the + // user. `--text-disabled` is ~4.1:1 and fails AA at 12px. + Waiting for the exact project name. )}

    + {error && ( + // Rendered last, next to the button that was just pressed, and as an + // `alert` so it is announced on arrival rather than waiting to be + // found. +

    + {error} +

    + )}
    ); diff --git a/app/src/hooks/useDiskUsage.test.tsx b/app/src/hooks/useDiskUsage.test.tsx index bda6e72..2ddfbf9 100644 --- a/app/src/hooks/useDiskUsage.test.tsx +++ b/app/src/hooks/useDiskUsage.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { act, renderHook, waitFor } from "@testing-library/react"; -import { useDiskUsage } from "./useDiskUsage"; +import { useDiskUsage, type DiskUsageState } from "./useDiskUsage"; import type { DiskUsageReport } from "../lib/types"; const getDockerDiskUsage = vi.fn(); @@ -226,6 +226,157 @@ describe("useDiskUsage", () => { expect(result.current.outcome?.results[0].message).toMatch(/3 were left alone/); }); + // ------------------------------------------------------------------------- + // The scan-versus-mutation race + // ------------------------------------------------------------------------- + + it("throws away a scan that a reclaim overtook", async () => { + // The live race the generation counter used to miss entirely. A scan takes + // seconds and does not set `working`, so nothing stopped the user + // reclaiming on top of one — and when the scan landed it repainted the + // pre-reclaim report *and* a fresh, clickable plan listing objects the + // reclaim had just deleted. + let resolveScan: (value: DiskUsageReport) => void = () => {}; + getDockerDiskUsage.mockReturnValueOnce( + new Promise((r) => { + resolveScan = r; + }), + ); + + const { result } = renderHook(() => useDiskUsage()); + let inFlight: Promise = Promise.resolve(); + act(() => { + inFlight = result.current.scan(); + }); + expect(result.current.scanning).toBe(true); + + await act(async () => { + await result.current.runReclaim([{ kind: "dangling_snapshots" }]); + }); + expect(result.current.plan).toBeNull(); + + // The overtaken scan finishes last, and must land nothing at all. + await act(async () => { + resolveScan(report("measured before the reclaim")); + await inFlight; + }); + expect(result.current.report).toBeNull(); + expect(result.current.plan).toBeNull(); + // It does not even get as far as re-planning: a plan built from a report + // this stale is the clickable half of the bug. + expect(listReclaimable).not.toHaveBeenCalled(); + }); + + it("does not strand `scanning` when a mutation retires the scan", async () => { + // `scanning` is cleared against the newest *scan*, not the newest + // generation — a mutation bumps the generation without starting a scan, so + // guarding on that would leave the button reading "Scanning…" forever. + let resolveScan: (value: DiskUsageReport) => void = () => {}; + getDockerDiskUsage.mockReturnValueOnce( + new Promise((r) => { + resolveScan = r; + }), + ); + + const { result } = renderHook(() => useDiskUsage()); + let inFlight: Promise = Promise.resolve(); + act(() => { + inFlight = result.current.scan(); + }); + await act(async () => { + await result.current.runReclaim([{ kind: "dangling_snapshots" }]); + }); + await act(async () => { + resolveScan(report("stale")); + await inFlight; + }); + expect(result.current.scanning).toBe(false); + }); + + it("retires an in-flight scan for a destroy and a sweep too", async () => { + // Every mutation invalidates a measurement, not just the bulk one. + destroyProjectDiskObject.mockResolvedValue({ + target: null, + destroyed: { kind: "home_volume", project_id: "p1" }, + ok: true, + freed_bytes: 1, + projected_bytes: null, + message: "gone", + }); + sweepOrphanedSnapshots.mockResolvedValue({ + removed: [], + reclaimed_bytes: 0, + in_use: 0, + failed: [], + unavailable: null, + }); + + for (const mutate of [ + (r: DiskUsageState) => r.destroy({ kind: "home_volume", project_id: "p1" }, "whp"), + (r: DiskUsageState) => r.runSweep(), + ]) { + let resolveScan: (value: DiskUsageReport) => void = () => {}; + getDockerDiskUsage.mockReturnValueOnce( + new Promise((r) => { + resolveScan = r; + }), + ); + const { result } = renderHook(() => useDiskUsage()); + let inFlight: Promise = Promise.resolve(); + act(() => { + inFlight = result.current.scan(); + }); + await act(async () => { + await mutate(result.current); + }); + await act(async () => { + resolveScan(report("stale")); + await inFlight; + }); + expect(result.current.report).toBeNull(); + expect(result.current.plan).toBeNull(); + expect(result.current.scanning).toBe(false); + } + }); + + // ------------------------------------------------------------------------- + // Reporting failure back to the caller + // ------------------------------------------------------------------------- + + it("tells the caller a reclaim failed instead of only swallowing it into `error`", async () => { + // The confirmation dialogs close on completion. Without a return value + // they closed on failure too, leaving the error at the top of a panel the + // user had scrolled well past. + reclaim.mockRejectedValueOnce("compaction failed: no space left on device"); + const { result } = renderHook(() => useDiskUsage()); + let ok: boolean | undefined; + await act(async () => { + ok = await result.current.runReclaim([{ kind: "compact_snapshot", project_id: "p1" }]); + }); + expect(ok).toBe(false); + expect(result.current.error).toMatch(/no space left on device/); + }); + + it("tells the caller a destroy failed", async () => { + destroyProjectDiskObject.mockRejectedValueOnce("volume is in use by a running container"); + const { result } = renderHook(() => useDiskUsage()); + let ok: boolean | undefined; + await act(async () => { + ok = await result.current.destroy({ kind: "home_volume", project_id: "p1" }, "whp"); + }); + expect(ok).toBe(false); + expect(result.current.error).toMatch(/in use by a running container/); + }); + + it("reports success when the call came back", async () => { + const { result } = renderHook(() => useDiskUsage()); + let ok: boolean | undefined; + await act(async () => { + ok = await result.current.runReclaim([{ kind: "dangling_snapshots" }]); + }); + expect(ok).toBe(true); + }); + it("treats an unreachable daemon in the sweep report as an error", async () => { sweepOrphanedSnapshots.mockResolvedValue({ removed: [], diff --git a/app/src/hooks/useDiskUsage.ts b/app/src/hooks/useDiskUsage.ts index f82eb19..3f00330 100644 --- a/app/src/hooks/useDiskUsage.ts +++ b/app/src/hooks/useDiskUsage.ts @@ -32,9 +32,24 @@ import type { * A user who hits Scan twice can have two `df()` calls in flight, and they can * land out of order — the second one is not necessarily slower. Every async * write in `scan` checks it is still the newest before it lands, the same - * pattern `useContainerMigration` uses. `runReclaim` and `destroy` do not need - * it: the UI disables their buttons while `working` is set, so there is never - * a second one to race. + * pattern `useContainerMigration` uses. + * + * The race that actually bites, though, is not scan-versus-scan: it is + * scan-versus-**mutation**. A scan takes seconds and does not set `working`, so + * nothing stopped a reclaim starting on top of one. The reclaim correctly drops + * the plan — and then the still-running scan landed, passed its own generation + * check, and repainted a pre-reclaim report *plus a fresh, clickable plan + * listing objects that had just been deleted*. So every mutation bumps the + * counter as well: whatever a scan is holding was measured before the mutation + * and is now a lie, and throwing it away is the only honest thing to do with + * it. (The Scan button is disabled while `working` for the mirror-image case, + * so a scan can never start *during* a mutation.) + * + * That is also why `scanning` is not cleared against the same counter: a + * mutation bumping it mid-scan would strand the flag at true and leave the + * button reading "Scanning…" forever. `latestScan` records the generation the + * newest *scan* owns — only a newer scan may take the flag away — and that is + * what the `finally` compares against. */ export interface DiskUsageState { report: DiskUsageReport | null; @@ -47,8 +62,15 @@ export interface DiskUsageState { /** The outcome of the last reclaim, kept on screen until the next scan. */ outcome: ReclaimOutcome | null; scan: () => Promise; - runReclaim: (targets: ReclaimTarget[]) => Promise; - destroy: (target: DestructiveTarget, confirmation: string) => Promise; + /** + * Resolves `true` when the call came back, `false` when it threw and the + * failure went into `error`. Callers that dismiss UI on completion — the + * confirmation dialogs — must only dismiss on `true`, or the failure is left + * with nowhere on screen the user is looking. + */ + runReclaim: (targets: ReclaimTarget[]) => Promise; + /** Same contract as `runReclaim`: `false` means it failed and `error` says how. */ + destroy: (target: DestructiveTarget, confirmation: string) => Promise; /** Run the orphaned-snapshot sweep and report what it found *and refused*. */ runSweep: () => Promise; clearOutcome: () => void; @@ -62,9 +84,21 @@ export function useDiskUsage(): DiskUsageState { const [error, setError] = useState(null); const [outcome, setOutcome] = useState(null); const generation = useRef(0); + /** The generation belonging to the most recently *started* scan. */ + const latestScan = useRef(0); + + /** + * Retire every in-flight scan. Called at the top of each mutation, because + * the moment we start deleting things, a measurement taken before that is no + * longer describing the daemon the user is looking at. + */ + const invalidateScans = useCallback(() => { + generation.current += 1; + }, []); const scan = useCallback(async () => { const mine = ++generation.current; + latestScan.current = mine; setScanning(true); setError(null); // The previous outcome describes a state that no longer holds once a new @@ -92,49 +126,66 @@ export function useDiskUsage(): DiskUsageState { // user can no longer see the totals for, but that cannot happen: the two // only ever move together. } finally { - if (generation.current === mine) setScanning(false); + // Deliberately `latestScan`, not `generation`: a mutation that retired + // this scan did not start another one, so this scan is still the last + // word on whether a scan is running. + if (latestScan.current === mine) setScanning(false); } }, []); - const runReclaim = useCallback(async (targets: ReclaimTarget[]) => { - if (targets.length === 0) return; - setWorking(true); - setError(null); - try { - const result = await commands.reclaim(targets); - setOutcome(result); - // **The plan is now stale and must not stay clickable.** Its rows - // describe objects this call just removed, so leaving them ticked lets - // the user fire the same reclaim again against nothing. Dropping the plan - // (not the report) leaves the totals on screen, marked as measured before - // the reclaim, with the tick list gone. - // - // Deliberately no automatic re-scan: it costs another `df()`, and the - // outcome already reports measured bytes for every target — a user who - // wants the new totals asks for them. - setPlan(null); - } catch (e) { - setError(String(e)); - } finally { - setWorking(false); - } - }, []); + const runReclaim = useCallback( + async (targets: ReclaimTarget[]): Promise => { + // Nothing was asked for, so nothing failed — a caller gating a dialog on + // this must not be left staring at an error that has no cause. + if (targets.length === 0) return true; + invalidateScans(); + setWorking(true); + setError(null); + try { + const result = await commands.reclaim(targets); + setOutcome(result); + // **The plan is now stale and must not stay clickable.** Its rows + // describe objects this call just removed, so leaving them ticked lets + // the user fire the same reclaim again against nothing. Dropping the plan + // (not the report) leaves the totals on screen, marked as measured before + // the reclaim, with the tick list gone. + // + // Deliberately no automatic re-scan: it costs another `df()`, and the + // outcome already reports measured bytes for every target — a user who + // wants the new totals asks for them. + setPlan(null); + return true; + } catch (e) { + setError(String(e)); + return false; + } finally { + setWorking(false); + } + }, + [invalidateScans], + ); - const destroy = useCallback(async (target: DestructiveTarget, confirmation: string) => { - setWorking(true); - setError(null); - try { - const result = await commands.destroyProjectDiskObject(target, confirmation); - setOutcome({ results: [result], total_freed_bytes: result.freed_bytes }); - // Same reasoning as `runReclaim`: the destructive list named an object - // that is now gone. - setPlan(null); - } catch (e) { - setError(String(e)); - } finally { - setWorking(false); - } - }, []); + const destroy = useCallback( + async (target: DestructiveTarget, confirmation: string): Promise => { + invalidateScans(); + setWorking(true); + setError(null); + try { + const result = await commands.destroyProjectDiskObject(target, confirmation); + setOutcome({ results: [result], total_freed_bytes: result.freed_bytes }); + // Same reasoning as `runReclaim`: the destructive list named an object + // that is now gone. + setPlan(null); + return true; + } catch (e) { + setError(String(e)); + return false; + } finally { + setWorking(false); + } + }, + [invalidateScans], + ); /** * The startup sweep, on demand. @@ -147,6 +198,7 @@ export function useDiskUsage(): DiskUsageState { * report away. */ const runSweep = useCallback(async () => { + invalidateScans(); setWorking(true); setError(null); try { @@ -178,7 +230,7 @@ export function useDiskUsage(): DiskUsageState { } finally { setWorking(false); } - }, []); + }, [invalidateScans]); const clearOutcome = useCallback(() => setOutcome(null), []); diff --git a/app/src/hooks/useFileManager.test.ts b/app/src/hooks/useFileManager.test.ts index c35e770..c91b8e5 100644 --- a/app/src/hooks/useFileManager.test.ts +++ b/app/src/hooks/useFileManager.test.ts @@ -13,7 +13,7 @@ const stageContainerFileForDrag = 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), + uploadFileToContainer: (...args: unknown[]) => uploadFileToContainer(...args), renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t), createContainerDirectory: (p: string, parent: string, n: string) => createContainerDirectory(p, parent, n), @@ -21,6 +21,22 @@ vi.mock("../lib/tauri-commands", () => ({ stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path), })); +/** + * Transient failures go to `ToastHost` rather than an inline string — see the + * comment at the top of `useFileManager`. The store is mocked down to the one + * method the hook reaches for. + */ +const pushToast = vi.fn(); +vi.mock("../store/appState", () => ({ + useAppState: { getState: () => ({ pushToast }) }, +})); + +/** Everything the hook has said through the toast host, message and detail. */ +const toastText = () => + pushToast.mock.calls + .map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`) + .join("\n"); + const save = vi.fn(); const openDialog = vi.fn(); vi.mock("@tauri-apps/plugin-dialog", () => ({ @@ -111,7 +127,10 @@ describe("useFileManager uploads", () => { await act(async () => { await result.current.uploadPaths(["/host/ok.txt", "/host/huge.bin"]); }); - expect(result.current.error).toContain("too large"); + // Inline `error` is reserved for the listing failure the user can see in + // context; a failed upload goes where it cannot scroll away. + expect(result.current.error).toBeNull(); + expect(toastText()).toContain("too large"); expect(listContainerFiles).toHaveBeenCalled(); }); @@ -152,7 +171,7 @@ describe("useFileManager rename and mkdir", () => { ok = await result.current.renameEntry(file("hosts"), "hosts.bak"); }); expect(ok).toBe(false); - expect(result.current.error).toContain("Permission denied"); + expect(toastText()).toContain("Permission denied"); }); it("treats an unchanged name as a no-op rather than a round trip", async () => { @@ -183,7 +202,7 @@ describe("useFileManager rename and mkdir", () => { ok = await result.current.createFolder("src"); }); expect(ok).toBe(false); - expect(result.current.error).toContain("File exists"); + expect(toastText()).toContain("File exists"); }); }); @@ -208,7 +227,7 @@ describe("useFileManager save to host", () => { await act(async () => { await result.current.downloadFile(file("src", { is_directory: true })); }); - expect(result.current.error).toContain("is a folder"); + expect(toastText()).toContain("is a folder"); }); }); @@ -270,8 +289,8 @@ describe("useFileManager drag-out staging", () => { }); expect(staged).toBeNull(); - expect(result.current.error).toContain("too large to drag out"); - expect(result.current.error).toContain("Save to host"); + expect(toastText()).toContain("too large to drag out"); + expect(toastText()).toContain("Save to host"); expect(result.current.busy).toBeNull(); }); @@ -290,3 +309,193 @@ describe("useFileManager drag-out staging", () => { expect(staged).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: false }); }); }); + +describe("useFileManager stays where the user is", () => { + it("does not drag the pane back when the user navigates away mid-upload", async () => { + // The closure captured `/workspace`; the user is in `/workspace/src` by the + // time the copy finishes. Re-listing the *captured* path is what used to + // yank them out of the directory they had walked into. + let failUpload: (reason: unknown) => void = () => {}; + // `Once`, deliberately: `clearAllMocks` clears calls but not + // implementations, so a never-settling one would hang every test after it. + uploadFileToContainer.mockImplementationOnce( + () => new Promise((_resolve, reject) => { failUpload = reject; }), + ); + const { result } = renderHook(() => useFileManager("p1")); + + let upload!: Promise; + await act(async () => { + upload = result.current.uploadPaths(["/host/big.bin"]); + await Promise.resolve(); + }); + + listContainerFiles.mockResolvedValue([file("index.ts", { path: "/workspace/src/index.ts" })]); + await act(async () => { + await result.current.navigate("/workspace/src"); + }); + listContainerFiles.mockClear(); + + await act(async () => { + failUpload("cp: no space left on device"); + await upload; + }); + + expect(result.current.currentPath).toBe("/workspace/src"); + expect(result.current.entries.map((e) => e.name)).toEqual(["index.ts"]); + // No re-list of the directory the upload targeted… + expect(listContainerFiles).not.toHaveBeenCalled(); + // …and no failure text painted over the listing that replaced it. + expect(result.current.error).toBeNull(); + expect(toastText()).toContain("no space left"); + }); + + it("re-lists when the user stayed put, which is the ordinary case", async () => { + const { result } = renderHook(() => useFileManager("p1")); + await act(async () => { + await result.current.navigate("/workspace"); + }); + listContainerFiles.mockClear(); + await act(async () => { + await result.current.uploadPaths(["/host/a.png"]); + }); + expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace"); + }); + + it("lets the newest listing win when a slow one lands last", async () => { + // Two listings in flight, and the slower one is not necessarily the older + // one. Landing last used to set both the rows and the breadcrumb back. + let landSlow: (entries: FileEntry[]) => void = () => {}; + listContainerFiles.mockImplementationOnce( + () => new Promise((resolve) => { landSlow = resolve; }), + ); + listContainerFiles.mockResolvedValueOnce([file("new.txt")]); + const { result } = renderHook(() => useFileManager("p1")); + + let slow!: Promise; + await act(async () => { + slow = result.current.navigate("/workspace/slow"); + await Promise.resolve(); + }); + await act(async () => { + await result.current.navigate("/workspace/fast"); + }); + expect(result.current.currentPath).toBe("/workspace/fast"); + + await act(async () => { + landSlow([file("stale.txt")]); + await slow; + }); + + expect(result.current.currentPath).toBe("/workspace/fast"); + expect(result.current.entries.map((e) => e.name)).toEqual(["new.txt"]); + }); + + it("keeps a failed navigation from claiming the directory it never reached", async () => { + listContainerFiles.mockRejectedValueOnce("Permission denied"); + const { result } = renderHook(() => useFileManager("p1")); + await act(async () => { + await result.current.navigate("/root"); + }); + listContainerFiles.mockClear(); + // The pane never left /workspace, so an upload started now targets it. + await act(async () => { + await result.current.uploadPaths(["/host/a.png"]); + }); + expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.png", "/workspace"); + }); +}); + +describe("useFileManager overwrite prompt", () => { + const alreadyThere = "FILE_EXISTS: /workspace/a.txt already exists"; + + it("asks rather than clobbering, and replaces on demand", async () => { + uploadFileToContainer.mockRejectedValueOnce(alreadyThere); + uploadFileToContainer.mockResolvedValueOnce(undefined); + const { result } = renderHook(() => useFileManager("p1")); + + let upload!: Promise; + await act(async () => { + upload = result.current.uploadPaths(["/host/a.txt"]); + await Promise.resolve(); + }); + await waitFor(() => expect(result.current.conflict?.name).toBe("a.txt")); + expect(result.current.conflict?.directory).toBe("/workspace"); + // One file, so there is nothing for a blanket answer to apply to. + expect(result.current.conflict?.remaining).toBe(0); + + await act(async () => { + result.current.resolveConflict("replace"); + await upload; + }); + + expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true); + expect(result.current.conflict).toBeNull(); + }); + + it("skips without uploading anything when the user says so", async () => { + uploadFileToContainer.mockRejectedValueOnce(alreadyThere); + const { result } = renderHook(() => useFileManager("p1")); + + let upload!: Promise; + await act(async () => { + upload = result.current.uploadPaths(["/host/a.txt"]); + await Promise.resolve(); + }); + await waitFor(() => expect(result.current.conflict).not.toBeNull()); + await act(async () => { + result.current.resolveConflict("skip"); + await upload; + }); + + expect(uploadFileToContainer).toHaveBeenCalledTimes(1); + // A skip is a choice, not a failure — nothing to report. + expect(toastText()).not.toContain("could not be uploaded"); + }); + + it("asks once for a batch when the answer is Replace all", async () => { + uploadFileToContainer.mockRejectedValueOnce(alreadyThere); + uploadFileToContainer.mockResolvedValueOnce(undefined); + uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/b.txt already exists"); + uploadFileToContainer.mockResolvedValueOnce(undefined); + const { result } = renderHook(() => useFileManager("p1")); + + let upload!: Promise; + await act(async () => { + upload = result.current.uploadPaths(["/host/a.txt", "/host/b.txt"]); + await Promise.resolve(); + }); + await waitFor(() => expect(result.current.conflict?.remaining).toBe(1)); + await act(async () => { + result.current.resolveConflict("replace-all"); + await upload; + }); + + expect(result.current.conflict).toBeNull(); + expect(uploadFileToContainer).toHaveBeenNthCalledWith(4, "p1", "/host/b.txt", "/workspace", true); + }); + + it("leaves an unrelated failure alone — no prompt offering a button that cannot work", async () => { + 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/huge.bin"]); + }); + expect(result.current.conflict).toBeNull(); + expect(toastText()).toContain("too large"); + }); +}); + +describe("useFileManager staged host paths", () => { + it("recognises a path it staged, and only that path", async () => { + stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt"); + const { result } = renderHook(() => useFileManager("p1")); + + expect(result.current.isStagedHostPath("/tmp/triple-c-drag-out/s1/a.txt")).toBe(false); + await act(async () => { + await result.current.stageForDrag(file("a.txt")); + }); + expect(result.current.isStagedHostPath("/tmp/triple-c-drag-out/s1/a.txt")).toBe(true); + // Same basename, a real host file the user actually wants uploaded. + expect(result.current.isStagedHostPath("/home/me/a.txt")).toBe(false); + }); +}); diff --git a/app/src/hooks/useFileManager.ts b/app/src/hooks/useFileManager.ts index 6e7bc31..d729e05 100644 --- a/app/src/hooks/useFileManager.ts +++ b/app/src/hooks/useFileManager.ts @@ -1,8 +1,75 @@ -import { useState, useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { save, open as openDialog } from "@tauri-apps/plugin-dialog"; import type { FileEntry } from "../lib/types"; import * as commands from "../lib/tauri-commands"; +import { useAppState } from "../store/appState"; +import { + fileExistsPath, + isFileExistsError, + type OverwriteChoice, +} from "../lib/uploadErrors"; +/** + * One upload waiting on the user to say whether it may replace what is there. + * `remaining` is how many files are queued behind this one, which is what + * decides whether the blanket answers are worth offering. + */ +export interface UploadConflict { + /** Host file being uploaded. */ + hostPath: string; + /** Bare name, for the prompt. */ + name: string; + /** Container directory it is going into. */ + directory: string; + remaining: number; +} + +/** `/a/b/c.txt` and `C:\a\b\c.txt` both give `c.txt`. */ +function baseName(path: string): string { + const parts = path.split(/[\\/]/); + return parts[parts.length - 1] || path; +} + +/** + * Host paths compare on separators, not on case: the OS hands a dropped path + * back in whatever form its file dialog produced, and on Windows that is not + * reliably the form `stage_container_file_for_drag` returned. + */ +function normaliseHostPath(path: string): string { + return path.replace(/\\/g, "/").replace(/\/+$/, ""); +} + +/** + * ## Where failures are reported + * + * Two audiences, two places, and the split is deliberate. + * + * The **initial listing** failure stays in `error`, rendered inline above the + * (empty) grid. It is on screen, it is in context, it explains why there are + * no rows, and it is not transient — it stands until the directory lists. + * + * Every **transient operation** failure — upload, rename, create folder, + * save-to-host, drag staging — goes to `ToastHost` instead. Those used to land + * in the same inline `error` div, which is the first child of the *scrolling* + * list: three hundred rows down, a refused rename produced no visible change + * at all, just a rename box that stayed open for no stated reason. Worse, the + * file viewer routes its "Save to host…" through the same call, and the viewer + * is a `fixed inset-0` portal at `z-50` — so that failure reported *behind* the + * dialog that caused it. The toast host is a persistent `aria-live` region at + * `z-[60]`, i.e. the one place in the app that is above a modal and does not + * scroll away. + * + * ## Where the current directory lives + * + * `currentPath` is state (the UI renders it) *and* a ref (async work reads it + * after an await). Every long operation captures the directory it targets at + * the start and compares it against the ref at the end: a 200 MB upload into + * `/workspace` must not drag the pane back out of `src/` because that is where + * the closure happened to be created. The ref moves at the *start* of a + * navigation rather than when the listing lands, because the question being + * asked is "where is the user going", not "what is on screen right now" — and + * it is put back if that navigation fails. + */ export function useFileManager(projectId: string) { const [currentPath, setCurrentPath] = useState("/workspace"); const [entries, setEntries] = useState([]); @@ -10,33 +77,72 @@ export function useFileManager(projectId: string) { const [error, setError] = useState(null); /** Transient "uploading 3 files…" style note, shown beside the breadcrumb. */ const [busy, setBusy] = useState(null); + /** + * What just finished. A live region that only ever says "uploading…" tells a + * screen reader user when to start waiting and never when to stop. + */ + const [completed, setCompleted] = useState(null); + const [conflict, setConflict] = useState(null); + + const currentPathRef = useRef(currentPath); + + /** + * A slow listing can land after a newer one and set both the rows and the + * breadcrumb back to a directory the user already left. Same generation + * guard `useDiskUsage` and `useContainerMigration` use: every async write + * checks it is still the newest before it lands. + */ + const navGeneration = useRef(0); + + const startWork = useCallback((note: string) => { + setBusy(note); + setCompleted(null); + }, []); + + const report = useCallback((message: string, detail?: string) => { + useAppState.getState().pushToast({ kind: "error", message, detail }); + }, []); + + const confirm = useCallback((message: string) => { + useAppState.getState().pushToast({ kind: "success", message }); + }, []); const navigate = useCallback( async (path: string) => { + const mine = ++navGeneration.current; + const previous = currentPathRef.current; + currentPathRef.current = path; setLoading(true); setError(null); try { const result = await commands.listContainerFiles(projectId, path); + if (navGeneration.current !== mine) return; setEntries(result); setCurrentPath(path); } catch (e) { + if (navGeneration.current !== mine) return; + // The move did not happen, so the pane is still where it was — the ref + // has to agree with the breadcrumb or the next operation will decide + // it targeted a directory nobody is looking at. + currentPathRef.current = previous; setError(String(e)); } finally { - setLoading(false); + if (navGeneration.current === mine) setLoading(false); } }, [projectId], ); const goUp = useCallback(() => { - if (currentPath === "/") return; - const parent = currentPath.replace(/\/[^/]+$/, "") || "/"; + const here = currentPathRef.current; + if (here === "/") return; + const parent = here.replace(/\/[^/]+$/, "") || "/"; navigate(parent); - }, [currentPath, navigate]); + }, [navigate]); const refresh = useCallback(() => { - navigate(currentPath); - }, [currentPath, navigate]); + navigate(currentPathRef.current); + }, [navigate]); /** Copy an entry out to a host path the user picks. */ const downloadFile = useCallback( @@ -44,30 +150,111 @@ export function useFileManager(projectId: string) { try { const hostPath = await save({ defaultPath: entry.name }); if (!hostPath) return; - setError(null); - await commands.downloadContainerFile(projectId, entry.path, hostPath); + // Every sibling operation sets `busy`; this one did not, so a 200 MB + // copy was a click, then a frozen-looking pane, then nothing. + startWork(`Saving "${entry.name}" to the host…`); + try { + await commands.downloadContainerFile(projectId, entry.path, hostPath); + setCompleted(`Saved "${entry.name}" to ${hostPath}.`); + confirm(`Saved "${entry.name}" to the host.`); + } finally { + setBusy(null); + } } catch (e) { - setError(String(e)); + report(`Could not save "${entry.name}" to the host`, String(e)); } }, - [projectId], + [projectId, startWork, report, confirm], + ); + + /** + * The pending answer to `conflict`. Kept in a ref rather than state because + * the upload loop is `await`ing it — it needs the resolver, not a re-render. + */ + const conflictResolver = useRef<((choice: OverwriteChoice) => void) | null>(null); + + const resolveConflict = useCallback((choice: OverwriteChoice) => { + const resolve = conflictResolver.current; + conflictResolver.current = null; + setConflict(null); + resolve?.(choice); + }, []); + + // A pane unmounted mid-prompt (the tab was closed, the container stopped) + // would otherwise leave the upload loop awaiting an answer that can never + // come. Skipping is the safe reading of "the dialog went away". + useEffect( + () => () => { + conflictResolver.current?.("skip-all"); + conflictResolver.current = null; + }, + [], + ); + + const askOverwrite = useCallback( + (hostPath: string, directory: string, remaining: number, containerPath: string | null) => + new Promise((resolve) => { + conflictResolver.current = resolve; + setConflict({ + hostPath, + name: baseName(containerPath ?? hostPath), + directory, + remaining, + }); + }), + [], ); /** * 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. + * + * The backend refuses to overwrite unless asked to, so a name clash is not a + * failure here: it is a question, and the answer can be given once for the + * whole batch. */ const uploadPaths = useCallback( async (hostPaths: string[]) => { if (hostPaths.length === 0) return; - setError(null); - setBusy(`Uploading ${hostPaths.length} item${hostPaths.length > 1 ? "s" : ""}…`); + // The directory this upload is *for*. Compared against the live ref at + // the end, because the user is free to walk away while it copies. + const target = currentPathRef.current; + startWork(`Uploading ${hostPaths.length} item${hostPaths.length > 1 ? "s" : ""}…`); const failures: string[] = []; + let uploaded = 0; + let skipped = 0; + /** A "…all" answer, applied to every remaining clash without asking. */ + let blanket: OverwriteChoice | null = null; try { - for (const hostPath of hostPaths) { + for (let i = 0; i < hostPaths.length; i++) { + const hostPath = hostPaths[i]; try { - await commands.uploadFileToContainer(projectId, hostPath, currentPath); + await commands.uploadFileToContainer(projectId, hostPath, target); + uploaded++; + continue; + } catch (e) { + if (!isFileExistsError(e)) { + failures.push(String(e)); + continue; + } + const choice: OverwriteChoice = + blanket ?? + (await askOverwrite( + hostPath, + target, + hostPaths.length - i - 1, + fileExistsPath(e), + )); + if (choice === "replace-all" || choice === "skip-all") blanket = choice; + if (choice === "skip" || choice === "skip-all") { + skipped++; + continue; + } + } + try { + await commands.uploadFileToContainer(projectId, hostPath, target, true); + uploaded++; } catch (e) { failures.push(String(e)); } @@ -75,12 +262,25 @@ export function useFileManager(projectId: string) { } 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(" · ")); + + const summary = + `Uploaded ${uploaded} item${uploaded === 1 ? "" : "s"}` + + (skipped > 0 ? `, skipped ${skipped}` : "") + + (failures.length > 0 ? `, ${failures.length} failed` : "") + + "."; + setCompleted(summary); + + if (failures.length > 0) { + report( + failures.length === 1 ? "A file could not be uploaded" : `${failures.length} files could not be uploaded`, + failures.join("\n"), + ); + } + // Only re-list if the user is still looking at the directory this went + // into. Navigating away during a slow copy used to drag the pane back. + if (currentPathRef.current === target) await navigate(target); }, - [projectId, currentPath, navigate], + [projectId, navigate, startWork, report, askOverwrite], ); /** @@ -89,10 +289,28 @@ export function useFileManager(projectId: string) { * last listing re-stages rather than dragging a stale copy. */ const stagedRef = useRef(new Map()); + /** + * The same paths the other way round, as a set. + * + * A drag-out released back inside the app arrives as an ordinary host drop + * carrying the staged copy's path, and uploading that would write the app's + * own temp copy over the container file it came from — which is worse than a + * no-op, because the key above is built from the *last listing*, so a file an + * agent rewrote since then would be replaced by a minutes-old snapshot. This + * set is what makes the "is this ours?" test exact instead of a guess at the + * temp directory's name. + */ + const stagedHostPathsRef = useRef(new Set()); + + /** True when `path` is a copy this pane staged for a drag-out. */ + const isStagedHostPath = useCallback( + (path: string) => stagedHostPathsRef.current.has(normaliseHostPath(path)), + [], + ); /** * Copy an entry onto the host so the OS can drag it, and return the absolute - * host path — or `null`, having set `error`, if it could not be staged. + * host path — or `null`, having reported why, if it could not be staged. * * `cached` is what the caller needs to tell a gesture that will feel * instantaneous from one that has a whole-file copy in front of it: the copy @@ -105,20 +323,21 @@ export function useFileManager(projectId: string) { const cached = stagedRef.current.get(key); if (cached) return { hostPath: cached, cached: true }; - setError(null); - setBusy(`Preparing "${entry.name}"…`); + startWork(`Preparing "${entry.name}"…`); try { const hostPath = await commands.stageContainerFileForDrag(projectId, entry.path); stagedRef.current.set(key, hostPath); + stagedHostPathsRef.current.add(normaliseHostPath(hostPath)); + setCompleted(`"${entry.name}" is ready to drag.`); return { hostPath, cached: false }; } catch (e) { - setError(String(e)); + report(`Could not prepare "${entry.name}" for dragging`, String(e)); return null; } finally { setBusy(null); } }, - [projectId], + [projectId, startWork, report], ); const uploadFile = useCallback(async () => { @@ -127,9 +346,9 @@ export function useFileManager(projectId: string) { if (!selected) return; await uploadPaths(Array.isArray(selected) ? selected : [selected as string]); } catch (e) { - setError(String(e)); + report("Could not open the file picker", String(e)); } - }, [uploadPaths]); + }, [uploadPaths, report]); /** * Rename in place. `newName` is a bare name — Rust rejects anything with a @@ -140,42 +359,50 @@ export function useFileManager(projectId: string) { async (entry: FileEntry, newName: string) => { const trimmed = newName.trim(); if (!trimmed || trimmed === entry.name) return true; + const target = currentPathRef.current; try { - setError(null); await commands.renameContainerPath(projectId, entry.path, trimmed); - await navigate(currentPath); + setCompleted(`Renamed "${entry.name}" to "${trimmed}".`); + if (currentPathRef.current === target) await navigate(target); return true; } catch (e) { - setError(String(e)); + report(`Could not rename "${entry.name}"`, String(e)); return false; } }, - [projectId, currentPath, navigate], + [projectId, navigate, report], ); const createFolder = useCallback( async (name: string) => { const trimmed = name.trim(); if (!trimmed) return true; + const target = currentPathRef.current; try { - setError(null); - await commands.createContainerDirectory(projectId, currentPath, trimmed); - await navigate(currentPath); + await commands.createContainerDirectory(projectId, target, trimmed); + setCompleted(`Created "${trimmed}".`); + if (currentPathRef.current === target) await navigate(target); return true; } catch (e) { - setError(String(e)); + report(`Could not create "${trimmed}"`, String(e)); return false; } }, - [projectId, currentPath, navigate], + [projectId, navigate, report], ); return { currentPath, entries, loading, + /** Inline, in-context: why the listing on screen is empty. */ error, busy, + /** What the last operation finished doing, for the live region. */ + completed, + /** An upload waiting for a Replace / Skip answer, or `null`. */ + conflict, + resolveConflict, setError, navigate, goUp, @@ -184,6 +411,7 @@ export function useFileManager(projectId: string) { uploadFile, uploadPaths, stageForDrag, + isStagedHostPath, renameEntry, createFolder, }; diff --git a/app/src/hooks/useProjectActions.ts b/app/src/hooks/useProjectActions.ts index 9f5d7f4..07ded95 100644 --- a/app/src/hooks/useProjectActions.ts +++ b/app/src/hooks/useProjectActions.ts @@ -2,6 +2,7 @@ import { useCallback, useState } from "react"; import { save } from "@tauri-apps/plugin-dialog"; import type { Project } from "../lib/types"; import * as commands from "../lib/tauri-commands"; +import { formatBytes } from "../lib/formatBytes"; import { useAppState } from "../store/appState"; import { useProjects } from "./useProjects"; import { useTerminal } from "./useTerminal"; @@ -122,10 +123,15 @@ export function useProjectActions(project: Project) { if (!hostPath) return; setBackingUp(true); const bytes = await commands.downloadContainerBackup(project.id, hostPath); - const mb = (bytes / (1024 * 1024)).toFixed(1); + // `binary` matches what the host's file browser will say about the + // tarball this just wrote. The unit is part of the formatted string, so + // there is no separate " MB" to append — and unlike the inline + // `toFixed(1)` this replaced, a multi-gigabyte backup no longer reports + // itself as a five-digit number of megabytes. + const size = formatBytes(bytes, { binary: true }); pushToast({ kind: "success", - message: `Backup saved (${mb} MB).`, + message: `Backup saved (${size}).`, detail: "Includes Claude config — may contain API keys. Keep the archive private.", }); diff --git a/app/src/lib/dropTarget.test.ts b/app/src/lib/dropTarget.test.ts new file mode 100644 index 0000000..07a8f8b --- /dev/null +++ b/app/src/lib/dropTarget.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { dropIsBlocked, isDropTarget } from "./dropTarget"; + +function pane(rect: Partial): HTMLElement { + const el = document.createElement("div"); + document.body.appendChild(el); + el.getBoundingClientRect = () => + ({ + left: 0, + top: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + x: 0, + y: 0, + toJSON: () => ({}), + ...rect, + }) as DOMRect; + return el; +} + +describe("dropTarget", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("accepts a point inside the pane", () => { + expect(isDropTarget(pane({}), { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(true); + }); + + it("rejects a point outside the pane", () => { + expect(isDropTarget(pane({}), { x: 400, y: 50 }, { devicePixelRatio: 1 })).toBe(false); + }); + + it("converts physical pixels to CSS pixels", () => { + const el = pane({}); + expect(isDropTarget(el, { x: 150, y: 150 }, { devicePixelRatio: 2 })).toBe(true); + expect(isDropTarget(el, { x: 150, y: 150 }, { devicePixelRatio: 1 })).toBe(false); + }); + + it("rejects a hidden pane, which has a zero-size rect", () => { + const el = pane({ right: 0, bottom: 0, width: 0, height: 0 }); + expect(isDropTarget(el, { x: 0, y: 0 }, { devicePixelRatio: 1 })).toBe(false); + }); + + it("rejects every drop while a modal is open", () => { + const el = pane({}); + const dialog = document.createElement("div"); + dialog.setAttribute("role", "dialog"); + dialog.setAttribute("aria-modal", "true"); + document.body.appendChild(dialog); + + expect(dropIsBlocked()).toBe(true); + expect(isDropTarget(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(false); + + dialog.remove(); + expect(dropIsBlocked()).toBe(false); + expect(isDropTarget(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(true); + }); + + it("rejects every drop while a blocking overlay is up", () => { + const el = pane({}); + const overlay = document.createElement("div"); + overlay.setAttribute("data-blocks-drop", "true"); + document.body.appendChild(overlay); + expect(isDropTarget(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(false); + }); + + it("rejects a null pane", () => { + expect(isDropTarget(null, { x: 1, y: 1 }, { devicePixelRatio: 1 })).toBe(false); + }); +}); diff --git a/app/src/lib/dropTarget.ts b/app/src/lib/dropTarget.ts new file mode 100644 index 0000000..534009e --- /dev/null +++ b/app/src/lib/dropTarget.ts @@ -0,0 +1,81 @@ +/** + * Routing for Tauri's *native* drag-drop event. + * + * The listener is window-wide — every pane that wants dropped file paths gets + * the same event — so each one decides for itself whether the drop was meant + * for it. That decision used to be purely geometric: is the payload position + * inside my rect? A rect is not what the user sees, though. An open `Modal` is + * a `fixed inset-0` portal at `z-50` painted *over* the whole window, and the + * pane underneath still had its rect, so releasing a drag onto a dialog + * uploaded the file into the directory the dialog was covering. Same for the + * shutdown overlay, which is on screen precisely while nothing should be + * accepting work at all. + * + * So the hit test is now: nothing is covering the window, **and** the point is + * inside my rect, **and** whatever is actually painted at that point is mine. + */ + +export interface DropPoint { + x: number; + y: number; +} + +/** + * Anything that swallows a drop wherever it lands. + * + * `[aria-modal="true"]` is every dialog in the app for free — `ui/Modal` is + * the only way one is built, and it sets that attribute. `data-blocks-drop` + * is for full-window overlays that are not dialogs (the shutdown overlay). + */ +const BLOCKING_SELECTOR = '[aria-modal="true"],[data-blocks-drop="true"]'; + +/** True while a modal or a blocking overlay is on screen. */ +export function dropIsBlocked(doc: Document = document): boolean { + return doc.querySelector(BLOCKING_SELECTOR) !== null; +} + +export interface DropTargetOptions { + doc?: Document; + /** Override the ratio used to convert physical pixels to CSS pixels. */ + devicePixelRatio?: number; +} + +/** + * Whether a native drop at `pos` (physical pixels) belongs to `el`. + * + * A hidden pane is `display:none` and therefore has a zero-size rect, which is + * what stops two panes both claiming the same drop. + */ +export function isDropTarget( + el: HTMLElement | null | undefined, + pos: DropPoint, + options: DropTargetOptions = {}, +): boolean { + const doc = options.doc ?? el?.ownerDocument ?? document; + if (dropIsBlocked(doc)) return false; + + const rect = el?.getBoundingClientRect(); + if (!el || !rect || rect.width === 0 || rect.height === 0) return false; + + const dpr = + options.devicePixelRatio ?? + (doc.defaultView?.devicePixelRatio || 1); + const x = pos.x / dpr; + const y = pos.y / dpr; + if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { + return false; + } + + // Z-order, where the environment can answer it. `elementFromPoint` skips + // `pointer-events: none`, so the pane's own decorative drop hint does not + // count as something covering it. jsdom has no layout and returns null, + // which is treated as "no opinion" rather than "not mine". + if (typeof doc.elementFromPoint === "function") { + const top = doc.elementFromPoint(x, y); + if (top && top !== doc.body && top !== doc.documentElement && !el.contains(top)) { + return false; + } + } + + return true; +} diff --git a/app/src/lib/formatBytes.test.ts b/app/src/lib/formatBytes.test.ts index aabd3d1..dc062ad 100644 --- a/app/src/lib/formatBytes.test.ts +++ b/app/src/lib/formatBytes.test.ts @@ -29,6 +29,22 @@ describe("formatBytes", () => { expect(formatBytes(1_610_612_736, { binary: true })).toBe("1.5 GB"); }); + it("absorbs the last two ad-hoc formatters, behaviour change and all", () => { + // `UpdateDialog.formatSize` and the inline `toFixed(1)` in + // `useProjectActions` both divided by 1024 and both stopped at MB. Routing + // them here is what finally makes this the *only* byte formatter, and it + // changes two things on purpose — pinned so neither reads as a regression + // to whoever meets them next. + // + // KB gains a decimal, matching every other size in the app: + expect(formatBytes(512 * 1024, { binary: true })).toBe("512.0 KB"); + // and the ladder no longer bottoms out at a five-digit megabyte count: + expect(formatBytes(2 * 1024 ** 3, { binary: true })).toBe("2.0 GB"); + // Sub-kilobyte sizes stop rendering as "0 KB", which is what the old + // `(bytes / 1024).toFixed(0)` said about every release asset under 512 B. + expect(formatBytes(400, { binary: true })).toBe("400 B"); + }); + it("reproduces the migration convention exactly by default", () => { // `migrationCopy.formatDataSize` is now a call to this, and its output is // asserted in MigrateContainerModal.test.tsx. diff --git a/app/src/lib/formatBytes.ts b/app/src/lib/formatBytes.ts index a66cb11..1ee4cab 100644 --- a/app/src/lib/formatBytes.ts +++ b/app/src/lib/formatBytes.ts @@ -4,11 +4,16 @@ * The app had four of them — `projects/home/format.ts`, * `projects/migrationCopy.ts`, `settings/UpdateDialog.tsx` and an inline * `toFixed(1)` in `useProjectActions.ts` — disagreeing about the divisor, the - * unit labels and the precision. The first two now delegate here. + * unit labels and the precision. All four now delegate here, and there are no + * remaining copies. * - * The other two deliberately do not, yet: `UpdateDialog` renders KB at - * `toFixed(0)`, so re-pointing it would change what a download size reads as, - * and neither is on the Disk panel's path. They are the remaining copies. + * The last two were held back because re-pointing them changes what they + * render, and that turned out to be the argument for doing it rather than + * against. `UpdateDialog` rendered KB at `toFixed(0)` (`512 KB` is now + * `512.0 KB`, consistent with every other size in the app) and both stopped + * the ladder at MB, so a 2 GB asset or backup read as a five-digit number of + * megabytes. Both are `{ binary: true }`: they describe files, and a host file + * browser shows the ÷1024 figure for the same bytes. * * ## Why the default is base 1000 * diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 597b4a9..afebc9d 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -75,8 +75,21 @@ export const downloadContainerFile = (projectId: string, containerPath: string, invoke("download_container_file", { projectId, containerPath, hostPath }); export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) => invoke("download_container_backup", { projectId, hostPath, containerPath }); -export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) => - invoke("upload_file_to_container", { projectId, hostPath, containerDir }); +/** + * Copy a host file into a container directory. + * + * `overwrite` is opt-in because a drop is aimed with a mouse: the backend + * refuses by default when the name is already taken (see `lib/uploadErrors.ts` + * for the marker that refusal carries), and the caller re-runs with `true` + * only once the user has said "Replace" to that specific file. Leaving it off + * is the safe default every existing caller gets. + */ +export const uploadFileToContainer = ( + projectId: string, + hostPath: string, + containerDir: string, + overwrite?: boolean, +) => invoke("upload_file_to_container", { projectId, hostPath, containerDir, overwrite }); 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. */ diff --git a/app/src/lib/uploadErrors.test.ts b/app/src/lib/uploadErrors.test.ts new file mode 100644 index 0000000..a5ee0e1 --- /dev/null +++ b/app/src/lib/uploadErrors.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { + FILE_EXISTS_MARKER, + fileExistsPath, + isFileExistsError, +} from "./uploadErrors"; + +/** + * The shapes here are the point of the module. + * + * A Tauri command error crosses the IPC boundary as whatever `serde` made of + * it, and the Rust side is free to change from `Err(String)` to a serialised + * error enum without anyone thinking of this file. Every one of these has to + * keep meaning "that name is taken", or an upload that could have been + * retried with `overwrite: true` degrades into a raw string in a toast. + */ +describe("isFileExistsError", () => { + it("recognises the agreed prose form", () => { + expect(isFileExistsError("FILE_EXISTS: /workspace/notes.txt already exists")).toBe(true); + }); + + it("recognises a bare marker", () => { + expect(isFileExistsError(FILE_EXISTS_MARKER)).toBe(true); + }); + + it("recognises a serialised error enum, whatever case it is written in", () => { + expect(isFileExistsError({ kind: "FileExists", path: "/workspace/a.txt" })).toBe(true); + expect(isFileExistsError({ code: "file-exists" })).toBe(true); + expect(isFileExistsError({ type: "file_exists" })).toBe(true); + }); + + it("recognises it inside a message field", () => { + expect(isFileExistsError({ message: "upload refused: FILE_EXISTS" })).toBe(true); + expect(isFileExistsError(new Error("FILE_EXISTS: /workspace/a.txt"))).toBe(true); + }); + + it("looks one level into a wrapped error", () => { + expect(isFileExistsError({ error: { kind: "FileExists" } })).toBe(true); + }); + + it("says no to every other failure, which must not raise an overwrite prompt", () => { + expect(isFileExistsError("File too large to upload (900 MB; limit 256 MB)")).toBe(false); + expect(isFileExistsError("cp: cannot create regular file: Permission denied")).toBe(false); + expect(isFileExistsError({ kind: "NotRunning" })).toBe(false); + expect(isFileExistsError(null)).toBe(false); + expect(isFileExistsError(undefined)).toBe(false); + expect(isFileExistsError(42)).toBe(false); + expect(isFileExistsError({})).toBe(false); + }); +}); + +describe("fileExistsPath", () => { + it("reads the path out of the agreed prose form", () => { + expect(fileExistsPath("FILE_EXISTS: /workspace/notes.txt already exists")).toBe( + "/workspace/notes.txt", + ); + }); + + it("prefers a structured field", () => { + expect(fileExistsPath({ kind: "FileExists", path: "/workspace/a.txt" })).toBe( + "/workspace/a.txt", + ); + expect(fileExistsPath({ kind: "FileExists", container_path: "/workspace/b.txt" })).toBe( + "/workspace/b.txt", + ); + }); + + it("finds one in a wrapped error", () => { + expect(fileExistsPath({ error: { kind: "FileExists", path: "/workspace/c.txt" } })).toBe( + "/workspace/c.txt", + ); + }); + + it("returns null rather than guessing", () => { + // The caller falls back to the host path it was uploading, which is always + // known — so "no path" is a perfectly good answer. + expect(fileExistsPath("FILE_EXISTS")).toBeNull(); + expect(fileExistsPath({ kind: "FileExists" })).toBeNull(); + expect(fileExistsPath(null)).toBeNull(); + }); +}); diff --git a/app/src/lib/uploadErrors.ts b/app/src/lib/uploadErrors.ts new file mode 100644 index 0000000..b93a5d2 --- /dev/null +++ b/app/src/lib/uploadErrors.ts @@ -0,0 +1,116 @@ +/** + * The one place the frontend agrees with Rust about "that name is taken". + * + * `upload_file_to_container` used to clobber whatever was already at the + * destination, which is the wrong default for a drop: a drag is aimed with a + * mouse, and the file it lands on is frequently not the file the user meant to + * replace. So the backend refuses by default and the frontend asks — but only + * if it can tell *this* refusal apart from "permission denied" or "no space + * left", because an overwrite prompt raised over an unrelated failure would + * offer a button that cannot possibly work. + * + * **This module is the contract point, and the Rust half has to hold up its + * end**: `upload_file_to_container` must put `FILE_EXISTS_MARKER` in the error + * it returns when the destination already exists, ideally in the agreed shape + * + * FILE_EXISTS: /workspace/notes.txt already exists + * + * and must accept an `overwrite: bool` argument that skips the check. Nothing + * here parses a human sentence — the marker is the whole agreement, and the + * path is a bonus that is only used to name the file in the prompt. + * + * The predicate is deliberately tolerant about the *shape* of the error rather + * than its wording, because a Tauri command error crosses the IPC boundary as + * whatever `serde` made of it: a bare string from `Err(String)`, an object from + * a `#[derive(Serialize)]` error enum, or an `Error` if a JS layer wrapped it + * on the way through. All three are the same refusal, and the UI must not + * behave differently depending on which one a future refactor produces. + */ + +/** Marker the backend puts in the error for "a file with this name is already there". */ +export const FILE_EXISTS_MARKER = "FILE_EXISTS"; + +/** + * Structured error shapes carry the marker in a discriminant rather than in + * prose. These are the field names a serialised Rust error realistically uses; + * matching is case-insensitive and ignores `_`/`-` so `FileExists`, + * `file_exists` and `FILE-EXISTS` all read as the same variant. + */ +const KIND_FIELDS = ["kind", "code", "type", "error", "reason"] as const; +const MESSAGE_FIELDS = ["message", "msg", "detail", "description"] as const; +const PATH_FIELDS = ["path", "container_path", "containerPath", "target", "file"] as const; + +/** `FileExists` / `file-exists` / `FILE_EXISTS` all normalise to `fileexists`. */ +function normaliseKind(value: string): string { + return value.toLowerCase().replace(/[\s_-]/g, ""); +} + +const KIND_NEEDLE = normaliseKind(FILE_EXISTS_MARKER); + +function asRecord(e: unknown): Record | null { + return typeof e === "object" && e !== null ? (e as Record) : null; +} + +/** + * Every string an error carries, flattened: the error itself if it is one, its + * message-ish fields, and its kind-ish fields. Nesting is followed one level + * because a wrapped error (`{ error: { kind: … } }`) is the same refusal. + */ +function stringsIn(e: unknown, depth = 0): string[] { + if (typeof e === "string") return [e]; + if (e instanceof Error) return [e.message, e.name]; + const record = asRecord(e); + if (!record || depth > 1) return []; + const out: string[] = []; + for (const field of [...KIND_FIELDS, ...MESSAGE_FIELDS]) { + const value = record[field]; + if (typeof value === "string") out.push(value); + else if (value !== undefined) out.push(...stringsIn(value, depth + 1)); + } + return out; +} + +/** + * True when the backend refused an upload because the destination is taken. + * + * Accepts a bare string, an `Error`, or an object with a `kind`/`code` + * discriminant or a `message` — see the module comment for why all three have + * to work. + */ +export function isFileExistsError(e: unknown): boolean { + return stringsIn(e).some((s) => normaliseKind(s).includes(KIND_NEEDLE)); +} + +/** + * The container path the conflict is about, when the error carries one — used + * only to name the file in the prompt, so `null` is a perfectly good answer + * and the caller falls back to the host path it was uploading. + */ +export function fileExistsPath(e: unknown): string | null { + const record = asRecord(e); + if (record) { + for (const field of PATH_FIELDS) { + const value = record[field]; + if (typeof value === "string" && value.length > 0) return value; + } + // One level down, for `{ error: { path } }`. + for (const field of KIND_FIELDS) { + const nested = fileExistsPath(record[field]); + if (nested) return nested; + } + } + for (const s of stringsIn(e)) { + // The agreed prose form: `FILE_EXISTS: ` — everything up to the + // first space after the marker. + const match = new RegExp(`${FILE_EXISTS_MARKER}\\s*[:=]\\s*(\\S+)`).exec(s); + if (match) return match[1]; + } + return null; +} + +/** + * What the user answered to one conflict. The blanket answers exist because a + * ten-file drop onto a populated directory is ten prompts otherwise, which is + * the kind of dialog people dismiss without reading. + */ +export type OverwriteChoice = "replace" | "skip" | "replace-all" | "skip-all"; diff --git a/app/src/lib/urlDetector.test.ts b/app/src/lib/urlDetector.test.ts index 23ff0e5..875bb9d 100644 --- a/app/src/lib/urlDetector.test.ts +++ b/app/src/lib/urlDetector.test.ts @@ -225,6 +225,55 @@ describe("UrlDetector — OSC 8", () => { expect(seen).toEqual([[url, "heuristic"]]); }); + it("never hands back a truncated guess at a link it has already seen exactly", () => { + // The defect: the prompt slot is emptied (dismissed, or auto-dismissed + // after 30 s), the OSC 8 target is deduped for the session and cannot come + // back, and the next repaint — sliced at a different offset, so a *new* + // string — reassembles into a prefix of the real link that fills the empty + // slot. It parses, it points at claude.ai, and it authorises nothing. + // + // Nothing here knows the slot was emptied, and that is the point: the rule + // holds however many times it is. + const seen: [string, UrlSource][] = []; + const d = new UrlDetector((u, s) => seen.push([u, s]), () => COLS); + + feed(d, "Open this link to sign in:\r\n" + slicedHyperlink(SIGN_IN_URL) + "\r\ndone\r\n"); + expect(seen).toEqual([[SIGN_IN_URL, "osc8"]]); + + // …the user dismisses the toast; the TUI repaints the same link as plain + // text, cut short by the frame it was painted into. + feed(d, SIGN_IN_URL.slice(0, 150) + "\r\nWaiting for the browser…\r\n"); + + expect(seen).toHaveLength(1); + expect(seen.map(([u]) => u)).not.toContain(SIGN_IN_URL.slice(0, 150)); + }); + + it("still offers a genuinely different link after an exact one", () => { + // The suppression is a prefix rule, not "one prompt per session". + const seen: string[] = []; + const d = new UrlDetector((u) => seen.push(u), () => COLS); + const other = "https://github.com/login/device?code=" + "x".repeat(90); + + feed(d, slicedHyperlink(SIGN_IN_URL) + "\r\n"); + feed(d, other + "\r\nnext\r\n"); + + expect(seen).toEqual([SIGN_IN_URL, other]); + }); + + it("suppresses a guess at a URL the consumer reported from the relay", () => { + // The OSC 7777 relay hands `TerminalView` a base64-encoded — therefore + // exact — URL that this detector never sees. `noteExactUrl` is how it gets + // told, so a dismissed relay prompt cannot be replaced by a scrape of the + // same link either. + const seen: string[] = []; + const d = new UrlDetector((u) => seen.push(u), () => COLS); + d.noteExactUrl(SIGN_IN_URL); + + feed(d, SIGN_IN_URL.slice(0, 150) + "\r\nnext\r\n"); + + expect(seen).toEqual([]); + }); + it("ignores a short hyperlink", () => { // `ls --hyperlink` decorates every filename; none of that is a prompt. const seen: string[] = []; diff --git a/app/src/lib/urlDetector.ts b/app/src/lib/urlDetector.ts index d485583..6bab70c 100644 --- a/app/src/lib/urlDetector.ts +++ b/app/src/lib/urlDetector.ts @@ -45,8 +45,27 @@ * * So each emitted candidate is tagged with where it came from, and the consumer * refuses to let a `heuristic` candidate displace an `osc8` one. + * + * ## …and the exact copy keeps winning after the prompt is gone + * + * The consumer's precedence rule only compares a new candidate against what is + * *currently* in the prompt slot. Empty the slot — the user dismisses the + * toast, or its 30 s auto-dismiss fires — and it has nothing to compare + * against, so the next truncated guess walks straight in. Meanwhile the OSC 8 + * target is deduped for the life of the session and cannot come back to + * displace it. The user is then holding a URL that parses, points at + * claude.ai, and authorises nothing, which is the exact bug the OSC 8 branch + * was added to kill. + * + * That is fixed *here* rather than in the consumer, because this is the side + * that knows both halves: {@link UrlDetector} remembers every exact URL it has + * seen and refuses to emit a heuristic candidate that is a strict prefix of + * one — see `truncatesKnownExact`. The rule then holds however often the slot + * is emptied, and needs no cooperation from whoever owns it. */ +import { extendsUrl } from "./urlRelay"; + const ANSI_RE = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g; @@ -196,6 +215,21 @@ export class UrlDetector { /** OSC 8 targets already offered, so a hyperlink repainted every frame does * not re-prompt. Bounded by {@link MAX_REMEMBERED_LINKS}. */ private emittedLinks = new Set(); + /** + * Every *exact* URL this session has seen — OSC 8 parameters, plus whatever + * the consumer reports through {@link noteExactUrl} (the OSC 7777 relay). + * + * Kept separately from `emittedLinks` because the two answer different + * questions: that one is "have I already prompted for this?", this one is "do + * I know the full text of a link some guess might be a prefix of?". The + * second answer must survive the prompt being dismissed; the whole defect is + * that a truncated guess fills the slot the moment it is empty. + * + * Bounded the same way, and cleared wholesale rather than evicted one by one: + * a program printing a fresh hyperlink every frame is not a program whose + * older links are still on screen to be mis-scraped. + */ + private exactUrls = new Set(); constructor(callback: UrlCallback, columns: ColumnsGetter) { this.callback = callback; @@ -285,7 +319,7 @@ export class UrlDetector { // 6. URL is clearly complete (more content follows) — dedup + emit this.pendingUrl = null; - if (url !== this.lastEmitted) { + if (url !== this.lastEmitted && !this.truncatesKnownExact(url)) { this.lastEmitted = url; this.callback(url, "heuristic"); } @@ -304,10 +338,23 @@ export class UrlDetector { * `lastEmitted` is moved along with them so an identical string arriving on * the heuristic path a moment later is recognised as the same candidate * rather than fired a second time. + * + * Every target is remembered as exact whether or not it is offered — a + * hyperlink repainted a second time is the same known link, and the dedup + * that stops it re-prompting must not also stop it counting as something a + * later guess can be a truncation of. + * + * The alternative fix considered here was to make this dedup *releasable*, + * so the consumer could hand the exact URL back and have it re-offered once + * the prompt slot emptied. Rejected: it re-offers on the very next repaint, + * so dismissing the toast would put it straight back on screen — and it + * still would not establish the invariant, because a truncated guess and the + * released exact URL would simply race for the empty slot. */ private scanLinks(): void { for (const uri of osc8Targets(this.buffer)) { if (uri.length < MIN_URL_LENGTH) continue; + this.rememberExact(uri); if (this.emittedLinks.has(uri)) continue; if (this.emittedLinks.size >= MAX_REMEMBERED_LINKS) { this.emittedLinks.clear(); @@ -319,13 +366,56 @@ export class UrlDetector { } private emitPending(): void { - if (this.pendingUrl && this.pendingUrl !== this.lastEmitted) { + if ( + this.pendingUrl && + this.pendingUrl !== this.lastEmitted && + !this.truncatesKnownExact(this.pendingUrl) + ) { this.lastEmitted = this.pendingUrl; this.callback(this.pendingUrl, "heuristic"); } this.pendingUrl = null; } + /** + * Whether `url` is a strict prefix of an exact URL already seen — i.e. a + * truncated guess at a link whose full text is known. + * + * {@link extendsUrl} is the predicate, used in the direction that asks "does + * the link I already have *extend* this guess?". It is the same rule the + * prompt slot uses to let a candidate grow into its complete form, which is + * the point: the two must agree about what "the same link, only shorter" + * means, so there is one implementation of it. + * + * Deliberately *not* symmetric. A candidate that is longer than a known exact + * URL and starts with it is a different problem (text glued onto the end by a + * wrap that was not a wrap), and it is still shown in full and confirmed by + * the user before anything opens. + */ + private truncatesKnownExact(url: string): boolean { + for (const exact of this.exactUrls) { + if (extendsUrl(exact, url)) return true; + } + return false; + } + + /** + * Record a URL that arrived somewhere exact, outside this detector. + * + * The OSC 7777 relay hands `TerminalView` a base64-encoded URL — exact by + * construction, and never seen here. Without this the suppression rule above + * would cover hyperlinks and miss the relay, and a dismissed relay prompt + * could still be replaced by a truncated scrape of the same link. + */ + noteExactUrl(url: string): void { + this.rememberExact(url); + } + + private rememberExact(url: string): void { + if (this.exactUrls.size >= MAX_REMEMBERED_LINKS) this.exactUrls.clear(); + this.exactUrls.add(url); + } + dispose(): void { if (this.timer !== null) { clearTimeout(this.timer); -- 2.52.0 From ae3ca8cda43c2d5938cbbf9e41ed34125d5931ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 11:11:57 -0700 Subject: [PATCH 11/43] Fix root scrub deleting host files through symlinked parents (C1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SNAPSHOT_SCRUB_PATHS is expanded by /bin/sh inside the container as root. For an entry ending `/*` the parent is a path *component*, resolved by both the glob expansion and the `rm -rf`. The agent has passwordless sudo, so `ln -s /workspace/myproject /var/log/apt` turned the next commit into a recursive delete of the user's real files on the host — reproduced end to end against a live container. snapshot_scrub_script now routes every deletion through one `scrub_in` function that validates the parent before touching anything inside it: `cd -P` for a TOCTOU-free handle, `pwd -P` equality to reject a symlinked component, a hardcoded containment allowlist that is deliberately not derived from the path list, and an st_dev comparison against `/` so a bind mount or a volume is refused even though it is not a symlink. It fails closed when `stat` is missing. Also: - /tmp/triple-c-drops/* and /tmp/clipboard_*.png are age-limited to 14 days instead of scrubbed unconditionally. They hold the user's own files, and scrubbing them meant "drop a file, change a setting, lose it silently"; removing them from the list would restore unbounded growth instead. - M11: scrub_writable_layer returns a ScrubOutcome and skips cleanly when the container is not running, so a migration no longer logs "could not run … committing anyway" on every run. - M12: the scrub exec is bounded by a 120s timeout; on expiry it logs and lets the commit proceed. - The script is now fold-safe (self-terminating lines, no `#` comments). disk.rs folds it onto one `RUN` line and the previous form was a `"do" unexpected` syntax error there, so compaction had been scrubbing nothing at all. Tests: the substring check on the script text is replaced by a behavioural test that runs the real generated script with a real symlink planted in a throwaway tree, plus structural tests over each containment construct and a `sh -n` check of the folded form. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc --- app/src-tauri/src/docker/container.rs | 703 ++++++++++++++++++++++++-- 1 file changed, 656 insertions(+), 47 deletions(-) diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index a495855..9738559 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -2041,32 +2041,73 @@ chmod 600 "$HOME/.aws/credentials""#; /// /// A snapshot is the user's system layer: their packages, their `/opt`, their /// `/var/lib/postgresql`. Nothing here may guess. Every entry is an absolute -/// path anchored to a directory Triple-C or a package manager owns, and the -/// three globs are anchored to `/tmp` specifically: +/// path anchored to a directory Triple-C or a package manager owns: /// /// * `/workspace/{mount_name}` subtrees are **host bind mounts** — the user's /// real project directories. No entry may ever reach one, which is why no /// pattern here starts with `/workspace`. /// * The only bind mounts under `/tmp` are `/tmp/.host-ca` and `/tmp/.host-aws` /// (both read-only). A leading-dot name is not matched by a shell glob, and -/// none of the three patterns share their prefix, so neither can be selected -/// even by accident. +/// none of these patterns share their prefix. /// * The apt entries keep their parent directory and remove only its contents /// (`lists/*`, `archives/*.deb`, `apt/*`); `apt-get` is unhappy when the /// directories themselves are missing. /// -/// A unit test pins the list, because the blast radius of a wrong entry here is -/// a user's data and the code that consumes it is a shell string. +/// ## Why a safe-looking *pattern* is not enough (C1) +/// +/// The list used to be the whole of the defence, and that was wrong. These +/// patterns are expanded by `/bin/sh` inside the container running as **root**, +/// and for an entry ending `/*` the parent is a *path component*: both the glob +/// expansion and the `rm -rf` that follows resolve it. The agent in the +/// container has passwordless sudo, so anything able to run +/// `ln -s /workspace/myproject /var/log/apt` turns the next commit into a +/// recursive delete of the user's real files **on the host**. That was +/// reproduced end to end against a live container — a bind mount emptied by a +/// scrub whose path list named no `/workspace` anywhere. +/// +/// (The entries whose glob is in the last component are the benign case: there +/// the *match* is the symlink, and `rm -rf -- link` unlinks the link and stops. +/// The distinction is not one a reviewer should have to make per entry, so the +/// script defends both alike.) +/// +/// So these entries are only half the contract. The other half is +/// [`snapshot_scrub_script`], which validates the parent directory — not +/// reached through a symlink, not on another filesystem, inside +/// [`SCRUB_CONTAINMENT_PREFIXES`] — before it deletes anything inside it. That +/// is also why every entry here must keep its glob in the **final component**: +/// the parent has to be literal for the script to be able to check it at all. A +/// test enforces it. +/// +/// A unit test also pins the list itself, because the blast radius of a wrong +/// entry here is a user's data and the code that consumes it is a shell string. pub(crate) const SNAPSHOT_SCRUB_PATHS: &[&str] = &[ // Agent scratchpads. The user's global CLAUDE.md instructs every agent to // put temporary files under a scratchpad directory in /tmp, so this is // where a long-running project's writable layer actually goes. + // + // Not age-limited, and worth being explicit about why that is only *just* + // safe: the scrub runs as root against a container that is still running, + // so a live Claude Code session or a `triple-c-scheduler` task writing here + // has its scratchpad pulled out from under it mid-write. Both callers stop + // the container within a line or two — `start_project_container` in + // `project_commands.rs` stops and removes it, `migrate_project_to_base` + // stops it — so the process that would notice is about to be killed anyway. + // That is a property of those two call sites and not of this entry: a third + // caller scrubbing a container it means to keep running would be corrupting + // a live session, and would need to age-limit this the way the two below + // are. "/tmp/claude-*", // Files drag-dropped into a terminal, staged by - // `commands/terminal_commands.rs` at up to 256 MiB each. Nothing in the - // repo deletes them. + // `commands/terminal_commands.rs` at up to 256 MiB each, and one PNG per + // pasted image from the same module. Nothing else in the repo deletes + // either, so leaving them out of this list restores unbounded growth — but + // they are the user's *own* files, and often the only copy inside the + // container of something handed to the agent seconds ago by a path that is + // still sitting in the conversation. Scrubbing them unconditionally meant + // "drop a file, change any of the 24 settings that trigger a recreation, + // lose it silently". Both are therefore age-limited rather than removed — + // see [`SCRUB_MIN_AGE_DAYS`]. "/tmp/triple-c-drops/*", - // One PNG per pasted image, from the same module. Also never deleted. "/tmp/clipboard_*.png", // Runtime apt debris. `browser_view/install.rs` and // `container/triple-c-playwright-heal` both run `apt-get install` inside a @@ -2077,32 +2118,172 @@ pub(crate) const SNAPSHOT_SCRUB_PATHS: &[&str] = &[ "/var/log/dpkg.log", ]; +/// Entries of [`SNAPSHOT_SCRUB_PATHS`] that are only deleted once a match's own +/// mtime is older than the given number of days. Anything not named here is +/// deleted whenever it is present. +/// +/// Two weeks is chosen against the thing that goes wrong: a recreation can +/// happen seconds after a drop, and no conversation is still quoting a path it +/// was given a fortnight ago. It is a compromise, not a proof — the alternative +/// of dropping these patterns entirely would be silent unbounded growth in a +/// directory nothing else ever cleans. +const SCRUB_MIN_AGE_DAYS: &[(&str, u32)] = &[ + ("/tmp/triple-c-drops/*", 14), + ("/tmp/clipboard_*.png", 14), +]; + +/// The only directory trees [`snapshot_scrub_script`] will operate in, checked +/// against the *resolved* parent directory at run time inside the container. +/// +/// Deliberately **not** derived from [`SNAPSHOT_SCRUB_PATHS`]: it is the +/// backstop for the case where that list is itself wrong. An entry added under +/// `/workspace`, `/home/claude` or `/etc` fails this check and deletes nothing, +/// however plausible it looked in review. +const SCRUB_CONTAINMENT_PREFIXES: &[&str] = &["/tmp", "/var/log", "/var/lib/apt", "/var/cache/apt"]; + /// Marker the scrub script prints so the byte total can be read back out of the /// exec's interleaved stdout/stderr. const SCRUB_MARKER: &str = "###TRIPLE-C-SCRUBBED "; -/// The `/bin/sh` program run inside the container to perform the scrub. +/// Split a [`SNAPSHOT_SCRUB_PATHS`] entry into the literal directory it is +/// anchored to and the glob to expand inside it. /// -/// Built here rather than inline so a test can read it. The path list is -/// interpolated **unquoted** on the `for` line, which is the whole point: the -/// shell expands the three globs there. An unmatched glob expands to itself, -/// the `[ -e ]` guard then fails, and the entry is skipped — so a pattern that -/// matches nothing is a no-op rather than an `rm` of a literal path. -/// Inside the loop `$p` is quoted, so a filename containing whitespace is one -/// argument. +/// `None` for anything that is not an absolute path with a non-empty final +/// component — which a test rules out, but the script generator must not have +/// to trust that, since what it emits runs as root. +fn split_scrub_pattern(pattern: &str) -> Option<(&str, &str)> { + let (dir, glob) = pattern.rsplit_once('/')?; + if !pattern.starts_with('/') || glob.is_empty() { + return None; + } + // A top-level entry such as `/dpkg.log` leaves an empty parent; anchor it. + Some((if dir.is_empty() { "/" } else { dir }, glob)) +} + +/// The `/bin/sh` program run inside the container to perform the scrub. pub(crate) fn snapshot_scrub_script() -> String { + snapshot_scrub_script_under("") +} + +/// [`snapshot_scrub_script`] with every absolute path re-anchored under `root`. +/// +/// ## What the script does +/// +/// One `scrub_in ` call per entry in +/// [`SNAPSHOT_SCRUB_PATHS`], with the pattern single-quoted so it reaches the +/// function unexpanded — unquoted, `/bin/sh` would expand the glob against the +/// script's own working directory before `scrub_in` ever ran. The function +/// splits it into parent and glob (`${1%/*}` / `${1##*/}`, which is why the +/// glob must live in the final component), and expands the glob only once the +/// working directory *is* the validated parent. Quoted everywhere it is used, +/// so a filename containing whitespace stays one argument; an unmatched glob +/// expands to itself and is skipped by the existence guard. +/// +/// Passing the whole pattern rather than the two halves also keeps each entry +/// readable verbatim in the compaction `RUN` line — `disk.rs` asserts exactly +/// that, to catch a second forked copy of the list. +/// +/// ## The containment guarantee (C1) +/// +/// `scrub_in` is the only place in the script that deletes anything, and it +/// does so only after four checks. In order: +/// +/// 0. `cd -P` into the parent **first**. Everything after that is relative to +/// the inode that gets validated, so re-pointing the *path* afterwards +/// cannot redirect the `rm`. A working directory is the only TOCTOU-free +/// handle `/bin/sh` offers. +/// 1. `pwd -P` — the fully resolved path — must equal what was asked for. +/// A symlinked component anywhere in the parent lands somewhere else, and +/// this is what catches `ln -s /workspace/myproject /var/log/apt`. +/// 2. The resolved path must be inside [`SCRUB_CONTAINMENT_PREFIXES`]. A +/// symlink is not the only way to name the wrong directory; this refuses to +/// operate outside the trees the scrub owns whatever the path list says. +/// 3. It must be on the same filesystem as the root. Check 1 does not see a +/// *bind mount*, which is not a symlink — but a bind mount and a named +/// volume each have a different `st_dev` from the overlay, and neither is +/// part of the writable layer the commit is about to capture. So anything on +/// another device is both dangerous to delete and pointless to. No `stat` +/// means no comparison, which means no deletion: this fails closed. +/// +/// Inside the loop, an expansion carrying a path separator means the list has +/// changed shape and is skipped, and `rm --one-file-system` (probed, because +/// busybox's `rm` would reject it) refuses to recurse across a mount planted +/// *below* a validated directory. +/// +/// ## Why every line ends in `;` +/// +/// `disk.rs` folds this script onto a single `RUN` line for the compaction +/// build, joining non-blank lines with a space. That is only a join and not a +/// rewrite if each line already terminates its own statement — the previous +/// version did not, and its folded form was a `"do" unexpected` syntax error, +/// so compaction had been running no scrub at all. It also means the script +/// carries **no `#` comments**: folded, one would swallow the rest of the +/// program. A test pins both the multi-line and the folded form. +/// +/// ## Why `root` exists +/// +/// `""` in production, which leaves the paths exactly as written. A non-empty +/// root lets the real generated script be run by a real `/bin/sh`, with real +/// symlinks planted in it, inside a throwaway directory tree — because +/// containment is a *runtime* property and the test this replaces +/// (`!dockerfile.contains("/workspace")`) was a substring check over the script +/// text that the exploitable version passed. +fn snapshot_scrub_script_under(root: &str) -> String { + let calls: String = SNAPSHOT_SCRUB_PATHS + .iter() + .filter_map(|pattern| { + // Validate the shape here even though the shell re-derives it: an + // entry the script could not split is one it must not be handed. + split_scrub_pattern(pattern)?; + let age = SCRUB_MIN_AGE_DAYS + .iter() + .find(|(p, _)| p == pattern) + .map(|(_, days)| days.to_string()) + .unwrap_or_else(|| "-".to_string()); + Some(format!("scrub_in '{root}{pattern}' '{age}';\n")) + }) + .collect(); + + let allowed: String = SCRUB_CONTAINMENT_PREFIXES + .iter() + .map(|p| format!("{root}{p}|{root}{p}/*")) + .collect::>() + .join("|"); + format!( - r#"total=0 -for p in {paths}; do - [ -e "$p" ] || continue - sz=$(du -sb "$p" 2>/dev/null | cut -f1) - case "$sz" in ''|*[!0-9]*) sz=0 ;; esac - rm -rf -- "$p" 2>/dev/null && total=$((total + sz)) -done -echo "{marker}$total" + r#"total=0; +rootdev=$(stat -c %d '{root}/' 2>/dev/null); +rmopt=; +rm --one-file-system --help >/dev/null 2>&1 && rmopt=--one-file-system; +scrub_in() {{ +n=$( +d=${{1%/*}}; [ -n "$d" ] || d=/; +g=${{1##*/}}; +cd -P "$d" 2>/dev/null || exit 0; +[ "$(pwd -P)" = "$d" ] || exit 0; +case "$d" in {allowed}) ;; *) exit 0 ;; esac; +[ -n "$rootdev" ] || exit 0; +[ "$(stat -c %d . 2>/dev/null)" = "$rootdev" ] || exit 0; +acc=0; +for p in $g; do +case "$p" in */*|.|..) continue ;; esac; +{{ [ -e "$p" ] || [ -L "$p" ]; }} || continue; +[ "$2" = "-" ] || [ -n "$(find "$p" -maxdepth 0 -mtime +"$2" -print 2>/dev/null)" ] || continue; +sz=$(du -sb -- "$p" 2>/dev/null | cut -f1); +case "$sz" in ''|*[!0-9]*) sz=0 ;; esac; +rm -rf $rmopt -- "$p" 2>/dev/null && acc=$((acc + sz)); +done; +echo "$acc"; +); +case "$n" in ''|*[!0-9]*) n=0 ;; esac; +total=$((total + n)); +}}; +{calls}echo "{marker}$total"; exit 0 "#, - paths = SNAPSHOT_SCRUB_PATHS.join(" "), + root = root, + allowed = allowed, + calls = calls, marker = SCRUB_MARKER, ) } @@ -2117,24 +2298,118 @@ fn parse_scrub_total(output: &str) -> Option { .find_map(|line| line.trim().strip_prefix(SCRUB_MARKER)?.trim().parse().ok()) } +/// How long [`scrub_writable_layer`] waits for its exec before the commit goes +/// ahead without it. +/// +/// The scrub is a `du -sb` plus an `rm -rf` over a tree a running agent may +/// still be writing to, and it sits on the critical path of every recreate and +/// every migration with the UI parked on "Saving container state…" and no way +/// to cancel. Two minutes is an order of magnitude more than the measured cost +/// on a 4.48 GB layer, and far less than the point where a user force-quits. +const SCRUB_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); + +/// What [`scrub_writable_layer`] actually did. +/// +/// The distinction that earns this type is [`ScrubOutcome::NotRunning`] versus +/// [`ScrubOutcome::Failed`]: "there was nothing to exec into" is routine, while +/// "the exec ran and broke" is the one case worth a warning in the log. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScrubOutcome { + /// The scrub ran to completion and freed this many bytes — possibly zero, + /// which is a real answer. + Reclaimed(u64), + /// The container is not running, so there is no `docker exec` to run in. + /// Not a failure: a stopped container's writable layer is not growing, and + /// on the migration path it was already scrubbed while it was. + NotRunning, + /// The container was running, but the scrub did not finish within + /// [`SCRUB_TIMEOUT`]. + TimedOut, + /// The container was running and the scrub genuinely failed. + Failed, +} + +impl ScrubOutcome { + /// Bytes reclaimed. Zero for every outcome that is not a completed scrub. + pub fn bytes(&self) -> u64 { + match self { + Self::Reclaimed(bytes) => *bytes, + _ => 0, + } + } +} + /// Delete the throwaway files listed in [`SNAPSHOT_SCRUB_PATHS`] from a /// container's writable layer so the commit that follows does not bake them in. /// /// Runs as **root**: the apt debris is root-owned while the scratchpads belong -/// to `claude`, and root can remove both. +/// to `claude`, and root can remove both. That is also what makes +/// [`snapshot_scrub_script`]'s containment checks load-bearing rather than +/// decorative. /// /// **Never fails the caller, by design.** A scrub is an optimisation; a commit /// is the only copy of the user's system layer. Losing some disk is a strictly /// better outcome than refusing to snapshot, so every failure here is a log -/// line and nothing more. Note that this is a `docker exec` and therefore only -/// works while the container runs: `migrate_project_to_base` stops its -/// container before the pre-swap commit, so it calls this itself beforehand -/// rather than relying on the call inside [`commit_container_snapshot`]. -pub async fn scrub_writable_layer(container_id: &str) -> u64 { +/// line and nothing more — including the timeout, which stops waiting and lets +/// the commit proceed rather than leaving the UI on "Saving container state…" +/// with no way out. +/// +/// ## Why it checks whether the container is running (M11) +/// +/// This is a `docker exec`, so it only works on a running container. +/// `migrate_project_to_base` knows that: it scrubs the container itself and +/// *then* stops it, because the pre-swap commit is the largest snapshot +/// Triple-C ever takes. The call inside [`commit_container_snapshot`] therefore +/// arrives at a stopped container and could only ever fail, which logged +/// "could not run … committing anyway" on every single migration — training +/// the reader to ignore the one line that would matter if the scrub had really +/// broken. Asking first turns that into a debug line and keeps the warning +/// meaningful. +pub async fn scrub_writable_layer(container_id: &str) -> ScrubOutcome { + match is_container_running(container_id).await { + Ok(true) => {} + Ok(false) => { + log::debug!( + "Skipping pre-commit scrub of container {}: it is not running, so there is nothing to exec into and its writable layer is not growing", + container_id + ); + return ScrubOutcome::NotRunning; + } + Err(e) => { + // Only Docker being unreachable reaches here, in which case the + // commit this precedes is about to fail on its own — but say so + // rather than reporting a skip that never happened. + log::warn!( + "Could not tell whether container {} is running ({}); skipping the pre-commit scrub and committing anyway", + container_id, + e + ); + return ScrubOutcome::Failed; + } + } + let script = snapshot_scrub_script(); let cmd = vec!["/bin/sh".to_string(), "-c".to_string(), script]; + let exec = crate::docker::exec::exec_oneshot_as(container_id, "root", cmd, Vec::new()); - match crate::docker::exec::exec_oneshot_as(container_id, "root", cmd, Vec::new()).await { + // M12: nothing under `docker/` has a timeout, and this is the one call on + // the critical path of every recreate. Dropping the future stops us + // *waiting*; the `sh` keeps running inside the container and its deletions + // are still valid, they just stop counting towards the total. That is the + // right trade — the commit that follows is merely a little larger. + let exec_result = match tokio::time::timeout(SCRUB_TIMEOUT, exec).await { + Ok(result) => result, + Err(_) => { + log::warn!( + "Pre-commit scrub of container {} did not finish within {}s; committing anyway", + container_id, + SCRUB_TIMEOUT.as_secs() + ); + return ScrubOutcome::TimedOut; + } + }; + + match exec_result { Ok((output, _exit_code)) => match parse_scrub_total(&output) { Some(bytes) => { if bytes > 0 { @@ -2144,7 +2419,7 @@ pub async fn scrub_writable_layer(container_id: &str) -> u64 { bytes as f64 / 1_048_576.0 ); } - bytes + ScrubOutcome::Reclaimed(bytes) } None => { log::warn!( @@ -2152,7 +2427,7 @@ pub async fn scrub_writable_layer(container_id: &str) -> u64 { container_id, output.trim() ); - 0 + ScrubOutcome::Failed } }, Err(e) => { @@ -2161,7 +2436,7 @@ pub async fn scrub_writable_layer(container_id: &str) -> u64 { container_id, e ); - 0 + ScrubOutcome::Failed } } } @@ -2198,6 +2473,10 @@ pub async fn scrub_writable_layer(container_id: &str) -> u64 { /// /// See [`SNAPSHOT_SCRUB_PATHS`]. Every commit stacks a layer, so a file present /// here is a file the project's image carries for the rest of its life. +/// +/// The scrub is a no-op on a container that is already stopped — the migration +/// path scrubs it itself while it is still running and stops it before getting +/// here — and it can never fail this function; see [`scrub_writable_layer`]. pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> Result<(), String> { let docker = get_docker()?; let image_name = get_snapshot_image_name(project); @@ -2206,7 +2485,7 @@ pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> // stacks a layer and never rewrites one, so anything present at this // instant is paid for permanently — see [`SNAPSHOT_SCRUB_PATHS`]. Failure // is swallowed inside; a scrub must never be able to block a snapshot. - scrub_writable_layer(container_id).await; + let scrub = scrub_writable_layer(container_id).await; // Parse repo:tag let (repo, tag) = match image_name.rsplit_once(':') { @@ -2232,7 +2511,13 @@ pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> .await .map_err(|e| format!("Failed to commit container snapshot: {}", e))?; - log::info!("Committed container {} as snapshot {}:{}", container_id, repo, tag); + log::info!( + "Committed container {} as snapshot {}:{} ({:.2} MB dropped by the pre-commit scrub)", + container_id, + repo, + tag, + scrub.bytes() as f64 / 1_048_576.0 + ); Ok(()) } @@ -3576,13 +3861,18 @@ mod tests { assert!(blind.left_something_behind()); } - // ── Pre-commit scrub (A1) ──────────────────────────────────────────────── + // ── Pre-commit scrub (A1, C1) ──────────────────────────────────────────── #[test] fn no_scrub_path_can_reach_a_host_bind_mount() { // `/workspace/{mount_name}` is the user's own project directory, bound // in from the host. Nothing in this list may ever name one — and the // two read-only host mounts under /tmp must be equally unreachable. + // + // Necessary, not sufficient: a pattern that looks like this can still + // resolve into a bind mount through a symlinked parent, which is what + // the script's own checks exist for. See + // `the_scrub_script_refuses_a_symlinked_parent_...` below. for path in SNAPSHOT_SCRUB_PATHS { assert!( path.starts_with('/'), @@ -3626,6 +3916,69 @@ mod tests { } } + #[test] + fn every_scrub_pattern_keeps_its_glob_in_the_final_component() { + // The script can only validate a parent directory it can name, so the + // parent has to be literal. An entry like `/var/*/apt/*` would put a + // glob in a path *component*, which is the shape that made C1 + // exploitable in the first place. + for pattern in SNAPSHOT_SCRUB_PATHS { + let (dir, glob) = split_scrub_pattern(pattern) + .unwrap_or_else(|| panic!("{} has no literal parent directory", pattern)); + assert!( + !dir.contains(['*', '?', '[']), + "{}: the parent {} is itself a glob, so the script cannot check it", + pattern, + dir + ); + assert!(!glob.is_empty(), "{} has an empty final component", pattern); + // Both halves are single-quoted into the script; a quote or a blank + // would break out of that quoting and change what runs as root. + assert!( + !pattern.contains('\'') && !pattern.contains(char::is_whitespace), + "{} cannot be safely single-quoted into the scrub script", + pattern + ); + } + } + + #[test] + fn every_scrub_parent_is_inside_the_scripts_containment_allowlist() { + // The allowlist is hardcoded in the script and deliberately not derived + // from this list, so the two can drift apart — in which case the entry + // silently stops being scrubbed. Fail here instead. + for pattern in SNAPSHOT_SCRUB_PATHS { + let (dir, _) = split_scrub_pattern(pattern).expect("a literal parent"); + assert!( + SCRUB_CONTAINMENT_PREFIXES + .iter() + .any(|p| dir == *p || dir.starts_with(&format!("{}/", p))), + "{} is anchored at {}, which the script's containment check would refuse", + pattern, + dir + ); + } + } + + #[test] + fn the_containment_allowlist_cannot_reach_anything_of_the_users() { + for prefix in SCRUB_CONTAINMENT_PREFIXES { + assert!(prefix.starts_with('/'), "{} is not absolute", prefix); + assert!(!prefix.ends_with('/'), "{} would match the wrong things", prefix); + for forbidden in [ + "/", "/workspace", "/home", "/etc", "/usr", "/opt", "/srv", "/root", + "/var/lib/postgresql", "/var/lib/docker", + ] { + assert!( + !(forbidden == *prefix || forbidden.starts_with(&format!("{}/", prefix))), + "the allowlist entry {} contains {}", + prefix, + forbidden + ); + } + } + } + #[test] fn the_scrub_list_covers_every_measured_source_of_writable_layer_growth() { // Each of these was measured in a real container's pending commit. @@ -3648,21 +4001,199 @@ mod tests { } #[test] - fn the_scrub_script_expands_globs_but_quotes_the_match() { + fn the_users_own_files_are_only_scrubbed_once_they_are_stale() { + // Regression: these two hold files the user handed the agent by hand, + // and deleting them at commit time turned "change a setting" into + // "silently lose the file you just dropped". They stay in the list — + // nothing else ever reclaims them — but only with an age gate. + for user_owned in ["/tmp/triple-c-drops/*", "/tmp/clipboard_*.png"] { + assert!( + SNAPSHOT_SCRUB_PATHS.contains(&user_owned), + "{} was removed from the list instead of age-limited, so nothing reclaims it", + user_owned + ); + let age = SCRUB_MIN_AGE_DAYS.iter().find(|(p, _)| *p == user_owned); + assert!( + age.is_some_and(|(_, days)| *days >= 7), + "{} is scrubbed without a meaningful age limit, which loses a just-dropped file", + user_owned + ); + } + // The scratchpads are machine debris and are not age-limited; if that + // ever changes, the comment explaining why must change with it. + assert!(!SCRUB_MIN_AGE_DAYS.iter().any(|(p, _)| *p == "/tmp/claude-*")); + // An age limit on a pattern that is not scrubbed at all does nothing + // and reads as though it does. + for (pattern, _) in SCRUB_MIN_AGE_DAYS { + assert!(SNAPSHOT_SCRUB_PATHS.contains(pattern), "{} is not scrubbed", pattern); + } + } + + #[test] + fn the_scrub_script_validates_every_directory_before_deleting_in_it() { + // The structural half of the C1 fix, for the environments where the + // `/bin/sh` test below cannot run. Each assertion here pins one of the + // four checks; deleting any of them from the script fails this test. let script = snapshot_scrub_script(); - // Unquoted on the `for` line — that is what makes the shell expand the - // globs at all. - assert!(script.contains("for p in /tmp/claude-* /tmp/triple-c-drops/*")); - // Quoted everywhere it is *used*, so a filename with a space is one - // argument and not two paths. - assert!(script.contains(r#"[ -e "$p" ] || continue"#)); - assert!(script.contains(r#"rm -rf -- "$p""#)); + + // Exactly one deletion in the whole script, inside `scrub_in` and + // downstream of all four checks. A future edit that adds a bare `rm` + // at the top level — which is what the vulnerable version was — fails + // here. + assert_eq!( + script.matches("rm -rf").count(), + 1, + "the scrub deletes somewhere other than inside the validated block:\n{}", + script + ); + // The handle: after this, paths are relative to a validated inode, so + // re-pointing the directory cannot redirect the delete. + assert!(script.contains(r#"cd -P "$d" 2>/dev/null || exit 0;"#)); + // 1. no symlinked component anywhere in the parent + assert!(script.contains(r#"[ "$(pwd -P)" = "$d" ] || exit 0;"#)); + // 2. positive containment against a hardcoded allowlist + assert!(script.contains("/tmp|/tmp/*|/var/log|/var/log/*")); + assert!(!script.contains("/workspace")); + // 3. the same filesystem as the root — a bind mount or volume is not + // part of the writable layer and must never be touched. Fails + // closed when `stat` is unavailable. + assert!(script.contains(r#"[ -n "$rootdev" ] || exit 0;"#)); + assert!(script.contains(r#"[ "$(stat -c %d . 2>/dev/null)" = "$rootdev" ] || exit 0;"#)); + // 4. an expansion carrying a separator means the list changed shape + assert!(script.contains(r#"case "$p" in */*|.|..) continue ;; esac;"#)); + } + + #[test] + fn the_scrub_script_hands_each_glob_over_unexpanded() { + let script = snapshot_scrub_script(); + // Single-quoted at the call site: an unquoted `*` would be expanded + // against the script's own working directory before `scrub_in` ran. + assert!(script.contains("scrub_in '/tmp/claude-*' '-';")); + assert!(script.contains("scrub_in '/var/log/apt/*' '-';")); + assert!(script.contains("scrub_in '/tmp/triple-c-drops/*' '14';")); + // The parent/glob split happens in the shell, so every entry stays + // readable verbatim — `disk.rs` folds this onto one `RUN` line and + // asserts each pattern appears there rather than a forked copy. + for pattern in SNAPSHOT_SCRUB_PATHS { + assert!(script.contains(pattern), "{} is not named in the script", pattern); + } + // Expanded inside the function, where the cwd is the validated + // directory, and quoted everywhere it is *used* so a filename with a + // space stays one argument. + assert!(script.contains("for p in $g; do")); + assert!(script.contains(r#"rm -rf $rmopt -- "$p""#)); // `rm -rf /` would be catastrophic and is exactly what a botched // interpolation produces. assert!(!script.contains("rm -rf -- /\n")); assert!(!script.contains(" / ")); } + /// The behavioural half of the C1 fix. + /// + /// The test this replaces asserted `!script.contains("/workspace")`, which + /// the exploited version passed: containment is a property of what the + /// paths resolve to at run time. Docker is not available everywhere the + /// suite runs, so a throwaway directory tree stands in for the container + /// and `snapshot_scrub_script_under` re-anchors the real generated script + /// onto it. Linux-only because the script uses GNU `stat -c` and `du -b`, + /// which is what it will always run against inside the image. + #[cfg(target_os = "linux")] + #[test] + fn the_scrub_script_refuses_a_symlinked_parent_and_still_reclaims_a_real_one() { + use std::fs; + use std::process::Command; + + // Canonicalised: a TMPDIR that is itself a symlink would trip check 1 + // and make every case pass by doing nothing at all. + let base = fs::canonicalize(std::env::temp_dir()).expect("a real temp dir"); + let root = base.join(format!("triple-c-scrub-{}", uuid::Uuid::new_v4().simple())); + let root_str = root.to_str().expect("a UTF-8 temp path").to_string(); + + let mk = |rel: &str| fs::create_dir_all(root.join(rel)).expect("mkdir"); + mk("tmp/triple-c-drops"); + mk("var/log"); + mk("var/lib/apt/lists"); + mk("var/cache/apt/archives"); + // Stands in for /workspace/{mount_name}: the user's own project, + // bind-mounted from the host. + mk("workspace/myproject/sub"); + fs::write(root.join("workspace/myproject/precious.txt"), "do not delete").unwrap(); + fs::write(root.join("workspace/myproject/sub/nested.txt"), "nor this").unwrap(); + + // Debris the scrub is supposed to take, so this cannot pass by + // refusing everything. + mk("tmp/claude-scratch"); + fs::write(root.join("tmp/claude-scratch/blob"), vec![0u8; 64 * 1024]).unwrap(); + fs::write(root.join("var/lib/apt/lists/deb.list"), vec![0u8; 32 * 1024]).unwrap(); + + // The attack, in the two shapes that were verified against a real + // container: a symlinked *parent* (a path component, resolved by both + // the glob and the `rm -rf`) and a symlinked *match* (safe already — + // `rm -rf -- link` unlinks the link — and pinned here so it stays so). + std::os::unix::fs::symlink(root.join("workspace/myproject"), root.join("var/log/apt")) + .unwrap(); + std::os::unix::fs::symlink( + root.join("workspace/myproject"), + root.join("tmp/claude-link"), + ) + .unwrap(); + + // The age gate on the user's own files: the one dropped a moment ago + // survives a recreation, the one from last month does not. + fs::write(root.join("tmp/triple-c-drops/fresh.txt"), "just dropped").unwrap(); + fs::write(root.join("tmp/triple-c-drops/stale.txt"), vec![0u8; 8 * 1024]).unwrap(); + let touched = Command::new("touch") + .arg("-d") + .arg("30 days ago") + .arg(root.join("tmp/triple-c-drops/stale.txt")) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + let script = snapshot_scrub_script_under(&root_str); + let output = Command::new("/bin/sh") + .arg("-c") + .arg(&script) + .output() + .expect("run the generated script"); + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + + let exists = |rel: &str| root.join(rel).exists(); + let survived = exists("workspace/myproject/precious.txt") + && exists("workspace/myproject/sub/nested.txt"); + let debris_gone = + !exists("tmp/claude-scratch") && !exists("var/lib/apt/lists/deb.list"); + let link_removed = fs::symlink_metadata(root.join("tmp/claude-link")).is_err(); + let fresh_kept = exists("tmp/triple-c-drops/fresh.txt"); + let stale_gone = !exists("tmp/triple-c-drops/stale.txt"); + let total = parse_scrub_total(&stdout); + + fs::remove_dir_all(&root).ok(); + + assert!( + survived, + "the scrub followed a symlinked parent into a bind mount.\nstdout: {}\nstderr: {}\nscript:\n{}", + stdout, stderr, script + ); + assert!( + debris_gone, + "the scrub stopped reclaiming anything.\nstdout: {}\nstderr: {}", + stdout, stderr + ); + assert!(link_removed, "a symlinked match was left behind instead of unlinked"); + assert!(fresh_kept, "a file dropped moments ago was scrubbed anyway"); + if touched { + assert!(stale_gone, "an age-limited file well past its limit was kept"); + } + let total = total.expect("the marker line is missing"); + assert!( + total >= 96 * 1024, + "reported total {} is too small to have removed the planted debris", + total + ); + } + #[test] fn the_scrub_total_is_read_back_from_the_marker_line() { assert_eq!( @@ -3677,6 +4208,84 @@ mod tests { assert_eq!(parse_scrub_total(""), None); } + #[test] + fn only_a_completed_scrub_reports_bytes() { + // M11: a stopped container is a skip, not a failure, and every outcome + // that is not a completed run contributes nothing to the log's total. + assert_eq!(ScrubOutcome::Reclaimed(4096).bytes(), 4096); + assert_eq!(ScrubOutcome::Reclaimed(0).bytes(), 0); + assert_eq!(ScrubOutcome::NotRunning.bytes(), 0); + assert_eq!(ScrubOutcome::TimedOut.bytes(), 0); + assert_eq!(ScrubOutcome::Failed.bytes(), 0); + assert_ne!(ScrubOutcome::NotRunning, ScrubOutcome::Failed); + } + + #[test] + fn the_scrub_cannot_hold_a_snapshot_up_indefinitely() { + // M12: the scrub is a `du -sb` plus an `rm -rf` over a tree a running + // agent may still be writing to, on the critical path of every + // recreate with the UI on "Saving container state…" and no cancel. + assert!(SCRUB_TIMEOUT.as_secs() >= 30, "too tight to survive a slow but healthy scrub"); + assert!(SCRUB_TIMEOUT.as_secs() <= 300, "long enough that a user would force-quit first"); + } + + /// `disk.rs` folds this script onto one `RUN` line for the compaction + /// build by joining its non-blank lines with a space, so the script has to + /// be a sequence of self-terminating statements and carry no `#` comments. + /// The previous version was neither: its folded form was a `"do" + /// unexpected` syntax error, which means compaction had been scrubbing + /// nothing at all. The fold is reproduced here rather than imported + /// because it is private to the other module — a divergence would show up + /// as this test passing while the real Dockerfile broke, so it is pinned + /// against the same wording in `fold_shell_script`. + #[cfg(unix)] + #[test] + fn the_scrub_script_survives_being_folded_onto_one_run_line() { + use std::io::Write; + use std::process::{Command, Stdio}; + + let script = snapshot_scrub_script(); + for line in script.lines() { + // A `#` only starts a comment at the beginning of a word, so the + // quoted `###TRIPLE-C-SCRUBBED` marker is fine; anything else is a + // comment that eats the rest of the program once folded. + assert!( + !line.trim_start().starts_with('#') && !line.contains(" #"), + "a `#` comment swallows the rest of the program once folded: {}", + line + ); + } + let folded = script + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" "); + assert!(!folded.contains('\n')); + + for candidate in [script.as_str(), folded.as_str()] { + let mut child = Command::new("/bin/sh") + .arg("-n") + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn /bin/sh -n"); + child + .stdin + .take() + .expect("stdin") + .write_all(candidate.as_bytes()) + .expect("write the script"); + let out = child.wait_with_output().expect("wait"); + assert!( + out.status.success(), + "the scrub script does not parse: {}\n---\n{}", + String::from_utf8_lossy(&out.stderr), + candidate + ); + } + } + // ── Container log rotation (A2) ────────────────────────────────────────── #[test] -- 2.52.0 From 092972fe92df7c394805342d77e3b56f5f8c4ba8 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 23 Aug 2026 11:13:15 -0700 Subject: [PATCH 12/43] security: close capability, CSP and auth-bridge holes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capabilities/default.json - Drop every `store:*` grant. `@tauri-apps/plugin-store` has no caller in `app/src`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData — `push` discards the base for an absolute path, so the grant was an arbitrary host read/write from the webview. - Replace `opener:default` with a scoped `opener:allow-open-url` (http/https only). That drops `reveal_item_in_dir`, which the plugin does not scope-check and nothing here calls, and the unused mailto:/tel: scope. - Record the unscopable `drag:allow-start-drag` residual risk in `description`. tauri.conf.json - Add `form-action 'none'`, `base-uri 'none'`, `object-src 'none'`. `form-action` has no `default-src` fallback, so an injected auto-submitting form was unblocked even though `script-src 'self'` blocks XSS. - Remove the dead `asset:` / `https://asset.localhost` img-src and `data:` font-src grants; `blob:` stays (the file viewer uses it). auth_bridge - The reserved-port set covered only this project's mappings and the two browser-view ranges. It now also covers the gateway, STT and web-terminal host ports (configured value and shipped default, read off the settings models) and every other project's published host port. A container binding container-loopback 4000 / 9876 / 7681 while those services were stopped had that port mirrored onto the host, unauthenticated, within one poll. - Gate the host listener on fetch metadata: refuse a request that is a cross-site sub-resource, allow navigations (the OAuth redirect) and anything without `Sec-Fetch-*`. Non-HTTP connections are classified from their first line and forwarded verbatim. Residual risk is spelled out in the module docs. - Bound the forwards: max concurrent connections per port, a first-byte deadline enforced before any `docker exec` is created, and an idle timeout. browser_view/mod.rs - `pick_viewer_port` reads procfs with `/usr/bin/cat`, not a bare `cat` the container can shim via its writable PATH entry. - Treat port choice as check-then-bind: walk to the next free candidate when the viewer does not come up, instead of failing the start. BrowserTab.tsx - Sandbox the viewer iframe. Container-controlled content could `top.location` the app's webview away. `allow-top-navigation*` and `allow-popups-to-escape-sandbox` are deliberately absent. HelpDialog.tsx - Escape the quote characters in the entity pass and escape captured attribute values. `href="$2"` with `$2` = `[^)]+` let remote GitHub markdown close the attribute and open another, in a document rendered with `dangerouslySetInnerHTML`. web_terminal/terminal.html - SRI hashes plus `crossorigin` on the three jsdelivr bundles and the stylesheet, and a CSP for the page — it is served 0.0.0.0 behind a permissive CORS layer and nothing else gives it one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc --- app/src-tauri/capabilities/default.json | 20 +- app/src-tauri/gen/schemas/capabilities.json | 2 +- app/src-tauri/src/auth_bridge/mod.rs | 193 +++++- app/src-tauri/src/auth_bridge/tunnel.rs | 551 +++++++++++++++++- app/src-tauri/src/browser_view/mod.rs | 103 +++- app/src-tauri/src/web_terminal/terminal.html | 75 ++- app/src-tauri/tauri.conf.json | 2 +- app/src/components/layout/HelpDialog.test.tsx | 100 ++++ app/src/components/layout/HelpDialog.tsx | 63 +- .../components/projects/home/BrowserTab.tsx | 29 + 10 files changed, 1066 insertions(+), 72 deletions(-) create mode 100644 app/src/components/layout/HelpDialog.test.tsx diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 702d43d..a5317e6 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -1,6 +1,6 @@ { "identifier": "default", - "description": "Default capabilities for Triple-C", + "description": "Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is kept to what the frontend actually uses. Two notes on what is deliberately absent and what is deliberately accepted: (1) the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). (2) `drag:allow-start-drag` stays, and cannot be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read. It is not a silent exfiltration primitive: the drag only delivers anything if the user completes a real drop onto a real target, and the OS shows the drag under the cursor while it is in flight. Removing it would remove drag-out from the Files pane (`stage_container_file_for_drag`), whose fallback is the explicit \"Save to host…\" action. Accepted residual risk, recorded here rather than fixed.", "windows": ["main"], "permissions": [ "core:default", @@ -15,20 +15,10 @@ "dialog:allow-message", "dialog:allow-ask", "dialog:allow-confirm", - "store:default", - "store:allow-get", - "store:allow-set", - "store:allow-delete", - "store:allow-keys", - "store:allow-values", - "store:allow-entries", - "store:allow-length", - "store:allow-load", - "store:allow-reset", - "store:allow-save", - "store:allow-clear", - "opener:default", - "opener:allow-open-url", + { + "identifier": "opener:allow-open-url", + "allow": [{ "url": "http://*" }, { "url": "https://*" }] + }, "drag:default", "drag:allow-start-drag" ] diff --git a/app/src-tauri/gen/schemas/capabilities.json b/app/src-tauri/gen/schemas/capabilities.json index a8b1260..f058044 100644 --- a/app/src-tauri/gen/schemas/capabilities.json +++ b/app/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capabilities for Triple-C","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit-to","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm","store:default","store:allow-get","store:allow-set","store:allow-delete","store:allow-keys","store:allow-values","store:allow-entries","store:allow-length","store:allow-load","store:allow-reset","store:allow-save","store:allow-clear","opener:default","opener:allow-open-url","drag:default","drag:allow-start-drag"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is kept to what the frontend actually uses. Two notes on what is deliberately absent and what is deliberately accepted: (1) the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). (2) `drag:allow-start-drag` stays, and cannot be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read. It is not a silent exfiltration primitive: the drag only delivers anything if the user completes a real drop onto a real target, and the OS shows the drag under the cursor while it is in flight. Removing it would remove drag-out from the Files pane (`stage_container_file_for_drag`), whose fallback is the explicit \"Save to host…\" action. Accepted residual risk, recorded here rather than fixed.","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit-to","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"}]},"drag:default","drag:allow-start-drag"]}} \ No newline at end of file diff --git a/app/src-tauri/src/auth_bridge/mod.rs b/app/src-tauri/src/auth_bridge/mod.rs index bc2b7e0..7473cbc 100644 --- a/app/src-tauri/src/auth_bridge/mod.rs +++ b/app/src-tauri/src/auth_bridge/mod.rs @@ -334,7 +334,10 @@ async fn poll_loop( Ok(text) => { exec_failures = 0; let discovered = proc_net::parse_loopback_listeners(&text); - let skip = skipped_ports(&project); + // Re-read every tick: a project can gain a port mapping and the + // gateway/STT/web-terminal ports can be re-pointed while the + // bridge is running, and a stale reservation set is a hole. + let skip = skipped_ports(&project, &store.list(), &app_settings(&app)); if reconcile(&container_id, &discovered, &skip, &state).await { emit_status(&app, &project_id, &state, true).await; } @@ -377,24 +380,101 @@ async fn poll_loop( } } -/// Ports Docker already handles for this project. A container port that is -/// explicitly published has a host-side path already, and the mapping's host -/// port is a binding we must not fight over. +/// Every port this project's bridge must not take. /// -/// [`RESERVED_CONTAINER_PORTS`] is folded in as well: those are container -/// loopback listeners another feature owns and exposes on its own, -/// authenticated terms. -fn skipped_ports(project: &crate::models::Project) -> HashSet { +/// The bridge's rule is "a container loopback listener on port N becomes an +/// **unauthenticated** host listener on port N". That is only safe for ports +/// nothing else on the host owns, so everything that *is* owned has to be +/// enumerated here. Four sources: +/// +/// 1. **This project's own published ports** — a container port that Docker +/// already publishes has a host-side path, and the mapping's host port is a +/// binding we must not fight over. +/// 2. **Every other project's published host ports.** The container names the +/// *host* port, so project A's container listening on 8080 would otherwise +/// have the bridge bind host 8080 — the port project B publishes on. Only +/// the host end of another project's mapping is reserved: its container end +/// is a number inside a different network namespace and means nothing here. +/// 3. **This app's own host services** — the LiteLLM gateway, the STT sidecar +/// and the web terminal. All three are off by default and bind on demand, so +/// first-come would win: a container that binds container-loopback 4000 +/// while the gateway is stopped gets host `127.0.0.1:4000` mirrored to it +/// within one [`POLL_INTERVAL`], after which the gateway cannot start and +/// anything on the host dialling 4000 — including *other project +/// containers*, which reach the gateway by host address — is talking to the +/// squatting container instead. The web terminal is the worst of the three, +/// because its access token travels in the URL query. Both the *configured* +/// port and the shipped default are reserved: the configured one is what the +/// service will bind next, and the default is what it falls back to for a +/// fresh profile or a settings file that failed to parse. +/// 4. [`RESERVED_CONTAINER_PORTS`] and [`RESERVED_HOST_PORTS`] — the +/// browser-view pane's two ends, which it exposes on its own authenticated +/// terms. +/// +/// Pure on purpose: everything it needs is passed in, so the whole reservation +/// policy is unit-testable without a store, a container or an app handle. +fn skipped_ports( + project: &crate::models::Project, + all_projects: &[crate::models::Project], + settings: &crate::models::AppSettings, +) -> HashSet { let mut skip: HashSet = project .port_mappings .iter() .flat_map(|m| [m.container_port, m.host_port]) .collect(); + + // Other projects: host end only. + skip.extend( + all_projects + .iter() + .filter(|p| p.id != project.id) + .flat_map(|p| p.port_mappings.iter().map(|m| m.host_port)), + ); + + skip.extend(app_service_host_ports(settings)); skip.extend(RESERVED_CONTAINER_PORTS.clone()); skip.extend(RESERVED_HOST_PORTS.clone()); skip } +/// Current app settings, or defaults if the state is not reachable. +/// +/// Falling back rather than unwrapping matters: the reservation set is a safety +/// rail, and a rail that panics the poller when it cannot read its input is +/// worse than one that falls back to the shipped port numbers — which are what +/// the services use anyway until someone changes them. +fn app_settings(app: &AppHandle) -> crate::models::AppSettings { + use tauri::Manager; + app.try_state::() + .map(|state| state.settings_store.get()) + .unwrap_or_default() +} + +/// Host ports this app's own sibling services bind, configured value and +/// shipped default alike. +/// +/// Read off the settings models rather than restated as literals here: a +/// duplicated port number is exactly the kind of constant that drifts silently, +/// and the failure mode of drift is a reservation that no longer covers the +/// service it was written for. +fn app_service_host_ports(settings: &crate::models::AppSettings) -> Vec { + use crate::models::{SttSettings, WebTerminalSettings}; + + vec![ + // LiteLLM gateway (`docker/gateway.rs`). + settings.gateway.port, + crate::models::default_gateway_port(), + // Speech-to-text sidecar (`docker/stt.rs`). + settings.stt.port, + SttSettings::default().port, + // Remote web terminal (`web_terminal/server.rs`) — binds 0.0.0.0, and + // its access token is in the URL query. + settings.web_terminal.port, + WebTerminalSettings::default().port, + ] +} + // ───────────────────────────────────────────────────────────────────────────── // Reservations // ───────────────────────────────────────────────────────────────────────────── @@ -591,7 +671,7 @@ async fn emit_status( #[cfg(test)] mod tests { use super::*; - use crate::models::{PortMapping, Project, ProjectPath}; + use crate::models::{AppSettings, PortMapping, Project, ProjectPath}; fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project { let mut p = Project::new( @@ -612,9 +692,14 @@ mod tests { p } + /// The common case: one project, no siblings, stock settings. + fn skip_for(project: &Project) -> HashSet { + skipped_ports(project, std::slice::from_ref(project), &AppSettings::default()) + } + #[test] fn ports_already_published_by_docker_are_skipped() { - let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000), (8081, 8080)])); + let skip = skip_for(&project_with_mappings(vec![(3000, 3000), (8081, 8080)])); assert!(skip.contains(&3000)); // Both ends of an asymmetric mapping are off limits: the container port // is already reachable, and the host port is Docker's binding. @@ -624,20 +709,96 @@ mod tests { } #[test] - fn no_mappings_means_nothing_but_the_reserved_ranges_are_skipped() { - let skip = skipped_ports(&project_with_mappings(vec![])); + fn no_mappings_means_nothing_but_the_reservations_are_skipped() { + let settings = AppSettings::default(); + let project = project_with_mappings(vec![]); + let skip = skip_for(&project); + + let mut expected: HashSet = RESERVED_CONTAINER_PORTS.collect(); + expected.extend(RESERVED_HOST_PORTS); + expected.extend(app_service_host_ports(&settings)); + assert_eq!(skip, expected); + + // The ranges and the service ports are disjoint, so nothing above is + // accidentally counting the same port twice. assert_eq!( skip.len(), - RESERVED_CONTAINER_PORTS.clone().count() + RESERVED_HOST_PORTS.clone().count() + RESERVED_CONTAINER_PORTS.clone().count() + + RESERVED_HOST_PORTS.clone().count() + + 3 ); } + #[test] + fn this_apps_own_host_services_are_never_taken() { + // The bug this guards: the reserved set used to cover only the + // browser-view ranges and this project's own mappings, so a container + // binding container-loopback 4000 / 9876 / 7681 while the matching + // service was stopped had that port mirrored, unauthenticated, onto the + // host — taking the gateway's, the STT sidecar's or the web terminal's + // door before they could bind it. + let settings = AppSettings::default(); + let skip = skip_for(&project_with_mappings(vec![])); + + assert!(skip.contains(&settings.gateway.port), "LiteLLM gateway port"); + assert!(skip.contains(&settings.stt.port), "STT sidecar port"); + assert!(skip.contains(&settings.web_terminal.port), "web terminal port"); + + // The shipped defaults, spelled out once so a change to any of them is + // a change to this assertion and not a silent narrowing. + assert!(skip.contains(&4000)); + assert!(skip.contains(&9876)); + assert!(skip.contains(&7681)); + } + + #[test] + fn a_reconfigured_service_port_is_reserved_alongside_its_default() { + let mut settings = AppSettings::default(); + settings.gateway.port = 4321; + settings.stt.port = 9000; + settings.web_terminal.port = 8443; + let project = project_with_mappings(vec![]); + let skip = skipped_ports(&project, std::slice::from_ref(&project), &settings); + + for port in [4321, 9000, 8443] { + assert!(skip.contains(&port), "configured port {} should be reserved", port); + } + // The default stays reserved too: it is what the service falls back to + // for a fresh profile or an unparseable settings file, so leaving it + // open is leaving the same squat available one restart later. + for port in [4000, 9876, 7681] { + assert!(skip.contains(&port), "default port {} should be reserved", port); + } + } + + #[test] + fn another_projects_published_host_port_is_not_stolen() { + // The container names the *host* port. Without this, project A's + // container listening on 8080 takes the host 8080 that project B + // publishes on — the bridge wins the race whenever B's container is not + // running yet. + let mine = project_with_mappings(vec![]); + let mut theirs = project_with_mappings(vec![(8080, 3000)]); + theirs.id = format!("{}-other", mine.id); + + let skip = skipped_ports( + &mine, + &[mine.clone(), theirs.clone()], + &AppSettings::default(), + ); + assert!(skip.contains(&8080), "another project's host port"); + // …but not the other project's *container* port: that number lives in a + // different network namespace and means nothing on this host, and + // reserving it would refuse a legitimate login callback for no reason. + assert!(!skip.contains(&3000)); + } + #[test] fn the_browser_views_host_ports_are_never_taken() { // The bridge binds *host* ports chosen by the container, so without // this it can take the port the browser-view proxy will want later — // that pane binds on demand, so first-come would win. - let skip = skipped_ports(&project_with_mappings(vec![])); + let skip = skip_for(&project_with_mappings(vec![])); for port in RESERVED_HOST_PORTS { assert!(skip.contains(&port), "host port {} should be reserved", port); } @@ -693,14 +854,14 @@ mod tests { // Mirroring these would publish an ungated second door to the // Playwright dashboard, which the pane deliberately keeps behind a // token-checking listener. - let skip = skipped_ports(&project_with_mappings(vec![])); + let skip = skip_for(&project_with_mappings(vec![])); for port in RESERVED_CONTAINER_PORTS { assert!(skip.contains(&port), "port {} should be reserved", port); } assert!(!skip.contains(&(RESERVED_CONTAINER_PORTS.end() + 1))); // Reservations coexist with Docker's own published ports. - let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000)])); + let skip = skip_for(&project_with_mappings(vec![(3000, 3000)])); assert!(skip.contains(RESERVED_CONTAINER_PORTS.start())); assert!(skip.contains(&3000)); } diff --git a/app/src-tauri/src/auth_bridge/tunnel.rs b/app/src-tauri/src/auth_bridge/tunnel.rs index 3882597..60c8ff3 100644 --- a/app/src-tauri/src/auth_bridge/tunnel.rs +++ b/app/src-tauri/src/auth_bridge/tunnel.rs @@ -13,8 +13,48 @@ //! The exec plumbing itself is *not* reimplemented here: it comes from //! [`crate::docker::exec::create_attached_exec`], the same helper the //! interactive terminal sessions are built on. +//! +//! ## What the host listener is, and is not +//! +//! The listener is **not authenticated**, and cannot be. The port number is +//! chosen by whatever CLI is logging in, the redirect URL is the provider's, and +//! nothing in that chain can be taught to present a token — so there is no path +//! token to add. Anything that can reach `127.0.0.1:` on this host reaches +//! the container-side listener. That includes **any web page the user has open**, +//! which can port-scan loopback from script. +//! +//! Two things narrow that, and neither is a substitute for the other: +//! +//! * The whole feature is opt-in per project, off by default, and only mirrors +//! ports while its container is running. +//! * [`web_request_verdict`] refuses the one case that is unambiguously a web +//! page reaching in: a request whose fetch metadata says it is a cross-site +//! **sub-resource** (`fetch`, `XMLHttpRequest`, ``, ` - - + + + + +