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