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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user