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
+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();
});
});