feat(viewer): reload/conflict reducer and editability rules

- viewerState.ts: pure reducer for the clean/dirty, same/changed/gone,
  container-down and overwrite-on-save states (spec §5), plus the
  pollEffect/canSave helpers EditorPane will drive off.
- editability.ts: classifies a fetched file as text/image/binary and
  decides whether it is editable, deferring to Rust's readonly_reason
  when it refuses.

Per preflight P1, languages.ts/.test.ts move to Task 10 (needs the
CodeMirror packages Task 6 installs; out of scope for this task's
worktree). Per P3, the "reloaded" action now carries `truncated` and
`polledHash` so a poll-driven reload of a truncated (prefix-hash-only)
file adopts the polled full-file hash instead of re-triggering a
reload on every subsequent poll -- with a reducer test covering it.
Per P13, tightened the poll_failed/canSave test to start from a dirty
doc so it actually exercises containerDown rather than passing only
because the doc was clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 21:07:28 -07:00
co-authored by Claude Opus 5.5
parent 3177af202d
commit 247ec764ea
4 changed files with 236 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { classifyViewerFile } from "./editability";
import type { ViewerFile } from "../lib/types";
const file = (over: Partial<ViewerFile> = {}): ViewerFile => ({
contents_base64: "", truncated: false, size: 10, hash: "0".repeat(64), editable: true, readonly_reason: null, ...over,
});
const text = new TextEncoder().encode("hello\n");
describe("classifyViewerFile", () => {
it("text in a write root is editable", () => {
expect(classifyViewerFile("/workspace/a/x.md", file(), text)).toEqual({ kind: "text", editable: true, reason: null });
});
it("a truncated file is read-only and says why", () => {
const r = classifyViewerFile("/workspace/a/big.log", file({ truncated: true }), text);
expect(r.editable).toBe(false);
expect(r.reason).toMatch(/1 MiB/);
});
it("Rust's refusal wins and is quoted", () => {
const r = classifyViewerFile("/etc/hosts", file({ editable: false, readonly_reason: "Only /workspace, /home/claude and /tmp can be written." }), text);
expect(r).toEqual({ kind: "text", editable: false, reason: "Only /workspace, /home/claude and /tmp can be written." });
});
it("images and binaries are never editable", () => {
expect(classifyViewerFile("/workspace/a/x.png", file(), new Uint8Array([137, 80]))).toMatchObject({ kind: "image", editable: false });
expect(classifyViewerFile("/workspace/a/x.bin", file(), new Uint8Array([0, 1, 2]))).toMatchObject({ kind: "binary", editable: false });
});
});
+15
View File
@@ -0,0 +1,15 @@
import { imageMimeFor, looksBinary, TEXT_PREVIEW_LIMIT } from "../components/projects/home/filePreview";
import type { ViewerFile } from "../lib/types";
export type ViewerKind = "text" | "image" | "binary";
export interface Editability { kind: ViewerKind; editable: boolean; reason: string | null }
const MIB = TEXT_PREVIEW_LIMIT / (1024 * 1024);
export function classifyViewerFile(path: string, file: ViewerFile, bytes: Uint8Array): Editability {
if (imageMimeFor(path)) return { kind: "image", editable: false, reason: "Images are shown, not edited." };
if (looksBinary(bytes)) return { kind: "binary", editable: false, reason: "This file is not text." };
if (file.truncated) return { kind: "text", editable: false, reason: `Files over ${MIB} MiB are read-only.` };
if (!file.editable) return { kind: "text", editable: false, reason: file.readonly_reason ?? "This location is read-only." };
return { kind: "text", editable: true, reason: null };
}
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { canSave, initialViewerState, pollEffect, reduceViewer, type ViewerDocState } from "./viewerState";
const H1 = "1".repeat(64);
const H2 = "2".repeat(64);
const H3 = "3".repeat(64);
const loaded = (truncated = false): ViewerDocState =>
reduceViewer(initialViewerState, { type: "loaded", hash: H1, truncated });
const poll = (s: ViewerDocState, hash: string | null, exists = true) =>
reduceViewer(s, { type: "polled", poll: { exists, hash, size: exists ? 1 : null } });
describe("reduceViewer", () => {
it("seeds both hashes from an untruncated load", () => {
expect(loaded()).toMatchObject({ doc: "clean", disk: "same", baseHash: H1, diskHash: H1 });
});
it("leaves diskHash unknown after a truncated load, so the first poll seeds it silently", () => {
const s = loaded(true);
expect(s.diskHash).toBeNull();
const after = poll(s, H2);
expect(after).toMatchObject({ disk: "same", diskHash: H2 });
expect(pollEffect(s, after)).toBe("none");
});
it("an unchanged poll is a no-op", () => {
const s = loaded();
expect(pollEffect(s, poll(s, H1))).toBe("none");
});
it("a changed poll on a clean doc reloads", () => {
const s = loaded();
const after = poll(s, H2);
expect(after).toMatchObject({ disk: "changed", diskHash: H2, doc: "clean" });
expect(pollEffect(s, after)).toBe("reload");
const reloaded = reduceViewer(after, { type: "reloaded", hash: H2, truncated: false, polledHash: H2 });
expect(reloaded).toMatchObject({ disk: "same", baseHash: H2, diskHash: H2, justReloaded: true });
});
it("a truncated reload adopts the polled hash, not the prefix hash; the next identical poll is a no-op", () => {
// A truncated load never gets a comparable full-file hash of its own, so a
// poll-driven reload of a large file must seed diskHash from the poll's
// hash (spec Decision 2) -- otherwise every poll re-triggers a reload.
const seeded = poll(loaded(true), H2);
const changed = poll(seeded, H3);
expect(pollEffect(seeded, changed)).toBe("reload");
const reloaded = reduceViewer(changed, { type: "reloaded", hash: H1, truncated: true, polledHash: changed.diskHash });
expect(reloaded).toMatchObject({ disk: "same", diskHash: H3, baseHash: H1, justReloaded: true });
expect(pollEffect(reloaded, poll(reloaded, H3))).toBe("none");
});
it("a changed poll on a dirty doc shows the banner and never reloads", () => {
const s = reduceViewer(loaded(), { type: "edited" });
const after = poll(s, H2);
expect(after).toMatchObject({ doc: "dirty", disk: "changed" });
expect(pollEffect(s, after)).toBe("banner");
expect(pollEffect(after, poll(after, H2))).toBe("none");
});
it("overwrite-on-save adopts the disk hash as the base", () => {
const s = poll(reduceViewer(loaded(), { type: "edited" }), H2);
const o = reduceViewer(s, { type: "overwrite_on_save" });
expect(o).toMatchObject({ baseHash: H2, disk: "same", overwrite: true, doc: "dirty" });
expect(canSave(o, true)).toBe(true);
});
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: "save_conflict" })).toMatchObject({ doc: "dirty", disk: "changed" });
expect(reduceViewer(s, { type: "save_gone" })).toMatchObject({ disk: "gone" });
});
it("a gone file disables saving but keeps the buffer state", () => {
const s = reduceViewer(loaded(), { type: "edited" });
const gone = poll(s, null, false);
expect(gone).toMatchObject({ disk: "gone", doc: "dirty" });
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", () => {
// 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);
expect(canSave(down, true)).toBe(false);
expect(poll(down, H1).containerDown).toBe(false);
});
it("canSave needs dirty + editable + disk in sync", () => {
expect(canSave(loaded(), true)).toBe(false);
const dirty = reduceViewer(loaded(), { type: "edited" });
expect(canSave(dirty, true)).toBe(true);
expect(canSave(dirty, false)).toBe(false);
expect(canSave(poll(dirty, H2), true)).toBe(false);
});
});
+105
View File
@@ -0,0 +1,105 @@
/**
* The viewer's reload/dirty/conflict rules as a pure reducer (spec §5).
*
* Two hashes, deliberately: `baseHash` is what the buffer was loaded from or
* last saved as -- the save's precondition. `diskHash` is the last full-file
* hash the poll reported. They differ only for a truncated (read-only) load,
* where the read's hash covers a prefix and can never equal `sha256sum`; the
* poll then seeds `diskHash` without triggering a reload.
*
* The same prefix-vs-full-file split applies to a poll-driven reload of a
* truncated file: the fresh read's hash is still only a prefix hash, so a
* `reloaded` action for a truncated file adopts the *polled* hash as the new
* `diskHash` rather than the read's own hash. Without this, a large file
* would re-download on every poll tick forever (spec Decision 2).
*/
import type { ViewerPoll } from "../lib/types";
export type DocStatus = "clean" | "dirty";
export type DiskStatus = "same" | "changed" | "gone";
export interface ViewerDocState {
doc: DocStatus;
disk: DiskStatus;
/** Hash the buffer was loaded from / last saved as. */
baseHash: string | null;
/** Last known full-file hash on disk (null until known). */
diskHash: string | null;
containerDown: boolean;
/** 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. */
overwrite: boolean;
}
export type ViewerAction =
| { type: "loaded"; hash: string; truncated: boolean }
| { type: "edited" }
| { type: "polled"; poll: ViewerPoll }
| { type: "poll_failed" }
| { type: "reloaded"; hash: string; truncated: boolean; polledHash: string | null }
| { type: "overwrite_on_save" }
| { type: "saved"; hash: string }
| { type: "save_conflict" }
| { type: "save_gone" };
export const initialViewerState: ViewerDocState = {
doc: "clean",
disk: "same",
baseHash: null,
diskHash: null,
containerDown: false,
justReloaded: false,
overwrite: false,
};
export function reduceViewer(state: ViewerDocState, action: ViewerAction): ViewerDocState {
const s = { ...state, justReloaded: false };
switch (action.type) {
case "loaded":
return { ...initialViewerState, baseHash: action.hash, diskHash: action.truncated ? null : action.hash };
case "edited":
return { ...s, doc: "dirty" };
case "polled": {
if (!action.poll.exists) return { ...s, disk: "gone", containerDown: false };
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 };
// 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 };
}
case "poll_failed":
return { ...s, containerDown: true };
case "reloaded":
return {
...s,
doc: "clean",
disk: "same",
baseHash: action.hash,
diskHash: action.truncated ? action.polledHash : action.hash,
justReloaded: true,
overwrite: false,
};
case "overwrite_on_save":
return { ...s, baseHash: s.diskHash, disk: "same", overwrite: true };
case "saved":
return { ...s, doc: "clean", disk: "same", baseHash: action.hash, diskHash: action.hash, overwrite: false };
case "save_conflict":
return { ...s, disk: "changed", overwrite: false };
case "save_gone":
return { ...s, disk: "gone" };
}
}
/** What EditorPane does after a poll: nothing, reload silently, or show the banner. */
export function pollEffect(before: ViewerDocState, after: ViewerDocState): "none" | "reload" | "banner" {
if (after.disk === "gone") return before.disk === "gone" ? "none" : "banner";
if (after.disk !== "changed" || after.diskHash === before.diskHash) return "none";
return after.doc === "clean" ? "reload" : "banner";
}
export function canSave(state: ViewerDocState, editable: boolean): boolean {
return editable && state.doc === "dirty" && state.disk === "same" && !state.containerDown;
}