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:
@@ -3,6 +3,7 @@ import {
|
|||||||
IMAGE_PREVIEW_LIMIT,
|
IMAGE_PREVIEW_LIMIT,
|
||||||
TEXT_PREVIEW_LIMIT,
|
TEXT_PREVIEW_LIMIT,
|
||||||
decodeBase64,
|
decodeBase64,
|
||||||
|
encodeBase64,
|
||||||
extensionOf,
|
extensionOf,
|
||||||
imageMimeFor,
|
imageMimeFor,
|
||||||
looksBinary,
|
looksBinary,
|
||||||
@@ -76,3 +77,22 @@ describe("decodeBase64 / looksBinary", () => {
|
|||||||
expect(looksBinary(bytes)).toBe(false);
|
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;
|
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,
|
* 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.
|
* and it is what `git` and `grep` use to decide the same question.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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() {
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user