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