diff --git a/app/src/viewer/editability.test.ts b/app/src/viewer/editability.test.ts new file mode 100644 index 0000000..a88e5f9 --- /dev/null +++ b/app/src/viewer/editability.test.ts @@ -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 => ({ + 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 }); + }); +}); diff --git a/app/src/viewer/editability.ts b/app/src/viewer/editability.ts new file mode 100644 index 0000000..fddbf10 --- /dev/null +++ b/app/src/viewer/editability.ts @@ -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 }; +} diff --git a/app/src/viewer/viewerState.test.ts b/app/src/viewer/viewerState.test.ts new file mode 100644 index 0000000..abfa131 --- /dev/null +++ b/app/src/viewer/viewerState.test.ts @@ -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); + }); +}); diff --git a/app/src/viewer/viewerState.ts b/app/src/viewer/viewerState.ts new file mode 100644 index 0000000..e399f5a --- /dev/null +++ b/app/src/viewer/viewerState.ts @@ -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; +}