char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somallow editing conflicted commit diffs80904d5

main
23.6 KiB665 linesraw
1import { type View, type ViewContext } from "@berry/api/web.ts";
2import { editing } from "./edit-view.tsx";
3import { editable } from "./eligibility.ts";
4import { fileKey, lineCounts } from "./model.ts";
5import { type Layout, rendering } from "./render.tsx";
6import type { FileDiff, Id, Reply, Revision, Shown } from "./types.ts";
7
8const LAYOUT = "berry.diff.layout";
9const VIEWED = "berry.diff.viewed";
10/** How many files' viewed marks are remembered, the oldest forgotten first. */
11const REMEMBERED = 5000;
12const PAGE = 100;
13
14// lockfiles and the like: shown folded, since nobody reads them line by line
15const GENERATED = /(?:^|\/)(?:[\w.-]+\.lock|package-lock\.json|pnpm-lock\.yaml|go\.sum)$/;
16
17// thousands of marks, which every file reads as it draws: parsed again only once another tab has changed them
18let parsed: { raw: string | null; marks: Record<string, number> } | null = null;
19
20function viewedMarks(): Record<string, number> {
21  const raw = localStorage.getItem(VIEWED);
22  if (parsed?.raw !== raw) {
23    let marks = {};
24    try {
25      marks = JSON.parse(raw ?? "{}");
26    } catch {
27      // a mangled store forgets its marks
28    }
29    parsed = { raw, marks };
30  }
31  return parsed!.marks;
32}
33
34function markViewed(key: string, viewed: boolean) {
35  const marks = { ...viewedMarks() };
36  if (viewed) marks[key] = Date.now();
37  else delete marks[key];
38  const kept = Object.entries(marks).sort(([, a], [, b]) => b - a).slice(0, REMEMBERED);
39  localStorage.setItem(VIEWED, JSON.stringify(Object.fromEntries(kept)));
40}
41
42const relative = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
43const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
44  ["year", 31536000],
45  ["month", 2592000],
46  ["week", 604800],
47  ["day", 86400],
48  ["hour", 3600],
49  ["minute", 60],
50];
51function ago(timestamp: string) {
52  const seconds = (new Date(timestamp).getTime() - Date.now()) / 1000;
53  for (const [unit, size] of UNITS) {
54    if (Math.abs(seconds) >= size) return relative.format(Math.trunc(seconds / size), unit);
55  }
56  return relative.format(0, "second");
57}
58
59const idOf = (id: Id, className: string, full = false) => (
60  <span class={className}>
61    {id.prefix ? <b>{id.prefix}</b> : []}
62    {full ? id.full.slice(id.prefix.length) : id.rest}
63  </span>
64);
65
66function ids(revision: Revision) {
67  if (revision.commit === null) return [<span class="change">working tree</span>];
68  return [
69    ...(revision.change ? [idOf(revision.change, "change")] : []),
70    idOf(revision.commit, "commit"),
71  ];
72}
73
74const glyph = (revision: Revision) =>
75  revision.workingCopy ? "@" : revision.conflict ? "×" : revision.immutable ? "◆" : "○";
76
77const summaryOf = (revision: Revision) =>
78  revision.description.split("\n")[0] ||
79  (revision.commit === null ? "uncommitted changes" : "(no description set)");
80
81type FileView = {
82  file: FileDiff;
83  key: string;
84  section: HTMLElement;
85  /** Which folds are open, by their place among the file's pieces. */
86  unfolded: Set<number>;
87  draw(): void;
88  dispose(): void;
89};
90
91/** A revision's changes beside the history it is in: the working copy at first. */
92export function diffView(): View {
93  const log = <nav class="diff-log" aria-label="history" /> as HTMLElement;
94  const head = <header class="diff-head" /> as HTMLElement;
95  const list = <div class="diff-files" /> as HTMLElement;
96  const main = <section class="diff-main">{head}{list}</section> as HTMLElement;
97  const element = <div class="diff-view">{log}{main}</div> as HTMLElement;
98
99  let context: ViewContext | null = null;
100  let shown: Shown | null = null;
101  let error: string | null = null;
102  let limit = PAGE;
103  let layout: Layout = localStorage.getItem(LAYOUT) === "split" ? "split" : "unified";
104  /** The request on its way, and the place it is for. */
105  let fetching: { route: string; controller: AbortController } | null = null;
106  let again = false;
107  let disposed = false;
108  /** The description being edited in place of the head's subject and body, and which it was. */
109  let rewording: { change: string; before: string; editor: HTMLElement } | null = null;
110  /** Whether each file is open, by path, as the user left it in this revision. */
111  const open = new Map<string, boolean>();
112  let files = new Map<string, FileView>();
113  const renderer = rendering(main);
114  const edits = editing(
115    (path) => {
116      const view = [...files.values()].find((view) => view.file.path === path);
117      if (view) {
118        open.set(path, true);
119        view.draw();
120      }
121    },
122    (path) => {
123      if (!edits.has(path)) {
124        files = new Map([...files].filter(([, view]) => {
125          if (view.file.path !== path) return true;
126          view.dispose();
127          return false;
128        }));
129      }
130      void refresh();
131    },
132    (message) => {
133      error = message;
134      drawHead();
135    },
136  );
137
138  function fileView(file: FileDiff, key: string): FileView {
139    const viewedKey = `${shown!.root}\0${key}`;
140    const viewed = viewedKey in viewedMarks();
141    const { additions, removals } = lineCounts(file);
142    const toggle = <button type="button" class="chevron" /> as HTMLButtonElement;
143    const box = <input type="checkbox" /> as HTMLInputElement;
144    box.checked = viewed;
145    const body = <div class="file-body" /> as HTMLElement;
146    const { revision } = shown!;
147    // jj rewrites a commit's descendants onto an edit to it, so any it would let change can be edited
148    const canEdit = (revision.workingCopy || (shown!.vcs === "jj" && !revision.immutable)) &&
149      editable(file);
150    const begin = (line?: number) => {
151      if (context?.route === shown!.revision.route) void edits.begin(view.file, shown!.root, line);
152    };
153    const editLink = (
154      <button type="button" class="link" _onclick={() => begin()}>
155        edit
156      </button>
157    ) as HTMLButtonElement;
158    const modes = file.modes.old !== null && file.modes.new !== null &&
159        file.modes.old !== file.modes.new
160      ? [<span class="mode">{file.modes.old} → {file.modes.new}</span>]
161      : [];
162    const section = (
163      <section class="file-diff">
164        <header class="file-summary">
165          {toggle}
166          <span class="file-summary-content">
167            <span class="path">{file.from ? `${file.from} → ${file.path}` : file.path}</span>
168            <span class="file-actions">
169              <span class="add-count">+{String(additions)}</span>
170              <span class="del-count">-{String(removals)}</span>
171              {file.status === "modified"
172                ? []
173                : <span class={`status ${file.status}`}>{file.status}</span>}
174              {modes}
175              {GENERATED.test(file.path) ? <span class="generated-note">generated</span> : []}
176              {canEdit ? editLink : []}
177              <label class="viewed">{box} viewed</label>
178            </span>
179          </span>
180        </header>
181        {body}
182      </section>
183    ) as HTMLElement;
184    let drawn: { key: string; layout: Layout; content: ReturnType<typeof renderer.file> } | null =
185      null;
186    const reset = () => {
187      drawn?.content.dispose();
188      drawn = null;
189    };
190    const view: FileView = {
191      file,
192      key,
193      section,
194      unfolded: new Set(),
195      draw,
196      dispose: reset,
197    };
198    const isOpen = () =>
199      open.get(file.path) ?? (!box.checked && !GENERATED.test(file.path) && !file.binary);
200
201    function draw() {
202      const file = view.file;
203      box.checked = `${shown!.root}\0${view.key}` in viewedMarks();
204      const { additions, removals } = lineCounts(file);
205      section.querySelector(".add-count")!.textContent = `+${additions}`;
206      section.querySelector(".del-count")!.textContent = `-${removals}`;
207      const opened = isOpen();
208
209      toggle.setAttribute("aria-expanded", String(opened));
210      toggle.setAttribute("aria-label", `${opened ? "fold" : "unfold"} ${file.path}`);
211      body.hidden = !opened;
212      editLink.hidden = edits.has(file.path, shown!.revision.route);
213      if (edits.attach(body, file, layout, shown!.revision.route)) {
214        reset();
215        return;
216      }
217      if (opened && drawn?.key === view.key && drawn.layout === layout) return;
218      reset();
219      section.style.setProperty("--rows", "0");
220      if (!opened) return body.replaceChildren();
221      if (file.binary) return body.replaceChildren(<p class="meta file-note">binary file</p>);
222      if (file.hunks.length === 0) {
223        return body.replaceChildren(
224          <p class="meta file-note">
225            {file.status === "added" ? "empty file" : "no changes to its content"}
226          </p>,
227        );
228      }
229      const content = renderer.file(file, layout, view.unfolded, (at) => {
230        view.unfolded.add(at);
231        reset();
232        draw();
233      }, canEdit ? begin : undefined);
234      drawn = { key: view.key, layout, content };
235      body.replaceChildren(content.element);
236      section.style.setProperty("--rows", String(content.rows));
237    }
238    toggle.addEventListener("click", () => {
239      open.set(file.path, !isOpen());
240      draw();
241    });
242    box.addEventListener("change", () => {
243      markViewed(`${shown!.root}\0${view.key}`, box.checked);
244      open.set(file.path, !box.checked);
245      draw();
246      if (box.checked && section.getBoundingClientRect().top < main.getBoundingClientRect().top) {
247        section.scrollIntoView({ block: "start" });
248      }
249    });
250    draw();
251    return view;
252  }
253
254  function reword(revision: Revision) {
255    if (!context || !shown || rewording) return;
256    const request = context.request;
257    const root = shown.root;
258    const change = revision.change!.full;
259    const before = revision.description;
260    const input = (
261      <textarea
262        aria-label="commit description"
263        rows={Math.max(4, before.split("\n").length + 1)}
264      />
265    ) as HTMLTextAreaElement;
266    input.value = before;
267    const message = <p class="diff-error" role="alert" /> as HTMLElement;
268    const save = <button type="button">save</button> as HTMLButtonElement;
269    const cancel = <button type="button">cancel</button> as HTMLButtonElement;
270    const editor = (
271      <div class="commit-reword">
272        {input}
273        {message}
274        <div class="commit-reword-actions">
275          {save}
276          {cancel}
277          <span class="meta">ctrl+enter saves · esc cancels</span>
278        </div>
279      </div>
280    ) as HTMLElement;
281    const active = { change, before, editor };
282    let pending = false;
283    const close = () => {
284      if (rewording !== active) return;
285      const focused = editor.contains(document.activeElement);
286      rewording = null;
287      drawHead();
288      if (focused) head.querySelector<HTMLElement>(".commit-subject")?.focus();
289    };
290    const submit = async () => {
291      if (pending) return;
292      pending = true;
293      save.disabled = cancel.disabled = input.disabled = true;
294      message.textContent = "";
295      try {
296        await request("reword", { root, change, before, description: input.value });
297        close();
298        void refresh();
299      } catch (error) {
300        message.textContent = error instanceof Error ? error.message : String(error);
301      } finally {
302        pending = false;
303        save.disabled = cancel.disabled = input.disabled = false;
304      }
305    };
306    save.addEventListener("click", submit);
307    cancel.addEventListener("click", close);
308    input.addEventListener("keydown", (event) => {
309      if (event.key === "Escape" && !pending) {
310        event.preventDefault();
311        close();
312      } else if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
313        event.preventDefault();
314        void submit();
315      }
316    });
317    rewording = active;
318    drawHead();
319    input.focus();
320  }
321
322  /** Each revision's entry in the log, kept while what it shows stays the same, as it mostly does across refreshes. */
323  let entries = new Map<string, { shows: string; link: HTMLElement }>();
324  function drawLog() {
325    if (!shown || !context) {
326      entries.clear();
327      return log.replaceChildren();
328    }
329    const current = shown.revision;
330    const next = new Map<string, { shows: string; link: HTMLElement }>();
331    const items = shown.log.map((revision) => {
332      const key = `${revision.route}\0${revision.commit?.full ?? ""}`;
333      const when = revision.author ? ago(revision.author.timestamp) : "";
334      const href = context!.href(revision.route);
335      const shows = JSON.stringify([revision, when, href]);
336      const kept = entries.get(key);
337      const link = kept?.shows === shows ? kept.link : (
338        <a href={href} class="revision">
339          <span class="revision-line">
340            <span class="glyph">{glyph(revision)}</span>
341            {ids(revision)}
342            {revision.refs.map((ref) => <span class="ref">{ref}</span>)}
343          </span>
344          <span class={revision.description ? "revision-summary" : "revision-summary none"}>
345            {revision.empty ? <span class="tag">(empty)</span> : []}
346            {summaryOf(revision)}
347          </span>
348          {revision.author
349            ? (
350              <span
351                class="revision-meta"
352                title={new Date(revision.author.timestamp).toLocaleString()}
353              >
354                {revision.author.name} · {when}
355              </span>
356            )
357            : []}
358        </a>
359      ) as HTMLElement;
360      next.set(key, { shows, link });
361      if (revision.route === current.route) link.setAttribute("aria-current", "page");
362      else link.removeAttribute("aria-current");
363      link.classList.toggle("immutable", revision.immutable);
364      return link;
365    });
366    entries = next;
367    const more = shown.log.length >= limit
368      ? [
369        (log.querySelector(":scope > .more") as HTMLElement | null) ?? (
370          <button
371            type="button"
372            class="link more"
373            _onclick={() => {
374              limit += PAGE;
375              void refresh();
376            }}
377          >
378            older…
379          </button>
380        ) as HTMLElement,
381      ]
382      : [];
383    const nodes = [...items, ...more];
384    // moving nodes that stay would lose the reader's focus and lay the list out again
385    if (nodes.length !== log.children.length || nodes.some((node, i) => node !== log.children[i])) {
386      log.replaceChildren(...nodes);
387    }
388  }
389
390  let heading: { key: string; editor: HTMLElement | null } | null = null;
391  function drawHead() {
392    if (!shown) {
393      return head.replaceChildren(
394        error ? <p class="diff-error">{error}</p> : <p class="meta">loading…</p>,
395      );
396    }
397    const key = JSON.stringify([
398      shown.root,
399      shown.vcs,
400      shown.revision,
401      shown.omitted,
402      [...files.keys()],
403      layout,
404      error,
405    ]);
406    const editor = rewording?.editor ?? null;
407    if (heading?.key === key && heading.editor === editor) return;
408    heading = { key, editor };
409    const { revision, files: changed } = shown;
410    const counts = changed.map(lineCounts);
411    const authored = { additions: 0, removals: 0 };
412    const generated = { additions: 0, removals: 0 };
413    changed.forEach((file, at) => {
414      const total = GENERATED.test(file.path) ? generated : authored;
415      total.additions += counts[at].additions;
416      total.removals += counts[at].removals;
417    });
418    const choice = (value: Layout) => (
419      <button
420        type="button"
421        aria-pressed={String(layout === value)}
422        _onclick={() => {
423          if (layout === value) return;
424          layout = value;
425          localStorage.setItem(LAYOUT, value);
426          for (const view of files.values()) view.draw();
427          drawHead();
428        }}
429      >
430        {value}
431      </button>
432    );
433    const [subject, ...rest] = revision.description.trimEnd().split("\n");
434    const body = rest.join("\n").trim();
435    const meta = <table class="commit-meta" /> as HTMLTableElement;
436    const row = (key: string, value: Node | string) =>
437      meta.append(
438        <tr>
439          <th>{key}</th>
440          <td>{value}</td>
441        </tr>,
442      );
443    if (revision.author) {
444      row("author", `${revision.author.name} <${revision.author.email}>`);
445      row("date", new Date(revision.author.timestamp).toLocaleString());
446    }
447    if (revision.change) row("change", idOf(revision.change, "change", true));
448    if (revision.commit) row("commit", idOf(revision.commit, "commit", true));
449    if (revision.refs.length) row("refs", revision.refs.join(" "));
450    row(shown.vcs, shown.root);
451    const mutable = shown.vcs === "jj" && !revision.immutable && revision.change !== undefined;
452    if (
453      rewording &&
454      !(mutable && rewording.change === revision.change!.full &&
455        rewording.before === revision.description)
456    ) rewording = null;
457    const focused = rewording?.editor.contains(document.activeElement)
458      ? document.activeElement as HTMLElement
459      : null;
460    const description = rewording ? [rewording.editor] : [
461      <h2 class={subject ? "commit-subject" : "commit-subject none"}>
462        {revision.empty ? <span class="tag">(empty)</span> : []}
463        {revision.conflict ? <span class="tag conflict">(conflict)</span> : []}
464        {subject || summaryOf(revision)}
465      </h2>,
466      ...(body ? [<pre class="commit-body">{body}</pre>] : []),
467    ] as HTMLElement[];
468    if (mutable && !rewording) {
469      for (const target of description) {
470        target.title = "double-click or press F2 to edit the description";
471        target.tabIndex = 0;
472        target.addEventListener("dblclick", () => reword(revision));
473        target.addEventListener("keydown", (event) => {
474          if (event.key === "F2") reword(revision);
475        });
476      }
477    }
478    head.replaceChildren(
479      ...description,
480      meta,
481      <div class="diff-toolbar">
482        <span class="diffstat">
483          <span class="add-count">
484            +{String(authored.additions)}
485            {generated.additions
486              ? (
487                <span class="generated-count" title="generated file additions">
488                  {` (+${generated.additions})`}
489                </span>
490              )
491              : []}
492          </span>
493          <span class="del-count">
494            -{String(authored.removals)}
495            {generated.removals
496              ? <span title="generated file removals">{` (-${generated.removals})`}</span>
497              : []}
498          </span>
499          <span>{`${changed.length} changed file${changed.length === 1 ? "" : "s"}`}</span>
500        </span>
501        <span class="spacer" />
502        <span class="diff-layout" role="group" aria-label="layout">
503          {choice("unified")}
504          {choice("split")}
505        </span>
506        <button type="button" class="link" _onclick={() => void refresh()}>refresh</button>
507      </div>,
508      ...(error ? [<p class="diff-error">{error}</p>] : []),
509      ...(shown.omitted > 0
510        ? [<p class="meta">{String(shown.omitted)} more untracked files not shown</p>]
511        : []),
512      ...(changed.length === 0 ? [<p class="meta">no changes</p>] : []),
513      <ol class="diff-index">
514        {changed.map((file, at) => (
515          <li>
516            <span class="add-count">+{String(counts[at].additions)}</span>
517            <span class="del-count">-{String(counts[at].removals)}</span>
518            <button
519              type="button"
520              class="link"
521              _onclick={() => {
522                const view = [...files.values()].find((view) => view.file.path === file.path);
523                if (!view) return;
524                open.set(file.path, true);
525                view.draw();
526                view.section.scrollIntoView({ block: "start" });
527              }}
528            >
529              <span class={`status ${file.status}`}>{file.status[0].toUpperCase()}</span>
530              <span>{file.path}</span>
531            </button>
532          </li>
533        ))}
534      </ol>,
535    );
536    // moving the editor blurs it, but the draft and its selection stay with the element
537    focused?.focus();
538  }
539
540  function show(next: Reply, known: Map<string, FileView>) {
541    const sameRevision = shown?.revision.route === next.revision.route &&
542      shown.root === next.root;
543    if (!sameRevision) {
544      open.clear();
545      main.scrollTop = 0;
546    }
547    // keep what has not changed, and the file at the top of the pane where it is
548    const top = main.getBoundingClientRect().top;
549    const anchor = [...files.values()].find((view) =>
550      view.section.getBoundingClientRect().bottom > top
551    );
552    const offset = anchor ? anchor.section.getBoundingClientRect().top - top : 0;
553    const previous = files;
554    // a change left out is one the view has
555    const sent = next.files.map(({ key, diff }) => ({
556      key,
557      file: diff ?? known.get(key)!.file,
558    }));
559    for (const file of edits.files(next.root, next.revision.route)) {
560      if (!sent.some((item) => item.file.path === file.path)) {
561        sent.push({ key: fileKey(file), file });
562      }
563    }
564    shown = { ...next, files: sent.map(({ file }) => file) };
565    files = new Map(
566      sent.map(({ key, file }) => {
567        const active = sameRevision && edits.has(file.path, next.revision.route)
568          ? [...previous.values()].find((view) => view.file.path === file.path)
569          : undefined;
570        const view = active ?? (sameRevision ? previous.get(key) : undefined) ??
571          fileView(file, key);
572        const changed = active !== undefined && active.key !== key;
573        view.file = file;
574        view.key = key;
575        if (active) view.draw();
576        if (changed) edits.sync(file.path);
577        return [key, view];
578      }),
579    );
580    for (const [key, view] of previous) {
581      if (files.get(key) !== view) {
582        view.dispose();
583      }
584    }
585    const nodes = [...files.values()].map((view) => view.section);
586    if (
587      nodes.length !== list.children.length || nodes.some((node, i) => node !== list.children[i])
588    ) {
589      const focused = document.activeElement as HTMLElement | null;
590      list.replaceChildren(...nodes);
591      if (focused && list.contains(focused)) focused.focus();
592    }
593    drawHead();
594    drawLog();
595    if (sameRevision && anchor && files.get(anchor.key) === anchor) {
596      main.scrollTop += anchor.section.getBoundingClientRect().top - top - offset;
597    }
598  }
599
600  async function refresh() {
601    if (!context || disposed) return;
602    const route = context.route;
603    // the same place again waits for the answer on its way; another place makes that answer moot
604    if (fetching?.route === route) return void (again = true);
605    fetching?.controller.abort();
606    const current = fetching = { route, controller: new AbortController() };
607    again = false;
608    head.classList.add("loading");
609    try {
610      const known = new Map(files);
611      const value = await context.request("show", { route, limit, known: [...known.keys()] }, {
612        signal: current.controller.signal,
613      }) as unknown as Reply;
614      if (fetching !== current) return;
615      error = null;
616      show(value, known);
617    } catch (caught) {
618      if (fetching !== current) return;
619      error = caught instanceof Error ? caught.message : String(caught);
620      drawHead();
621    } finally {
622      if (fetching === current) {
623        fetching = null;
624        head.classList.remove("loading");
625        if (again) void refresh();
626      }
627    }
628  }
629
630  const onVisible = () => {
631    if (document.visibilityState === "visible") void refresh();
632  };
633  document.addEventListener("visibilitychange", onVisible);
634
635  return {
636    element,
637    update(next) {
638      const previous = context;
639      context = next;
640      edits.update(next);
641      if (previous?.route !== next.route && rewording) {
642        rewording = null;
643        drawHead();
644      }
645      if (previous?.route !== next.route || previous.latest !== next.latest) void refresh();
646    },
647    async beforeLeave(destination) {
648      element.inert = true;
649      try {
650        return await edits.beforeLeave(destination);
651      } finally {
652        element.inert = false;
653      }
654    },
655    flush: () => edits.flush(),
656    dispose() {
657      disposed = true;
658      edits.dispose();
659      fetching?.controller.abort();
660      for (const view of files.values()) view.dispose();
661      renderer.dispose();
662      document.removeEventListener("visibilitychange", onVisible);
663    },
664  };
665}