char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somperf(diff): virtualize rich rows with searchable text fallbacksece94e9

main
9.3 KiB273 linesraw
1import { highlight } from "@berry/api/web.ts";
2import {
3  type Line,
4  linesOf,
5  type Pair,
6  pairs,
7  pieces,
8  type Range,
9  trailing,
10  wordChanges,
11} from "./model.ts";
12import type { FileDiff } from "./types.ts";
13import { viewport } from "./viewport.ts";
14
15export type Layout = "unified" | "split";
16/** Each side's highlighted lines, once the worker has answered. */
17type Lit = { old: Node[][] | null; new: Node[][] | null };
18const KIND = { " ": "ctx", "-": "del", "+": "add" } as const;
19const CHUNK_ROWS = 64;
20type Row = { pair: Pair; line: Line | null };
21/** Wrap the text `root` holds within `ranges` in `<mark>`s, through whatever highlighting it carries. */
22function wrap(root: Element, ranges: readonly Range[], className: string) {
23  if (ranges.length === 0) return;
24  const texts: Text[] = [];
25  const visit = (node: Node) => {
26    if (node.nodeType === 3) texts.push(node as Text);
27    else for (const child of [...node.childNodes]) visit(child);
28  };
29  visit(root);
30  let offset = 0;
31  for (const text of texts) {
32    const start = offset;
33    offset += text.data.length;
34    // from the last range back, so `text` stays the part before those already wrapped
35    for (const [from, to] of [...ranges].reverse()) {
36      const a = Math.max(from, start) - start;
37      const b = Math.min(to, offset) - start;
38      if (a >= b) continue;
39      if (b < text.data.length) text.splitText(b);
40      const inside = a > 0 ? text.splitText(a) : text;
41      const mark = document.createElement("mark");
42      mark.className = className;
43      inside.replaceWith(mark);
44      mark.append(inside);
45      if (inside === text) break;
46    }
47  }
48}
49
50/** A line's text, highlighted once that is known, with what changed in it marked. */
51function lineText(
52  line: Line,
53  side: "old" | "new",
54  lit: Lit | null,
55  words: readonly Range[],
56  edit?: (line: number) => void,
57) {
58  const cell = document.createElement("td");
59  if (edit && side === "new" && line.new !== null) {
60    cell.addEventListener("dblclick", () => edit(line.new!));
61  }
62  cell.className = `text ${KIND[line.mark]}`;
63  const index = line.at[side];
64  const highlighted = index === null ? undefined : lit?.[side]?.[index];
65  const text = line.text.replace(/\r$/, "");
66  if (highlighted) cell.append(...highlighted.map((node) => node.cloneNode(true)));
67  else cell.append(text);
68  wrap(cell, words, "word");
69  if (line.mark !== " ") {
70    const space = trailing(text);
71    if (space) wrap(cell, [space], "space");
72  }
73  if (line.text.endsWith("\r")) {
74    cell.append(<span class="badge" title="carriage return">␍</span>);
75  }
76  if (!line.newline) {
77    cell.append(<span class="badge" title="no newline at end of file">⊘</span>);
78  }
79  return cell;
80}
81
82const number = (n: number | null) => <td class="ln">{n === null ? "" : String(n)}</td>;
83
84/** Searchable chunks, with expensive rows and syntax nodes kept only around the viewport. */
85export function rendering(root: HTMLElement) {
86  const window = viewport(root);
87  const waiting = new Set<() => Promise<void>>();
88  let lighting = false;
89  let disposed = false;
90
91  async function light() {
92    if (lighting || disposed) return;
93    lighting = true;
94    try {
95      for (const next of waiting) {
96        waiting.delete(next);
97        if (disposed) break;
98        await next();
99      }
100    } finally {
101      lighting = false;
102    }
103  }
104
105  return {
106    file(
107      file: FileDiff,
108      layout: Layout,
109      unfolded: Set<number>,
110      unfold: (at: number) => void,
111      edit?: (line: number) => void,
112    ) {
113      const element = <div class="diff-content" /> as HTMLElement;
114      const chunks: ReturnType<typeof window.add>[] = [];
115      const words = new Map<Pair, ReturnType<typeof wordChanges>>();
116      let lit: Lit | null = null;
117      let mounted = 0;
118      let visible = 0;
119      let attempted = false;
120      let pending = false;
121      let removed = false;
122      let count = 0;
123      const illuminate = async () => {
124        if (removed || !visible || !mounted || attempted || pending) return;
125        attempted = true;
126        pending = true;
127        const { sides } = linesOf(file);
128        const [old, neu] = await Promise.all(
129          [sides.old, sides.new].map((lines) =>
130            lines.length ? highlight(lines.join("\n"), { path: file.path }).catch(() => null) : null
131          ),
132        );
133        pending = false;
134        if (disposed || removed) return;
135        if (!mounted) {
136          attempted = false;
137          return;
138        }
139        attempted = true;
140        if (old === null && neu === null) return;
141        lit = { old, new: neu };
142        for (const chunk of chunks) chunk.redraw();
143      };
144      const queue = () => {
145        if (!visible || !mounted || attempted || pending || removed) return;
146        waiting.add(illuminate);
147        void light();
148      };
149
150      for (const [at, piece] of pieces(file).entries()) {
151        if (piece.kind === "gap" || (piece.kind === "fold" && !unfolded.has(at))) {
152          element.append(
153            <div class="diff-fold">
154              {piece.kind === "gap"
155                ? `⋯ ${piece.count} unchanged lines not loaded`
156                : (
157                  <button type="button" class="link fold" _onclick={() => unfold(at)}>
158                    ⋯ {String(piece.lines.length)} unchanged lines
159                  </button>
160                )}
161            </div>,
162          );
163          count++;
164          continue;
165        }
166        const paired = pairs(piece.lines);
167        const partners = new Map<Line, Pair>();
168        for (const pair of paired) {
169          if (pair.old) partners.set(pair.old, pair);
170          if (pair.new) partners.set(pair.new, pair);
171        }
172        const rows: Row[] = layout === "unified"
173          ? piece.lines.map((line) => ({ pair: partners.get(line)!, line }))
174          : paired.map((pair) => ({ pair, line: null }));
175        count += rows.length;
176        for (let start = 0; start < rows.length; start += CHUNK_ROWS) {
177          const part = rows.slice(start, start + CHUNK_ROWS);
178          const chunk = <div class={`diff-chunk ${layout}`} /> as HTMLElement;
179          chunk.style.setProperty("--rows", String(part.length));
180          let rich = false;
181          let seen = false;
182          const draw = (full: boolean) => {
183            if (rich !== full) mounted += full ? 1 : -1;
184            rich = full;
185            if (!full) {
186              if (!mounted) {
187                lit = null;
188                attempted = false;
189                words.clear();
190              }
191              const text = (line: Line | null) => line?.text.replace(/\r$/, "") ?? "";
192              chunk.replaceChildren(
193                layout === "unified"
194                  ? (
195                    <pre class="diff-plain">{part.map(({ line }) => text(line)).join("\n") + "\n"}</pre>
196                  )
197                  : (
198                    <div class="diff-plain-split">
199                      {(["old", "new"] as const).map((side) => (
200                        <pre class="diff-plain">{part.map(({ pair }) => text(pair[side])).join("\n") + "\n"}</pre>
201                      ))}
202                    </div>
203                  ),
204              );
205              return;
206            }
207            const table = (
208              <table class={`diff ${layout}`}>
209                <colgroup>
210                  {layout === "unified"
211                    ? [<col class="gutter" />, <col class="gutter" />, <col />]
212                    : [<col class="gutter" />, <col />, <col class="gutter" />, <col />]}
213                </colgroup>
214                {part.map(({ pair, line }) => {
215                  let changes = words.get(pair);
216                  if (changes === undefined) {
217                    changes = pair.old && pair.new && pair.old !== pair.new
218                      ? wordChanges(pair.old.text, pair.new.text)
219                      : null;
220                    words.set(pair, changes);
221                  }
222                  if (line) {
223                    const side = line.mark === "-" ? "old" : "new";
224                    return (
225                      <tr class={KIND[line.mark]}>
226                        {number(line.old)}
227                        {number(line.new)}
228                        {lineText(line, side, lit, changes?.[side] ?? [], edit)}
229                      </tr>
230                    );
231                  }
232                  const side = (line: Line | null, which: "old" | "new") =>
233                    line
234                      ? [
235                        number(line[which]),
236                        lineText(line, which, lit, changes?.[which] ?? [], edit),
237                      ]
238                      : [number(null), <td class="text none" />];
239                  return <tr>{side(pair.old, "old")}{side(pair.new, "new")}</tr>;
240                })}
241              </table>
242            );
243            chunk.replaceChildren(table);
244            queue();
245          };
246          chunks.push(window.add(chunk, draw, (intersects) => {
247            if (seen === intersects) return;
248            seen = intersects;
249            visible += seen ? 1 : -1;
250            queue();
251          }));
252          element.append(chunk);
253        }
254      }
255      return {
256        element,
257        rows: count,
258        dispose() {
259          removed = true;
260          waiting.delete(illuminate);
261          for (const chunk of chunks) chunk.dispose();
262          lit = null;
263          words.clear();
264        },
265      };
266    },
267    dispose() {
268      disposed = true;
269      waiting.clear();
270      window.dispose();
271    },
272  };
273}