Compare commits
10
Commits
168b61d632
...
016de8f641
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
016de8f641 | ||
|
|
4d1a5a2417 | ||
|
|
a323047964 | ||
|
|
11216c45e3 | ||
|
|
913aa85805 | ||
|
|
e9902f0564 | ||
|
|
7488fc5b70 | ||
|
|
06ccb4d818 | ||
|
|
9472cb3c4c | ||
|
|
dd23a52b41 |
@@ -79,14 +79,34 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
|
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
|
||||||
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
|
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
|
||||||
modals — see "UI conventions" below.
|
modals — see "UI conventions" below.
|
||||||
- **Files takes drops *in*, and that path does not use HTML5 drag.** Dropping into the pane
|
- **Files is container-side only. It does no host filesystem I/O, and must not grow any.**
|
||||||
is Tauri's native `onDragDropEvent`, which is window-wide and therefore routed by a
|
The tab lists, opens (text and image viewer), renames and creates folders *inside* the
|
||||||
hit-test of the physical-pixel payload position against the pane's rect ÷
|
container: `list_container_files`, `read_container_file`, `rename_container_path`,
|
||||||
`devicePixelRatio` — a hidden pane has a zero-size rect, which is what stops it and
|
`create_container_directory`. There is no upload button, no "Save to host…", and no
|
||||||
`TerminalView`'s listener both firing. Keep `lib/dropTarget.ts` and both listeners.
|
drop-into-the-pane. Four successive audits found that host filesystem paths crossing IPC
|
||||||
- **Getting a file *out* is "Save to host…", and there is no other route.** OS drag-out —
|
were where the criticals lived — a caller-named host destination for container-controlled
|
||||||
`tauri-plugin-drag`, `stage_container_file_for_drag` and its host staging directory — was
|
bytes, an arbitrary host source read into the container, a `link(2)` upload reservation
|
||||||
removed from the ship branch and held back for separate hardening; it lives on
|
that succeeded against a directory and failed forever on any filesystem without hard
|
||||||
|
links — so the feature was narrowed rather than fixed a fifth time. If a host path ever
|
||||||
|
needs to reach this pane again, the honest shape is for the *backend* to drive
|
||||||
|
`tauri-plugin-dialog`, so no host path arrives over IPC at all.
|
||||||
|
- **A file gets *in* by being dropped on the Terminal, and *out* through "Back up
|
||||||
|
container".** Those two are the whole host↔container story, they predate the Files work,
|
||||||
|
and their hardening is not to be weakened. `TerminalView`'s `onDragDropEvent` is Tauri's
|
||||||
|
native drop event (window-wide, so routed by `lib/dropTarget.ts` — geometry for *whose*
|
||||||
|
drop it is, a document-wide `dropIsBlocked` for whether the app should accept one at all;
|
||||||
|
keep both halves and keep `PaneVisibility`). Backup is
|
||||||
|
`file_commands::download_container_backup`.
|
||||||
|
- **`resolve_host_path` applies the full lexical predicate twice — as written, and again
|
||||||
|
after canonicalisation.** That includes the general hidden-component rule, which
|
||||||
|
deliberately over-catches: a path resolving through `node_modules/.pnpm`, `~/.cache` or
|
||||||
|
`~/.local/share` is refused. Do not narrow it back to a list of "credential" directories.
|
||||||
|
That was tried, and allow-by-omission let `~/.local/bin` (write there and you own the
|
||||||
|
user's next shell command), `~/.password-store`, browser profiles and `~/.pki/nssdb`
|
||||||
|
through a planted symlink with a perfectly visible name. The two callers left are
|
||||||
|
occasional, so over-refusing is the cheaper mistake.
|
||||||
|
- **OS drag-out is not here.** `tauri-plugin-drag`, `stage_container_file_for_drag` and its
|
||||||
|
host staging directory were held back for separate hardening and live on
|
||||||
`hold/disk-and-dragout`. Do not re-add `drag:allow-start-drag` or a staging command
|
`hold/disk-and-dragout`. Do not re-add `drag:allow-start-drag` or a staging command
|
||||||
without taking that work back whole: the plugin has no scope mechanism, so the grant lets
|
without taking that work back whole: the plugin has no scope mechanism, so the grant lets
|
||||||
a compromised webview start a drag on *any* host path the user can read, and the staging
|
a compromised webview start a drag on *any* host path the user can read, and the staging
|
||||||
|
|||||||
+61
-21
@@ -228,7 +228,7 @@ buttons. Below that are six tabs:
|
|||||||
| **Sessions** | Past Claude Code conversations stored on this project's config volume, each with a **Resume** button |
|
| **Sessions** | Past Claude Code conversations stored on this project's config volume, each with a **Resume** button |
|
||||||
| **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) |
|
| **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) |
|
||||||
| **Config** | All per-project configuration — see [Project Configuration](#project-configuration) |
|
| **Config** | All per-project configuration — see [Project Configuration](#project-configuration) |
|
||||||
| **Files** | Browse, download and upload files inside the container |
|
| **Files** | Browse, view and rename files inside the container — see [Files](#files) for how files get in and out |
|
||||||
| **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) |
|
| **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) |
|
||||||
|
|
||||||
### Sessions
|
### Sessions
|
||||||
@@ -351,7 +351,7 @@ it. The sidebar row carries only the two hover controls.
|
|||||||
| **Force stop** | Project Home header | Starting / Stopping | Interrupts a transition that is stuck |
|
| **Force stop** | Project Home header | Starting / Stopping | Interrupts a transition that is stuck |
|
||||||
| **Open Claude Terminal** | Project Home header; sidebar hover control; `Ctrl+T` | Running | Opens a new Claude Code terminal tab |
|
| **Open Claude Terminal** | Project Home header; sidebar hover control; `Ctrl+T` | Running | Opens a new Claude Code terminal tab |
|
||||||
| **Shell** | Project Home header | Running | Opens a bash login shell tab in the container (no Claude Code) |
|
| **Shell** | Project Home header | Running | Opens a bash login shell tab in the container (no Claude Code) |
|
||||||
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, download and upload files |
|
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, view and rename files inside the container |
|
||||||
| **Config** | The **Config** tab | Always | Per-project configuration (most fields need the container stopped) |
|
| **Config** | The **Config** tab | Always | Per-project configuration (most fields need the container stopped) |
|
||||||
| **Back up container** | **⋯** overflow menu | A container exists | Saves a `.tar.gz` archive of the container to a location you choose |
|
| **Back up container** | **⋯** overflow menu | A container exists | Saves a `.tar.gz` archive of the container to a location you choose |
|
||||||
| **Reset container…** | **⋯** overflow menu | Stopped or Error | Destroys the container, snapshot image and both volumes, then recreates from the base image (wipes `~/.claude`) — asks first |
|
| **Reset container…** | **⋯** overflow menu | Stopped or Error | Destroys the container, snapshot image and both volumes, then recreates from the base image (wipes `~/.claude`) — asks first |
|
||||||
@@ -615,18 +615,27 @@ The **Claude Code settings** editor, also at the bottom of the Config tab, confi
|
|||||||
|
|
||||||
| Setting | What It Does |
|
| Setting | What It Does |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| **TUI Mode** | Set to **Fullscreen** for flicker-free alt-screen rendering (uses `CLAUDE_CODE_NO_FLICKER=1`) |
|
| **TUI Mode** | **Automatic** lets Claude Code choose; **Classic** pins the main-screen renderer; **Fullscreen** pins the flicker-free alt-screen one |
|
||||||
| **Effort Level** | Controls reasoning depth: **Low** (fast, less thorough), **Medium**, **High** (deep reasoning) |
|
| **Effort Level** | Reasoning depth: **Low**, **Medium**, **High**, **Extra high** |
|
||||||
| **Focus Mode** | Collapses tool output to one-line summaries, showing only the prompt and final response |
|
| **Focus Mode** | Summarises tool *calls* to one line each, showing the last prompt and the final response. **Needs the fullscreen renderer** — set TUI Mode to Fullscreen or this does nothing |
|
||||||
| **Thinking Summaries** | Shows Claude's thinking process as summaries during responses |
|
| **Thinking Summaries** | Shows Claude's thinking as summaries rather than a collapsed stub |
|
||||||
| **Session Recap** | Provides context when returning to a session after being away |
|
| **Session Recap** | A one-line recap when you return to the terminal after a few minutes away. **On by default** — the switch is how you turn it off |
|
||||||
| **Auto-Scroll Disabled** | Disables auto-scroll when in fullscreen TUI mode |
|
| **Auto-Scroll** | Follows new output to the bottom in fullscreen rendering. On by default |
|
||||||
| **Env Scrub** | Strips credentials from subprocess environments for security |
|
| **Env Scrub** | Strips credentials from subprocess environments for security |
|
||||||
| **Prompt Caching (1h)** | Enables 1-hour prompt cache TTL instead of the default 5 minutes |
|
| **Prompt Caching (1h)** | Requests a 1-hour prompt cache TTL instead of the default 5 minutes |
|
||||||
|
|
||||||
Per-project settings override global defaults set in Settings. If all settings are at their defaults, no configuration is injected.
|
Each switch has three states on a project: **Global** (follow Settings), **On**, and **Off**. Off is a
|
||||||
|
real choice — it overrides a global On, which a project could not previously do.
|
||||||
|
|
||||||
> These settings map to Claude Code environment variables and `~/.claude/settings.json` entries. Changes require stopping and restarting the container to take effect.
|
> These map to Claude Code environment variables and `~/.claude/settings.json` keys, and are applied
|
||||||
|
> when the container starts. Changing one stops and recreates the container.
|
||||||
|
>
|
||||||
|
> **Two caveats on an existing project.** Changing any of these recreates the container, and a
|
||||||
|
> recreation commits a new image layer — so flipping switches repeatedly costs disk. And
|
||||||
|
> **TUI Mode, Effort Level, Focus Mode and Session Recap cannot be returned to Global** until the
|
||||||
|
> project's base image is updated: those four are cleared by *removing* a key, and an older image's
|
||||||
|
> startup script ignores the instruction to remove it. Update the base image from the project's
|
||||||
|
> Overview tab first. The other switches work on any image.
|
||||||
|
|
||||||
### MCP Servers
|
### MCP Servers
|
||||||
|
|
||||||
@@ -1161,16 +1170,43 @@ When you scroll up in the terminal to review previous output, a **Jump to Curren
|
|||||||
|
|
||||||
### Files
|
### Files
|
||||||
|
|
||||||
The **Files** tab of Project Home browses inside a running container. You can:
|
The **Files** tab of Project Home browses inside a running container. It works entirely on the
|
||||||
|
container side — it never reads or writes anything on your own machine. You can:
|
||||||
|
|
||||||
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation
|
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation.
|
||||||
- **Save to host…** — copy any file out to a location you pick. This is the way to get a file out
|
Double-click a folder to open it, or the `..` row to go up; the arrow keys, Home and End move
|
||||||
of a container; there is one button per file entry, and the file viewer offers it too
|
between rows and Enter opens the selected one
|
||||||
- **Upload file** from your host into the current container directory — or **drop files straight
|
- **View** a file — double-click it, or press Enter. Text files and images render in a read-only
|
||||||
onto the pane** from your desktop, which uploads them into the directory on screen
|
viewer
|
||||||
|
- **Rename** an entry, from the row's Rename button or by pressing `F2`. A rename never moves a
|
||||||
|
file between folders
|
||||||
|
- **New folder** in the directory on screen
|
||||||
- **Refresh** the directory listing at any time
|
- **Refresh** the directory listing at any time
|
||||||
|
|
||||||
The listing shows file names, sizes, and modification dates.
|
The listing shows file names, sizes, and modification dates, and marks symbolic links.
|
||||||
|
|
||||||
|
#### Getting files in and out
|
||||||
|
|
||||||
|
The Files tab deliberately does **not** copy files between your computer and the container. There
|
||||||
|
are two supported routes, and they are the ones to use:
|
||||||
|
|
||||||
|
- **To get a file in:** drag it from your desktop and **drop it onto the Terminal tab**. The file
|
||||||
|
is copied into the container and its path is typed into the terminal for you, ready to hand to
|
||||||
|
Claude Code. (You can also drop it onto the terminal's *Following* toggle — the whole pane is a
|
||||||
|
drop target.)
|
||||||
|
- **To get files out:** use **Back up container** in Project Home's **⋯** overflow menu. It writes
|
||||||
|
a `.tar.gz` of the workspace and the container's `~/.claude` config to a location you choose.
|
||||||
|
For a single file, `cat` it in a terminal, or work in a project folder that is mounted from your
|
||||||
|
host in the first place — those files are already on both sides.
|
||||||
|
|
||||||
|
Both routes refuse a destination whose path passes through a hidden folder — anything with a
|
||||||
|
component beginning with `.`, such as `~/.ssh`, `~/.local/bin` or `~/.config`. That rule catches
|
||||||
|
more than it strictly needs to (a path that happens to resolve through `~/.cache` or
|
||||||
|
`node_modules/.pnpm` is refused as well), and the refusal says which folder tripped it. Choose a
|
||||||
|
visible location such as `~/Documents` or `~/Downloads`.
|
||||||
|
|
||||||
|
If you already keep the project in a folder mounted into the container, the simplest answer is
|
||||||
|
usually neither of the above: edit the file on your host and it is already inside.
|
||||||
|
|
||||||
### Terminal Rendering
|
### Terminal Rendering
|
||||||
|
|
||||||
@@ -1216,10 +1252,14 @@ change. Remember that a headless run cannot answer a permission prompt, so in an
|
|||||||
**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode
|
**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode
|
||||||
that was used.
|
that was used.
|
||||||
|
|
||||||
### Creating Tasks (In the Container)
|
### Creating Tasks
|
||||||
|
|
||||||
There is no "add task" form in the app. Create tasks from a terminal in the container — either type
|
The quickest route is the **New task** button on a project's **Automation** tab, which gives you a
|
||||||
the commands yourself in a **Shell** session, or just ask Claude to do it.
|
form for the name, the schedule and the prompt.
|
||||||
|
|
||||||
|
You can also create tasks from a terminal in the container — type the commands yourself in a
|
||||||
|
**Shell** session, or just ask Claude to do it. That is the better route when you want Claude to
|
||||||
|
work out the schedule or the prompt for you, and it is what the rest of this section covers.
|
||||||
|
|
||||||
### Create a Recurring Task
|
### Create a Recurring Task
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ configuration. Per-project configuration lives in the Config tab rather than in
|
|||||||
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
|
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
|
||||||
| **Automation** | The container's `triple-c-scheduler` tasks — create, edit, enable/disable, run now, read logs, remove, and completion notifications |
|
| **Automation** | The container's `triple-c-scheduler` tasks — create, edit, enable/disable, run now, read logs, remove, and completion notifications |
|
||||||
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
|
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
|
||||||
| **Files** | Browse, download and upload files inside the container |
|
| **Files** | Browse, view and rename files inside the container. Container-side only: to get a file *in*, drop it on the Terminal tab; to get files *out*, use **Back up container** |
|
||||||
| **Browser** | Watch and take over the Playwright browser inside the container — see [Browser View](#browser-view) |
|
| **Browser** | Watch and take over the Playwright browser inside the container — see [Browser View](#browser-view) |
|
||||||
|
|
||||||
Container start/stop progress is reported inline (on the sidebar row and in the Project Home
|
Container start/stop progress is reported inline (on the sidebar row and in the Project Home
|
||||||
@@ -513,7 +513,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
|||||||
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: create, toggle, run now, logs, remove, notifications |
|
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: create, toggle, run now, logs, remove, notifications |
|
||||||
| `app/src/components/projects/home/TaskEditorModal.tsx` | Create/edit a scheduled task; `taskValidation.ts` holds the cron and schedule rules |
|
| `app/src/components/projects/home/TaskEditorModal.tsx` | Create/edit a scheduled task; `taskValidation.ts` holds the cron and schedule rules |
|
||||||
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
|
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
|
||||||
| `app/src/components/projects/home/FilesTab.tsx` | File browser (browse, download, upload) |
|
| `app/src/components/projects/home/FilesTab.tsx` | Container-side file browser (navigate, view, rename, new folder) |
|
||||||
| `app/src/components/projects/home/BrowserTab.tsx` | Browser view pane: detect, install, watch, take over, pop out |
|
| `app/src/components/projects/home/BrowserTab.tsx` | Browser view pane: detect, install, watch, take over, pop out |
|
||||||
| `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport |
|
| `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport |
|
||||||
| `app/src/components/projects/home/ContainerMigrationBanner.tsx` | Base-image staleness banner, migration progress, resume/rollback |
|
| `app/src/components/projects/home/ContainerMigrationBanner.tsx` | Base-image staleness banner, migration progress, resume/rollback |
|
||||||
@@ -536,7 +536,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
|||||||
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
||||||
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
|
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
|
||||||
| `app/src/hooks/useContainerMigration.ts` | Staleness polling, migration run, resume and rollback |
|
| `app/src/hooks/useContainerMigration.ts` | Staleness polling, migration run, resume and rollback |
|
||||||
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
|
| `app/src/hooks/useFileManager.ts` | File browser operations (list, navigate, rename, mkdir) |
|
||||||
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
|
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
|
||||||
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
|
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
|
||||||
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
|
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
|
||||||
@@ -547,7 +547,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
|||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` |
|
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` |
|
||||||
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; file upload/download via tar |
|
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; one-shot execs and single-file tar building |
|
||||||
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
||||||
| `app/src-tauri/src/docker/migration.rs` | Base-image migration: manifest capture, delta computation, crash-recovery state machine |
|
| `app/src-tauri/src/docker/migration.rs` | Base-image migration: manifest capture, delta computation, crash-recovery state machine |
|
||||||
| `app/src-tauri/src/docker/ca_certs.rs` | CA certificate discovery, `.crt` renaming, fingerprinting |
|
| `app/src-tauri/src/docker/ca_certs.rs` | CA certificate discovery, `.crt` renaming, fingerprinting |
|
||||||
@@ -561,7 +561,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
|||||||
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
|
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
|
||||||
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
|
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
|
||||||
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
|
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
|
||||||
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
|
| `app/src-tauri/src/commands/file_commands.rs` | Container-side file commands (list, read, rename, mkdir) plus `download_container_backup` |
|
||||||
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||||
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
||||||
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, browser view, CA path, shared-token opt-out) |
|
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, browser view, CA path, shared-token opt-out) |
|
||||||
|
|||||||
+1
-1
@@ -504,7 +504,7 @@ triple-c/
|
|||||||
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
||||||
│ ├── aws_commands.rs # AWS profile/region discovery
|
│ ├── aws_commands.rs # AWS profile/region discovery
|
||||||
│ ├── docker_commands.rs # Docker status, image ops
|
│ ├── docker_commands.rs # Docker status, image ops
|
||||||
│ ├── file_commands.rs # File browser (list/download/upload)
|
│ ├── file_commands.rs # File browser (browse, view, rename)
|
||||||
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
|
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
|
||||||
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
||||||
│ ├── install_helper_commands.rs # Guided Docker installation
|
│ ├── install_helper_commands.rs # Guided Docker installation
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`), the plugin is no longer a dependency, and getting a file out of a container is now the explicit \"Save to host…\" action, which never touches this permission. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.",
|
"description": "Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is now \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API and never touches this permission; the Files tab is browse, view and rename only. An earlier version of this sentence pointed at a \"Save to host…\" action, which was removed in the same round that removed drag-out — this file is the reviewed threat model of record, so a stale reference here is worse than none. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:event:allow-listen",
|
"core:event:allow-listen",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -37,20 +37,3 @@ pub async fn get_container_info(
|
|||||||
docker::get_container_info(&project).await
|
docker::get_container_info(&project).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn list_sibling_containers() -> Result<Vec<serde_json::Value>, String> {
|
|
||||||
let containers = docker::list_sibling_containers().await?;
|
|
||||||
let result: Vec<serde_json::Value> = containers
|
|
||||||
.into_iter()
|
|
||||||
.map(|c| {
|
|
||||||
serde_json::json!({
|
|
||||||
"id": c.id,
|
|
||||||
"names": c.names,
|
|
||||||
"image": c.image,
|
|
||||||
"state": c.state,
|
|
||||||
"status": c.status,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1071,21 +1071,54 @@ fn reconcile_retries() -> &'static std::sync::Mutex<std::collections::HashSet<St
|
|||||||
RETRIES.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
|
RETRIES.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One project's place in [`reconcile_retries`], handed back on drop.
|
||||||
|
///
|
||||||
|
/// RAII for the reason [`crate::project_lock::ProjectGuard`] sets out, and this
|
||||||
|
/// claim is the case that proves the rule: the release used to be a trailing
|
||||||
|
/// statement at the bottom of the spawned task in
|
||||||
|
/// [`defer_migration_reconcile`], sitting after an `.await` on
|
||||||
|
/// [`reconcile_migration_now`]. A panic in there — or the future simply being
|
||||||
|
/// dropped, which is what happens to every in-flight task at shutdown — skips
|
||||||
|
/// the statement, and nothing else ever removes an id from that set. The
|
||||||
|
/// project is then fenced off from *every* later deferral for the rest of the
|
||||||
|
/// process: each `reconcile_project_statuses` pass finds it held, fails to
|
||||||
|
/// claim, and returns, so the phase stays un-normalised, no resume or rollback
|
||||||
|
/// is offered, and the `:pre-migration-*` pin stays `Claimed`. That is the
|
||||||
|
/// session-long silence deferring was written to end, reintroduced one panic
|
||||||
|
/// later and lasting until the app is restarted.
|
||||||
|
///
|
||||||
|
/// Dropping this hands the claim straight back, so a caller that discards the
|
||||||
|
/// value has claimed nothing while reading as though it had; `#[must_use]`
|
||||||
|
/// makes that a compile warning rather than a second waiter on one record.
|
||||||
|
#[must_use = "the claim is handed back the moment this guard drops; bind it inside the waiting task, for the whole task"]
|
||||||
|
struct ReconcileRetryClaim {
|
||||||
|
project_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ReconcileRetryClaim {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// `into_inner` past poisoning, as in `project_lock`: the only thing
|
||||||
|
// ever done while holding this mutex is a single `HashSet` insert or
|
||||||
|
// remove, so a panic on another thread cannot have left it half
|
||||||
|
// written — and declining to release here would strand the project
|
||||||
|
// permanently, which is the exact failure the guard exists to stop.
|
||||||
|
reconcile_retries()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.remove(&self.project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Claim the right to be the one deferred reconcile for `project_id`.
|
/// Claim the right to be the one deferred reconcile for `project_id`.
|
||||||
/// `false` means somebody else already is.
|
/// `None` means somebody else already is.
|
||||||
fn claim_reconcile_retry(project_id: &str) -> bool {
|
fn claim_reconcile_retry(project_id: &str) -> Option<ReconcileRetryClaim> {
|
||||||
reconcile_retries()
|
reconcile_retries()
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
.insert(project_id.to_string())
|
.insert(project_id.to_string())
|
||||||
}
|
.then(|| ReconcileRetryClaim {
|
||||||
|
project_id: project_id.to_string(),
|
||||||
/// Give the claim back, so a later `reconcile_project_statuses` can defer again.
|
})
|
||||||
fn release_reconcile_retry(project_id: &str) {
|
|
||||||
reconcile_retries()
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
|
||||||
.remove(project_id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Come back to a project that was held when [`reconcile_migration`] reached it.
|
/// Come back to a project that was held when [`reconcile_migration`] reached it.
|
||||||
@@ -1108,13 +1141,19 @@ fn defer_migration_reconcile(project: &Project, app_handle: &tauri::AppHandle) {
|
|||||||
if !migration_store::has_record(&project.id).unwrap_or(true) {
|
if !migration_store::has_record(&project.id).unwrap_or(true) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !claim_reconcile_retry(&project.id) {
|
let Some(claim) = claim_reconcile_retry(&project.id) else {
|
||||||
return;
|
return;
|
||||||
}
|
};
|
||||||
|
|
||||||
let project = project.clone();
|
let project = project.clone();
|
||||||
let app_handle = app_handle.clone();
|
let app_handle = app_handle.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
|
// Moved in and bound for the whole body, rather than released by a
|
||||||
|
// statement at the bottom: everything below this line can panic or be
|
||||||
|
// dropped mid-await, and a claim that only comes back on the happy path
|
||||||
|
// is a claim that eventually does not come back at all. See
|
||||||
|
// [`ReconcileRetryClaim`].
|
||||||
|
let _claim = claim;
|
||||||
let released =
|
let released =
|
||||||
await_release(&project.id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await;
|
await_release(&project.id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await;
|
||||||
if released {
|
if released {
|
||||||
@@ -1133,28 +1172,42 @@ fn defer_migration_reconcile(project: &Project, app_handle: &tauri::AppHandle) {
|
|||||||
RECONCILE_RETRY_INTERVAL.as_secs() as usize * RECONCILE_RETRY_ATTEMPTS / 60
|
RECONCILE_RETRY_INTERVAL.as_secs() as usize * RECONCILE_RETRY_ATTEMPTS / 60
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
release_reconcile_retry(&project.id);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wait for `project_id` to stop being held, up to `attempts` looks
|
/// Wait for `project_id` to stop being held: `attempts` looks, the first
|
||||||
/// `interval` apart. `true` means it was released, `false` that the budget ran
|
/// immediate and the rest `interval` apart. `true` means it was released,
|
||||||
/// out with it still held.
|
/// `false` that the budget ran out with it still held.
|
||||||
///
|
///
|
||||||
/// Split out of [`defer_migration_reconcile`] so the waiting can be tested
|
/// Split out of [`defer_migration_reconcile`] so the waiting can be tested
|
||||||
/// against a real [`crate::project_lock`] guard on a paused clock — the part
|
/// against a real [`crate::project_lock`] guard on a paused clock — the parts
|
||||||
/// that is easy to get wrong is "gives up while still holding the claim" and
|
/// that are easy to get wrong are "gives up while still holding the claim",
|
||||||
/// "never looks again", neither of which is visible from the constants.
|
/// "never looks again", and the ordering of the look against the sleep, none of
|
||||||
|
/// which is visible from the constants.
|
||||||
async fn await_release(
|
async fn await_release(
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
interval: std::time::Duration,
|
interval: std::time::Duration,
|
||||||
attempts: usize,
|
attempts: usize,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
for _ in 0..attempts {
|
for attempt in 0..attempts {
|
||||||
tokio::time::sleep(interval).await;
|
// Look first, sleep second. Sleeping first charged every deferral a
|
||||||
|
// full interval before anyone read the map even once, and the common
|
||||||
|
// case is a holder that has already let go: `held()` is sampled in
|
||||||
|
// `reconcile_migration`, a task is spawned, and by the time it is first
|
||||||
|
// polled the Reset that was on its last step is frequently finished.
|
||||||
|
// That bought nothing and cost twenty seconds of a startup pass waiting
|
||||||
|
// on a lock nobody holds, in front of a check that is one `HashMap`
|
||||||
|
// lookup.
|
||||||
if crate::project_lock::held(project_id).is_none() {
|
if crate::project_lock::held(project_id).is_none() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// And no sleep after the final look: nothing reads the map again
|
||||||
|
// afterwards, so it is twenty seconds of delay in front of a `false`
|
||||||
|
// that has already been decided. The budget is still `attempts` looks,
|
||||||
|
// which is what the constants above are chosen against.
|
||||||
|
if attempt + 1 < attempts {
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -2217,6 +2270,34 @@ mod tests {
|
|||||||
assert!(budget >= std::time::Duration::from_secs(15 * 60), "{:?}", budget);
|
assert!(budget >= std::time::Duration::from_secs(15 * 60), "{:?}", budget);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// MEDIUM: an unheld project is reconciled now, not in twenty seconds.
|
||||||
|
///
|
||||||
|
/// The wait slept before its first look, so a holder that let go between
|
||||||
|
/// `reconcile_migration` sampling `held()` and this task being polled — the
|
||||||
|
/// *common* case, since a deferral is only taken when something was on its
|
||||||
|
/// way out — still cost a full `RECONCILE_RETRY_INTERVAL` of a startup pass
|
||||||
|
/// waiting on a lock nobody held. On a paused clock the assertion is exact:
|
||||||
|
/// the fixed shape returns without the clock moving at all, the sleep-first
|
||||||
|
/// shape cannot return before it has advanced one interval.
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn an_unheld_project_is_seen_without_waiting_out_an_interval() {
|
||||||
|
let id = format!("await-release-{}", uuid::Uuid::new_v4().simple());
|
||||||
|
assert!(
|
||||||
|
crate::project_lock::held(&id).is_none(),
|
||||||
|
"a fresh uuid is not held"
|
||||||
|
);
|
||||||
|
|
||||||
|
let before = tokio::time::Instant::now();
|
||||||
|
assert!(await_release(&id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await);
|
||||||
|
let waited = tokio::time::Instant::now() - before;
|
||||||
|
assert_eq!(
|
||||||
|
waited,
|
||||||
|
std::time::Duration::ZERO,
|
||||||
|
"an already-released project cost {:?} before anyone looked",
|
||||||
|
waited
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_one_deferred_reconcile_waits_per_project() {
|
fn only_one_deferred_reconcile_waits_per_project() {
|
||||||
// Every "Docker became available" walks every project, so without the
|
// Every "Docker became available" walks every project, so without the
|
||||||
@@ -2224,14 +2305,72 @@ mod tests {
|
|||||||
// per call — all of which then reconcile the same record in a row.
|
// per call — all of which then reconcile the same record in a row.
|
||||||
let id = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
|
let id = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
|
||||||
let other = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
|
let other = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
|
||||||
assert!(claim_reconcile_retry(&id));
|
let first = claim_reconcile_retry(&id).expect("a fresh project id is unclaimed");
|
||||||
assert!(!claim_reconcile_retry(&id), "a second waiter was allowed in");
|
assert!(
|
||||||
assert!(claim_reconcile_retry(&other), "the claim is not per-project");
|
claim_reconcile_retry(&id).is_none(),
|
||||||
release_reconcile_retry(&id);
|
"a second waiter was allowed in"
|
||||||
assert!(claim_reconcile_retry(&id), "the claim was never handed back");
|
);
|
||||||
release_reconcile_retry(&id);
|
let other_claim =
|
||||||
release_reconcile_retry(&other);
|
claim_reconcile_retry(&other).expect("the claim is not per-project");
|
||||||
// Releasing something that was never claimed is not an error.
|
drop(first);
|
||||||
release_reconcile_retry(&id);
|
// Bound rather than discarded: the guard releases on drop, so
|
||||||
|
// `claim_reconcile_retry(&id);` as a bare statement would test nothing
|
||||||
|
// — which is what `#[must_use]` is there to catch in real callers.
|
||||||
|
let retaken = claim_reconcile_retry(&id).expect("the claim was never handed back");
|
||||||
|
drop(retaken);
|
||||||
|
drop(other_claim);
|
||||||
|
// And the other project's claim was never the same claim.
|
||||||
|
drop(claim_reconcile_retry(&other).expect("released independently"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MEDIUM: the claim survives the task that holds it dying badly.
|
||||||
|
///
|
||||||
|
/// The release used to be a trailing statement after
|
||||||
|
/// `reconcile_migration_now(...).await` at the bottom of the spawned task,
|
||||||
|
/// so a panic anywhere in that call — or the future being dropped at
|
||||||
|
/// shutdown — skipped it and left the id in the set with no task behind it.
|
||||||
|
/// Nothing removes it afterwards, so that project could never be deferred
|
||||||
|
/// again for the rest of the process: exactly the state deferring was added
|
||||||
|
/// to prevent, now permanent instead of one pass long. Fails against the
|
||||||
|
/// trailing-statement shape, which is the point.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_panicking_deferred_reconcile_hands_its_claim_back() {
|
||||||
|
let id = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
|
||||||
|
let claimed = claim_reconcile_retry(&id).expect("a fresh project id is unclaimed");
|
||||||
|
|
||||||
|
// Spawned, not just called: the real claim is held across an await
|
||||||
|
// inside a `tauri::async_runtime::spawn`, and a task panic is caught by
|
||||||
|
// the runtime rather than unwinding the caller.
|
||||||
|
let task = {
|
||||||
|
let id = id.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _claim = claimed;
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
panic!("reconcile_migration_now blew up on '{}'", id);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
assert!(task.await.is_err(), "the task was supposed to panic");
|
||||||
|
|
||||||
|
let after = claim_reconcile_retry(&id);
|
||||||
|
assert!(
|
||||||
|
after.is_some(),
|
||||||
|
"a panicking reconcile stranded the claim — this project can never be \
|
||||||
|
deferred again for the rest of the process"
|
||||||
|
);
|
||||||
|
drop(after);
|
||||||
|
|
||||||
|
// The other half of the same failure: a task that is simply dropped
|
||||||
|
// mid-flight, which is every in-flight task at shutdown.
|
||||||
|
let claimed = claim_reconcile_retry(&id).expect("released above");
|
||||||
|
let never_finishes = tokio::spawn(async move {
|
||||||
|
let _claim = claimed;
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
});
|
||||||
|
never_finishes.abort();
|
||||||
|
let _ = never_finishes.await;
|
||||||
|
assert!(
|
||||||
|
claim_reconcile_retry(&id).is_some(),
|
||||||
|
"a dropped task stranded the claim"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,7 +195,9 @@ pub(crate) fn load_secrets_for_project(project: &mut Project) {
|
|||||||
/// entire host filesystem read-write into a container whose agent has
|
/// entire host filesystem read-write into a container whose agent has
|
||||||
/// passwordless sudo. Anything short of a filesystem root is the user choosing
|
/// passwordless sudo. Anything short of a filesystem root is the user choosing
|
||||||
/// a folder — the Browse button and the free-text field lead to the same place
|
/// a folder — the Browse button and the free-text field lead to the same place
|
||||||
/// — so only the roots themselves are refused.
|
/// — so only the roots themselves are refused, and *root* is answered by
|
||||||
|
/// resolving the path rather than by reading it: `/..` is spelled like a folder
|
||||||
|
/// and is the root. See [`classify_mount_source`].
|
||||||
///
|
///
|
||||||
/// This is the **whole** rule set, and it belongs to `add_project`, where every
|
/// This is the **whole** rule set, and it belongs to `add_project`, where every
|
||||||
/// row is new by definition. `update_project` runs
|
/// row is new by definition. `update_project` runs
|
||||||
@@ -228,18 +230,32 @@ fn validate_one_path(p: &ProjectPath) -> Result<(), String> {
|
|||||||
return Err(format!("Mount name '{}' contains invalid characters. Use alphanumeric, dash, underscore, or dot.", p.mount_name));
|
return Err(format!("Mount name '{}' contains invalid characters. Use alphanumeric, dash, underscore, or dot.", p.mount_name));
|
||||||
}
|
}
|
||||||
check_mount_name_stays_under_workspace(&p.mount_name)?;
|
check_mount_name_stays_under_workspace(&p.mount_name)?;
|
||||||
if p.host_path.is_empty() {
|
// Trimmed: a host path of spaces is not a folder, and `classify_mount_source`
|
||||||
|
// is deliberately silent about a path with nothing in it — this is the
|
||||||
|
// message that names the mount it belongs to.
|
||||||
|
if p.host_path.trim().is_empty() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Folder mounted at '/workspace/{}' has no host path.",
|
"Folder mounted at '/workspace/{}' has no host path.",
|
||||||
p.mount_name
|
p.mount_name
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if is_filesystem_root(&p.host_path) {
|
match classify_mount_source(&p.host_path) {
|
||||||
|
None => {}
|
||||||
|
Some(UnmountableHostPath::FilesystemRoot { resolved }) => {
|
||||||
|
return Err(filesystem_root_message(
|
||||||
|
&p.host_path,
|
||||||
|
&resolved,
|
||||||
|
"using it as a project folder",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(UnmountableHostPath::NotAbsolute) => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"'{}' is a filesystem root. Choose the project folder itself — mounting the whole drive gives the container everything on it.",
|
"'{}' is not a full path to a folder — where it lands is decided by wherever \
|
||||||
|
Triple-C is running from rather than by you. Give the whole path.",
|
||||||
p.host_path
|
p.host_path
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +280,23 @@ fn check_mount_name_stays_under_workspace(mount_name: &str) -> Result<(), String
|
|||||||
mount_name
|
mount_name
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// **An empty name is not refused here, and that is a live residual.** It
|
||||||
|
// makes the target `/workspace/`, which the daemon normalises to
|
||||||
|
// `/workspace` — so the host folder shadows the directory the other mounts
|
||||||
|
// land in, and Docker then creates their mount points *inside it*, on the
|
||||||
|
// host. It is not refused because it cannot be: an empty name is what a
|
||||||
|
// half-filled row holds, `legacy_rows` shows those are already in
|
||||||
|
// `projects.json`, and this function runs on grandfathered rows too, so
|
||||||
|
// refusing it would make every such project unsavable — the exact
|
||||||
|
// regression [`validate_project_paths_update`] exists to prevent.
|
||||||
|
//
|
||||||
|
// Introducing one is closed at both ends: `validate_one_path` refuses an
|
||||||
|
// empty name on any new or edited row, and `WorkspaceSection` no longer
|
||||||
|
// sends half-filled or blank ones. What remains is the *stored* row, and it
|
||||||
|
// belongs where the mount is built — `docker::create_container` should skip
|
||||||
|
// a row with no mount name or no host path, which is the same filter that
|
||||||
|
// stops a stored blank row failing the create with
|
||||||
|
// `field Source must not be empty`.
|
||||||
if !mount_name.is_empty() && mount_name.chars().all(|c| c == '.') {
|
if !mount_name.is_empty() && mount_name.chars().all(|c| c == '.') {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Mount name '{}' is not a folder name — it names the directory the mount would sit in.",
|
"Mount name '{}' is not a folder name — it names the directory the mount would sit in.",
|
||||||
@@ -363,12 +396,13 @@ fn validate_project_paths_update(
|
|||||||
/// `/tmp/.host-ssh` and `/tmp/.host-ca` — and neither had any check at all, so
|
/// `/tmp/.host-ssh` and `/tmp/.host-ca` — and neither had any check at all, so
|
||||||
/// `/` handed the whole host filesystem to the agent to read. Read-only, so
|
/// `/` handed the whole host filesystem to the agent to read. Read-only, so
|
||||||
/// this is disclosure rather than the read-write hole a `/` project folder is,
|
/// this is disclosure rather than the read-write hole a `/` project folder is,
|
||||||
/// but the fix is the same one line.
|
/// but it is the same check: [`classify_mount_source`], resolved rather than
|
||||||
|
/// spelled, so `/..` and `/home/..` are refused here too.
|
||||||
///
|
///
|
||||||
/// Same grandfathering as the folder list, for the same reason: a value already
|
/// Same grandfathering as the folder list, for the same reason: a value already
|
||||||
/// stored is already mounted on every start, and refusing an unrelated save
|
/// stored is already mounted on every start, and refusing an unrelated save
|
||||||
/// does not unmount it. Only a *change* is held to the rule.
|
/// does not unmount it. Only a *change* is held to the rule.
|
||||||
fn validate_mounted_host_path(
|
pub(crate) fn validate_mounted_host_path(
|
||||||
label: &str,
|
label: &str,
|
||||||
stored: Option<&str>,
|
stored: Option<&str>,
|
||||||
incoming: Option<&str>,
|
incoming: Option<&str>,
|
||||||
@@ -379,29 +413,255 @@ fn validate_mounted_host_path(
|
|||||||
if stored.map(str::trim) == Some(value) {
|
if stored.map(str::trim) == Some(value) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if is_filesystem_root(value) {
|
match classify_mount_source(value) {
|
||||||
|
None => {}
|
||||||
|
Some(UnmountableHostPath::FilesystemRoot { resolved }) => {
|
||||||
|
return Err(filesystem_root_message(
|
||||||
|
value,
|
||||||
|
&resolved,
|
||||||
|
&format!("setting it as {}", label),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(UnmountableHostPath::NotAbsolute) => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"'{}' is a filesystem root, so setting it as {} would mount the whole drive into the \
|
"'{}' is not a full path, so it cannot be used as {}. Give the whole path.",
|
||||||
container. Choose the folder itself.",
|
|
||||||
value, label
|
value, label
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a host path is the root of a filesystem, in any spelling the three
|
/// Why a host path may not be used as the source of a bind mount.
|
||||||
/// desktop platforms produce: `/`, a Windows drive root, or a bare UNC/share
|
///
|
||||||
/// prefix. Trailing separators are ignored, so `C:\\` and `C:/` are the same
|
/// Two answers rather than a `bool` because they need different sentences, and
|
||||||
/// answer.
|
/// because "is a root" is no longer a question about how the path is *spelled*
|
||||||
fn is_filesystem_root(host_path: &str) -> bool {
|
/// — the refusal has to be able to say where the path actually landed.
|
||||||
let trimmed = host_path.trim_end_matches(['/', '\\']);
|
#[derive(Debug, PartialEq)]
|
||||||
if trimmed.is_empty() {
|
enum UnmountableHostPath {
|
||||||
// Nothing but separators: `/`, `\\`, `//`.
|
/// The path is, or resolves to, the root of a filesystem. `resolved` is
|
||||||
return true;
|
/// what it lands on, which is the same string only when a root was typed
|
||||||
|
/// outright.
|
||||||
|
FilesystemRoot { resolved: String },
|
||||||
|
/// The path does not name a location at all. Where it lands is decided by
|
||||||
|
/// whatever directory Triple-C happens to be running from, so it can be a
|
||||||
|
/// root tomorrow and a folder today, and nothing here can judge it.
|
||||||
|
NotAbsolute,
|
||||||
}
|
}
|
||||||
// `C:` — a drive with no path on it.
|
|
||||||
let bytes = trimmed.as_bytes();
|
/// Length of a `C:` drive prefix at the head of `path`, or 0.
|
||||||
bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
|
///
|
||||||
|
/// Duplicated from `commands::file_commands::drive_prefix_len`, together with
|
||||||
|
/// [`is_windows_style_path`] and [`normalize_host_path`] below. Those are
|
||||||
|
/// private to that module and it is not this branch's file to change; if the
|
||||||
|
/// two copies are ever merged, that one is the original and carries the wider
|
||||||
|
/// test coverage.
|
||||||
|
fn drive_prefix_len(path: &str) -> usize {
|
||||||
|
let b = path.as_bytes();
|
||||||
|
if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `path` is written in Windows form, and so whether `\` separates its
|
||||||
|
/// components. On Linux a backslash is an ordinary filename character, which is
|
||||||
|
/// why this is a question rather than an unconditional substitution.
|
||||||
|
///
|
||||||
|
/// Copy of `file_commands::is_windows_style_path` — see [`drive_prefix_len`].
|
||||||
|
fn is_windows_style_path(path: &str) -> bool {
|
||||||
|
cfg!(windows) || path.starts_with("\\\\") || drive_prefix_len(path) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `path` with its separators unified and any Win32 verbatim/device prefix
|
||||||
|
/// removed — the form every rule below is expressed against.
|
||||||
|
///
|
||||||
|
/// `\\?\C:\Windows` and `\\?\UNC\server\share` name the *same locations* as
|
||||||
|
/// `C:\Windows` and `\\server\share`; the prefix only turns off Win32 path
|
||||||
|
/// parsing. Stripping it is what stops four characters being a bypass — and it
|
||||||
|
/// has to run on our own output as well, because `std::fs::canonicalize` hands
|
||||||
|
/// back exactly that spelling on Windows.
|
||||||
|
///
|
||||||
|
/// Copy of `file_commands::normalize_host_path` — see [`drive_prefix_len`].
|
||||||
|
fn normalize_host_path(path: &str) -> String {
|
||||||
|
let mut s = if is_windows_style_path(path) {
|
||||||
|
path.replace('\\', "/")
|
||||||
|
} else {
|
||||||
|
path.to_string()
|
||||||
|
};
|
||||||
|
// Slicing by byte index is safe here only because a prefix matched
|
||||||
|
// case-insensitively as ASCII is ASCII, so its end is a char boundary.
|
||||||
|
for prefix in ["//?/unc/", "//./unc/"] {
|
||||||
|
if s.len() >= prefix.len()
|
||||||
|
&& s.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
|
||||||
|
{
|
||||||
|
return format!("//{}", &s[prefix.len()..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for prefix in ["//?/", "//./"] {
|
||||||
|
if s.len() >= prefix.len()
|
||||||
|
&& s.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
|
||||||
|
{
|
||||||
|
s = s[prefix.len()..].to_string();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A normalised absolute path split into the root it hangs off and the part
|
||||||
|
/// below it, or `None` when it names no location at all.
|
||||||
|
///
|
||||||
|
/// The three roots the desktop platforms have: `/`, a drive (`C:/`), and a UNC
|
||||||
|
/// share (`//server/share` — the share *is* the root; `//server` alone names a
|
||||||
|
/// machine and nothing on it).
|
||||||
|
fn split_host_root(norm: &str) -> Option<(&str, &str)> {
|
||||||
|
if let Some(rest) = norm.strip_prefix("//") {
|
||||||
|
let mut parts = rest.splitn(3, '/');
|
||||||
|
let server = parts.next().unwrap_or("");
|
||||||
|
let share = parts.next().unwrap_or("");
|
||||||
|
if server.is_empty() || share.is_empty() {
|
||||||
|
// `//server`, `//server/`: no share, so nothing under it is named.
|
||||||
|
return Some((norm, ""));
|
||||||
|
}
|
||||||
|
let root_len = 2 + server.len() + 1 + share.len();
|
||||||
|
return Some((&norm[..root_len], &norm[root_len..]));
|
||||||
|
}
|
||||||
|
let drive = drive_prefix_len(norm);
|
||||||
|
if drive > 0 {
|
||||||
|
// `C:x` is drive-*relative* — it means "x under the current directory
|
||||||
|
// on C:", which is a location only the process's own state decides.
|
||||||
|
return match norm[drive..].strip_prefix('/') {
|
||||||
|
Some(tail) => Some((&norm[..drive + 1], tail)),
|
||||||
|
None if norm.len() == drive => Some((norm, "")),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
norm.strip_prefix('/').map(|tail| (&norm[..1], tail))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many named components deep `tail` ends up, with `.` dropped and `..`
|
||||||
|
/// applied — clamped at the root, because `/..` is `/` and not an error.
|
||||||
|
fn depth_below_root(tail: &str) -> usize {
|
||||||
|
let mut depth = 0usize;
|
||||||
|
for segment in tail.split('/') {
|
||||||
|
match segment {
|
||||||
|
"" | "." => {}
|
||||||
|
".." => depth = depth.saturating_sub(1),
|
||||||
|
_ => depth += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
depth
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a host path can be handed to Docker as a bind-mount source, and if
|
||||||
|
/// not, why.
|
||||||
|
///
|
||||||
|
/// ## Resolved, not spelled
|
||||||
|
///
|
||||||
|
/// This used to be `is_filesystem_root`, and it was purely lexical: trim the
|
||||||
|
/// trailing separators, say yes to what was left over only if it was empty or a
|
||||||
|
/// bare `C:`. Nothing in this file called `canonicalize`, so `/..`, `/./`,
|
||||||
|
/// `/home/..`, `/etc/../` and `C:\..` all sailed through and were passed
|
||||||
|
/// verbatim to `docker::create_container`, which builds a
|
||||||
|
/// `Mount { source, read_only: Some(false) }` out of them. Verified against the
|
||||||
|
/// daemon: `-v /..:/mnt/probe` mounts the host root. That is the whole host
|
||||||
|
/// filesystem, read-write, in a container whose agent has passwordless sudo —
|
||||||
|
/// the same escalation `check_mount_name_stays_under_workspace` exists to
|
||||||
|
/// close, reached through the host-path half of the mount instead of the
|
||||||
|
/// mount-name half.
|
||||||
|
///
|
||||||
|
/// So the answer comes from the OS where the OS can give one: `canonicalize`
|
||||||
|
/// applies `..`, follows every symlink in the path, and on Windows returns the
|
||||||
|
/// long name for an 8.3 alias and the verbatim spelling of a UNC share — all
|
||||||
|
/// things a string comparison cannot see.
|
||||||
|
///
|
||||||
|
/// ## When the path cannot be resolved
|
||||||
|
///
|
||||||
|
/// `canonicalize` fails on a path that does not exist *here*, which is an
|
||||||
|
/// ordinary state rather than an attack: `projects.json` syncs between machines
|
||||||
|
/// and names `C:\Users\jo\code` on a box that has never had a `C:`, and a
|
||||||
|
/// folder can be created after the project is. Refusing outright would make
|
||||||
|
/// every such project unsavable, which is the exact failure
|
||||||
|
/// [`validate_project_paths_update`] exists to avoid — so an unresolvable path
|
||||||
|
/// falls back to the lexical answer, with `.` and `..` collapsed by
|
||||||
|
/// [`depth_below_root`] rather than ignored.
|
||||||
|
///
|
||||||
|
/// That is a weaker guarantee, not a wrong one, and the gap is bounded: what
|
||||||
|
/// resolution adds over the lexical rule is symlinks and 8.3 aliases, and both
|
||||||
|
/// of those are properties of a path that *exists* — precisely the case where
|
||||||
|
/// `canonicalize` answers. What is left is a path that does not exist at save
|
||||||
|
/// time and is a symlink to the root by the time the container starts, i.e. the
|
||||||
|
/// user doing it to themselves after being asked.
|
||||||
|
///
|
||||||
|
/// Blocking on the filesystem here is deliberate: this runs on a save, once per
|
||||||
|
/// row, and is a single `realpath` walk.
|
||||||
|
fn classify_mount_source(host_path: &str) -> Option<UnmountableHostPath> {
|
||||||
|
let raw = host_path.trim();
|
||||||
|
if raw.is_empty() {
|
||||||
|
// Emptiness is somebody else's error message — see
|
||||||
|
// `validate_one_path`, which names the mount it belongs to.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing but separators, in either spelling: `/`, `//`, `\`, `\\`. Taken
|
||||||
|
// first because a lone `\` is a *relative* name on Linux, and reporting a
|
||||||
|
// Windows root as "not a full path" would be answering a question the user
|
||||||
|
// did not ask.
|
||||||
|
if raw.chars().all(|c| c == '/' || c == '\\') {
|
||||||
|
return Some(UnmountableHostPath::FilesystemRoot {
|
||||||
|
resolved: raw.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absoluteness is judged on what the user typed, **before** resolution.
|
||||||
|
//
|
||||||
|
// `canonicalize` resolves a relative path against Triple-C's own working
|
||||||
|
// directory, so it hands back an absolute path and the `NotAbsolute` branch
|
||||||
|
// below never fires — it was reachable only when canonicalize *failed*,
|
||||||
|
// i.e. only for relative paths that happened not to exist. That made the
|
||||||
|
// verdict depend on where the app was launched from: `.` and `..` were
|
||||||
|
// accepted from the repo, refused from `/`. The daemon then refuses the
|
||||||
|
// mount outright (`invalid mount path: '..' mount path must be absolute`),
|
||||||
|
// so the project saved cleanly and could never start again — the bricking
|
||||||
|
// mode `project_path_mounts`'s filter exists to prevent, reached through
|
||||||
|
// the host-path half of the row instead of the mount-name half.
|
||||||
|
if split_host_root(&normalize_host_path(raw)).is_none() {
|
||||||
|
return Some(UnmountableHostPath::NotAbsolute);
|
||||||
|
}
|
||||||
|
|
||||||
|
let canonical = std::fs::canonicalize(raw)
|
||||||
|
.ok()
|
||||||
|
.map(|p| p.to_string_lossy().into_owned());
|
||||||
|
let judged = canonical.as_deref().unwrap_or(raw);
|
||||||
|
let norm = normalize_host_path(judged);
|
||||||
|
|
||||||
|
let Some((root, tail)) = split_host_root(&norm) else {
|
||||||
|
return Some(UnmountableHostPath::NotAbsolute);
|
||||||
|
};
|
||||||
|
if depth_below_root(tail) == 0 {
|
||||||
|
return Some(UnmountableHostPath::FilesystemRoot {
|
||||||
|
resolved: root.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The refusal for a host path that lands on a filesystem root, naming the
|
||||||
|
/// resolved location as well as what was typed when those differ. `/..` reads
|
||||||
|
/// as a folder; `/..` *is* `/`, and a message that only quoted it back would
|
||||||
|
/// leave the user with nothing to act on.
|
||||||
|
fn filesystem_root_message(typed: &str, resolved: &str, use_for: &str) -> String {
|
||||||
|
let where_it_lands = if typed.trim() == resolved {
|
||||||
|
format!("'{}' is a filesystem root", typed)
|
||||||
|
} else {
|
||||||
|
format!("'{}' resolves to '{}', which is a filesystem root", typed, resolved)
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{}, so {} would mount the whole drive into the container. Choose the folder itself.",
|
||||||
|
where_it_lands, use_for
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -510,7 +770,7 @@ pub async fn update_project(
|
|||||||
// Fields this command does not get to write, whoever is calling it.
|
// Fields this command does not get to write, whoever is calling it.
|
||||||
//
|
//
|
||||||
// `container_id` is the one that matters: it is the handle the whole file
|
// `container_id` is the one that matters: it is the handle the whole file
|
||||||
// command surface resolves against, `list_sibling_containers` hands the
|
// command surface resolves against, `list_sibling_containers` used to hand the
|
||||||
// webview the ids of every other container on the daemon, and a project
|
// webview the ids of every other container on the daemon, and a project
|
||||||
// save is not the place a container is adopted. It is assigned by
|
// save is not the place a container is adopted. It is assigned by
|
||||||
// `start_project_container` through `projects_store::set_container_id` and
|
// `start_project_container` through `projects_store::set_container_id` and
|
||||||
@@ -551,6 +811,12 @@ pub async fn update_project(
|
|||||||
project.ca_cert_path.as_deref(),
|
project.ca_cert_path.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// Custom env var names had no charset check anywhere, so a key like
|
||||||
|
// `BASH_FUNC_stat%%` reached the container environment verbatim. Same
|
||||||
|
// grandfathering as the folder list, for the same reason — see
|
||||||
|
// [`crate::models::validate_env_vars_update`].
|
||||||
|
crate::models::validate_env_vars_update(&stored.custom_env_vars, &project.custom_env_vars)?;
|
||||||
|
|
||||||
project.container_id = stored.container_id;
|
project.container_id = stored.container_id;
|
||||||
project.status = stored.status;
|
project.status = stored.status;
|
||||||
project.created_at = stored.created_at;
|
project.created_at = stored.created_at;
|
||||||
@@ -1159,6 +1425,161 @@ mod tests {
|
|||||||
assert!(validate_project_paths(&[path("C:\\Users\\u\\project", "project")]).is_ok());
|
assert!(validate_project_paths(&[path("C:\\Users\\u\\project", "project")]).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every spelling of a root that is not *spelled* like one.
|
||||||
|
///
|
||||||
|
/// The predicate this replaces trimmed trailing separators and compared
|
||||||
|
/// what was left, so `/..` — which the daemon mounts as the host root,
|
||||||
|
/// verified with `docker run -v /..:/mnt/probe` — was indistinguishable
|
||||||
|
/// from a project folder called `..`. No test in the repo contained a `.`
|
||||||
|
/// or a `..` in a host path, which is why it shipped.
|
||||||
|
#[test]
|
||||||
|
fn a_host_path_that_resolves_to_a_root_is_refused() {
|
||||||
|
let escapes = [
|
||||||
|
"/..",
|
||||||
|
"/../",
|
||||||
|
"/./",
|
||||||
|
"/.",
|
||||||
|
"/home/..",
|
||||||
|
"/etc/../",
|
||||||
|
"/tmp/../..",
|
||||||
|
// Deliberately not present on any machine, so this is the
|
||||||
|
// unresolvable path taking the lexical route.
|
||||||
|
"/no-such-dir-here/../..",
|
||||||
|
"C:\\..",
|
||||||
|
"C:\\Users\\..",
|
||||||
|
"c:/foo/..",
|
||||||
|
// Win32 verbatim spelling of a drive root.
|
||||||
|
"\\\\?\\C:\\",
|
||||||
|
"\\\\?\\C:\\..",
|
||||||
|
// A UNC share root is the root of everything on that share, which
|
||||||
|
// the old predicate accepted despite its doc comment claiming
|
||||||
|
// otherwise.
|
||||||
|
"\\\\server\\share",
|
||||||
|
"//server/share/",
|
||||||
|
];
|
||||||
|
for escape in escapes {
|
||||||
|
assert!(
|
||||||
|
validate_project_paths(&[path(escape, "everything")]).is_err(),
|
||||||
|
"host path '{}' was accepted as a project folder, which bind-mounts a whole \
|
||||||
|
filesystem read-write into a container with passwordless sudo",
|
||||||
|
escape
|
||||||
|
);
|
||||||
|
// The same value must not be reachable through the editor either.
|
||||||
|
assert!(
|
||||||
|
validate_project_paths_update(&[], &[path(escape, "everything")]).is_err(),
|
||||||
|
"host path '{}' was accepted through update_project",
|
||||||
|
escape
|
||||||
|
);
|
||||||
|
// And the two read-only mounts are the same check.
|
||||||
|
assert!(
|
||||||
|
validate_mounted_host_path("the SSH key folder", None, Some(escape)).is_err(),
|
||||||
|
"'{}' was accepted as an SSH key path, which read-only bind-mounts a whole \
|
||||||
|
filesystem at /tmp/.host-ssh",
|
||||||
|
escape
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A drive-relative path (`C:x`, no separator) means "x under whatever the
|
||||||
|
/// current directory on C: happens to be" — a location decided by the
|
||||||
|
/// process rather than by the user, so it may be the drive root.
|
||||||
|
#[test]
|
||||||
|
fn a_relative_path_is_refused_however_it_resolves_from_here() {
|
||||||
|
// The previous test for this passed by coincidence: its four examples
|
||||||
|
// did not exist under `app/src-tauri`, so `canonicalize` failed and the
|
||||||
|
// `NotAbsolute` branch fired for the wrong reason. Creating a directory
|
||||||
|
// named `project` there flipped it red.
|
||||||
|
//
|
||||||
|
// These are paths that *do* exist relative to wherever the test runs,
|
||||||
|
// so they exercise the branch that used to be unreachable. Judged on
|
||||||
|
// the typed string, the answer is the same from any working directory —
|
||||||
|
// which is the property that matters, because the daemon refuses a
|
||||||
|
// relative mount source and the project would save fine and then never
|
||||||
|
// start.
|
||||||
|
for existing in [".", "..", "src", "./src"] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
classify_mount_source(existing),
|
||||||
|
Some(UnmountableHostPath::NotAbsolute)
|
||||||
|
),
|
||||||
|
"{} is relative and must be refused regardless of cwd",
|
||||||
|
existing
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the fix must not have made an absolute path unreachable.
|
||||||
|
assert!(
|
||||||
|
classify_mount_source("/usr").is_none(),
|
||||||
|
"an ordinary absolute folder must still be accepted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_path_that_names_no_location_is_refused_rather_than_guessed_at() {
|
||||||
|
for relative in ["C:x", "C:Users\\jo", "relative/path", "./project"] {
|
||||||
|
assert!(
|
||||||
|
validate_project_paths(&[path(relative, "project")]).is_err(),
|
||||||
|
"'{}' was accepted, though where it lands depends on Triple-C's own \
|
||||||
|
working directory",
|
||||||
|
relative
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dots that are *not* an escape have to keep working — a folder can
|
||||||
|
/// legitimately be reached through `.` or a `..` that goes back down again,
|
||||||
|
/// and the Browse button produces paths on machines this list is not
|
||||||
|
/// running on.
|
||||||
|
#[test]
|
||||||
|
fn an_ordinary_folder_is_still_accepted_however_it_is_spelled() {
|
||||||
|
for ok in [
|
||||||
|
"/home/u/./project",
|
||||||
|
"/home/u/x/../project",
|
||||||
|
"/home/u/..project",
|
||||||
|
"/home/u/project/..hidden",
|
||||||
|
"C:\\Users\\u\\x\\..\\project",
|
||||||
|
"\\\\server\\share\\project",
|
||||||
|
"\\\\?\\C:\\Users\\u\\project",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
validate_project_paths(&[path(ok, "project")]).is_ok(),
|
||||||
|
"host path '{}' should be usable",
|
||||||
|
ok
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half of this that only resolution can answer.
|
||||||
|
///
|
||||||
|
/// A lexical check sees a two-component path under `/tmp` and stops. The
|
||||||
|
/// container is what plants the link — `/proc/self/mountinfo` inside a
|
||||||
|
/// Triple-C container spells the host's project paths out verbatim — so the
|
||||||
|
/// symlink is reachable, and the mount that follows it is read-write.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn a_symlink_to_the_root_is_refused_because_resolution_is_what_answers() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"triple-c-root-link-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let link = dir.join("innocent");
|
||||||
|
std::os::unix::fs::symlink("/", &link).unwrap();
|
||||||
|
|
||||||
|
let verdict = validate_project_paths(&[path(&link.to_string_lossy(), "project")]);
|
||||||
|
std::fs::remove_file(&link).ok();
|
||||||
|
std::fs::remove_dir(&dir).ok();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
verdict.is_err(),
|
||||||
|
"a symlink to / was accepted as a project folder; only canonicalisation can see it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn duplicate_and_half_filled_rows_are_refused_but_the_blank_row_is_not() {
|
fn duplicate_and_half_filled_rows_are_refused_but_the_blank_row_is_not() {
|
||||||
assert!(validate_project_paths(&[
|
assert!(validate_project_paths(&[
|
||||||
|
|||||||
@@ -16,6 +16,38 @@ pub async fn update_settings(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<AppSettings, String> {
|
) -> Result<AppSettings, String> {
|
||||||
let before = state.settings_store.get();
|
let before = state.settings_store.get();
|
||||||
|
|
||||||
|
// The global half of the same rule the project half gets in
|
||||||
|
// `update_project`: a global custom env var is merged into every project's
|
||||||
|
// container environment, so an unchecked name here reaches all of them.
|
||||||
|
crate::models::validate_env_vars_update(
|
||||||
|
&before.global_custom_env_vars,
|
||||||
|
&settings.global_custom_env_vars,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// The same for the two host paths this struct owns. `update_project`
|
||||||
|
// validated its per-project overrides and this side validated nothing,
|
||||||
|
// which left the wider hole of the two: `default_ssh_key_path` is the
|
||||||
|
// fallback for **every** project without an override
|
||||||
|
// (`container.rs`'s `create_container`), so `/` here read-only bind-mounts
|
||||||
|
// the whole host at `/tmp/.host-ssh` for all of them — and `entrypoint.sh`
|
||||||
|
// then does `cp -a /tmp/.host-ssh ~/.ssh`, recursively copying it into the
|
||||||
|
// home volume this release exists to bound.
|
||||||
|
//
|
||||||
|
// Grandfathered the same way project paths are: a value carried over
|
||||||
|
// unchanged still saves, so a store written before this check cannot lock
|
||||||
|
// the user out of their own settings.
|
||||||
|
crate::commands::project_commands::validate_mounted_host_path(
|
||||||
|
"SSH key path",
|
||||||
|
before.default_ssh_key_path.as_deref(),
|
||||||
|
settings.default_ssh_key_path.as_deref(),
|
||||||
|
)?;
|
||||||
|
crate::commands::project_commands::validate_mounted_host_path(
|
||||||
|
"CA certificate path",
|
||||||
|
before.ca_cert_path.as_deref(),
|
||||||
|
settings.ca_cert_path.as_deref(),
|
||||||
|
)?;
|
||||||
|
|
||||||
let saved = state.settings_store.update(settings)?;
|
let saved = state.settings_store.update(settings)?;
|
||||||
|
|
||||||
// Persisting a setting is not the same as applying it. The gateway is the
|
// Persisting a setting is not the same as applying it. The gateway is the
|
||||||
|
|||||||
@@ -197,18 +197,17 @@ pub async fn upload_host_file_to_terminal(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
// The drop target is a host path chosen by the webview, not by the OS drag
|
// The drop target is a host path chosen by the webview, not by the OS drag
|
||||||
// itself, so it gets the same host-read policy as the Files pane's upload:
|
// itself, so it goes through `file_commands`' host-read policy: absolute,
|
||||||
// absolute, no traversal, and nothing out of a hidden directory
|
// no traversal, and nothing whose path passes through a hidden directory
|
||||||
// (`~/.ssh`, `~/.aws`) or a system location — applied to the path with its
|
// (`~/.ssh`, `~/.aws`, `~/.local/bin`) or a system location — applied to
|
||||||
// symlinks already resolved, so a visible directory that *leads* to `~/.ssh`
|
// the path with its symlinks already resolved, so a visible directory that
|
||||||
// is refused too. What comes back is that resolved path, and it is what
|
// *leads* to one of those is refused too. What comes back is that resolved
|
||||||
// gets opened.
|
// path, and it is what gets opened. This is now one of only two commands
|
||||||
|
// that touch a host path at all; the other is `download_container_backup`.
|
||||||
// The name is taken from the path the user actually dropped, *before*
|
// The name is taken from the path the user actually dropped, *before*
|
||||||
// resolution. Deriving it from the resolved path renames the file behind
|
// resolution. Deriving it from the resolved path renames the file behind
|
||||||
// the user's back: dropping `~/Downloads/latest.log`, where `latest.log` is
|
// the user's back: dropping `~/Downloads/latest.log`, where `latest.log` is
|
||||||
// a symlink, would land it in the container as `2026-08-23.log`. The Files
|
// a symlink, would land it in the container as `2026-08-23.log`.
|
||||||
// pane's upload had the same bug and fixes it the same way — one helper, so
|
|
||||||
// the two drop targets cannot drift.
|
|
||||||
let base = crate::commands::file_commands::host_upload_name(&host_path)?;
|
let base = crate::commands::file_commands::host_upload_name(&host_path)?;
|
||||||
let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?;
|
let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?;
|
||||||
|
|
||||||
@@ -217,8 +216,23 @@ pub async fn upload_host_file_to_terminal(
|
|||||||
let meta = tokio::fs::metadata(&host_path)
|
let meta = tokio::fs::metadata(&host_path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
||||||
if meta.is_dir() {
|
// `!is_file()`, not `!is_dir()`. A FIFO is neither a directory nor a
|
||||||
return Err(format!("{} is a directory — drop individual files", host_path));
|
// regular file, reports `len() == 0`, and passes both the directory check
|
||||||
|
// and the size cap below — and `std::fs::File::open` on one blocks forever
|
||||||
|
// with no writer, with no timeout anywhere on this path. The upload then
|
||||||
|
// never returns, the toast sticks on "Adding N files…" for the session and
|
||||||
|
// the rest of the batch is abandoned. Sockets and device nodes are the same
|
||||||
|
// shape. With the Files tab's upload removed, this is the only route for
|
||||||
|
// getting a file into a container, so it is the wrong place to be clever.
|
||||||
|
if !meta.is_file() {
|
||||||
|
return Err(if meta.is_dir() {
|
||||||
|
format!("{} is a directory — drop individual files", host_path)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{} is not a regular file — only ordinary files can be dropped into a terminal",
|
||||||
|
host_path
|
||||||
|
)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard against ballooning host RAM: the file is packed into an in-memory
|
// Guard against ballooning host RAM: the file is packed into an in-memory
|
||||||
@@ -229,7 +243,7 @@ pub async fn upload_host_file_to_terminal(
|
|||||||
use crate::docker::exec::MAX_DROP_BYTES;
|
use crate::docker::exec::MAX_DROP_BYTES;
|
||||||
if meta.len() > MAX_DROP_BYTES {
|
if meta.len() > MAX_DROP_BYTES {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project or use the Files panel instead.",
|
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project instead.",
|
||||||
meta.len() as f64 / (1024.0 * 1024.0),
|
meta.len() as f64 / (1024.0 * 1024.0),
|
||||||
MAX_DROP_BYTES / (1024 * 1024)
|
MAX_DROP_BYTES / (1024 * 1024)
|
||||||
));
|
));
|
||||||
@@ -301,19 +315,18 @@ pub async fn stop_audio_bridge(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
/// Both drop targets must name a dropped file the way the *user* named it.
|
/// A dropped file must be named the way the *user* named it.
|
||||||
///
|
///
|
||||||
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
|
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
|
||||||
/// name from the path *after* symlink resolution, so dropping
|
/// name from the path *after* symlink resolution, so dropping
|
||||||
/// `~/Downloads/latest.log` — where `latest.log` is a symlink to
|
/// `~/Downloads/latest.log` — where `latest.log` is a symlink to
|
||||||
/// `2026-08-23.log` — silently landed the file in the container under the
|
/// `2026-08-23.log` — silently landed the file in the container under the
|
||||||
/// target's name. Nothing errored; the user just got a name they never
|
/// target's name. Nothing errored; the user just got a name they never
|
||||||
/// typed. The Files pane had the identical bug.
|
/// typed.
|
||||||
///
|
///
|
||||||
/// What actually keeps the two from drifting is that they now call one
|
/// This asserts the shared helper's contract from the terminal side: the
|
||||||
/// helper, so this asserts that helper's contract from the terminal side:
|
/// answer comes from the spelling, and a path that does not name a file is
|
||||||
/// the answer comes from the spelling, and a path that does not name a file
|
/// refused rather than silently substituted (it used to fall back to
|
||||||
/// is refused rather than silently substituted (it used to fall back to
|
|
||||||
/// `"dropped-file"`).
|
/// `"dropped-file"`).
|
||||||
#[test]
|
#[test]
|
||||||
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -351,8 +351,8 @@ pub async fn upload_host_file_to_container(
|
|||||||
// The caller resolved this path (`resolve_host_read_path`); opening it
|
// The caller resolved this path (`resolve_host_read_path`); opening it
|
||||||
// is a second trip through the same directories, so the descriptor is
|
// is a second trip through the same directories, so the descriptor is
|
||||||
// checked against the path that was validated before its bytes are
|
// checked against the path that was validated before its bytes are
|
||||||
// packed into anything. Same policy as the Files pane's upload — this
|
// packed into anything. This is the terminal's drop target, and it is
|
||||||
// is the terminal's drop target, and the two must not differ.
|
// the only path by which host bytes enter a container.
|
||||||
let file = std::fs::File::open(&host_path)
|
let file = std::fs::File::open(&host_path)
|
||||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||||
crate::commands::file_commands::verify_opened_path(
|
crate::commands::file_commands::verify_opened_path(
|
||||||
@@ -601,44 +601,6 @@ pub async fn exec_oneshot_as(
|
|||||||
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await
|
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`exec_oneshot_as`] with a wall-clock ceiling on the whole call.
|
|
||||||
///
|
|
||||||
/// H8. Nothing in this module bounds how long a container command may take,
|
|
||||||
/// which is right for the callers that need it — a base-image migration replays
|
|
||||||
/// `apt-get` and takes minutes — and wrong for a short command that can be made
|
|
||||||
/// to block forever by a *file* the caller does not control. The upload
|
|
||||||
/// reservation is the one that bit: a shell redirect onto a FIFO blocks in
|
|
||||||
/// `open(2)` until a reader appears, so a single `mkfifo` in a project
|
|
||||||
/// directory left the Files pane on "Uploading…" for the rest of the session
|
|
||||||
/// with the rest of the batch abandoned.
|
|
||||||
///
|
|
||||||
/// So the ceiling is opt-in per call site rather than global. Note what it can
|
|
||||||
/// and cannot do: dropping the future closes our end of the stream, but Docker
|
|
||||||
/// has no "kill an exec" API, so a process that is genuinely wedged stays
|
|
||||||
/// wedged in the container's process table. That is why the primitive matters
|
|
||||||
/// more than the timeout — this turns "the app never comes back" into "that
|
|
||||||
/// upload failed", and it is the caller's job not to run something that blocks.
|
|
||||||
pub async fn exec_oneshot_as_within(
|
|
||||||
container_id: &str,
|
|
||||||
user: &str,
|
|
||||||
cmd: Vec<String>,
|
|
||||||
env: Vec<String>,
|
|
||||||
limit: std::time::Duration,
|
|
||||||
) -> Result<(String, i64), String> {
|
|
||||||
match tokio::time::timeout(
|
|
||||||
limit,
|
|
||||||
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(result) => result,
|
|
||||||
Err(_) => Err(format!(
|
|
||||||
"The container did not answer within {}s — the command may still be running inside it.",
|
|
||||||
limit.as_secs()
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What a one-shot exec printed, with the two streams still tellable apart.
|
/// What a one-shot exec printed, with the two streams still tellable apart.
|
||||||
///
|
///
|
||||||
/// `combined` is stdout and stderr interleaved in arrival order — the shape
|
/// `combined` is stdout and stderr interleaved in arrival order — the shape
|
||||||
|
|||||||
@@ -369,6 +369,15 @@ pub fn set_delta(from: &BTreeSet<String>, base: &BTreeSet<String>) -> Vec<String
|
|||||||
pub fn bind_mount_exclusions(paths: &[ProjectPath]) -> Vec<String> {
|
pub fn bind_mount_exclusions(paths: &[ProjectPath]) -> Vec<String> {
|
||||||
let mut out: Vec<String> = paths
|
let mut out: Vec<String> = paths
|
||||||
.iter()
|
.iter()
|
||||||
|
// **The same filter `project_path_mounts` applies, and it has to be.**
|
||||||
|
// That function skips a row with an empty `host_path` or `mount_name`
|
||||||
|
// so a legacy row cannot brick the create. The consequence is that
|
||||||
|
// `/workspace/<name>` for such a row is *not* a bind mount — it is
|
||||||
|
// ordinary writable-layer content. Excluding it here would tell
|
||||||
|
// `compute_verbatim_paths` to skip staging it, and the container swap
|
||||||
|
// would then destroy whatever the user has put there. The two
|
||||||
|
// predicates must agree or a migration silently eats a directory.
|
||||||
|
.filter(|p| !p.mount_name.trim().is_empty() && !p.host_path.trim().is_empty())
|
||||||
.map(|p| format!("/workspace/{}", p.mount_name))
|
.map(|p| format!("/workspace/{}", p.mount_name))
|
||||||
.collect();
|
.collect();
|
||||||
out.sort();
|
out.sort();
|
||||||
@@ -1368,6 +1377,30 @@ pub fn parse_preflight(raw: &str) -> PreflightEnvironment {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
|
/// The mount filter and the migration's exclusion list must agree.
|
||||||
|
///
|
||||||
|
/// `project_path_mounts` skips a row with an empty `host_path` so a legacy
|
||||||
|
/// row cannot brick the create. That makes `/workspace/<name>` ordinary
|
||||||
|
/// writable-layer content rather than a bind mount — and if this function
|
||||||
|
/// still excluded it, `compute_verbatim_paths` would skip staging it and
|
||||||
|
/// the container swap would destroy whatever is there. A migration eating a
|
||||||
|
/// directory is the quietest kind of data loss there is.
|
||||||
|
#[test]
|
||||||
|
fn an_unmountable_row_is_not_excluded_from_the_migration_payload() {
|
||||||
|
let paths = vec![
|
||||||
|
ProjectPath { host_path: "/home/u/code".into(), mount_name: "code".into() },
|
||||||
|
// Legacy shapes that `project_path_mounts` skips.
|
||||||
|
ProjectPath { host_path: "".into(), mount_name: "data".into() },
|
||||||
|
ProjectPath { host_path: "/home/u/x".into(), mount_name: " ".into() },
|
||||||
|
];
|
||||||
|
let excluded = bind_mount_exclusions(&paths);
|
||||||
|
assert_eq!(
|
||||||
|
excluded,
|
||||||
|
vec!["/workspace/code".to_string()],
|
||||||
|
"only rows that are actually mounted may be excluded from staging"
|
||||||
|
);
|
||||||
|
}
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,
|
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,
|
||||||
|
|||||||
+177
-3
@@ -435,7 +435,6 @@ pub fn run() {
|
|||||||
commands::docker_commands::check_image_exists,
|
commands::docker_commands::check_image_exists,
|
||||||
commands::docker_commands::build_image,
|
commands::docker_commands::build_image,
|
||||||
commands::docker_commands::get_container_info,
|
commands::docker_commands::get_container_info,
|
||||||
commands::docker_commands::list_sibling_containers,
|
|
||||||
// Projects
|
// Projects
|
||||||
commands::project_commands::list_projects,
|
commands::project_commands::list_projects,
|
||||||
commands::project_commands::add_project,
|
commands::project_commands::add_project,
|
||||||
@@ -497,9 +496,7 @@ pub fn run() {
|
|||||||
commands::terminal_commands::stop_audio_bridge,
|
commands::terminal_commands::stop_audio_bridge,
|
||||||
// Files
|
// Files
|
||||||
commands::file_commands::list_container_files,
|
commands::file_commands::list_container_files,
|
||||||
commands::file_commands::download_container_file,
|
|
||||||
commands::file_commands::download_container_backup,
|
commands::file_commands::download_container_backup,
|
||||||
commands::file_commands::upload_file_to_container,
|
|
||||||
commands::file_commands::read_container_file,
|
commands::file_commands::read_container_file,
|
||||||
commands::file_commands::rename_container_path,
|
commands::file_commands::rename_container_path,
|
||||||
commands::file_commands::create_container_directory,
|
commands::file_commands::create_container_directory,
|
||||||
@@ -689,6 +686,183 @@ mod tests {
|
|||||||
/// pulls in `core:image:default` → `allow-from-path`, which is an
|
/// pulls in `core:image:default` → `allow-from-path`, which is an
|
||||||
/// unconditional `std::fs::read` of any host path with no scope check, and
|
/// unconditional `std::fs::read` of any host path with no scope check, and
|
||||||
/// nothing in the frontend has ever imported `@tauri-apps/api/image`.
|
/// nothing in the frontend has ever imported `@tauri-apps/api/image`.
|
||||||
|
/// Every `#[tauri::command]` is registered, and every registration names a
|
||||||
|
/// command that exists.
|
||||||
|
///
|
||||||
|
/// This is the shape of the bug that caused the original OAuth-callback
|
||||||
|
/// complaint: `set_auth_bridge_enabled` existed, worked, and had a typed
|
||||||
|
/// frontend wrapper — with **zero call sites**. The switch the docs told
|
||||||
|
/// users to flip was never wired to anything, so the bridge stayed off and
|
||||||
|
/// every login callback was refused. Nothing failed; the feature was simply
|
||||||
|
/// absent, and no test noticed because both halves compiled.
|
||||||
|
///
|
||||||
|
/// The reverse direction matters too, and for a sharper reason: a command
|
||||||
|
/// that is registered but reachable from nowhere is still IPC surface a
|
||||||
|
/// compromised webview can call. `list_sibling_containers` — which returned
|
||||||
|
/// every container on the daemon, including the user's unrelated work —
|
||||||
|
/// sat in exactly that state, and this test is what found it. It has since
|
||||||
|
/// been removed at all four levels: registration, command, docker helper,
|
||||||
|
/// and the frontend wrapper and type.
|
||||||
|
///
|
||||||
|
/// So this asserts the two lists agree, and leaves *deciding* what belongs
|
||||||
|
/// on them to a human. It cannot see frontend call sites; `tsc` and the
|
||||||
|
/// vitest suite cover that side.
|
||||||
|
#[test]
|
||||||
|
fn every_command_is_registered_exactly_once() {
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
let mut defined: BTreeSet<String> = BTreeSet::new();
|
||||||
|
|
||||||
|
// Walk the source tree for the command attribute and take the `fn` name
|
||||||
|
// that follows.
|
||||||
|
//
|
||||||
|
// The first version of this matched `line.trim() == "#[tauri::command]"`
|
||||||
|
// exactly and broke on the first non-`#` line. An audit got five real,
|
||||||
|
// compiling, unregistered commands past it — `#[tauri::command(async)]`,
|
||||||
|
// `#[tauri::command(rename_all = "snake_case")]`, a trailing comment,
|
||||||
|
// spaces in the path, and a bare `#[command]` after `use tauri::command`
|
||||||
|
// — plus `pub(crate) fn` and a `///` line between attribute and `fn`.
|
||||||
|
// Every one of those is a command the frontend could not call, which is
|
||||||
|
// the bug this test exists for, and the test stayed green.
|
||||||
|
//
|
||||||
|
// The asymmetry matters: confusion on the *definition* side is a silent
|
||||||
|
// pass, while on the *registration* side it fails loudly against
|
||||||
|
// legitimate code — and rustc already covers that direction. So this
|
||||||
|
// errs toward over-matching definitions.
|
||||||
|
fn collect(dir: &std::path::Path, out: &mut BTreeSet<String>) {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect(&path, out);
|
||||||
|
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||||
|
let Ok(text) = std::fs::read_to_string(&path) else { continue };
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
for (i, line) in lines.iter().enumerate() {
|
||||||
|
let t = line.trim();
|
||||||
|
// `#[tauri::command]`, `#[tauri::command(async)]`,
|
||||||
|
// `#[tauri :: command]`, a bare `#[command]` under
|
||||||
|
// `use tauri::command`, and any of those with a
|
||||||
|
// trailing comment.
|
||||||
|
let attr = t.strip_prefix("#[").map(|a| {
|
||||||
|
a.split(']').next().unwrap_or("").replace(' ', "")
|
||||||
|
});
|
||||||
|
let is_command_attr = attr.is_some_and(|a| {
|
||||||
|
a == "command" || a == "tauri::command"
|
||||||
|
|| a.starts_with("command(")
|
||||||
|
|| a.starts_with("tauri::command(")
|
||||||
|
});
|
||||||
|
if !is_command_attr {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Skip further attributes and doc comments rather than
|
||||||
|
// giving up at the first line that is not an attribute.
|
||||||
|
for next in lines.iter().skip(i + 1) {
|
||||||
|
let t = next.trim();
|
||||||
|
if t.starts_with('#') || t.starts_with("//") || t.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Any visibility, then `fn` or `async fn`.
|
||||||
|
let after_vis = t
|
||||||
|
.strip_prefix("pub(crate) ")
|
||||||
|
.or_else(|| t.strip_prefix("pub(super) "))
|
||||||
|
.or_else(|| t.strip_prefix("pub(in crate) "))
|
||||||
|
.or_else(|| t.strip_prefix("pub "))
|
||||||
|
.unwrap_or(t);
|
||||||
|
let after_async =
|
||||||
|
after_vis.strip_prefix("async ").unwrap_or(after_vis);
|
||||||
|
if let Some(rest) = after_async.strip_prefix("fn ") {
|
||||||
|
if let Some(name) = rest.split(['(', '<']).next() {
|
||||||
|
out.insert(name.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collect(
|
||||||
|
std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
|
||||||
|
&mut defined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The registration list, read from this file rather than from a macro
|
||||||
|
// expansion so the test does not depend on `generate_handler!`'s shape.
|
||||||
|
let this = include_str!("lib.rs");
|
||||||
|
let handler = this
|
||||||
|
.split_once("generate_handler![")
|
||||||
|
.and_then(|(_, rest)| rest.split_once("])"))
|
||||||
|
.map(|(inside, _)| inside)
|
||||||
|
.expect("lib.rs should contain a generate_handler! list");
|
||||||
|
// Line-based, not `split(',')`: the list is grouped under `// Docker`
|
||||||
|
// style comments, and splitting on commas glues each comment to the
|
||||||
|
// command that follows it. A `starts_with("//")` filter then drops that
|
||||||
|
// command — silently, and once per group.
|
||||||
|
let registered: BTreeSet<String> = handler
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|l| !l.is_empty() && !l.starts_with("//"))
|
||||||
|
.filter_map(|l| {
|
||||||
|
l.trim_end_matches(',')
|
||||||
|
.rsplit("::")
|
||||||
|
.next()
|
||||||
|
.map(|n| n.trim().to_string())
|
||||||
|
})
|
||||||
|
.filter(|n| !n.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!defined.is_empty() && !registered.is_empty(),
|
||||||
|
"the scan found nothing — it has stopped testing anything (defined={}, registered={})",
|
||||||
|
defined.len(),
|
||||||
|
registered.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let unregistered: Vec<&String> = defined.difference(®istered).collect();
|
||||||
|
assert!(
|
||||||
|
unregistered.is_empty(),
|
||||||
|
"these commands exist but are not registered, so the frontend cannot call them: {:?}",
|
||||||
|
unregistered
|
||||||
|
);
|
||||||
|
|
||||||
|
let undefined: Vec<&String> = registered.difference(&defined).collect();
|
||||||
|
assert!(
|
||||||
|
undefined.is_empty(),
|
||||||
|
"these are registered but no `#[tauri::command]` defines them: {:?}",
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// "exactly once" was in this test's name and not in its body: both
|
||||||
|
// sides were sets, so registering the same command twice in a
|
||||||
|
// hand-maintained 118-line list compiled, warned about nothing, and
|
||||||
|
// passed here.
|
||||||
|
let mut seen: Vec<&str> = Vec::new();
|
||||||
|
let mut duplicated: Vec<&str> = Vec::new();
|
||||||
|
for line in handler
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|l| !l.is_empty() && !l.starts_with("//"))
|
||||||
|
{
|
||||||
|
if let Some(name) = line.trim_end_matches(',').rsplit("::").next() {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if seen.contains(&name) {
|
||||||
|
duplicated.push(name);
|
||||||
|
} else {
|
||||||
|
seen.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
duplicated.is_empty(),
|
||||||
|
"these are registered more than once: {:?}",
|
||||||
|
duplicated
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_capability_grants_are_the_ones_that_were_reviewed() {
|
fn the_capability_grants_are_the_ones_that_were_reviewed() {
|
||||||
let raw = include_str!("../capabilities/default.json");
|
let raw = include_str!("../capabilities/default.json");
|
||||||
|
|||||||
@@ -8,6 +8,100 @@ pub struct EnvVar {
|
|||||||
pub value: String,
|
pub value: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `key` is a name a shell will read back as an ordinary variable:
|
||||||
|
/// `[A-Za-z_][A-Za-z0-9_]*`.
|
||||||
|
///
|
||||||
|
/// ## Why a charset rule, and not just the reserved-name list
|
||||||
|
///
|
||||||
|
/// `docker::container::is_reserved_env_key` answers a different question — "is
|
||||||
|
/// this one of the names Triple-C manages itself" — and nothing anywhere asked
|
||||||
|
/// what the *characters* were. A key is joined into `KEY=VALUE` and handed to
|
||||||
|
/// the daemon, which puts it in the container's environment verbatim, so a name
|
||||||
|
/// that is not an identifier travels through unchallenged.
|
||||||
|
///
|
||||||
|
/// The one that matters is `BASH_FUNC_name%%`, bash's wire format for an
|
||||||
|
/// exported shell function: bash imports those at startup and the *body* is the
|
||||||
|
/// value. Today that is latent rather than live — the image's `/bin/sh` is
|
||||||
|
/// dash, which does not import them, and an auditor confirmed the vector fires
|
||||||
|
/// under `bash -c` and not under `sh -c` in the shipped image. But the
|
||||||
|
/// pre-commit scrub runs `/bin/sh -c` **as root**, `/bin/sh` is whatever
|
||||||
|
/// `ubuntu:24.04` points it at, and nothing pins that. One base-image change,
|
||||||
|
/// or one call site spelled `bash`, turns a stored project setting into root
|
||||||
|
/// code execution inside the container at commit time.
|
||||||
|
///
|
||||||
|
/// So the rule is the shape of the thing rather than a list of the names that
|
||||||
|
/// are known to be dangerous: `IFS`, `LD_PRELOAD` and `PATH` are all perfectly
|
||||||
|
/// good identifiers and are the user's business, while nothing legitimate needs
|
||||||
|
/// a `%`, a `(` or a space in an environment variable name.
|
||||||
|
///
|
||||||
|
/// The key is judged **trimmed**, because that is what `create_container` sends
|
||||||
|
/// — ` FOO ` already reaches the container as `FOO`, and refusing it here would
|
||||||
|
/// break a setting that works.
|
||||||
|
pub fn is_valid_env_key(key: &str) -> bool {
|
||||||
|
let mut chars = key.trim().chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a custom environment variable list that is about to be stored,
|
||||||
|
/// admitting the entries it is already stored with.
|
||||||
|
///
|
||||||
|
/// Same shape, and the same reasoning, as
|
||||||
|
/// `commands::project_commands::validate_project_paths_update`: nothing ever
|
||||||
|
/// checked these keys, so `projects.json` and `settings.json` in the field can
|
||||||
|
/// hold whatever was typed. Holding every save to the new rule would make such
|
||||||
|
/// a project unsavable *entirely* — `update_project` is the single command
|
||||||
|
/// behind the whole Config tab — and would buy nothing, because the stored key
|
||||||
|
/// is already being handed to every container that starts. An entry carried
|
||||||
|
/// over verbatim is admitted; a new or edited one is held to the rule, which is
|
||||||
|
/// what keeps the escalation closed, since escalation means *introducing* a bad
|
||||||
|
/// key through this command.
|
||||||
|
///
|
||||||
|
/// Counted rather than set-tested, for the same reason as the folder rows: a
|
||||||
|
/// second copy of an existing entry is a new entry.
|
||||||
|
///
|
||||||
|
/// The blank entry is not a violation. "+ Add variable" appends
|
||||||
|
/// `{key: "", value: ""}` and saves the list immediately, so refusing it would
|
||||||
|
/// turn the button itself into an error toast; `create_container` skips an
|
||||||
|
/// empty key, so it reaches nothing.
|
||||||
|
pub fn validate_env_vars_update(stored: &[EnvVar], incoming: &[EnvVar]) -> Result<(), String> {
|
||||||
|
// An entry with no key is the placeholder, whatever is in its value:
|
||||||
|
// `create_container` skips it, so it reaches nothing and there is nothing
|
||||||
|
// to refuse. The editor saves on every blur, and typing the value before
|
||||||
|
// the name is an ordinary way to fill a row in.
|
||||||
|
let is_blank = |v: &EnvVar| v.key.trim().is_empty();
|
||||||
|
|
||||||
|
let mut carried: std::collections::HashMap<(&str, &str), usize> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for v in stored.iter().filter(|v| !is_blank(v)) {
|
||||||
|
*carried
|
||||||
|
.entry((v.key.as_str(), v.value.as_str()))
|
||||||
|
.or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for v in incoming.iter().filter(|v| !is_blank(v)) {
|
||||||
|
match carried.get_mut(&(v.key.as_str(), v.value.as_str())) {
|
||||||
|
Some(remaining) if *remaining > 0 => {
|
||||||
|
*remaining -= 1;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if !is_valid_env_key(&v.key) {
|
||||||
|
return Err(format!(
|
||||||
|
"'{}' is not a usable environment variable name. Use a letter or \
|
||||||
|
underscore followed by letters, digits or underscores.",
|
||||||
|
v.key
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct ProjectPath {
|
pub struct ProjectPath {
|
||||||
pub host_path: String,
|
pub host_path: String,
|
||||||
@@ -85,6 +179,7 @@ impl PermissionMode {
|
|||||||
/// Settings for Claude Code CLI behavior inside the container.
|
/// Settings for Claude Code CLI behavior inside the container.
|
||||||
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
#[serde(from = "StoredClaudeCodeSettings")]
|
||||||
/// Every field is three-state, and the third state is load-bearing.
|
/// Every field is three-state, and the third state is load-bearing.
|
||||||
///
|
///
|
||||||
/// `None` means "not set at this level". For a *project* that is "inherit
|
/// `None` means "not set at this level". For a *project* that is "inherit
|
||||||
@@ -98,24 +193,24 @@ pub struct ClaudeCodeSettings {
|
|||||||
/// what lets Claude Code pick the renderer itself; `Some("default")` pins
|
/// what lets Claude Code pick the renderer itself; `Some("default")` pins
|
||||||
/// the classic main-screen renderer and `Some("fullscreen")` the alt-screen
|
/// the classic main-screen renderer and `Some("fullscreen")` the alt-screen
|
||||||
/// one. All three are distinct — "let it choose" is not "classic".
|
/// one. All three are distinct — "let it choose" is not "classic".
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub tui_mode: Option<String>,
|
pub tui_mode: Option<String>,
|
||||||
/// Saved `/effort` level: `None` = unset, otherwise one of
|
/// Saved `/effort` level: `None` = unset, otherwise one of
|
||||||
/// `"low" | "medium" | "high" | "xhigh"`. Written to settings.json as
|
/// `"low" | "medium" | "high" | "xhigh"`. Written to settings.json as
|
||||||
/// `effortLevel` (**not** `effort`, which Claude Code has never read).
|
/// `effortLevel` (**not** `effort`, which Claude Code has never read).
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub effort: Option<String>,
|
pub effort: Option<String>,
|
||||||
/// Disable auto-scroll in fullscreen TUI mode. Held in the *disabled* sense
|
/// Disable auto-scroll in fullscreen TUI mode. Held in the *disabled* sense
|
||||||
/// because Claude Code's `autoScrollEnabled` defaults to `true`, so the
|
/// because Claude Code's `autoScrollEnabled` defaults to `true`, so the
|
||||||
/// zero value of this field has to mean "leave it on".
|
/// zero value of this field has to mean "leave it on".
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub auto_scroll_disabled: Option<bool>,
|
pub auto_scroll_disabled: Option<bool>,
|
||||||
/// Collapse tool output to one-line summaries. Written to settings.json as
|
/// Collapse tool output to one-line summaries. Written to settings.json as
|
||||||
/// `viewMode: "focus"`; there is no `focusMode` key in Claude Code.
|
/// `viewMode: "focus"`; there is no `focusMode` key in Claude Code.
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub focus_mode: Option<bool>,
|
pub focus_mode: Option<bool>,
|
||||||
/// Show thinking summaries in responses
|
/// Show thinking summaries in responses
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub show_thinking_summaries: Option<bool>,
|
pub show_thinking_summaries: Option<bool>,
|
||||||
/// Turn the session recap **off**.
|
/// Turn the session recap **off**.
|
||||||
///
|
///
|
||||||
@@ -128,16 +223,99 @@ pub struct ClaudeCodeSettings {
|
|||||||
/// never touched the control holds — as "the user turned the recap off" and
|
/// never touched the control holds — as "the user turned the recap off" and
|
||||||
/// silently disabled it for all of them. A new name lets the old key be
|
/// silently disabled it for all of them. A new name lets the old key be
|
||||||
/// ignored, which lands every existing project on the correct default.
|
/// ignored, which lands every existing project on the correct default.
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub session_recap_disabled: Option<bool>,
|
pub session_recap_disabled: Option<bool>,
|
||||||
/// Strip credentials from subprocess environments
|
/// Strip credentials from subprocess environments
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub env_scrub: Option<bool>,
|
pub env_scrub: Option<bool>,
|
||||||
/// Enable 1-hour prompt cache TTL (vs default 5-minute)
|
/// Enable 1-hour prompt cache TTL (vs default 5-minute)
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub prompt_caching_1h: Option<bool>,
|
pub prompt_caching_1h: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `ClaudeCodeSettings` in every shape `projects.json` and `settings.json` can
|
||||||
|
/// be holding, which is what [`ClaudeCodeSettings`] is actually deserialised
|
||||||
|
/// through.
|
||||||
|
///
|
||||||
|
/// ## The upgrade this exists to survive
|
||||||
|
///
|
||||||
|
/// Before the widening, the five booleans were plain `bool`s with
|
||||||
|
/// `#[serde(default)]` and no `skip_serializing_if`, so **every** settings
|
||||||
|
/// object ever written carries an explicit `"env_scrub": false` — not because
|
||||||
|
/// anyone chose it, but because that is what a `bool` serialises to. Under the
|
||||||
|
/// old merge (`if p.x { true } else { g.x }`) that `false` carried no
|
||||||
|
/// information at all: it was the only value an unset switch could produce, and
|
||||||
|
/// the global always won.
|
||||||
|
///
|
||||||
|
/// Read as `Some(false)` by the new code it becomes a *deliberate off* that
|
||||||
|
/// beats a global `Some(true)` — so upgrading silently turned five settings off
|
||||||
|
/// for every project that had ever opened this editor, `env_scrub` ("strip
|
||||||
|
/// credentials from subprocess environments") among them. There is no store
|
||||||
|
/// migration anywhere: `projects_store` parses these structs directly.
|
||||||
|
///
|
||||||
|
/// ## How an old record is told apart from a new one
|
||||||
|
///
|
||||||
|
/// By `enable_session_recap`. It was in the struct from the day it existed and
|
||||||
|
/// was a plain `bool`, so its key is present in every pre-widening record and
|
||||||
|
/// in no other — the field was *renamed* to `session_recap_disabled` precisely
|
||||||
|
/// so the old key could be ignored (see the doc on that field), and the new
|
||||||
|
/// code has never written it. Its presence is therefore an exact statement that
|
||||||
|
/// these bytes were written by a binary in which `false` meant "unset", and the
|
||||||
|
/// booleans are read back that way: `true` is a real choice and survives,
|
||||||
|
/// `false` becomes `None` and inherits again.
|
||||||
|
///
|
||||||
|
/// Nothing marks a *new* record, and nothing needs to: absent is `None` (the
|
||||||
|
/// fields skip serialising when unset) and a present `false` is the deliberate
|
||||||
|
/// off the widening was for. That is also what keeps a downgrade survivable —
|
||||||
|
/// an older binary reads an absent key as `false` through its own
|
||||||
|
/// `#[serde(default)]`, where a `null` would fail to parse and take the whole
|
||||||
|
/// of `projects.json` down with it, since `ProjectsStore` parses all-or-nothing
|
||||||
|
/// and starts empty on an error.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct StoredClaudeCodeSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
tui_mode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
effort: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
auto_scroll_disabled: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
focus_mode: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
show_thinking_summaries: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
session_recap_disabled: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
env_scrub: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
prompt_caching_1h: Option<bool>,
|
||||||
|
/// The pre-widening spelling of `session_recap_disabled`, and the *only*
|
||||||
|
/// use of its value: presence dates the record. Its meaning was inverted
|
||||||
|
/// and it never worked, so it is read for the marker and discarded.
|
||||||
|
#[serde(default)]
|
||||||
|
enable_session_recap: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StoredClaudeCodeSettings> for ClaudeCodeSettings {
|
||||||
|
fn from(stored: StoredClaudeCodeSettings) -> Self {
|
||||||
|
let pre_widening = stored.enable_session_recap.is_some();
|
||||||
|
// On a pre-widening record `false` is what an untouched switch wrote,
|
||||||
|
// so it means "not set at this level" and must inherit. A `true` was a
|
||||||
|
// real choice either way.
|
||||||
|
let read = |v: Option<bool>| if pre_widening { v.filter(|on| *on) } else { v };
|
||||||
|
ClaudeCodeSettings {
|
||||||
|
tui_mode: stored.tui_mode,
|
||||||
|
effort: stored.effort,
|
||||||
|
auto_scroll_disabled: read(stored.auto_scroll_disabled),
|
||||||
|
focus_mode: read(stored.focus_mode),
|
||||||
|
show_thinking_summaries: read(stored.show_thinking_summaries),
|
||||||
|
session_recap_disabled: read(stored.session_recap_disabled),
|
||||||
|
env_scrub: read(stored.env_scrub),
|
||||||
|
prompt_caching_1h: read(stored.prompt_caching_1h),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Project {
|
pub struct Project {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -467,3 +645,170 @@ impl Project {
|
|||||||
val
|
val
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── Custom environment variable names ─────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_env_var_name_has_to_be_a_shell_identifier() {
|
||||||
|
for ok in ["PATH", "_", "_x", "MY_VAR2", "a", " SPACED_BY_THE_EDITOR "] {
|
||||||
|
assert!(is_valid_env_key(ok), "'{}' should be a usable name", ok);
|
||||||
|
}
|
||||||
|
for bad in [
|
||||||
|
// bash's wire format for an exported shell function: the value is
|
||||||
|
// the body, and a `bash` that imports it runs it. The scrub exec is
|
||||||
|
// `/bin/sh -c` as root, and nothing pins `/bin/sh` to dash.
|
||||||
|
"BASH_FUNC_stat%%",
|
||||||
|
"BASH_FUNC_ls()",
|
||||||
|
"MY VAR",
|
||||||
|
"2FAST",
|
||||||
|
"WITH-DASH",
|
||||||
|
"WITH.DOT",
|
||||||
|
"",
|
||||||
|
" ",
|
||||||
|
"$(id)",
|
||||||
|
"A=B",
|
||||||
|
] {
|
||||||
|
assert!(!is_valid_env_key(bad), "'{}' should be refused", bad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env(key: &str, value: &str) -> EnvVar {
|
||||||
|
EnvVar { key: key.to_string(), value: value.to_string() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_env_var_name_cannot_be_introduced_but_a_stored_one_does_not_brick_the_editor() {
|
||||||
|
let bad = [env("BASH_FUNC_stat%%", "() { id; }")];
|
||||||
|
// Introducing it through the Config tab is the escalation.
|
||||||
|
assert!(validate_env_vars_update(&[], &bad).is_err());
|
||||||
|
// Already stored: it is handed to every container that starts whether
|
||||||
|
// or not an unrelated save is allowed through, and refusing the save
|
||||||
|
// would make every toggle on the Config tab fail.
|
||||||
|
assert!(validate_env_vars_update(&bad, &bad).is_ok());
|
||||||
|
// Editing its value is a new entry, and refused again.
|
||||||
|
assert!(
|
||||||
|
validate_env_vars_update(&bad, &[env("BASH_FUNC_stat%%", "() { rm -rf /; }")]).is_err()
|
||||||
|
);
|
||||||
|
// Fixing the name is what the message asks for, and it saves.
|
||||||
|
assert!(validate_env_vars_update(&bad, &[env("STAT", "() { id; }")]).is_ok());
|
||||||
|
// Dropping it entirely is always fine.
|
||||||
|
assert!(validate_env_vars_update(&bad, &[]).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_blank_row_the_add_button_saves_is_not_an_error() {
|
||||||
|
// "+ Add variable" appends an empty entry and saves the list at once,
|
||||||
|
// so this is the button, not an attempt at anything.
|
||||||
|
assert!(validate_env_vars_update(&[], &[env("", "")]).is_ok());
|
||||||
|
// Typing the value before the name is an ordinary way to fill it in,
|
||||||
|
// and an entry with no name reaches no container either way.
|
||||||
|
assert!(validate_env_vars_update(&[], &[env("", "value-first")]).is_ok());
|
||||||
|
assert!(validate_env_vars_update(&[], &[env("GOOD", "v"), env("", "")]).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stored_entry_may_be_kept_but_not_multiplied() {
|
||||||
|
let stored = [env("BAD NAME", "v")];
|
||||||
|
assert!(validate_env_vars_update(&stored, &stored).is_ok());
|
||||||
|
// A second copy is a new entry, and held to the rule.
|
||||||
|
assert!(
|
||||||
|
validate_env_vars_update(&stored, &[env("BAD NAME", "v"), env("BAD NAME", "v")])
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Claude Code settings written before the fields were widened ───────
|
||||||
|
|
||||||
|
/// `projects.json` exactly as the shipped `main` binary wrote it: the five
|
||||||
|
/// booleans were plain `bool`s that always serialised, so every project
|
||||||
|
/// that ever opened the editor carries `false` for the ones it never
|
||||||
|
/// touched.
|
||||||
|
const MAIN_SHAPE_PROJECT: &str = r#"{
|
||||||
|
"id": "p1",
|
||||||
|
"name": "demo",
|
||||||
|
"paths": [{ "host_path": "/home/u/demo", "mount_name": "demo" }],
|
||||||
|
"container_id": null,
|
||||||
|
"status": "stopped",
|
||||||
|
"backend": "anthropic",
|
||||||
|
"bedrock_config": null,
|
||||||
|
"ollama_config": null,
|
||||||
|
"openai_compatible_config": null,
|
||||||
|
"allow_docker_access": false,
|
||||||
|
"ssh_key_path": null,
|
||||||
|
"git_user_name": null,
|
||||||
|
"git_user_email": null,
|
||||||
|
"claude_code_settings": {
|
||||||
|
"tui_mode": "fullscreen",
|
||||||
|
"effort": null,
|
||||||
|
"auto_scroll_disabled": false,
|
||||||
|
"focus_mode": false,
|
||||||
|
"show_thinking_summaries": false,
|
||||||
|
"enable_session_recap": false,
|
||||||
|
"env_scrub": false,
|
||||||
|
"prompt_caching_1h": false
|
||||||
|
},
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-01-01T00:00:00Z"
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_setting_stored_as_false_by_the_old_binary_still_inherits_the_global() {
|
||||||
|
let project: Project = serde_json::from_str(MAIN_SHAPE_PROJECT).unwrap();
|
||||||
|
let stored = project.claude_code_settings.expect("settings should parse");
|
||||||
|
|
||||||
|
// Read verbatim these would be `Some(false)`, which under
|
||||||
|
// `docker::container::merge_claude_code_settings` beats the global.
|
||||||
|
assert_eq!(stored.env_scrub, None);
|
||||||
|
assert_eq!(stored.auto_scroll_disabled, None);
|
||||||
|
assert_eq!(stored.focus_mode, None);
|
||||||
|
assert_eq!(stored.show_thinking_summaries, None);
|
||||||
|
assert_eq!(stored.prompt_caching_1h, None);
|
||||||
|
assert_eq!(stored.session_recap_disabled, None);
|
||||||
|
// A value the user did choose is untouched.
|
||||||
|
assert_eq!(stored.tui_mode.as_deref(), Some("fullscreen"));
|
||||||
|
|
||||||
|
// The merge rule itself, spelled the way
|
||||||
|
// `merge_claude_code_settings` spells it. `main` resolved this with
|
||||||
|
// `if p.env_scrub { true } else { g.env_scrub }`, i.e. the global won —
|
||||||
|
// and it has to go on winning, because the user never turned this off.
|
||||||
|
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
|
||||||
|
assert_eq!(
|
||||||
|
stored.env_scrub.or(global.env_scrub),
|
||||||
|
Some(true),
|
||||||
|
"upgrading silently turned off 'strip credentials from subprocess environments'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_off_chosen_in_the_new_editor_still_beats_a_global_on() {
|
||||||
|
// Same record without the pre-widening key: this `false` is the
|
||||||
|
// deliberate off the widening exists to make expressible.
|
||||||
|
let json = r#"{ "env_scrub": false }"#;
|
||||||
|
let chosen: ClaudeCodeSettings = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(chosen.env_scrub, Some(false));
|
||||||
|
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
|
||||||
|
assert_eq!(chosen.env_scrub.or(global.env_scrub), Some(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unset_setting_is_written_as_absent_rather_than_null() {
|
||||||
|
// A downgrade parses these fields as plain `bool` with
|
||||||
|
// `#[serde(default)]`: an absent key is `false`, a `null` is a parse
|
||||||
|
// error — and `ProjectsStore` parses all-or-nothing, so one project
|
||||||
|
// with one null empties the whole list and the next save persists that.
|
||||||
|
let json = serde_json::to_string(&ClaudeCodeSettings::default()).unwrap();
|
||||||
|
assert_eq!(json, "{}");
|
||||||
|
assert!(!json.contains("null"));
|
||||||
|
|
||||||
|
let partial = ClaudeCodeSettings { env_scrub: Some(false), ..Default::default() };
|
||||||
|
let json = serde_json::to_string(&partial).unwrap();
|
||||||
|
assert_eq!(json, r#"{"env_scrub":false}"#);
|
||||||
|
// And it reads back as what it is.
|
||||||
|
let round_tripped: ClaudeCodeSettings = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(round_tripped, partial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -431,6 +431,18 @@
|
|||||||
const mobileInput = document.getElementById('mobileInput');
|
const mobileInput = document.getElementById('mobileInput');
|
||||||
const btnEnter = document.getElementById('btnEnter');
|
const btnEnter = document.getElementById('btnEnter');
|
||||||
const btnNewline = document.getElementById('btnNewline');
|
const btnNewline = document.getElementById('btnNewline');
|
||||||
|
|
||||||
|
// Whether the *active* session understands ESC+CR as "insert a newline".
|
||||||
|
//
|
||||||
|
// Only Claude Code does. `bash -l` has no readline binding for `\e\r`, so
|
||||||
|
// sending it there is a silent no-op — which is worse from the mobile bar
|
||||||
|
// than from a hardware key, because the bar puts a dedicated button on
|
||||||
|
// screen that appears to do nothing. The xterm key handler is already scoped
|
||||||
|
// this way; these two paths were not.
|
||||||
|
function activeSessionTakesEscCr() {
|
||||||
|
const s = activeSessionId && sessions[activeSessionId];
|
||||||
|
return !!s && s.type === 'claude';
|
||||||
|
}
|
||||||
const btnTab = document.getElementById('btnTab');
|
const btnTab = document.getElementById('btnTab');
|
||||||
const btnCtrlC = document.getElementById('btnCtrlC');
|
const btnCtrlC = document.getElementById('btnCtrlC');
|
||||||
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
|
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
|
||||||
@@ -590,7 +602,7 @@
|
|||||||
updateProjectList(msg.projects);
|
updateProjectList(msg.projects);
|
||||||
break;
|
break;
|
||||||
case 'opened':
|
case 'opened':
|
||||||
onSessionOpened(msg.session_id, msg.project_name);
|
onSessionOpened(msg.session_id, msg.project_name, msg.session_type);
|
||||||
break;
|
break;
|
||||||
case 'output':
|
case 'output':
|
||||||
onSessionOutput(msg.session_id, msg.data);
|
onSessionOutput(msg.session_id, msg.data);
|
||||||
@@ -641,8 +653,18 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSessionOpened(sessionId, projectName) {
|
function onSessionOpened(sessionId, projectName, serverSessionType) {
|
||||||
const sessionType = pendingSessionType || 'claude';
|
// Prefer the type the *server* reports for this session. The old path read
|
||||||
|
// a single `pendingSessionType` global set at request time, so opening two
|
||||||
|
// sessions before the first reply landed swapped their labels — routine on
|
||||||
|
// mobile, where nothing disables the buttons. That was cosmetic until
|
||||||
|
// Shift+Enter became type-dependent: a Claude session labelled `shell`
|
||||||
|
// sends a bare CR and submits a half-written prompt.
|
||||||
|
//
|
||||||
|
// The fallback keeps an older server working, and defaults to `claude`,
|
||||||
|
// which is the safe direction — ESC+CR is an unbound no-op in bash, while
|
||||||
|
// a bare CR in Claude Code loses the prompt.
|
||||||
|
const sessionType = serverSessionType || pendingSessionType || 'claude';
|
||||||
pendingSessionType = null;
|
pendingSessionType = null;
|
||||||
|
|
||||||
// Create terminal
|
// Create terminal
|
||||||
@@ -791,6 +813,7 @@
|
|||||||
switchToSession(remaining[remaining.length - 1]);
|
switchToSession(remaining[remaining.length - 1]);
|
||||||
} else {
|
} else {
|
||||||
activeSessionId = null;
|
activeSessionId = null;
|
||||||
|
syncNewlineButton();
|
||||||
emptyState.style.display = '';
|
emptyState.style.display = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -817,6 +840,7 @@
|
|||||||
|
|
||||||
function switchToSession(sessionId) {
|
function switchToSession(sessionId) {
|
||||||
activeSessionId = sessionId;
|
activeSessionId = sessionId;
|
||||||
|
syncNewlineButton();
|
||||||
|
|
||||||
// Update tab styles
|
// Update tab styles
|
||||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
@@ -896,7 +920,7 @@
|
|||||||
// reasoning, as the terminal's own key handler above. A hardware
|
// reasoning, as the terminal's own key handler above. A hardware
|
||||||
// keyboard on a tablet is the only way to reach this; the phone case is
|
// keyboard on a tablet is the only way to reach this; the phone case is
|
||||||
// the dedicated newline button beside Enter.
|
// the dedicated newline button beside Enter.
|
||||||
sendTerminalInput(e.shiftKey ? '\x1b\r' : '\r');
|
sendTerminalInput(e.shiftKey && activeSessionTakesEscCr() ? '\x1b\r' : '\r');
|
||||||
} else if (e.key === 'Tab') {
|
} else if (e.key === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendTerminalInput('\t');
|
sendTerminalInput('\t');
|
||||||
@@ -904,7 +928,27 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
|
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
|
||||||
btnNewline.onclick = () => { sendTerminalInput('\x1b\r'); mobileInput.focus(); };
|
btnNewline.onclick = () => {
|
||||||
|
if (!activeSessionTakesEscCr()) { mobileInput.focus(); return; }
|
||||||
|
sendTerminalInput('\x1b\r');
|
||||||
|
mobileInput.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep the button's affordance honest: on a shell tab there is no byte that
|
||||||
|
// means "newline without running the line", so the control is disabled
|
||||||
|
// rather than left looking live.
|
||||||
|
function syncNewlineButton() {
|
||||||
|
const usable = activeSessionTakesEscCr();
|
||||||
|
btnNewline.disabled = !usable;
|
||||||
|
btnNewline.title = usable
|
||||||
|
? 'Insert a newline without submitting (Shift+Enter)'
|
||||||
|
: 'Only Claude sessions support this — a shell runs the line instead';
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no session open yet, `activeSessionTakesEscCr()` is already false —
|
||||||
|
// but nothing had called this, so the button rendered live before the first
|
||||||
|
// tab existed.
|
||||||
|
syncNewlineButton();
|
||||||
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
|
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
|
||||||
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
|
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ enum ServerMessage {
|
|||||||
Opened {
|
Opened {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
project_name: String,
|
project_name: String,
|
||||||
|
/// Echoed back so the client can label the session from the reply
|
||||||
|
/// rather than from a global set at request time.
|
||||||
|
///
|
||||||
|
/// Without it the client correlates through a single
|
||||||
|
/// `pendingSessionType`, so opening two sessions before the first
|
||||||
|
/// reply lands swaps their labels. That used to be cosmetic; it stopped
|
||||||
|
/// being cosmetic when Shift+Enter became type-dependent, because a
|
||||||
|
/// Claude session mislabelled as a shell now submits a half-written
|
||||||
|
/// prompt instead of inserting a newline.
|
||||||
|
session_type: String,
|
||||||
},
|
},
|
||||||
Output {
|
Output {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
@@ -319,6 +329,11 @@ async fn handle_open(
|
|||||||
let _ = out_tx.send(ServerMessage::Opened {
|
let _ = out_tx.send(ServerMessage::Opened {
|
||||||
session_id,
|
session_id,
|
||||||
project_name,
|
project_name,
|
||||||
|
// Derived from the same match that chose `cmd` above, not echoed from
|
||||||
|
// the request: anything that is not exactly "bash" runs Claude, so
|
||||||
|
// echoing the raw value would label an unrecognised string as its own
|
||||||
|
// type and put the client back where it started.
|
||||||
|
session_type: if session_type == Some("bash") { "bash" } else { "claude" }.to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -60,12 +60,18 @@ describe("ClaudeCodeSettingsEditor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("offers every effort level Claude Code accepts", () => {
|
it("offers every effort level Claude Code accepts", () => {
|
||||||
|
// Verified against the shipped `claude` binary's own schema rather than
|
||||||
|
// inferred: low/medium/high/xhigh/max. `max` was missing until an audit
|
||||||
|
// checked externally — which is the whole weakness of this test. It can
|
||||||
|
// only prove the editor agrees with this list, never that the list is the
|
||||||
|
// one Claude Code reads. The same blind spot is why `effort` and
|
||||||
|
// `focusMode` were confidently wrong for months.
|
||||||
renderEditor(null);
|
renderEditor(null);
|
||||||
expect(
|
expect(
|
||||||
Array.from(
|
Array.from(
|
||||||
screen.getByLabelText("Effort level").querySelectorAll("option"),
|
screen.getByLabelText("Effort level").querySelectorAll("option"),
|
||||||
).map((o) => o.getAttribute("value")),
|
).map((o) => o.getAttribute("value")),
|
||||||
).toEqual(["", "low", "medium", "high", "xhigh"]);
|
).toEqual(["", "low", "medium", "high", "xhigh", "max"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("project scope", () => {
|
describe("project scope", () => {
|
||||||
@@ -193,4 +199,27 @@ describe("ClaudeCodeSettingsEditor", () => {
|
|||||||
expect(screen.getByRole("switch", { name: label })).toBeChecked();
|
expect(screen.getByRole("switch", { name: label })).toBeChecked();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A settings object with nothing set at this level arrives as `{}`: the Rust
|
||||||
|
* struct skips serialising a field it has no value for, which is what keeps
|
||||||
|
* an older binary able to parse `projects.json` after a downgrade. It is also
|
||||||
|
* the exact shape a project stored before the fields were widened is read
|
||||||
|
* back as — every one of its `false`s meant "unset" — so reading absent as
|
||||||
|
* "off" would show a switch the user never touched as a deliberate choice.
|
||||||
|
*/
|
||||||
|
it("reads an absent field as Global rather than as Off", () => {
|
||||||
|
renderEditor({} as ClaudeCodeSettings, "project");
|
||||||
|
expect((screen.getByLabelText("Env scrub") as HTMLSelectElement).value).toBe("global");
|
||||||
|
expect((screen.getByLabelText("Session recap") as HTMLSelectElement).value).toBe("global");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still collapses to null when an absent-field object is edited back", () => {
|
||||||
|
const onSave = renderEditor({} as ClaudeCodeSettings, "global");
|
||||||
|
// Off and straight back on: the round trip has to land on `null`, or an
|
||||||
|
// untouched global stops being indistinguishable from one never opened.
|
||||||
|
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
||||||
|
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
||||||
|
expect(onSave).toHaveBeenLastCalledWith(null);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,15 +37,20 @@ export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
|
|||||||
* overrides a global on, so a settings object holding one has to be persisted.
|
* overrides a global on, so a settings object holding one has to be persisted.
|
||||||
*/
|
*/
|
||||||
function isAllDefaults(s: ClaudeCodeSettings): boolean {
|
function isAllDefaults(s: ClaudeCodeSettings): boolean {
|
||||||
|
// `== null`, not `===`: an unset field is *absent* on the wire, not null.
|
||||||
|
// The Rust struct skips serialising one it has no value for, so a project
|
||||||
|
// whose stored settings were all "unset" arrives here as `{}` — and reading
|
||||||
|
// that as "off" is exactly the mistake the three-state control exists to
|
||||||
|
// avoid. See the note on `ClaudeCodeSettings` in `lib/types.ts`.
|
||||||
return (
|
return (
|
||||||
s.tui_mode === null &&
|
s.tui_mode == null &&
|
||||||
s.effort === null &&
|
s.effort == null &&
|
||||||
s.auto_scroll_disabled === null &&
|
s.auto_scroll_disabled == null &&
|
||||||
s.focus_mode === null &&
|
s.focus_mode == null &&
|
||||||
s.show_thinking_summaries === null &&
|
s.show_thinking_summaries == null &&
|
||||||
s.session_recap_disabled === null &&
|
s.session_recap_disabled == null &&
|
||||||
s.env_scrub === null &&
|
s.env_scrub == null &&
|
||||||
s.prompt_caching_1h === null
|
s.prompt_caching_1h == null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +68,15 @@ const BOOLEAN_FIELDS: {
|
|||||||
hint: string;
|
hint: string;
|
||||||
invert?: boolean;
|
invert?: boolean;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ key: "focus_mode", label: "Focus mode", hint: "Collapses tool output to one-line summaries." },
|
{
|
||||||
|
key: "focus_mode",
|
||||||
|
label: "Focus mode",
|
||||||
|
// It summarises tool *calls*, not all output — and it does nothing at all
|
||||||
|
// unless the fullscreen renderer is on, which is a separate switch above.
|
||||||
|
// Saying so here is cheaper than the user concluding the setting is broken,
|
||||||
|
// which is the complaint that started this whole round of work.
|
||||||
|
hint: "Summarises each tool call to one line, showing the last prompt and the final response. Needs TUI mode set to Fullscreen.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "show_thinking_summaries",
|
key: "show_thinking_summaries",
|
||||||
label: "Thinking summaries",
|
label: "Thinking summaries",
|
||||||
@@ -163,6 +176,9 @@ export default function ClaudeCodeSettingsEditor({
|
|||||||
<option value="medium">Medium</option>
|
<option value="medium">Medium</option>
|
||||||
<option value="high">High</option>
|
<option value="high">High</option>
|
||||||
<option value="xhigh">Extra high</option>
|
<option value="xhigh">Extra high</option>
|
||||||
|
{/* `max` is accepted by the CLI and was missing here. Confirmed
|
||||||
|
against the shipped claude binary's own schema, not just docs. */}
|
||||||
|
<option value="max">Maximum</option>
|
||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -204,7 +220,7 @@ export default function ClaudeCodeSettingsEditor({
|
|||||||
// `stored` holds the deviation from Claude Code's default, so an
|
// `stored` holds the deviation from Claude Code's default, so an
|
||||||
// inverted field reads back the other way round — see BOOLEAN_FIELDS.
|
// inverted field reads back the other way round — see BOOLEAN_FIELDS.
|
||||||
const selected =
|
const selected =
|
||||||
stored === null ? "global" : (invert ? !stored : stored) ? "on" : "off";
|
stored == null ? "global" : (invert ? !stored : stored) ? "on" : "off";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
|
|||||||
@@ -16,14 +16,12 @@ interface Props {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
entry: FileEntry;
|
entry: FileEntry;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** "Save to host…" — the way out for anything the viewer can't render. */
|
|
||||||
onSaveToHost: (entry: FileEntry) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Preview =
|
type Preview =
|
||||||
| { kind: "loading" }
|
| { kind: "loading" }
|
||||||
| { kind: "error"; message: string }
|
| { kind: "error"; message: string }
|
||||||
/** Too big to render whole — offered as a download rather than a half-file. */
|
/** Too big to render whole — said so rather than shown as a half-file. */
|
||||||
| { kind: "too-large" }
|
| { kind: "too-large" }
|
||||||
| { kind: "text"; text: string; truncated: boolean; shownBytes: number; trueSize: number }
|
| { kind: "text"; text: string; truncated: boolean; shownBytes: number; trueSize: number }
|
||||||
| { kind: "image"; url: string }
|
| { kind: "image"; url: string }
|
||||||
@@ -37,7 +35,7 @@ type Preview =
|
|||||||
* keeps a multi-megabyte base64 string out of the DOM. `blob:` is in the app's
|
* keeps a multi-megabyte base64 string out of the DOM. `blob:` is in the app's
|
||||||
* `img-src` for exactly this; the asset protocol deliberately is not enabled.
|
* `img-src` for exactly this; the asset protocol deliberately is not enabled.
|
||||||
*/
|
*/
|
||||||
export default function FileViewerModal({ projectId, entry, onClose, onSaveToHost }: Props) {
|
export default function FileViewerModal({ projectId, entry, onClose }: Props) {
|
||||||
const [preview, setPreview] = useState<Preview>({ kind: "loading" });
|
const [preview, setPreview] = useState<Preview>({ kind: "loading" });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -121,19 +119,9 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
|
|||||||
);
|
);
|
||||||
|
|
||||||
const footer = (
|
const footer = (
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="md"
|
|
||||||
onClick={() => {
|
|
||||||
onSaveToHost(entry);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save to host…
|
|
||||||
</Button>
|
|
||||||
<Button size="md" variant="primary" onClick={onClose}>
|
<Button size="md" variant="primary" onClick={onClose}>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -156,14 +144,15 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
|
|||||||
|
|
||||||
{preview.kind === "too-large" && (
|
{preview.kind === "too-large" && (
|
||||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
This file is {formatBytes(entry.size)} — too large to preview in the app. Save it
|
This file is {formatBytes(entry.size)} — too large to preview in the app. Open it
|
||||||
to the host to open it there.
|
from a terminal in the container, or take a backup and open it on the host.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{preview.kind === "unsupported" && (
|
{preview.kind === "unsupported" && (
|
||||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
There is no preview for this file type. Save it to the host to open it there.
|
There is no preview for this file type. Open it from a terminal in the container,
|
||||||
|
or take a backup and open it on the host.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,12 @@ import FilesTab from "./FilesTab";
|
|||||||
import type { FileContents, FileEntry, Project } from "../../../lib/types";
|
import type { FileContents, FileEntry, Project } from "../../../lib/types";
|
||||||
|
|
||||||
const listContainerFiles = vi.fn();
|
const listContainerFiles = vi.fn();
|
||||||
const downloadContainerFile = vi.fn(async () => {});
|
|
||||||
const uploadFileToContainer = vi.fn(async () => {});
|
|
||||||
const renameContainerPath = vi.fn(async () => "");
|
const renameContainerPath = vi.fn(async () => "");
|
||||||
const createContainerDirectory = vi.fn(async () => "");
|
const createContainerDirectory = vi.fn(async () => "");
|
||||||
const readContainerFile = vi.fn();
|
const readContainerFile = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../../lib/tauri-commands", () => ({
|
vi.mock("../../../lib/tauri-commands", () => ({
|
||||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||||
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
|
|
||||||
uploadFileToContainer: (...args: unknown[]) => uploadFileToContainer(...args),
|
|
||||||
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
||||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||||
createContainerDirectory(p, parent, n),
|
createContainerDirectory(p, parent, n),
|
||||||
@@ -31,29 +27,6 @@ const toastText = () =>
|
|||||||
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
|
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
const save = vi.fn(async () => "/host/out");
|
|
||||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
|
||||||
save: (o: unknown) => save(o),
|
|
||||||
open: vi.fn(async () => null),
|
|
||||||
}));
|
|
||||||
|
|
||||||
/** The webview's window-wide native drag-drop listener, captured for driving. */
|
|
||||||
type DragPayload =
|
|
||||||
| { type: "enter" | "over"; position: { x: number; y: number }; paths: string[] }
|
|
||||||
| { type: "leave" }
|
|
||||||
| { type: "drop"; position: { x: number; y: number }; paths: string[] };
|
|
||||||
let dragHandler: ((e: { payload: DragPayload }) => void | Promise<void>) | null = null;
|
|
||||||
const unlistenDrag = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/webview", () => ({
|
|
||||||
getCurrentWebview: () => ({
|
|
||||||
onDragDropEvent: async (cb: (e: { payload: DragPayload }) => void) => {
|
|
||||||
dragHandler = cb;
|
|
||||||
return unlistenDrag;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const project = { id: "p1", name: "api", status: "running" } as unknown as Project;
|
const project = { id: "p1", name: "api", status: "running" } as unknown as Project;
|
||||||
|
|
||||||
const entry = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
|
const entry = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
|
||||||
@@ -82,39 +55,17 @@ async function renderTab() {
|
|||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fire the native drop payload at a point inside the pane's stubbed rect. */
|
|
||||||
async function drop(paths: string[], position = { x: 100, y: 100 }) {
|
|
||||||
await act(async () => {
|
|
||||||
await dragHandler?.({ payload: { type: "drop", position, paths } });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every row that is part of the grid's roving tabindex, in order. */
|
/** Every row that is part of the grid's roving tabindex, in order. */
|
||||||
const gridRows = () => Array.from(document.querySelectorAll("tr[data-file-row]"));
|
const gridRows = () => Array.from(document.querySelectorAll("tr[data-file-row]"));
|
||||||
/** The rows that are actually tab stops. There must never be more than one. */
|
/** The rows that are actually tab stops. There must never be more than one. */
|
||||||
const tabStops = () => gridRows().filter((r) => r.getAttribute("tabindex") === "0");
|
const tabStops = () => gridRows().filter((r) => r.getAttribute("tabindex") === "0");
|
||||||
|
|
||||||
/** Fire a drop without awaiting it — for the paths that stop to ask a question. */
|
|
||||||
function dropWithoutWaiting(paths: string[], position = { x: 100, y: 100 }) {
|
|
||||||
let pending: unknown;
|
|
||||||
act(() => {
|
|
||||||
pending = dragHandler?.({ payload: { type: "drop", position, paths } });
|
|
||||||
});
|
|
||||||
return pending as Promise<void> | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
dragHandler = null;
|
|
||||||
listContainerFiles.mockResolvedValue([
|
listContainerFiles.mockResolvedValue([
|
||||||
entry("src", { is_directory: true, path: "/workspace/src" }),
|
entry("src", { is_directory: true, path: "/workspace/src" }),
|
||||||
entry("notes.txt"),
|
entry("notes.txt"),
|
||||||
]);
|
]);
|
||||||
// jsdom lays nothing out, so the pane's hit-test rect has to be supplied.
|
|
||||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
|
|
||||||
x: 0, y: 0, left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600,
|
|
||||||
toJSON: () => ({}),
|
|
||||||
} as DOMRect);
|
|
||||||
// Not implemented in jsdom; the image preview needs both halves.
|
// Not implemented in jsdom; the image preview needs both halves.
|
||||||
URL.createObjectURL = vi.fn(() => "blob:mock-url");
|
URL.createObjectURL = vi.fn(() => "blob:mock-url");
|
||||||
URL.revokeObjectURL = vi.fn();
|
URL.revokeObjectURL = vi.fn();
|
||||||
@@ -224,7 +175,9 @@ describe("FilesTab viewer", () => {
|
|||||||
});
|
});
|
||||||
expect(await screen.findByText(/too large to preview/)).toBeTruthy();
|
expect(await screen.findByText(/too large to preview/)).toBeTruthy();
|
||||||
expect(screen.queryByAltText("huge.png")).toBeNull();
|
expect(screen.queryByAltText("huge.png")).toBeNull();
|
||||||
expect(screen.getByRole("button", { name: "Save to host…" })).toBeTruthy();
|
// The way out is named, and it is not a host path this pane could write:
|
||||||
|
// a terminal inside the container, or a backup.
|
||||||
|
expect(screen.getByText(/take a backup/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("says so in words when only a prefix of a big text file came back", async () => {
|
it("says so in words when only a prefix of a big text file came back", async () => {
|
||||||
@@ -239,7 +192,7 @@ describe("FilesTab viewer", () => {
|
|||||||
expect(screen.getByText("first megabyte")).toBeTruthy();
|
expect(screen.getByText("first megabyte")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers Save to host for a file it cannot render", async () => {
|
it("says there is no preview, and where to open the file instead", async () => {
|
||||||
listContainerFiles.mockResolvedValue([entry("blob.bin")]);
|
listContainerFiles.mockResolvedValue([entry("blob.bin")]);
|
||||||
readContainerFile.mockResolvedValue(contents("a\x00b"));
|
readContainerFile.mockResolvedValue(contents("a\x00b"));
|
||||||
await renderTab();
|
await renderTab();
|
||||||
@@ -314,191 +267,6 @@ describe("FilesTab new folder", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("FilesTab host drag-and-drop", () => {
|
|
||||||
it("uploads dropped paths into the directory on screen, then re-lists", async () => {
|
|
||||||
await renderTab();
|
|
||||||
listContainerFiles.mockClear();
|
|
||||||
await drop(["/host/a.png", "/host/b.png"]);
|
|
||||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(1, "p1", "/host/a.png", "/workspace");
|
|
||||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.png", "/workspace");
|
|
||||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops into the directory the user has navigated to", async () => {
|
|
||||||
await renderTab();
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.doubleClick(screen.getByText("src"));
|
|
||||||
});
|
|
||||||
await drop(["/host/a.png"]);
|
|
||||||
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.png", "/workspace/src");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores a drop outside the pane — the listener is window-wide", async () => {
|
|
||||||
// This is the whole routing discipline: the terminal's listener is live at
|
|
||||||
// the same time, and only the hit-test keeps them apart.
|
|
||||||
await renderTab();
|
|
||||||
await drop(["/host/a.png"], { x: 5000, y: 5000 });
|
|
||||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("divides the payload position by devicePixelRatio on Windows only", async () => {
|
|
||||||
// Only wry's WebView2 backend hands over *physical* pixels; the macOS and
|
|
||||||
// GTK ones deliver logical points and `tauri-runtime-wry` does not rescale
|
|
||||||
// them. At dpr 2 a physical (900, 900) is a CSS (450, 450) — inside the
|
|
||||||
// 800x600 pane — but the same payload on a HiDPI Mac or Linux box really
|
|
||||||
// is (900, 900) and belongs to nobody.
|
|
||||||
const originalDpr = window.devicePixelRatio;
|
|
||||||
const originalUa = window.navigator.userAgent;
|
|
||||||
Object.defineProperty(window, "devicePixelRatio", { value: 2, configurable: true });
|
|
||||||
Object.defineProperty(window.navigator, "userAgent", {
|
|
||||||
value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
await renderTab();
|
|
||||||
await drop(["/host/a.png"], { x: 900, y: 900 });
|
|
||||||
expect(uploadFileToContainer).toHaveBeenCalled();
|
|
||||||
|
|
||||||
Object.defineProperty(window.navigator, "userAgent", {
|
|
||||||
value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
vi.mocked(uploadFileToContainer).mockClear();
|
|
||||||
await drop(["/host/a.png"], { x: 900, y: 900 });
|
|
||||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
|
||||||
// …and the *unhalved* point still lands, which is the half a HiDPI Mac
|
|
||||||
// user was losing.
|
|
||||||
await drop(["/host/a.png"], { x: 400, y: 300 });
|
|
||||||
expect(uploadFileToContainer).toHaveBeenCalled();
|
|
||||||
|
|
||||||
Object.defineProperty(window, "devicePixelRatio", {
|
|
||||||
value: originalDpr,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
Object.defineProperty(window.navigator, "userAgent", {
|
|
||||||
value: originalUa,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts a drop that lands on a toast floating over the pane", async () => {
|
|
||||||
// Round 1. `ToastHost` is `fixed bottom-4 right-4 z-[60]` and 24rem wide,
|
|
||||||
// and its error cards stay until dismissed — so a z-order gate asking "is
|
|
||||||
// what is painted here part of my pane?" made the bottom-right corner of
|
|
||||||
// this pane refuse drops for as long as one error was on screen. jsdom has
|
|
||||||
// no `elementFromPoint`, so that branch only ran when a test supplied one;
|
|
||||||
// the gate no longer asks, and this pins that nothing painted over a pane
|
|
||||||
// can refuse a drop on its own account.
|
|
||||||
await renderTab();
|
|
||||||
const toastCard = document.createElement("div");
|
|
||||||
document.body.appendChild(toastCard);
|
|
||||||
Object.defineProperty(document, "elementFromPoint", {
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
value: () => toastCard,
|
|
||||||
});
|
|
||||||
|
|
||||||
await drop(["/host/a.png"], { x: 700, y: 550 });
|
|
||||||
expect(uploadFileToContainer).toHaveBeenCalled();
|
|
||||||
|
|
||||||
delete (document as Partial<Document>).elementFromPoint;
|
|
||||||
toastCard.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses a drop while a dialog is open, toast painted over it or not", async () => {
|
|
||||||
// Round 2, which is the reason this file exists in its current shape. The
|
|
||||||
// refusal pushes a toast; `ToastHost` is `z-[60]` and the `Modal` backdrop
|
|
||||||
// is `z-50` in the same stacking context, so the *toast* becomes the
|
|
||||||
// topmost element over a covered pane. A gate that asked `elementFromPoint`
|
|
||||||
// "is a blocker painted here?" then answered no and uploaded into the
|
|
||||||
// directory the dialog was covering — one refused drop was all it took to
|
|
||||||
// open the hole. Both stubs below therefore have to be refused.
|
|
||||||
await renderTab();
|
|
||||||
const backdrop = document.createElement("div");
|
|
||||||
backdrop.setAttribute("data-blocks-drop", "true");
|
|
||||||
document.body.appendChild(backdrop);
|
|
||||||
const toastCard = document.createElement("div"); // z-[60], above the backdrop
|
|
||||||
document.body.appendChild(toastCard);
|
|
||||||
const stub = (top: Element) =>
|
|
||||||
Object.defineProperty(document, "elementFromPoint", {
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
value: () => top,
|
|
||||||
});
|
|
||||||
|
|
||||||
stub(backdrop);
|
|
||||||
await drop(["/host/a.png"], { x: 400, y: 300 });
|
|
||||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
stub(toastCard);
|
|
||||||
await drop(["/host/a.png"], { x: 700, y: 550 });
|
|
||||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
delete (document as Partial<Document>).elementFromPoint;
|
|
||||||
toastCard.remove();
|
|
||||||
backdrop.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("highlights the pane while a drag hovers it, and drops the highlight on leave", async () => {
|
|
||||||
await renderTab();
|
|
||||||
await act(async () => {
|
|
||||||
await dragHandler?.({
|
|
||||||
payload: { type: "over", position: { x: 100, y: 100 }, paths: [] },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(screen.getByText(/Drop files into \/workspace/)).toBeTruthy();
|
|
||||||
await act(async () => {
|
|
||||||
await dragHandler?.({ payload: { type: "leave" } });
|
|
||||||
});
|
|
||||||
expect(screen.queryByText(/Drop files into/)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("FilesTab save to host", () => {
|
|
||||||
it("copies a file out to the path the user picks", async () => {
|
|
||||||
await renderTab();
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save to host… — notes.txt" }));
|
|
||||||
});
|
|
||||||
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "/host/out");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not offer a directory download, which cannot work", async () => {
|
|
||||||
await renderTab();
|
|
||||||
expect(screen.queryByRole("button", { name: "Save to host… — src" })).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("FilesTab drop hit test", () => {
|
|
||||||
it("uploads nothing when a dialog is covering the pane", async () => {
|
|
||||||
// The pane still has its rect underneath the viewer's `fixed inset-0`
|
|
||||||
// portal, which is exactly why a rect alone was the wrong test.
|
|
||||||
readContainerFile.mockResolvedValue(contents("hello"));
|
|
||||||
await renderTab();
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.doubleClick(screen.getByText("notes.txt"));
|
|
||||||
});
|
|
||||||
await screen.findByRole("dialog");
|
|
||||||
|
|
||||||
await drop(["/host/a.png"]);
|
|
||||||
|
|
||||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not paint the hint under a dialog either", async () => {
|
|
||||||
readContainerFile.mockResolvedValue(contents("hello"));
|
|
||||||
await renderTab();
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.doubleClick(screen.getByText("notes.txt"));
|
|
||||||
});
|
|
||||||
await screen.findByRole("dialog");
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await dragHandler?.({ payload: { type: "over", position: { x: 100, y: 100 }, paths: [] } });
|
|
||||||
});
|
|
||||||
expect(screen.queryByText(/Drop files into/)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("FilesTab grid focus", () => {
|
describe("FilesTab grid focus", () => {
|
||||||
it("gives the grid exactly one tab stop and moves it with the arrows", async () => {
|
it("gives the grid exactly one tab stop and moves it with the arrows", async () => {
|
||||||
// Every row used to be `tabIndex={0}`: a 400-entry directory was ~1200 tab
|
// Every row used to be `tabIndex={0}`: a 400-entry directory was ~1200 tab
|
||||||
@@ -592,22 +360,28 @@ describe("FilesTab grid semantics", () => {
|
|||||||
await renderTab();
|
await renderTab();
|
||||||
const rename = screen.getByRole("button", { name: "Rename — notes.txt" });
|
const rename = screen.getByRole("button", { name: "Rename — notes.txt" });
|
||||||
expect(rename.textContent).toBe("Rename");
|
expect(rename.textContent).toBe("Rename");
|
||||||
expect(rename.getAttribute("aria-label")).toContain("Rename");
|
expect(rename.getAttribute("aria-label")).toContain(rename.textContent!);
|
||||||
const saveTo = screen.getByRole("button", { name: "Save to host… — notes.txt" });
|
|
||||||
expect(saveTo.getAttribute("aria-label")).toContain(saveTo.textContent!);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mounts the live region empty, then fills it", async () => {
|
it("mounts the live region empty, then fills it", async () => {
|
||||||
// A `role="status"` node inserted already carrying its text is frequently
|
// A `role="status"` node inserted already carrying its text is frequently
|
||||||
// not announced at all, which is how every one of these went by in silence.
|
// not announced at all, which is how every one of these went by in silence.
|
||||||
|
createContainerDirectory.mockResolvedValue("/workspace/new");
|
||||||
await renderTab();
|
await renderTab();
|
||||||
const live = screen.getByRole("status");
|
const live = screen.getByRole("status");
|
||||||
expect(live.textContent).toBe("");
|
expect(live.textContent).toBe("");
|
||||||
|
|
||||||
await drop(["/host/a.png"]);
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "New folder" }));
|
||||||
|
});
|
||||||
|
const input = screen.getByLabelText("New folder name");
|
||||||
|
fireEvent.change(input, { target: { value: "new" } });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.blur(input);
|
||||||
|
});
|
||||||
// Same node throughout — it is never unmounted.
|
// Same node throughout — it is never unmounted.
|
||||||
expect(screen.getByRole("status")).toBe(live);
|
expect(screen.getByRole("status")).toBe(live);
|
||||||
expect(live.textContent).toContain("Uploaded 1 item");
|
expect(live.textContent).toContain('Created "new"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a listing failure inline, where the rows it explains are missing", async () => {
|
it("keeps a listing failure inline, where the rows it explains are missing", async () => {
|
||||||
@@ -618,130 +392,3 @@ describe("FilesTab grid semantics", () => {
|
|||||||
expect(screen.getByRole("alert").textContent).toContain("Permission denied");
|
expect(screen.getByRole("alert").textContent).toContain("Permission denied");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("FilesTab overwrite prompt", () => {
|
|
||||||
it("asks before replacing, and re-uploads with overwrite on Replace", async () => {
|
|
||||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/notes.txt already exists");
|
|
||||||
await renderTab();
|
|
||||||
|
|
||||||
const pending = dropWithoutWaiting(["/host/notes.txt"]);
|
|
||||||
const dialog = await screen.findByRole("dialog");
|
|
||||||
expect(dialog.textContent).toContain("notes.txt");
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
|
|
||||||
await pending;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(uploadFileToContainer).toHaveBeenLastCalledWith(
|
|
||||||
"p1",
|
|
||||||
"/host/notes.txt",
|
|
||||||
"/workspace",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(screen.queryByRole("dialog")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uploads nothing more on Skip", async () => {
|
|
||||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/notes.txt already exists");
|
|
||||||
await renderTab();
|
|
||||||
|
|
||||||
const pending = dropWithoutWaiting(["/host/notes.txt"]);
|
|
||||||
await screen.findByRole("dialog");
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Skip" }));
|
|
||||||
await pending;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
|
|
||||||
expect(screen.queryByRole("dialog")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers the blanket answers only when files are queued behind this one", async () => {
|
|
||||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/a.txt already exists");
|
|
||||||
await renderTab();
|
|
||||||
|
|
||||||
const pending = dropWithoutWaiting(["/host/a.txt", "/host/b.txt"]);
|
|
||||||
await screen.findByRole("dialog");
|
|
||||||
expect(screen.getByRole("button", { name: "Replace all" })).toBeTruthy();
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Skip all" }));
|
|
||||||
await pending;
|
|
||||||
});
|
|
||||||
expect(screen.queryByRole("dialog")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dismissal. `Modal` gives every dialog Escape, a ✕ and click-outside for free,
|
|
||||||
* and `OverwriteConfirmModal` maps all three onto `onChoose("skip")` — because
|
|
||||||
* the destructive answer has to be chosen, and because a dialog that is closed
|
|
||||||
* rather than answered must not leave the batch waiting forever or throw away
|
|
||||||
* the files behind it.
|
|
||||||
*/
|
|
||||||
describe("FilesTab overwrite prompt dismissal", () => {
|
|
||||||
/**
|
|
||||||
* Drop two files where the first name is taken, and stop at the dialog. The
|
|
||||||
* unsettled batch comes back wrapped — returning it bare from an `async`
|
|
||||||
* helper would adopt it, and awaiting the helper would then wait for an
|
|
||||||
* upload that cannot proceed until the helper has returned.
|
|
||||||
*/
|
|
||||||
async function dropIntoConflict(): Promise<{ batch: Promise<void> | undefined }> {
|
|
||||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/a.txt already exists");
|
|
||||||
await renderTab();
|
|
||||||
const batch = dropWithoutWaiting(["/host/a.txt", "/host/b.txt"]);
|
|
||||||
await screen.findByRole("dialog");
|
|
||||||
return { batch };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What every dismissal has to leave behind: one skip, one upload, no clobber. */
|
|
||||||
function expectSkippedAndCarriedOn() {
|
|
||||||
expect(screen.queryByRole("dialog")).toBeNull();
|
|
||||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
|
|
||||||
expect(uploadFileToContainer).toHaveBeenLastCalledWith("p1", "/host/b.txt", "/workspace");
|
|
||||||
expect(uploadFileToContainer.mock.calls.some((call) => call[3] === true)).toBe(false);
|
|
||||||
expect(screen.getByRole("status").textContent).toContain("skipped 1");
|
|
||||||
}
|
|
||||||
|
|
||||||
it("counts Escape as a Skip", async () => {
|
|
||||||
const { batch } = await dropIntoConflict();
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.keyDown(document, { key: "Escape" });
|
|
||||||
await batch;
|
|
||||||
});
|
|
||||||
expectSkippedAndCarriedOn();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("counts the ✕ as a Skip", async () => {
|
|
||||||
const { batch } = await dropIntoConflict();
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Close dialog" }));
|
|
||||||
await batch;
|
|
||||||
});
|
|
||||||
expectSkippedAndCarriedOn();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("counts a click on the backdrop as a Skip", async () => {
|
|
||||||
const { batch } = await dropIntoConflict();
|
|
||||||
// The overlay is the dialog panel's parent — `Modal` only closes when the
|
|
||||||
// click landed on the overlay itself, not on anything inside the panel.
|
|
||||||
const overlay = screen.getByRole("dialog").parentElement!;
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(overlay);
|
|
||||||
await batch;
|
|
||||||
});
|
|
||||||
expectSkippedAndCarriedOn();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not dismiss on a click inside the dialog", async () => {
|
|
||||||
const { batch } = await dropIntoConflict();
|
|
||||||
fireEvent.click(screen.getByRole("dialog"));
|
|
||||||
expect(screen.queryByRole("dialog")).not.toBeNull();
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
|
|
||||||
await batch;
|
|
||||||
});
|
|
||||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
|
||||||
import type { FileEntry, Project } from "../../../lib/types";
|
import type { FileEntry, Project } from "../../../lib/types";
|
||||||
import { useFileManager } from "../../../hooks/useFileManager";
|
import { useFileManager } from "../../../hooks/useFileManager";
|
||||||
import { classifyDrop, isDropTarget, DROP_BLOCKED_TOAST } from "../../../lib/dropTarget";
|
|
||||||
import { useAppState } from "../../../store/appState";
|
|
||||||
import Button from "../../ui/Button";
|
import Button from "../../ui/Button";
|
||||||
import FileViewerModal from "./FileViewerModal";
|
import FileViewerModal from "./FileViewerModal";
|
||||||
import OverwriteConfirmModal from "./OverwriteConfirmModal";
|
|
||||||
import { formatBytes } from "./format";
|
import { formatBytes } from "./format";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -17,7 +13,15 @@ interface Props {
|
|||||||
const PARENT_ROW = "..";
|
const PARENT_ROW = "..";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The project's file manager.
|
* The project's file browser.
|
||||||
|
*
|
||||||
|
* Container-side only: it lists, opens, renames and creates folders inside the
|
||||||
|
* container, and it does no host filesystem I/O at all. A file gets *into* a
|
||||||
|
* container by being dropped onto the Terminal tab, and a whole tree comes back
|
||||||
|
* out through "Back up container" in the project's Workspace settings. Four
|
||||||
|
* successive audits found that host paths crossing IPC were where the criticals
|
||||||
|
* lived; those two paths are the ones that survived, and this pane is not one
|
||||||
|
* of them.
|
||||||
*
|
*
|
||||||
* Interaction model, chosen to match every desktop file manager rather than
|
* Interaction model, chosen to match every desktop file manager rather than
|
||||||
* the old half-and-half: **single click selects, double click opens**. That
|
* the old half-and-half: **single click selects, double click opens**. That
|
||||||
@@ -42,16 +46,10 @@ export default function FilesTab({ project }: Props) {
|
|||||||
entries,
|
entries,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
busy,
|
|
||||||
completed,
|
completed,
|
||||||
conflict,
|
|
||||||
resolveConflict,
|
|
||||||
navigate,
|
navigate,
|
||||||
goUp,
|
goUp,
|
||||||
refresh,
|
refresh,
|
||||||
downloadFile,
|
|
||||||
uploadFile,
|
|
||||||
uploadPaths,
|
|
||||||
renameEntry,
|
renameEntry,
|
||||||
createFolder,
|
createFolder,
|
||||||
} = useFileManager(project.id);
|
} = useFileManager(project.id);
|
||||||
@@ -65,8 +63,6 @@ export default function FilesTab({ project }: Props) {
|
|||||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||||
const [folderDraft, setFolderDraft] = useState("");
|
const [folderDraft, setFolderDraft] = useState("");
|
||||||
const [viewing, setViewing] = useState<FileEntry | null>(null);
|
const [viewing, setViewing] = useState<FileEntry | null>(null);
|
||||||
/** A host drag is currently over this pane. */
|
|
||||||
const [dragOver, setDragOver] = useState(false);
|
|
||||||
/** The row that owns the grid's single tab stop. */
|
/** The row that owns the grid's single tab stop. */
|
||||||
const [activeRow, setActiveRow] = useState<string | null>(null);
|
const [activeRow, setActiveRow] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -234,58 +230,6 @@ export default function FilesTab({ project }: Props) {
|
|||||||
goUp();
|
goUp();
|
||||||
}, [currentPath, goUp]);
|
}, [currentPath, goUp]);
|
||||||
|
|
||||||
// Host → container drag and drop.
|
|
||||||
//
|
|
||||||
// This is Tauri's *native* drag-drop event, not HTML5 `ondrop`, for the same
|
|
||||||
// reason `TerminalView` uses it: `dragDropEnabled` is on (the terminal needs
|
|
||||||
// it), which blocks HTML5 drag inside the webview on Windows, and only the
|
|
||||||
// native payload carries real file *paths*. The listener is window-wide, so
|
|
||||||
// routing is `classifyDrop` — the rect hit test, which says *whose* drop it
|
|
||||||
// is, plus the document-wide question a rect cannot answer: is a modal or a
|
|
||||||
// blocking overlay on screen at all? That second half is deliberately not a
|
|
||||||
// per-point z-order test; `lib/dropTarget.ts` records the two ways that went
|
|
||||||
// wrong.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!running) return;
|
|
||||||
let unlisten: (() => void) | undefined;
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
|
|
||||||
const payload = event.payload;
|
|
||||||
if (payload.type === "leave") {
|
|
||||||
setDragOver(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (payload.type === "enter" || payload.type === "over") {
|
|
||||||
setDragOver(isDropTarget(paneRef.current, payload.position));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (payload.type !== "drop") return;
|
|
||||||
setDragOver(false);
|
|
||||||
const verdict = classifyDrop(paneRef.current, payload.position);
|
|
||||||
// Aimed at this pane and refused anyway: say so. Nothing else would —
|
|
||||||
// the file just never appears in the listing.
|
|
||||||
if (verdict === "blocked") {
|
|
||||||
console.warn("[drop] refused: a dialog or overlay is open", payload.position);
|
|
||||||
useAppState.getState().pushToast(DROP_BLOCKED_TOAST);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (verdict !== "accept") return;
|
|
||||||
const paths = payload.paths ?? [];
|
|
||||||
if (paths.length === 0) return;
|
|
||||||
await uploadPaths(paths);
|
|
||||||
});
|
|
||||||
if (cancelled) un();
|
|
||||||
else unlisten = un;
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
unlisten?.();
|
|
||||||
};
|
|
||||||
}, [running, uploadPaths]);
|
|
||||||
|
|
||||||
const breadcrumbs =
|
const breadcrumbs =
|
||||||
currentPath === "/"
|
currentPath === "/"
|
||||||
? [{ label: "/", path: "/" }]
|
? [{ label: "/", path: "/" }]
|
||||||
@@ -324,10 +268,10 @@ export default function FilesTab({ project }: Props) {
|
|||||||
/**
|
/**
|
||||||
* The live region's text. One region, always mounted, filled and emptied —
|
* The live region's text. One region, always mounted, filled and emptied —
|
||||||
* a `role="status"` node that is *inserted* already carrying its text is
|
* a `role="status"` node that is *inserted* already carrying its text is
|
||||||
* frequently not announced at all, which is how "uploading 3 items…" and
|
* frequently not announced at all, which is how every completion notice used
|
||||||
* every completion notice used to go by in silence.
|
* to go by in silence.
|
||||||
*/
|
*/
|
||||||
const liveText = busy ? busy : (completed ?? "");
|
const liveText = completed ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={paneRef} className="relative flex flex-col h-full min-h-0">
|
<div ref={paneRef} className="relative flex flex-col h-full min-h-0">
|
||||||
@@ -361,9 +305,6 @@ export default function FilesTab({ project }: Props) {
|
|||||||
>
|
>
|
||||||
New folder
|
New folder
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={uploadFile} className="ml-1">
|
|
||||||
Upload file
|
|
||||||
</Button>
|
|
||||||
<Button onClick={refresh} disabled={loading} className="ml-1">
|
<Button onClick={refresh} disabled={loading} className="ml-1">
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
@@ -372,9 +313,9 @@ export default function FilesTab({ project }: Props) {
|
|||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
{/* The one failure that stays inline: it explains why the grid below is
|
{/* The one failure that stays inline: it explains why the grid below is
|
||||||
empty, it is in context, and there are no rows for it to scroll
|
empty, it is in context, and there are no rows for it to scroll
|
||||||
behind. Every *transient* failure — upload, rename, mkdir,
|
behind. Every *transient* failure — rename, new folder — goes to
|
||||||
save-to-host — goes to `ToastHost` instead, which is above
|
`ToastHost` instead, which is above the file viewer's overlay and
|
||||||
the file viewer's overlay and does not scroll away. */}
|
does not scroll away. */}
|
||||||
{error && (
|
{error && (
|
||||||
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
|
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
|
||||||
{error}
|
{error}
|
||||||
@@ -560,18 +501,6 @@ export default function FilesTab({ project }: Props) {
|
|||||||
>
|
>
|
||||||
Rename
|
Rename
|
||||||
</Button>
|
</Button>
|
||||||
{!entry.is_directory && (
|
|
||||||
<Button
|
|
||||||
aria-label={`Save to host… — ${entry.name}`}
|
|
||||||
className="ml-1"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
downloadFile(entry);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save to host…
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -594,34 +523,11 @@ export default function FilesTab({ project }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drop hint. Purely decorative — the native listener is what accepts the
|
|
||||||
drop, so this must never intercept pointer events. */}
|
|
||||||
{dragOver && (
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute inset-0 flex items-center justify-center border-2 border-dashed border-[var(--accent)] bg-[var(--bg-primary)]/70"
|
|
||||||
>
|
|
||||||
<span className="text-[13px] font-medium text-[var(--text-primary)]">
|
|
||||||
Drop files into {currentPath}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{conflict && (
|
|
||||||
<OverwriteConfirmModal
|
|
||||||
name={conflict.name}
|
|
||||||
directory={conflict.directory}
|
|
||||||
remaining={conflict.remaining}
|
|
||||||
onChoose={resolveConflict}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{viewing && (
|
{viewing && (
|
||||||
<FileViewerModal
|
<FileViewerModal
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
entry={viewing}
|
entry={viewing}
|
||||||
onClose={() => setViewing(null)}
|
onClose={() => setViewing(null)}
|
||||||
onSaveToHost={downloadFile}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import type { OverwriteChoice } from "../../../lib/uploadErrors";
|
|
||||||
import Button from "../../ui/Button";
|
|
||||||
import Modal from "../../ui/Modal";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Bare name of the file that is already there. */
|
|
||||||
name: string;
|
|
||||||
/** Container directory it is going into. */
|
|
||||||
directory: string;
|
|
||||||
/** How many more files are queued behind this one. */
|
|
||||||
remaining: number;
|
|
||||||
onChoose: (choice: OverwriteChoice) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "That name is taken — replace it?"
|
|
||||||
*
|
|
||||||
* This exists because the backend stopped overwriting silently, and a raw
|
|
||||||
* error string would have been a worse answer than the old silent clobber: it
|
|
||||||
* tells the user their drop failed without telling them it *can* succeed. The
|
|
||||||
* dialog names the file and the directory, because a drop is aimed with a
|
|
||||||
* mouse and "notes.txt" alone does not say which `notes.txt`.
|
|
||||||
*
|
|
||||||
* The blanket answers only appear when there is something to apply them to — a
|
|
||||||
* single-file drop with "Replace all" on it invites the reflex of clicking the
|
|
||||||
* widest button for no benefit.
|
|
||||||
*
|
|
||||||
* Dismissing (Escape, ✕, click-outside) is a **skip**, never a replace: the
|
|
||||||
* destructive answer has to be chosen explicitly.
|
|
||||||
*/
|
|
||||||
export default function OverwriteConfirmModal({ name, directory, remaining, onChoose }: Props) {
|
|
||||||
const footer = (
|
|
||||||
<>
|
|
||||||
{remaining > 0 && (
|
|
||||||
<>
|
|
||||||
<Button size="md" onClick={() => onChoose("skip-all")}>
|
|
||||||
Skip all
|
|
||||||
</Button>
|
|
||||||
<Button size="md" onClick={() => onChoose("replace-all")}>
|
|
||||||
Replace all
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button size="md" onClick={() => onChoose("skip")}>
|
|
||||||
Skip
|
|
||||||
</Button>
|
|
||||||
<Button size="md" variant="primary" onClick={() => onChoose("replace")}>
|
|
||||||
Replace
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title="A file with that name is already there"
|
|
||||||
description={directory}
|
|
||||||
onClose={() => onChoose("skip")}
|
|
||||||
footer={footer}
|
|
||||||
widthClassName="w-[30rem]"
|
|
||||||
>
|
|
||||||
<p className="text-[13px] text-[var(--text-primary)]">
|
|
||||||
<span className="font-mono">{name}</span> already exists in{" "}
|
|
||||||
<span className="font-mono">{directory}</span>. Replacing it overwrites the container's
|
|
||||||
copy, and that cannot be undone from here.
|
|
||||||
</p>
|
|
||||||
{remaining > 0 && (
|
|
||||||
<p className="mt-2 text-xs text-[var(--text-secondary)]">
|
|
||||||
{remaining} more file{remaining === 1 ? "" : "s"} still to upload.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -109,7 +109,16 @@ export default function RuntimeSection({
|
|||||||
|
|
||||||
<ConfigGroup
|
<ConfigGroup
|
||||||
title="Claude Code settings"
|
title="Claude Code settings"
|
||||||
description="Per-project CLI behaviour. Anything left on Global follows Settings; Off overrides a global On."
|
description={
|
||||||
|
"Per-project CLI behaviour. Anything left on Global follows Settings; " +
|
||||||
|
"Off overrides a global On. Changing any of these recreates the container, " +
|
||||||
|
"which commits a new image layer — so flipping switches repeatedly costs disk. " +
|
||||||
|
"Turning TUI mode, Effort level, Focus mode or Session recap back to Global " +
|
||||||
|
"also needs the base image updated first: those four are cleared by removing a " +
|
||||||
|
"key, and an older image's startup script ignores the instruction to remove it. " +
|
||||||
|
"Update the base image from Overview. TUI mode, Effort level and Focus mode " +
|
||||||
|
"visibly refuse to switch off until you do; Session recap just stays off silently."
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ClaudeCodeSettingsEditor
|
<ClaudeCodeSettingsEditor
|
||||||
scope="project"
|
scope="project"
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
|
import WorkspaceSection from "./WorkspaceSection";
|
||||||
|
import type { Project } from "../../../../lib/types";
|
||||||
|
|
||||||
|
// The Browse button is the OS folder picker.
|
||||||
|
const open = vi.fn();
|
||||||
|
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||||
|
open: (...args: unknown[]) => open(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseProject: Project = {
|
||||||
|
id: "p1",
|
||||||
|
name: "api-server",
|
||||||
|
paths: [{ host_path: "/src/api", mount_name: "api" }],
|
||||||
|
container_id: null,
|
||||||
|
status: "stopped",
|
||||||
|
backend: "anthropic",
|
||||||
|
bedrock_config: null,
|
||||||
|
ollama_config: null,
|
||||||
|
llamacpp_config: null,
|
||||||
|
openai_compatible_config: null,
|
||||||
|
allow_docker_access: false,
|
||||||
|
sandbox_mode_enabled: true,
|
||||||
|
mission_control_enabled: false,
|
||||||
|
auth_bridge_enabled: false,
|
||||||
|
browser_view_enabled: false,
|
||||||
|
vpn_support_enabled: false,
|
||||||
|
use_shared_auth_token: true,
|
||||||
|
full_permissions: false,
|
||||||
|
permission_mode: null,
|
||||||
|
ssh_key_path: null,
|
||||||
|
ca_cert_path: null,
|
||||||
|
git_token: null,
|
||||||
|
git_user_name: null,
|
||||||
|
git_user_email: null,
|
||||||
|
custom_env_vars: [],
|
||||||
|
port_mappings: [],
|
||||||
|
claude_instructions: null,
|
||||||
|
claude_code_settings: null,
|
||||||
|
renamed_session_names: {},
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = vi.fn().mockResolvedValue(true);
|
||||||
|
|
||||||
|
function renderSection(over: Partial<Project> = {}, disabled = false) {
|
||||||
|
return render(
|
||||||
|
<WorkspaceSection
|
||||||
|
project={{ ...baseProject, ...over }}
|
||||||
|
save={save}
|
||||||
|
disabled={disabled}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every folder list this component has sent to `update_project`. */
|
||||||
|
function savedLists() {
|
||||||
|
return save.mock.calls
|
||||||
|
.filter(([patch]) => "paths" in patch)
|
||||||
|
.map(([patch]) => patch.paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WorkspaceSection — the blank row is never stored", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bug this file exists for. `create_container` mounts every stored row
|
||||||
|
* unfiltered, so a persisted `{host_path: "", mount_name: ""}` becomes
|
||||||
|
* `{"Target": "/workspace/", "Source": ""}` and the daemon refuses the whole
|
||||||
|
* container with `field Source must not be empty` — the project can never be
|
||||||
|
* started or recreated again. Click "+ Add folder", blur a field, and it is
|
||||||
|
* bricked.
|
||||||
|
*/
|
||||||
|
it("drops the placeholder row when a real edit is saved", () => {
|
||||||
|
renderSection();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||||
|
|
||||||
|
const hostPath = screen.getByLabelText("Folder 1 host path");
|
||||||
|
fireEvent.change(hostPath, { target: { value: "/src/api-v2" } });
|
||||||
|
fireEvent.blur(hostPath);
|
||||||
|
|
||||||
|
expect(save).toHaveBeenCalledTimes(1);
|
||||||
|
expect(savedLists()[0]).toEqual([{ host_path: "/src/api-v2", mount_name: "api" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops it when Browse fills a different row in", async () => {
|
||||||
|
open.mockResolvedValueOnce("/src/api-v2");
|
||||||
|
renderSection();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||||
|
|
||||||
|
// The picker is awaited inside the handler, so the state update that
|
||||||
|
// follows it lands outside the click.
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "Browse" })[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(savedLists()[0]).toEqual([{ host_path: "/src/api-v2", mount_name: "api" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops it when a row is removed", () => {
|
||||||
|
renderSection({
|
||||||
|
paths: [
|
||||||
|
{ host_path: "/src/api", mount_name: "api" },
|
||||||
|
{ host_path: "/src/web", mount_name: "web" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove folder 2" }));
|
||||||
|
|
||||||
|
expect(savedLists()[0]).toEqual([{ host_path: "/src/api", mount_name: "api" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never sends a row with an empty host path, whatever the route", () => {
|
||||||
|
renderSection();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||||
|
const hostPath = screen.getByLabelText("Folder 1 host path");
|
||||||
|
fireEvent.change(hostPath, { target: { value: "/src/api-v2" } });
|
||||||
|
fireEvent.blur(hostPath);
|
||||||
|
|
||||||
|
for (const list of savedLists()) {
|
||||||
|
for (const row of list) {
|
||||||
|
expect(row.host_path).not.toBe("");
|
||||||
|
expect(row.mount_name).not.toBe("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WorkspaceSection — what a blur is allowed to save", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both inputs save on blur, so tabbing from the host path to the mount name
|
||||||
|
* fires a save with the name still empty — which `update_project` refuses,
|
||||||
|
* turning an ordinary keystroke into an error toast.
|
||||||
|
*/
|
||||||
|
it("holds a half-filled row back until it is complete", () => {
|
||||||
|
renderSection();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||||
|
|
||||||
|
const newHostPath = screen.getByLabelText("Folder 2 host path");
|
||||||
|
fireEvent.change(newHostPath, { target: { value: "/src/web" } });
|
||||||
|
fireEvent.blur(newHostPath);
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const newMountName = screen.getByLabelText("Folder 2 mount name");
|
||||||
|
fireEvent.change(newMountName, { target: { value: "web" } });
|
||||||
|
fireEvent.blur(newMountName);
|
||||||
|
expect(savedLists()[0]).toEqual([
|
||||||
|
{ host_path: "/src/api", mount_name: "api" },
|
||||||
|
{ host_path: "/src/web", mount_name: "web" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blurring out of an untouched field is not an edit. Saving anyway would
|
||||||
|
* round-trip the filtered list through `project` and take the empty row away
|
||||||
|
* while the user was still filling it in.
|
||||||
|
*/
|
||||||
|
it("saves nothing when the blur changed nothing", () => {
|
||||||
|
renderSection();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||||
|
fireEvent.blur(screen.getByLabelText("Folder 1 mount name"));
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByLabelText("Folder 2 host path")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still saves a rename, which does not go through the folder list", () => {
|
||||||
|
renderSection();
|
||||||
|
const name = screen.getByDisplayValue("api-server");
|
||||||
|
fireEvent.change(name, { target: { value: "api-v2" } });
|
||||||
|
fireEvent.blur(name);
|
||||||
|
expect(save).toHaveBeenCalledWith({ name: "api-v2" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,14 @@ interface Props {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether two folder lists are the same rows in the same order. */
|
||||||
|
function sameRows(a: ProjectPath[], b: ProjectPath[]): boolean {
|
||||||
|
return (
|
||||||
|
a.length === b.length &&
|
||||||
|
a.every((row, i) => row.host_path === b[i].host_path && row.mount_name === b[i].mount_name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function WorkspaceSection({ project, save, disabled }: Props) {
|
export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||||
const [name, setName] = useState(project.name);
|
const [name, setName] = useState(project.name);
|
||||||
const [paths, setPaths] = useState<ProjectPath[]>(project.paths ?? []);
|
const [paths, setPaths] = useState<ProjectPath[]>(project.paths ?? []);
|
||||||
@@ -19,6 +27,27 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
|||||||
setPaths(project.paths ?? []);
|
setPaths(project.paths ?? []);
|
||||||
}, [project]);
|
}, [project]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a folder list, minus the rows that are only in it because the UI
|
||||||
|
* put them there.
|
||||||
|
*
|
||||||
|
* **The blank row must never reach the store.** "+ Add folder" inserts
|
||||||
|
* `{host_path: "", mount_name: ""}` deliberately, and `create_container`
|
||||||
|
* mounts every stored row unfiltered — a stored blank one becomes
|
||||||
|
* `{"Target": "/workspace/", "Source": ""}`, which the daemon rejects with
|
||||||
|
* `field Source must not be empty`. The project then cannot be started or
|
||||||
|
* recreated at all, from a click and a blur. `AddProjectDialog` has always
|
||||||
|
* filtered this; this section computed the filtered list and then saved the
|
||||||
|
* unfiltered one.
|
||||||
|
*
|
||||||
|
* Every save goes through here for that reason — Browse and Remove write the
|
||||||
|
* list too, and either can be holding a blank row from an earlier click.
|
||||||
|
*/
|
||||||
|
const persist = (rows: ProjectPath[]) => {
|
||||||
|
const filled = rows.filter((p) => p.host_path.trim() || p.mount_name.trim());
|
||||||
|
return save({ paths: filled });
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save only when every row is fully filled in.
|
* Save only when every row is fully filled in.
|
||||||
*
|
*
|
||||||
@@ -27,12 +56,18 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
|||||||
* a half-filled row is refused — so the unconditional save turned an ordinary
|
* a half-filled row is refused — so the unconditional save turned an ordinary
|
||||||
* keystroke into an error toast. A blank row is *not* incomplete: the
|
* keystroke into an error toast. A blank row is *not* incomplete: the
|
||||||
* "+ Add folder" button adds one deliberately, and it is dropped on save.
|
* "+ Add folder" button adds one deliberately, and it is dropped on save.
|
||||||
|
*
|
||||||
|
* A blur that changed nothing saves nothing, which is what keeps the blank
|
||||||
|
* row on screen while it is being filled in: persisting the filtered list
|
||||||
|
* would round-trip through `project` and take the empty row away under the
|
||||||
|
* cursor.
|
||||||
*/
|
*/
|
||||||
const saveIfComplete = () => {
|
const saveIfComplete = () => {
|
||||||
const filled = paths.filter((p) => p.host_path.trim() || p.mount_name.trim());
|
const filled = paths.filter((p) => p.host_path.trim() || p.mount_name.trim());
|
||||||
const halfFilled = filled.some((p) => !p.host_path.trim() || !p.mount_name.trim());
|
const halfFilled = filled.some((p) => !p.host_path.trim() || !p.mount_name.trim());
|
||||||
if (halfFilled) return;
|
if (halfFilled) return;
|
||||||
return save({ paths });
|
if (sameRows(filled, project.paths ?? [])) return;
|
||||||
|
return persist(paths);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -106,7 +141,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
|||||||
mount_name: updated[i].mount_name || basename,
|
mount_name: updated[i].mount_name || basename,
|
||||||
};
|
};
|
||||||
setPaths(updated);
|
setPaths(updated);
|
||||||
save({ paths: updated });
|
persist(updated);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -137,7 +172,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const updated = paths.filter((_, j) => j !== i);
|
const updated = paths.filter((_, j) => j !== i);
|
||||||
setPaths(updated);
|
setPaths(updated);
|
||||||
save({ paths: updated });
|
persist(updated);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
|
|||||||
@@ -4,15 +4,11 @@ import { useFileManager } from "./useFileManager";
|
|||||||
import type { FileEntry } from "../lib/types";
|
import type { FileEntry } from "../lib/types";
|
||||||
|
|
||||||
const listContainerFiles = vi.fn();
|
const listContainerFiles = vi.fn();
|
||||||
const downloadContainerFile = vi.fn();
|
|
||||||
const uploadFileToContainer = vi.fn();
|
|
||||||
const renameContainerPath = vi.fn();
|
const renameContainerPath = vi.fn();
|
||||||
const createContainerDirectory = vi.fn();
|
const createContainerDirectory = vi.fn();
|
||||||
|
|
||||||
vi.mock("../lib/tauri-commands", () => ({
|
vi.mock("../lib/tauri-commands", () => ({
|
||||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||||
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
|
|
||||||
uploadFileToContainer: (...args: unknown[]) => uploadFileToContainer(...args),
|
|
||||||
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
||||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||||
createContainerDirectory(p, parent, n),
|
createContainerDirectory(p, parent, n),
|
||||||
@@ -35,13 +31,6 @@ const toastText = () =>
|
|||||||
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
|
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
const save = vi.fn();
|
|
||||||
const openDialog = vi.fn();
|
|
||||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
|
||||||
save: (opts: unknown) => save(opts),
|
|
||||||
open: (opts: unknown) => openDialog(opts),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const file = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
|
const file = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
|
||||||
name,
|
name,
|
||||||
path: `/workspace/${name}`,
|
path: `/workspace/${name}`,
|
||||||
@@ -100,48 +89,6 @@ describe("useFileManager navigation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("useFileManager uploads", () => {
|
|
||||||
it("uploads every dropped path into the current directory, then re-lists once", async () => {
|
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.navigate("/workspace/app");
|
|
||||||
});
|
|
||||||
listContainerFiles.mockClear();
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.uploadPaths(["/host/a.png", "/host/b.png"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(1, "p1", "/host/a.png", "/workspace/app");
|
|
||||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.png", "/workspace/app");
|
|
||||||
// One refresh for the batch, not one per file.
|
|
||||||
expect(listContainerFiles).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a failed upload but still lists whatever did land", async () => {
|
|
||||||
uploadFileToContainer.mockResolvedValueOnce(undefined);
|
|
||||||
uploadFileToContainer.mockRejectedValueOnce("File too large to upload (900 MB; limit 256 MB)");
|
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.uploadPaths(["/host/ok.txt", "/host/huge.bin"]);
|
|
||||||
});
|
|
||||||
// Inline `error` is reserved for the listing failure the user can see in
|
|
||||||
// context; a failed upload goes where it cannot scroll away.
|
|
||||||
expect(result.current.error).toBeNull();
|
|
||||||
expect(toastText()).toContain("too large");
|
|
||||||
expect(listContainerFiles).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing when the file picker is cancelled", async () => {
|
|
||||||
openDialog.mockResolvedValue(null);
|
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.uploadFile();
|
|
||||||
});
|
|
||||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("useFileManager rename and mkdir", () => {
|
describe("useFileManager rename and mkdir", () => {
|
||||||
it("sends the bare new name, never a path, and re-lists on success", async () => {
|
it("sends the bare new name, never a path, and re-lists on success", async () => {
|
||||||
renameContainerPath.mockResolvedValue("/workspace/renamed.txt");
|
renameContainerPath.mockResolvedValue("/workspace/renamed.txt");
|
||||||
@@ -204,47 +151,22 @@ describe("useFileManager rename and mkdir", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("useFileManager save to host", () => {
|
|
||||||
it("writes to the path the user picked", async () => {
|
|
||||||
save.mockResolvedValue("/host/Downloads/a.txt");
|
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.downloadFile(file("a.txt"));
|
|
||||||
});
|
|
||||||
expect(downloadContainerFile).toHaveBeenCalledWith(
|
|
||||||
"p1",
|
|
||||||
"/workspace/a.txt",
|
|
||||||
"/host/Downloads/a.txt",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a refused download — a directory is no longer written as garbage", async () => {
|
|
||||||
save.mockResolvedValue("/host/Downloads/src");
|
|
||||||
downloadContainerFile.mockRejectedValue("/workspace/src is a folder — download its files individually");
|
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.downloadFile(file("src", { is_directory: true }));
|
|
||||||
});
|
|
||||||
expect(toastText()).toContain("is a folder");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("useFileManager stays where the user is", () => {
|
describe("useFileManager stays where the user is", () => {
|
||||||
it("does not drag the pane back when the user navigates away mid-upload", async () => {
|
it("does not drag the pane back when the user navigates away mid-operation", async () => {
|
||||||
// The closure captured `/workspace`; the user is in `/workspace/src` by the
|
// The closure captured `/workspace`; the user is in `/workspace/src` by the
|
||||||
// time the copy finishes. Re-listing the *captured* path is what used to
|
// time the rename finishes. Re-listing the *captured* path is what used to
|
||||||
// yank them out of the directory they had walked into.
|
// yank them out of the directory they had walked into.
|
||||||
let failUpload: (reason: unknown) => void = () => {};
|
let failRename: (reason: unknown) => void = () => {};
|
||||||
// `Once`, deliberately: `clearAllMocks` clears calls but not
|
// `Once`, deliberately: `clearAllMocks` clears calls but not
|
||||||
// implementations, so a never-settling one would hang every test after it.
|
// implementations, so a never-settling one would hang every test after it.
|
||||||
uploadFileToContainer.mockImplementationOnce(
|
renameContainerPath.mockImplementationOnce(
|
||||||
() => new Promise((_resolve, reject) => { failUpload = reject; }),
|
() => new Promise((_resolve, reject) => { failRename = reject; }),
|
||||||
);
|
);
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
|
|
||||||
let upload!: Promise<void>;
|
let rename!: Promise<boolean>;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
upload = result.current.uploadPaths(["/host/big.bin"]);
|
rename = result.current.renameEntry(file("big.bin"), "bigger.bin");
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -255,13 +177,13 @@ describe("useFileManager stays where the user is", () => {
|
|||||||
listContainerFiles.mockClear();
|
listContainerFiles.mockClear();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
failUpload("cp: no space left on device");
|
failRename("mv: no space left on device");
|
||||||
await upload;
|
await rename;
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.currentPath).toBe("/workspace/src");
|
expect(result.current.currentPath).toBe("/workspace/src");
|
||||||
expect(result.current.entries.map((e) => e.name)).toEqual(["index.ts"]);
|
expect(result.current.entries.map((e) => e.name)).toEqual(["index.ts"]);
|
||||||
// No re-list of the directory the upload targeted…
|
// No re-list of the directory the rename targeted…
|
||||||
expect(listContainerFiles).not.toHaveBeenCalled();
|
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||||
// …and no failure text painted over the listing that replaced it.
|
// …and no failure text painted over the listing that replaced it.
|
||||||
expect(result.current.error).toBeNull();
|
expect(result.current.error).toBeNull();
|
||||||
@@ -269,13 +191,14 @@ describe("useFileManager stays where the user is", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("re-lists when the user stayed put, which is the ordinary case", async () => {
|
it("re-lists when the user stayed put, which is the ordinary case", async () => {
|
||||||
|
renameContainerPath.mockResolvedValue("/workspace/b.txt");
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.navigate("/workspace");
|
await result.current.navigate("/workspace");
|
||||||
});
|
});
|
||||||
listContainerFiles.mockClear();
|
listContainerFiles.mockClear();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.uploadPaths(["/host/a.png"]);
|
await result.current.renameEntry(file("a.txt"), "b.txt");
|
||||||
});
|
});
|
||||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
|
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
|
||||||
});
|
});
|
||||||
@@ -316,248 +239,11 @@ describe("useFileManager stays where the user is", () => {
|
|||||||
await result.current.navigate("/root");
|
await result.current.navigate("/root");
|
||||||
});
|
});
|
||||||
listContainerFiles.mockClear();
|
listContainerFiles.mockClear();
|
||||||
// The pane never left /workspace, so an upload started now targets it.
|
// The pane never left /workspace, so a new folder made now lands there.
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.uploadPaths(["/host/a.png"]);
|
await result.current.createFolder("new");
|
||||||
});
|
});
|
||||||
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.png", "/workspace");
|
expect(createContainerDirectory).toHaveBeenCalledWith("p1", "/workspace", "new");
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("useFileManager overwrite prompt", () => {
|
|
||||||
const alreadyThere = "FILE_EXISTS: /workspace/a.txt already exists";
|
|
||||||
|
|
||||||
it("asks rather than clobbering, and replaces on demand", async () => {
|
|
||||||
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
|
|
||||||
uploadFileToContainer.mockResolvedValueOnce(undefined);
|
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
|
||||||
|
|
||||||
let upload!: Promise<void>;
|
|
||||||
await act(async () => {
|
|
||||||
upload = result.current.uploadPaths(["/host/a.txt"]);
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
await waitFor(() => expect(result.current.conflict?.name).toBe("a.txt"));
|
|
||||||
expect(result.current.conflict?.directory).toBe("/workspace");
|
|
||||||
// One file, so there is nothing for a blanket answer to apply to.
|
|
||||||
expect(result.current.conflict?.remaining).toBe(0);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
result.current.resolveConflict("replace");
|
|
||||||
await upload;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
|
|
||||||
expect(result.current.conflict).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips without uploading anything when the user says so", async () => {
|
|
||||||
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
|
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
|
||||||
|
|
||||||
let upload!: Promise<void>;
|
|
||||||
await act(async () => {
|
|
||||||
upload = result.current.uploadPaths(["/host/a.txt"]);
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
await waitFor(() => expect(result.current.conflict).not.toBeNull());
|
|
||||||
await act(async () => {
|
|
||||||
result.current.resolveConflict("skip");
|
|
||||||
await upload;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
|
|
||||||
// A skip is a choice, not a failure — nothing to report.
|
|
||||||
expect(toastText()).not.toContain("could not be uploaded");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("asks once for a batch when the answer is Replace all", async () => {
|
|
||||||
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
|
|
||||||
uploadFileToContainer.mockResolvedValueOnce(undefined);
|
|
||||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/b.txt already exists");
|
|
||||||
uploadFileToContainer.mockResolvedValueOnce(undefined);
|
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
|
||||||
|
|
||||||
let upload!: Promise<void>;
|
|
||||||
await act(async () => {
|
|
||||||
upload = result.current.uploadPaths(["/host/a.txt", "/host/b.txt"]);
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
await waitFor(() => expect(result.current.conflict?.remaining).toBe(1));
|
|
||||||
await act(async () => {
|
|
||||||
result.current.resolveConflict("replace-all");
|
|
||||||
await upload;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.conflict).toBeNull();
|
|
||||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(4, "p1", "/host/b.txt", "/workspace", true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves an unrelated failure alone — no prompt offering a button that cannot work", async () => {
|
|
||||||
uploadFileToContainer.mockRejectedValueOnce("File too large to upload (900 MB; limit 256 MB)");
|
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.uploadPaths(["/host/huge.bin"]);
|
|
||||||
});
|
|
||||||
expect(result.current.conflict).toBeNull();
|
|
||||||
expect(toastText()).toContain("too large");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The loop, end to end. The prompt only earns its place if the *batch* survives
|
|
||||||
* it: one answer, given once, has to leave every other file in the drop exactly
|
|
||||||
* where it would have been.
|
|
||||||
*/
|
|
||||||
describe("useFileManager overwrite prompt closes the loop", () => {
|
|
||||||
const clash = (name: string) => `FILE_EXISTS: /workspace/${name} already exists`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start an upload and wait for it to stop at the prompt, handing back the
|
|
||||||
* still-unsettled batch.
|
|
||||||
*
|
|
||||||
* Wrapped in an object on purpose: an `async` function that returned the
|
|
||||||
* promise itself would *adopt* it, so awaiting the helper would wait for the
|
|
||||||
* whole upload — which cannot finish until the question is answered, which
|
|
||||||
* cannot happen until the helper returns. That deadlock looks exactly like
|
|
||||||
* the hang these tests exist to rule out.
|
|
||||||
*/
|
|
||||||
async function uploadUntilPrompt(
|
|
||||||
result: { current: ReturnType<typeof useFileManager> },
|
|
||||||
paths: string[],
|
|
||||||
): Promise<{ batch: Promise<void> }> {
|
|
||||||
let batch!: Promise<void>;
|
|
||||||
await act(async () => {
|
|
||||||
batch = result.current.uploadPaths(paths);
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
await waitFor(() => expect(result.current.conflict).not.toBeNull());
|
|
||||||
return { batch };
|
|
||||||
}
|
|
||||||
|
|
||||||
it("replaces the file that clashed and still uploads the rest of the batch", async () => {
|
|
||||||
uploadFileToContainer
|
|
||||||
.mockRejectedValueOnce(clash("a.txt")) // 1: a.txt, no overwrite
|
|
||||||
.mockResolvedValueOnce(undefined) // 2: a.txt, overwrite: true
|
|
||||||
.mockResolvedValueOnce(undefined); // 3: b.txt, no clash
|
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
|
||||||
|
|
||||||
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt"]);
|
|
||||||
expect(result.current.conflict?.name).toBe("a.txt");
|
|
||||||
expect(result.current.conflict?.remaining).toBe(1);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
result.current.resolveConflict("replace");
|
|
||||||
await batch;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(3);
|
|
||||||
// The retry is the whole point: same file, same directory, overwrite on.
|
|
||||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
|
|
||||||
// …and "Replace" answered for *that* file only, so the next one is offered
|
|
||||||
// to the backend the safe way round.
|
|
||||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(3, "p1", "/host/b.txt", "/workspace");
|
|
||||||
expect(result.current.conflict).toBeNull();
|
|
||||||
expect(result.current.completed).toContain("Uploaded 2 items");
|
|
||||||
expect(toastText()).not.toContain("could not be uploaded");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moves on to the next file on Skip rather than ending the batch", async () => {
|
|
||||||
uploadFileToContainer
|
|
||||||
.mockRejectedValueOnce(clash("a.txt"))
|
|
||||||
.mockResolvedValueOnce(undefined); // b.txt still goes
|
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
|
||||||
|
|
||||||
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt"]);
|
|
||||||
await act(async () => {
|
|
||||||
result.current.resolveConflict("skip");
|
|
||||||
await batch;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
|
|
||||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.txt", "/workspace");
|
|
||||||
// Nothing was overwritten.
|
|
||||||
expect(uploadFileToContainer.mock.calls.some((c) => c[3] === true)).toBe(false);
|
|
||||||
expect(result.current.completed).toContain("skipped 1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dismissing the dialog is a Skip — the batch carries on", async () => {
|
|
||||||
// `OverwriteConfirmModal` maps Escape / ✕ / click-outside onto this exact
|
|
||||||
// call, so a dismissal must not hang the loop or abort the drop.
|
|
||||||
uploadFileToContainer
|
|
||||||
.mockRejectedValueOnce(clash("a.txt"))
|
|
||||||
.mockResolvedValueOnce(undefined);
|
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
|
||||||
|
|
||||||
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt"]);
|
|
||||||
await act(async () => {
|
|
||||||
// What `Modal`'s `onClose` produces.
|
|
||||||
result.current.resolveConflict("skip");
|
|
||||||
await batch;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
|
|
||||||
expect(result.current.completed).toContain("Uploaded 1 item, skipped 1");
|
|
||||||
expect(result.current.busy).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("answers every remaining clash with Skip all, asking only once", async () => {
|
|
||||||
uploadFileToContainer
|
|
||||||
.mockRejectedValueOnce(clash("a.txt"))
|
|
||||||
.mockRejectedValueOnce(clash("b.txt"))
|
|
||||||
.mockRejectedValueOnce(clash("c.txt"));
|
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
|
||||||
|
|
||||||
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt", "/host/c.txt"]);
|
|
||||||
expect(result.current.conflict?.remaining).toBe(2);
|
|
||||||
await act(async () => {
|
|
||||||
result.current.resolveConflict("skip-all");
|
|
||||||
await batch;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Three attempts, no second prompt, nothing replaced.
|
|
||||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(3);
|
|
||||||
expect(uploadFileToContainer.mock.calls.some((c) => c[3] === true)).toBe(false);
|
|
||||||
expect(result.current.conflict).toBeNull();
|
|
||||||
expect(result.current.completed).toContain("skipped 3");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("puts a picked file through exactly the road a dropped one takes", async () => {
|
|
||||||
// The Upload button and the native drop listener are one routine —
|
|
||||||
// `uploadPaths` — so the prompt, the retry and the blanket answers cannot
|
|
||||||
// drift apart between them. This is that claim, from the picker end.
|
|
||||||
openDialog.mockResolvedValueOnce(["/host/a.txt", "/host/b.txt"]);
|
|
||||||
uploadFileToContainer
|
|
||||||
.mockRejectedValueOnce(clash("a.txt"))
|
|
||||||
.mockResolvedValueOnce(undefined)
|
|
||||||
.mockResolvedValueOnce(undefined);
|
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
|
||||||
|
|
||||||
let picked!: Promise<void>;
|
|
||||||
await act(async () => {
|
|
||||||
picked = result.current.uploadFile();
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
await waitFor(() => expect(result.current.conflict?.name).toBe("a.txt"));
|
|
||||||
await act(async () => {
|
|
||||||
result.current.resolveConflict("replace");
|
|
||||||
await picked;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
|
|
||||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(3, "p1", "/host/b.txt", "/workspace");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not leave the batch waiting for an answer that can never arrive", async () => {
|
|
||||||
// The pane unmounted mid-prompt (tab closed, container stopped). The upload
|
|
||||||
// promise has to settle, or `busy` never clears and the loop leaks.
|
|
||||||
uploadFileToContainer.mockRejectedValueOnce(clash("a.txt"));
|
|
||||||
const { result, unmount } = renderHook(() => useFileManager("p1"));
|
|
||||||
|
|
||||||
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt"]);
|
|
||||||
unmount();
|
|
||||||
await expect(batch).resolves.toBeUndefined();
|
|
||||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -567,8 +253,6 @@ describe("useFileManager overwrite prompt closes the loop", () => {
|
|||||||
* reported that way is a sentence nobody reads.
|
* reported that way is a sentence nobody reads.
|
||||||
*/
|
*/
|
||||||
describe("useFileManager surfaces written refusals as prose", () => {
|
describe("useFileManager surfaces written refusals as prose", () => {
|
||||||
const hiddenFolder =
|
|
||||||
'".ssh" is a hidden folder — Triple-C will not save there. Choose a visible location.';
|
|
||||||
const outsideRoots =
|
const outsideRoots =
|
||||||
"Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc";
|
"Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc";
|
||||||
|
|
||||||
@@ -576,10 +260,10 @@ describe("useFileManager surfaces written refusals as prose", () => {
|
|||||||
const lastToast = () => pushToast.mock.calls.at(-1)?.[0];
|
const lastToast = () => pushToast.mock.calls.at(-1)?.[0];
|
||||||
|
|
||||||
it("puts the write-root refusal in the headline, not behind Details", async () => {
|
it("puts the write-root refusal in the headline, not behind Details", async () => {
|
||||||
uploadFileToContainer.mockRejectedValueOnce(outsideRoots);
|
createContainerDirectory.mockRejectedValueOnce(outsideRoots);
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.uploadPaths(["/host/a.txt"]);
|
await result.current.createFolder("new");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(lastToast().message).toBe(outsideRoots);
|
expect(lastToast().message).toBe(outsideRoots);
|
||||||
@@ -587,39 +271,24 @@ describe("useFileManager surfaces written refusals as prose", () => {
|
|||||||
expect(lastToast().message).not.toMatch(/^Error:/);
|
expect(lastToast().message).not.toMatch(/^Error:/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("says it once for a whole batch that failed the same way", async () => {
|
it("unwraps an `Error` rather than stamping \"Error:\" on prose", async () => {
|
||||||
// The refusal is about the target directory, so every file in the drop
|
renameContainerPath.mockRejectedValueOnce(new Error(outsideRoots));
|
||||||
// fails identically — three copies of the same sentence is not detail.
|
|
||||||
uploadFileToContainer.mockRejectedValue(outsideRoots);
|
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.uploadPaths(["/host/a.txt", "/host/b.txt"]);
|
await result.current.renameEntry(file("a.txt"), "b.txt");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(lastToast().message).toBe(outsideRoots);
|
expect(lastToast().message).toBe(outsideRoots);
|
||||||
expect(lastToast().detail).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does the same for a refused save to the host", async () => {
|
|
||||||
save.mockResolvedValue("/home/me/.ssh/a.txt");
|
|
||||||
downloadContainerFile.mockRejectedValueOnce(new Error(hiddenFolder));
|
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.downloadFile(file("a.txt"));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Unwrapped: an `Error` on the way through must not stamp "Error:" on prose.
|
|
||||||
expect(lastToast().message).toBe(hiddenFolder);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the hook's own headline when the failure is not a written refusal", async () => {
|
it("keeps the hook's own headline when the failure is not a written refusal", async () => {
|
||||||
uploadFileToContainer.mockRejectedValueOnce("no space left on device");
|
createContainerDirectory.mockRejectedValueOnce("no space left on device");
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.uploadPaths(["/host/a.txt"]);
|
await result.current.createFolder("new");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(lastToast().message).toBe("A file could not be uploaded");
|
expect(lastToast().message).toBe('Could not create "new"');
|
||||||
expect(lastToast().detail).toBe("no space left on device");
|
expect(lastToast().detail).toBe("no space left on device");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+20
-235
@@ -1,36 +1,8 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
|
|
||||||
import type { FileEntry } from "../lib/types";
|
import type { FileEntry } from "../lib/types";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
import { useAppState } from "../store/appState";
|
import { useAppState } from "../store/appState";
|
||||||
import {
|
import { errorText, readableRefusal } from "../lib/refusalText";
|
||||||
errorText,
|
|
||||||
fileExistsPath,
|
|
||||||
isFileExistsError,
|
|
||||||
readableRefusal,
|
|
||||||
type OverwriteChoice,
|
|
||||||
} from "../lib/uploadErrors";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One upload waiting on the user to say whether it may replace what is there.
|
|
||||||
* `remaining` is how many files are queued behind this one, which is what
|
|
||||||
* decides whether the blanket answers are worth offering.
|
|
||||||
*/
|
|
||||||
export interface UploadConflict {
|
|
||||||
/** Host file being uploaded. */
|
|
||||||
hostPath: string;
|
|
||||||
/** Bare name, for the prompt. */
|
|
||||||
name: string;
|
|
||||||
/** Container directory it is going into. */
|
|
||||||
directory: string;
|
|
||||||
remaining: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `/a/b/c.txt` and `C:\a\b\c.txt` both give `c.txt`. */
|
|
||||||
function baseName(path: string): string {
|
|
||||||
const parts = path.split(/[\\/]/);
|
|
||||||
return parts[parts.length - 1] || path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ## Where failures are reported
|
* ## Where failures are reported
|
||||||
@@ -41,22 +13,19 @@ function baseName(path: string): string {
|
|||||||
* (empty) grid. It is on screen, it is in context, it explains why there are
|
* (empty) grid. It is on screen, it is in context, it explains why there are
|
||||||
* no rows, and it is not transient — it stands until the directory lists.
|
* no rows, and it is not transient — it stands until the directory lists.
|
||||||
*
|
*
|
||||||
* Every **transient operation** failure — upload, rename, create folder,
|
* Every **transient operation** failure — rename, create folder — goes to
|
||||||
* save-to-host — goes to `ToastHost` instead. Those used to land
|
* `ToastHost` instead. Those used to land in the same inline `error` div, which
|
||||||
* in the same inline `error` div, which is the first child of the *scrolling*
|
* is the first child of the *scrolling* list: three hundred rows down, a
|
||||||
* list: three hundred rows down, a refused rename produced no visible change
|
* refused rename produced no visible change at all, just a rename box that
|
||||||
* at all, just a rename box that stayed open for no stated reason. Worse, the
|
* stayed open for no stated reason. The toast host is a persistent `aria-live`
|
||||||
* file viewer routes its "Save to host…" through the same call, and the viewer
|
* region at `z-[60]`, i.e. the one place in the app that is above a modal and
|
||||||
* is a `fixed inset-0` portal at `z-50` — so that failure reported *behind* the
|
* does not scroll away.
|
||||||
* dialog that caused it. The toast host is a persistent `aria-live` region at
|
|
||||||
* `z-[60]`, i.e. the one place in the app that is above a modal and does not
|
|
||||||
* scroll away.
|
|
||||||
*
|
*
|
||||||
* ## Where the current directory lives
|
* ## Where the current directory lives
|
||||||
*
|
*
|
||||||
* `currentPath` is state (the UI renders it) *and* a ref (async work reads it
|
* `currentPath` is state (the UI renders it) *and* a ref (async work reads it
|
||||||
* after an await). Every long operation captures the directory it targets at
|
* after an await). Every long operation captures the directory it targets at
|
||||||
* the start and compares it against the ref at the end: a 200 MB upload into
|
* the start and compares it against the ref at the end: a slow rename in
|
||||||
* `/workspace` must not drag the pane back out of `src/` because that is where
|
* `/workspace` must not drag the pane back out of `src/` because that is where
|
||||||
* the closure happened to be created. The ref moves at the *start* of a
|
* the closure happened to be created. The ref moves at the *start* of a
|
||||||
* navigation rather than when the listing lands, because the question being
|
* navigation rather than when the listing lands, because the question being
|
||||||
@@ -68,14 +37,11 @@ export function useFileManager(projectId: string) {
|
|||||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
/** Transient "uploading 3 files…" style note, shown beside the breadcrumb. */
|
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
|
||||||
/**
|
/**
|
||||||
* What just finished. A live region that only ever says "uploading…" tells a
|
* What just finished, for the live region — a rename or a new folder is a
|
||||||
* screen reader user when to start waiting and never when to stop.
|
* change a sighted user sees in the grid and a screen reader user does not.
|
||||||
*/
|
*/
|
||||||
const [completed, setCompleted] = useState<string | null>(null);
|
const [completed, setCompleted] = useState<string | null>(null);
|
||||||
const [conflict, setConflict] = useState<UploadConflict | null>(null);
|
|
||||||
|
|
||||||
const currentPathRef = useRef(currentPath);
|
const currentPathRef = useRef(currentPath);
|
||||||
|
|
||||||
@@ -87,45 +53,29 @@ export function useFileManager(projectId: string) {
|
|||||||
*/
|
*/
|
||||||
const navGeneration = useRef(0);
|
const navGeneration = useRef(0);
|
||||||
|
|
||||||
const startWork = useCallback((note: string) => {
|
|
||||||
setBusy(note);
|
|
||||||
setCompleted(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Report a failed operation, given the headline this hook would write and the
|
* Report a failed operation, given the headline this hook would write and the
|
||||||
* raw failures behind it.
|
* raw failure behind it.
|
||||||
*
|
*
|
||||||
* The headline is what the *hook* knows ("Could not rename …"); it is a
|
* The headline is what the *hook* knows ("Could not rename …"); it is a
|
||||||
* category, not an explanation. Some backend refusals are already a finished
|
* category, not an explanation. Some backend refusals are already a finished
|
||||||
* sentence written for the person reading it — a hidden host folder, a
|
* sentence written for the person reading it — a container path outside the
|
||||||
* container path outside the roots this panel may change — and those used to
|
* roots this panel may change, a name it will not create — and those used to
|
||||||
* arrive as the toast's `detail`, which `ToastHost` renders as collapsed
|
* arrive as the toast's `detail`, which `ToastHost` renders as collapsed
|
||||||
* monospace behind a "Details" button. So the sentence that said what was
|
* monospace behind a "Details" button. So the sentence that said what was
|
||||||
* wrong and what to do about it was hidden under a headline that said
|
* wrong and what to do about it was hidden under a headline that said
|
||||||
* neither. When every failure reduces to the *same* such sentence — which is
|
* neither. When there is such a sentence it becomes the headline, and there
|
||||||
* the normal case, since these refusals are about the target directory and so
|
* is nothing left to hide.
|
||||||
* fail identically for every file in a batch — it becomes the headline and
|
|
||||||
* there is nothing left to hide.
|
|
||||||
*/
|
*/
|
||||||
const report = useCallback((message: string, ...causes: unknown[]) => {
|
const report = useCallback((message: string, cause: unknown) => {
|
||||||
const refusals = causes.map(readableRefusal);
|
const promoted = readableRefusal(cause);
|
||||||
const shared =
|
|
||||||
causes.length > 0 && refusals.every((r) => r !== null)
|
|
||||||
? [...new Set(refusals as string[])]
|
|
||||||
: [];
|
|
||||||
const promoted = shared.length === 1 ? shared[0] : null;
|
|
||||||
useAppState.getState().pushToast({
|
useAppState.getState().pushToast({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
message: promoted ?? message,
|
message: promoted ?? message,
|
||||||
detail: promoted || causes.length === 0 ? undefined : causes.map(errorText).join("\n"),
|
detail: promoted ? undefined : errorText(cause),
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const confirm = useCallback((message: string) => {
|
|
||||||
useAppState.getState().pushToast({ kind: "success", message });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const navigate = useCallback(
|
const navigate = useCallback(
|
||||||
async (path: string) => {
|
async (path: string) => {
|
||||||
const mine = ++navGeneration.current;
|
const mine = ++navGeneration.current;
|
||||||
@@ -163,164 +113,6 @@ export function useFileManager(projectId: string) {
|
|||||||
navigate(currentPathRef.current);
|
navigate(currentPathRef.current);
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
/** Copy an entry out to a host path the user picks. */
|
|
||||||
const downloadFile = useCallback(
|
|
||||||
async (entry: FileEntry) => {
|
|
||||||
try {
|
|
||||||
const hostPath = await save({ defaultPath: entry.name });
|
|
||||||
if (!hostPath) return;
|
|
||||||
// Every sibling operation sets `busy`; this one did not, so a 200 MB
|
|
||||||
// copy was a click, then a frozen-looking pane, then nothing.
|
|
||||||
startWork(`Saving "${entry.name}" to the host…`);
|
|
||||||
try {
|
|
||||||
await commands.downloadContainerFile(projectId, entry.path, hostPath);
|
|
||||||
setCompleted(`Saved "${entry.name}" to ${hostPath}.`);
|
|
||||||
confirm(`Saved "${entry.name}" to the host.`);
|
|
||||||
} finally {
|
|
||||||
setBusy(null);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
report(`Could not save "${entry.name}" to the host`, e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[projectId, startWork, report, confirm],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The pending answer to `conflict`. Kept in a ref rather than state because
|
|
||||||
* the upload loop is `await`ing it — it needs the resolver, not a re-render.
|
|
||||||
*/
|
|
||||||
const conflictResolver = useRef<((choice: OverwriteChoice) => void) | null>(null);
|
|
||||||
|
|
||||||
const resolveConflict = useCallback((choice: OverwriteChoice) => {
|
|
||||||
const resolve = conflictResolver.current;
|
|
||||||
conflictResolver.current = null;
|
|
||||||
setConflict(null);
|
|
||||||
resolve?.(choice);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// A pane unmounted mid-prompt (the tab was closed, the container stopped)
|
|
||||||
// would otherwise leave the upload loop awaiting an answer that can never
|
|
||||||
// come. Skipping is the safe reading of "the dialog went away".
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
conflictResolver.current?.("skip-all");
|
|
||||||
conflictResolver.current = null;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const askOverwrite = useCallback(
|
|
||||||
(hostPath: string, directory: string, remaining: number, containerPath: string | null) =>
|
|
||||||
new Promise<OverwriteChoice>((resolve) => {
|
|
||||||
// One batch asks one question at a time — the loop awaits each answer —
|
|
||||||
// so a resolver still sitting here belongs to a *different* batch (two
|
|
||||||
// drops in flight at once, or a drop landing while the Upload button's
|
|
||||||
// batch is still copying). Installing over it would leave that batch
|
|
||||||
// awaiting an answer no dialog can ever produce: a silent hang, with
|
|
||||||
// its file neither uploaded nor skipped. Skipping it is the same
|
|
||||||
// reading of "the dialog went away" the unmount cleanup uses.
|
|
||||||
conflictResolver.current?.("skip");
|
|
||||||
conflictResolver.current = resolve;
|
|
||||||
setConflict({
|
|
||||||
hostPath,
|
|
||||||
name: baseName(containerPath ?? hostPath),
|
|
||||||
directory,
|
|
||||||
remaining,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy host files into the current directory. Shared by the Upload button and
|
|
||||||
* the native drag-drop listener, so a dropped file and a picked one take the
|
|
||||||
* same path — including the one refresh at the end rather than one per file.
|
|
||||||
*
|
|
||||||
* The backend refuses to overwrite unless asked to, so a name clash is not a
|
|
||||||
* failure here: it is a question, and the answer can be given once for the
|
|
||||||
* whole batch.
|
|
||||||
*/
|
|
||||||
const uploadPaths = useCallback(
|
|
||||||
async (hostPaths: string[]) => {
|
|
||||||
if (hostPaths.length === 0) return;
|
|
||||||
// The directory this upload is *for*. Compared against the live ref at
|
|
||||||
// the end, because the user is free to walk away while it copies.
|
|
||||||
const target = currentPathRef.current;
|
|
||||||
startWork(`Uploading ${hostPaths.length} item${hostPaths.length > 1 ? "s" : ""}…`);
|
|
||||||
/** Raw failures, kept unstringified so `report` can read their shape. */
|
|
||||||
const failures: unknown[] = [];
|
|
||||||
let uploaded = 0;
|
|
||||||
let skipped = 0;
|
|
||||||
/** A "…all" answer, applied to every remaining clash without asking. */
|
|
||||||
let blanket: OverwriteChoice | null = null;
|
|
||||||
try {
|
|
||||||
for (let i = 0; i < hostPaths.length; i++) {
|
|
||||||
const hostPath = hostPaths[i];
|
|
||||||
try {
|
|
||||||
await commands.uploadFileToContainer(projectId, hostPath, target);
|
|
||||||
uploaded++;
|
|
||||||
continue;
|
|
||||||
} catch (e) {
|
|
||||||
if (!isFileExistsError(e)) {
|
|
||||||
failures.push(e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const choice: OverwriteChoice =
|
|
||||||
blanket ??
|
|
||||||
(await askOverwrite(
|
|
||||||
hostPath,
|
|
||||||
target,
|
|
||||||
hostPaths.length - i - 1,
|
|
||||||
fileExistsPath(e),
|
|
||||||
));
|
|
||||||
if (choice === "replace-all" || choice === "skip-all") blanket = choice;
|
|
||||||
if (choice === "skip" || choice === "skip-all") {
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await commands.uploadFileToContainer(projectId, hostPath, target, true);
|
|
||||||
uploaded++;
|
|
||||||
} catch (e) {
|
|
||||||
failures.push(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setBusy(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary =
|
|
||||||
`Uploaded ${uploaded} item${uploaded === 1 ? "" : "s"}` +
|
|
||||||
(skipped > 0 ? `, skipped ${skipped}` : "") +
|
|
||||||
(failures.length > 0 ? `, ${failures.length} failed` : "") +
|
|
||||||
".";
|
|
||||||
setCompleted(summary);
|
|
||||||
|
|
||||||
if (failures.length > 0) {
|
|
||||||
report(
|
|
||||||
failures.length === 1 ? "A file could not be uploaded" : `${failures.length} files could not be uploaded`,
|
|
||||||
...failures,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Only re-list if the user is still looking at the directory this went
|
|
||||||
// into. Navigating away during a slow copy used to drag the pane back.
|
|
||||||
if (currentPathRef.current === target) await navigate(target);
|
|
||||||
},
|
|
||||||
[projectId, navigate, startWork, report, askOverwrite],
|
|
||||||
);
|
|
||||||
|
|
||||||
const uploadFile = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const selected = await openDialog({ multiple: true, directory: false });
|
|
||||||
if (!selected) return;
|
|
||||||
await uploadPaths(Array.isArray(selected) ? selected : [selected as string]);
|
|
||||||
} catch (e) {
|
|
||||||
report("Could not open the file picker", e);
|
|
||||||
}
|
|
||||||
}, [uploadPaths, report]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rename in place. `newName` is a bare name — Rust rejects anything with a
|
* Rename in place. `newName` is a bare name — Rust rejects anything with a
|
||||||
* `/` in it, so this can never turn into a move. Resolves true on success so
|
* `/` in it, so this can never turn into a move. Resolves true on success so
|
||||||
@@ -368,19 +160,12 @@ export function useFileManager(projectId: string) {
|
|||||||
loading,
|
loading,
|
||||||
/** Inline, in-context: why the listing on screen is empty. */
|
/** Inline, in-context: why the listing on screen is empty. */
|
||||||
error,
|
error,
|
||||||
busy,
|
|
||||||
/** What the last operation finished doing, for the live region. */
|
/** What the last operation finished doing, for the live region. */
|
||||||
completed,
|
completed,
|
||||||
/** An upload waiting for a Replace / Skip answer, or `null`. */
|
|
||||||
conflict,
|
|
||||||
resolveConflict,
|
|
||||||
setError,
|
setError,
|
||||||
navigate,
|
navigate,
|
||||||
goUp,
|
goUp,
|
||||||
refresh,
|
refresh,
|
||||||
downloadFile,
|
|
||||||
uploadFile,
|
|
||||||
uploadPaths,
|
|
||||||
renameEntry,
|
renameEntry,
|
||||||
createFolder,
|
createFolder,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
*
|
*
|
||||||
* 1. **Which pane is this drop for?** Geometry, and nothing else: is the
|
* 1. **Which pane is this drop for?** Geometry, and nothing else: is the
|
||||||
* payload position inside my rect? A hidden pane is `display:none` and so
|
* payload position inside my rect? A hidden pane is `display:none` and so
|
||||||
* has a zero-size rect, which is what stops `TerminalView` and `FilesTab`
|
* has a zero-size rect, which is what stops two panes both claiming the
|
||||||
* both claiming the same drop.
|
* same drop. `TerminalView` is the only pane that takes dropped files
|
||||||
|
* today — the Files pane is container-side only — but the routing is what
|
||||||
|
* keeps it honest when a second one appears.
|
||||||
* 2. **Should the app accept a drop at all right now?** `dropIsBlocked` —
|
* 2. **Should the app accept a drop at all right now?** `dropIsBlocked` —
|
||||||
* document-wide, no geometry, no z-order. While a modal or a blocking
|
* document-wide, no geometry, no z-order. While a modal or a blocking
|
||||||
* overlay is on screen anywhere, every drop is refused.
|
* overlay is on screen anywhere, every drop is refused.
|
||||||
@@ -16,7 +18,7 @@
|
|||||||
* ## Why there is no z-order test here, and must not be one
|
* ## Why there is no z-order test here, and must not be one
|
||||||
*
|
*
|
||||||
* A drop that lands underneath a dialog and silently uploads into the
|
* A drop that lands underneath a dialog and silently uploads into the
|
||||||
* directory the dialog is covering is the failure mode that matters: it is
|
* container behind it is the failure mode that matters: it is
|
||||||
* invisible, it writes to the container, and the user did not ask for it.
|
* invisible, it writes to the container, and the user did not ask for it.
|
||||||
* Every attempt to be *precise* about which points a dialog covers has gone
|
* Every attempt to be *precise* about which points a dialog covers has gone
|
||||||
* wrong, twice, in opposite directions:
|
* wrong, twice, in opposite directions:
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { errorText, readableRefusal } from "./refusalText";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refusals that are a sentence the backend wrote for the person reading it.
|
||||||
|
* They used to arrive as a toast's `detail`, which renders as collapsed
|
||||||
|
* monospace behind a "Details" button — so the only part of the message that
|
||||||
|
* explained anything was the part nobody saw.
|
||||||
|
*/
|
||||||
|
describe("readableRefusal", () => {
|
||||||
|
const hidden =
|
||||||
|
'the path goes through ".ssh", a hidden folder — Triple-C will not save anything whose folders are not all visible. Choose a visible location.';
|
||||||
|
const outside =
|
||||||
|
"Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc";
|
||||||
|
|
||||||
|
it("recognises the hidden-host-folder refusal, in both directions", () => {
|
||||||
|
expect(readableRefusal(hidden)).toBe(hidden);
|
||||||
|
expect(
|
||||||
|
readableRefusal(
|
||||||
|
'the path goes through ".aws", a hidden folder — Triple-C will not read anything whose folders are not all visible. Choose a visible location.',
|
||||||
|
),
|
||||||
|
).toContain("hidden folder");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognises the container write-root refusal", () => {
|
||||||
|
expect(readableRefusal(outside)).toBe(outside);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips a wrapper a JS layer put in front of the sentence", () => {
|
||||||
|
// `invoke` rejects with the bare string today, but an `Error` anywhere in
|
||||||
|
// between would otherwise put "Error: " in front of prose meant to be read.
|
||||||
|
expect(readableRefusal(new Error(hidden))).toBe(hidden);
|
||||||
|
expect(readableRefusal(`Error: ${hidden}`)).toBe(hidden);
|
||||||
|
expect(readableRefusal(`Uncaught (in promise) Error: ${outside}`)).toBe(outside);
|
||||||
|
expect(readableRefusal({ message: `invoke failed: ${outside}` })).toBe(outside);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing about failures that are not a written refusal", () => {
|
||||||
|
// Promotion is an improvement, not a fallback: anything unrecognised keeps
|
||||||
|
// reporting exactly as it did before.
|
||||||
|
expect(readableRefusal("File too large to upload (900 MB; limit 256 MB)")).toBeNull();
|
||||||
|
expect(readableRefusal("FILE_EXISTS: /workspace/a.txt already exists")).toBeNull();
|
||||||
|
expect(readableRefusal("cp: Permission denied")).toBeNull();
|
||||||
|
expect(readableRefusal(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("errorText", () => {
|
||||||
|
it("keeps an ordinary message intact", () => {
|
||||||
|
expect(errorText("cp: cannot create regular file: Permission denied")).toBe(
|
||||||
|
"cp: cannot create regular file: Permission denied",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a message out of a shape `String()` would render as [object Object]", () => {
|
||||||
|
expect(errorText({ message: "Container not running" })).toBe("Container not running");
|
||||||
|
expect(errorText({ kind: "NotRunning" })).toBe("NotRunning");
|
||||||
|
expect(errorText(new Error("Failed to upload file to container: no space left"))).toBe(
|
||||||
|
"Failed to upload file to container: no space left",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the written refusal when there is one", () => {
|
||||||
|
expect(errorText(new Error("Folder path is outside the folders this panel can change (/workspace): /etc"))).toBe(
|
||||||
|
"Folder path is outside the folders this panel can change (/workspace): /etc",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Turning a backend refusal into the sentence a person reads.
|
||||||
|
*
|
||||||
|
* Tauri command errors cross the IPC boundary as whatever `serde` made of them:
|
||||||
|
* a bare string from `Err(String)`, an object from a `#[derive(Serialize)]`
|
||||||
|
* error enum, or an `Error` if a JS layer wrapped it on the way through. All
|
||||||
|
* three are the same refusal, and the UI must not read differently depending on
|
||||||
|
* which one a future refactor produces — so everything here is tolerant about
|
||||||
|
* the *shape* of an error and picks the most human string out of it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field names a serialised Rust error realistically uses for its discriminant
|
||||||
|
* and for its human text. `error` is listed as a discriminant field and yet
|
||||||
|
* routinely carries a whole sentence, which is why a kind string is read as
|
||||||
|
* prose too.
|
||||||
|
*/
|
||||||
|
const KIND_FIELDS = ["kind", "code", "type", "error", "reason"] as const;
|
||||||
|
const MESSAGE_FIELDS = ["message", "msg", "detail", "description"] as const;
|
||||||
|
|
||||||
|
function asRecord(e: unknown): Record<string, unknown> | null {
|
||||||
|
return typeof e === "object" && e !== null ? (e as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every string an error carries, flattened: the error itself if it is one, its
|
||||||
|
* message-ish fields, and its kind-ish fields. Nesting is followed one level
|
||||||
|
* because a wrapped error (`{ error: { message: … } }`) is the same refusal.
|
||||||
|
*/
|
||||||
|
function stringsIn(e: unknown, depth = 0): string[] {
|
||||||
|
if (typeof e === "string") return [e];
|
||||||
|
if (e instanceof Error) return [e.message, e.name];
|
||||||
|
const record = asRecord(e);
|
||||||
|
if (!record || depth > 1) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
const walk = (value: unknown) => {
|
||||||
|
if (typeof value === "string") out.push(value);
|
||||||
|
else if (value !== undefined) out.push(...stringsIn(value, depth + 1));
|
||||||
|
};
|
||||||
|
for (const field of KIND_FIELDS) walk(record[field]);
|
||||||
|
for (const field of MESSAGE_FIELDS) walk(record[field]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragments that identify a refusal the backend already wrote **for a person**.
|
||||||
|
*
|
||||||
|
* The file commands guard two policies that a user can trip over by accident,
|
||||||
|
* and both answer with a finished sentence that names the offending path and
|
||||||
|
* says what to do instead:
|
||||||
|
*
|
||||||
|
* the path goes through ".ssh", a hidden folder — Triple-C will not save …
|
||||||
|
* Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc
|
||||||
|
*
|
||||||
|
* Those sentences were being used as the *detail* of a generic toast, and
|
||||||
|
* `ToastHost` renders a detail as collapsed monospace behind a "Details"
|
||||||
|
* button — so the one part of the message that explained anything was the part
|
||||||
|
* nobody saw. Matching them here lets the caller promote the sentence to the
|
||||||
|
* toast's headline.
|
||||||
|
*
|
||||||
|
* Matched on a stable fragment rather than the whole string, because the path
|
||||||
|
* and the verb ("save"/"read", "file"/"folder") vary per call. Deliberately a
|
||||||
|
* short list: an error that is *not* recognised still reports exactly as it
|
||||||
|
* did before, so a wrong guess here can only fail to promote, never mangle.
|
||||||
|
*/
|
||||||
|
const REFUSAL_MARKERS = [
|
||||||
|
// `validate_host_path` — hidden host component, and system locations.
|
||||||
|
"Triple-C will not",
|
||||||
|
// `validate_container_write_path` — outside /workspace, /home/claude, /tmp.
|
||||||
|
"outside the folders this panel can change",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Error: …`, `TypeError: …`, `invoke failed: …` — wrappers a JS layer may have
|
||||||
|
* put in front of the backend's sentence on the way through. Stripped so the
|
||||||
|
* prose starts where the backend started it; applied twice at most, because a
|
||||||
|
* doubly-wrapped error is the realistic worst case and looping on user text is
|
||||||
|
* not.
|
||||||
|
*/
|
||||||
|
const WRAPPER_PREFIX = /^(?:uncaught\s*(?:\(in promise\)\s*)?)?(?:[a-z]*error|invoke(?:\s+failed)?)\s*:\s*/i;
|
||||||
|
|
||||||
|
function stripWrapper(text: string): string {
|
||||||
|
let out = text.trim();
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const next = out.replace(WRAPPER_PREFIX, "").trim();
|
||||||
|
if (next === out) break;
|
||||||
|
out = next;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backend's own user-facing sentence, when this failure is one — otherwise
|
||||||
|
* `null`, and the caller reports it however it reported everything else.
|
||||||
|
*/
|
||||||
|
export function readableRefusal(e: unknown): string | null {
|
||||||
|
for (const s of stringsIn(e)) {
|
||||||
|
const text = stripWrapper(s);
|
||||||
|
if (REFUSAL_MARKERS.some((marker) => text.includes(marker))) return text;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most human form of any failure, for the places that show one verbatim.
|
||||||
|
*
|
||||||
|
* `String(e)` is what these used to be, which turns a serialised error object
|
||||||
|
* into `[object Object]` and leaves a JS wrapper prefix on a sentence that
|
||||||
|
* reads perfectly well without it.
|
||||||
|
*/
|
||||||
|
export function errorText(e: unknown): string {
|
||||||
|
const readable = readableRefusal(e);
|
||||||
|
if (readable) return readable;
|
||||||
|
if (typeof e === "string") return stripWrapper(e);
|
||||||
|
if (e instanceof Error) return stripWrapper(e.message);
|
||||||
|
const record = asRecord(e);
|
||||||
|
if (record) {
|
||||||
|
for (const field of [...MESSAGE_FIELDS, ...KIND_FIELDS]) {
|
||||||
|
const value = record[field];
|
||||||
|
if (typeof value === "string" && value.trim().length > 0) return stripWrapper(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
|
import type { Project, ProjectPath, ContainerInfo, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -7,8 +7,6 @@ export const checkImageExists = () => invoke<boolean>("check_image_exists");
|
|||||||
export const buildImage = () => invoke<void>("build_image");
|
export const buildImage = () => invoke<void>("build_image");
|
||||||
export const getContainerInfo = (projectId: string) =>
|
export const getContainerInfo = (projectId: string) =>
|
||||||
invoke<ContainerInfo | null>("get_container_info", { projectId });
|
invoke<ContainerInfo | null>("get_container_info", { projectId });
|
||||||
export const listSiblingContainers = () =>
|
|
||||||
invoke<SiblingContainer[]>("list_sibling_containers");
|
|
||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
export const listProjects = () => invoke<Project[]>("list_projects");
|
export const listProjects = () => invoke<Project[]>("list_projects");
|
||||||
@@ -71,25 +69,8 @@ export const stopAudioBridge = (sessionId: string) =>
|
|||||||
// Files
|
// Files
|
||||||
export const listContainerFiles = (projectId: string, path: string) =>
|
export const listContainerFiles = (projectId: string, path: string) =>
|
||||||
invoke<FileEntry[]>("list_container_files", { projectId, path });
|
invoke<FileEntry[]>("list_container_files", { projectId, path });
|
||||||
export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) =>
|
|
||||||
invoke<void>("download_container_file", { projectId, containerPath, hostPath });
|
|
||||||
export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) =>
|
export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) =>
|
||||||
invoke<number>("download_container_backup", { projectId, hostPath, containerPath });
|
invoke<number>("download_container_backup", { projectId, hostPath, containerPath });
|
||||||
/**
|
|
||||||
* Copy a host file into a container directory.
|
|
||||||
*
|
|
||||||
* `overwrite` is opt-in because a drop is aimed with a mouse: the backend
|
|
||||||
* refuses by default when the name is already taken (see `lib/uploadErrors.ts`
|
|
||||||
* for the marker that refusal carries), and the caller re-runs with `true`
|
|
||||||
* only once the user has said "Replace" to that specific file. Leaving it off
|
|
||||||
* is the safe default every existing caller gets.
|
|
||||||
*/
|
|
||||||
export const uploadFileToContainer = (
|
|
||||||
projectId: string,
|
|
||||||
hostPath: string,
|
|
||||||
containerDir: string,
|
|
||||||
overwrite?: boolean,
|
|
||||||
) => invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir, overwrite });
|
|
||||||
export const readContainerFile = (projectId: string, path: string, maxBytes?: number) =>
|
export const readContainerFile = (projectId: string, path: string, maxBytes?: number) =>
|
||||||
invoke<FileContents>("read_container_file", { projectId, path, maxBytes });
|
invoke<FileContents>("read_container_file", { projectId, path, maxBytes });
|
||||||
/** `toPath` is the new *name*, not a destination — renames never move. */
|
/** `toPath` is the new *name*, not a destination — renames never move. */
|
||||||
|
|||||||
+13
-15
@@ -162,23 +162,28 @@ export interface OpenAiCompatibleConfig {
|
|||||||
* project that is "inherit the global value", and on the global settings it is
|
* project that is "inherit the global value", and on the global settings it is
|
||||||
* "leave Claude Code's own default alone". `false` is a deliberate off, which
|
* "leave Claude Code's own default alone". `false` is a deliberate off, which
|
||||||
* is what lets a project turn a globally-enabled setting back off.
|
* is what lets a project turn a globally-enabled setting back off.
|
||||||
|
*
|
||||||
|
* Every field is optional as well as nullable: the Rust struct skips
|
||||||
|
* serialising a field it has no value for, so an object with nothing set at
|
||||||
|
* this level arrives as `{}`. Absent and `null` mean the same thing, which is
|
||||||
|
* why every read of one of these has to use `== null` rather than `=== null`.
|
||||||
*/
|
*/
|
||||||
export interface ClaudeCodeSettings {
|
export interface ClaudeCodeSettings {
|
||||||
/** `null` = let Claude Code choose the renderer; `"default"` = classic, `"fullscreen"` = alt-screen. */
|
/** `null` = let Claude Code choose the renderer; `"default"` = classic, `"fullscreen"` = alt-screen. */
|
||||||
tui_mode: string | null;
|
tui_mode?: string | null;
|
||||||
/** `null` = unset, else `"low" | "medium" | "high" | "xhigh"`. Written as `effortLevel`. */
|
/** `null` = unset, else `"low" | "medium" | "high" | "xhigh"`. Written as `effortLevel`. */
|
||||||
effort: string | null;
|
effort?: string | null;
|
||||||
auto_scroll_disabled: boolean | null;
|
auto_scroll_disabled?: boolean | null;
|
||||||
/** Written as `viewMode: "focus"`. */
|
/** Written as `viewMode: "focus"`. */
|
||||||
focus_mode: boolean | null;
|
focus_mode?: boolean | null;
|
||||||
show_thinking_summaries: boolean | null;
|
show_thinking_summaries?: boolean | null;
|
||||||
/**
|
/**
|
||||||
* Turns the session recap **off**. Held in the disabled sense because Claude
|
* Turns the session recap **off**. Held in the disabled sense because Claude
|
||||||
* Code's recap is on by default — see the Rust doc on `ClaudeCodeSettings`.
|
* Code's recap is on by default — see the Rust doc on `ClaudeCodeSettings`.
|
||||||
*/
|
*/
|
||||||
session_recap_disabled: boolean | null;
|
session_recap_disabled?: boolean | null;
|
||||||
env_scrub: boolean | null;
|
env_scrub?: boolean | null;
|
||||||
prompt_caching_1h: boolean | null;
|
prompt_caching_1h?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContainerInfo {
|
export interface ContainerInfo {
|
||||||
@@ -188,13 +193,6 @@ export interface ContainerInfo {
|
|||||||
image: string;
|
image: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SiblingContainer {
|
|
||||||
id: string;
|
|
||||||
names: string[] | null;
|
|
||||||
image: string;
|
|
||||||
state: string;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TerminalSession {
|
export interface TerminalSession {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
+33
-11
@@ -454,27 +454,48 @@ fi
|
|||||||
# previous Bedrock-profile session — ~/.claude.json lives in the persisted home
|
# previous Bedrock-profile session — ~/.claude.json lives in the persisted home
|
||||||
# volume, so without this the container keeps trying to run the SSO refresh even
|
# volume, so without this the container keeps trying to run the SSO refresh even
|
||||||
# after switching to a non-SSO backend (Anthropic/Ollama) or to static creds.
|
# after switching to a non-SSO backend (Anthropic/Ollama) or to static creds.
|
||||||
|
# Replace ~/.claude.json atomically: write a sibling temp file, then rename.
|
||||||
|
#
|
||||||
|
# `> "$CLAUDE_JSON"` truncates before it writes, so a write that fails part-way
|
||||||
|
# — a full home volume being the obvious way, and bounding that volume is what
|
||||||
|
# half this release is about — leaves the file unparseable. It holds the OAuth
|
||||||
|
# account, and the damage does not self-heal: the next start's `jq` fails on the
|
||||||
|
# corrupt file, `MERGED` comes back empty, and the `[ -n "$MERGED" ]` guard
|
||||||
|
# skips the write that would have repaired it. `triple-c-task-runner` has done
|
||||||
|
# it this way all along.
|
||||||
|
write_claude_json() {
|
||||||
|
_wcj_tmp="${CLAUDE_JSON}.triple-c-tmp"
|
||||||
|
if printf '%s\n' "$1" > "$_wcj_tmp" 2>/dev/null; then
|
||||||
|
mv -f "$_wcj_tmp" "$CLAUDE_JSON" 2>/dev/null || rm -f "$_wcj_tmp"
|
||||||
|
else
|
||||||
|
rm -f "$_wcj_tmp"
|
||||||
|
echo "entrypoint: warning — could not write $CLAUDE_JSON (leaving it as it was)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# By name, after the rename, so these land on the new inode.
|
||||||
|
chown claude:claude "$CLAUDE_JSON"
|
||||||
|
chmod 600 "$CLAUDE_JSON"
|
||||||
|
}
|
||||||
|
|
||||||
CLAUDE_JSON="/home/claude/.claude.json"
|
CLAUDE_JSON="/home/claude/.claude.json"
|
||||||
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
|
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
|
||||||
if [ -f "$CLAUDE_JSON" ]; then
|
if [ -f "$CLAUDE_JSON" ]; then
|
||||||
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
|
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
|
||||||
if [ -n "$MERGED" ]; then
|
if [ -n "$MERGED" ]; then
|
||||||
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
|
write_claude_json "$MERGED"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
printf '{"awsAuthRefresh":"%s"}\n' "$AWS_SSO_AUTH_REFRESH_CMD" > "$CLAUDE_JSON"
|
# No existing file, so there is nothing to destroy — but go through the
|
||||||
|
# same helper so the owner and mode are set in one place.
|
||||||
|
write_claude_json "$(printf '{"awsAuthRefresh":"%s"}' "$AWS_SSO_AUTH_REFRESH_CMD")"
|
||||||
fi
|
fi
|
||||||
chown claude:claude "$CLAUDE_JSON"
|
|
||||||
chmod 600 "$CLAUDE_JSON"
|
|
||||||
unset AWS_SSO_AUTH_REFRESH_CMD
|
unset AWS_SSO_AUTH_REFRESH_CMD
|
||||||
elif [ -f "$CLAUDE_JSON" ] && grep -q '"awsAuthRefresh"' "$CLAUDE_JSON" 2>/dev/null; then
|
elif [ -f "$CLAUDE_JSON" ] && grep -q '"awsAuthRefresh"' "$CLAUDE_JSON" 2>/dev/null; then
|
||||||
# Only rewrite when the key is actually present, to avoid a needless jq
|
# Only rewrite when the key is actually present, to avoid a needless jq
|
||||||
# reformat of ~/.claude.json on every start of a non-SSO backend.
|
# reformat of ~/.claude.json on every start of a non-SSO backend.
|
||||||
MERGED=$(jq 'del(.awsAuthRefresh)' "$CLAUDE_JSON" 2>/dev/null)
|
MERGED=$(jq 'del(.awsAuthRefresh)' "$CLAUDE_JSON" 2>/dev/null)
|
||||||
if [ -n "$MERGED" ]; then
|
if [ -n "$MERGED" ]; then
|
||||||
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
|
write_claude_json "$MERGED"
|
||||||
chown claude:claude "$CLAUDE_JSON"
|
|
||||||
chmod 600 "$CLAUDE_JSON"
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -491,16 +512,17 @@ if [ -f "$CLAUDE_JSON" ]; then
|
|||||||
# Only rewrite when the value isn't already true, to avoid a needless jq
|
# Only rewrite when the value isn't already true, to avoid a needless jq
|
||||||
# reformat of ~/.claude.json on every single start.
|
# reformat of ~/.claude.json on every single start.
|
||||||
if ! grep -q '"shiftEnterKeyBindingInstalled"[[:space:]]*:[[:space:]]*true' "$CLAUDE_JSON" 2>/dev/null; then
|
if ! grep -q '"shiftEnterKeyBindingInstalled"[[:space:]]*:[[:space:]]*true' "$CLAUDE_JSON" 2>/dev/null; then
|
||||||
|
# Atomic, via `write_claude_json` — see its comment for why a plain
|
||||||
|
# `>` on this file can permanently destroy the OAuth login.
|
||||||
MERGED=$(jq '.shiftEnterKeyBindingInstalled = true' "$CLAUDE_JSON" 2>/dev/null)
|
MERGED=$(jq '.shiftEnterKeyBindingInstalled = true' "$CLAUDE_JSON" 2>/dev/null)
|
||||||
if [ -n "$MERGED" ]; then
|
if [ -n "$MERGED" ]; then
|
||||||
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
|
write_claude_json "$MERGED"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
printf '{"shiftEnterKeyBindingInstalled":true}\n' > "$CLAUDE_JSON"
|
# Nothing to destroy, but the helper owns the owner/mode too.
|
||||||
|
write_claude_json '{"shiftEnterKeyBindingInstalled":true}'
|
||||||
fi
|
fi
|
||||||
chown claude:claude "$CLAUDE_JSON"
|
|
||||||
chmod 600 "$CLAUDE_JSON"
|
|
||||||
|
|
||||||
# ── Docker socket permissions ────────────────────────────────────────────────
|
# ── Docker socket permissions ────────────────────────────────────────────────
|
||||||
if [ -S /var/run/docker.sock ]; then
|
if [ -S /var/run/docker.sock ]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user