diff --git a/app/src/components/projects/home/filePreview.test.ts b/app/src/components/projects/home/filePreview.test.ts index 3dbf0c8..284277a 100644 --- a/app/src/components/projects/home/filePreview.test.ts +++ b/app/src/components/projects/home/filePreview.test.ts @@ -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(""); + }); +}); diff --git a/app/src/components/projects/home/filePreview.ts b/app/src/components/projects/home/filePreview.ts index 831b8e5..45cf71c 100644 --- a/app/src/components/projects/home/filePreview.ts +++ b/app/src/components/projects/home/filePreview.ts @@ -96,6 +96,19 @@ export function decodeBase64(base64: string): Uint8Array { 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. diff --git a/app/src/viewer/EditorPane.test.tsx b/app/src/viewer/EditorPane.test.tsx new file mode 100644 index 0000000..1d345c8 --- /dev/null +++ b/app/src/viewer/EditorPane.test.tsx @@ -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), destroy: vi.fn(), listeners: new Map 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/app/src/viewer/EditorPane.tsx b/app/src/viewer/EditorPane.tsx new file mode 100644 index 0000000..07fff02 --- /dev/null +++ b/app/src/viewer/EditorPane.tsx @@ -0,0 +1,285 @@ +/// +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({ kind: "loading" }); + const [language, setLanguage] = useState(null); + const [doc, dispatch] = useReducer(reduceViewer, initialViewerState); + const [closing, setClosing] = useState(false); + const [saveError, setSaveError] = useState(null); + const editor = useRef(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(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(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 ( +
+
+ {path} + {state.project_name} + + {badge && } + + +
+ + {doc.containerDown && } + {doc.disk === "gone" && } + {doc.disk === "changed" && doc.doc === "dirty" && ( + + + + + )} + {saveError && } + {closing && ( + + + + + + )} + +
+ {view.kind === "loading" &&

Loading…

} + {view.kind === "error" &&

{view.message}

} + {view.kind === "binary" &&

{view.editability.reason}

} + {view.kind === "image" && {path}} + {view.kind === "text" && ( + void save()} + /> + )} +
+
+ ); +} + +function Banner({ text, children }: { text: string; children?: ReactNode }) { + return ( +
+ {text} + {children} +
+ ); +} diff --git a/app/src/viewer/ViewerApp.test.tsx b/app/src/viewer/ViewerApp.test.tsx new file mode 100644 index 0000000..2e1a3ef --- /dev/null +++ b/app/src/viewer/ViewerApp.test.tsx @@ -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 }) => ( +

editor for {state.state.kind === "resolved" ? state.state.container_path : "?"}

+ ), +})); + +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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/app/src/viewer/ViewerApp.tsx b/app/src/viewer/ViewerApp.tsx index a945f95..c73685c 100644 --- a/app/src/viewer/ViewerApp.tsx +++ b/app/src/viewer/ViewerApp.tsx @@ -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
Loading…
; + const [state, setState] = useState(null); + const [chooseError, setChooseError] = useState(null); + + useEffect(() => { + viewerGetState().then(setState, (e) => setState({ error: errorText(e) })); + }, []); + + if (state === null) return

Loading…

; + if ("error" in state) return

{state.error}

; + + const choose = (index: number) => { + setChooseError(null); + viewerChooseFile(index).then(setState, (e) => setChooseError(errorText(e))); + }; + + switch (state.state.kind) { + case "resolved": + return ; + case "not_found": + return ( +
+

Could not find {state.raw_path} in the container. Looked in:

+
    + {state.state.tried.map((p) =>
  • {p}
  • )} +
+
+ ); + case "choose": + return ( +
+

Several files match {state.raw_path}. Open which?

+
    + {state.state.candidates.map((p, i) => ( +
  • + +
  • + ))} +
+ {chooseError &&

{chooseError}

} +
+ ); + } } diff --git a/app/src/viewer/useViewerPolling.test.ts b/app/src/viewer/useViewerPolling.test.ts new file mode 100644 index 0000000..7be5dbc --- /dev/null +++ b/app/src/viewer/useViewerPolling.test.ts @@ -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((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); + }); +}); diff --git a/app/src/viewer/useViewerPolling.ts b/app/src/viewer/useViewerPolling.ts new file mode 100644 index 0000000..5a64124 --- /dev/null +++ b/app/src/viewer/useViewerPolling.ts @@ -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, enabled: boolean): void { + const tickRef = useRef(tick); + tickRef.current = tick; + + useEffect(() => { + if (!enabled) return; + let disposed = false; + let inFlight = false; + let timer: ReturnType | 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]); +}