feat(viewer): viewer window UI with live reload, save and close guard

EditorPane loads the resolved file, polls it every 2 s while visible,
reloads a clean buffer silently and shows the "Changed on disk" banner
for a dirty one, saves against the loaded hash, and intercepts closing
with unsaved edits. ViewerApp routes to the editor, the not-found list
or the choose list.

Preflight rulings carried: one reload helper that passes the truncated
flag and polled hash (P3/P14), a poll right after a save conflict so
Overwrite on save adopts the current hash (P4), a chunked base64
encoder (P5), StatusIndicator for the badge (P11), banner-only test
queries (P2), and a Range geometry stub for jsdom (P17). A save the
container user may not write is reported as read-only and keeps the
buffer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 21:22:52 -07:00
co-authored by Claude Opus 5.5
parent caaf70a66c
commit 20e60b78a1
8 changed files with 727 additions and 1 deletions
@@ -3,6 +3,7 @@ import {
IMAGE_PREVIEW_LIMIT,
TEXT_PREVIEW_LIMIT,
decodeBase64,
encodeBase64,
extensionOf,
imageMimeFor,
looksBinary,
@@ -76,3 +77,22 @@ describe("decodeBase64 / looksBinary", () => {
expect(looksBinary(bytes)).toBe(false);
});
});
describe("encodeBase64", () => {
it("matches btoa on a small input", () => {
expect(encodeBase64(new Uint8Array([0xff, 0xd8, 0x00, 0x41]))).toBe(btoa("\xff\xd8\x00\x41"));
});
it("round-trips 1 MiB without overflowing the call stack", () => {
// Spreading a 1 MiB array into String.fromCharCode throws RangeError in V8.
const bytes = new Uint8Array(TEXT_PREVIEW_LIMIT);
for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 31 + 7) & 0xff;
const back = decodeBase64(encodeBase64(bytes));
expect(back.length).toBe(bytes.length);
expect(back.every((b, i) => b === bytes[i])).toBe(true);
});
it("encodes an empty input as the empty string", () => {
expect(encodeBase64(new Uint8Array(0))).toBe("");
});
});
@@ -96,6 +96,19 @@ export function decodeBase64(base64: string): Uint8Array<ArrayBuffer> {
return bytes;
}
/**
* Bytes → base64. Built 32 KiB at a time: spreading a whole buffer into
* `String.fromCharCode` overflows the argument limit well below 1 MiB.
*/
export function encodeBase64(bytes: Uint8Array): string {
const CHUNK = 0x8000;
let binary = "";
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}
/**
* The classic heuristic: a NUL byte early on means this is not text. Cheap,
* and it is what `git` and `grep` use to decide the same question.
+216
View File
@@ -0,0 +1,216 @@
import { afterEach, beforeAll, 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 H3 = "3".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 },
};
const textFile = (text: string, hash: string, extra: Partial<{ truncated: boolean; editable: boolean }> = {}) => ({
contents_base64: b64(text), truncated: false, size: text.length, hash, editable: true, readonly_reason: null, ...extra,
});
/** Mark the buffer dirty through the pane's test hook (jsdom cannot drive CodeMirror's contenteditable). */
const edit = () => fireEvent(document, new CustomEvent("triple-c-test-edit"));
const clickSave = async () => { await act(async () => { fireEvent.click(screen.getByRole("button", { name: /^save$/i })); }); };
const poll = async (ms = 2100) => { await act(async () => { await vi.advanceTimersByTimeAsync(ms); }); };
describe("EditorPane", () => {
beforeAll(() => {
// P17: CodeMirror's measure pass calls Range geometry, which jsdom lacks.
const rect = () => ({ x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON() {} }) as DOMRect;
Range.prototype.getClientRects = () => ({ length: 0, item: () => null, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList;
Range.prototype.getBoundingClientRect = rect;
});
beforeEach(() => {
// Only the poll's interval is faked. Testing Library's async utilities
// settle through a real setTimeout(0), which fully faked timers freeze.
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
commands.viewerReadFile.mockReset().mockResolvedValue(textFile("hello\n", H1));
commands.viewerPollFile.mockReset().mockResolvedValue({ exists: true, hash: H1, size: 6 });
commands.viewerWriteFile.mockReset().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);
expect(await screen.findByText("Saved")).toBeInTheDocument();
});
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(textFile("changed\n", H2));
await poll();
expect(await screen.findByText(/Reloaded/)).toBeInTheDocument();
expect(screen.queryByText(/while you were editing/)).toBeNull();
expect(screen.getByTestId("code-editor")).toHaveTextContent("changed");
});
it("a changed poll on a dirty document shows the banner instead of reloading", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H2, size: 8 });
await poll();
expect(await screen.findByText(/while you were editing/)).toBeInTheDocument();
expect(commands.viewerReadFile).toHaveBeenCalledTimes(1);
});
it("a truncated file reloads once per change, not on every poll", async () => {
commands.viewerReadFile.mockResolvedValue(textFile("big", "a".repeat(64), { truncated: true }));
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
await poll(); // seeds diskHash = H1 from the poll
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H2, size: 9 });
commands.viewerReadFile.mockResolvedValue(textFile("bigger", "b".repeat(64), { truncated: true }));
await poll();
expect(commands.viewerReadFile).toHaveBeenCalledTimes(2);
await poll(2000);
await poll(2000);
expect(commands.viewerReadFile).toHaveBeenCalledTimes(2);
});
it("a gone file shows the banner and disables Save", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
expect(screen.getByRole("button", { name: /^save$/i })).toBeEnabled();
commands.viewerPollFile.mockResolvedValue({ exists: false, hash: null, size: null });
await poll();
expect(await screen.findByText(/in the container/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^save$/i })).toBeDisabled();
});
it("saves the buffer against the loaded hash", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
await clickSave();
expect(commands.viewerWriteFile).toHaveBeenCalledWith(b64("hello\n"), H1);
expect(await screen.findByText("Saved")).toBeInTheDocument();
});
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");
edit();
await clickSave();
expect(await screen.findByText(/while you were editing/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Reload/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Overwrite on save/ })).toBeInTheDocument();
});
it("after a conflict, Overwrite on save saves against the freshly polled hash", async () => {
// A string rejection, as Tauri's invoke delivers it.
commands.viewerWriteFile.mockRejectedValueOnce("conflict: the file changed on disk since it was loaded.");
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H3, size: 7 });
await clickSave();
const overwrite = await screen.findByRole("button", { name: /Overwrite on save/ });
await act(async () => { fireEvent.click(overwrite); });
commands.viewerWriteFile.mockResolvedValue(H2);
await clickSave();
expect(commands.viewerWriteFile).toHaveBeenLastCalledWith(b64("hello\n"), H3);
expect(await screen.findByText("Saved")).toBeInTheDocument();
});
it("Reload (discard mine) replaces the buffer with the disk copy", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H2, size: 8 });
commands.viewerReadFile.mockResolvedValue(textFile("theirs\n", H2));
await poll();
const reload = await screen.findByRole("button", { name: /Reload/ });
await act(async () => { fireEvent.click(reload); });
expect(screen.queryByText(/while you were editing/)).toBeNull();
expect(screen.getByTestId("code-editor")).toHaveTextContent("theirs");
expect(screen.getByRole("button", { name: /^save$/i })).toBeDisabled();
});
it("a save refused because the file is read-only says so and keeps the buffer", async () => {
commands.viewerWriteFile.mockRejectedValue("Could not save the file: The file is read-only.");
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
await clickSave();
expect(await screen.findByText(/read-only for the container user/)).toBeInTheDocument();
expect(screen.getByTestId("code-editor")).toHaveTextContent("hello");
expect(screen.getByText("Unsaved")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^save$/i })).toBeEnabled();
});
it("any other save failure is shown as it came", async () => {
commands.viewerWriteFile.mockRejectedValue("Could not save the file: disk full");
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
await clickSave();
expect(await screen.findByText("Could not save the file: disk full")).toBeInTheDocument();
});
it("a failed poll shows Container not running and disables Save", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
commands.viewerPollFile.mockRejectedValue("Container is not running.");
await poll();
expect(await screen.findByText(/until the project starts again/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^save$/i })).toBeDisabled();
});
it("closing with unsaved edits is intercepted", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
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();
});
it("closing a clean document is not intercepted", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
const prevent = vi.fn();
await act(async () => { await windowApi.closeRequested?.({ preventDefault: prevent }); });
expect(prevent).not.toHaveBeenCalled();
expect(screen.queryByText(/Unsaved changes/)).toBeNull();
});
});
+285
View File
@@ -0,0 +1,285 @@
/// <reference types="vite/client" />
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type ReactNode } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { Extension } from "@codemirror/state";
import Button from "../components/ui/Button";
import StatusIndicator, { type StatusTone } from "../components/ui/StatusIndicator";
import { decodeBase64, encodeBase64, imageMimeFor, previewLimit } from "../components/projects/home/filePreview";
import { viewerPollFile, viewerReadFile, viewerWriteFile } from "../lib/tauri-commands";
import type { ViewerFile, 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";
const READ_ONLY_SAVE =
"This file is read-only for the container user, so it was not saved. Your text is kept: change the file's permissions in the container and save again, or copy your text out.";
const NOT_UTF8 = "This file is not valid UTF-8, so it is read-only.";
type View =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "text"; doc: string; editability: Editability }
| { kind: "image"; url: string; editability: Editability }
| { kind: "binary"; editability: Editability };
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
/** The save script's refusal to replace a file the container user may not write (Task 3, I3). */
const isReadOnlyRefusal = (msg: string) => /the file is read-only|read-only for the container user/i.test(msg);
/**
* Decode text for the editor. An editable file must round-trip byte for byte,
* so invalid UTF-8 (which the lenient decoder would turn into U+FFFD, and a
* save would then write back) makes the file read-only instead.
*/
function decodeText(bytes: Uint8Array, editability: Editability): { text: string; editability: Editability } {
if (!editability.editable) return { text: new TextDecoder().decode(bytes), editability };
try {
return { text: new TextDecoder("utf-8", { fatal: true }).decode(bytes), editability };
} catch {
return { text: new TextDecoder().decode(bytes), editability: { kind: "text", editable: false, reason: NOT_UTF8 } };
}
}
export default function EditorPane({ state }: { state: ViewerState }) {
const path = state.state.kind === "resolved" ? state.state.container_path : "";
const [view, setView] = useState<View>({ kind: "loading" });
const [language, setLanguage] = useState<Extension | null>(null);
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 closingRef = useRef(closing);
closingRef.current = closing;
/** Bumped synchronously on every user edit, so async work can tell an edit happened meanwhile. */
const editGen = useRef(0);
const saving = useRef(false);
const imageUrl = useRef<string | null>(null);
const markEdited = useCallback(() => {
editGen.current += 1;
dispatch({ type: "edited" });
}, []);
/** Put a freshly read file on screen: text into the editor, or an image/binary view. */
const show = useCallback((file: ViewerFile) => {
const bytes = decodeBase64(file.contents_base64);
const classified = classifyViewerFile(path, file, bytes);
if (imageUrl.current) { URL.revokeObjectURL(imageUrl.current); imageUrl.current = null; }
if (classified.kind === "image") {
const url = URL.createObjectURL(new Blob([bytes], { type: imageMimeFor(path) ?? "application/octet-stream" }));
imageUrl.current = url;
setView({ kind: "image", url, editability: classified });
} else if (classified.kind === "binary") {
setView({ kind: "binary", editability: classified });
} else {
const { text, editability } = decodeText(bytes, classified);
setView({ kind: "text", doc: text, editability });
editor.current?.setDoc(text);
}
}, [path]);
useEffect(() => () => { if (imageUrl.current) URL.revokeObjectURL(imageUrl.current); }, []);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const file = await viewerReadFile(previewLimit(path));
if (cancelled) return;
show(file);
dispatch({ type: "loaded", hash: file.hash, truncated: file.truncated });
} catch (e) {
if (!cancelled) setView({ kind: "error", message: errorText(e) });
}
})();
return () => { cancelled = true; };
}, [path, show]);
// The language loads lazily and separately, so the text is on screen (and
// polling runs) without waiting for a grammar chunk.
useEffect(() => {
let cancelled = false;
languageFor(path).then((l) => { if (!cancelled) setLanguage(l); }, () => {});
return () => { cancelled = true; };
}, [path]);
/**
* The one reload path (P3/P14), for a clean poll-driven reload and for
* "Reload (discard mine)". `polledHash` is the poll's full-file hash, which
* a truncated read's own (prefix) hash can never equal. With `onlyIfClean`,
* an edit made while the read was in flight wins: nothing is replaced, and
* the next poll shows the banner instead.
*/
const reloadFromDisk = useCallback(async (polledHash: string | null, onlyIfClean: boolean) => {
const gen = editGen.current;
const file = await viewerReadFile(previewLimit(path));
if (onlyIfClean && editGen.current !== gen) return;
show(file);
dispatch({ type: "reloaded", hash: file.hash, truncated: file.truncated, polledHash });
}, [path, show]);
// 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 { await reloadFromDisk(after.diskHash, true); } catch { dispatch({ type: "poll_failed" }); }
}
}, view.kind === "text" || view.kind === "image" || view.kind === "binary");
const editable = view.kind === "text" && view.editability.editable;
const saveEnabled = canSave(doc, editable);
const save = useCallback(async () => {
const handle = editor.current;
const baseHash = docRef.current.baseHash;
if (!saveEnabled || !handle || !baseHash || saving.current) return;
saving.current = true;
setSaveError(null);
const gen = editGen.current;
try {
const hash = await viewerWriteFile(encodeBase64(new TextEncoder().encode(handle.getDoc())), baseHash);
dispatch({ type: "saved", hash });
if (editGen.current !== gen) dispatch({ type: "edited" }); // typed while the save was in flight
else if (closingRef.current) await getCurrentWindow().destroy();
} catch (e) {
const msg = errorText(e);
if (msg.startsWith("conflict:")) {
// The disk changed between polls. Poll now (P4), so "Overwrite on
// save" adopts the current hash rather than the stale one.
try {
const poll = await viewerPollFile();
dispatch({ type: "polled", poll });
if (poll.exists) dispatch({ type: "save_conflict" });
} catch {
dispatch({ type: "poll_failed" });
dispatch({ type: "save_conflict" });
}
} else if (msg.startsWith("gone:")) {
dispatch({ type: "save_gone" });
} else if (isReadOnlyRefusal(msg)) {
setSaveError(READ_ONLY_SAVE);
} else {
setSaveError(msg);
}
} finally {
saving.current = false;
}
}, [saveEnabled]);
// Ctrl/Cmd+S outside the editor; the editor's own keymap handles it inside
// (and prevents the default, which is how this listener knows to skip it).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || !(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== "s") return;
e.preventDefault();
void save();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [save]);
// Close guard + goto (spec §3/§5).
useEffect(() => {
const win = getCurrentWindow();
let disposed = false;
const unlisten: Array<() => void> = [];
const keep = (u: () => void) => { if (disposed) u(); else unlisten.push(u); };
void win.onCloseRequested((event) => {
if (docRef.current.doc === "dirty") { event.preventDefault(); setClosing(true); }
}).then(keep);
void win.listen<ViewerLocation>(GOTO_EVENT, (e) => editor.current?.goTo(e.payload)).then(keep);
return () => { disposed = true; unlisten.forEach((u) => u()); };
}, []);
useEffect(() => {
if (import.meta.env.MODE !== "test") return;
document.addEventListener("triple-c-test-edit", markEdited);
return () => document.removeEventListener("triple-c-test-edit", markEdited);
}, [markEdited]);
const reloadDiscarding = useCallback(async () => {
setSaveError(null);
try { await reloadFromDisk(docRef.current.diskHash, false); } catch (e) { setSaveError(errorText(e)); }
}, [reloadFromDisk]);
const badge = useMemo((): { tone: StatusTone; label: string; title?: string } | null => {
if (view.kind === "loading" || view.kind === "error") return null;
if (doc.containerDown) return { tone: "error", label: "Container not running" };
if (doc.disk === "gone") return { tone: "error", label: "File no longer exists" };
if (!view.editability.editable) return { tone: "off", label: "Read-only", title: view.editability.reason ?? undefined };
if (doc.disk === "changed") return { tone: "busy", label: "Changed on disk" };
if (doc.doc === "dirty") return { tone: "busy", label: "Unsaved" };
if (doc.justReloaded) return { tone: "ok", label: "Reloaded" };
return { tone: "ok", label: "Saved" };
}, [doc, view]);
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" aria-live="polite">
{badge && <StatusIndicator tone={badge.tone} label={badge.label} title={badge.title} />}
</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 and close</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">
{view.kind === "loading" && <p className="p-4 text-sm text-[var(--text-secondary)]">Loading</p>}
{view.kind === "error" && <p className="p-4 text-sm">{view.message}</p>}
{view.kind === "binary" && <p className="p-4 text-sm">{view.editability.reason}</p>}
{view.kind === "image" && <img src={view.url} alt={path} className="max-h-full max-w-full object-contain p-4" />}
{view.kind === "text" && (
<CodeEditor
ref={editor}
initialDoc={view.doc}
readOnly={!view.editability.editable}
language={language}
lineWrapping={wrapsLines(path)}
initialLocation={state.initial}
onDocChanged={markEdited}
onSave={() => void save()}
/>
)}
</main>
</div>
);
}
function Banner({ text, children }: { text: string; children?: 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>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { ViewerState } from "../lib/types";
import ViewerApp from "./ViewerApp";
const commands = vi.hoisted(() => ({
viewerGetState: vi.fn(),
viewerChooseFile: vi.fn(),
}));
vi.mock("../lib/tauri-commands", () => commands);
// The editor itself is covered by EditorPane.test; here only the routing matters.
vi.mock("./EditorPane", () => ({
default: ({ state }: { state: ViewerState }) => (
<p>editor for {state.state.kind === "resolved" ? state.state.container_path : "?"}</p>
),
}));
const base = { project_id: "p", project_name: "Demo", raw_path: "foo.ts", initial: { line: 3, col: null, end_line: null } };
describe("ViewerApp", () => {
beforeEach(() => {
commands.viewerGetState.mockReset();
commands.viewerChooseFile.mockReset();
});
it("opens the editor for a resolved file", async () => {
commands.viewerGetState.mockResolvedValue({ ...base, state: { kind: "resolved", container_path: "/workspace/a/foo.ts" } });
render(<ViewerApp />);
expect(await screen.findByText("editor for /workspace/a/foo.ts")).toBeInTheDocument();
});
it("lists every path it tried when the file is not found", async () => {
commands.viewerGetState.mockResolvedValue({ ...base, state: { kind: "not_found", tried: ["/workspace/a/foo.ts", "/workspace/b/foo.ts"] } });
render(<ViewerApp />);
expect(await screen.findByText(/Could not find/)).toBeInTheDocument();
expect(screen.getByText("/workspace/a/foo.ts")).toBeInTheDocument();
expect(screen.getByText("/workspace/b/foo.ts")).toBeInTheDocument();
});
it("choosing a candidate asks the backend by index and opens the result", async () => {
commands.viewerGetState.mockResolvedValue({ ...base, state: { kind: "choose", candidates: ["/workspace/a/foo.ts", "/workspace/b/foo.ts"] } });
commands.viewerChooseFile.mockResolvedValue({ ...base, state: { kind: "resolved", container_path: "/workspace/b/foo.ts" } });
render(<ViewerApp />);
const second = await screen.findByRole("button", { name: "/workspace/b/foo.ts" });
await act(async () => { fireEvent.click(second); });
expect(commands.viewerChooseFile).toHaveBeenCalledWith(1);
expect(await screen.findByText("editor for /workspace/b/foo.ts")).toBeInTheDocument();
});
it("shows a failure to load the state", async () => {
commands.viewerGetState.mockRejectedValue("This window is not a file viewer.");
render(<ViewerApp />);
expect(await screen.findByText("This window is not a file viewer.")).toBeInTheDocument();
});
it("keeps the choice list when choosing fails, and says why", async () => {
commands.viewerGetState.mockResolvedValue({ ...base, state: { kind: "choose", candidates: ["/workspace/a/foo.ts"] } });
commands.viewerChooseFile.mockRejectedValue("That choice is no longer available.");
render(<ViewerApp />);
const only = await screen.findByRole("button", { name: "/workspace/a/foo.ts" });
await act(async () => { fireEvent.click(only); });
expect(await screen.findByText("That choice is no longer available.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "/workspace/a/foo.ts" })).toBeInTheDocument();
});
});
+52 -1
View File
@@ -1,3 +1,54 @@
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";
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
export default function ViewerApp() {
return <div className="p-4 text-sm text-[var(--text-secondary)]">Loading</div>;
const [state, setState] = useState<ViewerState | { error: string } | null>(null);
const [chooseError, setChooseError] = useState<string | null>(null);
useEffect(() => {
viewerGetState().then(setState, (e) => setState({ error: errorText(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>;
const choose = (index: number) => {
setChooseError(null);
viewerChooseFile(index).then(setState, (e) => setChooseError(errorText(e)));
};
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 items-start gap-1">
{state.state.candidates.map((p, i) => (
<li key={p}>
<Button size="sm" onClick={() => choose(i)}>
<span className="font-mono">{p}</span>
</Button>
</li>
))}
</ul>
{chooseError && <p role="alert" className="mt-2">{chooseError}</p>}
</div>
);
}
}
+43
View File
@@ -0,0 +1,43 @@
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);
});
});
+33
View File
@@ -0,0 +1,33 @@
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]);
}