import { highlight } from "@berry/api/web.ts"; import { type Line, linesOf, type Pair, pairs, pieces, type Range, trailing, wordChanges, } from "./model.ts"; import type { FileDiff } from "./types.ts"; import { viewport } from "./viewport.ts"; export type Layout = "unified" | "split"; /** Each side's highlighted lines, once the worker has answered. */ type Lit = { old: Node[][] | null; new: Node[][] | null }; const KIND = { " ": "ctx", "-": "del", "+": "add" } as const; const CHUNK_ROWS = 64; type Row = { pair: Pair; line: Line | null }; /** Wrap the text `root` holds within `ranges` in ``s, through whatever highlighting it carries. */ function wrap(root: Element, ranges: readonly Range[], className: string) { if (ranges.length === 0) return; const texts: Text[] = []; const visit = (node: Node) => { if (node.nodeType === 3) texts.push(node as Text); else for (const child of [...node.childNodes]) visit(child); }; visit(root); let offset = 0; for (const text of texts) { const start = offset; offset += text.data.length; // from the last range back, so `text` stays the part before those already wrapped for (const [from, to] of [...ranges].reverse()) { const a = Math.max(from, start) - start; const b = Math.min(to, offset) - start; if (a >= b) continue; if (b < text.data.length) text.splitText(b); const inside = a > 0 ? text.splitText(a) : text; const mark = document.createElement("mark"); mark.className = className; inside.replaceWith(mark); mark.append(inside); if (inside === text) break; } } } /** A line's text, highlighted once that is known, with what changed in it marked. */ function lineText( line: Line, side: "old" | "new", lit: Lit | null, words: readonly Range[], edit?: (line: number) => void, ) { const cell = document.createElement("td"); if (edit && side === "new" && line.new !== null) { cell.addEventListener("dblclick", () => edit(line.new!)); } cell.className = `text ${KIND[line.mark]}`; const index = line.at[side]; const highlighted = index === null ? undefined : lit?.[side]?.[index]; const text = line.text.replace(/\r$/, ""); if (highlighted) cell.append(...highlighted.map((node) => node.cloneNode(true))); else cell.append(text); wrap(cell, words, "word"); if (line.mark !== " ") { const space = trailing(text); if (space) wrap(cell, [space], "space"); } if (line.text.endsWith("\r")) { cell.append(␍); } if (!line.newline) { cell.append(⊘); } return cell; } const number = (n: number | null) => {n === null ? "" : String(n)}; /** Searchable chunks, with expensive rows and syntax nodes kept only around the viewport. */ export function rendering(root: HTMLElement) { const window = viewport(root); const waiting = new Set<() => Promise>(); let lighting = false; let disposed = false; async function light() { if (lighting || disposed) return; lighting = true; try { for (const next of waiting) { waiting.delete(next); if (disposed) break; await next(); } } finally { lighting = false; } } return { file( file: FileDiff, layout: Layout, unfolded: Set, unfold: (at: number) => void, edit?: (line: number) => void, ) { const element =
as HTMLElement; const chunks: ReturnType[] = []; const words = new Map>(); let lit: Lit | null = null; let mounted = 0; let visible = 0; let attempted = false; let pending = false; let removed = false; let count = 0; const illuminate = async () => { if (removed || !visible || !mounted || attempted || pending) return; attempted = true; pending = true; const { sides } = linesOf(file); const [old, neu] = await Promise.all( [sides.old, sides.new].map((lines) => lines.length ? highlight(lines.join("\n"), { path: file.path }).catch(() => null) : null ), ); pending = false; if (disposed || removed) return; if (!mounted) { attempted = false; return; } attempted = true; if (old === null && neu === null) return; lit = { old, new: neu }; for (const chunk of chunks) chunk.redraw(); }; const queue = () => { if (!visible || !mounted || attempted || pending || removed) return; waiting.add(illuminate); void light(); }; for (const [at, piece] of pieces(file).entries()) { if (piece.kind === "gap" || (piece.kind === "fold" && !unfolded.has(at))) { element.append(
{piece.kind === "gap" ? `⋯ ${piece.count} unchanged lines not loaded` : ( )}
, ); count++; continue; } const paired = pairs(piece.lines); const partners = new Map(); for (const pair of paired) { if (pair.old) partners.set(pair.old, pair); if (pair.new) partners.set(pair.new, pair); } const rows: Row[] = layout === "unified" ? piece.lines.map((line) => ({ pair: partners.get(line)!, line })) : paired.map((pair) => ({ pair, line: null })); count += rows.length; for (let start = 0; start < rows.length; start += CHUNK_ROWS) { const part = rows.slice(start, start + CHUNK_ROWS); const chunk =
as HTMLElement; chunk.style.setProperty("--rows", String(part.length)); let rich = false; let seen = false; const draw = (full: boolean) => { if (rich !== full) mounted += full ? 1 : -1; rich = full; if (!full) { if (!mounted) { lit = null; attempted = false; words.clear(); } const text = (line: Line | null) => line?.text.replace(/\r$/, "") ?? ""; chunk.replaceChildren( layout === "unified" ? (
{part.map(({ line }) => text(line)).join("\n") + "\n"}
) : (
{(["old", "new"] as const).map((side) => (
{part.map(({ pair }) => text(pair[side])).join("\n") + "\n"}
))}
), ); return; } const table = ( {layout === "unified" ? [, , ] : [, , , ]} {part.map(({ pair, line }) => { let changes = words.get(pair); if (changes === undefined) { changes = pair.old && pair.new && pair.old !== pair.new ? wordChanges(pair.old.text, pair.new.text) : null; words.set(pair, changes); } if (line) { const side = line.mark === "-" ? "old" : "new"; return ( {number(line.old)} {number(line.new)} {lineText(line, side, lit, changes?.[side] ?? [], edit)} ); } const side = (line: Line | null, which: "old" | "new") => line ? [ number(line[which]), lineText(line, which, lit, changes?.[which] ?? [], edit), ] : [number(null), {side(pair.old, "old")}{side(pair.new, "new")}; })}
]; return
); chunk.replaceChildren(table); queue(); }; chunks.push(window.add(chunk, draw, (intersects) => { if (seen === intersects) return; seen = intersects; visible += seen ? 1 : -1; queue(); })); element.append(chunk); } } return { element, rows: count, dispose() { removed = true; waiting.delete(illuminate); for (const chunk of chunks) chunk.dispose(); lit = null; words.clear(); }, }; }, dispose() { disposed = true; waiting.clear(); window.dispose(); }, }; }