diff --git a/app/src/viewer/CodeEditor.test.tsx b/app/src/viewer/CodeEditor.test.tsx new file mode 100644 index 0000000..f0ec2ed --- /dev/null +++ b/app/src/viewer/CodeEditor.test.tsx @@ -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(); + const onDocChanged = vi.fn(); + const utils = render( + {}} + />, + ); + 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); + }); +}); diff --git a/app/src/viewer/EditorPane.test.tsx b/app/src/viewer/EditorPane.test.tsx index 1d345c8..bc80659 100644 --- a/app/src/viewer/EditorPane.test.tsx +++ b/app/src/viewer/EditorPane.test.tsx @@ -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(); 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); }); diff --git a/app/src/viewer/EditorPane.tsx b/app/src/viewer/EditorPane.tsx index 07fff02..99664f7 100644 --- a/app/src/viewer/EditorPane.tsx +++ b/app/src/viewer/EditorPane.tsx @@ -1,4 +1,3 @@ -/// 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(null); const [doc, dispatch] = useReducer(reduceViewer, initialViewerState); const [closing, setClosing] = useState(false); - const [saveError, setSaveError] = useState(null); + const [saveError, setSaveError] = useState(null); const editor = useRef(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({ bom: false, eol: "\n" }); const imageUrl = useRef(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 }); - 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:")) { + 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(); + } 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 }) {
{path} {state.project_name} - - {badge && } + + {badge && } + {badge?.detail && {badge.detail}}
- {doc.containerDown && } - {doc.disk === "gone" && } + {doc.containerDown && } + {doc.disk === "gone" && } {doc.disk === "changed" && doc.doc === "dirty" && ( )} - {saveError && } + {saveError && ( + + {saveError.reload && } + + )} {closing && ( @@ -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 ( -
+
{text} {children}
diff --git a/app/src/viewer/textFormat.test.ts b/app/src/viewer/textFormat.test.ts new file mode 100644 index 0000000..88327d4 --- /dev/null +++ b/app/src/viewer/textFormat.test.ts @@ -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/) }); + }); +}); diff --git a/app/src/viewer/textFormat.ts b/app/src/viewer/textFormat.ts new file mode 100644 index 0000000..991d567 --- /dev/null +++ b/app/src/viewer/textFormat.ts @@ -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; +} diff --git a/app/src/viewer/viewerState.test.ts b/app/src/viewer/viewerState.test.ts index abfa131..5383619 100644 --- a/app/src/viewer/viewerState.test.ts +++ b/app/src/viewer/viewerState.test.ts @@ -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); diff --git a/app/src/viewer/viewerState.ts b/app/src/viewer/viewerState.ts index e399f5a..b0b8a4b 100644 --- a/app/src/viewer/viewerState.ts +++ b/app/src/viewer/viewerState.ts @@ -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 { diff --git a/app/src/vite-env.d.ts b/app/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/app/src/vite-env.d.ts @@ -0,0 +1 @@ +///