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>
139 KiB
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. Neverfocus:outline-none, neverdisabled:opacity-50. - Use
components/uiprimitives (Button,StatusIndicator) — do not hand-roll replacements. Status is never colour alone. - Frontend types in
lib/types.tsmust match Rust structs field-for-field (snake_case both sides). - Every
#[tauri::command]goes intogenerate_handler![]inlib.rs, one fully-qualified path per line. The testevery_command_is_registered_exactly_once(lib.rs:759) fails otherwise. - No viewer command accepts a path or a label argument. Commands take
window: tauri::Windowand gate onwindow.label(). viewer.htmlmust not contain an inline<style>element (Tauri would add a style nonce, which disables'unsafe-inline', which CodeMirror needs). CSP intauri.conf.jsonis unchanged.- No
codemirrormeta-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 throughclip_container_text. - Frontend tests mock
lib/tauri-commands, never@tauri-apps/api/core. - No
tempfilecrate. Rust tests needing a directory usestd::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 thefile-viewer-<n>window and hooksDestroyed.commands/file_viewer_commands.rs— the six IPC entry points and the label gates.
Create (frontend, app/src/):
lib/filePathLinks.ts— pure matcherfindFilePathLinks.lib/xtermLineJoin.ts— wrapped-row joining and string-index → cell mapping over anIBuffer.components/terminal/filePathLinkProvider.ts— theILinkProviderbuilt from the two above.viewer/main.tsx— entry forviewer.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 aViewerFile.viewer/languages.ts— extension → lazily loaded CodeMirror language.viewer/viewerTheme.ts—EditorView.theme+HighlightStylefrom CSS tokens.viewer/highlightLine.ts— line-range highlightStateField+setHighlighteffect.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) andcapabilities/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)onvalidate_container_path,validate_container_write_path,require_running,clip_container_text,fetch_container_file,FetchedFileand 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):
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 placeholderregistry.rs,resolve.rs,poll.rs,write.rs,window.rsin the same directory (each containing only//! Filled in by Task N.) - Modify:
app/src-tauri/src/lib.rs— addpub mod file_viewer;beside the othermodlines - Modify:
app/src-tauri/src/commands/file_commands.rs— change topub(crate):const MAX_READ_BYTES(line 49),fn validate_container_path(355),fn validate_container_write_path(397),struct FetchedFileand 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):
//! 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:
// ---- 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(addViewerFile, ViewerPoll, ViewerStateto the existingimport type { … } from "./types"):
// ---- 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) anddocker::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 +uselines only):
#[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):
//! 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 --offlineclean. -
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
#[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):
#[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):
//! 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 --offlineclean. -
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
// 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:
#[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:
#[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
//! 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
//! 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 --offlineclean. -
Step 6 (optional, Docker present here): exercise the script against a stock container — copy
WRITE_SCRIPTto the scratchpad asw.sh, then: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.txtisnew,payloadis 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
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):
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:
/**
* 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. AdjustTOKEN/isPathLikeuntil 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
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
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
/**
* 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 vianpm 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/):
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):
<!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.tsxand a placeholderViewerApp.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>,
);
// 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— addimport { fileURLToPath } from "node:url";at the top and abuildblock insidedefineConfig({ … }):
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
{
"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:
/// 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.htmlexists (ls dist/viewer.html);npx tsc --noEmit→ 0;cd src-tauri && cargo test --offline file_viewer::tests→ pass (the capability file is validated bytauri_buildatcargo checktime — 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
// 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):
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):
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):
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
/**
* 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
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
/**
* 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; sixgenerate_handler!lines after the// Filesblock under a// Terminal file viewercomment).
Interfaces: Consumes Tasks 1–3. Produces the six commands with the exact signatures in "Interfaces", plus:
// 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):
#[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
//! 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
//! 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 togenerate_handler!:
// 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_onceplus the new tests) → pass;cargo clippy --offline→ clean. Tauri's IPC arg names: JS sendsmaxBytes,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; theWebLinksAddoncomment + registration ~lines 918–953; cleanup ~line 1238) - Modify:
app/src/components/terminal/TerminalView.test.tsx(mockopenFileViewer; new tests in thecreateOsc8LinkHandlerdescribe)
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}): ILinkProviderand a third parameter oncreateOsc8LinkHandler(getHost, readState, onOpenFile?: (path: string) => void). -
Step 1: Write the failing provider test (
filePathLinkProvider.test.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
/**
* 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- Imports:
import { openFileViewer } from "../../lib/tauri-commands";(extend the existing import),import { createFilePathLinkProvider } from "./filePathLinkProvider";,import type { FilePathMatch } from "../../lib/filePathLinks";. - Add a module-level helper next to
reportOpenFailure: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", }); } createOsc8LinkHandlergains a third parameteronOpenFile?: (path: string) => voidand setsallowNonHttpProtocols: true. Inactivate, replace the body after the gate with:Inlet 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);hover, before building the card: parse the same way; return (no card) unlessfile:orhttp(s):. Forfile:the card's bold span is"Open in viewer"and the rest is the decoded path; the hint line is unchanged.- At the
new Terminal({ linkHandler: … })site, pass the third argument:where(path) => { if (projectIdRef.current) openFileViewer(projectIdRef.current, path).catch(reportViewerFailure); },projectIdRefis a newuseRef<string | undefined>(projectId)kept current withuseEffect(() => { projectIdRef.current = projectId; }, [projectId]);(the mount effect is keyed onsessionIdonly, so it cannot close overprojectId). - After
term.loadAddon(webLinksAddon);:and in the cleanup, beforeconst 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(), }, ), );term.dispose():filePathLinks.dispose();. - Update the
WebLinksAddoncomment paragraph that begins "It is also the only gate on a real bypass of the OSC 8 one" to say:allowNonHttpProtocolsis now on, soOscLinkProviderhands every target tocreateOsc8LinkHandler, which parses and refuses anything butfile:andhttp(s):itself; this branch remains the only handler for plain-text URLs.
- Imports:
-
Step 5: Add tests to
TerminalView.test.tsx— addopenFileViewer: vi.fn(async () => {}),to thevi.mock("../../lib/tauri-commands", …)factory and import it; in thecreateOsc8LinkHandlerdescribe add:
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
// 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):
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
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):
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
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 forlang-*(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.
// useViewerPolling.ts
export function useViewerPolling(intervalMs: number, tick: () => Promise<void>, enabled: boolean): void;
- Step 1: Write the failing polling-hook test (
useViewerPolling.test.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
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
EditorPanetest (EditorPane.test.tsx) — mocks the command module and@tauri-apps/api/window, and drives the banner through a poll:
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
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
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.jsondescription — append one sentence before "On the CSP side": "A second capability file,file-viewer.json, covers thefile-viewer-*windows the terminal file viewer opens onviewer.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 becauseonCloseRequestedcannot close a window without it. App commands stay ungated by capability in both files; the viewer's are gated by label incommands/file_viewer_commands.rs." -
Step 2:
CLAUDE.md— add:- Under Frontend Structure, after the
components/terminal/TerminalView.tsxbullet: "viewer/— the terminal file viewer's window (second Vite entryviewer.html→src/viewer/main.tsx; CodeMirror 6).lib/filePathLinks.tsdecides what a path is;components/terminal/filePathLinkProvider.tsregisters it with xterm. The OSC 8 handler now runs withallowNonHttpProtocolson and dispatchesfile:to the viewer, so every other scheme must be refused there.viewer.htmlmust 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 managedViewerRegistry, resolution by probing/workspace/<p>then/workspace/<mount>/<p>in one exec asclaude, polling bysha256sum, saves staged in/tmpand swapped in by ashscript as the container user (spec §5 says why the archive API never writes to the target directory). Commands takewindow: 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.jsonis the model), andlib.rs'son_window_eventstays guarded onlabel() == \"main\"."
- Under Frontend Structure, after the
-
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 devon a machine with a display and a running project container; not possible in the planning container):- In a Claude session, ask for a file listing; click
src/…paths,/workspace/...absolute paths, apath:lineand apath:start-end. Each opens its own window titled<basename> — <project>, scrolled to and highlighting the line/range. - Click the same path again → the existing window is focused and re-highlights; no duplicate.
- Open 20 windows; the 21st click shows the "20 file windows are already open" toast.
- Edit,
Ctrl+S: file changes in the container (catit in a bash tab); mode preserved (stat -c %a); owner is the container user. - 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.
- Save while the file changed between polls → "Changed on disk" banner, no data written.
- Delete the file in a bash tab → "File no longer exists", text still copyable, Save disabled.
- Stop the project with a window open → "Container not running" banner; start it → banner clears, polling resumes.
- Click a path under
/etcor afile:///etc/hostsOSC 8 link → read-only badge with the write-roots reason. - Close a dirty window with the X → Save / Discard / Cancel bar; Cancel keeps it open; Discard closes.
- Close the main window with viewers open → app exits, viewers close.
- Ctrl/Cmd+Shift+I opens devtools in a viewer in dev; in a release build the CSP console shows no violations (CodeMirror styles apply).
- A wrapped long path (narrow the terminal) underlines across the wrap and opens.
- Image (
.png) opens read-only; a.bin/binary shows "not text".
- In a Claude session, ask for a file listing; click
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.