import { assert, assertEquals } from "@std/assert"; import { EditorView } from "@codemirror/view"; import { redo, undo } from "@codemirror/commands"; import { until } from "@berry/util"; import type { Save, Saved } from "./edit-types.ts"; import type { JsonValue } from "@berry/api"; import type { View, ViewContext } from "@berry/api/web.ts"; import { fill, press, viewContext, withDom as dom } from "@berry/util/dom.ts"; import { fileKey, sending } from "./model.ts"; import type { FileDiff, Reply, Revision } from "./types.ts"; import { diffView } from "./view.tsx"; import { recoverClosed } from "./drafts.ts"; const withDom: typeof dom = (test, options = {}) => dom(test, { ...options, globals: { IntersectionObserver: undefined, ...options.globals } }); const revision = (route: string, description: string): Revision => ({ route, change: { full: (route || "kpnvutls").padEnd(32, "k"), prefix: route ? route.slice(0, 2) : "kp", rest: route ? route.slice(2, 8) : "nvutls", }, commit: { full: "74ea55bb".repeat(5), prefix: "7", rest: "4ea55bb" }, description, author: { name: "Test", email: "test@example.com", timestamp: "2026-01-01T00:00:00Z" }, refs: [], workingCopy: route === "", empty: false, immutable: false, conflict: false, }); const LOG = [revision("", ""), revision("vvkvpomk", "subscription tweaks\n")]; const modified = (lines: string[]): FileDiff => ({ path: "src/total.ts", from: null, status: "modified", modes: { old: "100644", new: "100644" }, binary: false, hunks: [{ old: 1, new: 1, lines }], }); const LONG = [ ...Array.from({ length: 12 }, (_, i) => ` line ${i + 1}`), "-const total = a + b;", "+const sum = a + b;", " end", ]; function fixture( files: (route: string) => FileDiff[], { vcs = "jj", log = LOG }: { vcs?: Reply["vcs"]; log?: Revision[] } = {}, ) { const calls: { route: string; limit: number; known: string[] }[] = []; const context = viewContext({ latest: 1, href: (route) => `#/3mxd6epszps5d/diff/${route}`, request(name, args) { assertEquals(name, "show"); const { route, limit, known } = args as { route: string; limit: number; known: string[] }; calls.push({ route, limit, known }); const reply: Reply = { vcs, root: "/repo", log, revision: log.find((r) => r.route === route)!, files: sending(files(route), known), omitted: 0, }; return Promise.resolve(reply as unknown as JsonValue); }, }); return { calls, context }; } const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); async function open(context: ViewContext): Promise { const view = diffView(); document.body.append(view.element); view.update(context); await settle(); return view; } const text = (element: Element, selector: string) => [...element.querySelectorAll(selector)].map((node) => node.textContent); Deno.test("the view shows the working copy and its history, and follows links and new entries", () => withDom(async () => { const working = modified([" a", "-b", "+c"]); const { calls, context } = fixture((route) => route === "" ? [working] : [{ ...modified(["+x"]), status: "added" }] ); const view = await open(context); const links = [...view.element.querySelectorAll(".diff-log a")]; assertEquals(links.map((link) => link.getAttribute("href")), [ "#/3mxd6epszps5d/diff/", "#/3mxd6epszps5d/diff/vvkvpomk", ]); assertEquals(links.map((link) => link.getAttribute("aria-current")), ["page", null]); assertEquals(text(view.element, ".path"), ["src/total.ts"]); assertEquals(text(view.element, "table.diff td.text"), ["a", "b", "c"]); // a new entry asks again, saying which changes the view has, which it keeps drawn const table = view.element.querySelector("table.diff"); view.update({ ...context, latest: 2 }); await settle(); assertEquals(view.element.querySelector("table.diff"), table); assertEquals([...view.element.querySelectorAll(".diff-log a")], links); view.update({ ...context, latest: 2, route: "vvkvpomk" }); await settle(); assertEquals(calls, [ { route: "", limit: 100, known: [] }, { route: "", limit: 100, known: [fileKey(working)] }, { route: "vvkvpomk", limit: 100, known: [fileKey(working)] }, ]); assertEquals( [...view.element.querySelectorAll(".diff-log a")].map((a) => a.getAttribute("aria-current")), [null, "page"], ); assertEquals(text(view.element, ".commit-subject"), ["subscription tweaks"]); assertEquals(text(view.element, "table.diff td.text"), ["x"]); view.dispose?.(); })); Deno.test("descriptions reword in place, keeping drafts across redraws and failed saves", () => withDom(async () => { const log = [revision("", ""), revision("vvkvpomk", "subject\n\nfull body\n")]; const { context: base } = fixture(() => [], { log }); let reject = true; const calls: JsonValue[] = []; const context: ViewContext = { ...base, route: log[1].route, async request(name, args, options) { if (name !== "reword") return await base.request(name, args, options); calls.push(args!); if (reject) throw new Error("description changed elsewhere"); log[1] = { ...log[1], description: (args as { description: string }).description }; return null; }, }; const view = await open(context); try { view.element.querySelector(".commit-body")!.dispatchEvent( new MouseEvent("dblclick", { bubbles: true }), ); const input = view.element.querySelector(".diff-head textarea")!; assertEquals(input.value, "subject\n\nfull body\n"); assertEquals(view.element.querySelector(".commit-subject"), null); fill(input, "new subject\n\nnew body"); view.update({ ...context, latest: 2 }); await settle(); assertEquals(view.element.querySelector(".diff-head textarea"), input); assertEquals(input.value, "new subject\n\nnew body"); assertEquals(document.activeElement, input); const save = view.element.querySelector(".commit-reword button")!; save.click(); await settle(); assert( view.element.querySelector('[role="alert"]')!.textContent!.includes("changed elsewhere"), ); assertEquals(input.value, "new subject\n\nnew body"); assertEquals(save.disabled, false); reject = false; press(input, "Enter", { ctrlKey: true }); await settle(); assertEquals(calls.length, 2); assertEquals(calls[1], { root: "/repo", change: log[1].change!.full, before: "subject\n\nfull body\n", description: "new subject\n\nnew body", }); assertEquals(view.element.querySelector("textarea"), null); assertEquals(text(view.element, ".commit-subject"), ["new subject"]); assertEquals(text(view.element, ".commit-body"), ["new body"]); press(view.element.querySelector(".commit-subject")!, "F2"); const again = view.element.querySelector(".diff-head textarea")!; assertEquals(again.value, "new subject\n\nnew body"); press(again, "Escape"); assertEquals(view.element.querySelector("textarea"), null); assertEquals(calls.length, 2); } finally { view.dispose?.(); } })); Deno.test("a description draft is thrown away on navigation or when the description changes", () => withDom(async () => { const log = [revision("", ""), revision("vvkvpomk", "original\n")]; const { context: base } = fixture(() => [], { log }); const context = { ...base, route: log[1].route }; const view = await open(context); const edit = () => { view.element.querySelector(".commit-subject")!.dispatchEvent(new MouseEvent("dblclick")); return view.element.querySelector(".diff-head textarea")!; }; fill(edit(), "draft"); view.update({ ...context, route: "" }); assertEquals(view.element.querySelector("textarea"), null); await settle(); view.update(context); await settle(); assertEquals(edit().value, "original\n"); log[1] = { ...log[1], description: "edited elsewhere\n" }; view.update({ ...context, latest: 2 }); await settle(); assertEquals(view.element.querySelector("textarea"), null); assertEquals(text(view.element, ".commit-subject"), ["edited elsewhere"]); view.dispose?.(); })); Deno.test("the index counts each file's lines and totals separate generated edits across refreshes", () => withDom(async () => { const source = { ...modified([]), hunks: [ { old: 1, new: 1, lines: [" context", "-old", "+new", "\\ No newline at end of file"] }, { old: 20, new: 20, lines: ["+extra"] }, ], }; let lock = { ...modified(["-old", "+a", "+b", "+c"]), path: "nested/Cargo.lock" }; const binary = { ...modified([]), path: "image.png", binary: true }; const { context } = fixture(() => [source, lock, binary]); const view = await open(context); try { assertEquals(text(view.element, ".diff-index .add-count"), ["+2", "+3", "+0"]); assertEquals(text(view.element, ".diff-index .del-count"), ["-1", "-1", "-0"]); assertEquals(text(view.element, ".diff-toolbar .add-count"), ["+2 (+3)"]); assertEquals(text(view.element, ".diff-toolbar .del-count"), ["-1 (-1)"]); lock = { ...lock, hunks: modified(["+a"]).hunks }; view.update({ ...context, latest: 2 }); await settle(); assertEquals(text(view.element, ".diff-index .add-count"), ["+2", "+1", "+0"]); assertEquals(text(view.element, ".diff-index .del-count"), ["-1", "-0", "-0"]); assertEquals(text(view.element, ".diff-toolbar .add-count"), ["+2 (+1)"]); assertEquals(text(view.element, ".diff-toolbar .del-count"), ["-1"]); } finally { view.dispose?.(); } })); Deno.test("an unchanged refresh preserves the heading, index focus, and diff rows", () => withDom(async () => { const { context } = fixture(() => [modified(LONG)]); const view = await open(context); try { const subject = view.element.querySelector(".commit-subject"); const index = view.element.querySelector(".diff-index"); const table = view.element.querySelector("table.diff"); const link = index!.querySelector("button")!; link.focus(); view.update({ ...context, latest: 2 }); assertEquals(view.element.classList.contains("loading"), false); assert(view.element.querySelector(".diff-head")!.classList.contains("loading")); await settle(); assertEquals(view.element.querySelector(".commit-subject"), subject); assertEquals(view.element.querySelector(".diff-index"), index); assertEquals(view.element.querySelector("table.diff"), table); assertEquals(document.activeElement, link); } finally { view.dispose?.(); } })); Deno.test("unchanged stretches unfold on request, and changed words are marked", () => withDom(async () => { const { context } = fixture(() => [modified(LONG)]); const view = await open(context); const fold = view.element.querySelector(".fold")!; assertEquals(text(view.element, "table.diff td.text").length, 6); fold.click(); assertEquals(text(view.element, "table.diff td.text").length, 15); assertEquals(text(view.element, "mark.word"), ["total", "sum"]); view.dispose?.(); })); Deno.test("a viewed file folds away, and stays viewed until it changes again", () => withDom(async () => { let lines = LONG; const { context } = fixture(() => [modified(lines)]); const first = await open(context); const box = first.element.querySelector(".viewed input")!; box.click(); assert(first.element.querySelector(".file-body")!.hidden); first.dispose?.(); const again = await open(context); assert(again.element.querySelector(".viewed input")!.checked); assert(again.element.querySelector(".file-body")!.hidden); again.dispose?.(); lines = [...LONG, "+more"]; const changed = await open(context); assertEquals( changed.element.querySelector(".viewed input")!.checked, false, ); assertEquals(changed.element.querySelector(".file-body")!.hidden, false); changed.dispose?.(); })); Deno.test("side by side, removed and added lines share rows, and the choice is remembered", () => withDom(async () => { const { context } = fixture(() => [modified([" a", "-b", "+B", "+c"])]); const first = await open(context); const split = [...first.element.querySelectorAll(".diff-layout button")] .find((button) => button.textContent === "split")!; split.click(); const rows = (view: View) => [...view.element.querySelectorAll("table.diff tr")].map((row) => [...row.querySelectorAll(".text")].map((cell) => cell.textContent).join("|") ); await until(() => !!first.element.querySelector("table.diff.split")); assertEquals(rows(first), ["a|a", "b|B", "|c"]); first.dispose?.(); const again = await open(context); assertEquals(rows(again), ["a|a", "b|B", "|c"]); again.dispose?.(); })); const button = (element: Element, label: string) => [...element.querySelectorAll("button")].find((b) => b.textContent?.trim() === label )!; const editorOf = (view: View) => EditorView.findFromDOM(view.element.querySelector(".cm-merge-b")!)!; const status = (view: View) => view.element.querySelector('[role="status"]')?.textContent; const plain = (view: View) => until(() => !view.element.querySelector(".cm-editor")); const insert = (editor: EditorView, text: string) => editor.dispatch({ changes: { from: 0, insert: text } }); const OUTSIDE = { tid: "other", extension: null, route: "" }; /** A page served over plain http, which has no `crypto.randomUUID`. */ const withPage = (test: Parameters>[0]) => withDom(test, { globals: { crypto: { getRandomValues: crypto.getRandomValues.bind(crypto) } } }); async function edit(view: View) { button(view.element, "edit").click(); await until(() => !!view.element.querySelector(".cm-editor")); return editorOf(view); } /** * A file to edit, whose disk answers as the extension would: opens read it, a save writes it if its version still * holds, once per save id, and exits record their reports. Hooks fail or hold a save before or after it writes, or * a report. */ function editable( { content = "new\n", original = "", path = "src/total.ts", conflicted = false } = {}, ) { const disk = { content, version: "v0" }; const lines = (text: string, mark: string) => text.split("\n").filter((line) => line !== "").map((line) => mark + line); const file = () => ({ ...modified([...lines(original, "-"), ...lines(disk.content, "+")]), path, }); const base = fixture(() => [file()], { log: [{ ...LOG[0], conflict: conflicted }, { ...LOG[1], conflict: conflicted }], }); const saves: Save[] = []; const reports: string[] = []; const opens: string[] = []; const receipts = new Map(); const hooks: { save?(): Promise | void; saved?(): void; finish?(): Promise | void; } = {}; const context: ViewContext = { ...base.context, async request(name, args, options) { if (name === "save") { const save = args as unknown as Save; saves.push(structuredClone(save)); await hooks.save?.(); let receipt = receipts.get(save.id); if (!receipt) { if (save.version !== disk.version) { throw new Error("This file changed on disk since you opened it."); } Object.assign(disk, { content: save.content, version: `v${receipts.size + 1}` }); const { id, working, path } = save; receipt = { id, working, path, version: disk.version, patch: "human edits" }; receipts.set(id, receipt); } hooks.saved?.(); return receipt as unknown as JsonValue; } if (name === "open") { const route = (args as { route: string }).route; opens.push(route); return { working: { machine: "local", root: "/repo", vcs: "jj", identity: "change", ...(route ? { change: route } : {}), }, file: file(), original, ...disk, } as unknown as JsonValue; } return base.context.request(name, args, options); }, async command(name, args = "") { assertEquals(name, "diff-finish"); reports.push((JSON.parse(args) as { session: string }).session); await hooks.finish?.(); return null; }, }; return { context, disk, saves, reports, opens, hooks }; } Deno.test("new-side editing survives refresh and failed saves, and reports saved edits only on leaving the view", () => withPage(async () => { const { context, saves, reports, hooks } = editable({ original: "old\n" }); hooks.save = () => { if (saves.length === 1) throw new Error("Offline"); }; const view = await open(context); const dblclick = (selector: string) => view.element.querySelector(selector)!.dispatchEvent( new MouseEvent("dblclick", { bubbles: true }), ); try { dblclick("td.text.del"); await settle(); assertEquals(view.element.querySelector(".cm-editor"), null); dblclick("td.text.add"); await until(() => !!view.element.querySelector(".cm-editor")); const editor = editorOf(view); editor.dispatch({ changes: { from: 0, to: 3, insert: "human\nextra" } }); view.update({ ...context, latest: 2 }); await settle(); assertEquals(editorOf(view), editor); button(view.element, "save").click(); await until(() => status(view) === "Offline"); assertEquals(editor.state.doc.toString(), "human\nextra\n"); button(view.element, "save").click(); await plain(view); assertEquals(text(view.element, "table.diff td.text"), ["old", "human", "extra"]); assertEquals(saves[1], saves[0]); const history = { tid: context.tid, extension: "diff", route: "vvkvpomk" }; assertEquals(await view.beforeLeave!(history), true); assertEquals(reports, []); assertEquals(await view.beforeLeave!(OUTSIDE), true); assertEquals(reports, [saves[0].session]); } finally { view.dispose?.(); } })); Deno.test("Save closes clean editors but retains edits made while saving", () => withPage(async () => { const { context, disk, saves, hooks } = editable(); const saving = Promise.withResolvers(); hooks.save = () => saving.promise; const view = await open(context); try { const editor = await edit(view); insert(editor, "saved "); button(view.element, "save").click(); await until(() => saves.length === 1); insert(editor, "later "); saving.resolve(); await until(() => status(view) === "unsaved edits"); assertEquals(editorOf(view), editor); assertEquals(editor.state.doc.toString(), "later saved new\n"); assertEquals(disk.content, "saved new\n"); button(view.element, "save").click(); await plain(view); assertEquals(text(view.element, "table.diff td.text"), ["later saved new"]); assertEquals(disk.content, "later saved new\n"); await edit(view); button(view.element, "save").click(); await plain(view); assertEquals(saves.length, 2); } finally { saving.resolve(); view.dispose?.(); } })); Deno.test("leaving with unsaved edits can stay, or discard them and still report the saves before", () => withPage(async () => { const { context, saves, reports, hooks } = editable(); hooks.saved = () => { throw new Error("Save response lost"); }; const reporting = Promise.withResolvers(); hooks.finish = () => reporting.promise; const view = await open(context); try { const editor = await edit(view); insert(editor, "saved "); button(view.element, "save").click(); await until(() => status(view) === "Save response lost"); insert(editor, "unsaved "); const stay = view.beforeLeave!(OUTSIDE); await until(() => !!document.querySelector("dialog")); button(document.body, "stay").click(); assertEquals(await stay, false); assertEquals(editor.state.doc.toString(), "unsaved saved new\n"); const leaving = view.beforeLeave!(OUTSIDE); await until(() => !!document.querySelector("dialog")); button(document.body, "discard and leave").click(); await until(() => reports.length === 1); // Reporting the saves can wait for the agent's turn, and must not hold navigation open. assertEquals(await leaving, true); assertEquals((view.element as HTMLElement).inert, false); assertEquals(view.element.querySelector(".cm-editor"), null); reporting.resolve(); await settle(); assertEquals(saves.map((save) => save.content), ["saved new\n"]); } finally { reporting.resolve(); view.dispose?.(); } })); Deno.test("a failed exit report is recoverable after the view is reloaded", () => withPage(async () => { const { context, reports, hooks } = editable(); hooks.finish = () => { if (reports.length === 1) throw new Error("Offline"); }; const first = await open(context); insert(await edit(first), "human "); button(first.element, "save").click(); await plain(first); // A failed report must not hold navigation open; it stays to retry on a later visit. assertEquals(await first.beforeLeave!(OUTSIDE), true); await until(() => reports.length === 1); await until(() => first.element.textContent?.includes("Could not report")); first.dispose?.(); first.element.remove(); const reloaded = await open(context); try { assertEquals(reloaded.element.querySelector(".cm-editor"), null); assertEquals(text(reloaded.element, "table.diff td.text"), ["human new"]); assertEquals(await reloaded.beforeLeave!(OUTSIDE), true); assertEquals(reports, [reports[0], reports[0]]); } finally { reloaded.dispose?.(); } })); Deno.test("a closed tab recovers an unacknowledged save before reporting, without saving its newer draft", () => withPage(async (window) => { const { context, saves, reports, hooks } = editable({ path: "__proto__" }); hooks.saved = () => { if (saves.length === 1) throw new Error("Save response lost"); }; const first = await open(context); const editor = await edit(first); insert(editor, "saved "); button(first.element, "save").click(); await until(() => status(first) === "Save response lost"); insert(editor, "unsaved "); window.dispatchEvent(new window.Event("pagehide")); first.dispose?.(); first.element.remove(); await recoverClosed(context); await recoverClosed(context); assertEquals(saves.length, 2); assertEquals(saves[1], saves[0]); assertEquals(reports, [saves[0].session]); const restored = await open(context); try { await until(() => !!restored.element.querySelector(".cm-editor")); assertEquals(editorOf(restored).state.doc.toString(), "unsaved saved new\n"); } finally { restored.dispose?.(); } })); Deno.test("leaving the diff reports saved edits without waiting, in a fresh session afterwards", () => withPage(async () => { const { context, saves, reports, hooks } = editable(); const reporting = Promise.withResolvers(); hooks.finish = () => reports.length === 1 ? reporting.promise : undefined; const here = { tid: context.tid, extension: "diff", route: context.route }; const view = await open(context); try { let editor = await edit(view); insert(editor, "first "); button(view.element, "save").click(); await plain(view); await edit(view); const leaving = view.beforeLeave!(OUTSIDE); await until(() => reports.length === 1); // A busy agent holds the report open, which must not hold navigation open. assertEquals(await leaving, true); assertEquals(view.element.querySelector(".cm-editor"), null); const returning = view.beforeLeave!(here); assertEquals(await returning, true); reporting.resolve(); await settle(); editor = await edit(view); insert(editor, "second "); button(view.element, "save").click(); await plain(view); assertEquals(saves.length, 2); assert(saves[1].session !== saves[0].session); editor = await edit(view); insert(editor, "unsaved "); const exit = view.beforeLeave!(OUTSIDE); await until(() => !!document.querySelector("dialog")); const back = view.beforeLeave!(here); assertEquals(await exit, false); assertEquals(await back, false); assertEquals(document.querySelector("dialog"), null); assertEquals(editor.state.doc.toString(), "unsaved second first new\n"); assertEquals(reports, [saves[0].session]); } finally { reporting.resolve(); view.dispose?.(); } })); Deno.test("Ctrl+Enter saves; cancel asks before discarding unsaved edits, Escape twice does not, and neither touches saves", () => withPage(async () => { const { context, disk, saves, hooks } = editable(); let confirmations = 0; let discard = false; document.defaultView!.confirm = () => (confirmations++, discard); const saving = Promise.withResolvers(); hooks.save = () => saving.promise; const mod = /Mac/.test(navigator.platform) ? { metaKey: true } : { ctrlKey: true }; const key = (editor: EditorView, key: string, init: KeyboardEventInit = {}) => editor.contentDOM.dispatchEvent( new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }), ); const view = await open(context); try { await edit(view); button(view.element, "cancel").click(); assertEquals(view.element.querySelector(".cm-editor"), null); assertEquals(confirmations, 0); let editor = await edit(view); insert(editor, "draft "); button(view.element, "cancel").click(); assertEquals(editorOf(view), editor); discard = true; button(view.element, "cancel").click(); assertEquals(view.element.querySelector(".cm-editor"), null); editor = await edit(view); assertEquals(editor.state.doc.toString(), "new\n"); insert(editor, "saved "); key(editor, "Enter", mod); await until(() => saves.length === 1); assert(button(view.element, "cancel").disabled); key(editor, "Escape"); key(editor, "Escape"); assertEquals(editorOf(view), editor); saving.resolve(); await plain(view); editor = await edit(view); insert(editor, "unsaved "); key(editor, "Escape"); assertEquals(editorOf(view), editor); insert(editor, "more "); key(editor, "Escape"); assertEquals(editorOf(view), editor); key(editor, "Escape"); assertEquals(view.element.querySelector(".cm-editor"), null); assertEquals(confirmations, 2); assertEquals(saves.length, 1); assertEquals(disk.content, "saved new\n"); } finally { saving.resolve(); view.dispose?.(); } })); Deno.test("disk changes merge into an open draft, keeping its cursor and undo, and overlaps wait for a choice", () => withPage(async () => { const { context, disk, saves } = editable({ content: "a\nb\nc\n" }); const agent = async (content: string, latest: number) => { Object.assign(disk, { content, version: `agent${latest}` }); view.update({ ...context, latest }); await settle(); }; const view = await open(context); try { let editor = await edit(view); editor.dispatch({ changes: { from: 0, insert: "mine " }, selection: { anchor: 6 } }); await agent("a\nb\nc agent\n", 2); await until(() => status(view) === "merged changes from disk"); assertEquals(editorOf(view), editor); assertEquals(editor.state.doc.toString(), "mine a\nb\nc agent\n"); assertEquals(editor.state.selection.main.head, 6); undo(editor); assertEquals(editor.state.doc.toString(), "a\nb\nc agent\n"); redo(editor); await agent("A\nb\nc agent\n", 3); await until(() => !!status(view)?.includes("where you edited")); assertEquals(editor.state.doc.toString(), "mine a\nb\nc agent\n"); button(view.element, "keep mine").click(); button(view.element, "save").click(); await plain(view); assertEquals(saves.at(-1)!.version, "agent3"); assertEquals(disk.content, "mine a\nb\nc agent\n"); editor = await edit(view); editor.dispatch({ changes: { from: editor.state.doc.length, insert: "end\n" } }); Object.assign(disk, { content: "mine a\nB\nc agent\n", version: "unseen" }); button(view.element, "save").click(); await until(() => editor.state.doc.toString() === "mine a\nB\nc agent\nend\n"); assertEquals(editorOf(view), editor); assertEquals(disk.content, "mine a\nB\nc agent\n"); await agent("other\n", 5); await until(() => !!status(view)?.includes("where you edited")); button(view.element, "take disk's").click(); assertEquals(editor.state.doc.toString(), "other\n"); undo(editor); assertEquals(editor.state.doc.toString(), "mine a\nB\nc agent\nend\n"); } finally { view.dispose?.(); } })); Deno.test("a jj commit's files are editable, and a save that would conflict later commits can be forced", () => withPage(async () => { const { context, saves, opens, hooks } = editable(); hooks.save = () => { if (!saves.at(-1)!.force) { throw new Error("Saving would cause conflicts in 1 later commit (wmqqymrk)."); } }; const view = await open({ ...context, route: "vvkvpomk" }); try { insert(await edit(view), "human "); assert(opens.length > 0 && opens.every((route) => route === "vvkvpomk")); button(view.element, "save").click(); await until(() => !!status(view)?.includes("Save anyway")); button(view.element, "save anyway").click(); await plain(view); assertEquals(saves.map((save) => [save.id, save.force]), [ [saves[0].id, undefined], [saves[0].id, true], ]); } finally { view.dispose?.(); } })); Deno.test("a conflicted commit's files can still be edited", () => withPage(async () => { const { context, saves } = editable({ conflicted: true }); const view = await open({ ...context, route: "vvkvpomk" }); try { insert(await edit(view), "human "); button(view.element, "save").click(); await plain(view); assertEquals(saves.map((save) => [save.working.change, save.content]), [ ["vvkvpomk", "human new\n"], ]); } finally { view.dispose?.(); } })); Deno.test("flush reports saved edits without leaving, in a fresh session afterwards", () => withPage(async () => { const { context, saves, reports } = editable(); const view = await open(context); try { insert(await edit(view), "first "); button(view.element, "save").click(); await plain(view); assertEquals(reports, []); await view.flush!(); assertEquals(reports, [saves[0].session]); // a second flush has nothing new to report await view.flush!(); assertEquals(reports, [saves[0].session]); assertEquals(view.element.querySelector(".cm-editor"), null); insert(await edit(view), "second "); button(view.element, "save").click(); await plain(view); assert(saves[1].session !== saves[0].session); await view.flush!(); assertEquals(reports, [saves[0].session, saves[1].session]); // leaving afterwards has nothing new to report assertEquals(await view.beforeLeave!(OUTSIDE), true); assertEquals(reports, [saves[0].session, saves[1].session]); } finally { view.dispose?.(); } })); Deno.test("flush keeps dirty drafts unsaved and a failed flush stays to retry", () => withPage(async () => { const { context, saves, reports, hooks } = editable(); let fail = false; hooks.finish = () => { if (fail) throw new Error("Offline"); }; const view = await open(context); try { const editor = await edit(view); insert(editor, "draft "); await view.flush!(); assertEquals(saves, []); assertEquals(reports, []); assertEquals(editor.state.doc.toString(), "draft new\n"); button(view.element, "save").click(); await plain(view); fail = true; let failed = false; try { await view.flush!(); } catch { failed = true; } assertEquals(failed, true); assertEquals(reports, [saves[0].session]); fail = false; await view.flush!(); assertEquals(reports, [saves[0].session, saves[0].session]); } finally { view.dispose?.(); } }));