Merge branch 'r4/narrow' into ship/core

This commit is contained in:
2026-08-23 17:08:04 -07:00
19 changed files with 761 additions and 2928 deletions
+28 -8
View File
@@ -79,14 +79,34 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- **`components/projects/home/`** — **Project Home**, the main-area view for a project: - **`components/projects/home/`** — **Project Home**, the main-area view for a project:
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
modals — see "UI conventions" below. modals — see "UI conventions" below.
- **Files takes drops *in*, and that path does not use HTML5 drag.** Dropping into the pane - **Files is container-side only. It does no host filesystem I/O, and must not grow any.**
is Tauri's native `onDragDropEvent`, which is window-wide and therefore routed by a The tab lists, opens (text and image viewer), renames and creates folders *inside* the
hit-test of the physical-pixel payload position against the pane's rect ÷ container: `list_container_files`, `read_container_file`, `rename_container_path`,
`devicePixelRatio` — a hidden pane has a zero-size rect, which is what stops it and `create_container_directory`. There is no upload button, no "Save to host…", and no
`TerminalView`'s listener both firing. Keep `lib/dropTarget.ts` and both listeners. drop-into-the-pane. Four successive audits found that host filesystem paths crossing IPC
- **Getting a file *out* is "Save to host…", and there is no other route.** OS drag-out — were where the criticals lived — a caller-named host destination for container-controlled
`tauri-plugin-drag`, `stage_container_file_for_drag` and its host staging directory — was bytes, an arbitrary host source read into the container, a `link(2)` upload reservation
removed from the ship branch and held back for separate hardening; it lives on that succeeded against a directory and failed forever on any filesystem without hard
links — so the feature was narrowed rather than fixed a fifth time. If a host path ever
needs to reach this pane again, the honest shape is for the *backend* to drive
`tauri-plugin-dialog`, so no host path arrives over IPC at all.
- **A file gets *in* by being dropped on the Terminal, and *out* through "Back up
container".** Those two are the whole host↔container story, they predate the Files work,
and their hardening is not to be weakened. `TerminalView`'s `onDragDropEvent` is Tauri's
native drop event (window-wide, so routed by `lib/dropTarget.ts` — geometry for *whose*
drop it is, a document-wide `dropIsBlocked` for whether the app should accept one at all;
keep both halves and keep `PaneVisibility`). Backup is
`file_commands::download_container_backup`.
- **`resolve_host_path` applies the full lexical predicate twice — as written, and again
after canonicalisation.** That includes the general hidden-component rule, which
deliberately over-catches: a path resolving through `node_modules/.pnpm`, `~/.cache` or
`~/.local/share` is refused. Do not narrow it back to a list of "credential" directories.
That was tried, and allow-by-omission let `~/.local/bin` (write there and you own the
user's next shell command), `~/.password-store`, browser profiles and `~/.pki/nssdb`
through a planted symlink with a perfectly visible name. The two callers left are
occasional, so over-refusing is the cheaper mistake.
- **OS drag-out is not here.** `tauri-plugin-drag`, `stage_container_file_for_drag` and its
host staging directory were held back for separate hardening and live on
`hold/disk-and-dragout`. Do not re-add `drag:allow-start-drag` or a staging command `hold/disk-and-dragout`. Do not re-add `drag:allow-start-drag` or a staging command
without taking that work back whole: the plugin has no scope mechanism, so the grant lets without taking that work back whole: the plugin has no scope mechanism, so the grant lets
a compromised webview start a drag on *any* host path the user can read, and the staging a compromised webview start a drag on *any* host path the user can read, and the staging
+36 -9
View File
@@ -228,7 +228,7 @@ buttons. Below that are six tabs:
| **Sessions** | Past Claude Code conversations stored on this project's config volume, each with a **Resume** button | | **Sessions** | Past Claude Code conversations stored on this project's config volume, each with a **Resume** button |
| **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) | | **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) |
| **Config** | All per-project configuration — see [Project Configuration](#project-configuration) | | **Config** | All per-project configuration — see [Project Configuration](#project-configuration) |
| **Files** | Browse, download and upload files inside the container | | **Files** | Browse, view and rename files inside the container — see [Files](#files) for how files get in and out |
| **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) | | **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) |
### Sessions ### Sessions
@@ -351,7 +351,7 @@ it. The sidebar row carries only the two hover controls.
| **Force stop** | Project Home header | Starting / Stopping | Interrupts a transition that is stuck | | **Force stop** | Project Home header | Starting / Stopping | Interrupts a transition that is stuck |
| **Open Claude Terminal** | Project Home header; sidebar hover control; `Ctrl+T` | Running | Opens a new Claude Code terminal tab | | **Open Claude Terminal** | Project Home header; sidebar hover control; `Ctrl+T` | Running | Opens a new Claude Code terminal tab |
| **Shell** | Project Home header | Running | Opens a bash login shell tab in the container (no Claude Code) | | **Shell** | Project Home header | Running | Opens a bash login shell tab in the container (no Claude Code) |
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, download and upload files | | **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, view and rename files inside the container |
| **Config** | The **Config** tab | Always | Per-project configuration (most fields need the container stopped) | | **Config** | The **Config** tab | Always | Per-project configuration (most fields need the container stopped) |
| **Back up container** | **⋯** overflow menu | A container exists | Saves a `.tar.gz` archive of the container to a location you choose | | **Back up container** | **⋯** overflow menu | A container exists | Saves a `.tar.gz` archive of the container to a location you choose |
| **Reset container…** | **⋯** overflow menu | Stopped or Error | Destroys the container, snapshot image and both volumes, then recreates from the base image (wipes `~/.claude`) — asks first | | **Reset container…** | **⋯** overflow menu | Stopped or Error | Destroys the container, snapshot image and both volumes, then recreates from the base image (wipes `~/.claude`) — asks first |
@@ -1161,16 +1161,43 @@ When you scroll up in the terminal to review previous output, a **Jump to Curren
### Files ### Files
The **Files** tab of Project Home browses inside a running container. You can: The **Files** tab of Project Home browses inside a running container. It works entirely on the
container side — it never reads or writes anything on your own machine. You can:
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation - **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation.
- **Save to host…** — copy any file out to a location you pick. This is the way to get a file out Double-click a folder to open it, or the `..` row to go up; the arrow keys, Home and End move
of a container; there is one button per file entry, and the file viewer offers it too between rows and Enter opens the selected one
- **Upload file** from your host into the current container directory — or **drop files straight - **View** a file — double-click it, or press Enter. Text files and images render in a read-only
onto the pane** from your desktop, which uploads them into the directory on screen viewer
- **Rename** an entry, from the row's Rename button or by pressing `F2`. A rename never moves a
file between folders
- **New folder** in the directory on screen
- **Refresh** the directory listing at any time - **Refresh** the directory listing at any time
The listing shows file names, sizes, and modification dates. The listing shows file names, sizes, and modification dates, and marks symbolic links.
#### Getting files in and out
The Files tab deliberately does **not** copy files between your computer and the container. There
are two supported routes, and they are the ones to use:
- **To get a file in:** drag it from your desktop and **drop it onto the Terminal tab**. The file
is copied into the container and its path is typed into the terminal for you, ready to hand to
Claude Code. (You can also drop it onto the terminal's *Following* toggle — the whole pane is a
drop target.)
- **To get files out:** use **Back up container** in Project Home's **⋯** overflow menu. It writes
a `.tar.gz` of the workspace and the container's `~/.claude` config to a location you choose.
For a single file, `cat` it in a terminal, or work in a project folder that is mounted from your
host in the first place — those files are already on both sides.
Both routes refuse a destination whose path passes through a hidden folder — anything with a
component beginning with `.`, such as `~/.ssh`, `~/.local/bin` or `~/.config`. That rule catches
more than it strictly needs to (a path that happens to resolve through `~/.cache` or
`node_modules/.pnpm` is refused as well), and the refusal says which folder tripped it. Choose a
visible location such as `~/Documents` or `~/Downloads`.
If you already keep the project in a folder mounted into the container, the simplest answer is
usually neither of the above: edit the file on your host and it is already inside.
### Terminal Rendering ### Terminal Rendering
+5 -5
View File
@@ -105,7 +105,7 @@ configuration. Per-project configuration lives in the Config tab rather than in
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** | | **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
| **Automation** | The container's `triple-c-scheduler` tasks — create, edit, enable/disable, run now, read logs, remove, and completion notifications | | **Automation** | The container's `triple-c-scheduler` tasks — create, edit, enable/disable, run now, read logs, remove, and completion notifications |
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) | | **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
| **Files** | Browse, download and upload files inside the container | | **Files** | Browse, view and rename files inside the container. Container-side only: to get a file *in*, drop it on the Terminal tab; to get files *out*, use **Back up container** |
| **Browser** | Watch and take over the Playwright browser inside the container — see [Browser View](#browser-view) | | **Browser** | Watch and take over the Playwright browser inside the container — see [Browser View](#browser-view) |
Container start/stop progress is reported inline (on the sidebar row and in the Project Home Container start/stop progress is reported inline (on the sidebar row and in the Project Home
@@ -513,7 +513,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: create, toggle, run now, logs, remove, notifications | | `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: create, toggle, run now, logs, remove, notifications |
| `app/src/components/projects/home/TaskEditorModal.tsx` | Create/edit a scheduled task; `taskValidation.ts` holds the cron and schedule rules | | `app/src/components/projects/home/TaskEditorModal.tsx` | Create/edit a scheduled task; `taskValidation.ts` holds the cron and schedule rules |
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) | | `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
| `app/src/components/projects/home/FilesTab.tsx` | File browser (browse, download, upload) | | `app/src/components/projects/home/FilesTab.tsx` | Container-side file browser (navigate, view, rename, new folder) |
| `app/src/components/projects/home/BrowserTab.tsx` | Browser view pane: detect, install, watch, take over, pop out | | `app/src/components/projects/home/BrowserTab.tsx` | Browser view pane: detect, install, watch, take over, pop out |
| `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport | | `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport |
| `app/src/components/projects/home/ContainerMigrationBanner.tsx` | Base-image staleness banner, migration progress, resume/rollback | | `app/src/components/projects/home/ContainerMigrationBanner.tsx` | Base-image staleness banner, migration progress, resume/rollback |
@@ -536,7 +536,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) | | `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers | | `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
| `app/src/hooks/useContainerMigration.ts` | Staleness polling, migration run, resume and rollback | | `app/src/hooks/useContainerMigration.ts` | Staleness polling, migration run, resume and rollback |
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) | | `app/src/hooks/useFileManager.ts` | File browser operations (list, navigate, rename, mkdir) |
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition | | `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management | | `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting | | `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
@@ -547,7 +547,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| File | Purpose | | File | Purpose |
|---|---| |---|---|
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` | | `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` |
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; file upload/download via tar | | `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; one-shot execs and single-file tar building |
| `app/src-tauri/src/docker/image.rs` | Image building/pulling | | `app/src-tauri/src/docker/image.rs` | Image building/pulling |
| `app/src-tauri/src/docker/migration.rs` | Base-image migration: manifest capture, delta computation, crash-recovery state machine | | `app/src-tauri/src/docker/migration.rs` | Base-image migration: manifest capture, delta computation, crash-recovery state machine |
| `app/src-tauri/src/docker/ca_certs.rs` | CA certificate discovery, `.crt` renaming, fingerprinting | | `app/src-tauri/src/docker/ca_certs.rs` | CA certificate discovery, `.crt` renaming, fingerprinting |
@@ -561,7 +561,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks | | `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage | | `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands | | `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) | | `app/src-tauri/src/commands/file_commands.rs` | Container-side file commands (list, read, rename, mkdir) plus `download_container_backup` |
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands | | `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands | | `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, browser view, CA path, shared-token opt-out) | | `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, browser view, CA path, shared-token opt-out) |
File diff suppressed because it is too large Load Diff
+14 -16
View File
@@ -197,18 +197,17 @@ pub async fn upload_host_file_to_terminal(
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<String, String> { ) -> Result<String, String> {
// The drop target is a host path chosen by the webview, not by the OS drag // The drop target is a host path chosen by the webview, not by the OS drag
// itself, so it gets the same host-read policy as the Files pane's upload: // itself, so it goes through `file_commands`' host-read policy: absolute,
// absolute, no traversal, and nothing out of a hidden directory // no traversal, and nothing whose path passes through a hidden directory
// (`~/.ssh`, `~/.aws`) or a system location — applied to the path with its // (`~/.ssh`, `~/.aws`, `~/.local/bin`) or a system location — applied to
// symlinks already resolved, so a visible directory that *leads* to `~/.ssh` // the path with its symlinks already resolved, so a visible directory that
// is refused too. What comes back is that resolved path, and it is what // *leads* to one of those is refused too. What comes back is that resolved
// gets opened. // path, and it is what gets opened. This is now one of only two commands
// that touch a host path at all; the other is `download_container_backup`.
// The name is taken from the path the user actually dropped, *before* // The name is taken from the path the user actually dropped, *before*
// resolution. Deriving it from the resolved path renames the file behind // resolution. Deriving it from the resolved path renames the file behind
// the user's back: dropping `~/Downloads/latest.log`, where `latest.log` is // the user's back: dropping `~/Downloads/latest.log`, where `latest.log` is
// a symlink, would land it in the container as `2026-08-23.log`. The Files // a symlink, would land it in the container as `2026-08-23.log`.
// pane's upload had the same bug and fixes it the same way — one helper, so
// the two drop targets cannot drift.
let base = crate::commands::file_commands::host_upload_name(&host_path)?; let base = crate::commands::file_commands::host_upload_name(&host_path)?;
let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?; let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?;
@@ -229,7 +228,7 @@ pub async fn upload_host_file_to_terminal(
use crate::docker::exec::MAX_DROP_BYTES; use crate::docker::exec::MAX_DROP_BYTES;
if meta.len() > MAX_DROP_BYTES { if meta.len() > MAX_DROP_BYTES {
return Err(format!( return Err(format!(
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project or use the Files panel instead.", "File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project instead.",
meta.len() as f64 / (1024.0 * 1024.0), meta.len() as f64 / (1024.0 * 1024.0),
MAX_DROP_BYTES / (1024 * 1024) MAX_DROP_BYTES / (1024 * 1024)
)); ));
@@ -301,19 +300,18 @@ pub async fn stop_audio_bridge(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
/// Both drop targets must name a dropped file the way the *user* named it. /// A dropped file must be named the way the *user* named it.
/// ///
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry /// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
/// name from the path *after* symlink resolution, so dropping /// name from the path *after* symlink resolution, so dropping
/// `~/Downloads/latest.log` — where `latest.log` is a symlink to /// `~/Downloads/latest.log` — where `latest.log` is a symlink to
/// `2026-08-23.log` — silently landed the file in the container under the /// `2026-08-23.log` — silently landed the file in the container under the
/// target's name. Nothing errored; the user just got a name they never /// target's name. Nothing errored; the user just got a name they never
/// typed. The Files pane had the identical bug. /// typed.
/// ///
/// What actually keeps the two from drifting is that they now call one /// This asserts the shared helper's contract from the terminal side: the
/// helper, so this asserts that helper's contract from the terminal side: /// answer comes from the spelling, and a path that does not name a file is
/// the answer comes from the spelling, and a path that does not name a file /// refused rather than silently substituted (it used to fall back to
/// is refused rather than silently substituted (it used to fall back to
/// `"dropped-file"`). /// `"dropped-file"`).
#[test] #[test]
fn a_dropped_file_keeps_the_name_the_user_dropped() { fn a_dropped_file_keeps_the_name_the_user_dropped() {
+2 -40
View File
@@ -351,8 +351,8 @@ pub async fn upload_host_file_to_container(
// The caller resolved this path (`resolve_host_read_path`); opening it // The caller resolved this path (`resolve_host_read_path`); opening it
// is a second trip through the same directories, so the descriptor is // is a second trip through the same directories, so the descriptor is
// checked against the path that was validated before its bytes are // checked against the path that was validated before its bytes are
// packed into anything. Same policy as the Files pane's upload — this // packed into anything. This is the terminal's drop target, and it is
// is the terminal's drop target, and the two must not differ. // the only path by which host bytes enter a container.
let file = std::fs::File::open(&host_path) let file = std::fs::File::open(&host_path)
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?; .map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
crate::commands::file_commands::verify_opened_path( crate::commands::file_commands::verify_opened_path(
@@ -601,44 +601,6 @@ pub async fn exec_oneshot_as(
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await
} }
/// [`exec_oneshot_as`] with a wall-clock ceiling on the whole call.
///
/// H8. Nothing in this module bounds how long a container command may take,
/// which is right for the callers that need it — a base-image migration replays
/// `apt-get` and takes minutes — and wrong for a short command that can be made
/// to block forever by a *file* the caller does not control. The upload
/// reservation is the one that bit: a shell redirect onto a FIFO blocks in
/// `open(2)` until a reader appears, so a single `mkfifo` in a project
/// directory left the Files pane on "Uploading…" for the rest of the session
/// with the rest of the batch abandoned.
///
/// So the ceiling is opt-in per call site rather than global. Note what it can
/// and cannot do: dropping the future closes our end of the stream, but Docker
/// has no "kill an exec" API, so a process that is genuinely wedged stays
/// wedged in the container's process table. That is why the primitive matters
/// more than the timeout — this turns "the app never comes back" into "that
/// upload failed", and it is the caller's job not to run something that blocks.
pub async fn exec_oneshot_as_within(
container_id: &str,
user: &str,
cmd: Vec<String>,
env: Vec<String>,
limit: std::time::Duration,
) -> Result<(String, i64), String> {
match tokio::time::timeout(
limit,
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT),
)
.await
{
Ok(result) => result,
Err(_) => Err(format!(
"The container did not answer within {}s — the command may still be running inside it.",
limit.as_secs()
)),
}
}
/// What a one-shot exec printed, with the two streams still tellable apart. /// What a one-shot exec printed, with the two streams still tellable apart.
/// ///
/// `combined` is stdout and stderr interleaved in arrival order — the shape /// `combined` is stdout and stderr interleaved in arrival order — the shape
-2
View File
@@ -497,9 +497,7 @@ pub fn run() {
commands::terminal_commands::stop_audio_bridge, commands::terminal_commands::stop_audio_bridge,
// Files // Files
commands::file_commands::list_container_files, commands::file_commands::list_container_files,
commands::file_commands::download_container_file,
commands::file_commands::download_container_backup, commands::file_commands::download_container_backup,
commands::file_commands::upload_file_to_container,
commands::file_commands::read_container_file, commands::file_commands::read_container_file,
commands::file_commands::rename_container_path, commands::file_commands::rename_container_path,
commands::file_commands::create_container_directory, commands::file_commands::create_container_directory,
@@ -16,14 +16,12 @@ interface Props {
projectId: string; projectId: string;
entry: FileEntry; entry: FileEntry;
onClose: () => void; onClose: () => void;
/** "Save to host…" — the way out for anything the viewer can't render. */
onSaveToHost: (entry: FileEntry) => void;
} }
type Preview = type Preview =
| { kind: "loading" } | { kind: "loading" }
| { kind: "error"; message: string } | { kind: "error"; message: string }
/** Too big to render whole — offered as a download rather than a half-file. */ /** Too big to render whole — said so rather than shown as a half-file. */
| { kind: "too-large" } | { kind: "too-large" }
| { kind: "text"; text: string; truncated: boolean; shownBytes: number; trueSize: number } | { kind: "text"; text: string; truncated: boolean; shownBytes: number; trueSize: number }
| { kind: "image"; url: string } | { kind: "image"; url: string }
@@ -37,7 +35,7 @@ type Preview =
* keeps a multi-megabyte base64 string out of the DOM. `blob:` is in the app's * 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. * `img-src` for exactly this; the asset protocol deliberately is not enabled.
*/ */
export default function FileViewerModal({ projectId, entry, onClose, onSaveToHost }: Props) { export default function FileViewerModal({ projectId, entry, onClose }: Props) {
const [preview, setPreview] = useState<Preview>({ kind: "loading" }); const [preview, setPreview] = useState<Preview>({ kind: "loading" });
/** /**
@@ -121,19 +119,9 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
); );
const footer = ( const footer = (
<> <Button size="md" variant="primary" onClick={onClose}>
<Button Close
size="md" </Button>
onClick={() => {
onSaveToHost(entry);
}}
>
Save to host
</Button>
<Button size="md" variant="primary" onClick={onClose}>
Close
</Button>
</>
); );
return ( return (
@@ -156,14 +144,15 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
{preview.kind === "too-large" && ( {preview.kind === "too-large" && (
<p className="text-[13px] text-[var(--text-secondary)]"> <p className="text-[13px] text-[var(--text-secondary)]">
This file is {formatBytes(entry.size)} too large to preview in the app. Save it This file is {formatBytes(entry.size)} too large to preview in the app. Open it
to the host to open it there. from a terminal in the container, or take a backup and open it on the host.
</p> </p>
)} )}
{preview.kind === "unsupported" && ( {preview.kind === "unsupported" && (
<p className="text-[13px] text-[var(--text-secondary)]"> <p className="text-[13px] text-[var(--text-secondary)]">
There is no preview for this file type. Save it to the host to open it there. There is no preview for this file type. Open it from a terminal in the container,
or take a backup and open it on the host.
</p> </p>
)} )}
@@ -4,16 +4,12 @@ import FilesTab from "./FilesTab";
import type { FileContents, FileEntry, Project } from "../../../lib/types"; import type { FileContents, FileEntry, Project } from "../../../lib/types";
const listContainerFiles = vi.fn(); const listContainerFiles = vi.fn();
const downloadContainerFile = vi.fn(async () => {});
const uploadFileToContainer = vi.fn(async () => {});
const renameContainerPath = vi.fn(async () => ""); const renameContainerPath = vi.fn(async () => "");
const createContainerDirectory = vi.fn(async () => ""); const createContainerDirectory = vi.fn(async () => "");
const readContainerFile = vi.fn(); const readContainerFile = vi.fn();
vi.mock("../../../lib/tauri-commands", () => ({ vi.mock("../../../lib/tauri-commands", () => ({
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path), listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
uploadFileToContainer: (...args: unknown[]) => uploadFileToContainer(...args),
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t), renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
createContainerDirectory: (p: string, parent: string, n: string) => createContainerDirectory: (p: string, parent: string, n: string) =>
createContainerDirectory(p, parent, n), createContainerDirectory(p, parent, n),
@@ -31,29 +27,6 @@ const toastText = () =>
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`) .map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
.join("\n"); .join("\n");
const save = vi.fn(async () => "/host/out");
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: (o: unknown) => save(o),
open: vi.fn(async () => null),
}));
/** The webview's window-wide native drag-drop listener, captured for driving. */
type DragPayload =
| { type: "enter" | "over"; position: { x: number; y: number }; paths: string[] }
| { type: "leave" }
| { type: "drop"; position: { x: number; y: number }; paths: string[] };
let dragHandler: ((e: { payload: DragPayload }) => void | Promise<void>) | null = null;
const unlistenDrag = vi.fn();
vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: () => ({
onDragDropEvent: async (cb: (e: { payload: DragPayload }) => void) => {
dragHandler = cb;
return unlistenDrag;
},
}),
}));
const project = { id: "p1", name: "api", status: "running" } as unknown as Project; const project = { id: "p1", name: "api", status: "running" } as unknown as Project;
const entry = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({ const entry = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
@@ -82,39 +55,17 @@ async function renderTab() {
return view; 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 } });
});
}
/** Every row that is part of the grid's roving tabindex, in order. */ /** Every row that is part of the grid's roving tabindex, in order. */
const gridRows = () => Array.from(document.querySelectorAll("tr[data-file-row]")); const gridRows = () => Array.from(document.querySelectorAll("tr[data-file-row]"));
/** The rows that are actually tab stops. There must never be more than one. */ /** The rows that are actually tab stops. There must never be more than one. */
const tabStops = () => gridRows().filter((r) => r.getAttribute("tabindex") === "0"); const tabStops = () => gridRows().filter((r) => r.getAttribute("tabindex") === "0");
/** Fire a drop without awaiting it — for the paths that stop to ask a question. */
function dropWithoutWaiting(paths: string[], position = { x: 100, y: 100 }) {
let pending: unknown;
act(() => {
pending = dragHandler?.({ payload: { type: "drop", position, paths } });
});
return pending as Promise<void> | undefined;
}
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
dragHandler = null;
listContainerFiles.mockResolvedValue([ listContainerFiles.mockResolvedValue([
entry("src", { is_directory: true, path: "/workspace/src" }), entry("src", { is_directory: true, path: "/workspace/src" }),
entry("notes.txt"), 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. // Not implemented in jsdom; the image preview needs both halves.
URL.createObjectURL = vi.fn(() => "blob:mock-url"); URL.createObjectURL = vi.fn(() => "blob:mock-url");
URL.revokeObjectURL = vi.fn(); URL.revokeObjectURL = vi.fn();
@@ -224,7 +175,9 @@ describe("FilesTab viewer", () => {
}); });
expect(await screen.findByText(/too large to preview/)).toBeTruthy(); expect(await screen.findByText(/too large to preview/)).toBeTruthy();
expect(screen.queryByAltText("huge.png")).toBeNull(); expect(screen.queryByAltText("huge.png")).toBeNull();
expect(screen.getByRole("button", { name: "Save to host…" })).toBeTruthy(); // The way out is named, and it is not a host path this pane could write:
// a terminal inside the container, or a backup.
expect(screen.getByText(/take a backup/)).toBeTruthy();
}); });
it("says so in words when only a prefix of a big text file came back", async () => { it("says so in words when only a prefix of a big text file came back", async () => {
@@ -239,7 +192,7 @@ describe("FilesTab viewer", () => {
expect(screen.getByText("first megabyte")).toBeTruthy(); expect(screen.getByText("first megabyte")).toBeTruthy();
}); });
it("offers Save to host for a file it cannot render", async () => { it("says there is no preview, and where to open the file instead", async () => {
listContainerFiles.mockResolvedValue([entry("blob.bin")]); listContainerFiles.mockResolvedValue([entry("blob.bin")]);
readContainerFile.mockResolvedValue(contents("a\x00b")); readContainerFile.mockResolvedValue(contents("a\x00b"));
await renderTab(); await renderTab();
@@ -314,191 +267,6 @@ describe("FilesTab new folder", () => {
}); });
}); });
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 on Windows only", async () => {
// Only wry's WebView2 backend hands over *physical* pixels; the macOS and
// GTK ones deliver logical points and `tauri-runtime-wry` does not rescale
// them. At dpr 2 a physical (900, 900) is a CSS (450, 450) — inside the
// 800x600 pane — but the same payload on a HiDPI Mac or Linux box really
// is (900, 900) and belongs to nobody.
const originalDpr = window.devicePixelRatio;
const originalUa = window.navigator.userAgent;
Object.defineProperty(window, "devicePixelRatio", { value: 2, configurable: true });
Object.defineProperty(window.navigator, "userAgent", {
value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
configurable: true,
});
await renderTab();
await drop(["/host/a.png"], { x: 900, y: 900 });
expect(uploadFileToContainer).toHaveBeenCalled();
Object.defineProperty(window.navigator, "userAgent", {
value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
configurable: true,
});
vi.mocked(uploadFileToContainer).mockClear();
await drop(["/host/a.png"], { x: 900, y: 900 });
expect(uploadFileToContainer).not.toHaveBeenCalled();
// …and the *unhalved* point still lands, which is the half a HiDPI Mac
// user was losing.
await drop(["/host/a.png"], { x: 400, y: 300 });
expect(uploadFileToContainer).toHaveBeenCalled();
Object.defineProperty(window, "devicePixelRatio", {
value: originalDpr,
configurable: true,
});
Object.defineProperty(window.navigator, "userAgent", {
value: originalUa,
configurable: true,
});
});
it("accepts a drop that lands on a toast floating over the pane", async () => {
// Round 1. `ToastHost` is `fixed bottom-4 right-4 z-[60]` and 24rem wide,
// and its error cards stay until dismissed — so a z-order gate asking "is
// what is painted here part of my pane?" made the bottom-right corner of
// this pane refuse drops for as long as one error was on screen. jsdom has
// no `elementFromPoint`, so that branch only ran when a test supplied one;
// the gate no longer asks, and this pins that nothing painted over a pane
// can refuse a drop on its own account.
await renderTab();
const toastCard = document.createElement("div");
document.body.appendChild(toastCard);
Object.defineProperty(document, "elementFromPoint", {
configurable: true,
writable: true,
value: () => toastCard,
});
await drop(["/host/a.png"], { x: 700, y: 550 });
expect(uploadFileToContainer).toHaveBeenCalled();
delete (document as Partial<Document>).elementFromPoint;
toastCard.remove();
});
it("refuses a drop while a dialog is open, toast painted over it or not", async () => {
// Round 2, which is the reason this file exists in its current shape. The
// refusal pushes a toast; `ToastHost` is `z-[60]` and the `Modal` backdrop
// is `z-50` in the same stacking context, so the *toast* becomes the
// topmost element over a covered pane. A gate that asked `elementFromPoint`
// "is a blocker painted here?" then answered no and uploaded into the
// directory the dialog was covering — one refused drop was all it took to
// open the hole. Both stubs below therefore have to be refused.
await renderTab();
const backdrop = document.createElement("div");
backdrop.setAttribute("data-blocks-drop", "true");
document.body.appendChild(backdrop);
const toastCard = document.createElement("div"); // z-[60], above the backdrop
document.body.appendChild(toastCard);
const stub = (top: Element) =>
Object.defineProperty(document, "elementFromPoint", {
configurable: true,
writable: true,
value: () => top,
});
stub(backdrop);
await drop(["/host/a.png"], { x: 400, y: 300 });
expect(uploadFileToContainer).not.toHaveBeenCalled();
stub(toastCard);
await drop(["/host/a.png"], { x: 700, y: 550 });
expect(uploadFileToContainer).not.toHaveBeenCalled();
delete (document as Partial<Document>).elementFromPoint;
toastCard.remove();
backdrop.remove();
});
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 to host… — notes.txt" }));
});
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 to host… — src" })).toBeNull();
});
});
describe("FilesTab drop hit test", () => {
it("uploads nothing when a dialog is covering the pane", async () => {
// The pane still has its rect underneath the viewer's `fixed inset-0`
// portal, which is exactly why a rect alone was the wrong test.
readContainerFile.mockResolvedValue(contents("hello"));
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("notes.txt"));
});
await screen.findByRole("dialog");
await drop(["/host/a.png"]);
expect(uploadFileToContainer).not.toHaveBeenCalled();
});
it("does not paint the hint under a dialog either", async () => {
readContainerFile.mockResolvedValue(contents("hello"));
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("notes.txt"));
});
await screen.findByRole("dialog");
await act(async () => {
await dragHandler?.({ payload: { type: "over", position: { x: 100, y: 100 }, paths: [] } });
});
expect(screen.queryByText(/Drop files into/)).toBeNull();
});
});
describe("FilesTab grid focus", () => { describe("FilesTab grid focus", () => {
it("gives the grid exactly one tab stop and moves it with the arrows", async () => { it("gives the grid exactly one tab stop and moves it with the arrows", async () => {
// Every row used to be `tabIndex={0}`: a 400-entry directory was ~1200 tab // Every row used to be `tabIndex={0}`: a 400-entry directory was ~1200 tab
@@ -592,22 +360,28 @@ describe("FilesTab grid semantics", () => {
await renderTab(); await renderTab();
const rename = screen.getByRole("button", { name: "Rename — notes.txt" }); const rename = screen.getByRole("button", { name: "Rename — notes.txt" });
expect(rename.textContent).toBe("Rename"); expect(rename.textContent).toBe("Rename");
expect(rename.getAttribute("aria-label")).toContain("Rename"); expect(rename.getAttribute("aria-label")).toContain(rename.textContent!);
const saveTo = screen.getByRole("button", { name: "Save to host… — notes.txt" });
expect(saveTo.getAttribute("aria-label")).toContain(saveTo.textContent!);
}); });
it("mounts the live region empty, then fills it", async () => { it("mounts the live region empty, then fills it", async () => {
// A `role="status"` node inserted already carrying its text is frequently // A `role="status"` node inserted already carrying its text is frequently
// not announced at all, which is how every one of these went by in silence. // not announced at all, which is how every one of these went by in silence.
createContainerDirectory.mockResolvedValue("/workspace/new");
await renderTab(); await renderTab();
const live = screen.getByRole("status"); const live = screen.getByRole("status");
expect(live.textContent).toBe(""); expect(live.textContent).toBe("");
await drop(["/host/a.png"]); await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "New folder" }));
});
const input = screen.getByLabelText("New folder name");
fireEvent.change(input, { target: { value: "new" } });
await act(async () => {
fireEvent.blur(input);
});
// Same node throughout — it is never unmounted. // Same node throughout — it is never unmounted.
expect(screen.getByRole("status")).toBe(live); expect(screen.getByRole("status")).toBe(live);
expect(live.textContent).toContain("Uploaded 1 item"); expect(live.textContent).toContain('Created "new"');
}); });
it("keeps a listing failure inline, where the rows it explains are missing", async () => { it("keeps a listing failure inline, where the rows it explains are missing", async () => {
@@ -618,130 +392,3 @@ describe("FilesTab grid semantics", () => {
expect(screen.getByRole("alert").textContent).toContain("Permission denied"); expect(screen.getByRole("alert").textContent).toContain("Permission denied");
}); });
}); });
describe("FilesTab overwrite prompt", () => {
it("asks before replacing, and re-uploads with overwrite on Replace", async () => {
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/notes.txt already exists");
await renderTab();
const pending = dropWithoutWaiting(["/host/notes.txt"]);
const dialog = await screen.findByRole("dialog");
expect(dialog.textContent).toContain("notes.txt");
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
await pending;
});
expect(uploadFileToContainer).toHaveBeenLastCalledWith(
"p1",
"/host/notes.txt",
"/workspace",
true,
);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("uploads nothing more on Skip", async () => {
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/notes.txt already exists");
await renderTab();
const pending = dropWithoutWaiting(["/host/notes.txt"]);
await screen.findByRole("dialog");
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Skip" }));
await pending;
});
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("offers the blanket answers only when files are queued behind this one", async () => {
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/a.txt already exists");
await renderTab();
const pending = dropWithoutWaiting(["/host/a.txt", "/host/b.txt"]);
await screen.findByRole("dialog");
expect(screen.getByRole("button", { name: "Replace all" })).toBeTruthy();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Skip all" }));
await pending;
});
expect(screen.queryByRole("dialog")).toBeNull();
});
});
/**
* Dismissal. `Modal` gives every dialog Escape, a ✕ and click-outside for free,
* and `OverwriteConfirmModal` maps all three onto `onChoose("skip")` — because
* the destructive answer has to be chosen, and because a dialog that is closed
* rather than answered must not leave the batch waiting forever or throw away
* the files behind it.
*/
describe("FilesTab overwrite prompt dismissal", () => {
/**
* Drop two files where the first name is taken, and stop at the dialog. The
* unsettled batch comes back wrapped — returning it bare from an `async`
* helper would adopt it, and awaiting the helper would then wait for an
* upload that cannot proceed until the helper has returned.
*/
async function dropIntoConflict(): Promise<{ batch: Promise<void> | undefined }> {
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/a.txt already exists");
await renderTab();
const batch = dropWithoutWaiting(["/host/a.txt", "/host/b.txt"]);
await screen.findByRole("dialog");
return { batch };
}
/** What every dismissal has to leave behind: one skip, one upload, no clobber. */
function expectSkippedAndCarriedOn() {
expect(screen.queryByRole("dialog")).toBeNull();
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
expect(uploadFileToContainer).toHaveBeenLastCalledWith("p1", "/host/b.txt", "/workspace");
expect(uploadFileToContainer.mock.calls.some((call) => call[3] === true)).toBe(false);
expect(screen.getByRole("status").textContent).toContain("skipped 1");
}
it("counts Escape as a Skip", async () => {
const { batch } = await dropIntoConflict();
await act(async () => {
fireEvent.keyDown(document, { key: "Escape" });
await batch;
});
expectSkippedAndCarriedOn();
});
it("counts the ✕ as a Skip", async () => {
const { batch } = await dropIntoConflict();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Close dialog" }));
await batch;
});
expectSkippedAndCarriedOn();
});
it("counts a click on the backdrop as a Skip", async () => {
const { batch } = await dropIntoConflict();
// The overlay is the dialog panel's parent — `Modal` only closes when the
// click landed on the overlay itself, not on anything inside the panel.
const overlay = screen.getByRole("dialog").parentElement!;
await act(async () => {
fireEvent.click(overlay);
await batch;
});
expectSkippedAndCarriedOn();
});
it("does not dismiss on a click inside the dialog", async () => {
const { batch } = await dropIntoConflict();
fireEvent.click(screen.getByRole("dialog"));
expect(screen.queryByRole("dialog")).not.toBeNull();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
await batch;
});
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
});
});
+15 -109
View File
@@ -1,12 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import type { FileEntry, Project } from "../../../lib/types"; import type { FileEntry, Project } from "../../../lib/types";
import { useFileManager } from "../../../hooks/useFileManager"; import { useFileManager } from "../../../hooks/useFileManager";
import { classifyDrop, isDropTarget, DROP_BLOCKED_TOAST } from "../../../lib/dropTarget";
import { useAppState } from "../../../store/appState";
import Button from "../../ui/Button"; import Button from "../../ui/Button";
import FileViewerModal from "./FileViewerModal"; import FileViewerModal from "./FileViewerModal";
import OverwriteConfirmModal from "./OverwriteConfirmModal";
import { formatBytes } from "./format"; import { formatBytes } from "./format";
interface Props { interface Props {
@@ -17,7 +13,15 @@ interface Props {
const PARENT_ROW = ".."; const PARENT_ROW = "..";
/** /**
* The project's file manager. * The project's file browser.
*
* Container-side only: it lists, opens, renames and creates folders inside the
* container, and it does no host filesystem I/O at all. A file gets *into* a
* container by being dropped onto the Terminal tab, and a whole tree comes back
* out through "Back up container" in the project's Workspace settings. Four
* successive audits found that host paths crossing IPC were where the criticals
* lived; those two paths are the ones that survived, and this pane is not one
* of them.
* *
* Interaction model, chosen to match every desktop file manager rather than * Interaction model, chosen to match every desktop file manager rather than
* the old half-and-half: **single click selects, double click opens**. That * the old half-and-half: **single click selects, double click opens**. That
@@ -42,16 +46,10 @@ export default function FilesTab({ project }: Props) {
entries, entries,
loading, loading,
error, error,
busy,
completed, completed,
conflict,
resolveConflict,
navigate, navigate,
goUp, goUp,
refresh, refresh,
downloadFile,
uploadFile,
uploadPaths,
renameEntry, renameEntry,
createFolder, createFolder,
} = useFileManager(project.id); } = useFileManager(project.id);
@@ -65,8 +63,6 @@ export default function FilesTab({ project }: Props) {
const [creatingFolder, setCreatingFolder] = useState(false); const [creatingFolder, setCreatingFolder] = useState(false);
const [folderDraft, setFolderDraft] = useState(""); const [folderDraft, setFolderDraft] = useState("");
const [viewing, setViewing] = useState<FileEntry | null>(null); const [viewing, setViewing] = useState<FileEntry | null>(null);
/** A host drag is currently over this pane. */
const [dragOver, setDragOver] = useState(false);
/** The row that owns the grid's single tab stop. */ /** The row that owns the grid's single tab stop. */
const [activeRow, setActiveRow] = useState<string | null>(null); const [activeRow, setActiveRow] = useState<string | null>(null);
@@ -234,58 +230,6 @@ export default function FilesTab({ project }: Props) {
goUp(); goUp();
}, [currentPath, goUp]); }, [currentPath, goUp]);
// 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 `classifyDrop` — the rect hit test, which says *whose* drop it
// is, plus the document-wide question a rect cannot answer: is a modal or a
// blocking overlay on screen at all? That second half is deliberately not a
// per-point z-order test; `lib/dropTarget.ts` records the two ways that went
// wrong.
useEffect(() => {
if (!running) return;
let unlisten: (() => void) | undefined;
let cancelled = false;
(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(isDropTarget(paneRef.current, payload.position));
return;
}
if (payload.type !== "drop") return;
setDragOver(false);
const verdict = classifyDrop(paneRef.current, payload.position);
// Aimed at this pane and refused anyway: say so. Nothing else would —
// the file just never appears in the listing.
if (verdict === "blocked") {
console.warn("[drop] refused: a dialog or overlay is open", payload.position);
useAppState.getState().pushToast(DROP_BLOCKED_TOAST);
return;
}
if (verdict !== "accept") 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 = const breadcrumbs =
currentPath === "/" currentPath === "/"
? [{ label: "/", path: "/" }] ? [{ label: "/", path: "/" }]
@@ -324,10 +268,10 @@ export default function FilesTab({ project }: Props) {
/** /**
* The live region's text. One region, always mounted, filled and emptied — * The live region's text. One region, always mounted, filled and emptied —
* a `role="status"` node that is *inserted* already carrying its text is * a `role="status"` node that is *inserted* already carrying its text is
* frequently not announced at all, which is how "uploading 3 items…" and * frequently not announced at all, which is how every completion notice used
* every completion notice used to go by in silence. * to go by in silence.
*/ */
const liveText = busy ? busy : (completed ?? ""); const liveText = completed ?? "";
return ( return (
<div ref={paneRef} className="relative flex flex-col h-full min-h-0"> <div ref={paneRef} className="relative flex flex-col h-full min-h-0">
@@ -361,9 +305,6 @@ export default function FilesTab({ project }: Props) {
> >
New folder New folder
</Button> </Button>
<Button onClick={uploadFile} className="ml-1">
Upload file
</Button>
<Button onClick={refresh} disabled={loading} className="ml-1"> <Button onClick={refresh} disabled={loading} className="ml-1">
Refresh Refresh
</Button> </Button>
@@ -372,9 +313,9 @@ export default function FilesTab({ project }: Props) {
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">
{/* The one failure that stays inline: it explains why the grid below is {/* 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 empty, it is in context, and there are no rows for it to scroll
behind. Every *transient* failure — upload, rename, mkdir, behind. Every *transient* failure — rename, new folder — goes to
save-to-host — goes to `ToastHost` instead, which is above `ToastHost` instead, which is above the file viewer's overlay and
the file viewer's overlay and does not scroll away. */} does not scroll away. */}
{error && ( {error && (
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]"> <div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
{error} {error}
@@ -560,18 +501,6 @@ export default function FilesTab({ project }: Props) {
> >
Rename Rename
</Button> </Button>
{!entry.is_directory && (
<Button
aria-label={`Save to host… — ${entry.name}`}
className="ml-1"
onClick={(e) => {
e.stopPropagation();
downloadFile(entry);
}}
>
Save to host
</Button>
)}
</> </>
)} )}
</td> </td>
@@ -594,34 +523,11 @@ export default function FilesTab({ project }: Props) {
)} )}
</div> </div>
{/* Drop hint. Purely decorative — the native listener is what accepts the
drop, so this must never intercept pointer events. */}
{dragOver && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-center border-2 border-dashed border-[var(--accent)] bg-[var(--bg-primary)]/70"
>
<span className="text-[13px] font-medium text-[var(--text-primary)]">
Drop files into {currentPath}
</span>
</div>
)}
{conflict && (
<OverwriteConfirmModal
name={conflict.name}
directory={conflict.directory}
remaining={conflict.remaining}
onChoose={resolveConflict}
/>
)}
{viewing && ( {viewing && (
<FileViewerModal <FileViewerModal
projectId={project.id} projectId={project.id}
entry={viewing} entry={viewing}
onClose={() => setViewing(null)} onClose={() => setViewing(null)}
onSaveToHost={downloadFile}
/> />
)} )}
</div> </div>
@@ -1,73 +0,0 @@
import type { OverwriteChoice } from "../../../lib/uploadErrors";
import Button from "../../ui/Button";
import Modal from "../../ui/Modal";
interface Props {
/** Bare name of the file that is already there. */
name: string;
/** Container directory it is going into. */
directory: string;
/** How many more files are queued behind this one. */
remaining: number;
onChoose: (choice: OverwriteChoice) => 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 && (
<>
<Button size="md" onClick={() => onChoose("skip-all")}>
Skip all
</Button>
<Button size="md" onClick={() => onChoose("replace-all")}>
Replace all
</Button>
</>
)}
<Button size="md" onClick={() => onChoose("skip")}>
Skip
</Button>
<Button size="md" variant="primary" onClick={() => onChoose("replace")}>
Replace
</Button>
</>
);
return (
<Modal
title="A file with that name is already there"
description={directory}
onClose={() => onChoose("skip")}
footer={footer}
widthClassName="w-[30rem]"
>
<p className="text-[13px] text-[var(--text-primary)]">
<span className="font-mono">{name}</span> already exists in{" "}
<span className="font-mono">{directory}</span>. Replacing it overwrites the container's
copy, and that cannot be undone from here.
</p>
{remaining > 0 && (
<p className="mt-2 text-xs text-[var(--text-secondary)]">
{remaining} more file{remaining === 1 ? "" : "s"} still to upload.
</p>
)}
</Modal>
);
}
+23 -354
View File
@@ -4,15 +4,11 @@ import { useFileManager } from "./useFileManager";
import type { FileEntry } from "../lib/types"; import type { FileEntry } from "../lib/types";
const listContainerFiles = vi.fn(); const listContainerFiles = vi.fn();
const downloadContainerFile = vi.fn();
const uploadFileToContainer = vi.fn();
const renameContainerPath = vi.fn(); const renameContainerPath = vi.fn();
const createContainerDirectory = vi.fn(); const createContainerDirectory = vi.fn();
vi.mock("../lib/tauri-commands", () => ({ vi.mock("../lib/tauri-commands", () => ({
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path), listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
uploadFileToContainer: (...args: unknown[]) => uploadFileToContainer(...args),
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t), renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
createContainerDirectory: (p: string, parent: string, n: string) => createContainerDirectory: (p: string, parent: string, n: string) =>
createContainerDirectory(p, parent, n), createContainerDirectory(p, parent, n),
@@ -35,13 +31,6 @@ const toastText = () =>
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`) .map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
.join("\n"); .join("\n");
const save = vi.fn();
const openDialog = vi.fn();
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: (opts: unknown) => save(opts),
open: (opts: unknown) => openDialog(opts),
}));
const file = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({ const file = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
name, name,
path: `/workspace/${name}`, path: `/workspace/${name}`,
@@ -100,48 +89,6 @@ describe("useFileManager navigation", () => {
}); });
}); });
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"]);
});
// 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();
});
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", () => { describe("useFileManager rename and mkdir", () => {
it("sends the bare new name, never a path, and re-lists on success", async () => { it("sends the bare new name, never a path, and re-lists on success", async () => {
renameContainerPath.mockResolvedValue("/workspace/renamed.txt"); renameContainerPath.mockResolvedValue("/workspace/renamed.txt");
@@ -204,47 +151,22 @@ describe("useFileManager rename and mkdir", () => {
}); });
}); });
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(toastText()).toContain("is a folder");
});
});
describe("useFileManager stays where the user is", () => { describe("useFileManager stays where the user is", () => {
it("does not drag the pane back when the user navigates away mid-upload", async () => { it("does not drag the pane back when the user navigates away mid-operation", async () => {
// The closure captured `/workspace`; the user is in `/workspace/src` by the // 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 // time the rename finishes. Re-listing the *captured* path is what used to
// yank them out of the directory they had walked into. // yank them out of the directory they had walked into.
let failUpload: (reason: unknown) => void = () => {}; let failRename: (reason: unknown) => void = () => {};
// `Once`, deliberately: `clearAllMocks` clears calls but not // `Once`, deliberately: `clearAllMocks` clears calls but not
// implementations, so a never-settling one would hang every test after it. // implementations, so a never-settling one would hang every test after it.
uploadFileToContainer.mockImplementationOnce( renameContainerPath.mockImplementationOnce(
() => new Promise((_resolve, reject) => { failUpload = reject; }), () => new Promise((_resolve, reject) => { failRename = reject; }),
); );
const { result } = renderHook(() => useFileManager("p1")); const { result } = renderHook(() => useFileManager("p1"));
let upload!: Promise<void>; let rename!: Promise<boolean>;
await act(async () => { await act(async () => {
upload = result.current.uploadPaths(["/host/big.bin"]); rename = result.current.renameEntry(file("big.bin"), "bigger.bin");
await Promise.resolve(); await Promise.resolve();
}); });
@@ -255,13 +177,13 @@ describe("useFileManager stays where the user is", () => {
listContainerFiles.mockClear(); listContainerFiles.mockClear();
await act(async () => { await act(async () => {
failUpload("cp: no space left on device"); failRename("mv: no space left on device");
await upload; await rename;
}); });
expect(result.current.currentPath).toBe("/workspace/src"); expect(result.current.currentPath).toBe("/workspace/src");
expect(result.current.entries.map((e) => e.name)).toEqual(["index.ts"]); expect(result.current.entries.map((e) => e.name)).toEqual(["index.ts"]);
// No re-list of the directory the upload targeted… // No re-list of the directory the rename targeted…
expect(listContainerFiles).not.toHaveBeenCalled(); expect(listContainerFiles).not.toHaveBeenCalled();
// …and no failure text painted over the listing that replaced it. // …and no failure text painted over the listing that replaced it.
expect(result.current.error).toBeNull(); expect(result.current.error).toBeNull();
@@ -269,13 +191,14 @@ describe("useFileManager stays where the user is", () => {
}); });
it("re-lists when the user stayed put, which is the ordinary case", async () => { it("re-lists when the user stayed put, which is the ordinary case", async () => {
renameContainerPath.mockResolvedValue("/workspace/b.txt");
const { result } = renderHook(() => useFileManager("p1")); const { result } = renderHook(() => useFileManager("p1"));
await act(async () => { await act(async () => {
await result.current.navigate("/workspace"); await result.current.navigate("/workspace");
}); });
listContainerFiles.mockClear(); listContainerFiles.mockClear();
await act(async () => { await act(async () => {
await result.current.uploadPaths(["/host/a.png"]); await result.current.renameEntry(file("a.txt"), "b.txt");
}); });
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace"); expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
}); });
@@ -316,248 +239,11 @@ describe("useFileManager stays where the user is", () => {
await result.current.navigate("/root"); await result.current.navigate("/root");
}); });
listContainerFiles.mockClear(); listContainerFiles.mockClear();
// The pane never left /workspace, so an upload started now targets it. // The pane never left /workspace, so a new folder made now lands there.
await act(async () => { await act(async () => {
await result.current.uploadPaths(["/host/a.png"]); await result.current.createFolder("new");
}); });
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.png", "/workspace"); expect(createContainerDirectory).toHaveBeenCalledWith("p1", "/workspace", "new");
});
});
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<void>;
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<void>;
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<void>;
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");
});
});
/**
* The loop, end to end. The prompt only earns its place if the *batch* survives
* it: one answer, given once, has to leave every other file in the drop exactly
* where it would have been.
*/
describe("useFileManager overwrite prompt closes the loop", () => {
const clash = (name: string) => `FILE_EXISTS: /workspace/${name} already exists`;
/**
* Start an upload and wait for it to stop at the prompt, handing back the
* still-unsettled batch.
*
* Wrapped in an object on purpose: an `async` function that returned the
* promise itself would *adopt* it, so awaiting the helper would wait for the
* whole upload — which cannot finish until the question is answered, which
* cannot happen until the helper returns. That deadlock looks exactly like
* the hang these tests exist to rule out.
*/
async function uploadUntilPrompt(
result: { current: ReturnType<typeof useFileManager> },
paths: string[],
): Promise<{ batch: Promise<void> }> {
let batch!: Promise<void>;
await act(async () => {
batch = result.current.uploadPaths(paths);
await Promise.resolve();
});
await waitFor(() => expect(result.current.conflict).not.toBeNull());
return { batch };
}
it("replaces the file that clashed and still uploads the rest of the batch", async () => {
uploadFileToContainer
.mockRejectedValueOnce(clash("a.txt")) // 1: a.txt, no overwrite
.mockResolvedValueOnce(undefined) // 2: a.txt, overwrite: true
.mockResolvedValueOnce(undefined); // 3: b.txt, no clash
const { result } = renderHook(() => useFileManager("p1"));
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt"]);
expect(result.current.conflict?.name).toBe("a.txt");
expect(result.current.conflict?.remaining).toBe(1);
await act(async () => {
result.current.resolveConflict("replace");
await batch;
});
expect(uploadFileToContainer).toHaveBeenCalledTimes(3);
// The retry is the whole point: same file, same directory, overwrite on.
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
// …and "Replace" answered for *that* file only, so the next one is offered
// to the backend the safe way round.
expect(uploadFileToContainer).toHaveBeenNthCalledWith(3, "p1", "/host/b.txt", "/workspace");
expect(result.current.conflict).toBeNull();
expect(result.current.completed).toContain("Uploaded 2 items");
expect(toastText()).not.toContain("could not be uploaded");
});
it("moves on to the next file on Skip rather than ending the batch", async () => {
uploadFileToContainer
.mockRejectedValueOnce(clash("a.txt"))
.mockResolvedValueOnce(undefined); // b.txt still goes
const { result } = renderHook(() => useFileManager("p1"));
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt"]);
await act(async () => {
result.current.resolveConflict("skip");
await batch;
});
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.txt", "/workspace");
// Nothing was overwritten.
expect(uploadFileToContainer.mock.calls.some((c) => c[3] === true)).toBe(false);
expect(result.current.completed).toContain("skipped 1");
});
it("dismissing the dialog is a Skip — the batch carries on", async () => {
// `OverwriteConfirmModal` maps Escape / ✕ / click-outside onto this exact
// call, so a dismissal must not hang the loop or abort the drop.
uploadFileToContainer
.mockRejectedValueOnce(clash("a.txt"))
.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useFileManager("p1"));
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt"]);
await act(async () => {
// What `Modal`'s `onClose` produces.
result.current.resolveConflict("skip");
await batch;
});
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
expect(result.current.completed).toContain("Uploaded 1 item, skipped 1");
expect(result.current.busy).toBeNull();
});
it("answers every remaining clash with Skip all, asking only once", async () => {
uploadFileToContainer
.mockRejectedValueOnce(clash("a.txt"))
.mockRejectedValueOnce(clash("b.txt"))
.mockRejectedValueOnce(clash("c.txt"));
const { result } = renderHook(() => useFileManager("p1"));
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt", "/host/c.txt"]);
expect(result.current.conflict?.remaining).toBe(2);
await act(async () => {
result.current.resolveConflict("skip-all");
await batch;
});
// Three attempts, no second prompt, nothing replaced.
expect(uploadFileToContainer).toHaveBeenCalledTimes(3);
expect(uploadFileToContainer.mock.calls.some((c) => c[3] === true)).toBe(false);
expect(result.current.conflict).toBeNull();
expect(result.current.completed).toContain("skipped 3");
});
it("puts a picked file through exactly the road a dropped one takes", async () => {
// The Upload button and the native drop listener are one routine —
// `uploadPaths` — so the prompt, the retry and the blanket answers cannot
// drift apart between them. This is that claim, from the picker end.
openDialog.mockResolvedValueOnce(["/host/a.txt", "/host/b.txt"]);
uploadFileToContainer
.mockRejectedValueOnce(clash("a.txt"))
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useFileManager("p1"));
let picked!: Promise<void>;
await act(async () => {
picked = result.current.uploadFile();
await Promise.resolve();
});
await waitFor(() => expect(result.current.conflict?.name).toBe("a.txt"));
await act(async () => {
result.current.resolveConflict("replace");
await picked;
});
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
expect(uploadFileToContainer).toHaveBeenNthCalledWith(3, "p1", "/host/b.txt", "/workspace");
});
it("does not leave the batch waiting for an answer that can never arrive", async () => {
// The pane unmounted mid-prompt (tab closed, container stopped). The upload
// promise has to settle, or `busy` never clears and the loop leaks.
uploadFileToContainer.mockRejectedValueOnce(clash("a.txt"));
const { result, unmount } = renderHook(() => useFileManager("p1"));
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt"]);
unmount();
await expect(batch).resolves.toBeUndefined();
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -567,8 +253,6 @@ describe("useFileManager overwrite prompt closes the loop", () => {
* reported that way is a sentence nobody reads. * reported that way is a sentence nobody reads.
*/ */
describe("useFileManager surfaces written refusals as prose", () => { describe("useFileManager surfaces written refusals as prose", () => {
const hiddenFolder =
'".ssh" is a hidden folder — Triple-C will not save there. Choose a visible location.';
const outsideRoots = const outsideRoots =
"Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc"; "Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc";
@@ -576,10 +260,10 @@ describe("useFileManager surfaces written refusals as prose", () => {
const lastToast = () => pushToast.mock.calls.at(-1)?.[0]; const lastToast = () => pushToast.mock.calls.at(-1)?.[0];
it("puts the write-root refusal in the headline, not behind Details", async () => { it("puts the write-root refusal in the headline, not behind Details", async () => {
uploadFileToContainer.mockRejectedValueOnce(outsideRoots); createContainerDirectory.mockRejectedValueOnce(outsideRoots);
const { result } = renderHook(() => useFileManager("p1")); const { result } = renderHook(() => useFileManager("p1"));
await act(async () => { await act(async () => {
await result.current.uploadPaths(["/host/a.txt"]); await result.current.createFolder("new");
}); });
expect(lastToast().message).toBe(outsideRoots); expect(lastToast().message).toBe(outsideRoots);
@@ -587,39 +271,24 @@ describe("useFileManager surfaces written refusals as prose", () => {
expect(lastToast().message).not.toMatch(/^Error:/); expect(lastToast().message).not.toMatch(/^Error:/);
}); });
it("says it once for a whole batch that failed the same way", async () => { it("unwraps an `Error` rather than stamping \"Error:\" on prose", async () => {
// The refusal is about the target directory, so every file in the drop renameContainerPath.mockRejectedValueOnce(new Error(outsideRoots));
// fails identically — three copies of the same sentence is not detail.
uploadFileToContainer.mockRejectedValue(outsideRoots);
const { result } = renderHook(() => useFileManager("p1")); const { result } = renderHook(() => useFileManager("p1"));
await act(async () => { await act(async () => {
await result.current.uploadPaths(["/host/a.txt", "/host/b.txt"]); await result.current.renameEntry(file("a.txt"), "b.txt");
}); });
expect(lastToast().message).toBe(outsideRoots); expect(lastToast().message).toBe(outsideRoots);
expect(lastToast().detail).toBeUndefined();
});
it("does the same for a refused save to the host", async () => {
save.mockResolvedValue("/home/me/.ssh/a.txt");
downloadContainerFile.mockRejectedValueOnce(new Error(hiddenFolder));
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.downloadFile(file("a.txt"));
});
// Unwrapped: an `Error` on the way through must not stamp "Error:" on prose.
expect(lastToast().message).toBe(hiddenFolder);
}); });
it("keeps the hook's own headline when the failure is not a written refusal", async () => { it("keeps the hook's own headline when the failure is not a written refusal", async () => {
uploadFileToContainer.mockRejectedValueOnce("no space left on device"); createContainerDirectory.mockRejectedValueOnce("no space left on device");
const { result } = renderHook(() => useFileManager("p1")); const { result } = renderHook(() => useFileManager("p1"));
await act(async () => { await act(async () => {
await result.current.uploadPaths(["/host/a.txt"]); await result.current.createFolder("new");
}); });
expect(lastToast().message).toBe("A file could not be uploaded"); expect(lastToast().message).toBe('Could not create "new"');
expect(lastToast().detail).toBe("no space left on device"); expect(lastToast().detail).toBe("no space left on device");
}); });
}); });
+20 -235
View File
@@ -1,36 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
import type { FileEntry } from "../lib/types"; import type { FileEntry } from "../lib/types";
import * as commands from "../lib/tauri-commands"; import * as commands from "../lib/tauri-commands";
import { useAppState } from "../store/appState"; import { useAppState } from "../store/appState";
import { import { errorText, readableRefusal } from "../lib/refusalText";
errorText,
fileExistsPath,
isFileExistsError,
readableRefusal,
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;
}
/** /**
* ## Where failures are reported * ## Where failures are reported
@@ -41,22 +13,19 @@ function baseName(path: string): string {
* (empty) grid. It is on screen, it is in context, it explains why there are * (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. * no rows, and it is not transient — it stands until the directory lists.
* *
* Every **transient operation** failure — upload, rename, create folder, * Every **transient operation** failure — rename, create folder — goes to
* save-to-host — goes to `ToastHost` instead. Those used to land * `ToastHost` instead. Those used to land in the same inline `error` div, which
* in the same inline `error` div, which is the first child of the *scrolling* * is the first child of the *scrolling* list: three hundred rows down, a
* list: three hundred rows down, a refused rename produced no visible change * refused rename produced no visible change at all, just a rename box that
* at all, just a rename box that stayed open for no stated reason. Worse, the * stayed open for no stated reason. The toast host is a persistent `aria-live`
* file viewer routes its "Save to host…" through the same call, and the viewer * region at `z-[60]`, i.e. the one place in the app that is above a modal and
* is a `fixed inset-0` portal at `z-50` — so that failure reported *behind* the * does not scroll away.
* 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 * ## Where the current directory lives
* *
* `currentPath` is state (the UI renders it) *and* a ref (async work reads it * `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 * 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 * the start and compares it against the ref at the end: a slow rename in
* `/workspace` must not drag the pane back out of `src/` because that is where * `/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 * 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 * navigation rather than when the listing lands, because the question being
@@ -68,14 +37,11 @@ export function useFileManager(projectId: string) {
const [entries, setEntries] = useState<FileEntry[]>([]); const [entries, setEntries] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
/** Transient "uploading 3 files…" style note, shown beside the breadcrumb. */
const [busy, setBusy] = useState<string | null>(null);
/** /**
* What just finished. A live region that only ever says "uploading…" tells a * What just finished, for the live region — a rename or a new folder is a
* screen reader user when to start waiting and never when to stop. * change a sighted user sees in the grid and a screen reader user does not.
*/ */
const [completed, setCompleted] = useState<string | null>(null); const [completed, setCompleted] = useState<string | null>(null);
const [conflict, setConflict] = useState<UploadConflict | null>(null);
const currentPathRef = useRef(currentPath); const currentPathRef = useRef(currentPath);
@@ -87,45 +53,29 @@ export function useFileManager(projectId: string) {
*/ */
const navGeneration = useRef(0); const navGeneration = useRef(0);
const startWork = useCallback((note: string) => {
setBusy(note);
setCompleted(null);
}, []);
/** /**
* Report a failed operation, given the headline this hook would write and the * Report a failed operation, given the headline this hook would write and the
* raw failures behind it. * raw failure behind it.
* *
* The headline is what the *hook* knows ("Could not rename …"); it is a * The headline is what the *hook* knows ("Could not rename …"); it is a
* category, not an explanation. Some backend refusals are already a finished * category, not an explanation. Some backend refusals are already a finished
* sentence written for the person reading it — a hidden host folder, a * sentence written for the person reading it — a container path outside the
* container path outside the roots this panel may change — and those used to * roots this panel may change, a name it will not create — and those used to
* arrive as the toast's `detail`, which `ToastHost` renders as collapsed * arrive as the toast's `detail`, which `ToastHost` renders as collapsed
* monospace behind a "Details" button. So the sentence that said what was * monospace behind a "Details" button. So the sentence that said what was
* wrong and what to do about it was hidden under a headline that said * wrong and what to do about it was hidden under a headline that said
* neither. When every failure reduces to the *same* such sentence — which is * neither. When there is such a sentence it becomes the headline, and there
* the normal case, since these refusals are about the target directory and so * is nothing left to hide.
* fail identically for every file in a batch — it becomes the headline and
* there is nothing left to hide.
*/ */
const report = useCallback((message: string, ...causes: unknown[]) => { const report = useCallback((message: string, cause: unknown) => {
const refusals = causes.map(readableRefusal); const promoted = readableRefusal(cause);
const shared =
causes.length > 0 && refusals.every((r) => r !== null)
? [...new Set(refusals as string[])]
: [];
const promoted = shared.length === 1 ? shared[0] : null;
useAppState.getState().pushToast({ useAppState.getState().pushToast({
kind: "error", kind: "error",
message: promoted ?? message, message: promoted ?? message,
detail: promoted || causes.length === 0 ? undefined : causes.map(errorText).join("\n"), detail: promoted ? undefined : errorText(cause),
}); });
}, []); }, []);
const confirm = useCallback((message: string) => {
useAppState.getState().pushToast({ kind: "success", message });
}, []);
const navigate = useCallback( const navigate = useCallback(
async (path: string) => { async (path: string) => {
const mine = ++navGeneration.current; const mine = ++navGeneration.current;
@@ -163,164 +113,6 @@ export function useFileManager(projectId: string) {
navigate(currentPathRef.current); navigate(currentPathRef.current);
}, [navigate]); }, [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;
// 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) {
report(`Could not save "${entry.name}" to the host`, e);
}
},
[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<OverwriteChoice>((resolve) => {
// One batch asks one question at a time — the loop awaits each answer —
// so a resolver still sitting here belongs to a *different* batch (two
// drops in flight at once, or a drop landing while the Upload button's
// batch is still copying). Installing over it would leave that batch
// awaiting an answer no dialog can ever produce: a silent hang, with
// its file neither uploaded nor skipped. Skipping it is the same
// reading of "the dialog went away" the unmount cleanup uses.
conflictResolver.current?.("skip");
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;
// 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" : ""}`);
/** Raw failures, kept unstringified so `report` can read their shape. */
const failures: unknown[] = [];
let uploaded = 0;
let skipped = 0;
/** A "…all" answer, applied to every remaining clash without asking. */
let blanket: OverwriteChoice | null = null;
try {
for (let i = 0; i < hostPaths.length; i++) {
const hostPath = hostPaths[i];
try {
await commands.uploadFileToContainer(projectId, hostPath, target);
uploaded++;
continue;
} catch (e) {
if (!isFileExistsError(e)) {
failures.push(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(e);
}
}
} finally {
setBusy(null);
}
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,
);
}
// 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, navigate, startWork, report, askOverwrite],
);
const uploadFile = useCallback(async () => {
try {
const selected = await openDialog({ multiple: true, directory: false });
if (!selected) return;
await uploadPaths(Array.isArray(selected) ? selected : [selected as string]);
} catch (e) {
report("Could not open the file picker", e);
}
}, [uploadPaths, report]);
/** /**
* Rename in place. `newName` is a bare name — Rust rejects anything with a * 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 * `/` in it, so this can never turn into a move. Resolves true on success so
@@ -368,19 +160,12 @@ export function useFileManager(projectId: string) {
loading, loading,
/** Inline, in-context: why the listing on screen is empty. */ /** Inline, in-context: why the listing on screen is empty. */
error, error,
busy,
/** What the last operation finished doing, for the live region. */ /** What the last operation finished doing, for the live region. */
completed, completed,
/** An upload waiting for a Replace / Skip answer, or `null`. */
conflict,
resolveConflict,
setError, setError,
navigate, navigate,
goUp, goUp,
refresh, refresh,
downloadFile,
uploadFile,
uploadPaths,
renameEntry, renameEntry,
createFolder, createFolder,
}; };
+5 -3
View File
@@ -7,8 +7,10 @@
* *
* 1. **Which pane is this drop for?** Geometry, and nothing else: is the * 1. **Which pane is this drop for?** Geometry, and nothing else: is the
* payload position inside my rect? A hidden pane is `display:none` and so * payload position inside my rect? A hidden pane is `display:none` and so
* has a zero-size rect, which is what stops `TerminalView` and `FilesTab` * has a zero-size rect, which is what stops two panes both claiming the
* both claiming the same drop. * same drop. `TerminalView` is the only pane that takes dropped files
* today — the Files pane is container-side only — but the routing is what
* keeps it honest when a second one appears.
* 2. **Should the app accept a drop at all right now?** `dropIsBlocked` — * 2. **Should the app accept a drop at all right now?** `dropIsBlocked` —
* document-wide, no geometry, no z-order. While a modal or a blocking * document-wide, no geometry, no z-order. While a modal or a blocking
* overlay is on screen anywhere, every drop is refused. * overlay is on screen anywhere, every drop is refused.
@@ -16,7 +18,7 @@
* ## Why there is no z-order test here, and must not be one * ## Why there is no z-order test here, and must not be one
* *
* A drop that lands underneath a dialog and silently uploads into the * A drop that lands underneath a dialog and silently uploads into the
* directory the dialog is covering is the failure mode that matters: it is * container behind it is the failure mode that matters: it is
* invisible, it writes to the container, and the user did not ask for it. * invisible, it writes to the container, and the user did not ask for it.
* Every attempt to be *precise* about which points a dialog covers has gone * Every attempt to be *precise* about which points a dialog covers has gone
* wrong, twice, in opposite directions: * wrong, twice, in opposite directions:
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { errorText, readableRefusal } from "./refusalText";
/**
* Refusals that are a sentence the backend wrote for the person reading it.
* They used to arrive as a toast's `detail`, which renders as collapsed
* monospace behind a "Details" button — so the only part of the message that
* explained anything was the part nobody saw.
*/
describe("readableRefusal", () => {
const hidden =
'the path goes through ".ssh", a hidden folder — Triple-C will not save anything whose folders are not all visible. Choose a visible location.';
const outside =
"Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc";
it("recognises the hidden-host-folder refusal, in both directions", () => {
expect(readableRefusal(hidden)).toBe(hidden);
expect(
readableRefusal(
'the path goes through ".aws", a hidden folder — Triple-C will not read anything whose folders are not all visible. Choose a visible location.',
),
).toContain("hidden folder");
});
it("recognises the container write-root refusal", () => {
expect(readableRefusal(outside)).toBe(outside);
});
it("strips a wrapper a JS layer put in front of the sentence", () => {
// `invoke` rejects with the bare string today, but an `Error` anywhere in
// between would otherwise put "Error: " in front of prose meant to be read.
expect(readableRefusal(new Error(hidden))).toBe(hidden);
expect(readableRefusal(`Error: ${hidden}`)).toBe(hidden);
expect(readableRefusal(`Uncaught (in promise) Error: ${outside}`)).toBe(outside);
expect(readableRefusal({ message: `invoke failed: ${outside}` })).toBe(outside);
});
it("says nothing about failures that are not a written refusal", () => {
// Promotion is an improvement, not a fallback: anything unrecognised keeps
// reporting exactly as it did before.
expect(readableRefusal("File too large to upload (900 MB; limit 256 MB)")).toBeNull();
expect(readableRefusal("FILE_EXISTS: /workspace/a.txt already exists")).toBeNull();
expect(readableRefusal("cp: Permission denied")).toBeNull();
expect(readableRefusal(null)).toBeNull();
});
});
describe("errorText", () => {
it("keeps an ordinary message intact", () => {
expect(errorText("cp: cannot create regular file: Permission denied")).toBe(
"cp: cannot create regular file: Permission denied",
);
});
it("reads a message out of a shape `String()` would render as [object Object]", () => {
expect(errorText({ message: "Container not running" })).toBe("Container not running");
expect(errorText({ kind: "NotRunning" })).toBe("NotRunning");
expect(errorText(new Error("Failed to upload file to container: no space left"))).toBe(
"Failed to upload file to container: no space left",
);
});
it("prefers the written refusal when there is one", () => {
expect(errorText(new Error("Folder path is outside the folders this panel can change (/workspace): /etc"))).toBe(
"Folder path is outside the folders this panel can change (/workspace): /etc",
);
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* Turning a backend refusal into the sentence a person reads.
*
* Tauri command errors cross the IPC boundary as whatever `serde` made of them:
* 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 read differently depending on
* which one a future refactor produces — so everything here is tolerant about
* the *shape* of an error and picks the most human string out of it.
*/
/**
* Field names a serialised Rust error realistically uses for its discriminant
* and for its human text. `error` is listed as a discriminant field and yet
* routinely carries a whole sentence, which is why a kind string is read as
* prose too.
*/
const KIND_FIELDS = ["kind", "code", "type", "error", "reason"] as const;
const MESSAGE_FIELDS = ["message", "msg", "detail", "description"] as const;
function asRecord(e: unknown): Record<string, unknown> | null {
return typeof e === "object" && e !== null ? (e as Record<string, unknown>) : 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: { message: … } }`) 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[] = [];
const walk = (value: unknown) => {
if (typeof value === "string") out.push(value);
else if (value !== undefined) out.push(...stringsIn(value, depth + 1));
};
for (const field of KIND_FIELDS) walk(record[field]);
for (const field of MESSAGE_FIELDS) walk(record[field]);
return out;
}
/**
* Fragments that identify a refusal the backend already wrote **for a person**.
*
* The file commands guard two policies that a user can trip over by accident,
* and both answer with a finished sentence that names the offending path and
* says what to do instead:
*
* the path goes through ".ssh", a hidden folder — Triple-C will not save …
* Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc
*
* Those sentences were being used as the *detail* of a generic toast, and
* `ToastHost` renders a detail as collapsed monospace behind a "Details"
* button — so the one part of the message that explained anything was the part
* nobody saw. Matching them here lets the caller promote the sentence to the
* toast's headline.
*
* Matched on a stable fragment rather than the whole string, because the path
* and the verb ("save"/"read", "file"/"folder") vary per call. Deliberately a
* short list: an error that is *not* recognised still reports exactly as it
* did before, so a wrong guess here can only fail to promote, never mangle.
*/
const REFUSAL_MARKERS = [
// `validate_host_path` — hidden host component, and system locations.
"Triple-C will not",
// `validate_container_write_path` — outside /workspace, /home/claude, /tmp.
"outside the folders this panel can change",
] as const;
/**
* `Error: …`, `TypeError: …`, `invoke failed: …` — wrappers a JS layer may have
* put in front of the backend's sentence on the way through. Stripped so the
* prose starts where the backend started it; applied twice at most, because a
* doubly-wrapped error is the realistic worst case and looping on user text is
* not.
*/
const WRAPPER_PREFIX = /^(?:uncaught\s*(?:\(in promise\)\s*)?)?(?:[a-z]*error|invoke(?:\s+failed)?)\s*:\s*/i;
function stripWrapper(text: string): string {
let out = text.trim();
for (let i = 0; i < 2; i++) {
const next = out.replace(WRAPPER_PREFIX, "").trim();
if (next === out) break;
out = next;
}
return out;
}
/**
* The backend's own user-facing sentence, when this failure is one — otherwise
* `null`, and the caller reports it however it reported everything else.
*/
export function readableRefusal(e: unknown): string | null {
for (const s of stringsIn(e)) {
const text = stripWrapper(s);
if (REFUSAL_MARKERS.some((marker) => text.includes(marker))) return text;
}
return null;
}
/**
* The most human form of any failure, for the places that show one verbatim.
*
* `String(e)` is what these used to be, which turns a serialised error object
* into `[object Object]` and leaves a JS wrapper prefix on a sentence that
* reads perfectly well without it.
*/
export function errorText(e: unknown): string {
const readable = readableRefusal(e);
if (readable) return readable;
if (typeof e === "string") return stripWrapper(e);
if (e instanceof Error) return stripWrapper(e.message);
const record = asRecord(e);
if (record) {
for (const field of [...MESSAGE_FIELDS, ...KIND_FIELDS]) {
const value = record[field];
if (typeof value === "string" && value.trim().length > 0) return stripWrapper(value);
}
}
return String(e);
}
-17
View File
@@ -71,25 +71,8 @@ export const stopAudioBridge = (sessionId: string) =>
// Files // Files
export const listContainerFiles = (projectId: string, path: string) => export const listContainerFiles = (projectId: string, path: string) =>
invoke<FileEntry[]>("list_container_files", { projectId, path }); invoke<FileEntry[]>("list_container_files", { projectId, path });
export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) =>
invoke<void>("download_container_file", { projectId, containerPath, hostPath });
export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) => export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) =>
invoke<number>("download_container_backup", { projectId, hostPath, containerPath }); invoke<number>("download_container_backup", { projectId, hostPath, containerPath });
/**
* 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<void>("upload_file_to_container", { projectId, hostPath, containerDir, overwrite });
export const readContainerFile = (projectId: string, path: string, maxBytes?: number) => export const readContainerFile = (projectId: string, path: string, maxBytes?: number) =>
invoke<FileContents>("read_container_file", { projectId, path, maxBytes }); invoke<FileContents>("read_container_file", { projectId, path, maxBytes });
/** `toPath` is the new *name*, not a destination — renames never move. */ /** `toPath` is the new *name*, not a destination — renames never move. */
-184
View File
@@ -1,184 +0,0 @@
import { describe, expect, it } from "vitest";
import {
errorText,
FILE_EXISTS_MARKER,
fileExistsPath,
isFileExistsError,
readableRefusal,
} 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);
});
it("cannot be forged by the name of the file being uploaded", () => {
// The one that mattered. Matching `fileexists` anywhere in a normalised
// error meant a host file called `file-exists.txt` turned *every* failure
// into a collision: the overwrite prompt appeared over a permission error,
// and Replace re-invoked the upload with `overwrite: true`, clobbering
// whatever shared that name in the container.
expect(
isFileExistsError("Failed to upload /host/file-exists.txt: Permission denied"),
).toBe(false);
expect(
isFileExistsError({
message: "cp: cannot create regular file '/workspace/FILE_EXISTS.txt'",
}),
).toBe(false);
expect(isFileExistsError("no space left on device: /host/File Exists.png")).toBe(
false,
);
// A path that merely ends in the marker is a path, not the marker.
expect(isFileExistsError("cannot stat /workspace/FILE_EXISTS: no such file")).toBe(
false,
);
// …while the contract's own shape still reads as the refusal it is.
expect(
isFileExistsError("FILE_EXISTS: /workspace/file-exists.txt already exists"),
).toBe(true);
});
it("still reads a wrapped error whose `error` field is a whole sentence", () => {
// `error` is listed as a discriminant field but routinely carries prose,
// so it is held to both standards.
expect(
isFileExistsError({ error: "FILE_EXISTS: /workspace/a.txt already exists" }),
).toBe(true);
expect(isFileExistsError({ error: "upload of file-exists.txt failed" })).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();
});
});
/**
* The other half of the contract: refusals that are *not* a name clash, but are
* a sentence the backend wrote for the person reading it. They used to arrive
* as a toast's `detail`, which renders as collapsed monospace behind a
* "Details" button — so the only part of the message that explained anything
* was the part nobody saw.
*/
describe("readableRefusal", () => {
const hidden =
'".ssh" is a hidden folder — Triple-C will not save there. Choose a visible location.';
const outside =
"Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc";
it("recognises the hidden-host-folder refusal, in both directions", () => {
expect(readableRefusal(hidden)).toBe(hidden);
expect(
readableRefusal('".aws" is a hidden folder — Triple-C will not read there. Choose a visible location.'),
).toContain("hidden folder");
});
it("recognises the container write-root refusal", () => {
expect(readableRefusal(outside)).toBe(outside);
});
it("strips a wrapper a JS layer put in front of the sentence", () => {
// `invoke` rejects with the bare string today, but an `Error` anywhere in
// between would otherwise put "Error: " in front of prose meant to be read.
expect(readableRefusal(new Error(hidden))).toBe(hidden);
expect(readableRefusal(`Error: ${hidden}`)).toBe(hidden);
expect(readableRefusal(`Uncaught (in promise) Error: ${outside}`)).toBe(outside);
expect(readableRefusal({ message: `invoke failed: ${outside}` })).toBe(outside);
});
it("says nothing about failures that are not a written refusal", () => {
// Promotion is an improvement, not a fallback: anything unrecognised keeps
// reporting exactly as it did before.
expect(readableRefusal("File too large to upload (900 MB; limit 256 MB)")).toBeNull();
expect(readableRefusal("FILE_EXISTS: /workspace/a.txt already exists")).toBeNull();
expect(readableRefusal("cp: Permission denied")).toBeNull();
expect(readableRefusal(null)).toBeNull();
});
});
describe("errorText", () => {
it("keeps an ordinary message intact", () => {
expect(errorText("cp: cannot create regular file: Permission denied")).toBe(
"cp: cannot create regular file: Permission denied",
);
});
it("reads a message out of a shape `String()` would render as [object Object]", () => {
expect(errorText({ message: "Container not running" })).toBe("Container not running");
expect(errorText({ kind: "NotRunning" })).toBe("NotRunning");
expect(errorText(new Error("Failed to upload file to container: no space left"))).toBe(
"Failed to upload file to container: no space left",
);
});
it("prefers the written refusal when there is one", () => {
expect(errorText(new Error("Folder path is outside the folders this panel can change (/workspace): /etc"))).toBe(
"Folder path is outside the folders this panel can change (/workspace): /etc",
);
});
});
-264
View File
@@ -1,264 +0,0 @@
/**
* 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.
*
* **Tolerant about shape is not the same as tolerant about content.** This
* used to normalise the whole error (lower-case, `_`/`-` stripped) and ask
* whether `fileexists` appeared *anywhere* in it — which a host file named
* `file-exists.txt` satisfies on its way through any error at all. Uploading
* that file and hitting "permission denied" therefore raised the overwrite
* prompt, and answering Replace re-invoked the upload with `overwrite: true`:
* an unrelated failure silently promoted into an overwrite of whatever shared
* the name in the container. So the marker now has to appear in a form a
* *filename* cannot produce:
*
* - in prose, the canonical `FILE_EXISTS` (or `FILE-EXISTS`) in upper case,
* standing alone — end of string, or followed by the `:`/`=` of the agreed
* `FILE_EXISTS: <path>` form. `file-exists.txt`, `FILE_EXISTS.txt` and
* `/workspace/FILE_EXISTS` all fail that, because a filename brings its own
* extension, quote or path separator along with it.
* - in a discriminant field, the *whole* value, case- and separator-insensitive
* (`FileExists`, `file_exists`, `file-exists`, `FileExistsError`) — a
* discriminant is a variant name, not a sentence, so equality is the right
* test and a filename never gets to be one.
*/
/** 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);
/**
* The marker standing on its own inside a sentence.
*
* Derived from `FILE_EXISTS_MARKER` so the two cannot drift. Upper case is
* load-bearing (a lower-case `file-exists` is a plausible filename, the
* upper-case token is not), and so is the lookahead: the marker must end the
* string or be followed by the `:`/`=` that introduces the path. That is what
* a path or a filename cannot forge — `FILE_EXISTS.txt`, `"FILE_EXISTS"` and
* `/workspace/FILE_EXISTS` are each rejected by one end or the other.
*/
const PROSE_MARKER = new RegExp(
`(?:^|[\\s:;(\\[{"'\`])${FILE_EXISTS_MARKER.replace(/_/g, "[_-]")}(?=$|[\\s:=])`,
);
/** A discriminant *is* the refusal, rather than mentioning it. */
function isFileExistsDiscriminant(value: string): boolean {
const normalised = normaliseKind(value);
return normalised === KIND_NEEDLE || normalised === `${KIND_NEEDLE}error`;
}
function asRecord(e: unknown): Record<string, unknown> | null {
return typeof e === "object" && e !== null ? (e as Record<string, unknown>) : 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[] {
const { prose, kinds } = partitionStrings(e, depth);
return [...prose, ...kinds];
}
/**
* The same flattening, but keeping track of *where* each string came from.
*
* A discriminant field and a message field are held to different standards
* (see the module comment), so they cannot be pooled. `error` is listed as a
* discriminant field and yet routinely carries a whole sentence, which is why
* a kind string is tested against both rules and a prose string only against
* the prose one.
*/
function partitionStrings(
e: unknown,
depth = 0,
): { prose: string[]; kinds: string[] } {
if (typeof e === "string") return { prose: [e], kinds: [] };
if (e instanceof Error) return { prose: [e.message], kinds: [e.name] };
const record = asRecord(e);
if (!record || depth > 1) return { prose: [], kinds: [] };
const prose: string[] = [];
const kinds: string[] = [];
const walk = (value: unknown, into: string[]) => {
if (typeof value === "string") into.push(value);
else if (value !== undefined) {
const nested = partitionStrings(value, depth + 1);
prose.push(...nested.prose);
kinds.push(...nested.kinds);
}
};
for (const field of KIND_FIELDS) walk(record[field], kinds);
for (const field of MESSAGE_FIELDS) walk(record[field], prose);
return { prose, kinds };
}
/**
* 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 {
const { prose, kinds } = partitionStrings(e);
return (
kinds.some((s) => isFileExistsDiscriminant(s) || PROSE_MARKER.test(s)) ||
prose.some((s) => PROSE_MARKER.test(s))
);
}
/**
* 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: <path>` — 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";
/**
* Fragments that identify a refusal the backend already wrote **for a person**.
*
* The file commands guard two policies that a user can trip over by accident,
* and both answer with a finished sentence that names the offending path and
* says what to do instead:
*
* ".ssh" is a hidden folder — Triple-C will not save there. Choose a visible location.
* Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc
*
* Those sentences were being used as the *detail* of a generic toast
* ("A file could not be uploaded"), and `ToastHost` renders a detail as
* collapsed monospace behind a "Details" button — so the one part of the
* message that explained anything was the part nobody saw. Matching them here
* lets the caller promote the sentence to the toast's headline.
*
* Matched on a stable fragment rather than the whole string, because the path
* and the verb ("save"/"read", "file"/"folder") vary per call. Deliberately a
* short list: an error that is *not* recognised still reports exactly as it
* did before, so a wrong guess here can only fail to promote, never mangle.
*/
const REFUSAL_MARKERS = [
// `validate_host_path` — hidden host component, and system locations.
"Triple-C will not",
// `validate_container_write_path` — outside /workspace, /home/claude, /tmp.
"outside the folders this panel can change",
] as const;
/**
* `Error: …`, `TypeError: …`, `invoke failed: …` — wrappers a JS layer may have
* put in front of the backend's sentence on the way through. Stripped so the
* prose starts where the backend started it; applied twice at most, because a
* doubly-wrapped error is the realistic worst case and looping on user text is
* not.
*/
const WRAPPER_PREFIX = /^(?:uncaught\s*(?:\(in promise\)\s*)?)?(?:[a-z]*error|invoke(?:\s+failed)?)\s*:\s*/i;
function stripWrapper(text: string): string {
let out = text.trim();
for (let i = 0; i < 2; i++) {
const next = out.replace(WRAPPER_PREFIX, "").trim();
if (next === out) break;
out = next;
}
return out;
}
/**
* The backend's own user-facing sentence, when this failure is one — otherwise
* `null`, and the caller reports it however it reported everything else.
*/
export function readableRefusal(e: unknown): string | null {
for (const s of stringsIn(e)) {
const text = stripWrapper(s);
if (REFUSAL_MARKERS.some((marker) => text.includes(marker))) return text;
}
return null;
}
/**
* The most human form of any failure, for the places that show one verbatim.
*
* `String(e)` is what these used to be, which turns a serialised error object
* into `[object Object]` and leaves a JS wrapper prefix on a sentence that
* reads perfectly well without it.
*/
export function errorText(e: unknown): string {
const readable = readableRefusal(e);
if (readable) return readable;
if (typeof e === "string") return stripWrapper(e);
if (e instanceof Error) return stripWrapper(e.message);
const record = asRecord(e);
if (record) {
for (const field of [...MESSAGE_FIELDS, ...KIND_FIELDS]) {
const value = record[field];
if (typeof value === "string" && value.trim().length > 0) return stripWrapper(value);
}
}
return String(e);
}