fix(viewer): final-review fixes — save base from written bytes, honest poll errors, retryable first read
- write.rs: a save's new base is sha256 of the bytes written; the script's post-mv hash comes back as disk_hash, and a mismatch (another writer landed after us) shows "Changed on disk" instead of being adopted (ledger M2). - write.rs: conflict:/gone:/read-only strings are constants with a pure saved_file() mapping and tests; app/src/viewer/ipcMessages.ts is the one TS copy and a cargo test checks it against the Rust originals. - write.rs: the comment now says the in-place `cat >` fallback follows a planted symlink, and why that is accepted (runs as claude). - poll.rs: a file deleted between `test -f` and `sha256sum` reads as gone. - viewerState/EditorPane: poll_failed carries its message; only the "Start the project before" refusal reads as Container not running, anything else gets its own banner and leaves Save enabled. - EditorPane: a failed first read shows Retry and is retried by the poll. - spec §1: refused OSC 8 targets keep the refusal card (Task 9 ruling). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,8 @@ const typeInto = (from: number, to: number, insert: string) => {
|
||||
const bytesB64 = (bytes: number[]) => encodeBase64(new Uint8Array(bytes));
|
||||
const utf8 = (s: string) => Array.from(new TextEncoder().encode(s));
|
||||
const READ_ONLY = "Could not save the file: The file is read-only for the container user.";
|
||||
const NOT_RUNNING = "Start the project before checking this file for changes — it runs inside the running container.";
|
||||
const saved = (hash: string, diskHash = hash) => ({ hash, disk_hash: diskHash });
|
||||
const poll = async (ms = 2100) => { await act(async () => { await vi.advanceTimersByTimeAsync(ms); }); };
|
||||
|
||||
describe("EditorPane", () => {
|
||||
@@ -65,7 +67,7 @@ describe("EditorPane", () => {
|
||||
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);
|
||||
commands.viewerWriteFile.mockReset().mockResolvedValue(saved(H2));
|
||||
windowApi.destroy.mockReset();
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
@@ -153,7 +155,7 @@ describe("EditorPane", () => {
|
||||
await clickSave();
|
||||
const overwrite = await screen.findByRole("button", { name: /Overwrite on save/ });
|
||||
await act(async () => { fireEvent.click(overwrite); });
|
||||
commands.viewerWriteFile.mockResolvedValue(H2);
|
||||
commands.viewerWriteFile.mockResolvedValue(saved(H2));
|
||||
await clickSave();
|
||||
expect(commands.viewerWriteFile).toHaveBeenLastCalledWith(b64("hello\n"), H3);
|
||||
expect(await screen.findByText("Saved")).toBeInTheDocument();
|
||||
@@ -194,16 +196,96 @@ describe("EditorPane", () => {
|
||||
expect(await screen.findByText("Could not save the file: disk full")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a failed poll shows Container not running and disables Save", async () => {
|
||||
it("a poll refused because the container is down 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.");
|
||||
commands.viewerPollFile.mockRejectedValue(NOT_RUNNING);
|
||||
await poll();
|
||||
expect(await screen.findByText(/until the project starts again/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Container not running")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^save$/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("any other poll failure says what failed, not that the container is down, and clears on a good poll", async () => {
|
||||
render(<EditorPane state={state} />);
|
||||
await screen.findByText("/workspace/demo/notes.md");
|
||||
edit();
|
||||
commands.viewerPollFile.mockRejectedValue("Could not check the file: sha256sum: Permission denied");
|
||||
await poll();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/Could not check the file: sha256sum: Permission denied/);
|
||||
expect(screen.getByText("Could not check for changes")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Container not running/)).toBeNull();
|
||||
// The write re-checks the hash itself, so saving stays possible.
|
||||
expect(screen.getByRole("button", { name: /^save$/i })).toBeEnabled();
|
||||
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H1, size: 6 });
|
||||
await poll(2000);
|
||||
expect(screen.queryByText(/Permission denied/)).toBeNull();
|
||||
expect(screen.getByText("Unsaved")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a save that another writer overtook shows Changed on disk instead of Saved", async () => {
|
||||
render(<EditorPane state={state} />);
|
||||
await screen.findByText("Saved");
|
||||
edit();
|
||||
commands.viewerWriteFile.mockResolvedValue(saved(H2, H3));
|
||||
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H3, size: 7 });
|
||||
await clickSave();
|
||||
expect(await screen.findByText(/while you were editing/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Changed on disk")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^save$/i })).toBeDisabled();
|
||||
// The next poll sees the same foreign hash: the banner stays, nothing is reloaded over the buffer.
|
||||
await poll();
|
||||
expect(screen.getByText(/while you were editing/)).toBeInTheDocument();
|
||||
expect(commands.viewerReadFile).toHaveBeenCalledTimes(1);
|
||||
// Overwrite now saves against what is actually on disk.
|
||||
await act(async () => { fireEvent.click(screen.getByRole("button", { name: /Overwrite on save/ })); });
|
||||
commands.viewerWriteFile.mockResolvedValue(saved(H2));
|
||||
await clickSave();
|
||||
expect(commands.viewerWriteFile).toHaveBeenLastCalledWith(b64("hello\n"), H3);
|
||||
expect(await screen.findByText("Saved")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Save and close does not close when another writer overtook the save", async () => {
|
||||
commands.viewerWriteFile.mockResolvedValue(saved(H2, H3));
|
||||
render(<EditorPane state={state} />);
|
||||
await screen.findByText("Saved");
|
||||
edit();
|
||||
await act(async () => { await windowApi.closeRequested?.({ preventDefault: () => {} }); });
|
||||
await act(async () => { fireEvent.click(await screen.findByRole("button", { name: "Save and close" })); });
|
||||
expect(windowApi.destroy).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText(/while you were editing/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a failed first read offers Retry, which loads the file", async () => {
|
||||
commands.viewerReadFile.mockRejectedValueOnce(NOT_RUNNING.replace("checking this file for changes", "opening files"));
|
||||
render(<EditorPane state={state} />);
|
||||
expect(await screen.findByText(/Start the project before opening files/)).toBeInTheDocument();
|
||||
const retry = screen.getByRole("button", { name: "Retry" });
|
||||
await act(async () => { fireEvent.click(retry); });
|
||||
expect(await screen.findByText("Saved")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("code-editor")).toHaveTextContent("hello");
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
|
||||
});
|
||||
|
||||
it("a failed first read is retried by the poll until it succeeds", async () => {
|
||||
commands.viewerReadFile.mockRejectedValueOnce("Docker is busy").mockRejectedValueOnce("Docker is still busy");
|
||||
render(<EditorPane state={state} />);
|
||||
expect(await screen.findByText("Docker is busy")).toBeInTheDocument();
|
||||
expect(commands.viewerReadFile).toHaveBeenCalledTimes(1);
|
||||
await poll();
|
||||
expect(await screen.findByText("Docker is still busy")).toBeInTheDocument();
|
||||
expect(commands.viewerReadFile).toHaveBeenCalledTimes(2);
|
||||
expect(commands.viewerPollFile).not.toHaveBeenCalled();
|
||||
await poll(2000);
|
||||
expect(await screen.findByText("Saved")).toBeInTheDocument();
|
||||
expect(commands.viewerReadFile).toHaveBeenCalledTimes(3);
|
||||
// Loaded: the poll is back to polling, not re-reading.
|
||||
await poll(2000);
|
||||
expect(commands.viewerPollFile).toHaveBeenCalled();
|
||||
expect(commands.viewerReadFile).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("closing with unsaved edits is intercepted", async () => {
|
||||
render(<EditorPane state={state} />);
|
||||
await screen.findByText("/workspace/demo/notes.md");
|
||||
@@ -249,7 +331,7 @@ describe("EditorPane", () => {
|
||||
render(<EditorPane state={state} />);
|
||||
await screen.findByText("Saved");
|
||||
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H2, size: 8 });
|
||||
commands.viewerReadFile.mockRejectedValueOnce("Container is not running.").mockResolvedValue(textFile("changed\n", H2));
|
||||
commands.viewerReadFile.mockRejectedValueOnce("Could not read the file: I/O error").mockResolvedValue(textFile("changed\n", H2));
|
||||
await poll();
|
||||
expect(screen.getByTestId("code-editor")).toHaveTextContent("hello");
|
||||
await poll(2000);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { decodeBase64, encodeBase64, imageMimeFor, previewLimit } from "../compo
|
||||
import { viewerPollFile, viewerReadFile, viewerWriteFile } from "../lib/tauri-commands";
|
||||
import type { ViewerFile, ViewerLocation, ViewerState } from "../lib/types";
|
||||
import { CodeEditor, type CodeEditorHandle } from "./CodeEditor";
|
||||
import { CONFLICT_PREFIX, GONE_PREFIX, READ_ONLY_MESSAGE } from "./ipcMessages";
|
||||
import { classifyViewerFile, type Editability } from "./editability";
|
||||
import { languageFor, wrapsLines } from "./languages";
|
||||
import { decodeViewerText, encodeViewerText, type TextFormat } from "./textFormat";
|
||||
@@ -34,7 +35,7 @@ type View =
|
||||
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
|
||||
/** `write.rs`'s refusal to replace a file the container user may not write. */
|
||||
const isReadOnlyRefusal = (msg: string) => msg.includes("The file is read-only for the container user.");
|
||||
const isReadOnlyRefusal = (msg: string) => msg.includes(READ_ONLY_MESSAGE);
|
||||
|
||||
export default function EditorPane({ state }: { state: ViewerState }) {
|
||||
const path = state.state.kind === "resolved" ? state.state.container_path : "";
|
||||
@@ -83,21 +84,36 @@ export default function EditorPane({ state }: { state: ViewerState }) {
|
||||
|
||||
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; };
|
||||
/** Bumped per initial-load attempt (and on unmount/path change); a stale attempt's result is dropped. */
|
||||
const loadGen = useRef(0);
|
||||
|
||||
/**
|
||||
* The initial read. Re-run by "Retry" and by the poll while the window shows
|
||||
* a load error, so a window opened while the container was restarting
|
||||
* recovers on its own instead of staying dead.
|
||||
*/
|
||||
const load = useCallback(async () => {
|
||||
const gen = ++loadGen.current;
|
||||
try {
|
||||
const file = await viewerReadFile(previewLimit(path));
|
||||
if (loadGen.current !== gen) return;
|
||||
show(file);
|
||||
dispatch({ type: "loaded", hash: file.hash, truncated: file.truncated });
|
||||
} catch (e) {
|
||||
if (loadGen.current === gen) setView({ kind: "error", message: errorText(e) });
|
||||
}
|
||||
}, [path, show]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
return () => { loadGen.current += 1; };
|
||||
}, [load]);
|
||||
|
||||
const retryLoad = useCallback(() => {
|
||||
setView({ kind: "loading" });
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// The language loads lazily and separately, so the text is on screen (and
|
||||
// polling runs) without waiting for a grammar chunk.
|
||||
useEffect(() => {
|
||||
@@ -122,20 +138,29 @@ export default function EditorPane({ state }: { state: ViewerState }) {
|
||||
}, [path, show]);
|
||||
|
||||
// Poll (spec §5). A reload replaces the document only when the reducer says so.
|
||||
// While the first read has failed, each tick retries that read instead.
|
||||
// Always enabled, so a loading -> error flip does not fire an immediate extra read.
|
||||
useViewerPolling(POLL_MS, async () => {
|
||||
if (view.kind === "loading") return;
|
||||
if (view.kind === "error") { await load(); return; }
|
||||
// A poll that overlaps a save can carry the pre-save hash; skip it (M2).
|
||||
if (saving.current) return;
|
||||
const gen = saveGen.current;
|
||||
let poll;
|
||||
try { poll = await viewerPollFile(); } catch { if (saveGen.current === gen) dispatch({ type: "poll_failed" }); return; }
|
||||
try {
|
||||
poll = await viewerPollFile();
|
||||
} catch (e) {
|
||||
if (saveGen.current === gen) dispatch({ type: "poll_failed", message: errorText(e) });
|
||||
return;
|
||||
}
|
||||
if (saveGen.current !== gen) 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" }); }
|
||||
try { await reloadFromDisk(after.diskHash, true); } catch (e) { dispatch({ type: "poll_failed", message: errorText(e) }); }
|
||||
}
|
||||
}, view.kind === "text" || view.kind === "image" || view.kind === "binary");
|
||||
}, true);
|
||||
|
||||
const editable = view.kind === "text" && view.editability.editable;
|
||||
const saveEnabled = canSave(doc, editable);
|
||||
@@ -150,17 +175,20 @@ export default function EditorPane({ state }: { state: ViewerState }) {
|
||||
try {
|
||||
const bytes = encodeViewerText(handle.getDoc(), textFormat.current);
|
||||
const result = await viewerWriteFile(encodeBase64(bytes), baseHash).then(
|
||||
(hash) => ({ ok: true as const, hash }),
|
||||
(saved) => ({ ok: true as const, saved }),
|
||||
(e: unknown) => ({ ok: false as const, msg: errorText(e) }),
|
||||
);
|
||||
saveGen.current += 1;
|
||||
if (result.ok) {
|
||||
dispatch({ type: "saved", hash: result.hash });
|
||||
const { hash, disk_hash: diskHash } = result.saved;
|
||||
dispatch({ type: "saved", hash, diskHash });
|
||||
if (editGen.current !== gen) dispatch({ type: "edited" }); // typed while the save was in flight
|
||||
else if (closingRef.current) await getCurrentWindow().destroy();
|
||||
} else if (result.msg.startsWith("conflict:")) {
|
||||
// Another writer landed right after ours: the reducer shows "Changed on
|
||||
// disk", and the window stays open so the user can decide.
|
||||
else if (closingRef.current && diskHash === hash) await getCurrentWindow().destroy();
|
||||
} else if (result.msg.startsWith(CONFLICT_PREFIX)) {
|
||||
await adoptConflict();
|
||||
} else if (result.msg.startsWith("gone:")) {
|
||||
} else if (result.msg.startsWith(GONE_PREFIX)) {
|
||||
dispatch({ type: "save_gone" });
|
||||
} else if (isReadOnlyRefusal(result.msg)) {
|
||||
setSaveError({ text: READ_ONLY_SAVE });
|
||||
@@ -181,8 +209,8 @@ export default function EditorPane({ state }: { state: ViewerState }) {
|
||||
let poll;
|
||||
try {
|
||||
poll = await viewerPollFile();
|
||||
} catch {
|
||||
dispatch({ type: "poll_failed" });
|
||||
} catch (e) {
|
||||
dispatch({ type: "poll_failed", message: errorText(e) });
|
||||
dispatch({ type: "save_conflict" });
|
||||
return;
|
||||
}
|
||||
@@ -230,6 +258,7 @@ export default function EditorPane({ state }: { state: ViewerState }) {
|
||||
const badge = useMemo((): { tone: StatusTone; label: string; detail?: string } | null => {
|
||||
if (view.kind === "loading" || view.kind === "error") return null;
|
||||
if (doc.containerDown) return { tone: "error", label: "Container not running" };
|
||||
if (doc.pollError) return { tone: "error", label: "Could not check for changes" };
|
||||
if (doc.disk === "gone") return { tone: "error", label: "File no longer exists" };
|
||||
if (!view.editability.editable) return { tone: "off", label: "Read-only", detail: view.editability.reason ?? undefined };
|
||||
if (doc.disk === "changed") return { tone: "busy", label: "Changed on disk" };
|
||||
@@ -251,6 +280,7 @@ export default function EditorPane({ state }: { state: ViewerState }) {
|
||||
</header>
|
||||
|
||||
{doc.containerDown && <Banner tone="error" text="Container not running — the file cannot be read or saved until the project starts again." />}
|
||||
{doc.pollError && <Banner tone="error" text={`${doc.pollError} — changes on disk go undetected until this clears; the viewer keeps trying.`} />}
|
||||
{doc.disk === "gone" && <Banner tone="error" 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.">
|
||||
@@ -273,7 +303,13 @@ export default function EditorPane({ state }: { state: ViewerState }) {
|
||||
|
||||
<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 === "error" && (
|
||||
<div className="flex flex-col items-start gap-2 p-4 text-sm">
|
||||
<p>{view.message}</p>
|
||||
<p className="text-[var(--text-secondary)]">The viewer retries every few seconds.</p>
|
||||
<Button size="sm" onClick={retryLoad}>Retry</Button>
|
||||
</div>
|
||||
)}
|
||||
{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" && (
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The error strings the Rust side of the viewer produces and this side matches
|
||||
* on. This file is the one TypeScript copy; the Rust originals are
|
||||
*
|
||||
* - `CONFLICT_PREFIX`, `GONE_PREFIX`, `READ_ONLY_MESSAGE` in
|
||||
* `src-tauri/src/file_viewer/write.rs` (`viewer_write_file` errors), and
|
||||
* - `NOT_RUNNING_PREFIX` in `src-tauri/src/commands/file_commands.rs`
|
||||
* (`require_running` and the viewer's "no container" refusal).
|
||||
*
|
||||
* `write.rs`'s test `the_frontend_copies_of_the_ipc_messages_match` reads this
|
||||
* file and fails if any literal here drifts from its Rust original.
|
||||
*/
|
||||
|
||||
/** A save refused because the file changed on disk since its base hash. */
|
||||
export const CONFLICT_PREFIX = "conflict:";
|
||||
/** A save refused because the file no longer exists. */
|
||||
export const GONE_PREFIX = "gone:";
|
||||
/** A save refused because the container user may not write the file. */
|
||||
export const READ_ONLY_MESSAGE = "The file is read-only for the container user.";
|
||||
/** Any command refused because the project's container is not running. */
|
||||
export const NOT_RUNNING_PREFIX = "Start the project before";
|
||||
@@ -62,10 +62,22 @@ describe("reduceViewer", () => {
|
||||
});
|
||||
it("a save clears dirty and aligns hashes; a conflict marks disk changed", () => {
|
||||
const s = reduceViewer(loaded(), { type: "edited" });
|
||||
expect(reduceViewer(s, { type: "saved", hash: H2 })).toMatchObject({ doc: "clean", disk: "same", baseHash: H2, diskHash: H2, overwrite: false });
|
||||
expect(reduceViewer(s, { type: "saved", hash: H2, diskHash: H2 })).toMatchObject({ doc: "clean", disk: "same", baseHash: H2, diskHash: H2, overwrite: false });
|
||||
expect(reduceViewer(s, { type: "save_conflict" })).toMatchObject({ doc: "dirty", disk: "changed" });
|
||||
expect(reduceViewer(s, { type: "save_gone" })).toMatchObject({ disk: "gone" });
|
||||
});
|
||||
it("a save another writer overtook keeps our base but shows Changed on disk (M2)", () => {
|
||||
const s = reduceViewer(loaded(), { type: "edited" });
|
||||
const raced = reduceViewer(s, { type: "saved", hash: H2, diskHash: H3 });
|
||||
expect(raced).toMatchObject({ doc: "dirty", disk: "changed", baseHash: H2, diskHash: H3, overwrite: false });
|
||||
expect(canSave(raced, true)).toBe(false);
|
||||
// The next poll reporting that same foreign hash is quiet: the banner stays up.
|
||||
const next = poll(raced, H3);
|
||||
expect(next).toMatchObject({ disk: "changed", doc: "dirty" });
|
||||
expect(pollEffect(raced, next)).toBe("none");
|
||||
// Overwrite adopts what is on disk, not our own hash.
|
||||
expect(reduceViewer(next, { type: "overwrite_on_save" })).toMatchObject({ baseHash: H3, disk: "same" });
|
||||
});
|
||||
it("a gone file disables saving but keeps the buffer state", () => {
|
||||
const s = reduceViewer(loaded(), { type: "edited" });
|
||||
const gone = poll(s, null, false);
|
||||
@@ -73,16 +85,33 @@ describe("reduceViewer", () => {
|
||||
expect(canSave(gone, true)).toBe(false);
|
||||
expect(pollEffect(s, gone)).toBe("banner");
|
||||
});
|
||||
it("a failed poll flags the container down and a good one clears it", () => {
|
||||
it("a poll refused as not running flags the container down and a good one clears it", () => {
|
||||
// Regression: start from a dirty doc, not a clean one -- otherwise
|
||||
// canSave(down, true) is false purely because doc !== "dirty", and the
|
||||
// assertion never actually exercises containerDown.
|
||||
const dirty = reduceViewer(loaded(), { type: "edited" });
|
||||
const down = reduceViewer(dirty, { type: "poll_failed" });
|
||||
expect(down.containerDown).toBe(true);
|
||||
const down = reduceViewer(dirty, {
|
||||
type: "poll_failed",
|
||||
message: "Start the project before checking this file for changes — it runs inside the running container.",
|
||||
});
|
||||
expect(down).toMatchObject({ containerDown: true, pollError: null });
|
||||
expect(canSave(down, true)).toBe(false);
|
||||
expect(poll(down, H1).containerDown).toBe(false);
|
||||
});
|
||||
it("any other poll failure is kept as its own message, does not claim the container is down, and clears on a good poll", () => {
|
||||
const dirty = reduceViewer(loaded(), { type: "edited" });
|
||||
const down = reduceViewer(dirty, { type: "poll_failed", message: "Start the project before checking this file for changes — files live in its container." });
|
||||
const failed = reduceViewer(down, { type: "poll_failed", message: "Could not check the file: Permission denied" });
|
||||
expect(failed).toMatchObject({ containerDown: false, pollError: "Could not check the file: Permission denied" });
|
||||
expect(canSave(failed, true)).toBe(true);
|
||||
expect(poll(failed, H1)).toMatchObject({ pollError: null, containerDown: false });
|
||||
expect(poll(failed, null, false)).toMatchObject({ pollError: null, disk: "gone" });
|
||||
});
|
||||
it("a hash-less poll and a gone file reappearing both clear the flags; the reappeared file is same", () => {
|
||||
const gone = poll(loaded(), null, false);
|
||||
expect(poll(gone, H1)).toMatchObject({ disk: "same" });
|
||||
expect(poll(gone, null)).toMatchObject({ disk: "gone", containerDown: false, pollError: null });
|
||||
});
|
||||
it("canSave needs dirty + editable + disk in sync", () => {
|
||||
expect(canSave(loaded(), true)).toBe(false);
|
||||
const dirty = reduceViewer(loaded(), { type: "edited" });
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* would re-download on every poll tick forever (spec Decision 2).
|
||||
*/
|
||||
import type { ViewerPoll } from "../lib/types";
|
||||
import { NOT_RUNNING_PREFIX } from "./ipcMessages";
|
||||
|
||||
export type DocStatus = "clean" | "dirty";
|
||||
export type DiskStatus = "same" | "changed" | "gone";
|
||||
@@ -25,7 +26,14 @@ export interface ViewerDocState {
|
||||
baseHash: string | null;
|
||||
/** Last known full-file hash on disk (null until known). */
|
||||
diskHash: string | null;
|
||||
/** The last poll was refused because the project's container is not running. */
|
||||
containerDown: boolean;
|
||||
/**
|
||||
* The last poll failed for any other reason (an unreadable file, a Docker
|
||||
* hiccup), with the backend's sentence. Changes on disk go unseen until a
|
||||
* poll succeeds, but saving stays possible: the write re-checks the hash.
|
||||
*/
|
||||
pollError: string | null;
|
||||
/** Set for one render after a clean reload; UI shows "Reloaded". */
|
||||
justReloaded: boolean;
|
||||
/** True when the user chose "Overwrite on save" after a disk change. */
|
||||
@@ -36,10 +44,10 @@ export type ViewerAction =
|
||||
| { type: "loaded"; hash: string; truncated: boolean }
|
||||
| { type: "edited" }
|
||||
| { type: "polled"; poll: ViewerPoll }
|
||||
| { type: "poll_failed" }
|
||||
| { type: "poll_failed"; message: string }
|
||||
| { type: "reloaded"; hash: string; truncated: boolean; polledHash: string | null }
|
||||
| { type: "overwrite_on_save" }
|
||||
| { type: "saved"; hash: string }
|
||||
| { type: "saved"; hash: string; diskHash: string }
|
||||
| { type: "save_conflict" }
|
||||
| { type: "save_gone" };
|
||||
|
||||
@@ -49,6 +57,7 @@ export const initialViewerState: ViewerDocState = {
|
||||
baseHash: null,
|
||||
diskHash: null,
|
||||
containerDown: false,
|
||||
pollError: null,
|
||||
justReloaded: false,
|
||||
overwrite: false,
|
||||
};
|
||||
@@ -61,17 +70,22 @@ export function reduceViewer(state: ViewerDocState, action: ViewerAction): Viewe
|
||||
case "edited":
|
||||
return { ...s, doc: "dirty" };
|
||||
case "polled": {
|
||||
if (!action.poll.exists) return { ...s, disk: "gone", containerDown: false };
|
||||
const ok = { ...s, containerDown: false, pollError: null };
|
||||
if (!action.poll.exists) return { ...ok, disk: "gone" };
|
||||
const hash = action.poll.hash;
|
||||
if (hash === null) return { ...s, containerDown: false };
|
||||
if (s.diskHash === null) return { ...s, diskHash: hash, disk: s.disk === "gone" ? "same" : s.disk, containerDown: false };
|
||||
if (hash === s.diskHash) return { ...s, disk: s.disk === "gone" ? "same" : s.disk, containerDown: false };
|
||||
if (hash === null) return ok;
|
||||
if (ok.diskHash === null) return { ...ok, diskHash: hash, disk: ok.disk === "gone" ? "same" : ok.disk };
|
||||
if (hash === ok.diskHash) return { ...ok, disk: ok.disk === "gone" ? "same" : ok.disk };
|
||||
// Changed on disk. "Overwrite on save" adopted a base; a further change
|
||||
// on disk invalidates it again.
|
||||
return { ...s, diskHash: hash, disk: "changed", overwrite: false, containerDown: false };
|
||||
return { ...ok, diskHash: hash, disk: "changed", overwrite: false };
|
||||
}
|
||||
case "poll_failed":
|
||||
return { ...s, containerDown: true };
|
||||
// Only the backend's "Start the project before …" refusal means the
|
||||
// container is down; anything else is reported as what it says.
|
||||
return action.message.startsWith(NOT_RUNNING_PREFIX)
|
||||
? { ...s, containerDown: true, pollError: null }
|
||||
: { ...s, containerDown: false, pollError: action.message };
|
||||
case "reloaded":
|
||||
return {
|
||||
...s,
|
||||
@@ -85,6 +99,13 @@ export function reduceViewer(state: ViewerDocState, action: ViewerAction): Viewe
|
||||
case "overwrite_on_save":
|
||||
return { ...s, baseHash: s.diskHash, disk: "same", overwrite: true };
|
||||
case "saved":
|
||||
// The base is always the hash of the bytes written. If the disk already
|
||||
// held something else right after the swap, another writer landed after
|
||||
// us: the buffer is not what is on disk, so say "Changed on disk" (with
|
||||
// Reload / Overwrite) rather than adopt the other writer's hash (M2).
|
||||
if (action.diskHash !== action.hash) {
|
||||
return { ...s, doc: "dirty", disk: "changed", baseHash: action.hash, diskHash: action.diskHash, overwrite: false };
|
||||
}
|
||||
return { ...s, doc: "clean", disk: "same", baseHash: action.hash, diskHash: action.hash, overwrite: false };
|
||||
case "save_conflict":
|
||||
return { ...s, disk: "changed", overwrite: false };
|
||||
|
||||
Reference in New Issue
Block a user