fix(viewer): byte-faithful saves, poll/save races, clearer errors

- Keep CRLF (or CR) line endings and a UTF-8 BOM through the editor:
  textFormat.ts records the dominant separator and the BOM on load and
  restores both on save, so a save changes only the user's edits.
- A clean document whose reload failed retries on the next poll.
- A poll that overlaps a save, or was issued before one settled, is
  ignored instead of reading the pre-save hash as a change.
- A conflict whose follow-up poll has no hash shows an error with a
  Reload button rather than an Overwrite that could only conflict again.
- Match write.rs's exact read-only message; show the read-only reason as
  visible text; error banners are role="alert".
- vite/client types move to src/vite-env.d.ts.
- Tests: CRLF and BOM saves, reload retry, poll/save race, null-hash
  conflict, Save and close success and failure, and CodeEditor.setDoc
  keeping cursor and scroll.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 21:29:44 -07:00
co-authored by Claude Opus 5.5
parent 804213a517
commit 16bfb3984c
8 changed files with 357 additions and 55 deletions
+111 -1
View File
@@ -1,6 +1,8 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, fireEvent } from "@testing-library/react";
import { EditorView } from "@codemirror/view";
import EditorPane from "./EditorPane";
import { encodeBase64 } from "../components/projects/home/filePreview";
import type { ViewerState } from "../lib/types";
const H1 = "1".repeat(64);
@@ -37,6 +39,15 @@ const textFile = (text: string, hash: string, extra: Partial<{ truncated: boolea
/** Mark the buffer dirty through the pane's test hook (jsdom cannot drive CodeMirror's contenteditable). */
const edit = () => fireEvent(document, new CustomEvent("triple-c-test-edit"));
const clickSave = async () => { await act(async () => { fireEvent.click(screen.getByRole("button", { name: /^save$/i })); }); };
/** A real edit through CodeMirror, so the saved bytes carry it. */
const typeInto = (from: number, to: number, insert: string) => {
const view = EditorView.findFromDOM(document.querySelector(".cm-editor") as HTMLElement);
if (!view) throw new Error("no editor");
act(() => { view.dispatch({ changes: { from, to, insert } }); });
};
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 poll = async (ms = 2100) => { await act(async () => { await vi.advanceTimersByTimeAsync(ms); }); };
describe("EditorPane", () => {
@@ -163,7 +174,7 @@ describe("EditorPane", () => {
});
it("a save refused because the file is read-only says so and keeps the buffer", async () => {
commands.viewerWriteFile.mockRejectedValue("Could not save the file: The file is read-only.");
commands.viewerWriteFile.mockRejectedValue(READ_ONLY);
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
@@ -213,4 +224,103 @@ describe("EditorPane", () => {
expect(prevent).not.toHaveBeenCalled();
expect(screen.queryByText(/Unsaved changes/)).toBeNull();
});
it("a one-character edit to a CRLF file saves with every CRLF intact", async () => {
commands.viewerReadFile.mockResolvedValue(textFile("a\r\nb\r\nc\r\n", H1));
render(<EditorPane state={state} />);
await screen.findByText("Saved");
typeInto(2, 3, "B"); // the editor holds "a\nb\nc\n"
expect(screen.getByText("Unsaved")).toBeInTheDocument();
await clickSave();
expect(commands.viewerWriteFile).toHaveBeenCalledWith(b64("a\r\nB\r\nc\r\n"), H1);
});
it("a file with a UTF-8 BOM keeps its BOM on save", async () => {
const BOM = [0xef, 0xbb, 0xbf];
commands.viewerReadFile.mockResolvedValue({ ...textFile("", H1), contents_base64: bytesB64([...BOM, ...utf8("hi\n")]) });
render(<EditorPane state={state} />);
await screen.findByText("Saved");
typeInto(2, 2, "!");
await clickSave();
expect(commands.viewerWriteFile).toHaveBeenCalledWith(bytesB64([...BOM, ...utf8("hi!\n")]), H1);
});
it("a reload that fails is retried on the next poll", async () => {
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));
await poll();
expect(screen.getByTestId("code-editor")).toHaveTextContent("hello");
await poll(2000);
expect(screen.getByTestId("code-editor")).toHaveTextContent("changed");
expect(screen.getByText("Reloaded")).toBeInTheDocument();
});
it("ignores a poll issued before a save completed", async () => {
render(<EditorPane state={state} />);
await screen.findByText("Saved");
edit();
let answer: (p: { exists: boolean; hash: string; size: number }) => void = () => {};
commands.viewerPollFile.mockImplementationOnce(() => new Promise((r) => { answer = r; }));
await poll(); // this poll is now in flight, carrying the pre-save hash
await clickSave(); // lands as H2
edit();
await act(async () => { answer({ exists: true, hash: H1, size: 6 }); });
expect(screen.queryByText(/while you were editing/)).toBeNull();
expect(screen.getByText("Unsaved")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^save$/i })).toBeEnabled();
});
it("a conflict whose follow-up poll has no hash shows an error instead of offering an overwrite", async () => {
commands.viewerWriteFile.mockRejectedValue("conflict: the file changed on disk since it was loaded.");
render(<EditorPane state={state} />);
await screen.findByText("Saved");
edit();
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: null, size: 7 });
await clickSave();
expect(await screen.findByRole("alert")).toHaveTextContent(/could not be checked/);
expect(screen.queryByRole("button", { name: /Overwrite on save/ })).toBeNull();
expect(screen.getByRole("button", { name: /Reload/ })).toBeInTheDocument();
});
it("states why a file is read-only as visible text", async () => {
commands.viewerReadFile.mockResolvedValue(textFile("big", H1, { truncated: true }));
render(<EditorPane state={state} />);
expect(await screen.findByText("Read-only")).toBeInTheDocument();
expect(screen.getByText("Files over 1 MiB are read-only.")).toBeVisible();
});
it("a save error is announced as an alert", async () => {
commands.viewerWriteFile.mockRejectedValue("Could not save the file: disk full");
render(<EditorPane state={state} />);
await screen.findByText("Saved");
edit();
await clickSave();
expect(await screen.findByRole("alert")).toHaveTextContent("Could not save the file: disk full");
});
it("Save and close saves, then closes the window", async () => {
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(commands.viewerWriteFile).toHaveBeenCalledWith(b64("hello\n"), H1);
expect(windowApi.destroy).toHaveBeenCalled();
});
it("a save that fails while closing keeps the window open and the buffer", async () => {
commands.viewerWriteFile.mockRejectedValue(READ_ONLY);
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(screen.getByText(/Unsaved changes/)).toBeInTheDocument();
expect(await screen.findByText(/read-only for the container user/)).toBeInTheDocument();
expect(screen.getByTestId("code-editor")).toHaveTextContent("hello");
expect(screen.getByText("Unsaved")).toBeInTheDocument();
});
});