docs: terminal file viewer spec review and implementation plan
Review the approved design against tauri 2.11.0, xterm 5.5.0, Vite 6.4.1 and the tree: record the verified multi-window, capability, CSP and exec facts, choose the save mechanism (stage in /tmp, swap in as the container user), make polling a sha256sum exec, store realpath-resolved targets, and pin the exact viewer capability set. Add the task-by-task plan with its parallel groups and the verification commands that work here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,347 @@
|
||||
# Terminal file viewer/editor — design
|
||||
|
||||
Date: 2026-09-22
|
||||
Status: approved in conversation (user); reviewed against the code 2026-09-22 (see "Decisions
|
||||
made during review" at the end); plan at `docs/superpowers/plans/2026-09-22-terminal-file-viewer.md`
|
||||
|
||||
## Goal
|
||||
|
||||
File locations Claude prints in a terminal tab (`src/foo.ts:42`, `/workspace/x/README.md`,
|
||||
`app/src/lib/urlRelay.ts:139-150`) become clickable. A click opens the file in its **own OS
|
||||
window**, scrolled to and highlighting the line/range, syntax-highlighted, live-reloading while
|
||||
Claude changes it, and **editable** so specs and similar files can be read, edited and saved
|
||||
from inside the app.
|
||||
|
||||
Non-goals: tabs inside a viewer window, creating new files, diff/merge views, opening files in a
|
||||
host editor, locking the viewer window out of every other app command (that is the follow-up
|
||||
AppManifest spec, `2026-09-22-app-manifest-lockdown-design.md`).
|
||||
|
||||
## Current state (verified against the tree at `3537b23`)
|
||||
|
||||
- Versions: tauri 2.11.0 / tauri-utils 2.9.0 / @tauri-apps/api 2.11.0, wry 0.55, Vite 6.4.1,
|
||||
@xterm/xterm 5.5.0, @xterm/addon-web-links 0.12.0, React 19, bollard 0.18.1.
|
||||
- `TerminalView.tsx` (props `{ sessionId, active }`; `projectId` is derived from the session in
|
||||
the store): `WebLinksAddon` (http/https only, its own `registerLinkProvider` inside the addon),
|
||||
OSC 8 via `linkHandler` (`createOsc8LinkHandler(getHost, readState)`; xterm's
|
||||
`OscLinkProvider` drops any target that is not `http:`/`https:` **or that fails `new URL()`**
|
||||
because `allowNonHttpProtocols` is unset), OSC 7777 URL relay. Nothing calls
|
||||
`terminal.registerLinkProvider` directly. `opensOnClick(event, ctx, modifierPromised)` is a
|
||||
module-private function in `TerminalView.tsx`; toasts are `useAppState.getState().pushToast`.
|
||||
- `read_container_file(project_id, path, max_bytes)` in `commands/file_commands.rs` reads via
|
||||
bollard `download_from_container` (`fetch_container_file`). **The archive endpoint does not
|
||||
follow a final symlink and the helper refuses links** ("is a link — open its target instead"),
|
||||
so any path the viewer reads must already be resolved to a regular file. It caps at
|
||||
`MAX_READ_BYTES` = 8 MiB, returns `FileContents { contents_base64, truncated, size }`, and does
|
||||
**not** call `require_running`. `validate_container_path` (absolute, no NUL, no `..` segment,
|
||||
≤ 4096 bytes; it rejects rather than normalises) and `validate_container_write_path` (that plus
|
||||
`is_under_root` against `CONTAINER_WRITE_ROOTS = /workspace, /home/claude, /tmp`, compared by
|
||||
whole segments) are private `fn`s in `file_commands.rs`.
|
||||
- Execs: the container user is addressed by **name**, `"claude"` (never `uid:gid`);
|
||||
`docker::exec::exec_oneshot_streams_as(container_id, "claude", cmd, env) -> (stdout, stderr,
|
||||
exit_code)` runs without stdin, cwd `/workspace`, 8 MiB output cap, lossy UTF-8. There is no
|
||||
helper that feeds bytes to an exec's stdin. `ExecSessionManager::write_file_to_container
|
||||
(container_id, file_name, bytes)` lands bytes at `/tmp/<file_name>` owned by the container
|
||||
user (`container_user_ids`) with mode 0644 via the archive API. `upload_host_file_with_ids`
|
||||
is the host-file variant of the same tar path. `resolve_container_dir` runs `realpath -m` as
|
||||
`claude` and re-validates the result against the write roots (allowing the literal path when
|
||||
`realpath` fails).
|
||||
- `FileViewerModal` + `components/projects/home/filePreview.ts` (Files tab): `previewKind`,
|
||||
`imageMimeFor`, `previewLimit`, `decodeBase64`, `looksBinary`, `TEXT_PREVIEW_LIMIT` 1 MiB,
|
||||
`IMAGE_PREVIEW_LIMIT` 5 MiB. Reuse the classification and limits.
|
||||
- Only second window today is the browser-view pop-out (`browser_view/popout.rs`), a
|
||||
remote-origin window with no capability and no IPC. It builds `WebviewWindowBuilder::new(app,
|
||||
&label, WebviewUrl::External(url))` straight from an **async** command (no
|
||||
`run_on_main_thread`; Tauri documents that windows must be created from async commands, not
|
||||
sync ones), hooks `window.on_window_event` for `Destroyed`, and closes with `destroy()`, never
|
||||
`close()`. `lib.rs`'s `on_window_event` returns early for any label but `main`.
|
||||
- `build.rs` is a bare `tauri_build::build()`: **every app command is callable from every local
|
||||
window**. `capabilities/default.json` gates plugin commands only and lists `windows: ["main"]`.
|
||||
App commands need no capability entry (CLAUDE.md, "Key Conventions").
|
||||
- The frontend does not know a terminal's cwd. Terminal execs start in `/workspace`; each project
|
||||
path is bind-mounted at `/workspace/<mount_name>` (`ProjectPath { host_path, mount_name }`,
|
||||
rows with an empty `mount_name` are skipped at container creation). `/workspace` itself is not a
|
||||
mount.
|
||||
- Tauri multi-window facts that the design rests on:
|
||||
- `WebviewUrl::App("viewer.html".into())` is `Url::join`ed onto `build.devUrl` in dev
|
||||
(`http://localhost:1420/viewer.html`) and onto `tauri://localhost/` in a bundle
|
||||
(`http://tauri.localhost/` on Windows); both are `Origin::Local`, so capability files apply.
|
||||
**If `viewer.html` is missing, both Vite's dev server and Tauri's asset lookup silently fall
|
||||
back to `index.html`** — the main app opens in the viewer window. A test guards against this.
|
||||
- Capability `windows` entries are `glob::Pattern`s, so `"file-viewer-*"` matches.
|
||||
- `getCurrentWindow().onCloseRequested(cb)` listens on `tauri://close-requested` and then calls
|
||||
`destroy()` itself; Rust calls `prevent_close()` whenever a JS listener exists. So the
|
||||
viewer needs `core:window:allow-destroy` or **the X button stops working** the moment the
|
||||
listener is registered. `listen`/`unlisten` need `core:event:allow-listen`/`allow-unlisten`.
|
||||
Rust→window emits need no grant on the receiving side.
|
||||
- `app.emit_to(label, …)` targets one label, but a bare `listen()` in the main window
|
||||
(`EventTarget::Any`) still receives it. The viewer listens through
|
||||
`getCurrentWindow().listen(...)`, and the main window never listens to viewer event names.
|
||||
- The `app.security.csp` applies to every `.html` Tauri serves, `viewer.html` included. Tauri
|
||||
adds a `'nonce-…'` to `style-src` only when the entry HTML contains a literal `<style>`
|
||||
element, and a nonce disables `'unsafe-inline'` — which CodeMirror's `style-mod` needs for its
|
||||
runtime `<style>` injection. **`viewer.html` must not contain an inline `<style>`.**
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Path detection in the terminal (`app/src/lib/filePathLinks.ts`, `TerminalView.tsx`)
|
||||
|
||||
- A pure matcher `findFilePathLinks(lineText) -> FilePathMatch[]`, where
|
||||
`FilePathMatch = { start, end, path, line?, col?, endLine? }` (`start` inclusive, `end`
|
||||
exclusive, string indices into `lineText`), used by a new `ILinkProvider` registered with
|
||||
`terminal.registerLinkProvider` right after `term.loadAddon(webLinksAddon)` (providers are
|
||||
asked in registration order; the web-links one runs first, so an `http://` span never reaches
|
||||
the file matcher as a candidate — the matcher additionally refuses any span overlapping `://`).
|
||||
- Matches tokens that look like paths **with a file extension** (or a known extensionless name:
|
||||
`Makefile`, `Dockerfile`, `CLAUDE.md`-style names are already covered by the extension rule),
|
||||
optionally absolute, optionally `./`/`../` prefixed, followed by optional `:line`,
|
||||
`:line:col`, or `:start-end`. Also accepts `#L42` / `#L40-L50` suffixes.
|
||||
- Strips wrapping that Claude's markdown rendering leaves: backticks, parentheses, brackets,
|
||||
quotes, trailing `.,;:` punctuation.
|
||||
- Must not match inside URLs (http links are already handled; skip spans overlapping `://`),
|
||||
bare version numbers (`1.2.3`), or domain names (`example.com` with no `/` — require either a
|
||||
`/` in the token or an extension from an allowlist of common source/doc extensions for
|
||||
slash-less tokens).
|
||||
- Wrapped rows: v1 joins a buffer row with its `isWrapped` continuation rows the way
|
||||
`WebLinksAddon`'s private `LinkComputer._getWindowedLineStrings` does (walk up while the row
|
||||
`isWrapped`, walk down while the next row `isWrapped`, 2048-char budget, `translateToString
|
||||
(true)`), then maps string indices back to `{x, y}` cells. `LinkComputer` is not exported from
|
||||
the built addon, so the walk is re-implemented in `app/src/lib/xtermLineJoin.ts`
|
||||
(`joinWrappedRows(buffer, rowIndex0) -> { text, firstRow0, rowStarts }` plus
|
||||
`stringIndexToCell`). `urlDetector.ts` works on the byte stream, not the buffer, so it has
|
||||
nothing reusable here. `ILink.range` is 1-based on both axes with an **inclusive** end column;
|
||||
`provideLinks(bufferLineNumber)` is 1-based and `buffer.active.getLine(y)` is 0-based.
|
||||
- OSC 8 `file://` targets: set `allowNonHttpProtocols: true` on the handler and dispatch by
|
||||
scheme in `createOsc8LinkHandler`. With the flag on, xterm hands **every** OSC 8 target to
|
||||
`activate`/`hover`, including unparseable ones and `javascript:`; so the handler parses with
|
||||
`new URL()` itself: `file:` → `onOpenFile(pathname decoded, no line)`, `http(s):` → the
|
||||
existing `sanitizeRelayUrl` → `openUrlExternal` path unchanged, anything else → refused
|
||||
(`console.warn`, no hover card). The hover card for a `file:` target shows the path and "Open
|
||||
in viewer". The `WebLinksAddon` comment that describes the old `allowNonHttpProtocols`
|
||||
behaviour is updated, not left stale.
|
||||
- Activation uses the same click gating as web links (`opensOnClick`: no selection drag,
|
||||
single click; `modifierPromised` for OSC 8 hovers). A click invokes
|
||||
`openFileViewer(projectId, rawPath, line, col, endLine)` → `open_file_viewer`. No
|
||||
confirmation toast — nothing leaves the app and nothing is written without an explicit save.
|
||||
A rejected open (cap, container not running, project gone) is a `pushToast({ kind: "error" })`.
|
||||
- Hover: underline + pointer cursor (`decorations`), and the same bottom-left card the OSC 8
|
||||
handler draws, reading "Open in viewer".
|
||||
|
||||
### 2. Path resolution (Rust, `commands/file_viewer_commands.rs` + `file_viewer/resolve.rs`)
|
||||
|
||||
- Pure candidate generation `candidate_paths(raw, mount_names) -> Result<Vec<String>, String>`:
|
||||
- Absolute: exactly one candidate, after `validate_container_path` (which rejects `..`).
|
||||
- Relative `p`: strip a leading `./`, collapse repeated `/`, reject any `..` segment or NUL or
|
||||
length > 4096; candidates in order `/workspace/<p>`, then `/workspace/<mount>/<p>` for each
|
||||
non-empty `mount_name`, de-duplicated. Relative paths are the common case (Claude prints
|
||||
project-relative paths) and `/workspace/<p>` is first because the terminal's exec cwd is
|
||||
`/workspace`.
|
||||
- Existence is checked in **one** exec as `claude`: `sh -c` with the candidates as `$1..$n`,
|
||||
printing `realpath -e -- "$c"` for each candidate that is a regular file (`test -f`, which
|
||||
follows symlinks). Output lines are the **resolved** paths, which is what the registry stores
|
||||
(`fetch_container_file` refuses a link, so the resolved path is the only one that reads). The
|
||||
resolved path is re-checked with `validate_container_path`; a candidate whose resolution
|
||||
escapes to an invalid path is dropped. Candidate lists are capped at 16 entries; output lines
|
||||
are de-duplicated (two candidates may resolve to one file).
|
||||
- 0 matches → window opens in a "not found" state listing the candidates tried.
|
||||
- 1 match → open it.
|
||||
- \>1 match → window opens in a "choose file" state listing matches; choosing one calls
|
||||
`viewer_choose_file(index)`; the choice is from the Rust-held candidate list, never a path from
|
||||
the window.
|
||||
- Container must be running (`require_running`, via `project.container_id`); otherwise error
|
||||
toast in the main window, no window opened.
|
||||
|
||||
### 3. One window per click (`src-tauri/src/file_viewer/{mod.rs,registry.rs,window.rs}`)
|
||||
|
||||
- Each successful `open_file_viewer` creates a `WebviewWindow` with label
|
||||
`file-viewer-<counter>` (monotonic `AtomicU64`, never reused within a process), loading the
|
||||
app's own bundle at a second Vite entry (`viewer.html` → `src/viewer/main.tsx`;
|
||||
`build.rollupOptions.input = { main: "index.html", viewer: "viewer.html" }`), title
|
||||
`<basename> — <project name>` (set from Rust), default 900×700, min 480×320, resizable. The
|
||||
command is `async` (Tauri's requirement for window creation from a command).
|
||||
- A registry `ViewerRegistry { entries: Mutex<HashMap<String, ViewerTarget>>, next: AtomicU64 }`
|
||||
managed with `app.manage(...)` (separate from `AppState`, like the browser view keeps its own
|
||||
state), where
|
||||
`ViewerTarget { project_id, project_name, state: Resolved{container_path} | Choose{candidates}
|
||||
| NotFound{tried}, initial: Location { line, col, end_line } }`.
|
||||
Entry removed on `WindowEvent::Destroyed`.
|
||||
- If a window for the same `(project_id, container_path)` is already open (Resolved entries
|
||||
only): `unminimize()`, `set_focus()` and `app.emit_to(label, "file-viewer-goto", Location)` to
|
||||
that window instead of opening a duplicate; the viewer listens with
|
||||
`getCurrentWindow().listen("file-viewer-goto", ...)`.
|
||||
- Cap: 20 viewer windows (`MAX_VIEWER_WINDOWS`); the 21st click returns an error the main window
|
||||
shows as a toast. The cap counts registry entries, and an entry is reserved **before** the
|
||||
window is built so two concurrent clicks cannot both pass the check.
|
||||
- When the project's container stops/is removed, open viewer windows stay open but their reads
|
||||
fail and they show a "container not running" banner; they are not force-closed (unsaved text
|
||||
must not be destroyed). The registry stores `project_id`, not `container_id`, so a recreated
|
||||
container is picked up on the next poll.
|
||||
- `lib.rs`'s shutdown teardown does nothing for viewers: the process exits and the OS closes
|
||||
them. Unsaved edits in a viewer are lost on app quit (accepted; it is what the main window's
|
||||
close does to a terminal too).
|
||||
|
||||
### 4. Editor (`app/src/viewer/`)
|
||||
|
||||
- CodeMirror 6, direct packages only (no `codemirror` meta-package, no
|
||||
`@codemirror/language-data` — it hard-depends on 13 more grammars, one of which,
|
||||
`legacy-modes/mode/pug`, calls `Function(...)`): `@codemirror/state`, `view`, `commands`,
|
||||
`search`, `language`, `lang-markdown`, `lang-javascript`, `lang-rust`, `lang-python`,
|
||||
`lang-json`, `lang-yaml`, `lang-css`, `lang-html`, `legacy-modes` (toml, shell via
|
||||
`StreamLanguage.define`), plus `@lezer/highlight` for the tag-based highlight style. Language
|
||||
packs are chosen by extension in `viewer/languages.ts` and loaded with dynamic `import()`
|
||||
(same-origin chunks, fine under `script-src 'self'`). Verified: none of these packages uses
|
||||
`eval`/`new Function`/Workers; `style-mod` injects a `<style>` element, allowed by
|
||||
`style-src 'unsafe-inline'`. No CSP change.
|
||||
- Theme: `EditorView.theme` + `HighlightStyle` in `viewer/viewerTheme.ts`, colours from the
|
||||
app's CSS custom properties (`index.css` is imported by the viewer entry so the tokens exist).
|
||||
- Line numbers, search (Ctrl/Cmd+F, `searchKeymap`), go to line (`gotoLine`), history,
|
||||
`indentWithTab`, `lineWrapping` for markdown/plain text, highlight of the target line/range
|
||||
(a `Decoration.line` `StateField` driven by a `setHighlight` `StateEffect`), scroll target
|
||||
into view centred (`EditorView.scrollIntoView(pos, { y: "center" })`) on open and on
|
||||
`file-viewer-goto`.
|
||||
- Header: container path, project name, state badges (Read-only reason / Unsaved / Saved /
|
||||
Changed on disk / Container not running), Save button.
|
||||
- **Editable** only when: text (per `filePreview.ts` classification and `looksBinary`), not
|
||||
truncated (≤ 1 MiB), and Rust says `editable: true` (the resolved path is inside
|
||||
`CONTAINER_WRITE_ROOTS`). Otherwise the editor is read-only (`EditorState.readOnly` +
|
||||
`EditorView.editable` both false) with a badge stating why. Images render read-only as in
|
||||
`FileViewerModal` (Blob + object URL, `IMAGE_PREVIEW_LIMIT`).
|
||||
|
||||
### 5. Save + live reload
|
||||
|
||||
- Content identity: SHA-256 hex of file bytes. Two producers, one definition — the hash of the
|
||||
file's full bytes: Rust computes it (`sha2`, already a dependency) over the bytes it fetched,
|
||||
and `sha256sum` inside the container computes it for polls and for the save check. The two
|
||||
agree exactly when the read was not truncated, which is the only case in which the hash is
|
||||
used as a save base.
|
||||
- `viewer_read_file(max_bytes)` → `ViewerFile { contents_base64, truncated, size, hash,
|
||||
editable, readonly_reason: Option<String> }` for the caller's own target (`Resolved` only).
|
||||
`hash` is over the returned bytes. `editable` is `validate_container_write_path(resolved)`
|
||||
succeeding; `readonly_reason` is its message otherwise.
|
||||
- `viewer_poll_file()` → `ViewerPoll { exists, hash: Option<String>, size: Option<u64> }` via one
|
||||
exec as `claude`: `sha256sum -- "$1"` + `stat -c %s -- "$1"`. Missing file → `exists: false`.
|
||||
This is one small exec per window per tick, not a 1 MiB archive download.
|
||||
- Poll every 2 s while `document.visibilityState === "visible"`; pause otherwise; poll once
|
||||
immediately on `visibilitychange` back to visible. The reducer tracks `diskHash` (last known
|
||||
full-file hash; seeded from the read's `hash` when `truncated` is false, otherwise from the
|
||||
first poll) and `baseHash` (the hash the buffer was loaded from).
|
||||
- Poll hash == `diskHash` → nothing.
|
||||
- Poll hash changed, editor clean → `viewer_read_file` again, replace document, preserve
|
||||
scroll position and cursor (clamped), brief "Reloaded" indicator.
|
||||
- Poll hash changed, editor dirty → banner "Changed on disk" with **Reload (discard mine)** and
|
||||
**Overwrite on save**. No auto-merge. "Overwrite on save" sets `baseHash` to the polled hash
|
||||
so the next save succeeds.
|
||||
- `exists: false` → banner "File no longer exists"; content kept, editable text retained so the
|
||||
user can copy it; saving is disabled (no file creation).
|
||||
- Poll error (container not running) → banner "Container not running", editor keeps its state,
|
||||
polling continues so the banner clears when the container is back.
|
||||
- Save: Ctrl/Cmd+S or Save button → `viewer_write_file(contents_base64, base_hash)` → `Ok(hash)`.
|
||||
- Mechanism (**all mutation of the target directory happens as the container user**; the
|
||||
Docker archive API, which writes as root, only ever lands the payload in `/tmp`):
|
||||
1. Rust: `Resolved` entry only; decode; refuse if `!editable`, if content > 1 MiB, or if
|
||||
`base_hash` is not 64 hex chars.
|
||||
2. `ExecSessionManager::write_file_to_container(container_id, "triple-c-viewer-<uuid>",
|
||||
bytes)` → `/tmp/triple-c-viewer-<uuid>`, owned by the container user, mode 0644.
|
||||
3. One `sh` script as `claude` (`exec_oneshot_streams_as`), args `target tmp base_hash`:
|
||||
`test -f target` else exit 4 (gone); `sha256sum target` ≠ `base_hash` → exit 3
|
||||
(conflict); if the directory is writable: `cp tmp dir/.<name>.triple-c-tmp`, `chmod
|
||||
--reference=target staged`, `mv -f staged target`; else `cat tmp > target` (in-place
|
||||
fallback for a writable file in a read-only directory); `rm -f tmp` in every branch
|
||||
(`trap`); print `sha256sum target` on success.
|
||||
4. Exit 3 → `Err("conflict: …")` (the window shows the "Changed on disk" banner); exit 4 →
|
||||
`Err("gone: …")`; other non-zero → the clipped stderr.
|
||||
- Ownership: a non-root user cannot `chown`, so the file ends up owned by the container user
|
||||
— the same thing Claude Code's own edits produce. Mode is preserved via `chmod --reference`.
|
||||
- Symlinks: the registry already holds the `realpath -e` target (§2), and `editable` was
|
||||
computed on it, so a link into a non-writable root is read-only.
|
||||
- Returns the new hash; editor becomes clean, `baseHash = diskHash = new hash`.
|
||||
- Closing with unsaved edits → the window's own confirm bar (Save / Discard / Cancel) driven by
|
||||
`onCloseRequested` (`event.preventDefault()` when dirty); Discard calls `destroy()`.
|
||||
|
||||
### 6. Security
|
||||
|
||||
- New capability file `capabilities/file-viewer.json`, `windows: ["file-viewer-*"]`, granting
|
||||
exactly: `core:event:allow-listen`, `core:event:allow-unlisten`, `core:window:allow-destroy`
|
||||
(required for `onCloseRequested` to be able to close the window at all — see "Current state"),
|
||||
and `core:webview:allow-internal-toggle-devtools` (dev-only, as in `default.json`). No
|
||||
`allow-close`, no `set-title`/`set-focus`/`is-minimized` (title and focus are set from Rust;
|
||||
polling pauses on `visibilityState`). `default.json` stays `windows: ["main"]`. Update the
|
||||
threat-model census in `default.json`'s description to name the second file and why the viewer
|
||||
gets `allow-destroy`.
|
||||
- Viewer commands take `window: tauri::Window` and check the label:
|
||||
- `open_file_viewer` — `window.label() == "main"` only.
|
||||
- `viewer_get_state`, `viewer_read_file`, `viewer_poll_file`, `viewer_write_file`,
|
||||
`viewer_choose_file` — `file-viewer-*` only, and they operate solely on the registry entry
|
||||
for **the caller's own label**. No viewer command accepts a path or a label as an argument.
|
||||
- Rendering: CodeMirror renders text as DOM text nodes; nothing uses `innerHTML` /
|
||||
`dangerouslySetInnerHTML` on file content. CSP unchanged.
|
||||
- Accepted residual risk (closed by the AppManifest follow-up): a compromised viewer window can
|
||||
still invoke other app commands, because `build.rs` does not restrict app commands per window.
|
||||
- Update CLAUDE.md (frontend/backend structure, Key Conventions note about the viewer window and
|
||||
label-gated commands).
|
||||
|
||||
### 7. Error handling
|
||||
|
||||
All Rust errors are sentences suitable for display. Container-authored text is clipped via
|
||||
`clip_container_text` (made `pub(crate)`). The main window shows `open_file_viewer` failures as
|
||||
toasts; the viewer shows read/write failures as banners and never loses the editor buffer on
|
||||
failure.
|
||||
|
||||
### 8. Testing
|
||||
|
||||
- Vitest: `filePathLinks` matcher (positive/negative table incl. markdown wrapping, URLs,
|
||||
versions, `:l:c`, ranges, `#L`), `xtermLineJoin` on a fake buffer, OSC 8 scheme dispatch (in
|
||||
`TerminalView.test.tsx`, which already tests `createOsc8LinkHandler`), the viewer
|
||||
reload/dirty/conflict state machine as a pure reducer (`viewer/viewerState.ts`), editability
|
||||
rules, language selection.
|
||||
- Rust unit tests: candidate generation and normalization, label gating, registry
|
||||
dedupe/cap/removal, write script argument shape, size cap, `viewer.html` presence + Vite
|
||||
input assertion, and `every_command_is_registered_exactly_once` (existing) for the new
|
||||
commands.
|
||||
- `npx tsc --noEmit`, `npx vitest run`, `cargo test`, `cargo clippy` clean.
|
||||
- Manual (`npx tauri dev`): click paths in Claude output, multiple windows, edit + save,
|
||||
edit while Claude edits the same file (conflict banner), container stop with window open.
|
||||
|
||||
## Decisions made during review
|
||||
|
||||
Facts were checked against tauri 2.11.0 / tauri-utils 2.9.0 / @tauri-apps/api 2.11.0 sources,
|
||||
xterm 5.5.0, Vite 6.4.1 and the tree at `3537b23`. The user-approved decisions (one window per
|
||||
click, editable, CodeMirror 6, probe-roots resolution, 2 s polling, no autosave, conflict banner,
|
||||
20-window cap, AppManifest deferred) are unchanged. What changed:
|
||||
|
||||
1. **Write mechanism chosen (§5).** No exec helper feeds stdin, and whether a half-close of the
|
||||
hijacked exec connection reaches the process is unverified. Instead: stage bytes at `/tmp`
|
||||
with the existing `write_file_to_container` (container-user-owned), then one `sh` script as
|
||||
`claude` does hash-check + `chmod --reference` + `mv -f` in the target directory. Every write
|
||||
into the target directory is therefore subject to the container user's permissions; the
|
||||
root-privileged archive API never touches it. `chown --reference` was dropped: a non-root
|
||||
exec cannot chown, so the spec now says the saved file is owned by the container user.
|
||||
2. **Polling is an exec (`sha256sum` + `stat`), not a re-download (§5).** A 2 s tick per window
|
||||
that streams a 1 MiB archive was the alternative. The full-file hash from `sha256sum` equals
|
||||
the Rust-side hash exactly when the read was untruncated, which is the only editable case;
|
||||
the reducer keeps `diskHash` separate from `baseHash` so read-only (truncated) files still
|
||||
detect change without a reload loop.
|
||||
3. **Resolution stores the `realpath -e` result (§2).** `fetch_container_file` refuses a
|
||||
symlink, so the raw candidate path could not be read; probing already needs an exec, so it
|
||||
resolves in the same one. Candidate normalisation *rejects* `..` (matching
|
||||
`validate_container_path`, which does not normalise) rather than resolving it.
|
||||
4. **Exact capability set (§6).** `onCloseRequested` in @tauri-apps/api 2.11 calls
|
||||
`this.destroy()`, and Rust `prevent_close()`s whenever a JS listener exists, so
|
||||
`core:window:allow-destroy` is mandatory; `allow-close` is not needed and not granted.
|
||||
Focus/title/unminimize are done from Rust, so no `core:window:allow-set-*` grants.
|
||||
5. **OSC 8 with `allowNonHttpProtocols: true` receives unparseable and `javascript:` targets
|
||||
(§1).** The handler now parses and dispatches itself, and refuses everything but `file:` and
|
||||
`http(s):` before drawing a hover card. The stale comment in the `WebLinksAddon` branch is
|
||||
updated as part of the change.
|
||||
6. **Wrapped-row joining is re-implemented (§1).** `WebLinksAddon`'s `LinkComputer` is not
|
||||
exported from the built package and `urlDetector.ts` never touches the buffer.
|
||||
7. **CodeMirror package list pinned (§4).** Direct packages only; `@codemirror/language-data`
|
||||
and the `codemirror` meta-package are excluded (extra grammars, one with `Function(...)`).
|
||||
A home-grown theme from CSS tokens rather than `theme-one-dark`.
|
||||
8. **`viewer.html` fallback trap (Current state, §8).** Both Vite dev and Tauri's asset lookup
|
||||
fall back to `index.html` when the entry is missing; a Rust test asserts the file and the
|
||||
Vite input entry exist.
|
||||
9. **Registry is its own managed state (§3)** rather than a field on `AppState`, with a
|
||||
reserve-before-build cap check; label counter is a process-wide `AtomicU64`.
|
||||
10. **Event delivery (§3).** `emit_to(label, …)` still reaches a bare `listen()` in the main
|
||||
window, so the viewer subscribes via `getCurrentWindow().listen` and the main window has no
|
||||
listener for viewer event names.
|
||||
Reference in New Issue
Block a user