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>
3239 lines
139 KiB
Markdown
3239 lines
139 KiB
Markdown
# Terminal File Viewer Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** File paths in terminal output become clickable; a click opens the file from the project's container in its own OS window with a CodeMirror 6 editor that highlights the target line, live-reloads on a 2 s poll, and saves with hash-based conflict detection.
|
||
|
||
**Architecture:** A pure matcher plus an xterm `ILinkProvider` find paths in the terminal buffer and call one Rust command, `open_file_viewer`, which probes the candidate container paths in a single exec and opens a `file-viewer-<n>` window on a second Vite entry (`viewer.html`). A managed `ViewerRegistry` maps window labels to targets; every viewer command takes `window: tauri::Window` and operates only on its caller's entry, so no viewer command ever accepts a path. Reads use the existing archive-API fetch; polls are one `sha256sum` exec; saves stage bytes in `/tmp` via the existing tar upload and then let a `sh` script running as the container user do the hash check, `chmod --reference` and `mv -f`.
|
||
|
||
**Tech Stack:** Rust (tauri 2.11, bollard 0.18, sha2, base64, uuid), React 19 + TypeScript, @xterm/xterm 5.5, CodeMirror 6 (`@codemirror/*` 6.x, `@lezer/highlight`), Vite 6 multi-page, Vitest + jsdom.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-09-22-terminal-file-viewer-design.md` — read it first, including "Decisions made during review", which records why each mechanism below is the one chosen.
|
||
|
||
## Global Constraints
|
||
|
||
- **User-approved decisions are fixed:** one window per click, editable, CodeMirror 6, probe-roots resolution, 2 s polling, no autosave, "Changed on disk" conflict banner, 20-window cap, AppManifest lockdown deferred.
|
||
- **Design tokens only.** All colour from CSS custom properties in `app/src/index.css` (`--bg-primary`, `--bg-secondary`, `--border-color`, `--text-primary`, `--text-secondary`, `--text-disabled`, `--accent`, `--accent-emphasis`, `--warning`, `--warning-muted`, `--success`, `--radius-control`, `--radius-panel`). Filled buttons use `--accent-emphasis`. Never `focus:outline-none`, never `disabled:opacity-50`.
|
||
- **Use `components/ui` primitives** (`Button`, `StatusIndicator`) — do not hand-roll replacements. Status is never colour alone.
|
||
- **Frontend types in `lib/types.ts` must match Rust structs field-for-field** (snake_case both sides).
|
||
- **Every `#[tauri::command]` goes into `generate_handler![]` in `lib.rs`, one fully-qualified path per line.** The test `every_command_is_registered_exactly_once` (`lib.rs:759`) fails otherwise.
|
||
- **No viewer command accepts a path or a label argument.** Commands take `window: tauri::Window` and gate on `window.label()`.
|
||
- **`viewer.html` must not contain an inline `<style>` element** (Tauri would add a style nonce, which disables `'unsafe-inline'`, which CodeMirror needs). CSP in `tauri.conf.json` is unchanged.
|
||
- **No `codemirror` meta-package, no `@codemirror/language-data`, no `@codemirror/theme-one-dark`.** Direct packages only (list in Task 6).
|
||
- **Commands return `Result<T, String>`** with user-readable sentences; container-authored text goes through `clip_container_text`.
|
||
- **Frontend tests mock `lib/tauri-commands`, never `@tauri-apps/api/core`.**
|
||
- **No `tempfile` crate.** Rust tests needing a directory use `std::env::temp_dir().join(format!("triple-c-…-{}", uuid::Uuid::new_v4().simple()))`.
|
||
- **Commit after every task** with a `feat:`/`test:`/`docs:` message. Do not push.
|
||
|
||
## What works in this environment (verified 2026-09-22)
|
||
|
||
| Command | Status |
|
||
|---|---|
|
||
| `cd app && npx vitest run [path]` | works (817 tests, ~5 s) |
|
||
| `cd app && npx tsc --noEmit` | works |
|
||
| `cd app && npm run build` | works (tsc + vite build; see Task 6 for the multi-page check) |
|
||
| `cd app && npm install <pkg>` | works (registry reachable; CodeMirror 6 packages resolved) |
|
||
| `cd app/src-tauri && cargo check --offline` | works (~7 s incremental; GTK/WebKitGTK 4.1/libsoup3 present) |
|
||
| `cd app/src-tauri && cargo test --offline [filter]` | works (598 tests listed; ~25 s to build) |
|
||
| `cd app/src-tauri && cargo clippy --offline` | works |
|
||
| `npx tauri dev` / `npx tauri build` | **not verifiable here** (no display). The manual checklist in Task 12 runs on the user's machine. |
|
||
| `docker` | daemon reachable, but no Triple-C container exists; the write script can be exercised against any `ubuntu:24.04` container (Task 3 shows how). |
|
||
|
||
`--offline` is used because the crate cache is complete; drop it if a new crate is ever needed (none is: `sha2`, `base64`, `uuid`, `serde`, `log` are already dependencies).
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**Create (Rust, `app/src-tauri/src/`):**
|
||
- `file_viewer/mod.rs` — module root: constants (`MAX_VIEWER_WINDOWS`, `VIEWER_LABEL_PREFIX`), `is_viewer_label`.
|
||
- `file_viewer/registry.rs` — `ViewerRegistry`, `ViewerTarget`, `ViewerTargetState`, `Location`. Reserve/get/set/remove, dedupe lookup, cap.
|
||
- `file_viewer/resolve.rs` — pure candidate generation, the probe script, its output parser, and the async probe.
|
||
- `file_viewer/poll.rs` — the poll script and its parser (`ViewerPoll`).
|
||
- `file_viewer/write.rs` — `sha256_hex`, the write script, exit-code classification, the async write.
|
||
- `file_viewer/window.rs` — builds the `file-viewer-<n>` window and hooks `Destroyed`.
|
||
- `commands/file_viewer_commands.rs` — the six IPC entry points and the label gates.
|
||
|
||
**Create (frontend, `app/src/`):**
|
||
- `lib/filePathLinks.ts` — pure matcher `findFilePathLinks`.
|
||
- `lib/xtermLineJoin.ts` — wrapped-row joining and string-index → cell mapping over an `IBuffer`.
|
||
- `components/terminal/filePathLinkProvider.ts` — the `ILinkProvider` built from the two above.
|
||
- `viewer/main.tsx` — entry for `viewer.html`.
|
||
- `viewer/ViewerApp.tsx` — loads state, routes to Choose / NotFound / EditorPane.
|
||
- `viewer/viewerState.ts` — pure reducer for clean/dirty/changed-on-disk/gone/container-down.
|
||
- `viewer/editability.ts` — text/image/binary classification + editable decision from a `ViewerFile`.
|
||
- `viewer/languages.ts` — extension → lazily loaded CodeMirror language.
|
||
- `viewer/viewerTheme.ts` — `EditorView.theme` + `HighlightStyle` from CSS tokens.
|
||
- `viewer/highlightLine.ts` — line-range highlight `StateField` + `setHighlight` effect.
|
||
- `viewer/CodeEditor.tsx` — CodeMirror React wrapper.
|
||
- `viewer/useViewerPolling.ts` — visibility-gated interval.
|
||
- `viewer/EditorPane.tsx` — header, banners, save, close confirmation.
|
||
- `app/viewer.html` — second Vite entry.
|
||
|
||
**Modify:**
|
||
- `app/vite.config.ts` — `build.rollupOptions.input`.
|
||
- `app/package.json` — CodeMirror dependencies.
|
||
- `app/src-tauri/capabilities/file-viewer.json` (new) and `capabilities/default.json` (description census).
|
||
- `app/src-tauri/src/lib.rs` — `mod file_viewer;`, `.manage(ViewerRegistry::default())`, six handler entries.
|
||
- `app/src-tauri/src/commands/mod.rs` — `pub mod file_viewer_commands;`.
|
||
- `app/src-tauri/src/commands/file_commands.rs` — `pub(crate)` on `validate_container_path`, `validate_container_write_path`, `require_running`, `clip_container_text`, `fetch_container_file`, `FetchedFile` and its fields.
|
||
- `app/src/lib/types.ts`, `app/src/lib/tauri-commands.ts` — viewer types and wrappers.
|
||
- `app/src/components/terminal/TerminalView.tsx` — register the provider; OSC 8 scheme dispatch.
|
||
- `app/src/components/terminal/TerminalView.test.tsx` — OSC 8 dispatch tests.
|
||
- `CLAUDE.md` — structure notes.
|
||
|
||
## Interfaces (the contract every task codes against)
|
||
|
||
**Rust → TS types** (`app/src/lib/types.ts`, added in Task 0; Rust in Tasks 2, 3, 8):
|
||
|
||
```ts
|
||
export interface ViewerLocation {
|
||
line: number | null;
|
||
col: number | null;
|
||
end_line: number | null;
|
||
}
|
||
export type ViewerTargetState =
|
||
| { kind: "resolved"; container_path: string }
|
||
| { kind: "choose"; candidates: string[] }
|
||
| { kind: "not_found"; tried: string[] };
|
||
export interface ViewerState {
|
||
project_id: string;
|
||
project_name: string;
|
||
raw_path: string;
|
||
state: ViewerTargetState;
|
||
initial: ViewerLocation;
|
||
}
|
||
export interface ViewerFile {
|
||
contents_base64: string;
|
||
truncated: boolean;
|
||
size: number;
|
||
/** SHA-256 hex of the returned bytes; equals the file's hash when `truncated` is false. */
|
||
hash: string;
|
||
editable: boolean;
|
||
readonly_reason: string | null;
|
||
}
|
||
export interface ViewerPoll {
|
||
exists: boolean;
|
||
hash: string | null;
|
||
size: number | null;
|
||
}
|
||
```
|
||
|
||
**Commands** (`app/src/lib/tauri-commands.ts`, Task 0; Rust signatures, Task 8):
|
||
|
||
| TS wrapper | Rust command | Caller |
|
||
|---|---|---|
|
||
| `openFileViewer(projectId, path, line?, col?, endLine?): Promise<void>` | `open_file_viewer(project_id: String, path: String, line: Option<u32>, col: Option<u32>, end_line: Option<u32>, window: tauri::Window, app: tauri::AppHandle, registry: State<ViewerRegistry>, state: State<AppState>) -> Result<(), String>` | main only |
|
||
| `viewerGetState(): Promise<ViewerState>` | `viewer_get_state(window, registry) -> Result<ViewerState, String>` | viewer only |
|
||
| `viewerReadFile(maxBytes): Promise<ViewerFile>` | `viewer_read_file(max_bytes: u64, window, registry, state) -> Result<ViewerFile, String>` | viewer only |
|
||
| `viewerPollFile(): Promise<ViewerPoll>` | `viewer_poll_file(window, registry, state) -> Result<ViewerPoll, String>` | viewer only |
|
||
| `viewerWriteFile(contentsBase64, baseHash): Promise<string>` | `viewer_write_file(contents_base64: String, base_hash: String, window, registry, state) -> Result<String, String>` (new hash) | viewer only |
|
||
| `viewerChooseFile(index): Promise<ViewerState>` | `viewer_choose_file(index: usize, window, registry) -> Result<ViewerState, String>` | viewer only |
|
||
|
||
**Error prefixes** `viewer_write_file` returns, which the frontend matches on: `"conflict: "` (disk changed since `base_hash`), `"gone: "` (file no longer exists). Anything else is a generic failure sentence.
|
||
|
||
**Events:** Rust → viewer window `"file-viewer-goto"` with payload `ViewerLocation`, emitted with `app.emit_to(label, …)`; the viewer subscribes with `getCurrentWindow().listen("file-viewer-goto", …)`.
|
||
|
||
**Window label:** `file-viewer-<n>`, `n` from a process-wide counter starting at 1.
|
||
|
||
---
|
||
|
||
## Task 0: Interfaces — TS types, command wrappers, Rust module skeleton
|
||
|
||
Run first, alone (minutes). Everything in Group A compiles against this, and it is the only task that touches `file_commands.rs` visibility or `file_viewer/mod.rs`'s module list, so the group can genuinely run in parallel.
|
||
|
||
**Files:**
|
||
- Modify: `app/src/lib/types.ts` (append at end)
|
||
- Modify: `app/src/lib/tauri-commands.ts` (append at end)
|
||
- Create: `app/src-tauri/src/file_viewer/mod.rs`, plus placeholder `registry.rs`, `resolve.rs`, `poll.rs`, `write.rs`, `window.rs` in the same directory (each containing only `//! Filled in by Task N.`)
|
||
- Modify: `app/src-tauri/src/lib.rs` — add `pub mod file_viewer;` beside the other `mod` lines
|
||
- Modify: `app/src-tauri/src/commands/file_commands.rs` — change to `pub(crate)`: `const MAX_READ_BYTES` (line 49), `fn validate_container_path` (355), `fn validate_container_write_path` (397), `struct FetchedFile` and its three fields (1181–1187), `async fn fetch_container_file` (1205), `async fn require_running` (1459), `fn clip_container_text` (2014)
|
||
|
||
**Interfaces:** Produces exactly the TS block in "Interfaces" above, and on the Rust side `file_viewer::{MAX_VIEWER_WINDOWS, VIEWER_LABEL_PREFIX, is_viewer_label}` and the `pub(crate)` helpers named above.
|
||
|
||
- [ ] **Step 0: Create `file_viewer/mod.rs`** (and the five one-line placeholder files):
|
||
|
||
```rust
|
||
//! The terminal file viewer: one OS window per clicked path.
|
||
//!
|
||
//! Every window is a `file-viewer-<n>` label registered in [`registry::ViewerRegistry`];
|
||
//! the commands in `commands/file_viewer_commands.rs` gate on the label and act only on
|
||
//! the caller's own entry, which is why nothing here takes a path from a window.
|
||
|
||
pub mod poll;
|
||
pub mod registry;
|
||
pub mod resolve;
|
||
pub mod window;
|
||
pub mod write;
|
||
|
||
/// Spec §3: the 21st click is refused with a toast.
|
||
pub const MAX_VIEWER_WINDOWS: usize = 20;
|
||
pub const VIEWER_LABEL_PREFIX: &str = "file-viewer-";
|
||
|
||
pub fn is_viewer_label(label: &str) -> bool {
|
||
label
|
||
.strip_prefix(VIEWER_LABEL_PREFIX)
|
||
.is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn only_numbered_viewer_labels_pass() {
|
||
assert!(is_viewer_label("file-viewer-1"));
|
||
assert!(is_viewer_label("file-viewer-20"));
|
||
assert!(!is_viewer_label("file-viewer-"));
|
||
assert!(!is_viewer_label("file-viewer-x"));
|
||
assert!(!is_viewer_label("main"));
|
||
assert!(!is_viewer_label("browser-view-abc"));
|
||
}
|
||
}
|
||
```
|
||
|
||
Then `cd app/src-tauri && cargo test --offline file_viewer` → the one test passes (a `dead_code` warning on the now-unused `pub(crate)` items is expected until Group A lands; do not `#[allow]` it).
|
||
|
||
- [ ] **Step 1: Add the types** to the end of `app/src/lib/types.ts`:
|
||
|
||
```ts
|
||
// ---- Terminal file viewer (commands/file_viewer_commands.rs) ----
|
||
|
||
export interface ViewerLocation {
|
||
line: number | null;
|
||
col: number | null;
|
||
end_line: number | null;
|
||
}
|
||
|
||
export type ViewerTargetState =
|
||
| { kind: "resolved"; container_path: string }
|
||
| { kind: "choose"; candidates: string[] }
|
||
| { kind: "not_found"; tried: string[] };
|
||
|
||
export interface ViewerState {
|
||
project_id: string;
|
||
project_name: string;
|
||
/** What was clicked, for the title and the not-found message. */
|
||
raw_path: string;
|
||
state: ViewerTargetState;
|
||
initial: ViewerLocation;
|
||
}
|
||
|
||
export interface ViewerFile {
|
||
contents_base64: string;
|
||
truncated: boolean;
|
||
size: number;
|
||
/** SHA-256 hex of the returned bytes; equals the file's hash when `truncated` is false. */
|
||
hash: string;
|
||
editable: boolean;
|
||
readonly_reason: string | null;
|
||
}
|
||
|
||
export interface ViewerPoll {
|
||
exists: boolean;
|
||
hash: string | null;
|
||
size: number | null;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add the wrappers** to the end of `app/src/lib/tauri-commands.ts` (add `ViewerFile, ViewerPoll, ViewerState` to the existing `import type { … } from "./types"`):
|
||
|
||
```ts
|
||
// ---- Terminal file viewer ----
|
||
|
||
export const openFileViewer = (
|
||
projectId: string,
|
||
path: string,
|
||
line?: number,
|
||
col?: number,
|
||
endLine?: number,
|
||
) => invoke<void>("open_file_viewer", { projectId, path, line, col, endLine });
|
||
|
||
export const viewerGetState = () => invoke<ViewerState>("viewer_get_state");
|
||
export const viewerReadFile = (maxBytes: number) =>
|
||
invoke<ViewerFile>("viewer_read_file", { maxBytes });
|
||
export const viewerPollFile = () => invoke<ViewerPoll>("viewer_poll_file");
|
||
export const viewerWriteFile = (contentsBase64: string, baseHash: string) =>
|
||
invoke<string>("viewer_write_file", { contentsBase64, baseHash });
|
||
export const viewerChooseFile = (index: number) =>
|
||
invoke<ViewerState>("viewer_choose_file", { index });
|
||
```
|
||
|
||
- [ ] **Step 3: Verify** — `cd app && npx tsc --noEmit` → exit 0.
|
||
|
||
- [ ] **Step 4: Commit** — `git add app/src/lib/types.ts app/src/lib/tauri-commands.ts app/src-tauri/src/file_viewer app/src-tauri/src/lib.rs app/src-tauri/src/commands/file_commands.rs && git commit -m "feat(viewer): IPC types, wrappers and Rust module skeleton for the file viewer"`
|
||
|
||
---
|
||
|
||
## Parallel group A (no dependencies between them; all need only Task 0 or nothing)
|
||
|
||
### Task 1: Rust — candidate generation and probe parsing (`file_viewer/resolve.rs`)
|
||
|
||
**Files:**
|
||
- Replace: `app/src-tauri/src/file_viewer/resolve.rs` (Task 0's placeholder)
|
||
|
||
**Interfaces:**
|
||
- Consumes `validate_container_path` (`pub(crate)` since Task 0) and `docker::exec::exec_oneshot_streams_as`.
|
||
- Produces `pub fn candidate_paths(raw: &str, mount_names: &[String]) -> Result<Vec<String>, String>`, `pub const PROBE_SCRIPT: &str`, `pub fn parse_probe_output(stdout: &str) -> Vec<String>`, `pub async fn probe_candidates(container_id: &str, candidates: &[String]) -> Result<Vec<String>, String>`, `pub const MAX_CANDIDATES: usize = 16`.
|
||
|
||
- [ ] **Step 1: Write the failing tests** in `file_viewer/resolve.rs` (the file starts as tests + `use` lines only):
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn mounts(names: &[&str]) -> Vec<String> {
|
||
names.iter().map(|s| s.to_string()).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn an_absolute_path_is_its_own_only_candidate() {
|
||
let c = candidate_paths("/workspace/api/src/main.rs", &mounts(&["api"])).unwrap();
|
||
assert_eq!(c, vec!["/workspace/api/src/main.rs"]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_relative_path_probes_workspace_then_each_mount() {
|
||
let c = candidate_paths("src/main.rs", &mounts(&["api", "web"])).unwrap();
|
||
assert_eq!(
|
||
c,
|
||
vec!["/workspace/src/main.rs", "/workspace/api/src/main.rs", "/workspace/web/src/main.rs"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dot_prefix_and_duplicate_slashes_are_normalised_and_candidates_deduped() {
|
||
let c = candidate_paths("./src//main.rs", &mounts(&["api", "api", ""])).unwrap();
|
||
assert_eq!(c, vec!["/workspace/src/main.rs", "/workspace/api/src/main.rs"]);
|
||
}
|
||
|
||
#[test]
|
||
fn traversal_nul_and_oversize_are_refused() {
|
||
assert!(candidate_paths("../etc/passwd", &[]).is_err());
|
||
assert!(candidate_paths("src/../../x", &[]).is_err());
|
||
assert!(candidate_paths("/workspace/../etc/passwd", &[]).is_err());
|
||
assert!(candidate_paths("a\0b", &[]).is_err());
|
||
assert!(candidate_paths("", &[]).is_err());
|
||
assert!(candidate_paths(&"a".repeat(5000), &[]).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn candidate_list_is_capped() {
|
||
let many: Vec<String> = (0..40).map(|i| format!("m{}", i)).collect();
|
||
let c = candidate_paths("x.rs", &many).unwrap();
|
||
assert_eq!(c.len(), MAX_CANDIDATES);
|
||
}
|
||
|
||
#[test]
|
||
fn probe_output_keeps_valid_resolved_regular_files_only() {
|
||
let out = "/workspace/api/src/main.rs\n/workspace/api/src/main.rs\n\nrelative/junk\n/etc/../x\n/workspace/web/src/main.rs\n";
|
||
assert_eq!(
|
||
parse_probe_output(out),
|
||
vec!["/workspace/api/src/main.rs", "/workspace/web/src/main.rs"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_probe_script_prints_resolved_paths_of_regular_files() {
|
||
// Shape assertions: the script is data handed to `sh -c`, and these are the
|
||
// three things a later edit must not lose.
|
||
assert!(PROBE_SCRIPT.contains("test -f"));
|
||
assert!(PROBE_SCRIPT.contains("realpath -e --"));
|
||
assert!(PROBE_SCRIPT.contains("for c in \"$@\""));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure** — `cd app/src-tauri && cargo test --offline file_viewer::resolve` → compile error (functions undefined).
|
||
|
||
- [ ] **Step 3: Implement** (top of `file_viewer/resolve.rs`, above the tests):
|
||
|
||
```rust
|
||
//! Turning what Claude printed into a container path that exists.
|
||
//!
|
||
//! Relative paths are the common case (Claude prints project-relative paths). The
|
||
//! terminal exec's cwd is `/workspace`, and each project path is mounted at
|
||
//! `/workspace/<mount_name>`, so those are the roots probed, in that order. The probe
|
||
//! is one exec as the container user and prints `realpath -e` of every candidate that
|
||
//! is a regular file: `fetch_container_file` refuses a symlink, so the registry must
|
||
//! hold the resolved path, not the one that was clicked.
|
||
|
||
use crate::commands::file_commands::validate_container_path;
|
||
use crate::docker::exec::exec_oneshot_streams_as;
|
||
|
||
pub const MAX_CANDIDATES: usize = 16;
|
||
const MAX_RAW_LEN: usize = 4096;
|
||
|
||
/// `$@` are the candidates. For each regular file, print its resolved path.
|
||
pub const PROBE_SCRIPT: &str = r#"for c in "$@"; do if test -f "$c"; then realpath -e -- "$c" 2>/dev/null; fi; done; exit 0"#;
|
||
|
||
pub fn candidate_paths(raw: &str, mount_names: &[String]) -> Result<Vec<String>, String> {
|
||
if raw.is_empty() {
|
||
return Err("The path is empty.".into());
|
||
}
|
||
if raw.len() > MAX_RAW_LEN {
|
||
return Err("The path is too long.".into());
|
||
}
|
||
if raw.contains('\0') {
|
||
return Err("The path contains a NUL byte.".into());
|
||
}
|
||
if raw.split('/').any(|seg| seg == "..") {
|
||
return Err(format!("{} climbs out of its folder with `..`; refusing.", raw));
|
||
}
|
||
|
||
if raw.starts_with('/') {
|
||
let normalised = collapse(raw);
|
||
validate_container_path("File", &normalised)?;
|
||
return Ok(vec![normalised]);
|
||
}
|
||
|
||
let rel = collapse(raw.strip_prefix("./").unwrap_or(raw));
|
||
let rel = rel.trim_start_matches("./");
|
||
if rel.is_empty() {
|
||
return Err("The path is empty.".into());
|
||
}
|
||
|
||
let mut out: Vec<String> = Vec::new();
|
||
let mut push = |candidate: String| {
|
||
if out.len() < MAX_CANDIDATES && !out.contains(&candidate) {
|
||
out.push(candidate);
|
||
}
|
||
};
|
||
push(format!("/workspace/{}", rel));
|
||
for mount in mount_names {
|
||
if mount.is_empty() || mount.contains('/') || mount == "." || mount == ".." {
|
||
continue;
|
||
}
|
||
push(format!("/workspace/{}/{}", mount, rel));
|
||
}
|
||
for c in &out {
|
||
validate_container_path("File", c)?;
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// `a//b/./c` → `a/b/c`. Never touches `..` (rejected before this runs).
|
||
fn collapse(path: &str) -> String {
|
||
let absolute = path.starts_with('/');
|
||
let joined = path
|
||
.split('/')
|
||
.filter(|seg| !seg.is_empty() && *seg != ".")
|
||
.collect::<Vec<_>>()
|
||
.join("/");
|
||
if absolute { format!("/{}", joined) } else { joined }
|
||
}
|
||
|
||
/// One resolved path per line; anything that is not an absolute, valid container path is
|
||
/// dropped (the script's own diagnostics go to stderr, but a hostile `realpath` output is
|
||
/// still container-authored text).
|
||
pub fn parse_probe_output(stdout: &str) -> Vec<String> {
|
||
let mut seen: Vec<String> = Vec::new();
|
||
for line in stdout.lines() {
|
||
let line = line.trim();
|
||
if line.is_empty() || validate_container_path("File", line).is_err() {
|
||
continue;
|
||
}
|
||
if !seen.iter().any(|s| s == line) {
|
||
seen.push(line.to_string());
|
||
}
|
||
}
|
||
seen
|
||
}
|
||
|
||
pub async fn probe_candidates(
|
||
container_id: &str,
|
||
candidates: &[String],
|
||
) -> Result<Vec<String>, String> {
|
||
let mut cmd: Vec<String> = vec!["sh".into(), "-c".into(), PROBE_SCRIPT.into(), "probe".into()];
|
||
cmd.extend(candidates.iter().cloned());
|
||
let (stdout, _stderr, _code) =
|
||
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
|
||
Ok(parse_probe_output(&stdout))
|
||
}
|
||
```
|
||
|
||
Note the `"probe"` argument after the script: with `sh -c SCRIPT NAME ARGS…`, the first word is `$0`, so the candidates land in `$@`.
|
||
|
||
- [ ] **Step 4: Run tests** — `cargo test --offline file_viewer::resolve` → all pass. `cargo clippy --offline` clean.
|
||
|
||
- [ ] **Step 5: Commit** — `git add app/src-tauri/src/file_viewer/resolve.rs && git commit -m "feat(viewer): candidate paths and container probe for the file viewer"`
|
||
|
||
---
|
||
|
||
### Task 2: Rust — the window registry (`file_viewer/registry.rs`)
|
||
|
||
**Files:**
|
||
- Replace: `app/src-tauri/src/file_viewer/registry.rs` (Task 0's placeholder)
|
||
|
||
**Interfaces:** Produces
|
||
|
||
```rust
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||
pub struct Location { pub line: Option<u32>, pub col: Option<u32>, pub end_line: Option<u32> }
|
||
|
||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||
pub enum ViewerTargetState {
|
||
Resolved { container_path: String },
|
||
Choose { candidates: Vec<String> },
|
||
NotFound { tried: Vec<String> },
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||
pub struct ViewerTarget {
|
||
pub project_id: String, pub project_name: String, pub raw_path: String,
|
||
pub state: ViewerTargetState, pub initial: Location,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub struct ViewerRegistry { /* Mutex<HashMap<String, ViewerTarget>>, AtomicU64 */ }
|
||
impl ViewerRegistry {
|
||
pub fn reserve(&self, target: ViewerTarget) -> Result<String, String>; // label, or cap error
|
||
pub fn get(&self, label: &str) -> Option<ViewerTarget>;
|
||
pub fn set_state(&self, label: &str, state: ViewerTargetState) -> Result<ViewerTarget, String>;
|
||
pub fn remove(&self, label: &str);
|
||
pub fn find_open(&self, project_id: &str, container_path: &str) -> Option<String>; // label
|
||
pub fn len(&self) -> usize;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1: Write the failing tests** (bottom of `registry.rs`):
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn target(project: &str, path: &str) -> ViewerTarget {
|
||
ViewerTarget {
|
||
project_id: project.into(),
|
||
project_name: "Demo".into(),
|
||
raw_path: path.into(),
|
||
state: ViewerTargetState::Resolved { container_path: path.into() },
|
||
initial: Location { line: Some(3), col: None, end_line: None },
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn labels_are_sequential_and_never_reused() {
|
||
let r = ViewerRegistry::default();
|
||
let a = r.reserve(target("p", "/workspace/a")).unwrap();
|
||
let b = r.reserve(target("p", "/workspace/b")).unwrap();
|
||
assert_eq!(a, "file-viewer-1");
|
||
assert_eq!(b, "file-viewer-2");
|
||
r.remove(&a);
|
||
let c = r.reserve(target("p", "/workspace/c")).unwrap();
|
||
assert_eq!(c, "file-viewer-3");
|
||
assert_eq!(r.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn the_cap_refuses_the_twenty_first_window() {
|
||
let r = ViewerRegistry::default();
|
||
for i in 0..MAX_VIEWER_WINDOWS {
|
||
r.reserve(target("p", &format!("/workspace/{}", i))).unwrap();
|
||
}
|
||
let err = r.reserve(target("p", "/workspace/one-more")).unwrap_err();
|
||
assert!(err.contains("20"), "{}", err);
|
||
assert_eq!(r.len(), MAX_VIEWER_WINDOWS);
|
||
}
|
||
|
||
#[test]
|
||
fn an_open_resolved_file_is_found_by_project_and_path() {
|
||
let r = ViewerRegistry::default();
|
||
let label = r.reserve(target("p", "/workspace/a")).unwrap();
|
||
assert_eq!(r.find_open("p", "/workspace/a"), Some(label.clone()));
|
||
assert_eq!(r.find_open("other", "/workspace/a"), None);
|
||
// A window still choosing is not "open on" any path.
|
||
r.set_state(&label, ViewerTargetState::Choose { candidates: vec!["/workspace/a".into()] }).unwrap();
|
||
assert_eq!(r.find_open("p", "/workspace/a"), None);
|
||
r.remove(&label);
|
||
assert_eq!(r.get(&label), None);
|
||
}
|
||
|
||
#[test]
|
||
fn set_state_on_an_unknown_label_is_an_error() {
|
||
let r = ViewerRegistry::default();
|
||
assert!(r.set_state("file-viewer-9", ViewerTargetState::NotFound { tried: vec![] }).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn target_state_serialises_with_a_kind_tag() {
|
||
let s = serde_json::to_string(&ViewerTargetState::NotFound { tried: vec!["/x".into()] }).unwrap();
|
||
assert_eq!(s, r#"{"kind":"not_found","tried":["/x"]}"#);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure** — `cargo test --offline file_viewer::registry` → compile error.
|
||
|
||
- [ ] **Step 3: Implement** (top of `registry.rs`):
|
||
|
||
```rust
|
||
//! Which viewer window is looking at what.
|
||
//!
|
||
//! Managed with `app.manage(ViewerRegistry::default())` rather than as a field on
|
||
//! `AppState`, like the browser view keeps its own state. A label is reserved *before*
|
||
//! the window is built so two concurrent clicks cannot both pass the cap check.
|
||
|
||
use std::collections::HashMap;
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use std::sync::Mutex;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use super::{MAX_VIEWER_WINDOWS, VIEWER_LABEL_PREFIX};
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||
pub struct Location {
|
||
pub line: Option<u32>,
|
||
pub col: Option<u32>,
|
||
pub end_line: Option<u32>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||
pub enum ViewerTargetState {
|
||
Resolved { container_path: String },
|
||
Choose { candidates: Vec<String> },
|
||
NotFound { tried: Vec<String> },
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||
pub struct ViewerTarget {
|
||
pub project_id: String,
|
||
pub project_name: String,
|
||
pub raw_path: String,
|
||
pub state: ViewerTargetState,
|
||
pub initial: Location,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub struct ViewerRegistry {
|
||
entries: Mutex<HashMap<String, ViewerTarget>>,
|
||
next: AtomicU64,
|
||
}
|
||
|
||
impl ViewerRegistry {
|
||
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, ViewerTarget>> {
|
||
self.entries.lock().unwrap_or_else(|e| e.into_inner())
|
||
}
|
||
|
||
pub fn reserve(&self, target: ViewerTarget) -> Result<String, String> {
|
||
let mut entries = self.lock();
|
||
if entries.len() >= MAX_VIEWER_WINDOWS {
|
||
return Err(format!(
|
||
"{} file windows are already open — close one before opening another.",
|
||
MAX_VIEWER_WINDOWS
|
||
));
|
||
}
|
||
let n = self.next.fetch_add(1, Ordering::SeqCst) + 1;
|
||
let label = format!("{}{}", VIEWER_LABEL_PREFIX, n);
|
||
entries.insert(label.clone(), target);
|
||
Ok(label)
|
||
}
|
||
|
||
pub fn get(&self, label: &str) -> Option<ViewerTarget> {
|
||
self.lock().get(label).cloned()
|
||
}
|
||
|
||
pub fn set_state(&self, label: &str, state: ViewerTargetState) -> Result<ViewerTarget, String> {
|
||
let mut entries = self.lock();
|
||
let entry = entries
|
||
.get_mut(label)
|
||
.ok_or_else(|| "This file window is no longer registered.".to_string())?;
|
||
entry.state = state;
|
||
Ok(entry.clone())
|
||
}
|
||
|
||
pub fn remove(&self, label: &str) {
|
||
self.lock().remove(label);
|
||
}
|
||
|
||
pub fn find_open(&self, project_id: &str, container_path: &str) -> Option<String> {
|
||
self.lock()
|
||
.iter()
|
||
.find(|(_, t)| {
|
||
t.project_id == project_id
|
||
&& matches!(&t.state, ViewerTargetState::Resolved { container_path: p } if p == container_path)
|
||
})
|
||
.map(|(label, _)| label.clone())
|
||
}
|
||
|
||
pub fn len(&self) -> usize {
|
||
self.lock().len()
|
||
}
|
||
|
||
pub fn is_empty(&self) -> bool {
|
||
self.len() == 0
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests** — `cargo test --offline file_viewer::registry` → pass; `cargo clippy --offline` clean.
|
||
|
||
- [ ] **Step 5: Commit** — `git add app/src-tauri/src/file_viewer && git commit -m "feat(viewer): window registry with cap, dedupe and sequential labels"`
|
||
|
||
---
|
||
|
||
### Task 3: Rust — hash, poll and write scripts (`file_viewer/poll.rs`, `file_viewer/write.rs`)
|
||
|
||
**Files:**
|
||
- Replace: `app/src-tauri/src/file_viewer/poll.rs`, `app/src-tauri/src/file_viewer/write.rs` (Task 0's placeholders)
|
||
|
||
**Interfaces:** Produces
|
||
|
||
```rust
|
||
// poll.rs
|
||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||
pub struct ViewerPoll { pub exists: bool, pub hash: Option<String>, pub size: Option<u64> }
|
||
pub const POLL_SCRIPT: &str;
|
||
pub fn parse_poll_output(code: i64, stdout: &str) -> ViewerPoll;
|
||
pub async fn poll_file(container_id: &str, container_path: &str) -> Result<ViewerPoll, String>;
|
||
|
||
// write.rs
|
||
pub const MAX_WRITE_BYTES: usize = 1024 * 1024;
|
||
pub fn sha256_hex(bytes: &[u8]) -> String;
|
||
pub fn is_sha256_hex(s: &str) -> bool;
|
||
pub const WRITE_SCRIPT: &str;
|
||
pub enum WriteOutcome { Saved(String), Conflict, Gone, Failed(String) }
|
||
pub fn classify_write(code: i64, stdout: &str, stderr: &str) -> WriteOutcome;
|
||
pub async fn write_file(container_id: &str, exec_manager: &ExecSessionManager, target: &str, bytes: &[u8], base_hash: &str) -> Result<String, String>;
|
||
```
|
||
|
||
- [ ] **Step 1: Write the failing tests** for `poll.rs`:
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn a_present_file_yields_hash_and_size() {
|
||
let out = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 /workspace/x\n42\n";
|
||
assert_eq!(
|
||
parse_poll_output(0, out),
|
||
ViewerPoll {
|
||
exists: true,
|
||
hash: Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".into()),
|
||
size: Some(42)
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn exit_four_means_gone() {
|
||
assert_eq!(parse_poll_output(4, ""), ViewerPoll { exists: false, hash: None, size: None });
|
||
}
|
||
|
||
#[test]
|
||
fn garbage_is_not_a_hash() {
|
||
let p = parse_poll_output(0, "not a hash /x\nabc\n");
|
||
assert_eq!(p, ViewerPoll { exists: true, hash: None, size: None });
|
||
}
|
||
|
||
#[test]
|
||
fn the_script_tests_existence_before_hashing() {
|
||
assert!(POLL_SCRIPT.contains("test -f \"$1\" || exit 4"));
|
||
assert!(POLL_SCRIPT.contains("sha256sum -- \"$1\""));
|
||
assert!(POLL_SCRIPT.contains("stat -c %s -- \"$1\""));
|
||
}
|
||
}
|
||
```
|
||
|
||
And for `write.rs`:
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn sha256_matches_coreutils() {
|
||
// `printf 'hello\n' | sha256sum`
|
||
assert_eq!(
|
||
sha256_hex(b"hello\n"),
|
||
"5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
|
||
);
|
||
assert!(is_sha256_hex(&sha256_hex(b"")));
|
||
assert!(!is_sha256_hex("ABC"));
|
||
assert!(!is_sha256_hex(&"g".repeat(64)));
|
||
}
|
||
|
||
#[test]
|
||
fn exit_codes_map_to_outcomes() {
|
||
let h = "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03";
|
||
assert!(matches!(classify_write(0, &format!("{} /x\n", h), ""), WriteOutcome::Saved(s) if s == h));
|
||
assert!(matches!(classify_write(3, "", ""), WriteOutcome::Conflict));
|
||
assert!(matches!(classify_write(4, "", ""), WriteOutcome::Gone));
|
||
assert!(matches!(classify_write(1, "", "cp: Permission denied"), WriteOutcome::Failed(m) if m.contains("Permission denied")));
|
||
// Success without a parseable hash is still a failure: the editor's base would be wrong.
|
||
assert!(matches!(classify_write(0, "junk", ""), WriteOutcome::Failed(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn the_write_script_checks_then_swaps_and_always_cleans_up() {
|
||
for needle in [
|
||
"test -f \"$target\" || exit 4",
|
||
"exit 3",
|
||
"chmod --reference=\"$target\"",
|
||
"mv -f --",
|
||
"cat -- \"$tmp\" > \"$target\"",
|
||
"trap 'rm -f -- \"$tmp\"' EXIT",
|
||
] {
|
||
assert!(WRITE_SCRIPT.contains(needle), "missing: {}", needle);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure** — `cargo test --offline file_viewer::poll file_viewer::write` → compile errors.
|
||
|
||
- [ ] **Step 3: Implement `poll.rs`**
|
||
|
||
```rust
|
||
//! One cheap exec per tick: the file's full hash and size, or "gone".
|
||
//!
|
||
//! This is what the 2 s poll asks, instead of re-downloading up to 1 MiB of archive per
|
||
//! window per tick. The hash is coreutils `sha256sum`, which equals `write::sha256_hex`
|
||
//! of the bytes whenever the read was not truncated — the only case in which the
|
||
//! editor uses a hash as its save base.
|
||
|
||
use serde::Serialize;
|
||
|
||
use crate::docker::exec::exec_oneshot_streams_as;
|
||
|
||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||
pub struct ViewerPoll {
|
||
pub exists: bool,
|
||
pub hash: Option<String>,
|
||
pub size: Option<u64>,
|
||
}
|
||
|
||
pub const POLL_SCRIPT: &str =
|
||
r#"test -f "$1" || exit 4; sha256sum -- "$1" && stat -c %s -- "$1""#;
|
||
|
||
pub fn parse_poll_output(code: i64, stdout: &str) -> ViewerPoll {
|
||
if code == 4 {
|
||
return ViewerPoll { exists: false, hash: None, size: None };
|
||
}
|
||
let mut lines = stdout.lines();
|
||
let hash = lines
|
||
.next()
|
||
.and_then(|l| l.split_whitespace().next())
|
||
.filter(|h| super::write::is_sha256_hex(h))
|
||
.map(str::to_string);
|
||
let size = lines.next().and_then(|l| l.trim().parse::<u64>().ok());
|
||
ViewerPoll { exists: true, hash, size }
|
||
}
|
||
|
||
pub async fn poll_file(container_id: &str, container_path: &str) -> Result<ViewerPoll, String> {
|
||
let cmd = vec![
|
||
"sh".to_string(),
|
||
"-c".to_string(),
|
||
POLL_SCRIPT.to_string(),
|
||
"poll".to_string(),
|
||
container_path.to_string(),
|
||
];
|
||
let (stdout, stderr, code) =
|
||
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
|
||
if code != 0 && code != 4 {
|
||
return Err(format!(
|
||
"Could not check the file: {}",
|
||
crate::commands::file_commands::clip_container_text(&stderr)
|
||
));
|
||
}
|
||
Ok(parse_poll_output(code, &stdout))
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement `write.rs`**
|
||
|
||
```rust
|
||
//! Saving: stage in `/tmp`, then swap in as the container user.
|
||
//!
|
||
//! The Docker archive API writes as root, so it is used for exactly one thing — landing
|
||
//! the payload at `/tmp/triple-c-viewer-<uuid>`, owned by the container user (the
|
||
//! existing `write_file_to_container`). Everything that touches the *target directory*
|
||
//! runs in an exec as `claude`, so a save can do nothing the user's own shell could not.
|
||
//! A non-root process cannot `chown`, so the saved file is owned by the container user,
|
||
//! as it would be after Claude Code edited it; mode is kept with `chmod --reference`.
|
||
|
||
use sha2::{Digest, Sha256};
|
||
|
||
use crate::commands::file_commands::clip_container_text;
|
||
use crate::docker::exec::{exec_oneshot_streams_as, ExecSessionManager};
|
||
|
||
/// Spec §4/§5: only untruncated (≤ 1 MiB) text is editable, so nothing larger is saved.
|
||
pub const MAX_WRITE_BYTES: usize = 1024 * 1024;
|
||
|
||
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||
let digest = Sha256::digest(bytes);
|
||
digest.iter().map(|b| format!("{:02x}", b)).collect()
|
||
}
|
||
|
||
pub fn is_sha256_hex(s: &str) -> bool {
|
||
s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
||
}
|
||
|
||
/// `$1` target, `$2` staged payload in /tmp, `$3` the hash the editor loaded from.
|
||
/// Exit 3 = changed on disk, 4 = gone; stdout on success is `sha256sum` of the target.
|
||
pub const WRITE_SCRIPT: &str = r#"target=$1; tmp=$2; expect=$3
|
||
trap 'rm -f -- "$tmp"' EXIT
|
||
test -f "$target" || exit 4
|
||
actual=$(sha256sum -- "$target" | cut -d' ' -f1) || exit 1
|
||
[ "$actual" = "$expect" ] || exit 3
|
||
dir=$(dirname -- "$target"); name=$(basename -- "$target")
|
||
if [ -w "$dir" ]; then
|
||
staged="$dir/.$name.triple-c-$$"
|
||
cp -- "$tmp" "$staged" || exit 1
|
||
chmod --reference="$target" "$staged" 2>/dev/null
|
||
mv -f -- "$staged" "$target" || { rm -f -- "$staged"; exit 1; }
|
||
else
|
||
cat -- "$tmp" > "$target" || exit 1
|
||
fi
|
||
sha256sum -- "$target""#;
|
||
|
||
pub enum WriteOutcome {
|
||
Saved(String),
|
||
Conflict,
|
||
Gone,
|
||
Failed(String),
|
||
}
|
||
|
||
pub fn classify_write(code: i64, stdout: &str, stderr: &str) -> WriteOutcome {
|
||
match code {
|
||
3 => WriteOutcome::Conflict,
|
||
4 => WriteOutcome::Gone,
|
||
0 => match stdout.split_whitespace().next().filter(|h| is_sha256_hex(h)) {
|
||
Some(h) => WriteOutcome::Saved(h.to_string()),
|
||
None => WriteOutcome::Failed("The container did not report the saved file's hash.".into()),
|
||
},
|
||
_ => WriteOutcome::Failed(clip_container_text(stderr)),
|
||
}
|
||
}
|
||
|
||
pub async fn write_file(
|
||
container_id: &str,
|
||
exec_manager: &ExecSessionManager,
|
||
target: &str,
|
||
bytes: &[u8],
|
||
base_hash: &str,
|
||
) -> Result<String, String> {
|
||
if bytes.len() > MAX_WRITE_BYTES {
|
||
return Err("Files over 1 MiB are read-only in the viewer.".into());
|
||
}
|
||
if !is_sha256_hex(base_hash) {
|
||
return Err("The editor's base hash is malformed; reload the file.".into());
|
||
}
|
||
let tmp_name = format!("triple-c-viewer-{}", uuid::Uuid::new_v4().simple());
|
||
let tmp_path = exec_manager
|
||
.write_file_to_container(container_id, &tmp_name, bytes)
|
||
.await?;
|
||
let cmd = vec![
|
||
"sh".to_string(),
|
||
"-c".to_string(),
|
||
WRITE_SCRIPT.to_string(),
|
||
"save".to_string(),
|
||
target.to_string(),
|
||
tmp_path,
|
||
base_hash.to_string(),
|
||
];
|
||
let (stdout, stderr, code) =
|
||
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
|
||
match classify_write(code, &stdout, &stderr) {
|
||
WriteOutcome::Saved(hash) => Ok(hash),
|
||
WriteOutcome::Conflict => Err("conflict: the file changed on disk since it was loaded.".into()),
|
||
WriteOutcome::Gone => Err("gone: the file no longer exists.".into()),
|
||
WriteOutcome::Failed(msg) => Err(format!("Could not save the file: {}", msg)),
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests + clippy** — `cargo test --offline file_viewer` → pass; `cargo clippy --offline` clean.
|
||
|
||
- [ ] **Step 6 (optional, Docker present here): exercise the script against a stock container** — copy `WRITE_SCRIPT` to the scratchpad as `w.sh`, then:
|
||
```bash
|
||
docker run --rm -v "$PWD":/s -w /tmp ubuntu:24.04 sh -c '
|
||
printf "old\n" > t.txt; printf "new\n" > payload; h=$(sha256sum t.txt | cut -d" " -f1)
|
||
sh /s/w.sh save /tmp/t.txt /tmp/payload "$h"; echo "exit=$?"; cat t.txt; ls payload 2>&1
|
||
sh /s/w.sh save /tmp/t.txt /tmp/payload "$h"; echo "stale-base exit=$?"'
|
||
```
|
||
Expected: first run prints the new hash and `exit=0`, `t.txt` is `new`, `payload` is gone; the second run (payload gone, hash stale) exits 3 or 1 — both non-zero, neither touches the file.
|
||
|
||
- [ ] **Step 7: Commit** — `git add app/src-tauri/src/file_viewer && git commit -m "feat(viewer): poll and save scripts run as the container user"`
|
||
|
||
---
|
||
|
||
### Task 4: Frontend — the path matcher (`lib/filePathLinks.ts`)
|
||
|
||
**Files:**
|
||
- Create: `app/src/lib/filePathLinks.ts`, `app/src/lib/filePathLinks.test.ts`
|
||
|
||
**Interfaces:** Produces
|
||
|
||
```ts
|
||
export interface FilePathMatch {
|
||
/** Indices into the input string; `end` exclusive. Covers the path + any :line suffix, not the wrapping. */
|
||
start: number; end: number;
|
||
path: string;
|
||
line?: number; col?: number; endLine?: number;
|
||
}
|
||
export function findFilePathLinks(text: string): FilePathMatch[];
|
||
```
|
||
|
||
- [ ] **Step 1: Write the failing tests** (`filePathLinks.test.ts`):
|
||
|
||
```ts
|
||
import { describe, expect, it } from "vitest";
|
||
import { findFilePathLinks } from "./filePathLinks";
|
||
|
||
const one = (text: string) => {
|
||
const m = findFilePathLinks(text);
|
||
expect(m, text).toHaveLength(1);
|
||
return m[0];
|
||
};
|
||
|
||
describe("findFilePathLinks — what is a path", () => {
|
||
it.each([
|
||
["src/foo.ts", "src/foo.ts"],
|
||
["/workspace/x/README.md", "/workspace/x/README.md"],
|
||
["./scripts/build.sh", "./scripts/build.sh"],
|
||
["../other/Cargo.toml", "../other/Cargo.toml"],
|
||
["Makefile", "Makefile"],
|
||
["Dockerfile", "Dockerfile"],
|
||
["CLAUDE.md", "CLAUDE.md"],
|
||
[".gitignore", ".gitignore"],
|
||
["app/src-tauri/src/lib.rs", "app/src-tauri/src/lib.rs"],
|
||
["my-dir/some_file.test.tsx", "my-dir/some_file.test.tsx"],
|
||
])("matches %s", (text, path) => {
|
||
expect(one(text).path).toBe(path);
|
||
});
|
||
|
||
it.each([
|
||
"1.2.3",
|
||
"v2.11.0",
|
||
"example.com",
|
||
"claude.ai",
|
||
"e.g.",
|
||
"https://example.com/a/b.ts",
|
||
"http://localhost:1420/viewer.html",
|
||
"foo",
|
||
"a.b",
|
||
"10.0.0.1",
|
||
])("does not match %s", (text) => {
|
||
expect(findFilePathLinks(text)).toEqual([]);
|
||
});
|
||
|
||
it("matches a slash-less token only with a known source/doc extension", () => {
|
||
expect(one("index.ts").path).toBe("index.ts");
|
||
expect(one("notes.md").path).toBe("notes.md");
|
||
expect(findFilePathLinks("archive.xyz")).toEqual([]);
|
||
// With a slash, any extension will do.
|
||
expect(one("dist/archive.xyz").path).toBe("dist/archive.xyz");
|
||
});
|
||
});
|
||
|
||
describe("findFilePathLinks — line and column suffixes", () => {
|
||
it("parses :line", () => {
|
||
expect(one("src/foo.ts:42")).toMatchObject({ path: "src/foo.ts", line: 42 });
|
||
});
|
||
it("parses :line:col", () => {
|
||
expect(one("src/foo.ts:42:7")).toMatchObject({ path: "src/foo.ts", line: 42, col: 7 });
|
||
});
|
||
it("parses :start-end", () => {
|
||
expect(one("app/src/lib/urlRelay.ts:139-150")).toMatchObject({ path: "app/src/lib/urlRelay.ts", line: 139, endLine: 150 });
|
||
});
|
||
it("parses #L42 and #L40-L50", () => {
|
||
expect(one("README.md#L42")).toMatchObject({ path: "README.md", line: 42 });
|
||
expect(one("README.md#L40-L50")).toMatchObject({ path: "README.md", line: 40, endLine: 50 });
|
||
});
|
||
it("does not read a trailing colon as a line", () => {
|
||
expect(one("Edited src/foo.ts:")).toMatchObject({ path: "src/foo.ts", line: undefined });
|
||
});
|
||
});
|
||
|
||
describe("findFilePathLinks — markdown wrapping and offsets", () => {
|
||
it.each([
|
||
["`src/foo.ts`", 1, 11],
|
||
["(src/foo.ts)", 1, 11],
|
||
["[src/foo.ts]", 1, 11],
|
||
['"src/foo.ts"', 1, 11],
|
||
["'src/foo.ts'", 1, 11],
|
||
["see src/foo.ts.", 4, 14],
|
||
["see src/foo.ts, then", 4, 14],
|
||
["see src/foo.ts;", 4, 14],
|
||
])("strips wrapping in %s", (text, start, end) => {
|
||
expect(one(text)).toMatchObject({ path: "src/foo.ts", start, end });
|
||
});
|
||
|
||
it("keeps the :line suffix inside the span", () => {
|
||
// "at `" is 4 characters; the span covers `src/foo.ts:42` (13 chars).
|
||
expect(one("at `src/foo.ts:42`")).toMatchObject({ path: "src/foo.ts", line: 42, start: 4, end: 17 });
|
||
});
|
||
|
||
it("finds several paths in one line, in order", () => {
|
||
const m = findFilePathLinks("Read src/a.ts and src/b.rs:3, wrote docs/c.md");
|
||
expect(m.map((x) => x.path)).toEqual(["src/a.ts", "src/b.rs", "docs/c.md"]);
|
||
expect(m[1].line).toBe(3);
|
||
});
|
||
|
||
it("skips anything inside a URL", () => {
|
||
expect(findFilePathLinks("see https://github.com/o/r/blob/main/src/foo.ts:12 now")).toEqual([]);
|
||
expect(one("see https://x.io/a and src/foo.ts").path).toBe("src/foo.ts");
|
||
});
|
||
|
||
it("ignores a Claude tool header like ⏺ Read(src/foo.ts) except for the path", () => {
|
||
expect(one("⏺ Read(src/foo.ts)").path).toBe("src/foo.ts");
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure** — `cd app && npx vitest run src/lib/filePathLinks.test.ts` → fails (module not found).
|
||
|
||
- [ ] **Step 3: Implement** `app/src/lib/filePathLinks.ts`:
|
||
|
||
```ts
|
||
/**
|
||
* Finds file paths in a line of terminal text.
|
||
*
|
||
* Pure: the xterm glue (`components/terminal/filePathLinkProvider.ts`) turns
|
||
* buffer rows into a string and string offsets back into cells; this decides
|
||
* what a path is. Deliberately conservative — a false link is an annoying
|
||
* underline, a missed one is a copy-paste — so a token needs either a `/` or
|
||
* a known extension, and never sits inside a URL.
|
||
*/
|
||
|
||
export interface FilePathMatch {
|
||
/** Indices into the input; `end` exclusive. Covers path + suffix, not wrapping. */
|
||
start: number;
|
||
end: number;
|
||
path: string;
|
||
line?: number;
|
||
col?: number;
|
||
endLine?: number;
|
||
}
|
||
|
||
/** Extensions that make a slash-less token (`index.ts`, `notes.md`) a path. */
|
||
const KNOWN_EXTENSIONS = new Set([
|
||
"md", "markdown", "txt", "rst", "json", "jsonc", "yaml", "yml", "toml", "ini", "cfg", "conf",
|
||
"env", "lock", "js", "jsx", "mjs", "cjs", "ts", "tsx", "rs", "py", "rb", "go", "java", "kt",
|
||
"c", "h", "cc", "cpp", "hpp", "cs", "php", "swift", "scala", "lua", "sh", "bash", "zsh",
|
||
"fish", "ps1", "html", "htm", "xml", "svelte", "vue", "css", "scss", "sass", "less", "sql",
|
||
"graphql", "proto", "diff", "patch", "csv", "tsv", "log", "svg", "png", "jpg", "jpeg", "gif",
|
||
"webp",
|
||
]);
|
||
|
||
/** Extensionless names that are files by convention. */
|
||
const KNOWN_BASENAMES = new Set([
|
||
"Makefile", "Dockerfile", "Rakefile", "Gemfile", "Procfile", "Vagrantfile", "LICENSE",
|
||
"README", "CHANGELOG", "PKGBUILD",
|
||
]);
|
||
|
||
/**
|
||
* A candidate token: path characters, optionally starting with `/`, `./`, `../`
|
||
* or `.` (dotfile). Excludes the wrapping characters the surrounding markdown
|
||
* leaves (`(`, `)`, `[`, `]`, backtick, quotes) and whitespace.
|
||
*/
|
||
const TOKEN = /(?:\.{1,2}\/|\/)?[A-Za-z0-9_.\-~+@]+(?:\/[A-Za-z0-9_.\-~+@]+)*\/?/g;
|
||
const URL_SCHEME = /[a-z][a-z0-9+.-]*:\/\//gi;
|
||
const LINE_SUFFIX = /^(?::(\d+)(?::(\d+))?(?:-(\d+))?|#L(\d+)(?:-L?(\d+))?)/;
|
||
const VERSION_LIKE = /^v?\d+(\.\d+)+$/;
|
||
const TRAILING_PUNCT = /[.,;:]+$/;
|
||
|
||
function isPathLike(token: string): boolean {
|
||
if (VERSION_LIKE.test(token)) return false;
|
||
const base = token.slice(token.lastIndexOf("/") + 1);
|
||
if (base === "" || base === "." || base === "..") return false;
|
||
if (token.includes("/")) {
|
||
// With a slash the token is a path unless it is only dots and digits.
|
||
return /[A-Za-z_]/.test(token);
|
||
}
|
||
if (KNOWN_BASENAMES.has(base)) return true;
|
||
const dot = base.lastIndexOf(".");
|
||
if (dot === 0) return KNOWN_EXTENSIONS.has(base.slice(1).toLowerCase()) || base === ".gitignore" || base === ".env";
|
||
if (dot < 0) return false;
|
||
return KNOWN_EXTENSIONS.has(base.slice(dot + 1).toLowerCase());
|
||
}
|
||
|
||
function urlSpans(text: string): Array<[number, number]> {
|
||
const spans: Array<[number, number]> = [];
|
||
for (const m of text.matchAll(URL_SCHEME)) {
|
||
const start = m.index ?? 0;
|
||
// A URL runs to the next whitespace or closing bracket/quote.
|
||
const rest = text.slice(start);
|
||
const len = rest.search(/[\s)\]'"`>]/);
|
||
spans.push([start, len < 0 ? text.length : start + len]);
|
||
}
|
||
return spans;
|
||
}
|
||
|
||
export function findFilePathLinks(text: string): FilePathMatch[] {
|
||
const urls = urlSpans(text);
|
||
const insideUrl = (i: number) => urls.some(([s, e]) => i >= s && i < e);
|
||
const out: FilePathMatch[] = [];
|
||
|
||
for (const m of text.matchAll(TOKEN)) {
|
||
let start = m.index ?? 0;
|
||
let token = m[0];
|
||
if (insideUrl(start)) continue;
|
||
|
||
// Trailing sentence punctuation is not part of the name.
|
||
const trimmed = token.replace(TRAILING_PUNCT, "");
|
||
if (trimmed !== token) token = trimmed;
|
||
if (token.endsWith("/")) token = token.slice(0, -1);
|
||
if (!token || !isPathLike(token)) continue;
|
||
|
||
let end = start + token.length;
|
||
const match: FilePathMatch = { start, end, path: token };
|
||
|
||
// The suffix sits right after the *trimmed* token: `TOKEN` may have
|
||
// consumed a trailing `.` that `TRAILING_PUNCT` then removed, so search
|
||
// from `start + token.length`, not from the end of the raw match.
|
||
const after = text.slice(start + token.length);
|
||
const s = LINE_SUFFIX.exec(after);
|
||
if (s) {
|
||
if (s[1] !== undefined) {
|
||
match.line = Number(s[1]);
|
||
if (s[2] !== undefined) match.col = Number(s[2]);
|
||
if (s[3] !== undefined) match.endLine = Number(s[3]);
|
||
} else if (s[4] !== undefined) {
|
||
match.line = Number(s[4]);
|
||
if (s[5] !== undefined) match.endLine = Number(s[5]);
|
||
}
|
||
end += s[0].length;
|
||
match.end = end;
|
||
}
|
||
out.push(match);
|
||
}
|
||
return out;
|
||
}
|
||
```
|
||
|
||
**Why this works:** the `:42` in `src/foo.ts:42` is not consumed by `TOKEN` because `:` is not a token character, so the suffix regex sees it; `#L42` likewise (`#` is not a token character). For `Edited src/foo.ts:` the suffix regex needs a digit after `:` and finds none, so `line` stays undefined and the span ends before the colon. `let start` is `let` only because `m.index` is typed optional; it is never reassigned — make it `const` if the linter asks.
|
||
|
||
- [ ] **Step 4: Run tests** — `npx vitest run src/lib/filePathLinks.test.ts` → all pass. Adjust `TOKEN`/`isPathLike` until the table passes; do not weaken the negative cases.
|
||
|
||
- [ ] **Step 5: Commit** — `git add app/src/lib/filePathLinks.ts app/src/lib/filePathLinks.test.ts && git commit -m "feat(viewer): pure file-path matcher for terminal text"`
|
||
|
||
---
|
||
|
||
### Task 5: Frontend — wrapped-row joining (`lib/xtermLineJoin.ts`)
|
||
|
||
**Files:**
|
||
- Create: `app/src/lib/xtermLineJoin.ts`, `app/src/lib/xtermLineJoin.test.ts`
|
||
|
||
**Interfaces:** Produces
|
||
|
||
```ts
|
||
export interface JoinedLine {
|
||
text: string;
|
||
/** 0-based index of the first buffer row that contributed. */
|
||
firstRow: number;
|
||
/** For each contributed row (in order), the string offset at which it starts. */
|
||
rowStarts: number[];
|
||
}
|
||
/** Minimal slice of xterm's IBuffer this needs. */
|
||
export interface RowSource {
|
||
getLine(y: number): { isWrapped: boolean; translateToString(trimRight?: boolean): string } | undefined;
|
||
}
|
||
export function joinWrappedRows(buffer: RowSource, row: number): JoinedLine;
|
||
/** String offset → 1-based {x, y} cell (y is the buffer row + 1). */
|
||
export function offsetToCell(joined: JoinedLine, offset: number): { x: number; y: number };
|
||
export const MAX_JOINED_LENGTH = 2048;
|
||
```
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```ts
|
||
import { describe, expect, it } from "vitest";
|
||
import { joinWrappedRows, offsetToCell, type RowSource } from "./xtermLineJoin";
|
||
|
||
/** rows[i] = [text, isWrapped] */
|
||
const buffer = (rows: Array<[string, boolean]>): RowSource => ({
|
||
getLine: (y) =>
|
||
rows[y] ? { isWrapped: rows[y][1], translateToString: (trim?: boolean) => (trim ? rows[y][0].trimEnd() : rows[y][0]) } : undefined,
|
||
});
|
||
|
||
describe("joinWrappedRows", () => {
|
||
it("returns a single unwrapped row as-is", () => {
|
||
const j = joinWrappedRows(buffer([["hello src/a.ts", false]]), 0);
|
||
expect(j).toEqual({ text: "hello src/a.ts", firstRow: 0, rowStarts: [0] });
|
||
});
|
||
|
||
it("walks up to the row that started the wrap and down through continuations", () => {
|
||
const b = buffer([
|
||
["unrelated", false],
|
||
["/workspace/very/long/pa", false],
|
||
["th/to/file.ts:12 and mo", true],
|
||
["re text", true],
|
||
["next line", false],
|
||
]);
|
||
const fromMiddle = joinWrappedRows(b, 2);
|
||
expect(fromMiddle.text).toBe("/workspace/very/long/path/to/file.ts:12 and more text");
|
||
expect(fromMiddle.firstRow).toBe(1);
|
||
expect(fromMiddle.rowStarts).toEqual([0, 23, 46]);
|
||
expect(joinWrappedRows(b, 1)).toEqual(fromMiddle);
|
||
expect(joinWrappedRows(b, 3)).toEqual(fromMiddle);
|
||
});
|
||
|
||
it("stops at the length budget", () => {
|
||
const rows: Array<[string, boolean]> = [["a".repeat(1000), false]];
|
||
for (let i = 0; i < 5; i++) rows.push(["b".repeat(1000), true]);
|
||
const j = joinWrappedRows(buffer(rows), 0);
|
||
expect(j.text.length).toBeLessThanOrEqual(3000);
|
||
expect(j.rowStarts.length).toBeLessThanOrEqual(3);
|
||
});
|
||
});
|
||
|
||
describe("offsetToCell", () => {
|
||
it("maps offsets to 1-based cells on the right row", () => {
|
||
const j = { text: "abcdefgh", firstRow: 4, rowStarts: [0, 3, 6] };
|
||
expect(offsetToCell(j, 0)).toEqual({ x: 1, y: 5 });
|
||
expect(offsetToCell(j, 2)).toEqual({ x: 3, y: 5 });
|
||
expect(offsetToCell(j, 3)).toEqual({ x: 1, y: 6 });
|
||
expect(offsetToCell(j, 7)).toEqual({ x: 2, y: 7 });
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure** — `npx vitest run src/lib/xtermLineJoin.test.ts` → module not found.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
```ts
|
||
/**
|
||
* Joins an xterm buffer row with its wrapped continuations.
|
||
*
|
||
* `WebLinksAddon` does the same in its private `LinkComputer`, which the
|
||
* built package does not export — so the walk is repeated here, with the same
|
||
* 2048-character budget. Rows are read with `translateToString(true)`, which
|
||
* trims the right edge; a wrap never ends in trailing spaces xterm would keep,
|
||
* so the join is exact for the text a path can occur in.
|
||
*
|
||
* Wide characters (CJK, emoji) occupy two cells but one string index, so a
|
||
* column computed from a string offset drifts right of the glyph on such rows.
|
||
* The addon corrects this with `getCell`; v1 accepts the drift (underline
|
||
* lands a cell early; the click still resolves the same link).
|
||
*/
|
||
|
||
export const MAX_JOINED_LENGTH = 2048;
|
||
|
||
export interface RowSource {
|
||
getLine(y: number): { isWrapped: boolean; translateToString(trimRight?: boolean): string } | undefined;
|
||
}
|
||
|
||
export interface JoinedLine {
|
||
text: string;
|
||
firstRow: number;
|
||
rowStarts: number[];
|
||
}
|
||
|
||
export function joinWrappedRows(buffer: RowSource, row: number): JoinedLine {
|
||
let top = row;
|
||
while (top > 0 && buffer.getLine(top)?.isWrapped) top--;
|
||
|
||
const parts: string[] = [];
|
||
let length = 0;
|
||
let y = top;
|
||
for (;;) {
|
||
const line = buffer.getLine(y);
|
||
if (!line) break;
|
||
if (y !== top && !line.isWrapped) break;
|
||
const text = line.translateToString(true);
|
||
if (length + text.length > MAX_JOINED_LENGTH && parts.length > 0) break;
|
||
parts.push(text);
|
||
length += text.length;
|
||
y++;
|
||
}
|
||
|
||
const rowStarts: number[] = [];
|
||
let offset = 0;
|
||
for (const p of parts) {
|
||
rowStarts.push(offset);
|
||
offset += p.length;
|
||
}
|
||
return { text: parts.join(""), firstRow: top, rowStarts };
|
||
}
|
||
|
||
export function offsetToCell(joined: JoinedLine, offset: number): { x: number; y: number } {
|
||
let rowIdx = 0;
|
||
for (let i = 0; i < joined.rowStarts.length; i++) {
|
||
if (joined.rowStarts[i] <= offset) rowIdx = i;
|
||
}
|
||
return { x: offset - joined.rowStarts[rowIdx] + 1, y: joined.firstRow + rowIdx + 1 };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests** → pass. `npx tsc --noEmit` → exit 0.
|
||
|
||
- [ ] **Step 5: Commit** — `git add app/src/lib/xtermLineJoin.ts app/src/lib/xtermLineJoin.test.ts && git commit -m "feat(viewer): join wrapped xterm rows for link matching"`
|
||
|
||
---
|
||
|
||
### Task 6: Second Vite entry, capability file, CodeMirror dependencies
|
||
|
||
**Files:**
|
||
- Create: `app/viewer.html`, `app/src/viewer/main.tsx`, `app/src/viewer/ViewerApp.tsx` (placeholder that Task 11 replaces), `app/src-tauri/capabilities/file-viewer.json`
|
||
- Modify: `app/vite.config.ts`, `app/package.json` (+ lockfile via `npm install`), `app/src-tauri/src/file_viewer/mod.rs` (test)
|
||
|
||
**Interfaces:** Produces the entry and the window's capability. Task 8 opens `WebviewUrl::App("viewer.html")`; Task 10/11 fill `src/viewer/`.
|
||
|
||
- [ ] **Step 1: Install CodeMirror** (from `app/`):
|
||
|
||
```bash
|
||
npm install @codemirror/state @codemirror/view @codemirror/commands @codemirror/search @codemirror/language @codemirror/lang-markdown @codemirror/lang-javascript @codemirror/lang-rust @codemirror/lang-python @codemirror/lang-json @codemirror/lang-yaml @codemirror/lang-css @codemirror/lang-html @codemirror/legacy-modes @lezer/highlight
|
||
```
|
||
|
||
Expected versions (as of review): state 6.7.x, view 6.43.x, commands 6.11.x, search 6.7.x, language 6.12.x, legacy-modes 6.5.x, @lezer/highlight 1.2.x. Verify with `grep -rl "new Function\|eval(" node_modules/@codemirror/*/dist/index.js node_modules/@lezer/*/dist/index.js` → no output (the only known hit is `legacy-modes/mode/pug.js`, which is never imported).
|
||
|
||
- [ ] **Step 2: Create `app/viewer.html`** (no inline `<style>`, no inline script — see Global Constraints):
|
||
|
||
```html
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Triple-C — file</title>
|
||
</head>
|
||
<body>
|
||
<div id="root"></div>
|
||
<script type="module" src="/src/viewer/main.tsx"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
- [ ] **Step 3: Create `app/src/viewer/main.tsx`** and a placeholder `ViewerApp.tsx`:
|
||
|
||
```tsx
|
||
// main.tsx
|
||
import React from "react";
|
||
import ReactDOM from "react-dom/client";
|
||
import ViewerApp from "./ViewerApp";
|
||
import "../index.css";
|
||
|
||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||
<React.StrictMode>
|
||
<ViewerApp />
|
||
</React.StrictMode>,
|
||
);
|
||
```
|
||
|
||
```tsx
|
||
// ViewerApp.tsx (placeholder; Task 11 replaces the body)
|
||
export default function ViewerApp() {
|
||
return <div className="p-4 text-sm text-[var(--text-secondary)]">Loading…</div>;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Register the entry in `app/vite.config.ts`** — add `import { fileURLToPath } from "node:url";` at the top and a `build` block inside `defineConfig({ … })`:
|
||
|
||
```ts
|
||
build: {
|
||
rollupOptions: {
|
||
input: {
|
||
main: fileURLToPath(new URL("index.html", import.meta.url)),
|
||
viewer: fileURLToPath(new URL("viewer.html", import.meta.url)),
|
||
},
|
||
},
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 5: Create `app/src-tauri/capabilities/file-viewer.json`**
|
||
|
||
```json
|
||
{
|
||
"identifier": "file-viewer",
|
||
"description": "The terminal file viewer windows (`file-viewer-<n>`, opened by `open_file_viewer` on the app's own `viewer.html`). Same rules as `default.json`: app commands need no entry here and are gated by label inside `commands/file_viewer_commands.rs`; this file is the plugin-command surface a compromised viewer webview could reach, and it is the smallest one that lets the window work. `core:event:allow-listen`/`allow-unlisten` are for `file-viewer-goto` (Rust → this window; the viewer subscribes through `getCurrentWindow().listen`, because a bare `listen()` in *any* window receives an `emit_to`). `core:window:allow-destroy` is not optional: `getCurrentWindow().onCloseRequested` in @tauri-apps/api 2.11 makes Rust `prevent_close()` whenever a JS listener exists and then calls `destroy()` itself, so without this grant the window's X button does nothing once the unsaved-changes guard is installed. `allow-close` is deliberately absent — nothing calls it, and `destroy` is the only exit. No `set-title`/`set-focus`/`unminimize`: those are done from Rust when a second click targets an already-open file. `core:webview:allow-internal-toggle-devtools` is the same dev-only convenience `default.json` carries.",
|
||
"windows": ["file-viewer-*"],
|
||
"permissions": [
|
||
"core:event:allow-listen",
|
||
"core:event:allow-unlisten",
|
||
"core:window:allow-destroy",
|
||
"core:webview:allow-internal-toggle-devtools"
|
||
]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Add the fallback-trap test** to `app/src-tauri/src/file_viewer/mod.rs`'s test module:
|
||
|
||
```rust
|
||
/// Both Vite's dev server and Tauri's asset lookup fall back to `index.html`
|
||
/// when `viewer.html` is missing, so a broken entry opens the *main app* in
|
||
/// the viewer window with no error anywhere. Pin the two files the entry needs.
|
||
#[test]
|
||
fn the_viewer_entry_exists_and_is_a_vite_input() {
|
||
let app_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||
let html = std::fs::read_to_string(app_dir.join("viewer.html")).expect("app/viewer.html");
|
||
assert!(html.contains("/src/viewer/main.tsx"));
|
||
assert!(!html.contains("<style"), "an inline <style> makes Tauri add a style nonce, which disables 'unsafe-inline' and breaks CodeMirror");
|
||
let vite = std::fs::read_to_string(app_dir.join("vite.config.ts")).expect("vite.config.ts");
|
||
assert!(vite.contains("viewer.html"), "vite.config.ts must list viewer.html in build.rollupOptions.input");
|
||
let cap = std::fs::read_to_string(app_dir.join("src-tauri/capabilities/file-viewer.json")).expect("capability");
|
||
assert!(cap.contains("\"file-viewer-*\"") && cap.contains("core:window:allow-destroy"));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Verify** — `cd app && npm run build` → `dist/viewer.html` exists (`ls dist/viewer.html`); `npx tsc --noEmit` → 0; `cd src-tauri && cargo test --offline file_viewer::tests` → pass (the capability file is validated by `tauri_build` at `cargo check` time — a typo in a permission identifier fails the build).
|
||
|
||
- [ ] **Step 8: Commit** — `git add app/viewer.html app/src/viewer app/vite.config.ts app/package.json app/package-lock.json app/src-tauri/capabilities/file-viewer.json app/src-tauri/src/file_viewer/mod.rs && git commit -m "feat(viewer): second Vite entry, viewer capability, CodeMirror deps"`
|
||
|
||
---
|
||
|
||
### Task 7: Frontend — viewer state machine, editability, languages
|
||
|
||
**Files:**
|
||
- Create: `app/src/viewer/viewerState.ts` + `.test.ts`, `app/src/viewer/editability.ts` + `.test.ts`, `app/src/viewer/languages.ts` + `.test.ts`
|
||
|
||
**Interfaces:** Produces
|
||
|
||
```ts
|
||
// viewerState.ts
|
||
export type DocStatus = "clean" | "dirty";
|
||
export type DiskStatus = "same" | "changed" | "gone";
|
||
export interface ViewerDocState {
|
||
doc: DocStatus;
|
||
disk: DiskStatus;
|
||
/** Hash the buffer was loaded from / last saved as. */
|
||
baseHash: string | null;
|
||
/** Last known full-file hash on disk (null until known). */
|
||
diskHash: string | null;
|
||
containerDown: boolean;
|
||
/** Set for one render after a clean reload; UI shows "Reloaded". */
|
||
justReloaded: boolean;
|
||
/** True when the user chose "Overwrite on save" after a disk change. */
|
||
overwrite: boolean;
|
||
}
|
||
export type ViewerAction =
|
||
| { type: "loaded"; hash: string; truncated: boolean }
|
||
| { type: "edited" }
|
||
| { type: "polled"; poll: ViewerPoll }
|
||
| { type: "poll_failed" }
|
||
| { type: "reloaded"; hash: string }
|
||
| { type: "overwrite_on_save" }
|
||
| { type: "saved"; hash: string }
|
||
| { type: "save_conflict" }
|
||
| { type: "save_gone" };
|
||
export const initialViewerState: ViewerDocState;
|
||
export function reduceViewer(state: ViewerDocState, action: ViewerAction): ViewerDocState;
|
||
/** What EditorPane does after a poll: nothing, reload silently, or show the banner. */
|
||
export function pollEffect(before: ViewerDocState, after: ViewerDocState): "none" | "reload" | "banner";
|
||
export function canSave(state: ViewerDocState, editable: boolean): boolean;
|
||
|
||
// editability.ts
|
||
export type ViewerKind = "text" | "image" | "binary";
|
||
export interface Editability { kind: ViewerKind; editable: boolean; reason: string | null }
|
||
export function classifyViewerFile(path: string, file: ViewerFile, bytes: Uint8Array): Editability;
|
||
|
||
// languages.ts
|
||
export function languageFor(path: string): Promise<Extension | null>;
|
||
export function wrapsLines(path: string): boolean;
|
||
```
|
||
|
||
- [ ] **Step 1: Write the failing reducer tests** (`viewerState.test.ts`):
|
||
|
||
```ts
|
||
import { describe, expect, it } from "vitest";
|
||
import { canSave, initialViewerState, pollEffect, reduceViewer, type ViewerDocState } from "./viewerState";
|
||
|
||
const H1 = "1".repeat(64);
|
||
const H2 = "2".repeat(64);
|
||
const loaded = (truncated = false): ViewerDocState =>
|
||
reduceViewer(initialViewerState, { type: "loaded", hash: H1, truncated });
|
||
const poll = (s: ViewerDocState, hash: string | null, exists = true) =>
|
||
reduceViewer(s, { type: "polled", poll: { exists, hash, size: exists ? 1 : null } });
|
||
|
||
describe("reduceViewer", () => {
|
||
it("seeds both hashes from an untruncated load", () => {
|
||
expect(loaded()).toMatchObject({ doc: "clean", disk: "same", baseHash: H1, diskHash: H1 });
|
||
});
|
||
it("leaves diskHash unknown after a truncated load, so the first poll seeds it silently", () => {
|
||
const s = loaded(true);
|
||
expect(s.diskHash).toBeNull();
|
||
const after = poll(s, H2);
|
||
expect(after).toMatchObject({ disk: "same", diskHash: H2 });
|
||
expect(pollEffect(s, after)).toBe("none");
|
||
});
|
||
it("an unchanged poll is a no-op", () => {
|
||
const s = loaded();
|
||
expect(pollEffect(s, poll(s, H1))).toBe("none");
|
||
});
|
||
it("a changed poll on a clean doc reloads", () => {
|
||
const s = loaded();
|
||
const after = poll(s, H2);
|
||
expect(after).toMatchObject({ disk: "changed", diskHash: H2, doc: "clean" });
|
||
expect(pollEffect(s, after)).toBe("reload");
|
||
const reloaded = reduceViewer(after, { type: "reloaded", hash: H2 });
|
||
expect(reloaded).toMatchObject({ disk: "same", baseHash: H2, diskHash: H2, justReloaded: true });
|
||
});
|
||
it("a changed poll on a dirty doc shows the banner and never reloads", () => {
|
||
const s = reduceViewer(loaded(), { type: "edited" });
|
||
const after = poll(s, H2);
|
||
expect(after).toMatchObject({ doc: "dirty", disk: "changed" });
|
||
expect(pollEffect(s, after)).toBe("banner");
|
||
expect(pollEffect(after, poll(after, H2))).toBe("none");
|
||
});
|
||
it("overwrite-on-save adopts the disk hash as the base", () => {
|
||
const s = poll(reduceViewer(loaded(), { type: "edited" }), H2);
|
||
const o = reduceViewer(s, { type: "overwrite_on_save" });
|
||
expect(o).toMatchObject({ baseHash: H2, disk: "same", overwrite: true, doc: "dirty" });
|
||
expect(canSave(o, true)).toBe(true);
|
||
});
|
||
it("a save clears dirty and aligns hashes; a conflict marks disk changed", () => {
|
||
const s = reduceViewer(loaded(), { type: "edited" });
|
||
expect(reduceViewer(s, { type: "saved", hash: H2 })).toMatchObject({ doc: "clean", disk: "same", baseHash: H2, diskHash: H2, overwrite: false });
|
||
expect(reduceViewer(s, { type: "save_conflict" })).toMatchObject({ doc: "dirty", disk: "changed" });
|
||
expect(reduceViewer(s, { type: "save_gone" })).toMatchObject({ disk: "gone" });
|
||
});
|
||
it("a gone file disables saving but keeps the buffer state", () => {
|
||
const s = reduceViewer(loaded(), { type: "edited" });
|
||
const gone = poll(s, null, false);
|
||
expect(gone).toMatchObject({ disk: "gone", doc: "dirty" });
|
||
expect(canSave(gone, true)).toBe(false);
|
||
expect(pollEffect(s, gone)).toBe("banner");
|
||
});
|
||
it("a failed poll flags the container down and a good one clears it", () => {
|
||
const down = reduceViewer(loaded(), { type: "poll_failed" });
|
||
expect(down.containerDown).toBe(true);
|
||
expect(canSave(down, true)).toBe(false);
|
||
expect(poll(down, H1).containerDown).toBe(false);
|
||
});
|
||
it("canSave needs dirty + editable + disk in sync", () => {
|
||
expect(canSave(loaded(), true)).toBe(false);
|
||
const dirty = reduceViewer(loaded(), { type: "edited" });
|
||
expect(canSave(dirty, true)).toBe(true);
|
||
expect(canSave(dirty, false)).toBe(false);
|
||
expect(canSave(poll(dirty, H2), true)).toBe(false);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing editability tests** (`editability.test.ts`):
|
||
|
||
```ts
|
||
import { describe, expect, it } from "vitest";
|
||
import { classifyViewerFile } from "./editability";
|
||
import type { ViewerFile } from "../lib/types";
|
||
|
||
const file = (over: Partial<ViewerFile> = {}): ViewerFile => ({
|
||
contents_base64: "", truncated: false, size: 10, hash: "0".repeat(64), editable: true, readonly_reason: null, ...over,
|
||
});
|
||
const text = new TextEncoder().encode("hello\n");
|
||
|
||
describe("classifyViewerFile", () => {
|
||
it("text in a write root is editable", () => {
|
||
expect(classifyViewerFile("/workspace/a/x.md", file(), text)).toEqual({ kind: "text", editable: true, reason: null });
|
||
});
|
||
it("a truncated file is read-only and says why", () => {
|
||
const r = classifyViewerFile("/workspace/a/big.log", file({ truncated: true }), text);
|
||
expect(r.editable).toBe(false);
|
||
expect(r.reason).toMatch(/1 MiB/);
|
||
});
|
||
it("Rust's refusal wins and is quoted", () => {
|
||
const r = classifyViewerFile("/etc/hosts", file({ editable: false, readonly_reason: "Only /workspace, /home/claude and /tmp can be written." }), text);
|
||
expect(r).toEqual({ kind: "text", editable: false, reason: "Only /workspace, /home/claude and /tmp can be written." });
|
||
});
|
||
it("images and binaries are never editable", () => {
|
||
expect(classifyViewerFile("/workspace/a/x.png", file(), new Uint8Array([137, 80]))).toMatchObject({ kind: "image", editable: false });
|
||
expect(classifyViewerFile("/workspace/a/x.bin", file(), new Uint8Array([0, 1, 2]))).toMatchObject({ kind: "binary", editable: false });
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Write the failing language tests** (`languages.test.ts`):
|
||
|
||
```ts
|
||
import { describe, expect, it } from "vitest";
|
||
import { languageFor, wrapsLines } from "./languages";
|
||
|
||
describe("languageFor", () => {
|
||
it.each(["a.ts", "a.tsx", "a.js", "a.jsx", "a.mjs", "a.rs", "a.py", "a.json", "a.yaml", "a.yml", "a.toml", "a.sh", "a.bash", "a.css", "a.html", "a.md", "Dockerfile", "Cargo.lock", "README"])(
|
||
"resolves %s without throwing", async (name) => { await expect(languageFor(`/workspace/${name}`)).resolves.toBeDefined(); });
|
||
it("returns null for an unknown extension", async () => {
|
||
await expect(languageFor("/workspace/x.xyz")).resolves.toBeNull();
|
||
});
|
||
it("returns an extension for markdown", async () => {
|
||
await expect(languageFor("/workspace/x.md")).resolves.not.toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("wrapsLines", () => {
|
||
it("wraps prose, not code", () => {
|
||
expect(wrapsLines("x.md")).toBe(true);
|
||
expect(wrapsLines("x.txt")).toBe(true);
|
||
expect(wrapsLines("x.rs")).toBe(false);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify failure** — `npx vitest run src/viewer` → three modules missing.
|
||
|
||
- [ ] **Step 5: Implement `viewerState.ts`**
|
||
|
||
```ts
|
||
/**
|
||
* The viewer's reload/dirty/conflict rules as a pure reducer (spec §5).
|
||
*
|
||
* Two hashes, deliberately: `baseHash` is what the buffer was loaded from or
|
||
* last saved as — the save's precondition. `diskHash` is the last full-file
|
||
* hash the poll reported. They differ only for a truncated (read-only) load,
|
||
* where the read's hash covers a prefix and can never equal `sha256sum`; the
|
||
* poll then seeds `diskHash` without triggering a reload.
|
||
*/
|
||
import type { ViewerPoll } from "../lib/types";
|
||
|
||
export type DocStatus = "clean" | "dirty";
|
||
export type DiskStatus = "same" | "changed" | "gone";
|
||
|
||
export interface ViewerDocState {
|
||
doc: DocStatus;
|
||
disk: DiskStatus;
|
||
baseHash: string | null;
|
||
diskHash: string | null;
|
||
containerDown: boolean;
|
||
justReloaded: boolean;
|
||
overwrite: boolean;
|
||
}
|
||
|
||
export type ViewerAction =
|
||
| { type: "loaded"; hash: string; truncated: boolean }
|
||
| { type: "edited" }
|
||
| { type: "polled"; poll: ViewerPoll }
|
||
| { type: "poll_failed" }
|
||
| { type: "reloaded"; hash: string }
|
||
| { type: "overwrite_on_save" }
|
||
| { type: "saved"; hash: string }
|
||
| { type: "save_conflict" }
|
||
| { type: "save_gone" };
|
||
|
||
export const initialViewerState: ViewerDocState = {
|
||
doc: "clean",
|
||
disk: "same",
|
||
baseHash: null,
|
||
diskHash: null,
|
||
containerDown: false,
|
||
justReloaded: false,
|
||
overwrite: false,
|
||
};
|
||
|
||
export function reduceViewer(state: ViewerDocState, action: ViewerAction): ViewerDocState {
|
||
const s = { ...state, justReloaded: false };
|
||
switch (action.type) {
|
||
case "loaded":
|
||
return { ...initialViewerState, baseHash: action.hash, diskHash: action.truncated ? null : action.hash };
|
||
case "edited":
|
||
return { ...s, doc: "dirty" };
|
||
case "polled": {
|
||
if (!action.poll.exists) return { ...s, disk: "gone", containerDown: false };
|
||
const hash = action.poll.hash;
|
||
if (hash === null) return { ...s, containerDown: false };
|
||
if (s.diskHash === null) return { ...s, diskHash: hash, disk: s.disk === "gone" ? "same" : s.disk, containerDown: false };
|
||
if (hash === s.diskHash) return { ...s, disk: s.disk === "gone" ? "same" : s.disk, containerDown: false };
|
||
// Changed on disk. "Overwrite on save" adopted a base; a further change
|
||
// on disk invalidates it again.
|
||
return { ...s, diskHash: hash, disk: "changed", overwrite: false, containerDown: false };
|
||
}
|
||
case "poll_failed":
|
||
return { ...s, containerDown: true };
|
||
case "reloaded":
|
||
return { ...s, doc: "clean", disk: "same", baseHash: action.hash, diskHash: action.hash, justReloaded: true, overwrite: false };
|
||
case "overwrite_on_save":
|
||
return { ...s, baseHash: s.diskHash, disk: "same", overwrite: true };
|
||
case "saved":
|
||
return { ...s, doc: "clean", disk: "same", baseHash: action.hash, diskHash: action.hash, overwrite: false };
|
||
case "save_conflict":
|
||
return { ...s, disk: "changed", overwrite: false };
|
||
case "save_gone":
|
||
return { ...s, disk: "gone" };
|
||
}
|
||
}
|
||
|
||
export function pollEffect(before: ViewerDocState, after: ViewerDocState): "none" | "reload" | "banner" {
|
||
if (after.disk === "gone") return before.disk === "gone" ? "none" : "banner";
|
||
if (after.disk !== "changed" || after.diskHash === before.diskHash) return "none";
|
||
return after.doc === "clean" ? "reload" : "banner";
|
||
}
|
||
|
||
export function canSave(state: ViewerDocState, editable: boolean): boolean {
|
||
return editable && state.doc === "dirty" && state.disk === "same" && !state.containerDown;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Implement `editability.ts`**
|
||
|
||
```ts
|
||
import { imageMimeFor, looksBinary, TEXT_PREVIEW_LIMIT } from "../components/projects/home/filePreview";
|
||
import type { ViewerFile } from "../lib/types";
|
||
|
||
export type ViewerKind = "text" | "image" | "binary";
|
||
export interface Editability { kind: ViewerKind; editable: boolean; reason: string | null }
|
||
|
||
const MIB = TEXT_PREVIEW_LIMIT / (1024 * 1024);
|
||
|
||
export function classifyViewerFile(path: string, file: ViewerFile, bytes: Uint8Array): Editability {
|
||
if (imageMimeFor(path)) return { kind: "image", editable: false, reason: "Images are shown, not edited." };
|
||
if (looksBinary(bytes)) return { kind: "binary", editable: false, reason: "This file is not text." };
|
||
if (file.truncated) return { kind: "text", editable: false, reason: `Files over ${MIB} MiB are read-only.` };
|
||
if (!file.editable) return { kind: "text", editable: false, reason: file.readonly_reason ?? "This location is read-only." };
|
||
return { kind: "text", editable: true, reason: null };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Implement `languages.ts`**
|
||
|
||
```ts
|
||
/**
|
||
* Extension → CodeMirror language, loaded on demand so a window only pays for
|
||
* the grammar it shows. Dynamic `import()` becomes a same-origin chunk, fine
|
||
* under `script-src 'self'`.
|
||
*/
|
||
import type { Extension } from "@codemirror/state";
|
||
import { extensionOf } from "../components/projects/home/filePreview";
|
||
|
||
type Loader = () => Promise<Extension>;
|
||
|
||
const BY_EXTENSION: Record<string, Loader> = {
|
||
md: () => import("@codemirror/lang-markdown").then((m) => m.markdown()),
|
||
markdown: () => import("@codemirror/lang-markdown").then((m) => m.markdown()),
|
||
js: () => import("@codemirror/lang-javascript").then((m) => m.javascript()),
|
||
mjs: () => import("@codemirror/lang-javascript").then((m) => m.javascript()),
|
||
cjs: () => import("@codemirror/lang-javascript").then((m) => m.javascript()),
|
||
jsx: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ jsx: true })),
|
||
ts: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ typescript: true })),
|
||
tsx: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ jsx: true, typescript: true })),
|
||
rs: () => import("@codemirror/lang-rust").then((m) => m.rust()),
|
||
py: () => import("@codemirror/lang-python").then((m) => m.python()),
|
||
json: () => import("@codemirror/lang-json").then((m) => m.json()),
|
||
jsonc: () => import("@codemirror/lang-json").then((m) => m.json()),
|
||
yaml: () => import("@codemirror/lang-yaml").then((m) => m.yaml()),
|
||
yml: () => import("@codemirror/lang-yaml").then((m) => m.yaml()),
|
||
css: () => import("@codemirror/lang-css").then((m) => m.css()),
|
||
html: () => import("@codemirror/lang-html").then((m) => m.html()),
|
||
htm: () => import("@codemirror/lang-html").then((m) => m.html()),
|
||
toml: () => stream("toml"),
|
||
lock: () => stream("toml"),
|
||
sh: () => stream("shell"),
|
||
bash: () => stream("shell"),
|
||
zsh: () => stream("shell"),
|
||
};
|
||
|
||
const BY_BASENAME: Record<string, Loader> = {
|
||
dockerfile: () => stream("shell"),
|
||
makefile: () => stream("shell"),
|
||
};
|
||
|
||
async function stream(mode: "toml" | "shell"): Promise<Extension> {
|
||
const { StreamLanguage } = await import("@codemirror/language");
|
||
const parser = mode === "toml"
|
||
? (await import("@codemirror/legacy-modes/mode/toml")).toml
|
||
: (await import("@codemirror/legacy-modes/mode/shell")).shell;
|
||
return StreamLanguage.define(parser);
|
||
}
|
||
|
||
export function languageFor(path: string): Promise<Extension | null> {
|
||
const ext = extensionOf(path);
|
||
const base = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
|
||
const loader = BY_EXTENSION[ext] ?? BY_BASENAME[base];
|
||
return loader ? loader() : Promise.resolve(null);
|
||
}
|
||
|
||
const PROSE = new Set(["md", "markdown", "txt", "rst", "log", ""]);
|
||
export function wrapsLines(path: string): boolean {
|
||
return PROSE.has(extensionOf(path));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Run tests** — `npx vitest run src/viewer` → all pass. `npx tsc --noEmit` → 0.
|
||
|
||
- [ ] **Step 9: Commit** — `git add app/src/viewer && git commit -m "feat(viewer): reload/conflict reducer, editability rules, language loading"`
|
||
|
||
---
|
||
|
||
## Group B (each lists its dependencies)
|
||
|
||
### Task 8: Rust — commands, window creation, registration (depends on Tasks 1, 2, 3, 6)
|
||
|
||
**Files:**
|
||
- Replace: `app/src-tauri/src/file_viewer/window.rs` (Task 0's placeholder)
|
||
- Create: `app/src-tauri/src/commands/file_viewer_commands.rs`
|
||
- Modify: `app/src-tauri/src/commands/mod.rs` (`pub mod file_viewer_commands;`), `app/src-tauri/src/lib.rs` (`.manage(file_viewer::registry::ViewerRegistry::default())` right after the `.manage(AppState {…})` call; six `generate_handler!` lines after the `// Files` block under a `// Terminal file viewer` comment).
|
||
|
||
**Interfaces:** Consumes Tasks 1–3. Produces the six commands with the exact signatures in "Interfaces", plus:
|
||
|
||
```rust
|
||
// window.rs
|
||
pub fn open_viewer_window(app: &AppHandle, label: &str, title: &str) -> Result<(), String>;
|
||
// file_viewer_commands.rs (private helpers, tested)
|
||
fn require_main(window_label: &str) -> Result<(), String>;
|
||
fn require_viewer(window_label: &str) -> Result<String, String>;
|
||
fn viewer_state_of(label: &str, target: ViewerTarget) -> ViewerState; // the serialisable shape
|
||
fn window_title(raw_path: &str, project_name: &str) -> String;
|
||
```
|
||
|
||
- [ ] **Step 1: Write the failing tests** (bottom of `file_viewer_commands.rs`):
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn open_is_main_only_and_viewer_commands_are_viewer_only() {
|
||
assert!(require_main("main").is_ok());
|
||
assert!(require_main("file-viewer-1").is_err());
|
||
assert!(require_main("browser-view-x").is_err());
|
||
assert_eq!(require_viewer("file-viewer-7").unwrap(), "file-viewer-7");
|
||
assert!(require_viewer("main").is_err());
|
||
assert!(require_viewer("file-viewer-").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn the_title_is_basename_then_project() {
|
||
assert_eq!(window_title("app/src/lib/urlRelay.ts", "Triple-C"), "urlRelay.ts — Triple-C");
|
||
assert_eq!(window_title("/workspace/x/README.md", "x"), "README.md — x");
|
||
assert_eq!(window_title("Makefile", "p"), "Makefile — p");
|
||
}
|
||
|
||
#[test]
|
||
fn viewer_state_serialises_the_ipc_shape() {
|
||
let target = ViewerTarget {
|
||
project_id: "pid".into(),
|
||
project_name: "P".into(),
|
||
raw_path: "src/a.rs".into(),
|
||
state: ViewerTargetState::Resolved { container_path: "/workspace/p/src/a.rs".into() },
|
||
initial: Location { line: Some(3), col: Some(2), end_line: None },
|
||
};
|
||
let json = serde_json::to_value(viewer_state_of("file-viewer-1", target)).unwrap();
|
||
assert_eq!(json["project_id"], "pid");
|
||
assert_eq!(json["state"]["kind"], "resolved");
|
||
assert_eq!(json["state"]["container_path"], "/workspace/p/src/a.rs");
|
||
assert_eq!(json["initial"]["line"], 3);
|
||
assert!(json["initial"]["end_line"].is_null());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure** — `cargo test --offline file_viewer_commands` → compile error.
|
||
|
||
- [ ] **Step 3: Implement `file_viewer/window.rs`**
|
||
|
||
```rust
|
||
//! The viewer window itself. Mirrors `browser_view/popout.rs`, with two differences:
|
||
//! the URL is the app's own second entry (`WebviewUrl::App`), so the capability in
|
||
//! `capabilities/file-viewer.json` applies; and the registry entry is removed on
|
||
//! `Destroyed`, which fires for both the X button (after JS calls `destroy()`) and a
|
||
//! Rust-side `destroy()`.
|
||
|
||
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
||
|
||
use super::registry::ViewerRegistry;
|
||
|
||
pub fn open_viewer_window(app: &AppHandle, label: &str, title: &str) -> Result<(), String> {
|
||
let window = WebviewWindowBuilder::new(app, label, WebviewUrl::App("viewer.html".into()))
|
||
.title(title)
|
||
.inner_size(900.0, 700.0)
|
||
.min_inner_size(480.0, 320.0)
|
||
.build()
|
||
.map_err(|e| format!("Could not open the file window: {}", e))?;
|
||
|
||
let app_for_event = app.clone();
|
||
let label_owned = label.to_string();
|
||
window.on_window_event(move |event| {
|
||
if let WindowEvent::Destroyed = event {
|
||
app_for_event.state::<ViewerRegistry>().remove(&label_owned);
|
||
}
|
||
});
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement `commands/file_viewer_commands.rs`**
|
||
|
||
```rust
|
||
//! IPC for the terminal file viewer. Every command here is gated on the calling
|
||
//! window's label and reads its target from the registry — no path, no label, no
|
||
//! project id crosses IPC from a viewer window. See spec §6.
|
||
|
||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||
use base64::Engine as _;
|
||
use serde::Serialize;
|
||
use tauri::{AppHandle, Emitter, Manager, State};
|
||
|
||
use crate::commands::file_commands::{
|
||
fetch_container_file, require_running, validate_container_write_path,
|
||
};
|
||
use crate::file_viewer::poll::{poll_file, ViewerPoll};
|
||
use crate::file_viewer::registry::{Location, ViewerRegistry, ViewerTarget, ViewerTargetState};
|
||
use crate::file_viewer::resolve::{candidate_paths, probe_candidates};
|
||
use crate::file_viewer::window::open_viewer_window;
|
||
use crate::file_viewer::write::{sha256_hex, write_file, MAX_WRITE_BYTES};
|
||
use crate::file_viewer::is_viewer_label;
|
||
use crate::AppState;
|
||
|
||
pub const GOTO_EVENT: &str = "file-viewer-goto";
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
pub struct ViewerState {
|
||
pub project_id: String,
|
||
pub project_name: String,
|
||
pub raw_path: String,
|
||
pub state: ViewerTargetState,
|
||
pub initial: Location,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
pub struct ViewerFile {
|
||
pub contents_base64: String,
|
||
pub truncated: bool,
|
||
pub size: u64,
|
||
pub hash: String,
|
||
pub editable: bool,
|
||
pub readonly_reason: Option<String>,
|
||
}
|
||
|
||
fn require_main(window_label: &str) -> Result<(), String> {
|
||
if window_label == "main" {
|
||
Ok(())
|
||
} else {
|
||
Err("Only the main window can open files.".into())
|
||
}
|
||
}
|
||
|
||
fn require_viewer(window_label: &str) -> Result<String, String> {
|
||
if is_viewer_label(window_label) {
|
||
Ok(window_label.to_string())
|
||
} else {
|
||
Err("This command belongs to a file window.".into())
|
||
}
|
||
}
|
||
|
||
fn viewer_state_of(_label: &str, target: ViewerTarget) -> ViewerState {
|
||
ViewerState {
|
||
project_id: target.project_id,
|
||
project_name: target.project_name,
|
||
raw_path: target.raw_path,
|
||
state: target.state,
|
||
initial: target.initial,
|
||
}
|
||
}
|
||
|
||
fn window_title(raw_path: &str, project_name: &str) -> String {
|
||
let base = raw_path.trim_end_matches('/').rsplit('/').next().unwrap_or(raw_path);
|
||
format!("{} — {}", base, project_name)
|
||
}
|
||
|
||
/// The caller's registry entry, or a sentence.
|
||
fn own_target(window: &tauri::Window, registry: &ViewerRegistry) -> Result<(String, ViewerTarget), String> {
|
||
let label = require_viewer(window.label())?;
|
||
let target = registry
|
||
.get(&label)
|
||
.ok_or_else(|| "This file window is no longer registered.".to_string())?;
|
||
Ok((label, target))
|
||
}
|
||
|
||
fn resolved_path(target: &ViewerTarget) -> Result<String, String> {
|
||
match &target.state {
|
||
ViewerTargetState::Resolved { container_path } => Ok(container_path.clone()),
|
||
_ => Err("Choose a file first.".into()),
|
||
}
|
||
}
|
||
|
||
async fn running_container_of(state: &State<'_, AppState>, project_id: &str) -> Result<String, String> {
|
||
let project = state
|
||
.projects_store
|
||
.get(project_id)
|
||
.ok_or_else(|| "This project no longer exists.".to_string())?;
|
||
let container_id = project
|
||
.container_id
|
||
.ok_or_else(|| "Start the project before opening files — they live in its container.".to_string())?;
|
||
require_running(&container_id, "opening files").await?;
|
||
Ok(container_id)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn open_file_viewer(
|
||
project_id: String,
|
||
path: String,
|
||
line: Option<u32>,
|
||
col: Option<u32>,
|
||
end_line: Option<u32>,
|
||
window: tauri::Window,
|
||
app: AppHandle,
|
||
registry: State<'_, ViewerRegistry>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<(), String> {
|
||
require_main(window.label())?;
|
||
let project = state
|
||
.projects_store
|
||
.get(&project_id)
|
||
.ok_or_else(|| "This project no longer exists.".to_string())?;
|
||
let container_id = running_container_of(&state, &project_id).await?;
|
||
|
||
let mounts: Vec<String> = project.paths.iter().map(|p| p.mount_name.clone()).collect();
|
||
let candidates = candidate_paths(&path, &mounts)?;
|
||
let matches = probe_candidates(&container_id, &candidates).await?;
|
||
let initial = Location { line, col, end_line };
|
||
|
||
let target_state = match matches.len() {
|
||
0 => ViewerTargetState::NotFound { tried: candidates },
|
||
1 => {
|
||
let container_path = matches[0].clone();
|
||
if let Some(label) = registry.find_open(&project_id, &container_path) {
|
||
if let Some(existing) = app.get_webview_window(&label) {
|
||
let _ = existing.unminimize();
|
||
let _ = existing.set_focus();
|
||
let _ = app.emit_to(label.as_str(), GOTO_EVENT, initial);
|
||
return Ok(());
|
||
}
|
||
registry.remove(&label);
|
||
}
|
||
ViewerTargetState::Resolved { container_path }
|
||
}
|
||
_ => ViewerTargetState::Choose { candidates: matches },
|
||
};
|
||
|
||
let title = window_title(&path, &project.name);
|
||
let label = registry.reserve(ViewerTarget {
|
||
project_id,
|
||
project_name: project.name.clone(),
|
||
raw_path: path,
|
||
state: target_state,
|
||
initial,
|
||
})?;
|
||
if let Err(e) = open_viewer_window(&app, &label, &title) {
|
||
registry.remove(&label);
|
||
return Err(e);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn viewer_get_state(
|
||
window: tauri::Window,
|
||
registry: State<'_, ViewerRegistry>,
|
||
) -> Result<ViewerState, String> {
|
||
let (label, target) = own_target(&window, ®istry)?;
|
||
Ok(viewer_state_of(&label, target))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn viewer_choose_file(
|
||
index: usize,
|
||
window: tauri::Window,
|
||
registry: State<'_, ViewerRegistry>,
|
||
) -> Result<ViewerState, String> {
|
||
let (label, target) = own_target(&window, ®istry)?;
|
||
let chosen = match &target.state {
|
||
ViewerTargetState::Choose { candidates } => candidates
|
||
.get(index)
|
||
.cloned()
|
||
.ok_or_else(|| "That choice is no longer available.".to_string())?,
|
||
_ => return Err("This window is not choosing a file.".into()),
|
||
};
|
||
let updated = registry.set_state(&label, ViewerTargetState::Resolved { container_path: chosen })?;
|
||
Ok(viewer_state_of(&label, updated))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn viewer_read_file(
|
||
max_bytes: u64,
|
||
window: tauri::Window,
|
||
registry: State<'_, ViewerRegistry>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<ViewerFile, String> {
|
||
let (_label, target) = own_target(&window, ®istry)?;
|
||
let path = resolved_path(&target)?;
|
||
let container_id = running_container_of(&state, &target.project_id).await?;
|
||
let cap = max_bytes.clamp(1, crate::commands::file_commands::MAX_READ_BYTES);
|
||
let fetched = fetch_container_file(&container_id, &path, cap).await?;
|
||
let (editable, readonly_reason) = match validate_container_write_path("File", &path) {
|
||
Ok(()) => (true, None),
|
||
Err(reason) => (false, Some(reason)),
|
||
};
|
||
Ok(ViewerFile {
|
||
hash: sha256_hex(&fetched.bytes),
|
||
contents_base64: BASE64.encode(&fetched.bytes),
|
||
truncated: fetched.truncated,
|
||
size: fetched.size,
|
||
editable,
|
||
readonly_reason,
|
||
})
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn viewer_poll_file(
|
||
window: tauri::Window,
|
||
registry: State<'_, ViewerRegistry>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<ViewerPoll, String> {
|
||
let (_label, target) = own_target(&window, ®istry)?;
|
||
let path = resolved_path(&target)?;
|
||
let container_id = running_container_of(&state, &target.project_id).await?;
|
||
poll_file(&container_id, &path).await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn viewer_write_file(
|
||
contents_base64: String,
|
||
base_hash: String,
|
||
window: tauri::Window,
|
||
registry: State<'_, ViewerRegistry>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<String, String> {
|
||
let (_label, target) = own_target(&window, ®istry)?;
|
||
let path = resolved_path(&target)?;
|
||
validate_container_write_path("File", &path)?;
|
||
if contents_base64.len() > MAX_WRITE_BYTES * 4 / 3 + 4 {
|
||
return Err("Files over 1 MiB are read-only in the viewer.".into());
|
||
}
|
||
let bytes = BASE64
|
||
.decode(contents_base64.as_bytes())
|
||
.map_err(|_| "The editor sent malformed content.".to_string())?;
|
||
let container_id = running_container_of(&state, &target.project_id).await?;
|
||
write_file(&container_id, &state.exec_manager, &path, &bytes, &base_hash).await
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Register** — in `lib.rs`: `pub mod file_viewer;` is already there from Task 0; add `.manage(file_viewer::registry::ViewerRegistry::default())` after the existing `.manage(AppState { … })` block; add to `generate_handler!`:
|
||
|
||
```rust
|
||
// Terminal file viewer
|
||
commands::file_viewer_commands::open_file_viewer,
|
||
commands::file_viewer_commands::viewer_get_state,
|
||
commands::file_viewer_commands::viewer_choose_file,
|
||
commands::file_viewer_commands::viewer_read_file,
|
||
commands::file_viewer_commands::viewer_poll_file,
|
||
commands::file_viewer_commands::viewer_write_file,
|
||
```
|
||
|
||
- [ ] **Step 6: Verify** — `cargo test --offline` (whole suite: `every_command_is_registered_exactly_once` plus the new tests) → pass; `cargo clippy --offline` → clean. Tauri's IPC arg names: JS sends `maxBytes`, `contentsBase64`, `baseHash`, `endLine`, `projectId` — Tauri maps camelCase to the snake_case parameters automatically.
|
||
|
||
- [ ] **Step 7: Commit** — `git add app/src-tauri && git commit -m "feat(viewer): label-gated viewer commands and window creation"`
|
||
|
||
---
|
||
|
||
### Task 9: Terminal integration — link provider and OSC 8 dispatch (depends on Tasks 0, 4, 5)
|
||
|
||
**Files:**
|
||
- Create: `app/src/components/terminal/filePathLinkProvider.ts` + `.test.ts`
|
||
- Modify: `app/src/components/terminal/TerminalView.tsx` (`createOsc8LinkHandler` ~line 401; the `WebLinksAddon` comment + registration ~lines 918–953; cleanup ~line 1238)
|
||
- Modify: `app/src/components/terminal/TerminalView.test.tsx` (mock `openFileViewer`; new tests in the `createOsc8LinkHandler` describe)
|
||
|
||
**Interfaces:**
|
||
- Consumes `findFilePathLinks`, `joinWrappedRows`, `offsetToCell`, `openFileViewer`.
|
||
- Produces `createFilePathLinkProvider(term: Pick<Terminal,"buffer">, onOpen: (m: FilePathMatch) => void, gate: (event: MouseEvent) => boolean, hover?: {show(path: string): void; hide(): void}): ILinkProvider` and a third parameter on `createOsc8LinkHandler(getHost, readState, onOpenFile?: (path: string) => void)`.
|
||
|
||
- [ ] **Step 1: Write the failing provider test** (`filePathLinkProvider.test.ts`):
|
||
|
||
```ts
|
||
import { describe, expect, it, vi } from "vitest";
|
||
import { createFilePathLinkProvider } from "./filePathLinkProvider";
|
||
|
||
const fakeTerm = (rows: Array<[string, boolean]>) => ({
|
||
buffer: {
|
||
active: {
|
||
getLine: (y: number) => rows[y] && { isWrapped: rows[y][1], translateToString: () => rows[y][0] },
|
||
},
|
||
},
|
||
}) as unknown as Parameters<typeof createFilePathLinkProvider>[0];
|
||
|
||
describe("createFilePathLinkProvider", () => {
|
||
it("reports 1-based inclusive ranges and activates through the gate", () => {
|
||
const onOpen = vi.fn();
|
||
const gate = vi.fn(() => true);
|
||
const provider = createFilePathLinkProvider(fakeTerm([["Edited src/foo.ts:42 today", false]]), onOpen, gate);
|
||
const links = vi.fn();
|
||
provider.provideLinks(1, links);
|
||
const [list] = links.mock.calls[0];
|
||
expect(list).toHaveLength(1);
|
||
expect(list[0].range).toEqual({ start: { x: 8, y: 1 }, end: { x: 20, y: 1 } });
|
||
expect(list[0].text).toBe("src/foo.ts:42");
|
||
list[0].activate(new MouseEvent("click"), list[0].text);
|
||
expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ path: "src/foo.ts", line: 42 }));
|
||
});
|
||
|
||
it("does nothing when the gate refuses", () => {
|
||
const onOpen = vi.fn();
|
||
const provider = createFilePathLinkProvider(fakeTerm([["src/foo.ts", false]]), onOpen, () => false);
|
||
const links = vi.fn();
|
||
provider.provideLinks(1, links);
|
||
links.mock.calls[0][0][0].activate(new MouseEvent("click"), "src/foo.ts");
|
||
expect(onOpen).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("spans a wrapped path across rows", () => {
|
||
const provider = createFilePathLinkProvider(fakeTerm([["see /workspace/p/", false], ["src/foo.ts:7", true]]), vi.fn(), () => true);
|
||
const links = vi.fn();
|
||
provider.provideLinks(2, links);
|
||
expect(links.mock.calls[0][0][0].range).toEqual({ start: { x: 5, y: 1 }, end: { x: 12, y: 2 } });
|
||
});
|
||
|
||
it("answers undefined for a row with nothing", () => {
|
||
const provider = createFilePathLinkProvider(fakeTerm([["plain words", false]]), vi.fn(), () => true);
|
||
const links = vi.fn();
|
||
provider.provideLinks(1, links);
|
||
expect(links).toHaveBeenCalledWith(undefined);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure** — `npx vitest run src/components/terminal/filePathLinkProvider.test.ts` → module missing.
|
||
|
||
- [ ] **Step 3: Implement `filePathLinkProvider.ts`**
|
||
|
||
```ts
|
||
/**
|
||
* xterm `ILinkProvider` for file paths in the buffer.
|
||
*
|
||
* Registered after `WebLinksAddon` so URLs are claimed first; `findFilePathLinks`
|
||
* also refuses anything inside a `scheme://` span, so the two never overlap.
|
||
* Ranges are 1-based on both axes with an *inclusive* end column (xterm's
|
||
* contract), and `provideLinks`' row is 1-based while `getLine` is 0-based.
|
||
*/
|
||
import type { ILink, ILinkProvider, Terminal } from "@xterm/xterm";
|
||
import { findFilePathLinks, type FilePathMatch } from "../../lib/filePathLinks";
|
||
import { joinWrappedRows, offsetToCell } from "../../lib/xtermLineJoin";
|
||
|
||
export interface FilePathHover {
|
||
show(path: string): void;
|
||
hide(): void;
|
||
}
|
||
|
||
export function createFilePathLinkProvider(
|
||
term: Pick<Terminal, "buffer">,
|
||
onOpen: (match: FilePathMatch) => void,
|
||
gate: (event: MouseEvent) => boolean,
|
||
hover?: FilePathHover,
|
||
): ILinkProvider {
|
||
return {
|
||
provideLinks(bufferLineNumber, callback) {
|
||
const row = bufferLineNumber - 1;
|
||
const line = term.buffer.active.getLine(row);
|
||
if (!line) return callback(undefined);
|
||
const joined = joinWrappedRows(term.buffer.active, row);
|
||
const matches = findFilePathLinks(joined.text);
|
||
if (matches.length === 0) return callback(undefined);
|
||
const links: ILink[] = matches
|
||
.map((m): ILink => ({
|
||
range: { start: offsetToCell(joined, m.start), end: offsetToCell(joined, m.end - 1) },
|
||
text: joined.text.slice(m.start, m.end),
|
||
decorations: { pointerCursor: true, underline: true },
|
||
activate: (event) => {
|
||
if (!gate(event)) return;
|
||
onOpen(m);
|
||
},
|
||
hover: () => hover?.show(m.path),
|
||
leave: () => hover?.hide(),
|
||
}))
|
||
// Only links that touch the row being asked about (xterm asks per row).
|
||
.filter((l) => l.range.start.y <= bufferLineNumber && l.range.end.y >= bufferLineNumber);
|
||
callback(links.length ? links : undefined);
|
||
},
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Wire it into `TerminalView.tsx`**
|
||
|
||
1. Imports: `import { openFileViewer } from "../../lib/tauri-commands";` (extend the existing import), `import { createFilePathLinkProvider } from "./filePathLinkProvider";`, `import type { FilePathMatch } from "../../lib/filePathLinks";`.
|
||
2. Add a module-level helper next to `reportOpenFailure`:
|
||
```ts
|
||
function reportViewerFailure(e: unknown): void {
|
||
useAppState.getState().pushToast({
|
||
kind: "error",
|
||
message: "Could not open the file",
|
||
detail: e instanceof Error ? e.message : String(e),
|
||
dedupeKey: "file-viewer-open",
|
||
});
|
||
}
|
||
```
|
||
3. `createOsc8LinkHandler` gains a third parameter `onOpenFile?: (path: string) => void` and sets `allowNonHttpProtocols: true`. In `activate`, replace the body after the gate with:
|
||
```ts
|
||
let parsed: URL;
|
||
try { parsed = new URL(text); } catch { console.warn("Refusing a hyperlink that is not a URL"); return; }
|
||
if (parsed.protocol === "file:") {
|
||
if (!onOpenFile) return;
|
||
onOpenFile(decodeURIComponent(parsed.pathname));
|
||
return;
|
||
}
|
||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||
console.warn("Refusing to open a link with an unsupported scheme");
|
||
return;
|
||
}
|
||
const safe = sanitizeRelayUrl(text);
|
||
if (!safe) { console.warn("Refusing to open a link that failed validation"); return; }
|
||
openUrlExternal(safe).catch(reportOpenFailure);
|
||
```
|
||
In `hover`, before building the card: parse the same way; return (no card) unless `file:` or `http(s):`. For `file:` the card's bold span is `"Open in viewer"` and the rest is the decoded path; the hint line is unchanged.
|
||
4. At the `new Terminal({ linkHandler: … })` site, pass the third argument:
|
||
```ts
|
||
(path) => { if (projectIdRef.current) openFileViewer(projectIdRef.current, path).catch(reportViewerFailure); },
|
||
```
|
||
where `projectIdRef` is a new `useRef<string | undefined>(projectId)` kept current with `useEffect(() => { projectIdRef.current = projectId; }, [projectId]);` (the mount effect is keyed on `sessionId` only, so it cannot close over `projectId`).
|
||
5. After `term.loadAddon(webLinksAddon);`:
|
||
```ts
|
||
const filePathLinks = term.registerLinkProvider(
|
||
createFilePathLinkProvider(
|
||
term,
|
||
(m: FilePathMatch) => {
|
||
const pid = projectIdRef.current;
|
||
if (!pid) return;
|
||
openFileViewer(pid, m.path, m.line, m.col, m.endLine).catch(reportViewerFailure);
|
||
},
|
||
(event) => opensOnClick(event, readClickContext(term)),
|
||
{
|
||
show: (path) => osc8LinkHandlerRef.current?.hover?.(new MouseEvent("mousemove"), `file://${path}`, { start: { x: 1, y: 1 }, end: { x: 1, y: 1 } }),
|
||
hide: () => osc8LinkHandlerRef.current?.dismiss(),
|
||
},
|
||
),
|
||
);
|
||
```
|
||
and in the cleanup, before `term.dispose()`: `filePathLinks.dispose();`.
|
||
6. Update the `WebLinksAddon` comment paragraph that begins "It is also the only gate on a real bypass of the OSC 8 one" to say: `allowNonHttpProtocols` is now **on**, so `OscLinkProvider` hands every target to `createOsc8LinkHandler`, which parses and refuses anything but `file:` and `http(s):` itself; this branch remains the only handler for plain-text URLs.
|
||
|
||
- [ ] **Step 5: Add tests to `TerminalView.test.tsx`** — add `openFileViewer: vi.fn(async () => {}),` to the `vi.mock("../../lib/tauri-commands", …)` factory and import it; in the `createOsc8LinkHandler` describe add:
|
||
|
||
```ts
|
||
it("routes a file: target to the viewer and never to the opener", () => {
|
||
const onOpenFile = vi.fn();
|
||
const h = createOsc8LinkHandler(() => host, () => state, onOpenFile);
|
||
h.activate(click(), "file:///workspace/p/src/a.ts", range);
|
||
expect(onOpenFile).toHaveBeenCalledWith("/workspace/p/src/a.ts");
|
||
expect(openUrlExternal).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("with non-http targets now delivered, still refuses javascript: and garbage", () => {
|
||
const onOpenFile = vi.fn();
|
||
const h = createOsc8LinkHandler(() => host, () => state, onOpenFile);
|
||
h.activate(click(), "javascript:alert(1)", range);
|
||
h.activate(click(), "not a url", range);
|
||
h.hover?.(new MouseEvent("mousemove"), "javascript:alert(1)", range);
|
||
expect(onOpenFile).not.toHaveBeenCalled();
|
||
expect(openUrlExternal).not.toHaveBeenCalled();
|
||
expect(hoverCard()).toBeNull();
|
||
});
|
||
|
||
it("the file: hover card names the viewer and the path", () => {
|
||
const h = createOsc8LinkHandler(() => host, () => state, vi.fn());
|
||
h.hover?.(new MouseEvent("mousemove"), "file:///workspace/p/README.md", range);
|
||
expect(hoverCard()?.textContent).toContain("Open in viewer");
|
||
expect(hoverCard()?.textContent).toContain("/workspace/p/README.md");
|
||
});
|
||
|
||
it("declares allowNonHttpProtocols so file: targets reach it", () => {
|
||
expect(createOsc8LinkHandler(() => host, () => state).allowNonHttpProtocols).toBe(true);
|
||
});
|
||
```
|
||
|
||
The existing test `refuses a target that fails validation, without reaching the opener` still passes: `file:///etc/passwd` with no `onOpenFile` goes nowhere.
|
||
|
||
- [ ] **Step 6: Verify** — `npx vitest run src/components/terminal` → all pass (82 existing + new); `npx tsc --noEmit` → 0.
|
||
|
||
- [ ] **Step 7: Commit** — `git add app/src/components/terminal && git commit -m "feat(viewer): clickable file paths and file: hyperlinks in the terminal"`
|
||
|
||
---
|
||
|
||
### Task 10: Viewer editor — CodeMirror wrapper, theme, line highlight (depends on Task 6)
|
||
|
||
**Files:**
|
||
- Create: `app/src/viewer/viewerTheme.ts`, `app/src/viewer/highlightLine.ts` + `.test.ts`, `app/src/viewer/CodeEditor.tsx`
|
||
|
||
**Interfaces:** Produces
|
||
|
||
```ts
|
||
// highlightLine.ts
|
||
export const setHighlight: StateEffectType<{ from: number; to: number } | null>; // 1-based line numbers, inclusive
|
||
export const highlightLineField: StateField<DecorationSet>;
|
||
export function highlightExtension(): Extension;
|
||
export function lineRangeToPositions(doc: Text, from: number, to: number): { from: number; to: number } | null; // clamped
|
||
|
||
// CodeEditor.tsx
|
||
export interface CodeEditorHandle {
|
||
getDoc(): string;
|
||
/** Replace the whole document, keeping scroll and a clamped cursor. Does not mark dirty. */
|
||
setDoc(text: string): void;
|
||
goTo(loc: ViewerLocation): void;
|
||
focus(): void;
|
||
}
|
||
export interface CodeEditorProps {
|
||
initialDoc: string;
|
||
readOnly: boolean;
|
||
language: Extension | null;
|
||
lineWrapping: boolean;
|
||
initialLocation: ViewerLocation;
|
||
onDocChanged(): void;
|
||
onSave(): void;
|
||
}
|
||
export const CodeEditor: React.ForwardRefExoticComponent<CodeEditorProps & React.RefAttributes<CodeEditorHandle>>;
|
||
```
|
||
|
||
- [ ] **Step 1: Write the failing highlight tests** (`highlightLine.test.ts`):
|
||
|
||
```ts
|
||
import { describe, expect, it } from "vitest";
|
||
import { EditorState, Text } from "@codemirror/state";
|
||
import { highlightExtension, highlightLineField, lineRangeToPositions, setHighlight } from "./highlightLine";
|
||
|
||
describe("lineRangeToPositions", () => {
|
||
const doc = Text.of(["one", "two", "three"]);
|
||
it("maps 1-based inclusive lines to document offsets", () => {
|
||
expect(lineRangeToPositions(doc, 2, 2)).toEqual({ from: 4, to: 4 });
|
||
expect(lineRangeToPositions(doc, 1, 3)).toEqual({ from: 0, to: 8 });
|
||
});
|
||
it("clamps past the end and refuses nonsense", () => {
|
||
expect(lineRangeToPositions(doc, 2, 99)).toEqual({ from: 4, to: 8 });
|
||
expect(lineRangeToPositions(doc, 99, 100)).toEqual({ from: 8, to: 8 });
|
||
expect(lineRangeToPositions(doc, 0, 1)).toEqual({ from: 0, to: 0 });
|
||
expect(lineRangeToPositions(doc, 3, 1)).toEqual({ from: 8, to: 8 });
|
||
});
|
||
});
|
||
|
||
describe("highlightLineField", () => {
|
||
it("decorates every line in the range and clears on null", () => {
|
||
let state = EditorState.create({ doc: "a\nb\nc\nd", extensions: [highlightExtension()] });
|
||
state = state.update({ effects: setHighlight.of({ from: 2, to: 3 }) }).state;
|
||
let count = 0;
|
||
state.field(highlightLineField).between(0, state.doc.length, () => { count++; });
|
||
expect(count).toBe(2);
|
||
state = state.update({ effects: setHighlight.of(null) }).state;
|
||
count = 0;
|
||
state.field(highlightLineField).between(0, state.doc.length, () => { count++; });
|
||
expect(count).toBe(0);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure** — `npx vitest run src/viewer/highlightLine.test.ts` → module missing.
|
||
|
||
- [ ] **Step 3: Implement `highlightLine.ts`**
|
||
|
||
```ts
|
||
import { StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
|
||
import { Decoration, EditorView, type DecorationSet } from "@codemirror/view";
|
||
|
||
export const setHighlight = StateEffect.define<{ from: number; to: number } | null>();
|
||
|
||
const lineMark = Decoration.line({ class: "cm-triple-c-target" });
|
||
|
||
export function lineRangeToPositions(doc: Text, from: number, to: number): { from: number; to: number } | null {
|
||
const clamp = (n: number) => Math.min(Math.max(1, Math.floor(n)), doc.lines);
|
||
const a = clamp(from);
|
||
const b = Math.max(a, clamp(to));
|
||
return { from: doc.line(a).from, to: doc.line(b).from };
|
||
}
|
||
|
||
export const highlightLineField = StateField.define<DecorationSet>({
|
||
create: () => Decoration.none,
|
||
update(value, tr) {
|
||
let next = value.map(tr.changes);
|
||
for (const e of tr.effects) {
|
||
if (!e.is(setHighlight)) continue;
|
||
if (e.value === null) { next = Decoration.none; continue; }
|
||
const range = lineRangeToPositions(tr.state.doc, e.value.from, e.value.to);
|
||
if (!range) { next = Decoration.none; continue; }
|
||
const marks = [];
|
||
for (let pos = range.from; pos <= range.to; ) {
|
||
const line = tr.state.doc.lineAt(pos);
|
||
marks.push(lineMark.range(line.from));
|
||
if (line.to + 1 > tr.state.doc.length) break;
|
||
pos = line.to + 1;
|
||
}
|
||
next = Decoration.set(marks, true);
|
||
}
|
||
return next;
|
||
},
|
||
provide: (f) => EditorView.decorations.from(f),
|
||
});
|
||
|
||
export function highlightExtension(): Extension {
|
||
return [highlightLineField];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement `viewerTheme.ts`** (tokens only; adjust the highlight tags to taste but keep the set small):
|
||
|
||
```ts
|
||
import { EditorView } from "@codemirror/view";
|
||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||
import { tags as t } from "@lezer/highlight";
|
||
import type { Extension } from "@codemirror/state";
|
||
|
||
export const viewerTheme: Extension = [
|
||
EditorView.theme(
|
||
{
|
||
"&": { backgroundColor: "var(--bg-primary)", color: "var(--text-primary)", height: "100%", fontSize: "13px" },
|
||
".cm-content": { fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace", caretColor: "var(--accent)" },
|
||
".cm-scroller": { overflow: "auto" },
|
||
".cm-gutters": { backgroundColor: "var(--bg-secondary)", color: "var(--text-secondary)", borderRight: "1px solid var(--border-color)" },
|
||
".cm-activeLine": { backgroundColor: "var(--accent-muted)" },
|
||
".cm-activeLineGutter": { backgroundColor: "var(--accent-muted)" },
|
||
".cm-triple-c-target": { backgroundColor: "var(--warning-muted)", outline: "1px solid var(--warning)" },
|
||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": { backgroundColor: "var(--accent-muted)" },
|
||
".cm-panels": { backgroundColor: "var(--bg-secondary)", color: "var(--text-primary)", borderBottom: "1px solid var(--border-color)" },
|
||
".cm-searchMatch": { backgroundColor: "var(--warning-muted)", outline: "1px solid var(--warning)" },
|
||
".cm-searchMatch.cm-searchMatch-selected": { backgroundColor: "var(--success-muted)" },
|
||
},
|
||
{ dark: true },
|
||
),
|
||
syntaxHighlighting(
|
||
HighlightStyle.define([
|
||
{ tag: [t.keyword, t.modifier, t.operatorKeyword], color: "#ff7b72" },
|
||
{ tag: [t.string, t.special(t.string)], color: "#a5d6ff" },
|
||
{ tag: [t.comment, t.lineComment, t.blockComment], color: "var(--text-secondary)", fontStyle: "italic" },
|
||
{ tag: [t.number, t.bool, t.null, t.atom], color: "#79c0ff" },
|
||
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: "#d2a8ff" },
|
||
{ tag: [t.typeName, t.className, t.namespace], color: "#ffa657" },
|
||
{ tag: [t.propertyName, t.attributeName], color: "#7ee787" },
|
||
{ tag: t.heading, fontWeight: "bold", color: "var(--accent)" },
|
||
{ tag: t.emphasis, fontStyle: "italic" },
|
||
{ tag: t.strong, fontWeight: "bold" },
|
||
{ tag: t.link, color: "var(--accent)", textDecoration: "underline" },
|
||
{ tag: t.invalid, color: "#ff7b72", textDecoration: "underline wavy" },
|
||
]),
|
||
),
|
||
];
|
||
```
|
||
|
||
(The literal hex values are the GitHub-dark syntax palette the terminal theme in `TerminalView.tsx` already uses; they are syntax colours, not UI chrome, so they do not go through `index.css` tokens.)
|
||
|
||
- [ ] **Step 5: Implement `CodeEditor.tsx`**
|
||
|
||
```tsx
|
||
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
||
import { Annotation, EditorState, Compartment, EditorSelection, type Extension } from "@codemirror/state";
|
||
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, highlightSpecialChars } from "@codemirror/view";
|
||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||
import { search, searchKeymap, gotoLine } from "@codemirror/search";
|
||
import { bracketMatching, indentOnInput } from "@codemirror/language";
|
||
import type { ViewerLocation } from "../lib/types";
|
||
import { viewerTheme } from "./viewerTheme";
|
||
import { highlightExtension, setHighlight } from "./highlightLine";
|
||
|
||
export interface CodeEditorHandle {
|
||
getDoc(): string;
|
||
setDoc(text: string): void;
|
||
goTo(loc: ViewerLocation): void;
|
||
focus(): void;
|
||
}
|
||
|
||
export interface CodeEditorProps {
|
||
initialDoc: string;
|
||
readOnly: boolean;
|
||
language: Extension | null;
|
||
lineWrapping: boolean;
|
||
initialLocation: ViewerLocation;
|
||
onDocChanged(): void;
|
||
onSave(): void;
|
||
}
|
||
|
||
/** A `dispatch` from `setDoc` is a reload, not a user edit; the listener must not mark it dirty. */
|
||
const reloadTag = Annotation.define<boolean>();
|
||
|
||
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function CodeEditor(props, ref) {
|
||
const host = useRef<HTMLDivElement>(null);
|
||
const view = useRef<EditorView | null>(null);
|
||
const readOnlyCompartment = useRef(new Compartment());
|
||
const languageCompartment = useRef(new Compartment());
|
||
const wrapCompartment = useRef(new Compartment());
|
||
const callbacks = useRef(props);
|
||
callbacks.current = props;
|
||
|
||
useEffect(() => {
|
||
if (!host.current) return;
|
||
const v = new EditorView({
|
||
parent: host.current,
|
||
state: EditorState.create({
|
||
doc: props.initialDoc,
|
||
extensions: [
|
||
lineNumbers(),
|
||
highlightActiveLine(),
|
||
highlightActiveLineGutter(),
|
||
highlightSpecialChars(),
|
||
drawSelection(),
|
||
history(),
|
||
bracketMatching(),
|
||
indentOnInput(),
|
||
search({ top: true }),
|
||
highlightExtension(),
|
||
viewerTheme,
|
||
keymap.of([
|
||
{ key: "Mod-s", run: () => { callbacks.current.onSave(); return true; } },
|
||
{ key: "Mod-g", run: gotoLine },
|
||
...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab,
|
||
]),
|
||
readOnlyCompartment.current.of([EditorState.readOnly.of(props.readOnly), EditorView.editable.of(!props.readOnly)]),
|
||
languageCompartment.current.of(props.language ?? []),
|
||
wrapCompartment.current.of(props.lineWrapping ? EditorView.lineWrapping : []),
|
||
EditorView.updateListener.of((u) => {
|
||
if (u.docChanged && !u.transactions.some((tr) => tr.annotation(reloadTag))) callbacks.current.onDocChanged();
|
||
}),
|
||
],
|
||
}),
|
||
});
|
||
view.current = v;
|
||
goTo(v, props.initialLocation);
|
||
return () => { v.destroy(); view.current = null; };
|
||
// The editor is created once per mount; later prop changes go through compartments below.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
view.current?.dispatch({ effects: readOnlyCompartment.current.reconfigure([EditorState.readOnly.of(props.readOnly), EditorView.editable.of(!props.readOnly)]) });
|
||
}, [props.readOnly]);
|
||
useEffect(() => {
|
||
view.current?.dispatch({ effects: languageCompartment.current.reconfigure(props.language ?? []) });
|
||
}, [props.language]);
|
||
useEffect(() => {
|
||
view.current?.dispatch({ effects: wrapCompartment.current.reconfigure(props.lineWrapping ? EditorView.lineWrapping : []) });
|
||
}, [props.lineWrapping]);
|
||
|
||
useImperativeHandle(ref, () => ({
|
||
getDoc: () => view.current?.state.doc.toString() ?? "",
|
||
setDoc: (text) => {
|
||
const v = view.current;
|
||
if (!v) return;
|
||
const scrollTop = v.scrollDOM.scrollTop;
|
||
const head = Math.min(v.state.selection.main.head, text.length);
|
||
v.dispatch({
|
||
changes: { from: 0, to: v.state.doc.length, insert: text },
|
||
selection: EditorSelection.single(head),
|
||
annotations: reloadTag.of(true),
|
||
});
|
||
v.scrollDOM.scrollTop = scrollTop;
|
||
},
|
||
goTo: (loc) => { if (view.current) goTo(view.current, loc); },
|
||
focus: () => view.current?.focus(),
|
||
}));
|
||
|
||
return <div ref={host} className="h-full min-h-0" data-testid="code-editor" />;
|
||
});
|
||
|
||
function goTo(v: EditorView, loc: ViewerLocation): void {
|
||
if (loc.line === null) return;
|
||
const from = loc.line;
|
||
const to = loc.end_line ?? loc.line;
|
||
const lineNo = Math.min(Math.max(1, from), v.state.doc.lines);
|
||
const line = v.state.doc.line(lineNo);
|
||
const pos = Math.min(line.from + Math.max(0, (loc.col ?? 1) - 1), line.to);
|
||
v.dispatch({
|
||
selection: EditorSelection.cursor(pos),
|
||
effects: [setHighlight.of({ from, to }), EditorView.scrollIntoView(pos, { y: "center" })],
|
||
});
|
||
}
|
||
```
|
||
|
||
The `Mod-s` binding is what makes Ctrl/Cmd+S save inside the editor; `EditorPane` also binds it on `document` for when focus is outside the editor. `reloadTag` is how `setDoc` (a reload from disk) is told apart from a user edit in `updateListener`.
|
||
|
||
- [ ] **Step 6: Verify** — `npx vitest run src/viewer` → pass; `npx tsc --noEmit` → 0; `npm run build` → `dist/assets/` contains separate chunks for `lang-*` (dynamic imports worked).
|
||
|
||
- [ ] **Step 7: Commit** — `git add app/src/viewer && git commit -m "feat(viewer): CodeMirror editor, theme and target-line highlight"`
|
||
|
||
---
|
||
|
||
### Task 11: Viewer app — load, poll, save, close guard, choose/not-found (depends on Tasks 0, 7, 10; Task 8 for a live run)
|
||
|
||
**Files:**
|
||
- Create: `app/src/viewer/useViewerPolling.ts` + `.test.ts`, `app/src/viewer/EditorPane.tsx`, `app/src/viewer/EditorPane.test.tsx`
|
||
- Replace: `app/src/viewer/ViewerApp.tsx`
|
||
|
||
**Interfaces:** Consumes everything above. Produces the running window.
|
||
|
||
```ts
|
||
// useViewerPolling.ts
|
||
export function useViewerPolling(intervalMs: number, tick: () => Promise<void>, enabled: boolean): void;
|
||
```
|
||
|
||
- [ ] **Step 1: Write the failing polling-hook test** (`useViewerPolling.test.ts`):
|
||
|
||
```ts
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import { renderHook } from "@testing-library/react";
|
||
import { useViewerPolling } from "./useViewerPolling";
|
||
|
||
describe("useViewerPolling", () => {
|
||
beforeEach(() => vi.useFakeTimers());
|
||
afterEach(() => vi.useRealTimers());
|
||
|
||
const setVisibility = (state: DocumentVisibilityState) => {
|
||
Object.defineProperty(document, "visibilityState", { value: state, configurable: true });
|
||
document.dispatchEvent(new Event("visibilitychange"));
|
||
};
|
||
|
||
it("ticks on the interval only while visible, and once immediately on becoming visible", async () => {
|
||
setVisibility("visible");
|
||
const tick = vi.fn(async () => {});
|
||
renderHook(() => useViewerPolling(2000, tick, true));
|
||
expect(tick).toHaveBeenCalledTimes(1); // initial
|
||
await vi.advanceTimersByTimeAsync(4000);
|
||
expect(tick).toHaveBeenCalledTimes(3);
|
||
setVisibility("hidden");
|
||
await vi.advanceTimersByTimeAsync(6000);
|
||
expect(tick).toHaveBeenCalledTimes(3);
|
||
setVisibility("visible");
|
||
expect(tick).toHaveBeenCalledTimes(4);
|
||
});
|
||
|
||
it("does not overlap ticks and stops when disabled", async () => {
|
||
setVisibility("visible");
|
||
let resolve: () => void = () => {};
|
||
const tick = vi.fn(() => new Promise<void>((r) => { resolve = r; }));
|
||
const { rerender } = renderHook(({ on }) => useViewerPolling(1000, tick, on), { initialProps: { on: true } });
|
||
await vi.advanceTimersByTimeAsync(3000);
|
||
expect(tick).toHaveBeenCalledTimes(1);
|
||
resolve();
|
||
await vi.advanceTimersByTimeAsync(1000);
|
||
expect(tick).toHaveBeenCalledTimes(2);
|
||
rerender({ on: false });
|
||
resolve();
|
||
await vi.advanceTimersByTimeAsync(5000);
|
||
expect(tick).toHaveBeenCalledTimes(2);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Implement `useViewerPolling.ts`**
|
||
|
||
```ts
|
||
import { useEffect, useRef } from "react";
|
||
|
||
/** A visibility-gated interval that never overlaps its own ticks (spec §5). */
|
||
export function useViewerPolling(intervalMs: number, tick: () => Promise<void>, enabled: boolean): void {
|
||
const tickRef = useRef(tick);
|
||
tickRef.current = tick;
|
||
|
||
useEffect(() => {
|
||
if (!enabled) return;
|
||
let disposed = false;
|
||
let inFlight = false;
|
||
let timer: ReturnType<typeof setInterval> | null = null;
|
||
|
||
const run = async () => {
|
||
if (disposed || inFlight || document.visibilityState !== "visible") return;
|
||
inFlight = true;
|
||
try { await tickRef.current(); } finally { inFlight = false; }
|
||
};
|
||
const start = () => { if (timer === null) timer = setInterval(run, intervalMs); };
|
||
const stop = () => { if (timer !== null) { clearInterval(timer); timer = null; } };
|
||
const onVisibility = () => {
|
||
if (document.visibilityState === "visible") { void run(); start(); } else { stop(); }
|
||
};
|
||
|
||
document.addEventListener("visibilitychange", onVisibility);
|
||
onVisibility();
|
||
return () => {
|
||
disposed = true;
|
||
stop();
|
||
document.removeEventListener("visibilitychange", onVisibility);
|
||
};
|
||
}, [intervalMs, enabled]);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Write the failing `EditorPane` test** (`EditorPane.test.tsx`) — mocks the command module and `@tauri-apps/api/window`, and drives the banner through a poll:
|
||
|
||
```tsx
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import { act, render, screen, fireEvent } from "@testing-library/react";
|
||
import EditorPane from "./EditorPane";
|
||
import type { ViewerState } from "../lib/types";
|
||
|
||
const H1 = "1".repeat(64);
|
||
const H2 = "2".repeat(64);
|
||
const b64 = (s: string) => btoa(s);
|
||
|
||
const commands = vi.hoisted(() => ({
|
||
viewerReadFile: vi.fn(),
|
||
viewerPollFile: vi.fn(),
|
||
viewerWriteFile: vi.fn(),
|
||
}));
|
||
vi.mock("../lib/tauri-commands", () => commands);
|
||
|
||
const windowApi = vi.hoisted(() => ({ closeRequested: null as null | ((e: { preventDefault(): void }) => Promise<void> | void), destroy: vi.fn(), listeners: new Map<string, (e: { payload: unknown }) => void>() }));
|
||
vi.mock("@tauri-apps/api/window", () => ({
|
||
getCurrentWindow: () => ({
|
||
onCloseRequested: async (cb: typeof windowApi.closeRequested) => { windowApi.closeRequested = cb; return () => {}; },
|
||
listen: async (name: string, cb: (e: { payload: unknown }) => void) => { windowApi.listeners.set(name, cb); return () => {}; },
|
||
destroy: windowApi.destroy,
|
||
}),
|
||
}));
|
||
|
||
const state: ViewerState = {
|
||
project_id: "p", project_name: "Demo", raw_path: "notes.md",
|
||
state: { kind: "resolved", container_path: "/workspace/demo/notes.md" },
|
||
initial: { line: 1, col: null, end_line: null },
|
||
};
|
||
|
||
describe("EditorPane", () => {
|
||
beforeEach(() => {
|
||
vi.useFakeTimers();
|
||
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
|
||
commands.viewerReadFile.mockResolvedValue({ contents_base64: b64("hello\n"), truncated: false, size: 6, hash: H1, editable: true, readonly_reason: null });
|
||
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H1, size: 6 });
|
||
commands.viewerWriteFile.mockResolvedValue(H2);
|
||
windowApi.destroy.mockReset();
|
||
});
|
||
afterEach(() => vi.useRealTimers());
|
||
|
||
it("loads the file and shows the path", async () => {
|
||
render(<EditorPane state={state} />);
|
||
expect(await screen.findByText("/workspace/demo/notes.md")).toBeInTheDocument();
|
||
expect(commands.viewerReadFile).toHaveBeenCalledWith(1024 * 1024);
|
||
});
|
||
|
||
it("a changed poll on a clean document reloads silently", async () => {
|
||
render(<EditorPane state={state} />);
|
||
await screen.findByText("/workspace/demo/notes.md");
|
||
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H2, size: 8 });
|
||
commands.viewerReadFile.mockResolvedValue({ contents_base64: b64("changed\n"), truncated: false, size: 8, hash: H2, editable: true, readonly_reason: null });
|
||
await act(async () => { await vi.advanceTimersByTimeAsync(2100); });
|
||
expect(await screen.findByText(/Reloaded/)).toBeInTheDocument();
|
||
expect(screen.queryByText(/Changed on disk/)).toBeNull();
|
||
});
|
||
|
||
it("a gone file shows the banner and disables Save", async () => {
|
||
render(<EditorPane state={state} />);
|
||
await screen.findByText("/workspace/demo/notes.md");
|
||
commands.viewerPollFile.mockResolvedValue({ exists: false, hash: null, size: null });
|
||
await act(async () => { await vi.advanceTimersByTimeAsync(2100); });
|
||
expect(await screen.findByText(/no longer exists/)).toBeInTheDocument();
|
||
expect(screen.getByRole("button", { name: /save/i })).toBeDisabled();
|
||
});
|
||
|
||
it("a save conflict shows the Changed on disk banner with both choices", async () => {
|
||
commands.viewerWriteFile.mockRejectedValue(new Error("conflict: the file changed on disk since it was loaded."));
|
||
render(<EditorPane state={state} />);
|
||
await screen.findByText("/workspace/demo/notes.md");
|
||
// Simulate an edit through the pane's test hook.
|
||
fireEvent(document, new CustomEvent("triple-c-test-edit"));
|
||
await act(async () => { fireEvent.click(screen.getByRole("button", { name: /save/i })); });
|
||
expect(await screen.findByText(/Changed on disk/)).toBeInTheDocument();
|
||
expect(screen.getByRole("button", { name: /Reload/ })).toBeInTheDocument();
|
||
expect(screen.getByRole("button", { name: /Overwrite on save/ })).toBeInTheDocument();
|
||
});
|
||
|
||
it("closing with unsaved edits is intercepted", async () => {
|
||
render(<EditorPane state={state} />);
|
||
await screen.findByText("/workspace/demo/notes.md");
|
||
fireEvent(document, new CustomEvent("triple-c-test-edit"));
|
||
const prevent = vi.fn();
|
||
await act(async () => { await windowApi.closeRequested?.({ preventDefault: prevent }); });
|
||
expect(prevent).toHaveBeenCalled();
|
||
expect(await screen.findByText(/Unsaved changes/)).toBeInTheDocument();
|
||
await act(async () => { fireEvent.click(screen.getByRole("button", { name: /Discard/ })); });
|
||
expect(windowApi.destroy).toHaveBeenCalled();
|
||
});
|
||
});
|
||
```
|
||
|
||
`triple-c-test-edit` is a `document` event the pane listens to **only** under `import.meta.env.MODE === "test"` (`if (import.meta.env.MODE === "test") document.addEventListener("triple-c-test-edit", () => dispatch({ type: "edited" }))`); jsdom has no layout, so driving CodeMirror's contenteditable in a test is not reliable.
|
||
|
||
- [ ] **Step 4: Implement `EditorPane.tsx`**
|
||
|
||
```tsx
|
||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||
import type { Extension } from "@codemirror/state";
|
||
import Button from "../components/ui/Button";
|
||
import { decodeBase64, imageMimeFor, previewLimit } from "../components/projects/home/filePreview";
|
||
import { viewerPollFile, viewerReadFile, viewerWriteFile } from "../lib/tauri-commands";
|
||
import type { ViewerLocation, ViewerState } from "../lib/types";
|
||
import { CodeEditor, type CodeEditorHandle } from "./CodeEditor";
|
||
import { classifyViewerFile, type Editability } from "./editability";
|
||
import { languageFor, wrapsLines } from "./languages";
|
||
import { useViewerPolling } from "./useViewerPolling";
|
||
import { canSave, initialViewerState, pollEffect, reduceViewer } from "./viewerState";
|
||
|
||
const POLL_MS = 2000;
|
||
export const GOTO_EVENT = "file-viewer-goto";
|
||
|
||
type Loaded =
|
||
| { kind: "loading" }
|
||
| { kind: "error"; message: string }
|
||
| { kind: "text"; doc: string; editability: Editability; language: Extension | null }
|
||
| { kind: "image"; url: string; editability: Editability }
|
||
| { kind: "binary"; editability: Editability };
|
||
|
||
export default function EditorPane({ state }: { state: ViewerState }) {
|
||
const path = state.state.kind === "resolved" ? state.state.container_path : "";
|
||
const [loaded, setLoaded] = useState<Loaded>({ kind: "loading" });
|
||
const [doc, dispatch] = useReducer(reduceViewer, initialViewerState);
|
||
const [closing, setClosing] = useState(false);
|
||
const [saveError, setSaveError] = useState<string | null>(null);
|
||
const editor = useRef<CodeEditorHandle>(null);
|
||
const docRef = useRef(doc);
|
||
docRef.current = doc;
|
||
|
||
const load = useCallback(async (): Promise<void> => {
|
||
try {
|
||
const file = await viewerReadFile(previewLimit(path));
|
||
const bytes = decodeBase64(file.contents_base64);
|
||
const editability = classifyViewerFile(path, file, bytes);
|
||
dispatch({ type: "loaded", hash: file.hash, truncated: file.truncated });
|
||
if (editability.kind === "image") {
|
||
const url = URL.createObjectURL(new Blob([bytes], { type: imageMimeFor(path) ?? "image/png" }));
|
||
setLoaded((prev) => { if (prev.kind === "image") URL.revokeObjectURL(prev.url); return { kind: "image", url, editability }; });
|
||
} else if (editability.kind === "binary") {
|
||
setLoaded({ kind: "binary", editability });
|
||
} else {
|
||
const text = new TextDecoder().decode(bytes);
|
||
const language = await languageFor(path);
|
||
setLoaded({ kind: "text", doc: text, editability, language });
|
||
editor.current?.setDoc(text);
|
||
}
|
||
} catch (e) {
|
||
setLoaded({ kind: "error", message: e instanceof Error ? e.message : String(e) });
|
||
}
|
||
}, [path]);
|
||
|
||
useEffect(() => { void load(); }, [load]);
|
||
|
||
// Poll (spec §5). A reload replaces the document only when the reducer says so.
|
||
useViewerPolling(POLL_MS, async () => {
|
||
let poll;
|
||
try { poll = await viewerPollFile(); } catch { dispatch({ type: "poll_failed" }); return; }
|
||
const before = docRef.current;
|
||
const after = reduceViewer(before, { type: "polled", poll });
|
||
dispatch({ type: "polled", poll });
|
||
if (pollEffect(before, after) === "reload") {
|
||
try {
|
||
const file = await viewerReadFile(previewLimit(path));
|
||
const text = new TextDecoder().decode(decodeBase64(file.contents_base64));
|
||
editor.current?.setDoc(text);
|
||
dispatch({ type: "reloaded", hash: file.hash });
|
||
} catch { dispatch({ type: "poll_failed" }); }
|
||
}
|
||
}, loaded.kind === "text" || loaded.kind === "image" || loaded.kind === "binary");
|
||
|
||
const editable = loaded.kind === "text" && loaded.editability.editable;
|
||
const saveEnabled = canSave(doc, editable);
|
||
|
||
const save = useCallback(async () => {
|
||
if (!saveEnabled || !editor.current || !docRef.current.baseHash) return;
|
||
setSaveError(null);
|
||
const text = editor.current.getDoc();
|
||
const b64 = btoa(String.fromCharCode(...new TextEncoder().encode(text)));
|
||
try {
|
||
const hash = await viewerWriteFile(b64, docRef.current.baseHash);
|
||
dispatch({ type: "saved", hash });
|
||
if (closing) await getCurrentWindow().destroy();
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
if (msg.startsWith("conflict:")) dispatch({ type: "save_conflict" });
|
||
else if (msg.startsWith("gone:")) dispatch({ type: "save_gone" });
|
||
else setSaveError(msg);
|
||
}
|
||
}, [saveEnabled, closing]);
|
||
|
||
// Ctrl/Cmd+S outside the editor; the editor's keymap covers inside.
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { e.preventDefault(); void save(); } };
|
||
document.addEventListener("keydown", onKey);
|
||
return () => document.removeEventListener("keydown", onKey);
|
||
}, [save]);
|
||
|
||
// Close guard + goto (spec §3/§5).
|
||
useEffect(() => {
|
||
const win = getCurrentWindow();
|
||
const unlisten: Array<() => void> = [];
|
||
void win.onCloseRequested((event) => {
|
||
if (docRef.current.doc === "dirty") { event.preventDefault(); setClosing(true); }
|
||
}).then((u) => unlisten.push(u));
|
||
void win.listen<ViewerLocation>(GOTO_EVENT, (e) => editor.current?.goTo(e.payload)).then((u) => unlisten.push(u));
|
||
return () => unlisten.forEach((u) => u());
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (import.meta.env.MODE !== "test") return;
|
||
const onEdit = () => dispatch({ type: "edited" });
|
||
document.addEventListener("triple-c-test-edit", onEdit);
|
||
return () => document.removeEventListener("triple-c-test-edit", onEdit);
|
||
}, []);
|
||
|
||
const reloadDiscarding = useCallback(async () => {
|
||
try {
|
||
const file = await viewerReadFile(previewLimit(path));
|
||
editor.current?.setDoc(new TextDecoder().decode(decodeBase64(file.contents_base64)));
|
||
dispatch({ type: "reloaded", hash: file.hash });
|
||
} catch (e) { setSaveError(e instanceof Error ? e.message : String(e)); }
|
||
}, [path]);
|
||
|
||
const badge = useMemo(() => {
|
||
if (doc.containerDown) return "Container not running";
|
||
if (doc.disk === "gone") return "File no longer exists";
|
||
if (loaded.kind === "text" && !loaded.editability.editable) return `Read-only — ${loaded.editability.reason}`;
|
||
if (doc.disk === "changed") return "Changed on disk";
|
||
if (doc.doc === "dirty") return "Unsaved";
|
||
if (doc.justReloaded) return "Reloaded";
|
||
return "Saved";
|
||
}, [doc, loaded]);
|
||
|
||
return (
|
||
<div className="flex h-screen flex-col bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
||
<header className="flex items-center gap-3 border-b border-[var(--border-color)] bg-[var(--bg-secondary)] px-3 py-2 text-xs">
|
||
<span className="truncate font-mono" title={path}>{path}</span>
|
||
<span className="text-[var(--text-secondary)]">{state.project_name}</span>
|
||
<span className="ml-auto rounded-[var(--radius-control)] border border-[var(--border-color)] px-2 py-0.5" aria-live="polite">{badge}</span>
|
||
<Button variant="primary" size="sm" onClick={() => void save()} disabled={!saveEnabled}>Save</Button>
|
||
</header>
|
||
|
||
{doc.containerDown && <Banner text="Container not running — the file cannot be read or saved until the project starts again." />}
|
||
{doc.disk === "gone" && <Banner text="This file no longer exists in the container. Your text is kept so you can copy it; saving is disabled." />}
|
||
{doc.disk === "changed" && doc.doc === "dirty" && (
|
||
<Banner text="Changed on disk while you were editing.">
|
||
<Button size="sm" onClick={() => void reloadDiscarding()}>Reload (discard mine)</Button>
|
||
<Button size="sm" onClick={() => dispatch({ type: "overwrite_on_save" })}>Overwrite on save</Button>
|
||
</Banner>
|
||
)}
|
||
{saveError && <Banner text={saveError} />}
|
||
{closing && (
|
||
<Banner text="Unsaved changes — save before closing?">
|
||
<Button variant="primary" size="sm" onClick={() => void save()} disabled={!saveEnabled}>Save</Button>
|
||
<Button variant="danger" size="sm" onClick={() => void getCurrentWindow().destroy()}>Discard</Button>
|
||
<Button size="sm" onClick={() => setClosing(false)}>Cancel</Button>
|
||
</Banner>
|
||
)}
|
||
|
||
<main className="min-h-0 flex-1">
|
||
{loaded.kind === "loading" && <p className="p-4 text-sm text-[var(--text-secondary)]">Loading…</p>}
|
||
{loaded.kind === "error" && <p className="p-4 text-sm">{loaded.message}</p>}
|
||
{loaded.kind === "binary" && <p className="p-4 text-sm">{loaded.editability.reason}</p>}
|
||
{loaded.kind === "image" && <img src={loaded.url} alt={path} className="max-h-full max-w-full object-contain p-4" />}
|
||
{loaded.kind === "text" && (
|
||
<CodeEditor
|
||
ref={editor}
|
||
initialDoc={loaded.doc}
|
||
readOnly={!loaded.editability.editable}
|
||
language={loaded.language}
|
||
lineWrapping={wrapsLines(path)}
|
||
initialLocation={state.initial}
|
||
onDocChanged={() => dispatch({ type: "edited" })}
|
||
onSave={() => void save()}
|
||
/>
|
||
)}
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Banner({ text, children }: { text: string; children?: React.ReactNode }) {
|
||
return (
|
||
<div role="status" className="flex flex-wrap items-center gap-2 border-b border-[var(--warning)] bg-[var(--warning-muted)] px-3 py-2 text-xs">
|
||
<span>{text}</span>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Replace `ViewerApp.tsx`**
|
||
|
||
```tsx
|
||
import { useEffect, useState } from "react";
|
||
import Button from "../components/ui/Button";
|
||
import { viewerChooseFile, viewerGetState } from "../lib/tauri-commands";
|
||
import type { ViewerState } from "../lib/types";
|
||
import EditorPane from "./EditorPane";
|
||
|
||
export default function ViewerApp() {
|
||
const [state, setState] = useState<ViewerState | { error: string } | null>(null);
|
||
|
||
useEffect(() => {
|
||
viewerGetState().then(setState, (e) => setState({ error: e instanceof Error ? e.message : String(e) }));
|
||
}, []);
|
||
|
||
if (state === null) return <p className="p-4 text-sm text-[var(--text-secondary)]">Loading…</p>;
|
||
if ("error" in state) return <p className="p-4 text-sm">{state.error}</p>;
|
||
|
||
switch (state.state.kind) {
|
||
case "resolved":
|
||
return <EditorPane state={state} />;
|
||
case "not_found":
|
||
return (
|
||
<div className="p-4 text-sm">
|
||
<p>Could not find <span className="font-mono">{state.raw_path}</span> in the container. Looked in:</p>
|
||
<ul className="mt-2 list-disc pl-6 font-mono text-xs text-[var(--text-secondary)]">
|
||
{state.state.tried.map((p) => <li key={p}>{p}</li>)}
|
||
</ul>
|
||
</div>
|
||
);
|
||
case "choose":
|
||
return (
|
||
<div className="p-4 text-sm">
|
||
<p>Several files match <span className="font-mono">{state.raw_path}</span>. Open which?</p>
|
||
<ul className="mt-2 flex flex-col gap-1">
|
||
{state.state.candidates.map((p, i) => (
|
||
<li key={p}>
|
||
<Button size="sm" onClick={() => viewerChooseFile(i).then(setState, (e) => setState({ error: String(e) }))}>
|
||
<span className="font-mono">{p}</span>
|
||
</Button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Verify** — `npx vitest run src/viewer` → pass; `npx vitest run` → whole suite passes; `npx tsc --noEmit` → 0; `npm run build` → 0.
|
||
|
||
- [ ] **Step 7: Commit** — `git add app/src/viewer && git commit -m "feat(viewer): viewer window UI with live reload, save and close guard"`
|
||
|
||
---
|
||
|
||
## Group C
|
||
|
||
### Task 12: Documentation, threat-model census, manual verification (depends on all)
|
||
|
||
**Files:**
|
||
- Modify: `CLAUDE.md` (Frontend Structure, Backend Structure, Key Conventions), `app/src-tauri/capabilities/default.json` (description)
|
||
|
||
- [ ] **Step 1: `default.json` description** — append one sentence before "On the CSP side": *"A second capability file, `file-viewer.json`, covers the `file-viewer-*` windows the terminal file viewer opens on `viewer.html`; it is the only other local-origin window, its grants are listed and justified there, and its one non-obvious grant (`core:window:allow-destroy`) exists because `onCloseRequested` cannot close a window without it. App commands stay ungated by capability in both files; the viewer's are gated by label in `commands/file_viewer_commands.rs`."*
|
||
|
||
- [ ] **Step 2: `CLAUDE.md`** — add:
|
||
- Under Frontend Structure, after the `components/terminal/TerminalView.tsx` bullet: *"**`viewer/`** — the terminal file viewer's window (second Vite entry `viewer.html` → `src/viewer/main.tsx`; CodeMirror 6). `lib/filePathLinks.ts` decides what a path is; `components/terminal/filePathLinkProvider.ts` registers it with xterm. The OSC 8 handler now runs with `allowNonHttpProtocols` on and dispatches `file:` to the viewer, so every other scheme must be refused *there*. `viewer.html` must never carry an inline `<style>` — Tauri would add a style nonce and CodeMirror's injected styles would stop applying."*
|
||
- Under Backend Structure, a `file_viewer/` bullet: *"one window per click (`file-viewer-<n>`), a managed `ViewerRegistry`, resolution by probing `/workspace/<p>` then `/workspace/<mount>/<p>` in one exec as `claude`, polling by `sha256sum`, saves staged in `/tmp` and swapped in by a `sh` script as the container user (spec §5 says why the archive API never writes to the target directory). Commands take `window: tauri::Window`, gate on the label and act on the caller's own registry entry — no viewer command accepts a path. The residual risk that any local window can call any app command is deliberate and documented; the AppManifest lockdown spec closes it."*
|
||
- Under Key Conventions: *"A new local window needs its own capability file (`capabilities/file-viewer.json` is the model), and `lib.rs`'s `on_window_event` stays guarded on `label() == \"main\"`."*
|
||
|
||
- [ ] **Step 3: Full verification** — `cd app && npx tsc --noEmit && npx vitest run && npm run build && cd src-tauri && cargo test --offline && cargo clippy --offline` → all clean.
|
||
|
||
- [ ] **Step 4: Commit** — `git add CLAUDE.md app/src-tauri/capabilities/default.json && git commit -m "docs: terminal file viewer structure and capability census"`
|
||
|
||
- [ ] **Step 5: Manual verification checklist** (needs `npx tauri dev` on a machine with a display and a running project container; not possible in the planning container):
|
||
|
||
1. In a Claude session, ask for a file listing; click `src/…` paths, `/workspace/...` absolute paths, a `path:line` and a `path:start-end`. Each opens its own window titled `<basename> — <project>`, scrolled to and highlighting the line/range.
|
||
2. Click the same path again → the existing window is focused and re-highlights; no duplicate.
|
||
3. Open 20 windows; the 21st click shows the "20 file windows are already open" toast.
|
||
4. Edit, `Ctrl+S`: file changes in the container (`cat` it in a bash tab); mode preserved (`stat -c %a`); owner is the container user.
|
||
5. While a window is open, have Claude edit the file: clean window reloads with "Reloaded"; a dirty window shows "Changed on disk" with both buttons; "Overwrite on save" then Save succeeds; "Reload (discard mine)" drops the edits.
|
||
6. Save while the file changed between polls → "Changed on disk" banner, no data written.
|
||
7. Delete the file in a bash tab → "File no longer exists", text still copyable, Save disabled.
|
||
8. Stop the project with a window open → "Container not running" banner; start it → banner clears, polling resumes.
|
||
9. Click a path under `/etc` or a `file:///etc/hosts` OSC 8 link → read-only badge with the write-roots reason.
|
||
10. Close a dirty window with the X → Save / Discard / Cancel bar; Cancel keeps it open; Discard closes.
|
||
11. Close the *main* window with viewers open → app exits, viewers close.
|
||
12. Ctrl/Cmd+Shift+I opens devtools in a viewer in dev; in a release build the CSP console shows no violations (CodeMirror styles apply).
|
||
13. A wrapped long path (narrow the terminal) underlines across the wrap and opens.
|
||
14. Image (`.png`) opens read-only; a `.bin`/binary shows "not text".
|
||
|
||
---
|
||
|
||
## Dependency summary
|
||
|
||
```
|
||
Task 0 ──┬────────────────────────────────┐
|
||
│ │
|
||
Group A (parallel): 1 2 3 4 5 6 7 │ (4, 5, 7 use Task 0's types; 1–3, 6 do not)
|
||
│ │ │ │ │ │ │ │
|
||
└──┴──┴──┼──┼──┼──┼── Task 8 (Rust commands; needs 1, 2, 3, 6)
|
||
└──┴──┼──┼── Task 9 (terminal integration; needs 0, 4, 5)
|
||
└──┼── Task 10 (editor; needs 6)
|
||
└── Task 11 (viewer app; needs 0, 7, 10; 8 to run live)
|
||
Task 12 (docs + manual) after all.
|
||
```
|
||
|
||
Parallel groups: **{0}** → **{1, 2, 3, 4, 5, 6, 7}** → **{8, 9, 10}** → **{11}** → **{12}**. Task 0 owns every shared file (`file_viewer/mod.rs`'s module list, `lib.rs`'s `mod` line, the `pub(crate)` changes in `file_commands.rs`), so Group A tasks each replace one placeholder file or create new files and never edit the same file; the one exception is Task 6, which *appends* a test to `file_viewer/mod.rs` — a clean append, no conflict with Task 0's content.
|