char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somperf(diff): save drafts once typing pausesbaa4ef6

main
20.0 KiB645 linesraw
1import type { JsonValue } from "@berry/api";
2import type { ViewContext, ViewDestination } from "@berry/api/web.ts";
3import type { DiffEditor, Layout } from "./editor.ts";
4import {
5  type Draft,
6  draftKey,
7  editId,
8  loadDrafts,
9  recover,
10  type Stored,
11  storeDrafts,
12} from "./drafts.ts";
13import type { OpenFile, Saved, WorkingCopy } from "./edit-types.ts";
14import type { FileDiff } from "./types.ts";
15
16type Loaded = {
17  diffEditor: typeof import("./editor.ts").diffEditor;
18  merge3: typeof import("./merge.ts").merge3;
19};
20
21let loaded: Loaded | null = null;
22let loading: Promise<Loaded> | null = null;
23/** CodeMirror and the merge are most of the bundle, and only editing needs them. */
24function load(): Promise<Loaded> {
25  loading ??= Promise.all([import("./editor.ts"), import("./merge.ts")]).then(
26    ([editor, merge]) => loaded = { diffEditor: editor.diffEditor, merge3: merge.merge3 },
27    (error) => {
28      loading = null;
29      throw new Error(
30        `Could not load the editor; reload the page. (${
31          error instanceof Error ? error.message : error
32        })`,
33      );
34    },
35  );
36  return loading;
37}
38
39type Editing = {
40  draft: Draft;
41  element: HTMLElement;
42  editor: DiffEditor;
43  status: HTMLElement;
44  save: HTMLButtonElement;
45  cancel: HTMLButtonElement;
46  force: HTMLButtonElement;
47  saving: Promise<boolean> | null;
48  syncing: Promise<void> | null;
49  /** Whether the disk may have changed since the last look. */
50  stale: boolean;
51  /** The disk's version, while it conflicts with the draft. */
52  conflict: OpenFile | null;
53  resolve: HTMLElement;
54};
55
56/** How long typing goes before its draft is written down for recovery. */
57const TYPED_MS = 250;
58
59export function editing(
60  redraw: (path: string) => void,
61  refreshed: (path: string) => void,
62  failure: (message: string) => void,
63) {
64  let context: ViewContext;
65  let key: string;
66  let stored: Stored;
67  let ready: Promise<void> = Promise.resolve();
68  let disposed = false;
69  let leaving: Promise<boolean> | null = null;
70  /** A report leaving kicked off without holding navigation open, which a flush still waits for. */
71  let backgroundReport: Promise<void> | null = null;
72  let destination: ViewDestination;
73  let cancelChoice: (() => void) | null = null;
74  const edits = new Map<string, Editing>();
75  const opening = new Map<string, Promise<OpenFile>>();
76  /** The view's route a draft belongs to: its jj change, or the working copy's empty one. */
77  const routeOf = (draft: Draft) => draft.opened.working.change ?? "";
78  const dirty = (draft: Draft) => draft.content !== draft.opened.content || !!draft.pending;
79  const staying = () =>
80    destination.tid === context.tid &&
81    destination.extension === "diff" && destination.route === context.route;
82  const exiting = () => destination.tid !== context.tid || destination.extension !== "diff";
83  /** A keystroke's write, held back a moment: each writes every draft whole, which per key is too slow to type in. */
84  let typed: ReturnType<typeof setTimeout> | undefined;
85  const persist = () => {
86    clearTimeout(typed);
87    typed = undefined;
88    try {
89      storeDrafts(key, stored);
90    } catch {
91      failure("Draft recovery storage is full. Keep this tab open until your edits are saved.");
92    }
93  };
94
95  function close(path: string) {
96    edits.get(path)?.editor.destroy();
97    edits.delete(path);
98    delete stored.drafts[path];
99    persist();
100    refreshed(path);
101  }
102
103  async function save(edit: Editing): Promise<boolean> {
104    if (edit.saving) return await edit.saving;
105    if (!dirty(edit.draft)) {
106      close(edit.draft.opened.file.path);
107      return true;
108    }
109    const { draft } = edit;
110    draft.pending ??= {
111      session: stored.id,
112      id: editId(),
113      working: draft.opened.working,
114      path: draft.opened.file.path,
115      version: draft.opened.version,
116      content: draft.content,
117    };
118    stored.saved = true;
119    persist();
120    edit.save.disabled = true;
121    edit.cancel.disabled = true;
122    edit.status.textContent = "saving…";
123    edit.force.hidden = true;
124    let changedOnDisk = false;
125    const task = (async () => {
126      try {
127        const saved = await context.request(
128          "save",
129          draft.pending as unknown as JsonValue,
130        ) as unknown as Saved;
131        draft.opened.content = draft.pending!.content;
132        draft.opened.version = saved.version;
133        draft.opened.working = saved.working;
134        delete draft.pending;
135        if (!stored.saves.some((receipt) => receipt.id === saved.id)) stored.saves.push(saved);
136        stored.saved = true;
137        persist();
138        if (dirty(draft)) {
139          edit.status.textContent = "unsaved edits";
140          refreshed(draft.opened.file.path);
141        } else {
142          close(draft.opened.file.path);
143        }
144        return true;
145      } catch (error) {
146        const message = error instanceof Error ? error.message : String(error);
147        changedOnDisk = message.includes("since you opened it");
148        edit.force.hidden = !message.includes("would cause conflicts");
149        edit.status.textContent = edit.force.hidden
150          ? message
151          : `${message} Save anyway to keep them?`;
152        return false;
153      } finally {
154        edit.save.disabled = false;
155        edit.cancel.disabled = false;
156        edit.saving = null;
157      }
158    })();
159    edit.saving = task;
160    const saved = await task;
161    if (changedOnDisk) {
162      await sync(edit);
163      const current = edits.get(draft.opened.file.path);
164      if (current && !current.conflict) {
165        current.status.textContent =
166          "since you opened this file, it changed. your edits have been merged.";
167      }
168    }
169    return saved;
170  }
171
172  /** Bring changes made on disk since the draft's base into the editor, as a rebase would. */
173  function sync(edit: Editing): Promise<void> {
174    edit.stale = true;
175    return edit.syncing ??= (async () => {
176      try {
177        while (edit.stale && !disposed && edits.get(edit.draft.opened.file.path) === edit) {
178          await edit.saving;
179          edit.stale = false;
180          await pull(edit);
181        }
182      } finally {
183        edit.syncing = null;
184      }
185    })();
186  }
187
188  async function pull(edit: Editing) {
189    const { draft } = edit;
190    const path = draft.opened.file.path;
191    let latest: OpenFile;
192    try {
193      latest = await context.request("open", {
194        root: draft.opened.working.root,
195        path,
196        route: routeOf(draft),
197      }) as unknown as OpenFile;
198    } catch (error) {
199      edit.status.textContent = `Could not check the file on disk: ${
200        error instanceof Error ? error.message : error
201      }`;
202      return;
203    }
204    if (disposed || edits.get(path) !== edit || edit.saving) return;
205    if (latest.version === draft.opened.version) {
206      // a commit rewritten around the file still moves the draft to the new commit
207      draft.opened.working = latest.working;
208      return persist();
209    }
210    // a save whose answer was lost may have landed, and is then the base
211    const base = draft.pending?.content === latest.content ? latest.content : draft.opened.content;
212    const merged = loaded!.merge3(base, draft.content, latest.content);
213    if (merged === false) {
214      edit.conflict = latest;
215      edit.resolve.hidden = false;
216      edit.status.textContent = "The file changed on disk where you edited it.";
217      return;
218    }
219    adopt(
220      edit,
221      latest,
222      merged,
223      merged === latest.content ? "updated from disk" : "merged changes from disk",
224      false,
225    );
226  }
227
228  /** Take `latest` as the draft's base, with `content` as its text; a new original or working copy remounts it. */
229  function adopt(
230    edit: Editing,
231    latest: OpenFile,
232    content: string,
233    message: string,
234    undoable: boolean,
235  ) {
236    const { draft } = edit;
237    const path = draft.opened.file.path;
238    const moved = latest.original !== draft.opened.original ||
239      (Object.keys(latest.working) as (keyof WorkingCopy)[]).some((key) =>
240        latest.working[key] !== draft.opened.working[key]
241      );
242    edit.conflict = null;
243    edit.resolve.hidden = true;
244    delete draft.pending;
245    draft.opened = latest;
246    if (moved) {
247      edit.editor.destroy();
248      edits.delete(path);
249      draft.content = content;
250      persist();
251      redraw(path);
252    } else {
253      edit.editor.replace(content, undoable);
254      draft.content = content;
255      persist();
256    }
257    const current = edits.get(path);
258    if (current) current.status.textContent = message;
259  }
260
261  function mount(draft: Draft, layout: Layout): Editing {
262    const status = <span role="status" /> as HTMLElement;
263    let escaped = 0;
264    const surface = <div class="diff-edit-surface" /> as HTMLElement;
265    const saveButton = (
266      <button
267        type="button"
268        class="primary diff-save"
269        title="Ctrl+Enter"
270        _onclick={() => void save(edit)}
271      >
272        save
273      </button>
274    ) as HTMLButtonElement;
275    const force = (
276      <button
277        type="button"
278        class="link diff-force"
279        hidden
280        _onclick={() => {
281          if (draft.pending) draft.pending.force = true;
282          persist();
283          void save(edit);
284        }}
285      >
286        save anyway
287      </button>
288    ) as HTMLButtonElement;
289    const cancel = (
290      <button
291        type="button"
292        class="link"
293        title="Escape-Escape"
294        _onclick={() => {
295          if (
296            !edit.saving && (!dirty(draft) || document.defaultView!.confirm("Discard this draft?"))
297          ) {
298            close(draft.opened.file.path);
299          }
300        }}
301      >
302        cancel
303      </button>
304    ) as HTMLButtonElement;
305    const element = (
306      <div class="diff-edit">
307        <div class="diff-edit-tools">
308          {status}
309          <span class="diff-conflict" hidden>
310            <button
311              type="button"
312              class="link"
313              _onclick={() =>
314                edit.conflict && adopt(
315                  edit,
316                  edit.conflict,
317                  draft.content,
318                  "keeping your changes (press save to replace!)",
319                  false,
320                )}
321            >
322              keep mine
323            </button>
324            <button
325              type="button"
326              class="link"
327              _onclick={() =>
328                edit.conflict && adopt(
329                  edit,
330                  edit.conflict,
331                  edit.conflict.content,
332                  "taking on-disk changes (press undo to restore!)",
333                  true,
334                )}
335            >
336              take disk's
337            </button>
338          </span>
339          <button
340            type="button"
341            class="link"
342            _onclick={() => {
343              void navigator.clipboard.writeText(draft.content).catch(() => {
344                status.textContent = "Could not copy; select and copy the draft in the editor.";
345              });
346            }}
347          >
348            copy draft
349          </button>
350          {force}
351          {cancel}
352          {saveButton}
353        </div>
354        {surface}
355      </div>
356    ) as HTMLElement;
357    const editor = loaded!.diffEditor(
358      surface,
359      { ...draft.opened, content: draft.content },
360      layout,
361      {
362        changed(content) {
363          escaped = 0;
364          draft.content = content;
365          status.textContent = dirty(draft) ? "unsaved edits" : "saved";
366          typed ??= setTimeout(persist, TYPED_MS);
367        },
368        save: () => void save(edit),
369        // a second press soon after the first is the confirmation that cancel asks for
370        escape() {
371          if (edit.saving) return;
372          if (Date.now() - escaped < 1500) return close(draft.opened.file.path);
373          escaped = Date.now();
374          status.textContent = dirty(draft)
375            ? "press Escape again to discard your edits"
376            : "press Escape again to exit edit mode";
377        },
378      },
379    );
380    const edit: Editing = {
381      draft,
382      element,
383      editor,
384      status,
385      save: saveButton,
386      cancel,
387      force,
388      saving: null,
389      syncing: null,
390      stale: false,
391      conflict: null,
392      resolve: element.querySelector(".diff-conflict") as HTMLElement,
393    };
394    status.textContent = dirty(draft) ? "recovered draft · unsaved edits" : "editing new side";
395    edits.set(draft.opened.file.path, edit);
396    return edit;
397  }
398
399  function choose(): Promise<"save" | "discard" | "stay"> {
400    return new Promise((resolve) => {
401      const dialog = (
402        <dialog class="diff-leave">
403          <p>You have unsaved diff edits.</p>
404        </dialog>
405      ) as HTMLDialogElement;
406      const finish = (choice: "save" | "discard" | "stay") => {
407        cancelChoice = null;
408        dialog.close();
409        dialog.remove();
410        resolve(choice);
411      };
412      cancelChoice = () => finish("stay");
413      for (
414        const [label, choice] of [
415          ["save and leave", "save"],
416          ["discard and leave", "discard"],
417          ["stay", "stay"],
418        ] as const
419      ) {
420        dialog.append(
421          <button
422            type="button"
423            class={choice === "save" ? "primary" : ""}
424            _onclick={() => finish(choice)}
425          >
426            {label}
427          </button>,
428        );
429      }
430      dialog.addEventListener("cancel", (event) => {
431        event.preventDefault();
432        finish("stay");
433      });
434      document.body.append(dialog);
435      dialog.showModal();
436    });
437  }
438
439  async function leave(): Promise<boolean> {
440    await ready;
441    await Promise.all([...edits.values()].map((edit) => edit.saving));
442    if (staying()) return true;
443    if (Object.values(stored.drafts).some(dirty)) {
444      const choice = await choose();
445      if (choice === "stay") return false;
446      if (choice === "save") {
447        try {
448          await load();
449        } catch (error) {
450          failure(error instanceof Error ? error.message : String(error));
451          return false;
452        }
453        for (const draft of Object.values(stored.drafts)) {
454          const edit = edits.get(draft.opened.file.path) ?? mount(draft, "unified");
455          while (dirty(draft)) if (!await save(edit)) return false;
456        }
457      }
458    }
459    if (staying()) return true;
460    if (exiting() && stored.saved) {
461      // Reporting saved edits can wait for the agent's turn to end, which must not hold navigation open.
462      const id = stored.id;
463      const saves = stored.saves;
464      stored.id = editId();
465      stored.saved = false;
466      stored.saves = [];
467      persist();
468      const task = report(id, saves);
469      backgroundReport = task;
470      void task.finally(() => {
471        if (backgroundReport === task) backgroundReport = null;
472      });
473    }
474    if (staying()) return true;
475    for (const path of Object.keys(stored.drafts)) close(path);
476    if (exiting()) localStorage.removeItem(key);
477    return true;
478  }
479
480  /** Report saved edits without holding navigation open; a failure stays to retry on a later visit. */
481  async function report(id: string, saves: Saved[], throwOnFailure = false) {
482    try {
483      await context.command("diff-finish", JSON.stringify({ session: id, saves }));
484    } catch (error) {
485      const message = `Could not report your saved edits: ${
486        error instanceof Error ? error.message : error
487      }`;
488      const restore = () => {
489        stored.id = id;
490        stored.saved = true;
491        stored.saves = [
492          ...saves,
493          ...stored.saves.filter((receipt) => !saves.some((save) => save.id === receipt.id)),
494        ];
495        persist();
496      };
497      if (disposed) {
498        try {
499          if (localStorage.getItem(key) === null) restore();
500        } catch { /* keep the navigation the user already made */ }
501        console.error(`[berry] ${message}`);
502      } else {
503        restore();
504        failure(message);
505      }
506      if (throwOnFailure) throw error instanceof Error ? error : new Error(message);
507    }
508  }
509
510  /**
511   * Report saved edits while staying in the view, so an idle message sends after them. Dirty drafts stay
512   * unsaved; a failure keeps the message in the composer by throwing.
513   */
514  async function flush(): Promise<void> {
515    await ready;
516    await Promise.all([...edits.values()].map((edit) => edit.saving));
517    if (disposed) return;
518    if (backgroundReport) await backgroundReport;
519    if (disposed || !stored?.saved) return;
520    const id = stored.id;
521    const saves = stored.saves;
522    stored.id = editId();
523    stored.saved = false;
524    stored.saves = [];
525    persist();
526    if (!saves.length) return;
527    await report(id, saves, true);
528  }
529
530  const onUnload = (event: BeforeUnloadEvent) => {
531    if (stored && Object.values(stored.drafts).some(dirty)) {
532      event.preventDefault();
533      event.returnValue = "";
534    }
535  };
536  const onHide = () => {
537    if (!stored) return;
538    stored.closed = true;
539    persist();
540  };
541  globalThis.addEventListener("beforeunload", onUnload);
542  globalThis.addEventListener("pagehide", onHide);
543
544  return {
545    update(next: ViewContext) {
546      context = next;
547      if (!key) {
548        key = draftKey(next.tid);
549        ready = recover(key, next).catch((error) => failure(String(error))).then(() => {
550          stored = loadDrafts(key);
551          if (!disposed) { for (const path of Object.keys(stored.drafts)) redraw(path); }
552        });
553      }
554    },
555    async begin(file: FileDiff, root: string, line?: number) {
556      await ready;
557      const route = context.route;
558      try {
559        const editor = load();
560        editor.catch(() => {});
561        if (!stored.drafts[file.path]) {
562          const request = opening.get(file.path) ??
563            context.request("open", { root, path: file.path, route }) as Promise<OpenFile>;
564          opening.set(file.path, request);
565          const opened = await request.finally(() => opening.delete(file.path));
566          if (disposed || context.route !== route) return;
567          stored.drafts[file.path] ??= { opened, content: opened.content };
568          persist();
569        }
570        await editor;
571        if (disposed || context.route !== route) return;
572        redraw(file.path);
573        const view = edits.get(file.path)?.editor.view;
574        if (view) {
575          if (line) {
576            view.dispatch({
577              selection: {
578                anchor: view.state.doc.line(Math.min(line, view.state.doc.lines)).from,
579              },
580            });
581          }
582          view.focus();
583        }
584      } catch (error) {
585        failure(error instanceof Error ? error.message : String(error));
586      }
587    },
588    /** Look at the file on disk again, as when its diff changes. */
589    sync(path: string) {
590      const edit = edits.get(path);
591      if (edit) void sync(edit);
592    },
593    has(path: string, route?: string) {
594      const draft = stored?.drafts[path];
595      return !!draft && (route === undefined || routeOf(draft) === route);
596    },
597    files(root: string, route: string): FileDiff[] {
598      return Object.values(stored?.drafts ?? {}).filter((draft) =>
599        draft.opened.working.root === root && routeOf(draft) === route
600      )
601        .map((draft) => draft.opened.file);
602    },
603    attach(body: HTMLElement, file: FileDiff, layout: Layout, route: string) {
604      const draft = stored?.drafts[file.path];
605      if (!draft || routeOf(draft) !== route) return false;
606      let edit = edits.get(file.path);
607      if (!edit && !loaded) {
608        body.replaceChildren(<p class="meta file-note">loading editor…</p>);
609        load().then(
610          () => redraw(file.path),
611          (error) => failure(error instanceof Error ? error.message : String(error)),
612        );
613        return true;
614      }
615      if (!edit) {
616        edit = mount(draft, layout);
617        void sync(edit);
618      }
619      edit.editor.layout(layout);
620      if (!body.contains(edit.element)) body.replaceChildren(edit.element);
621      return true;
622    },
623    beforeLeave(next: ViewDestination) {
624      destination = next;
625      if (staying()) {
626        cancelChoice?.();
627        if (!leaving) return true;
628      }
629      leaving ??= leave().finally(() => {
630        leaving = null;
631      });
632      return leaving;
633    },
634    flush,
635    dispose() {
636      if (typed !== undefined) persist();
637      disposed = true;
638      cancelChoice?.();
639      for (const edit of edits.values()) edit.editor.destroy();
640      edits.clear();
641      globalThis.removeEventListener("beforeunload", onUnload);
642      globalThis.removeEventListener("pagehide", onHide);
643    },
644  };
645}