char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somallow editing conflicted commit diffs80904d5

main
32.8 KiB848 linesraw
1import { assert, assertEquals } from "@std/assert";
2import { EditorView } from "@codemirror/view";
3import { redo, undo } from "@codemirror/commands";
4import { until } from "@berry/util";
5import type { Save, Saved } from "./edit-types.ts";
6import type { JsonValue } from "@berry/api";
7import type { View, ViewContext } from "@berry/api/web.ts";
8import { fill, press, viewContext, withDom as dom } from "@berry/util/dom.ts";
9import { fileKey, sending } from "./model.ts";
10import type { FileDiff, Reply, Revision } from "./types.ts";
11import { diffView } from "./view.tsx";
12import { recoverClosed } from "./drafts.ts";
13
14const withDom: typeof dom = (test, options = {}) =>
15  dom(test, { ...options, globals: { IntersectionObserver: undefined, ...options.globals } });
16
17const revision = (route: string, description: string): Revision => ({
18  route,
19  change: {
20    full: (route || "kpnvutls").padEnd(32, "k"),
21    prefix: route ? route.slice(0, 2) : "kp",
22    rest: route ? route.slice(2, 8) : "nvutls",
23  },
24  commit: { full: "74ea55bb".repeat(5), prefix: "7", rest: "4ea55bb" },
25  description,
26  author: { name: "Test", email: "test@example.com", timestamp: "2026-01-01T00:00:00Z" },
27  refs: [],
28  workingCopy: route === "",
29  empty: false,
30  immutable: false,
31  conflict: false,
32});
33
34const LOG = [revision("", ""), revision("vvkvpomk", "subscription tweaks\n")];
35
36const modified = (lines: string[]): FileDiff => ({
37  path: "src/total.ts",
38  from: null,
39  status: "modified",
40  modes: { old: "100644", new: "100644" },
41  binary: false,
42  hunks: [{ old: 1, new: 1, lines }],
43});
44
45const LONG = [
46  ...Array.from({ length: 12 }, (_, i) => ` line ${i + 1}`),
47  "-const total = a + b;",
48  "+const sum = a + b;",
49  " end",
50];
51
52function fixture(
53  files: (route: string) => FileDiff[],
54  { vcs = "jj", log = LOG }: { vcs?: Reply["vcs"]; log?: Revision[] } = {},
55) {
56  const calls: { route: string; limit: number; known: string[] }[] = [];
57  const context = viewContext({
58    latest: 1,
59    href: (route) => `#/3mxd6epszps5d/diff/${route}`,
60    request(name, args) {
61      assertEquals(name, "show");
62      const { route, limit, known } = args as { route: string; limit: number; known: string[] };
63      calls.push({ route, limit, known });
64      const reply: Reply = {
65        vcs,
66        root: "/repo",
67        log,
68        revision: log.find((r) => r.route === route)!,
69        files: sending(files(route), known),
70        omitted: 0,
71      };
72      return Promise.resolve(reply as unknown as JsonValue);
73    },
74  });
75  return { calls, context };
76}
77
78const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
79
80async function open(context: ViewContext): Promise<View> {
81  const view = diffView();
82  document.body.append(view.element);
83  view.update(context);
84  await settle();
85  return view;
86}
87
88const text = (element: Element, selector: string) =>
89  [...element.querySelectorAll(selector)].map((node) => node.textContent);
90
91Deno.test("the view shows the working copy and its history, and follows links and new entries", () =>
92  withDom(async () => {
93    const working = modified([" a", "-b", "+c"]);
94    const { calls, context } = fixture((route) =>
95      route === "" ? [working] : [{ ...modified(["+x"]), status: "added" }]
96    );
97    const view = await open(context);
98    const links = [...view.element.querySelectorAll<HTMLAnchorElement>(".diff-log a")];
99    assertEquals(links.map((link) => link.getAttribute("href")), [
100      "#/3mxd6epszps5d/diff/",
101      "#/3mxd6epszps5d/diff/vvkvpomk",
102    ]);
103    assertEquals(links.map((link) => link.getAttribute("aria-current")), ["page", null]);
104    assertEquals(text(view.element, ".path"), ["src/total.ts"]);
105    assertEquals(text(view.element, "table.diff td.text"), ["a", "b", "c"]);
106
107    // a new entry asks again, saying which changes the view has, which it keeps drawn
108    const table = view.element.querySelector("table.diff");
109    view.update({ ...context, latest: 2 });
110    await settle();
111    assertEquals(view.element.querySelector("table.diff"), table);
112    assertEquals([...view.element.querySelectorAll(".diff-log a")], links);
113    view.update({ ...context, latest: 2, route: "vvkvpomk" });
114    await settle();
115    assertEquals(calls, [
116      { route: "", limit: 100, known: [] },
117      { route: "", limit: 100, known: [fileKey(working)] },
118      { route: "vvkvpomk", limit: 100, known: [fileKey(working)] },
119    ]);
120    assertEquals(
121      [...view.element.querySelectorAll(".diff-log a")].map((a) => a.getAttribute("aria-current")),
122      [null, "page"],
123    );
124    assertEquals(text(view.element, ".commit-subject"), ["subscription tweaks"]);
125    assertEquals(text(view.element, "table.diff td.text"), ["x"]);
126    view.dispose?.();
127  }));
128
129Deno.test("descriptions reword in place, keeping drafts across redraws and failed saves", () =>
130  withDom(async () => {
131    const log = [revision("", ""), revision("vvkvpomk", "subject\n\nfull body\n")];
132    const { context: base } = fixture(() => [], { log });
133    let reject = true;
134    const calls: JsonValue[] = [];
135    const context: ViewContext = {
136      ...base,
137      route: log[1].route,
138      async request(name, args, options) {
139        if (name !== "reword") return await base.request(name, args, options);
140        calls.push(args!);
141        if (reject) throw new Error("description changed elsewhere");
142        log[1] = { ...log[1], description: (args as { description: string }).description };
143        return null;
144      },
145    };
146    const view = await open(context);
147    try {
148      view.element.querySelector(".commit-body")!.dispatchEvent(
149        new MouseEvent("dblclick", { bubbles: true }),
150      );
151      const input = view.element.querySelector<HTMLTextAreaElement>(".diff-head textarea")!;
152      assertEquals(input.value, "subject\n\nfull body\n");
153      assertEquals(view.element.querySelector(".commit-subject"), null);
154      fill(input, "new subject\n\nnew body");
155      view.update({ ...context, latest: 2 });
156      await settle();
157      assertEquals(view.element.querySelector(".diff-head textarea"), input);
158      assertEquals(input.value, "new subject\n\nnew body");
159      assertEquals(document.activeElement, input);
160
161      const save = view.element.querySelector<HTMLButtonElement>(".commit-reword button")!;
162      save.click();
163      await settle();
164      assert(
165        view.element.querySelector('[role="alert"]')!.textContent!.includes("changed elsewhere"),
166      );
167      assertEquals(input.value, "new subject\n\nnew body");
168      assertEquals(save.disabled, false);
169      reject = false;
170      press(input, "Enter", { ctrlKey: true });
171      await settle();
172      assertEquals(calls.length, 2);
173      assertEquals(calls[1], {
174        root: "/repo",
175        change: log[1].change!.full,
176        before: "subject\n\nfull body\n",
177        description: "new subject\n\nnew body",
178      });
179      assertEquals(view.element.querySelector("textarea"), null);
180      assertEquals(text(view.element, ".commit-subject"), ["new subject"]);
181      assertEquals(text(view.element, ".commit-body"), ["new body"]);
182
183      press(view.element.querySelector(".commit-subject")!, "F2");
184      const again = view.element.querySelector<HTMLTextAreaElement>(".diff-head textarea")!;
185      assertEquals(again.value, "new subject\n\nnew body");
186      press(again, "Escape");
187      assertEquals(view.element.querySelector("textarea"), null);
188      assertEquals(calls.length, 2);
189    } finally {
190      view.dispose?.();
191    }
192  }));
193
194Deno.test("a description draft is thrown away on navigation or when the description changes", () =>
195  withDom(async () => {
196    const log = [revision("", ""), revision("vvkvpomk", "original\n")];
197    const { context: base } = fixture(() => [], { log });
198    const context = { ...base, route: log[1].route };
199    const view = await open(context);
200    const edit = () => {
201      view.element.querySelector(".commit-subject")!.dispatchEvent(new MouseEvent("dblclick"));
202      return view.element.querySelector<HTMLTextAreaElement>(".diff-head textarea")!;
203    };
204    fill(edit(), "draft");
205    view.update({ ...context, route: "" });
206    assertEquals(view.element.querySelector("textarea"), null);
207    await settle();
208    view.update(context);
209    await settle();
210    assertEquals(edit().value, "original\n");
211
212    log[1] = { ...log[1], description: "edited elsewhere\n" };
213    view.update({ ...context, latest: 2 });
214    await settle();
215    assertEquals(view.element.querySelector("textarea"), null);
216    assertEquals(text(view.element, ".commit-subject"), ["edited elsewhere"]);
217    view.dispose?.();
218  }));
219
220Deno.test("the index counts each file's lines and totals separate generated edits across refreshes", () =>
221  withDom(async () => {
222    const source = {
223      ...modified([]),
224      hunks: [
225        { old: 1, new: 1, lines: [" context", "-old", "+new", "\\ No newline at end of file"] },
226        { old: 20, new: 20, lines: ["+extra"] },
227      ],
228    };
229    let lock = { ...modified(["-old", "+a", "+b", "+c"]), path: "nested/Cargo.lock" };
230    const binary = { ...modified([]), path: "image.png", binary: true };
231    const { context } = fixture(() => [source, lock, binary]);
232    const view = await open(context);
233    try {
234      assertEquals(text(view.element, ".diff-index .add-count"), ["+2", "+3", "+0"]);
235      assertEquals(text(view.element, ".diff-index .del-count"), ["-1", "-1", "-0"]);
236      assertEquals(text(view.element, ".diff-toolbar .add-count"), ["+2 (+3)"]);
237      assertEquals(text(view.element, ".diff-toolbar .del-count"), ["-1 (-1)"]);
238
239      lock = { ...lock, hunks: modified(["+a"]).hunks };
240      view.update({ ...context, latest: 2 });
241      await settle();
242      assertEquals(text(view.element, ".diff-index .add-count"), ["+2", "+1", "+0"]);
243      assertEquals(text(view.element, ".diff-index .del-count"), ["-1", "-0", "-0"]);
244      assertEquals(text(view.element, ".diff-toolbar .add-count"), ["+2 (+1)"]);
245      assertEquals(text(view.element, ".diff-toolbar .del-count"), ["-1"]);
246    } finally {
247      view.dispose?.();
248    }
249  }));
250
251Deno.test("an unchanged refresh preserves the heading, index focus, and diff rows", () =>
252  withDom(async () => {
253    const { context } = fixture(() => [modified(LONG)]);
254    const view = await open(context);
255    try {
256      const subject = view.element.querySelector(".commit-subject");
257      const index = view.element.querySelector(".diff-index");
258      const table = view.element.querySelector("table.diff");
259      const link = index!.querySelector<HTMLButtonElement>("button")!;
260      link.focus();
261      view.update({ ...context, latest: 2 });
262      assertEquals(view.element.classList.contains("loading"), false);
263      assert(view.element.querySelector(".diff-head")!.classList.contains("loading"));
264      await settle();
265      assertEquals(view.element.querySelector(".commit-subject"), subject);
266      assertEquals(view.element.querySelector(".diff-index"), index);
267      assertEquals(view.element.querySelector("table.diff"), table);
268      assertEquals(document.activeElement, link);
269    } finally {
270      view.dispose?.();
271    }
272  }));
273
274Deno.test("unchanged stretches unfold on request, and changed words are marked", () =>
275  withDom(async () => {
276    const { context } = fixture(() => [modified(LONG)]);
277    const view = await open(context);
278    const fold = view.element.querySelector<HTMLButtonElement>(".fold")!;
279    assertEquals(text(view.element, "table.diff td.text").length, 6);
280    fold.click();
281    assertEquals(text(view.element, "table.diff td.text").length, 15);
282    assertEquals(text(view.element, "mark.word"), ["total", "sum"]);
283    view.dispose?.();
284  }));
285
286Deno.test("a viewed file folds away, and stays viewed until it changes again", () =>
287  withDom(async () => {
288    let lines = LONG;
289    const { context } = fixture(() => [modified(lines)]);
290    const first = await open(context);
291    const box = first.element.querySelector<HTMLInputElement>(".viewed input")!;
292    box.click();
293    assert(first.element.querySelector<HTMLElement>(".file-body")!.hidden);
294    first.dispose?.();
295
296    const again = await open(context);
297    assert(again.element.querySelector<HTMLInputElement>(".viewed input")!.checked);
298    assert(again.element.querySelector<HTMLElement>(".file-body")!.hidden);
299    again.dispose?.();
300
301    lines = [...LONG, "+more"];
302    const changed = await open(context);
303    assertEquals(
304      changed.element.querySelector<HTMLInputElement>(".viewed input")!.checked,
305      false,
306    );
307    assertEquals(changed.element.querySelector<HTMLElement>(".file-body")!.hidden, false);
308    changed.dispose?.();
309  }));
310
311Deno.test("side by side, removed and added lines share rows, and the choice is remembered", () =>
312  withDom(async () => {
313    const { context } = fixture(() => [modified([" a", "-b", "+B", "+c"])]);
314    const first = await open(context);
315    const split = [...first.element.querySelectorAll<HTMLButtonElement>(".diff-layout button")]
316      .find((button) => button.textContent === "split")!;
317    split.click();
318    const rows = (view: View) =>
319      [...view.element.querySelectorAll("table.diff tr")].map((row) =>
320        [...row.querySelectorAll(".text")].map((cell) => cell.textContent).join("|")
321      );
322    await until(() => !!first.element.querySelector("table.diff.split"));
323    assertEquals(rows(first), ["a|a", "b|B", "|c"]);
324    first.dispose?.();
325    const again = await open(context);
326    assertEquals(rows(again), ["a|a", "b|B", "|c"]);
327    again.dispose?.();
328  }));
329
330const button = (element: Element, label: string) =>
331  [...element.querySelectorAll<HTMLButtonElement>("button")].find((b) =>
332    b.textContent?.trim() === label
333  )!;
334const editorOf = (view: View) =>
335  EditorView.findFromDOM(view.element.querySelector(".cm-merge-b")!)!;
336const status = (view: View) => view.element.querySelector('[role="status"]')?.textContent;
337const plain = (view: View) => until(() => !view.element.querySelector(".cm-editor"));
338const insert = (editor: EditorView, text: string) =>
339  editor.dispatch({ changes: { from: 0, insert: text } });
340const OUTSIDE = { tid: "other", extension: null, route: "" };
341
342/** A page served over plain http, which has no `crypto.randomUUID`. */
343const withPage = <T>(test: Parameters<typeof withDom<T>>[0]) =>
344  withDom(test, { globals: { crypto: { getRandomValues: crypto.getRandomValues.bind(crypto) } } });
345
346async function edit(view: View) {
347  button(view.element, "edit").click();
348  await until(() => !!view.element.querySelector(".cm-editor"));
349  return editorOf(view);
350}
351
352/**
353 * A file to edit, whose disk answers as the extension would: opens read it, a save writes it if its version still
354 * holds, once per save id, and exits record their reports. Hooks fail or hold a save before or after it writes, or
355 * a report.
356 */
357function editable(
358  { content = "new\n", original = "", path = "src/total.ts", conflicted = false } = {},
359) {
360  const disk = { content, version: "v0" };
361  const lines = (text: string, mark: string) =>
362    text.split("\n").filter((line) => line !== "").map((line) => mark + line);
363  const file = () => ({
364    ...modified([...lines(original, "-"), ...lines(disk.content, "+")]),
365    path,
366  });
367  const base = fixture(() => [file()], {
368    log: [{ ...LOG[0], conflict: conflicted }, { ...LOG[1], conflict: conflicted }],
369  });
370  const saves: Save[] = [];
371  const reports: string[] = [];
372  const opens: string[] = [];
373  const receipts = new Map<string, Saved>();
374  const hooks: {
375    save?(): Promise<void> | void;
376    saved?(): void;
377    finish?(): Promise<void> | void;
378  } = {};
379  const context: ViewContext = {
380    ...base.context,
381    async request(name, args, options) {
382      if (name === "save") {
383        const save = args as unknown as Save;
384        saves.push(structuredClone(save));
385        await hooks.save?.();
386        let receipt = receipts.get(save.id);
387        if (!receipt) {
388          if (save.version !== disk.version) {
389            throw new Error("This file changed on disk since you opened it.");
390          }
391          Object.assign(disk, { content: save.content, version: `v${receipts.size + 1}` });
392          const { id, working, path } = save;
393          receipt = { id, working, path, version: disk.version, patch: "human edits" };
394          receipts.set(id, receipt);
395        }
396        hooks.saved?.();
397        return receipt as unknown as JsonValue;
398      }
399      if (name === "open") {
400        const route = (args as { route: string }).route;
401        opens.push(route);
402        return {
403          working: {
404            machine: "local",
405            root: "/repo",
406            vcs: "jj",
407            identity: "change",
408            ...(route ? { change: route } : {}),
409          },
410          file: file(),
411          original,
412          ...disk,
413        } as unknown as JsonValue;
414      }
415      return base.context.request(name, args, options);
416    },
417    async command(name, args = "") {
418      assertEquals(name, "diff-finish");
419      reports.push((JSON.parse(args) as { session: string }).session);
420      await hooks.finish?.();
421      return null;
422    },
423  };
424  return { context, disk, saves, reports, opens, hooks };
425}
426
427Deno.test("new-side editing survives refresh and failed saves, and reports saved edits only on leaving the view", () =>
428  withPage(async () => {
429    const { context, saves, reports, hooks } = editable({ original: "old\n" });
430    hooks.save = () => {
431      if (saves.length === 1) throw new Error("Offline");
432    };
433    const view = await open(context);
434    const dblclick = (selector: string) =>
435      view.element.querySelector(selector)!.dispatchEvent(
436        new MouseEvent("dblclick", { bubbles: true }),
437      );
438    try {
439      dblclick("td.text.del");
440      await settle();
441      assertEquals(view.element.querySelector(".cm-editor"), null);
442      dblclick("td.text.add");
443      await until(() => !!view.element.querySelector(".cm-editor"));
444      const editor = editorOf(view);
445      editor.dispatch({ changes: { from: 0, to: 3, insert: "human\nextra" } });
446      view.update({ ...context, latest: 2 });
447      await settle();
448      assertEquals(editorOf(view), editor);
449      button(view.element, "save").click();
450      await until(() => status(view) === "Offline");
451      assertEquals(editor.state.doc.toString(), "human\nextra\n");
452      button(view.element, "save").click();
453      await plain(view);
454      assertEquals(text(view.element, "table.diff td.text"), ["old", "human", "extra"]);
455      assertEquals(saves[1], saves[0]);
456      const history = { tid: context.tid, extension: "diff", route: "vvkvpomk" };
457      assertEquals(await view.beforeLeave!(history), true);
458      assertEquals(reports, []);
459      assertEquals(await view.beforeLeave!(OUTSIDE), true);
460      assertEquals(reports, [saves[0].session]);
461    } finally {
462      view.dispose?.();
463    }
464  }));
465
466Deno.test("Save closes clean editors but retains edits made while saving", () =>
467  withPage(async () => {
468    const { context, disk, saves, hooks } = editable();
469    const saving = Promise.withResolvers<void>();
470    hooks.save = () => saving.promise;
471    const view = await open(context);
472    try {
473      const editor = await edit(view);
474      insert(editor, "saved ");
475      button(view.element, "save").click();
476      await until(() => saves.length === 1);
477      insert(editor, "later ");
478      saving.resolve();
479      await until(() => status(view) === "unsaved edits");
480      assertEquals(editorOf(view), editor);
481      assertEquals(editor.state.doc.toString(), "later saved new\n");
482      assertEquals(disk.content, "saved new\n");
483
484      button(view.element, "save").click();
485      await plain(view);
486      assertEquals(text(view.element, "table.diff td.text"), ["later saved new"]);
487      assertEquals(disk.content, "later saved new\n");
488
489      await edit(view);
490      button(view.element, "save").click();
491      await plain(view);
492      assertEquals(saves.length, 2);
493    } finally {
494      saving.resolve();
495      view.dispose?.();
496    }
497  }));
498
499Deno.test("leaving with unsaved edits can stay, or discard them and still report the saves before", () =>
500  withPage(async () => {
501    const { context, saves, reports, hooks } = editable();
502    hooks.saved = () => {
503      throw new Error("Save response lost");
504    };
505    const reporting = Promise.withResolvers<void>();
506    hooks.finish = () => reporting.promise;
507    const view = await open(context);
508    try {
509      const editor = await edit(view);
510      insert(editor, "saved ");
511      button(view.element, "save").click();
512      await until(() => status(view) === "Save response lost");
513      insert(editor, "unsaved ");
514      const stay = view.beforeLeave!(OUTSIDE);
515      await until(() => !!document.querySelector("dialog"));
516      button(document.body, "stay").click();
517      assertEquals(await stay, false);
518      assertEquals(editor.state.doc.toString(), "unsaved saved new\n");
519      const leaving = view.beforeLeave!(OUTSIDE);
520      await until(() => !!document.querySelector("dialog"));
521      button(document.body, "discard and leave").click();
522      await until(() => reports.length === 1);
523      // Reporting the saves can wait for the agent's turn, and must not hold navigation open.
524      assertEquals(await leaving, true);
525      assertEquals((view.element as HTMLElement).inert, false);
526      assertEquals(view.element.querySelector(".cm-editor"), null);
527      reporting.resolve();
528      await settle();
529      assertEquals(saves.map((save) => save.content), ["saved new\n"]);
530    } finally {
531      reporting.resolve();
532      view.dispose?.();
533    }
534  }));
535
536Deno.test("a failed exit report is recoverable after the view is reloaded", () =>
537  withPage(async () => {
538    const { context, reports, hooks } = editable();
539    hooks.finish = () => {
540      if (reports.length === 1) throw new Error("Offline");
541    };
542    const first = await open(context);
543    insert(await edit(first), "human ");
544    button(first.element, "save").click();
545    await plain(first);
546    // A failed report must not hold navigation open; it stays to retry on a later visit.
547    assertEquals(await first.beforeLeave!(OUTSIDE), true);
548    await until(() => reports.length === 1);
549    await until(() => first.element.textContent?.includes("Could not report"));
550    first.dispose?.();
551    first.element.remove();
552    const reloaded = await open(context);
553    try {
554      assertEquals(reloaded.element.querySelector(".cm-editor"), null);
555      assertEquals(text(reloaded.element, "table.diff td.text"), ["human new"]);
556      assertEquals(await reloaded.beforeLeave!(OUTSIDE), true);
557      assertEquals(reports, [reports[0], reports[0]]);
558    } finally {
559      reloaded.dispose?.();
560    }
561  }));
562
563Deno.test("a closed tab recovers an unacknowledged save before reporting, without saving its newer draft", () =>
564  withPage(async (window) => {
565    const { context, saves, reports, hooks } = editable({ path: "__proto__" });
566    hooks.saved = () => {
567      if (saves.length === 1) throw new Error("Save response lost");
568    };
569    const first = await open(context);
570    const editor = await edit(first);
571    insert(editor, "saved ");
572    button(first.element, "save").click();
573    await until(() => status(first) === "Save response lost");
574    insert(editor, "unsaved ");
575    window.dispatchEvent(new window.Event("pagehide"));
576    first.dispose?.();
577    first.element.remove();
578    await recoverClosed(context);
579    await recoverClosed(context);
580    assertEquals(saves.length, 2);
581    assertEquals(saves[1], saves[0]);
582    assertEquals(reports, [saves[0].session]);
583    const restored = await open(context);
584    try {
585      await until(() => !!restored.element.querySelector(".cm-editor"));
586      assertEquals(editorOf(restored).state.doc.toString(), "unsaved saved new\n");
587    } finally {
588      restored.dispose?.();
589    }
590  }));
591
592Deno.test("leaving the diff reports saved edits without waiting, in a fresh session afterwards", () =>
593  withPage(async () => {
594    const { context, saves, reports, hooks } = editable();
595    const reporting = Promise.withResolvers<void>();
596    hooks.finish = () => reports.length === 1 ? reporting.promise : undefined;
597    const here = { tid: context.tid, extension: "diff", route: context.route };
598    const view = await open(context);
599    try {
600      let editor = await edit(view);
601      insert(editor, "first ");
602      button(view.element, "save").click();
603      await plain(view);
604      await edit(view);
605      const leaving = view.beforeLeave!(OUTSIDE);
606      await until(() => reports.length === 1);
607      // A busy agent holds the report open, which must not hold navigation open.
608      assertEquals(await leaving, true);
609      assertEquals(view.element.querySelector(".cm-editor"), null);
610      const returning = view.beforeLeave!(here);
611      assertEquals(await returning, true);
612      reporting.resolve();
613      await settle();
614      editor = await edit(view);
615      insert(editor, "second ");
616      button(view.element, "save").click();
617      await plain(view);
618      assertEquals(saves.length, 2);
619      assert(saves[1].session !== saves[0].session);
620      editor = await edit(view);
621      insert(editor, "unsaved ");
622      const exit = view.beforeLeave!(OUTSIDE);
623      await until(() => !!document.querySelector("dialog"));
624      const back = view.beforeLeave!(here);
625      assertEquals(await exit, false);
626      assertEquals(await back, false);
627      assertEquals(document.querySelector("dialog"), null);
628      assertEquals(editor.state.doc.toString(), "unsaved second first new\n");
629      assertEquals(reports, [saves[0].session]);
630    } finally {
631      reporting.resolve();
632      view.dispose?.();
633    }
634  }));
635
636Deno.test("Ctrl+Enter saves; cancel asks before discarding unsaved edits, Escape twice does not, and neither touches saves", () =>
637  withPage(async () => {
638    const { context, disk, saves, hooks } = editable();
639    let confirmations = 0;
640    let discard = false;
641    document.defaultView!.confirm = () => (confirmations++, discard);
642    const saving = Promise.withResolvers<void>();
643    hooks.save = () => saving.promise;
644    const mod = /Mac/.test(navigator.platform) ? { metaKey: true } : { ctrlKey: true };
645    const key = (editor: EditorView, key: string, init: KeyboardEventInit = {}) =>
646      editor.contentDOM.dispatchEvent(
647        new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }),
648      );
649    const view = await open(context);
650    try {
651      await edit(view);
652      button(view.element, "cancel").click();
653      assertEquals(view.element.querySelector(".cm-editor"), null);
654      assertEquals(confirmations, 0);
655
656      let editor = await edit(view);
657      insert(editor, "draft ");
658      button(view.element, "cancel").click();
659      assertEquals(editorOf(view), editor);
660      discard = true;
661      button(view.element, "cancel").click();
662      assertEquals(view.element.querySelector(".cm-editor"), null);
663
664      editor = await edit(view);
665      assertEquals(editor.state.doc.toString(), "new\n");
666      insert(editor, "saved ");
667      key(editor, "Enter", mod);
668      await until(() => saves.length === 1);
669      assert(button(view.element, "cancel").disabled);
670      key(editor, "Escape");
671      key(editor, "Escape");
672      assertEquals(editorOf(view), editor);
673      saving.resolve();
674      await plain(view);
675
676      editor = await edit(view);
677      insert(editor, "unsaved ");
678      key(editor, "Escape");
679      assertEquals(editorOf(view), editor);
680      insert(editor, "more ");
681      key(editor, "Escape");
682      assertEquals(editorOf(view), editor);
683      key(editor, "Escape");
684      assertEquals(view.element.querySelector(".cm-editor"), null);
685      assertEquals(confirmations, 2);
686      assertEquals(saves.length, 1);
687      assertEquals(disk.content, "saved new\n");
688    } finally {
689      saving.resolve();
690      view.dispose?.();
691    }
692  }));
693
694Deno.test("disk changes merge into an open draft, keeping its cursor and undo, and overlaps wait for a choice", () =>
695  withPage(async () => {
696    const { context, disk, saves } = editable({ content: "a\nb\nc\n" });
697    const agent = async (content: string, latest: number) => {
698      Object.assign(disk, { content, version: `agent${latest}` });
699      view.update({ ...context, latest });
700      await settle();
701    };
702    const view = await open(context);
703    try {
704      let editor = await edit(view);
705      editor.dispatch({ changes: { from: 0, insert: "mine " }, selection: { anchor: 6 } });
706
707      await agent("a\nb\nc agent\n", 2);
708      await until(() => status(view) === "merged changes from disk");
709      assertEquals(editorOf(view), editor);
710      assertEquals(editor.state.doc.toString(), "mine a\nb\nc agent\n");
711      assertEquals(editor.state.selection.main.head, 6);
712      undo(editor);
713      assertEquals(editor.state.doc.toString(), "a\nb\nc agent\n");
714      redo(editor);
715
716      await agent("A\nb\nc agent\n", 3);
717      await until(() => !!status(view)?.includes("where you edited"));
718      assertEquals(editor.state.doc.toString(), "mine a\nb\nc agent\n");
719      button(view.element, "keep mine").click();
720      button(view.element, "save").click();
721      await plain(view);
722      assertEquals(saves.at(-1)!.version, "agent3");
723      assertEquals(disk.content, "mine a\nb\nc agent\n");
724
725      editor = await edit(view);
726      editor.dispatch({ changes: { from: editor.state.doc.length, insert: "end\n" } });
727      Object.assign(disk, { content: "mine a\nB\nc agent\n", version: "unseen" });
728      button(view.element, "save").click();
729      await until(() => editor.state.doc.toString() === "mine a\nB\nc agent\nend\n");
730      assertEquals(editorOf(view), editor);
731      assertEquals(disk.content, "mine a\nB\nc agent\n");
732
733      await agent("other\n", 5);
734      await until(() => !!status(view)?.includes("where you edited"));
735      button(view.element, "take disk's").click();
736      assertEquals(editor.state.doc.toString(), "other\n");
737      undo(editor);
738      assertEquals(editor.state.doc.toString(), "mine a\nB\nc agent\nend\n");
739    } finally {
740      view.dispose?.();
741    }
742  }));
743
744Deno.test("a jj commit's files are editable, and a save that would conflict later commits can be forced", () =>
745  withPage(async () => {
746    const { context, saves, opens, hooks } = editable();
747    hooks.save = () => {
748      if (!saves.at(-1)!.force) {
749        throw new Error("Saving would cause conflicts in 1 later commit (wmqqymrk).");
750      }
751    };
752    const view = await open({ ...context, route: "vvkvpomk" });
753    try {
754      insert(await edit(view), "human ");
755      assert(opens.length > 0 && opens.every((route) => route === "vvkvpomk"));
756      button(view.element, "save").click();
757      await until(() => !!status(view)?.includes("Save anyway"));
758      button(view.element, "save anyway").click();
759      await plain(view);
760      assertEquals(saves.map((save) => [save.id, save.force]), [
761        [saves[0].id, undefined],
762        [saves[0].id, true],
763      ]);
764    } finally {
765      view.dispose?.();
766    }
767  }));
768
769Deno.test("a conflicted commit's files can still be edited", () =>
770  withPage(async () => {
771    const { context, saves } = editable({ conflicted: true });
772    const view = await open({ ...context, route: "vvkvpomk" });
773    try {
774      insert(await edit(view), "human ");
775      button(view.element, "save").click();
776      await plain(view);
777      assertEquals(saves.map((save) => [save.working.change, save.content]), [
778        ["vvkvpomk", "human new\n"],
779      ]);
780    } finally {
781      view.dispose?.();
782    }
783  }));
784
785Deno.test("flush reports saved edits without leaving, in a fresh session afterwards", () =>
786  withPage(async () => {
787    const { context, saves, reports } = editable();
788    const view = await open(context);
789    try {
790      insert(await edit(view), "first ");
791      button(view.element, "save").click();
792      await plain(view);
793      assertEquals(reports, []);
794      await view.flush!();
795      assertEquals(reports, [saves[0].session]);
796      // a second flush has nothing new to report
797      await view.flush!();
798      assertEquals(reports, [saves[0].session]);
799      assertEquals(view.element.querySelector(".cm-editor"), null);
800
801      insert(await edit(view), "second ");
802      button(view.element, "save").click();
803      await plain(view);
804      assert(saves[1].session !== saves[0].session);
805      await view.flush!();
806      assertEquals(reports, [saves[0].session, saves[1].session]);
807      // leaving afterwards has nothing new to report
808      assertEquals(await view.beforeLeave!(OUTSIDE), true);
809      assertEquals(reports, [saves[0].session, saves[1].session]);
810    } finally {
811      view.dispose?.();
812    }
813  }));
814
815Deno.test("flush keeps dirty drafts unsaved and a failed flush stays to retry", () =>
816  withPage(async () => {
817    const { context, saves, reports, hooks } = editable();
818    let fail = false;
819    hooks.finish = () => {
820      if (fail) throw new Error("Offline");
821    };
822    const view = await open(context);
823    try {
824      const editor = await edit(view);
825      insert(editor, "draft ");
826      await view.flush!();
827      assertEquals(saves, []);
828      assertEquals(reports, []);
829      assertEquals(editor.state.doc.toString(), "draft new\n");
830
831      button(view.element, "save").click();
832      await plain(view);
833      fail = true;
834      let failed = false;
835      try {
836        await view.flush!();
837      } catch {
838        failed = true;
839      }
840      assertEquals(failed, true);
841      assertEquals(reports, [saves[0].session]);
842      fail = false;
843      await view.flush!();
844      assertEquals(reports, [saves[0].session, saves[0].session]);
845    } finally {
846      view.dispose?.();
847    }
848  }));