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
+61
View File
@@ -0,0 +1,61 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { act, render } from "@testing-library/react";
import { createRef } from "react";
import { EditorView } from "@codemirror/view";
import { CodeEditor, type CodeEditorHandle } from "./CodeEditor";
beforeAll(() => {
// P17: CodeMirror's measure pass calls Range geometry, which jsdom lacks.
Range.prototype.getClientRects = () => ({ length: 0, item: () => null, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList;
Range.prototype.getBoundingClientRect = () => ({ x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON() {} }) as DOMRect;
});
const mount = (initialDoc: string) => {
const ref = createRef<CodeEditorHandle>();
const onDocChanged = vi.fn();
const utils = render(
<CodeEditor
ref={ref}
initialDoc={initialDoc}
readOnly={false}
language={null}
lineWrapping={false}
initialLocation={{ line: null, col: null, end_line: null }}
onDocChanged={onDocChanged}
onSave={() => {}}
/>,
);
const view = EditorView.findFromDOM(utils.container.querySelector(".cm-editor") as HTMLElement)!;
return { ref, view, onDocChanged };
};
describe("CodeEditor.setDoc (a reload)", () => {
it("keeps the cursor and the scroll position, and is not an edit", () => {
const { ref, view, onDocChanged } = mount("one\ntwo\nthree\nfour\n");
act(() => { view.dispatch({ selection: { anchor: 9 } }); }); // inside "three"
// jsdom has no layout, so give the scroller a real, settable scrollTop.
let top = 0;
Object.defineProperty(view.scrollDOM, "scrollTop", { configurable: true, get: () => top, set: (v: number) => { top = v; } });
view.scrollDOM.scrollTop = 120;
act(() => { ref.current!.setDoc("one\ntwo\nTHREE\nfour\nfive\n"); });
expect(view.state.doc.toString()).toBe("one\ntwo\nTHREE\nfour\nfive\n");
expect(view.state.selection.main.head).toBe(9);
expect(view.scrollDOM.scrollTop).toBe(120);
expect(onDocChanged).not.toHaveBeenCalled();
});
it("clamps the cursor when the new text is shorter", () => {
const { ref, view } = mount("a long first line\n");
act(() => { view.dispatch({ selection: { anchor: 15 } }); });
act(() => { ref.current!.setDoc("short"); });
expect(view.state.selection.main.head).toBe(5);
});
it("a user edit is reported as a change", () => {
const { view, onDocChanged } = mount("x");
act(() => { view.dispatch({ changes: { from: 1, insert: "y" } }); });
expect(onDocChanged).toHaveBeenCalledTimes(1);
});
});
+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();
});
});
+72 -50
View File
@@ -1,4 +1,3 @@
/// <reference types="vite/client" />
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type ReactNode } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { Extension } from "@codemirror/state";
@@ -10,6 +9,7 @@ import type { ViewerFile, ViewerLocation, ViewerState } from "../lib/types";
import { CodeEditor, type CodeEditorHandle } from "./CodeEditor";
import { classifyViewerFile, type Editability } from "./editability";
import { languageFor, wrapsLines } from "./languages";
import { decodeViewerText, encodeViewerText, type TextFormat } from "./textFormat";
import { useViewerPolling } from "./useViewerPolling";
import { canSave, initialViewerState, pollEffect, reduceViewer } from "./viewerState";
@@ -18,7 +18,11 @@ export const GOTO_EVENT = "file-viewer-goto";
const READ_ONLY_SAVE =
"This file is read-only for the container user, so it was not saved. Your text is kept: change the file's permissions in the container and save again, or copy your text out.";
const NOT_UTF8 = "This file is not valid UTF-8, so it is read-only.";
const CONFLICT_UNCHECKED =
"The file changed on disk, but its new version could not be checked, so it cannot be overwritten safely. Copy your text out if you need it, then reload.";
/** A banner-worthy save failure; `reload` adds a "Reload (discard mine)" button. */
interface SaveError { text: string; reload?: boolean }
type View =
| { kind: "loading" }
@@ -29,22 +33,8 @@ type View =
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
/** The save script's refusal to replace a file the container user may not write (Task 3, I3). */
const isReadOnlyRefusal = (msg: string) => /the file is read-only|read-only for the container user/i.test(msg);
/**
* Decode text for the editor. An editable file must round-trip byte for byte,
* so invalid UTF-8 (which the lenient decoder would turn into U+FFFD, and a
* save would then write back) makes the file read-only instead.
*/
function decodeText(bytes: Uint8Array, editability: Editability): { text: string; editability: Editability } {
if (!editability.editable) return { text: new TextDecoder().decode(bytes), editability };
try {
return { text: new TextDecoder("utf-8", { fatal: true }).decode(bytes), editability };
} catch {
return { text: new TextDecoder().decode(bytes), editability: { kind: "text", editable: false, reason: NOT_UTF8 } };
}
}
/** `write.rs`'s refusal to replace a file the container user may not write. */
const isReadOnlyRefusal = (msg: string) => msg.includes("The file is read-only for the container user.");
export default function EditorPane({ state }: { state: ViewerState }) {
const path = state.state.kind === "resolved" ? state.state.container_path : "";
@@ -52,7 +42,7 @@ export default function EditorPane({ state }: { state: ViewerState }) {
const [language, setLanguage] = useState<Extension | null>(null);
const [doc, dispatch] = useReducer(reduceViewer, initialViewerState);
const [closing, setClosing] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<SaveError | null>(null);
const editor = useRef<CodeEditorHandle>(null);
const docRef = useRef(doc);
docRef.current = doc;
@@ -61,6 +51,10 @@ export default function EditorPane({ state }: { state: ViewerState }) {
/** Bumped synchronously on every user edit, so async work can tell an edit happened meanwhile. */
const editGen = useRef(0);
const saving = useRef(false);
/** Bumped when a save's write settles; a poll issued before that is stale. */
const saveGen = useRef(0);
/** Line ending and BOM of the loaded text, restored on save. */
const textFormat = useRef<TextFormat>({ bom: false, eol: "\n" });
const imageUrl = useRef<string | null>(null);
const markEdited = useCallback(() => {
@@ -80,7 +74,8 @@ export default function EditorPane({ state }: { state: ViewerState }) {
} else if (classified.kind === "binary") {
setView({ kind: "binary", editability: classified });
} else {
const { text, editability } = decodeText(bytes, classified);
const { text, editability, format } = decodeViewerText(bytes, classified);
textFormat.current = format;
setView({ kind: "text", doc: text, editability });
editor.current?.setDoc(text);
}
@@ -128,8 +123,12 @@ export default function EditorPane({ state }: { state: ViewerState }) {
// Poll (spec §5). A reload replaces the document only when the reducer says so.
useViewerPolling(POLL_MS, async () => {
// A poll that overlaps a save can carry the pre-save hash; skip it (M2).
if (saving.current) return;
const gen = saveGen.current;
let poll;
try { poll = await viewerPollFile(); } catch { dispatch({ type: "poll_failed" }); return; }
try { poll = await viewerPollFile(); } catch { if (saveGen.current === gen) dispatch({ type: "poll_failed" }); return; }
if (saveGen.current !== gen) return;
const before = docRef.current;
const after = reduceViewer(before, { type: "polled", poll });
dispatch({ type: "polled", poll });
@@ -149,35 +148,49 @@ export default function EditorPane({ state }: { state: ViewerState }) {
setSaveError(null);
const gen = editGen.current;
try {
const hash = await viewerWriteFile(encodeBase64(new TextEncoder().encode(handle.getDoc())), baseHash);
dispatch({ type: "saved", hash });
const bytes = encodeViewerText(handle.getDoc(), textFormat.current);
const result = await viewerWriteFile(encodeBase64(bytes), baseHash).then(
(hash) => ({ ok: true as const, hash }),
(e: unknown) => ({ ok: false as const, msg: errorText(e) }),
);
saveGen.current += 1;
if (result.ok) {
dispatch({ type: "saved", hash: result.hash });
if (editGen.current !== gen) dispatch({ type: "edited" }); // typed while the save was in flight
else if (closingRef.current) await getCurrentWindow().destroy();
} catch (e) {
const msg = errorText(e);
if (msg.startsWith("conflict:")) {
// The disk changed between polls. Poll now (P4), so "Overwrite on
// save" adopts the current hash rather than the stale one.
try {
const poll = await viewerPollFile();
dispatch({ type: "polled", poll });
if (poll.exists) dispatch({ type: "save_conflict" });
} catch {
dispatch({ type: "poll_failed" });
dispatch({ type: "save_conflict" });
}
} else if (msg.startsWith("gone:")) {
} else if (result.msg.startsWith("conflict:")) {
await adoptConflict();
} else if (result.msg.startsWith("gone:")) {
dispatch({ type: "save_gone" });
} else if (isReadOnlyRefusal(msg)) {
setSaveError(READ_ONLY_SAVE);
} else if (isReadOnlyRefusal(result.msg)) {
setSaveError({ text: READ_ONLY_SAVE });
} else {
setSaveError(msg);
setSaveError({ text: result.msg });
}
} finally {
saving.current = false;
}
}, [saveEnabled]);
/**
* The disk changed between polls. Poll now (P4), so "Overwrite on save"
* adopts the current hash rather than the stale one. With no hash to adopt,
* an overwrite would only conflict again, so say so instead (M3).
*/
async function adoptConflict() {
let poll;
try {
poll = await viewerPollFile();
} catch {
dispatch({ type: "poll_failed" });
dispatch({ type: "save_conflict" });
return;
}
if (poll.exists && poll.hash === null) { setSaveError({ text: CONFLICT_UNCHECKED, reload: true }); return; }
dispatch({ type: "polled", poll });
if (poll.exists) dispatch({ type: "save_conflict" });
}
// Ctrl/Cmd+S outside the editor; the editor's own keymap handles it inside
// (and prevents the default, which is how this listener knows to skip it).
useEffect(() => {
@@ -211,14 +224,14 @@ export default function EditorPane({ state }: { state: ViewerState }) {
const reloadDiscarding = useCallback(async () => {
setSaveError(null);
try { await reloadFromDisk(docRef.current.diskHash, false); } catch (e) { setSaveError(errorText(e)); }
try { await reloadFromDisk(docRef.current.diskHash, false); } catch (e) { setSaveError({ text: errorText(e) }); }
}, [reloadFromDisk]);
const badge = useMemo((): { tone: StatusTone; label: string; title?: string } | null => {
const badge = useMemo((): { tone: StatusTone; label: string; detail?: string } | null => {
if (view.kind === "loading" || view.kind === "error") return null;
if (doc.containerDown) return { tone: "error", label: "Container not running" };
if (doc.disk === "gone") return { tone: "error", label: "File no longer exists" };
if (!view.editability.editable) return { tone: "off", label: "Read-only", title: view.editability.reason ?? undefined };
if (!view.editability.editable) return { tone: "off", label: "Read-only", detail: view.editability.reason ?? undefined };
if (doc.disk === "changed") return { tone: "busy", label: "Changed on disk" };
if (doc.doc === "dirty") return { tone: "busy", label: "Unsaved" };
if (doc.justReloaded) return { tone: "ok", label: "Reloaded" };
@@ -230,21 +243,26 @@ export default function EditorPane({ state }: { state: ViewerState }) {
<header className="flex items-center gap-3 border-b border-[var(--border-color)] bg-[var(--bg-secondary)] px-3 py-2 text-xs">
<span className="truncate font-mono" title={path}>{path}</span>
<span className="text-[var(--text-secondary)]">{state.project_name}</span>
<span className="ml-auto" aria-live="polite">
{badge && <StatusIndicator tone={badge.tone} label={badge.label} title={badge.title} />}
<span className="ml-auto flex items-center" aria-live="polite">
{badge && <StatusIndicator tone={badge.tone} label={badge.label} />}
{badge?.detail && <span className="ml-2 text-[var(--text-secondary)]">{badge.detail}</span>}
</span>
<Button variant="primary" size="sm" onClick={() => void save()} disabled={!saveEnabled}>Save</Button>
</header>
{doc.containerDown && <Banner text="Container not running — the file cannot be read or saved until the project starts again." />}
{doc.disk === "gone" && <Banner text="This file no longer exists in the container. Your text is kept so you can copy it; saving is disabled." />}
{doc.containerDown && <Banner tone="error" text="Container not running — the file cannot be read or saved until the project starts again." />}
{doc.disk === "gone" && <Banner tone="error" text="This file no longer exists in the container. Your text is kept so you can copy it; saving is disabled." />}
{doc.disk === "changed" && doc.doc === "dirty" && (
<Banner text="Changed on disk while you were editing.">
<Button size="sm" onClick={() => void reloadDiscarding()}>Reload (discard mine)</Button>
<Button size="sm" onClick={() => dispatch({ type: "overwrite_on_save" })}>Overwrite on save</Button>
</Banner>
)}
{saveError && <Banner text={saveError} />}
{saveError && (
<Banner tone="error" text={saveError.text}>
{saveError.reload && <Button size="sm" onClick={() => void reloadDiscarding()}>Reload (discard mine)</Button>}
</Banner>
)}
{closing && (
<Banner text="Unsaved changes — save before closing?">
<Button variant="primary" size="sm" onClick={() => void save()} disabled={!saveEnabled}>Save and close</Button>
@@ -275,9 +293,13 @@ export default function EditorPane({ state }: { state: ViewerState }) {
);
}
function Banner({ text, children }: { text: string; children?: ReactNode }) {
/** A warning is a polite status; an error (a failed save, a lost file or container) is an alert. */
function Banner({ text, tone = "warning", children }: { text: string; tone?: "warning" | "error"; children?: ReactNode }) {
const colours = tone === "error"
? "border-[var(--error)] bg-[var(--error-muted)]"
: "border-[var(--warning)] bg-[var(--warning-muted)]";
return (
<div role="status" className="flex flex-wrap items-center gap-2 border-b border-[var(--warning)] bg-[var(--warning-muted)] px-3 py-2 text-xs">
<div role={tone === "error" ? "alert" : "status"} className={`flex flex-wrap items-center gap-2 border-b px-3 py-2 text-xs ${colours}`}>
<span>{text}</span>
{children}
</div>
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { decodeViewerText, encodeViewerText } from "./textFormat";
import type { Editability } from "./editability";
const editable: Editability = { kind: "text", editable: true, reason: null };
const bytes = (s: string) => new TextEncoder().encode(s);
const BOM = [0xef, 0xbb, 0xbf];
/** What CodeMirror hands back: every line break normalised to "\n". */
const asEditorText = (s: string) => s.replace(/\r\n?/g, "\n");
describe("decodeViewerText / encodeViewerText", () => {
it("round-trips an LF file byte for byte", () => {
const d = decodeViewerText(bytes("a\nb\n"), editable);
expect(d.format).toEqual({ bom: false, eol: "\n" });
expect(Array.from(encodeViewerText(asEditorText(d.text), d.format))).toEqual(Array.from(bytes("a\nb\n")));
});
it("keeps CRLF line endings through the editor's LF buffer", () => {
const d = decodeViewerText(bytes("a\r\nb\r\nc"), editable);
expect(d.format.eol).toBe("\r\n");
const edited = asEditorText(d.text).replace("b", "B");
expect(new TextDecoder().decode(encodeViewerText(edited, d.format))).toBe("a\r\nB\r\nc");
});
it("uses the dominant separator for a mixed file", () => {
expect(decodeViewerText(bytes("a\r\nb\r\nc\nd"), editable).format.eol).toBe("\r\n");
expect(decodeViewerText(bytes("a\nb\nc\r\nd"), editable).format.eol).toBe("\n");
expect(decodeViewerText(bytes("a\rb\rc"), editable).format.eol).toBe("\r");
});
it("strips a UTF-8 BOM from the text and puts it back on save", () => {
const d = decodeViewerText(new Uint8Array([...BOM, ...bytes("hi\n")]), editable);
expect(d.text).toBe("hi\n");
expect(d.format.bom).toBe(true);
expect(Array.from(encodeViewerText("hi\n", d.format))).toEqual([...BOM, ...bytes("hi\n")]);
});
it("makes invalid UTF-8 read-only rather than rewriting it", () => {
const d = decodeViewerText(new Uint8Array([0x61, 0xff, 0x62]), editable);
expect(d.editability).toMatchObject({ editable: false, reason: expect.stringMatching(/not valid UTF-8/) });
});
});
+58
View File
@@ -0,0 +1,58 @@
/**
* Byte-faithful text for the editor: a save must change only what the user
* edited. CodeMirror normalises every line break to "\n" and the UTF-8
* decoder drops a BOM, so both are recorded on load and restored on save.
*/
import type { Editability } from "./editability";
export type LineEnding = "\n" | "\r\n" | "\r";
export interface TextFormat { bom: boolean; eol: LineEnding }
const BOM = [0xef, 0xbb, 0xbf];
const NOT_UTF8 = "This file is not valid UTF-8, so it is read-only.";
/** The most common separator in the text; "\n" on a tie or with no breaks. */
function dominantEol(text: string): LineEnding {
let crlf = 0, lf = 0, cr = 0;
for (let i = 0; i < text.length; i++) {
const c = text.charCodeAt(i);
if (c === 13) {
if (text.charCodeAt(i + 1) === 10) { crlf++; i++; } else cr++;
} else if (c === 10) lf++;
}
if (crlf > lf && crlf >= cr) return "\r\n";
if (cr > lf && cr > crlf) return "\r";
return "\n";
}
export function decodeViewerText(
bytes: Uint8Array,
editability: Editability,
): { text: string; editability: Editability; format: TextFormat } {
const bom = bytes.length >= 3 && BOM.every((b, i) => bytes[i] === b);
const body = bom ? bytes.subarray(3) : bytes;
let text: string;
if (!editability.editable) {
text = new TextDecoder("utf-8", { ignoreBOM: true }).decode(body);
} else {
// An editable file must round-trip, so invalid UTF-8 (which the lenient
// decoder would turn into U+FFFD, and a save would write back) is read-only.
try {
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(body);
} catch {
text = new TextDecoder("utf-8", { ignoreBOM: true }).decode(body);
editability = { kind: "text", editable: false, reason: NOT_UTF8 };
}
}
return { text, editability, format: { bom, eol: dominantEol(text) } };
}
/** The editor's "\n"-joined text back to the file's bytes. */
export function encodeViewerText(text: string, format: TextFormat): Uint8Array {
const body = new TextEncoder().encode(format.eol === "\n" ? text : text.split("\n").join(format.eol));
if (!format.bom) return body;
const out = new Uint8Array(body.length + 3);
out.set(BOM, 0);
out.set(body, 3);
return out;
}
+4
View File
@@ -43,6 +43,10 @@ describe("reduceViewer", () => {
expect(reloaded).toMatchObject({ disk: "same", diskHash: H3, baseHash: H1, justReloaded: true });
expect(pollEffect(reloaded, poll(reloaded, H3))).toBe("none");
});
it("a clean doc still marked changed (its reload failed) retries on the next identical poll", () => {
const changed = poll(loaded(), H2);
expect(pollEffect(changed, poll(changed, H2))).toBe("reload");
});
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);
+5 -2
View File
@@ -96,8 +96,11 @@ export function reduceViewer(state: ViewerDocState, action: ViewerAction): Viewe
/** 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";
if (after.disk !== "changed") return "none";
// A clean doc still marked "changed" means its reload failed; retry it
// rather than leave stale text under a "Changed on disk" badge.
if (after.doc === "clean") return "reload";
return after.diskHash === before.diskHash ? "none" : "banner";
}
export function canSave(state: ViewerDocState, editable: boolean): boolean {
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />