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:
|
||||
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
|
||||
modals — see "UI conventions" below.
|
||||
- **Files takes drops *in*, and that path does not use HTML5 drag.** Dropping into the pane
|
||||
is Tauri's native `onDragDropEvent`, which is window-wide and therefore routed by a
|
||||
hit-test of the physical-pixel payload position against the pane's rect ÷
|
||||
`devicePixelRatio` — a hidden pane has a zero-size rect, which is what stops it and
|
||||
`TerminalView`'s listener both firing. Keep `lib/dropTarget.ts` and both listeners.
|
||||
- **Getting a file *out* is "Save to host…", and there is no other route.** OS drag-out —
|
||||
`tauri-plugin-drag`, `stage_container_file_for_drag` and its host staging directory — was
|
||||
removed from the ship branch and held back for separate hardening; it lives on
|
||||
- **Files is container-side only. It does no host filesystem I/O, and must not grow any.**
|
||||
The tab lists, opens (text and image viewer), renames and creates folders *inside* the
|
||||
container: `list_container_files`, `read_container_file`, `rename_container_path`,
|
||||
`create_container_directory`. There is no upload button, no "Save to host…", and no
|
||||
drop-into-the-pane. Four successive audits found that host filesystem paths crossing IPC
|
||||
were where the criticals lived — a caller-named host destination for container-controlled
|
||||
bytes, an arbitrary host source read into the container, a `link(2)` upload reservation
|
||||
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
|
||||
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
|
||||
|
||||
+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 |
|
||||
| **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) |
|
||||
| **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) |
|
||||
|
||||
### 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 |
|
||||
| **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) |
|
||||
| **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) |
|
||||
| **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 |
|
||||
@@ -1161,16 +1161,43 @@ When you scroll up in the terminal to review previous output, a **Jump to Curren
|
||||
|
||||
### 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
|
||||
- **Save to host…** — copy any file out to a location you pick. This is the way to get a file out
|
||||
of a container; there is one button per file entry, and the file viewer offers it too
|
||||
- **Upload file** from your host into the current container directory — or **drop files straight
|
||||
onto the pane** from your desktop, which uploads them into the directory on screen
|
||||
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation.
|
||||
Double-click a folder to open it, or the `..` row to go up; the arrow keys, Home and End move
|
||||
between rows and Enter opens the selected one
|
||||
- **View** a file — double-click it, or press Enter. Text files and images render in a read-only
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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** |
|
||||
| **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) |
|
||||
| **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) |
|
||||
|
||||
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/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/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/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 |
|
||||
@@ -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/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/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/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 |
|
||||
@@ -547,7 +547,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| 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/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/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 |
|
||||
@@ -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/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/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/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) |
|
||||
|
||||
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>,
|
||||
) -> Result<String, String> {
|
||||
// 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:
|
||||
// absolute, no traversal, and nothing out of a hidden directory
|
||||
// (`~/.ssh`, `~/.aws`) or a system location — applied to the path with its
|
||||
// symlinks already resolved, so a visible directory that *leads* to `~/.ssh`
|
||||
// is refused too. What comes back is that resolved path, and it is what
|
||||
// gets opened.
|
||||
// itself, so it goes through `file_commands`' host-read policy: absolute,
|
||||
// no traversal, and nothing whose path passes through a hidden directory
|
||||
// (`~/.ssh`, `~/.aws`, `~/.local/bin`) or a system location — applied to
|
||||
// the path with its symlinks already resolved, so a visible directory that
|
||||
// *leads* to one of those is refused too. What comes back is that resolved
|
||||
// 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*
|
||||
// resolution. Deriving it from the resolved path renames the file behind
|
||||
// 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
|
||||
// pane's upload had the same bug and fixes it the same way — one helper, so
|
||||
// the two drop targets cannot drift.
|
||||
// a symlink, would land it in the container as `2026-08-23.log`.
|
||||
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?;
|
||||
|
||||
@@ -229,7 +228,7 @@ pub async fn upload_host_file_to_terminal(
|
||||
use crate::docker::exec::MAX_DROP_BYTES;
|
||||
if meta.len() > MAX_DROP_BYTES {
|
||||
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),
|
||||
MAX_DROP_BYTES / (1024 * 1024)
|
||||
));
|
||||
@@ -301,19 +300,18 @@ pub async fn stop_audio_bridge(
|
||||
|
||||
#[cfg(test)]
|
||||
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
|
||||
/// name from the path *after* symlink resolution, so dropping
|
||||
/// `~/Downloads/latest.log` — where `latest.log` is a symlink to
|
||||
/// `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
|
||||
/// typed. The Files pane had the identical bug.
|
||||
/// typed.
|
||||
///
|
||||
/// What actually keeps the two from drifting is that they now call one
|
||||
/// helper, so this asserts that helper's contract from the terminal side:
|
||||
/// the answer comes from the spelling, and a path that does not name a file
|
||||
/// is refused rather than silently substituted (it used to fall back to
|
||||
/// This asserts the shared helper's contract from the terminal side: the
|
||||
/// answer comes from the spelling, and a path that does not name a file is
|
||||
/// refused rather than silently substituted (it used to fall back to
|
||||
/// `"dropped-file"`).
|
||||
#[test]
|
||||
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
|
||||
// is a second trip through the same directories, so the descriptor is
|
||||
// checked against the path that was validated before its bytes are
|
||||
// packed into anything. Same policy as the Files pane's upload — this
|
||||
// is the terminal's drop target, and the two must not differ.
|
||||
// packed into anything. This is the terminal's drop target, and it is
|
||||
// the only path by which host bytes enter a container.
|
||||
let file = std::fs::File::open(&host_path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
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_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.
|
||||
///
|
||||
/// `combined` is stdout and stderr interleaved in arrival order — the shape
|
||||
|
||||
@@ -497,9 +497,7 @@ pub fn run() {
|
||||
commands::terminal_commands::stop_audio_bridge,
|
||||
// Files
|
||||
commands::file_commands::list_container_files,
|
||||
commands::file_commands::download_container_file,
|
||||
commands::file_commands::download_container_backup,
|
||||
commands::file_commands::upload_file_to_container,
|
||||
commands::file_commands::read_container_file,
|
||||
commands::file_commands::rename_container_path,
|
||||
commands::file_commands::create_container_directory,
|
||||
|
||||
@@ -16,14 +16,12 @@ interface Props {
|
||||
projectId: string;
|
||||
entry: FileEntry;
|
||||
onClose: () => void;
|
||||
/** "Save to host…" — the way out for anything the viewer can't render. */
|
||||
onSaveToHost: (entry: FileEntry) => void;
|
||||
}
|
||||
|
||||
type Preview =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
/** Too big to render whole — offered as a download rather than a half-file. */
|
||||
/** Too big to render whole — said so rather than shown as a half-file. */
|
||||
| { kind: "too-large" }
|
||||
| { kind: "text"; text: string; truncated: boolean; shownBytes: number; trueSize: number }
|
||||
| { 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
|
||||
* `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" });
|
||||
|
||||
/**
|
||||
@@ -121,19 +119,9 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Button
|
||||
size="md"
|
||||
onClick={() => {
|
||||
onSaveToHost(entry);
|
||||
}}
|
||||
>
|
||||
Save to host…
|
||||
</Button>
|
||||
<Button size="md" variant="primary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</>
|
||||
<Button size="md" variant="primary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -156,14 +144,15 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
|
||||
|
||||
{preview.kind === "too-large" && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
This file is {formatBytes(entry.size)} — too large to preview in the app. Save it
|
||||
to the host to open it there.
|
||||
This file is {formatBytes(entry.size)} — too large to preview in the app. Open it
|
||||
from a terminal in the container, or take a backup and open it on the host.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview.kind === "unsupported" && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
There is no preview for this file type. Save it to the host to open it there.
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,16 +4,12 @@ import FilesTab from "./FilesTab";
|
||||
import type { FileContents, FileEntry, Project } from "../../../lib/types";
|
||||
|
||||
const listContainerFiles = vi.fn();
|
||||
const downloadContainerFile = vi.fn(async () => {});
|
||||
const uploadFileToContainer = vi.fn(async () => {});
|
||||
const renameContainerPath = vi.fn(async () => "");
|
||||
const createContainerDirectory = vi.fn(async () => "");
|
||||
const readContainerFile = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/tauri-commands", () => ({
|
||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
|
||||
uploadFileToContainer: (...args: unknown[]) => uploadFileToContainer(...args),
|
||||
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||
createContainerDirectory(p, parent, n),
|
||||
@@ -31,29 +27,6 @@ const toastText = () =>
|
||||
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
|
||||
.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 entry = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
|
||||
@@ -82,39 +55,17 @@ async function renderTab() {
|
||||
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. */
|
||||
const gridRows = () => Array.from(document.querySelectorAll("tr[data-file-row]"));
|
||||
/** The rows that are actually tab stops. There must never be more than one. */
|
||||
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(() => {
|
||||
vi.clearAllMocks();
|
||||
dragHandler = null;
|
||||
listContainerFiles.mockResolvedValue([
|
||||
entry("src", { is_directory: true, path: "/workspace/src" }),
|
||||
entry("notes.txt"),
|
||||
]);
|
||||
// jsdom lays nothing out, so the pane's hit-test rect has to be supplied.
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
|
||||
x: 0, y: 0, left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
// Not implemented in jsdom; the image preview needs both halves.
|
||||
URL.createObjectURL = vi.fn(() => "blob:mock-url");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
@@ -224,7 +175,9 @@ describe("FilesTab viewer", () => {
|
||||
});
|
||||
expect(await screen.findByText(/too large to preview/)).toBeTruthy();
|
||||
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 () => {
|
||||
@@ -239,7 +192,7 @@ describe("FilesTab viewer", () => {
|
||||
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")]);
|
||||
readContainerFile.mockResolvedValue(contents("a\x00b"));
|
||||
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", () => {
|
||||
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
|
||||
@@ -592,22 +360,28 @@ describe("FilesTab grid semantics", () => {
|
||||
await renderTab();
|
||||
const rename = screen.getByRole("button", { name: "Rename — notes.txt" });
|
||||
expect(rename.textContent).toBe("Rename");
|
||||
expect(rename.getAttribute("aria-label")).toContain("Rename");
|
||||
const saveTo = screen.getByRole("button", { name: "Save to host… — notes.txt" });
|
||||
expect(saveTo.getAttribute("aria-label")).toContain(saveTo.textContent!);
|
||||
expect(rename.getAttribute("aria-label")).toContain(rename.textContent!);
|
||||
});
|
||||
|
||||
it("mounts the live region empty, then fills it", async () => {
|
||||
// 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.
|
||||
createContainerDirectory.mockResolvedValue("/workspace/new");
|
||||
await renderTab();
|
||||
const live = screen.getByRole("status");
|
||||
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.
|
||||
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 () => {
|
||||
@@ -618,130 +392,3 @@ describe("FilesTab grid semantics", () => {
|
||||
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 { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import type { FileEntry, Project } from "../../../lib/types";
|
||||
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 FileViewerModal from "./FileViewerModal";
|
||||
import OverwriteConfirmModal from "./OverwriteConfirmModal";
|
||||
import { formatBytes } from "./format";
|
||||
|
||||
interface Props {
|
||||
@@ -17,7 +13,15 @@ interface Props {
|
||||
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
|
||||
* the old half-and-half: **single click selects, double click opens**. That
|
||||
@@ -42,16 +46,10 @@ export default function FilesTab({ project }: Props) {
|
||||
entries,
|
||||
loading,
|
||||
error,
|
||||
busy,
|
||||
completed,
|
||||
conflict,
|
||||
resolveConflict,
|
||||
navigate,
|
||||
goUp,
|
||||
refresh,
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
uploadPaths,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
} = useFileManager(project.id);
|
||||
@@ -65,8 +63,6 @@ export default function FilesTab({ project }: Props) {
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [folderDraft, setFolderDraft] = useState("");
|
||||
const [viewing, setViewing] = useState<FileEntry | null>(null);
|
||||
/** A host drag is currently over this pane. */
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
/** The row that owns the grid's single tab stop. */
|
||||
const [activeRow, setActiveRow] = useState<string | null>(null);
|
||||
|
||||
@@ -234,58 +230,6 @@ export default function FilesTab({ project }: Props) {
|
||||
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 =
|
||||
currentPath === "/"
|
||||
? [{ label: "/", path: "/" }]
|
||||
@@ -324,10 +268,10 @@ export default function FilesTab({ project }: Props) {
|
||||
/**
|
||||
* The live region's text. One region, always mounted, filled and emptied —
|
||||
* a `role="status"` node that is *inserted* already carrying its text is
|
||||
* frequently not announced at all, which is how "uploading 3 items…" and
|
||||
* every completion notice used to go by in silence.
|
||||
* frequently not announced at all, which is how every completion notice used
|
||||
* to go by in silence.
|
||||
*/
|
||||
const liveText = busy ? busy : (completed ?? "");
|
||||
const liveText = completed ?? "";
|
||||
|
||||
return (
|
||||
<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
|
||||
</Button>
|
||||
<Button onClick={uploadFile} className="ml-1">
|
||||
Upload file
|
||||
</Button>
|
||||
<Button onClick={refresh} disabled={loading} className="ml-1">
|
||||
Refresh
|
||||
</Button>
|
||||
@@ -372,9 +313,9 @@ export default function FilesTab({ project }: Props) {
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{/* The one failure that stays inline: it explains why the grid below is
|
||||
empty, it is in context, and there are no rows for it to scroll
|
||||
behind. Every *transient* failure — upload, rename, mkdir,
|
||||
save-to-host — goes to `ToastHost` instead, which is above
|
||||
the file viewer's overlay and does not scroll away. */}
|
||||
behind. Every *transient* failure — rename, new folder — goes to
|
||||
`ToastHost` instead, which is above the file viewer's overlay and
|
||||
does not scroll away. */}
|
||||
{error && (
|
||||
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
|
||||
{error}
|
||||
@@ -560,18 +501,6 @@ export default function FilesTab({ project }: Props) {
|
||||
>
|
||||
Rename
|
||||
</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>
|
||||
@@ -594,34 +523,11 @@ export default function FilesTab({ project }: Props) {
|
||||
)}
|
||||
</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 && (
|
||||
<FileViewerModal
|
||||
projectId={project.id}
|
||||
entry={viewing}
|
||||
onClose={() => setViewing(null)}
|
||||
onSaveToHost={downloadFile}
|
||||
/>
|
||||
)}
|
||||
</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";
|
||||
|
||||
const listContainerFiles = vi.fn();
|
||||
const downloadContainerFile = vi.fn();
|
||||
const uploadFileToContainer = vi.fn();
|
||||
const renameContainerPath = vi.fn();
|
||||
const createContainerDirectory = vi.fn();
|
||||
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
|
||||
uploadFileToContainer: (...args: unknown[]) => uploadFileToContainer(...args),
|
||||
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||
createContainerDirectory(p, parent, n),
|
||||
@@ -35,13 +31,6 @@ const toastText = () =>
|
||||
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
|
||||
.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 => ({
|
||||
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", () => {
|
||||
it("sends the bare new name, never a path, and re-lists on success", async () => {
|
||||
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", () => {
|
||||
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
|
||||
// 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.
|
||||
let failUpload: (reason: unknown) => void = () => {};
|
||||
let failRename: (reason: unknown) => void = () => {};
|
||||
// `Once`, deliberately: `clearAllMocks` clears calls but not
|
||||
// implementations, so a never-settling one would hang every test after it.
|
||||
uploadFileToContainer.mockImplementationOnce(
|
||||
() => new Promise((_resolve, reject) => { failUpload = reject; }),
|
||||
renameContainerPath.mockImplementationOnce(
|
||||
() => new Promise((_resolve, reject) => { failRename = reject; }),
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let upload!: Promise<void>;
|
||||
let rename!: Promise<boolean>;
|
||||
await act(async () => {
|
||||
upload = result.current.uploadPaths(["/host/big.bin"]);
|
||||
rename = result.current.renameEntry(file("big.bin"), "bigger.bin");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
@@ -255,13 +177,13 @@ describe("useFileManager stays where the user is", () => {
|
||||
listContainerFiles.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
failUpload("cp: no space left on device");
|
||||
await upload;
|
||||
failRename("mv: no space left on device");
|
||||
await rename;
|
||||
});
|
||||
|
||||
expect(result.current.currentPath).toBe("/workspace/src");
|
||||
expect(result.current.entries.map((e) => e.name)).toEqual(["index.ts"]);
|
||||
// No re-list of the directory the upload targeted…
|
||||
// No re-list of the directory the rename targeted…
|
||||
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||
// …and no failure text painted over the listing that replaced it.
|
||||
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 () => {
|
||||
renameContainerPath.mockResolvedValue("/workspace/b.txt");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
await result.current.uploadPaths(["/host/a.png"]);
|
||||
await result.current.renameEntry(file("a.txt"), "b.txt");
|
||||
});
|
||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
|
||||
});
|
||||
@@ -316,248 +239,11 @@ describe("useFileManager stays where the user is", () => {
|
||||
await result.current.navigate("/root");
|
||||
});
|
||||
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 result.current.uploadPaths(["/host/a.png"]);
|
||||
await result.current.createFolder("new");
|
||||
});
|
||||
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.png", "/workspace");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager overwrite prompt", () => {
|
||||
const alreadyThere = "FILE_EXISTS: /workspace/a.txt already exists";
|
||||
|
||||
it("asks rather than clobbering, and replaces on demand", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
|
||||
uploadFileToContainer.mockResolvedValueOnce(undefined);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let upload!: Promise<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);
|
||||
expect(createContainerDirectory).toHaveBeenCalledWith("p1", "/workspace", "new");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -567,8 +253,6 @@ describe("useFileManager overwrite prompt closes the loop", () => {
|
||||
* reported that way is a sentence nobody reads.
|
||||
*/
|
||||
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 =
|
||||
"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];
|
||||
|
||||
it("puts the write-root refusal in the headline, not behind Details", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce(outsideRoots);
|
||||
createContainerDirectory.mockRejectedValueOnce(outsideRoots);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadPaths(["/host/a.txt"]);
|
||||
await result.current.createFolder("new");
|
||||
});
|
||||
|
||||
expect(lastToast().message).toBe(outsideRoots);
|
||||
@@ -587,39 +271,24 @@ describe("useFileManager surfaces written refusals as prose", () => {
|
||||
expect(lastToast().message).not.toMatch(/^Error:/);
|
||||
});
|
||||
|
||||
it("says it once for a whole batch that failed the same way", async () => {
|
||||
// The refusal is about the target directory, so every file in the drop
|
||||
// fails identically — three copies of the same sentence is not detail.
|
||||
uploadFileToContainer.mockRejectedValue(outsideRoots);
|
||||
it("unwraps an `Error` rather than stamping \"Error:\" on prose", async () => {
|
||||
renameContainerPath.mockRejectedValueOnce(new Error(outsideRoots));
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
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().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 () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce("no space left on device");
|
||||
createContainerDirectory.mockRejectedValueOnce("no space left on device");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
+20
-235
@@ -1,36 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { FileEntry } from "../lib/types";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { useAppState } from "../store/appState";
|
||||
import {
|
||||
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;
|
||||
}
|
||||
import { errorText, readableRefusal } from "../lib/refusalText";
|
||||
|
||||
/**
|
||||
* ## 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
|
||||
* no rows, and it is not transient — it stands until the directory lists.
|
||||
*
|
||||
* Every **transient operation** failure — upload, rename, create folder,
|
||||
* save-to-host — goes to `ToastHost` instead. Those used to land
|
||||
* in the same inline `error` div, which is the first child of the *scrolling*
|
||||
* list: three hundred rows down, a refused rename produced no visible change
|
||||
* at all, just a rename box that stayed open for no stated reason. Worse, the
|
||||
* file viewer routes its "Save to host…" through the same call, and the viewer
|
||||
* is a `fixed inset-0` portal at `z-50` — so that failure reported *behind* the
|
||||
* dialog that caused it. The toast host is a persistent `aria-live` region at
|
||||
* `z-[60]`, i.e. the one place in the app that is above a modal and does not
|
||||
* scroll away.
|
||||
* Every **transient operation** failure — rename, create folder — goes to
|
||||
* `ToastHost` instead. Those used to land in the same inline `error` div, which
|
||||
* is the first child of the *scrolling* list: three hundred rows down, a
|
||||
* refused rename produced no visible change at all, just a rename box that
|
||||
* stayed open for no stated reason. The toast host is a persistent `aria-live`
|
||||
* region at `z-[60]`, i.e. the one place in the app that is above a modal and
|
||||
* does not scroll away.
|
||||
*
|
||||
* ## Where the current directory lives
|
||||
*
|
||||
* `currentPath` is state (the UI renders it) *and* a ref (async work reads it
|
||||
* after an await). Every long operation captures the directory it targets at
|
||||
* the start and compares it against the ref at the end: a 200 MB upload into
|
||||
* 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
|
||||
* 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
|
||||
@@ -68,14 +37,11 @@ export function useFileManager(projectId: string) {
|
||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Transient "uploading 3 files…" style note, shown beside the breadcrumb. */
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
/**
|
||||
* What just finished. A live region that only ever says "uploading…" tells a
|
||||
* screen reader user when to start waiting and never when to stop.
|
||||
* What just finished, for the live region — a rename or a new folder is a
|
||||
* change a sighted user sees in the grid and a screen reader user does not.
|
||||
*/
|
||||
const [completed, setCompleted] = useState<string | null>(null);
|
||||
const [conflict, setConflict] = useState<UploadConflict | null>(null);
|
||||
|
||||
const currentPathRef = useRef(currentPath);
|
||||
|
||||
@@ -87,45 +53,29 @@ export function useFileManager(projectId: string) {
|
||||
*/
|
||||
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
|
||||
* raw failures behind it.
|
||||
* raw failure behind it.
|
||||
*
|
||||
* The headline is what the *hook* knows ("Could not rename …"); it is a
|
||||
* category, not an explanation. Some backend refusals are already a finished
|
||||
* sentence written for the person reading it — a hidden host folder, a
|
||||
* container path outside the roots this panel may change — and those used to
|
||||
* sentence written for the person reading it — a container path outside the
|
||||
* 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
|
||||
* 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
|
||||
* neither. When every failure reduces to the *same* such sentence — which is
|
||||
* the normal case, since these refusals are about the target directory and so
|
||||
* fail identically for every file in a batch — it becomes the headline and
|
||||
* there is nothing left to hide.
|
||||
* neither. When there is such a sentence it becomes the headline, and there
|
||||
* is nothing left to hide.
|
||||
*/
|
||||
const report = useCallback((message: string, ...causes: unknown[]) => {
|
||||
const refusals = causes.map(readableRefusal);
|
||||
const shared =
|
||||
causes.length > 0 && refusals.every((r) => r !== null)
|
||||
? [...new Set(refusals as string[])]
|
||||
: [];
|
||||
const promoted = shared.length === 1 ? shared[0] : null;
|
||||
const report = useCallback((message: string, cause: unknown) => {
|
||||
const promoted = readableRefusal(cause);
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
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(
|
||||
async (path: string) => {
|
||||
const mine = ++navGeneration.current;
|
||||
@@ -163,164 +113,6 @@ export function useFileManager(projectId: string) {
|
||||
navigate(currentPathRef.current);
|
||||
}, [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
|
||||
* `/` 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,
|
||||
/** Inline, in-context: why the listing on screen is empty. */
|
||||
error,
|
||||
busy,
|
||||
/** What the last operation finished doing, for the live region. */
|
||||
completed,
|
||||
/** An upload waiting for a Replace / Skip answer, or `null`. */
|
||||
conflict,
|
||||
resolveConflict,
|
||||
setError,
|
||||
navigate,
|
||||
goUp,
|
||||
refresh,
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
uploadPaths,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
};
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
*
|
||||
* 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
|
||||
* has a zero-size rect, which is what stops `TerminalView` and `FilesTab`
|
||||
* both claiming the same drop.
|
||||
* has a zero-size rect, which is what stops two panes both claiming the
|
||||
* 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` —
|
||||
* document-wide, no geometry, no z-order. While a modal or a blocking
|
||||
* 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
|
||||
*
|
||||
* 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.
|
||||
* Every attempt to be *precise* about which points a dialog covers has gone
|
||||
* 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
|
||||
export const listContainerFiles = (projectId: string, path: string) =>
|
||||
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) =>
|
||||
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) =>
|
||||
invoke<FileContents>("read_container_file", { projectId, path, maxBytes });
|
||||
/** `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