Merge branch 'r4/narrow' into ship/core
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user