jacquardSnapshot

← snapshot

28077 bytes
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "@/lib/api";
import { diffLines, statOf, withElisions, type DiffRow } from "@/lib/diff";
import {
  applyJudgement,
  describe,
  editsOf,
  groupFiles,
  type DiffGroup,
  type FileEdits,
  type JudgedGroup,
  type Judgement,
} from "@/lib/diff-group";
import { judgeGroups } from "@/lib/judge";
import { governing } from "@/lib/paths";
import type { ActorView, DecisionDto } from "@/lib/types";
import { pairChanges, type Span } from "@/lib/word-diff";
import { RemarkZone } from "./remarks";

/**
 * A diff, read without leaving the tour.
 *
 * Jackie opens this over whatever she was showing you rather than navigating,
 * because a walkthrough that dumps you on another page is not a walkthrough.
 *
 * The organising idea is that a bulk edit is one change, not N. A rename
 * across a hundred and fifty files folds into a single entry describing the
 * substitution once — and the fold is refused for any file that also changed
 * something else, so nothing hides inside it. See `lib/diff-group.ts`.
 *
 * On colour: additions and removals sit deliberately outside the verdict
 * palette. Madder means blocked, verdant means admitted, amber means
 * ungoverned; a green line background meaning "added" would be one glance from
 * meaning "this passed". Diffs use teal and plum, and the two vocabularies
 * never share a shape — verdicts are pills, diffs are rows.
 */

interface FileDiff {
  path: string;
  rows: (DiffRow | null)[];
  added: number;
  removed: number;
  governedBy: DecisionDto[];
  edits: FileEdits;
  /** Set when the path exists at only one end. */
  note: string | null;
}

type Selection = { kind: "file"; path: string } | { kind: "group"; id: string };

/** Reading every path at once, without opening a hundred sockets. */
async function pooled<T>(items: string[], limit: number, run: (item: string) => Promise<T>) {
  const out: T[] = [];
  let cursor = 0;
  const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
    for (;;) {
      const i = cursor;
      cursor += 1;
      const item = items[i];
      if (item === undefined) return;
      out.push(await run(item));
    }
  });
  await Promise.all(workers);
  return out;
}

async function readAt(
  repo: string,
  snapshot: string | null,
  path: string,
): Promise<string | null> {
  if (!snapshot) return null;
  try {
    const snap = await api.snapshot(repo, snapshot);
    const file = snap.files.find((f) => f.path === path);
    if (!file) return null;
    const blob = await api.blob(repo, file.blob);
    return blob.content ?? "";
  } catch {
    return null;
  }
}

export function DiffModal({
  repo,
  fromSnapshot,
  intoSnapshot,
  paths,
  initialPath,
  decisions,
  humans,
  onClose,
}: {
  repo: string;
  /** The snapshot the change comes *from* (the newer side). */
  fromSnapshot: string;
  /** The snapshot it would land on (the older side). */
  intoSnapshot: string | null;
  paths: string[];
  initialPath?: string;
  decisions: DecisionDto[];
  humans: ActorView[];
  onClose: () => void;
}) {
  const [files, setFiles] = useState<Record<string, FileDiff>>({});
  const [progress, setProgress] = useState(0);
  const [ready, setReady] = useState(false);
  const [split, setSplit] = useState(false);
  const [selection, setSelection] = useState<Selection | null>(
    initialPath ? { kind: "file", path: initialPath } : null,
  );
  /** Row the keyboard is on, or -1 before anyone has pressed a key. */
  const [cursor, setCursor] = useState(-1);
  const [expanded, setExpanded] = useState<Set<number>>(new Set());
  const [showKeys, setShowKeys] = useState(false);
  const bodyRef = useRef<HTMLDivElement | null>(null);

  /* ---- read every path, then decide what folds ------------------------- */
  useEffect(() => {
    let alive = true;
    setReady(false);
    setProgress(0);
    const collected: Record<string, FileDiff> = {};

    void pooled(paths, 6, async (want) => {
      const [before, after] = await Promise.all([
        readAt(repo, intoSnapshot, want),
        readAt(repo, fromSnapshot, want),
      ]);
      const raw = diffLines(before ?? "", after ?? "");
      const rows = withElisions(raw, 4);
      const stat = statOf(raw);
      collected[want] = {
        path: want,
        rows,
        added: stat.added,
        removed: stat.removed,
        edits: editsOf(rows),
        governedBy: governing(want, decisions),
        note:
          before === null && after !== null
            ? "added in this change"
            : before !== null && after === null
              ? "removed in this change"
              : null,
      };
      if (alive) setProgress((n) => n + 1);
    }).then(() => {
      if (!alive) return;
      setFiles(collected);
      setReady(true);
    });

    return () => {
      alive = false;
    };
  }, [repo, paths, fromSnapshot, intoSnapshot, decisions]);

  const [judgement, setJudgement] = useState<Judgement | null>(null);
  const [judging, setJudging] = useState(false);

  const deterministic = useMemo(
    () => (ready ? groupFiles(paths, (p) => files[p]?.edits) : null),
    [ready, paths, files],
  );

  /* ---- the judge: advisory, and constrained ----------------------------
   * Deterministic folding cannot see that two spellings are one change. A
   * model can. Everything it says is filtered by `applyJudgement`, which
   * refuses invented ids and never lets a lone file be absorbed.
   */
  useEffect(() => {
    if (!deterministic || deterministic.groups.length < 2) return;
    let alive = true;
    setJudging(true);
    void judgeGroups(deterministic.groups)
      .then((j) => {
        if (alive) setJudgement(j);
      })
      .finally(() => {
        if (alive) setJudging(false);
      });
    return () => {
      alive = false;
    };
  }, [deterministic]);

  const grouping = useMemo(
    () => (deterministic ? applyJudgement(deterministic, judgement) : null),
    [deterministic, judgement],
  );

  /**
   * Keeps the selection pointing at something real.
   *
   * A judgement merges folds under a new id, which strands a selection made
   * against one of the originals. Follow it into whatever absorbed it rather
   * than dumping the reader back at the top — they were reading that change,
   * and it still exists, just under one heading now.
   */
  useEffect(() => {
    if (!grouping) return;
    if (selection?.kind === "file") return;
    const stillThere =
      selection?.kind === "group" &&
      grouping.groups.some((g) => g.id === selection.id);
    if (stillThere) return;

    const absorbed =
      selection?.kind === "group"
        ? grouping.groups.find((g) =>
            g.judged?.from.some((f) => f.id === selection.id),
          )
        : undefined;

    const next = absorbed ?? grouping.groups[0];
    setSelection(
      next
        ? { kind: "group", id: next.id }
        : grouping.singles[0]
          ? { kind: "file", path: grouping.singles[0] }
          : null,
    );
  }, [grouping, selection]);

  useEffect(() => {
    bodyRef.current?.scrollTo({ top: 0 });
    setCursor(-1);
    setExpanded(new Set());
  }, [selection]);

  /** Every entry in the rail, in the order it is shown. */
  const entries: Selection[] = useMemo(() => {
    if (!grouping) return paths.map((p) => ({ kind: "file", path: p }) as Selection);
    return [
      ...grouping.groups.map((g) => ({ kind: "group", id: g.id }) as Selection),
      ...grouping.singles.map((p) => ({ kind: "file", path: p }) as Selection),
    ];
  }, [grouping, paths]);

  const step = useCallback(
    (delta: number) => {
      setSelection((cur) => {
        const key = (s: Selection) => (s.kind === "file" ? `f:${s.path}` : `g:${s.id}`);
        const i = cur ? entries.findIndex((e) => key(e) === key(cur)) : -1;
        const next = Math.max(0, Math.min(entries.length - 1, i + delta));
        return entries[next] ?? cur;
      });
    },
    [entries],
  );

  const group =
    selection?.kind === "group"
      ? (grouping?.groups.find((g) => g.id === selection.id) ?? null)
      : null;
  const file = selection?.kind === "file" ? files[selection.path] : undefined;

  /* ---- keyboard navigation --------------------------------------------
   * A diff is a reading surface, and reading with a mouse wheel is how you
   * miss the third hunk. `n`/`p` jump between runs of changed lines, which is
   * the unit a reviewer actually moves in; `j`/`k` step a line at a time when
   * something needs a closer look.
   */
  const rows: (DiffRow | null)[] = useMemo(() => {
    if (group) return files[group.paths[0] as string]?.rows ?? [];
    return file?.rows ?? [];
  }, [group, file, files]);

  /** First row of each run of changed lines. */
  const hunks = useMemo(() => {
    const out: number[] = [];
    let inRun = false;
    rows.forEach((r, i) => {
      const changed = r !== null && r.kind !== "ctx";
      if (changed && !inRun) out.push(i);
      inRun = changed;
    });
    return out;
  }, [rows]);

  const moveTo = useCallback((next: number) => {
    setCursor(next);
    // Centre it: a cursor pinned to the viewport edge hides its own context.
    requestAnimationFrame(() => {
      document
        .querySelector(`[data-row="${next}"]`)
        ?.scrollIntoView({ block: "center", behavior: "smooth" });
    });
  }, []);

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        onClose();
        return;
      }
      if ((e.target as HTMLElement | null)?.closest?.("input, textarea, select")) return;
      if (e.metaKey || e.ctrlKey) return;

      const line = (delta: number) => {
        e.preventDefault();
        const from = cursor < 0 ? (delta > 0 ? -1 : rows.length) : cursor;
        moveTo(Math.max(0, Math.min(rows.length - 1, from + delta)));
      };
      const hunk = (delta: number) => {
        e.preventDefault();
        if (hunks.length === 0) return;
        const at = hunks.findIndex((h) => (delta > 0 ? h > cursor : h >= cursor));
        const next =
          delta > 0
            ? (hunks[at === -1 ? 0 : at] ?? hunks[0])
            : (hunks[(at === -1 ? hunks.length : at) - 1] ?? hunks[hunks.length - 1]);
        moveTo(next as number);
      };

      switch (e.key) {
        case "]":
          step(1);
          break;
        case "[":
          step(-1);
          break;
        case "s":
          setSplit((v) => !v);
          break;
        case "n":
          hunk(1);
          break;
        case "p":
          hunk(-1);
          break;
        case "j":
        case "ArrowDown":
          if (e.altKey) step(1);
          else line(1);
          break;
        case "k":
        case "ArrowUp":
          if (e.altKey) step(-1);
          else line(-1);
          break;
        case "ArrowRight":
          if (e.altKey) step(1);
          break;
        case "ArrowLeft":
          if (e.altKey) step(-1);
          break;
        case "g":
          moveTo(0);
          break;
        case "G":
          moveTo(rows.length - 1);
          break;
        case "e":
          // Expand the collapsed stretch the cursor is sitting on.
          if (rows[cursor] === null) {
            setExpanded((prev) => {
              const next = new Set(prev);
              if (next.has(cursor)) next.delete(cursor);
              else next.add(cursor);
              return next;
            });
          }
          break;
        case "?":
          setShowKeys((v) => !v);
          break;
        default:
          break;
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose, step, cursor, rows, hunks, moveTo]);


  const folded = grouping?.groups.reduce((n, g) => n + g.paths.length, 0) ?? 0;


  return (
    <div
      className="jac-diffm-scrim"
      role="dialog"
      aria-modal="true"
      aria-label="diff"
      onMouseDown={(e) => {
        if (e.target === e.currentTarget) onClose();
      }}
    >
      <div className="jac-diffm">
        <header className="jac-diffm-head">
          <div className="jac-diffm-title">
            {group ? (
              <span className="jac-diffm-path">
                <b>{group.paths.length} files</b>
                <span className="jac-diffm-path-dir">
                  {" "}
                  · one change, shown once
                </span>
              </span>
            ) : file ? (
              <>
                <PathName path={file.path} />
                <StatStrip added={file.added} removed={file.removed} />
              </>
            ) : (
              <span className="jac-diffm-path jac-diffm-path-dir">
                {ready ? "nothing selected" : `reading ${progress}/${paths.length}…`}
              </span>
            )}
          </div>

          <div className="jac-diffm-govern">
            <Governed decisions={file?.governedBy ?? groupGovern(group, files)} />
          </div>

          <div className="jac-diffm-tools">
            <div className="jac-seg">
              <button type="button" data-on={!split} onClick={() => setSplit(false)}>
                unified
              </button>
              <button type="button" data-on={split} onClick={() => setSplit(true)}>
                split
              </button>
            </div>
            <button type="button" className="jac-diffm-close" onClick={onClose}>
              Close <kbd>esc</kbd>
            </button>
          </div>
        </header>

        <div className="jac-diffm-main">
          {entries.length > 1 ? (
            <nav className="jac-diffm-rail" aria-label="what changed">
              <p className="jac-diffm-rail-label">
                {paths.length} paths
                {folded > 0 ? ` · ${folded} folded` : ""}
                {judging ? " · judging…" : ""}
              </p>

              {grouping?.groups.map((g) => (
                <button
                  key={g.id}
                  type="button"
                  className="jac-diffm-file jac-diffm-fold"
                  data-judged={Boolean(g.judged)}
                  data-on={selection?.kind === "group" && selection.id === g.id}
                  onClick={() => setSelection({ kind: "group", id: g.id })}
                >
                  <span className="jac-diffm-fold-badge">×{g.paths.length}</span>
                  <span className="jac-diffm-file-name">
                    {g.judged?.label ?? describe(g.substitutions[0] as never)}
                  </span>
                  <span className="jac-diffm-file-dir">
                    {g.judged
                      ? `${g.judged.from.length} spellings · proposed`
                      : g.substitutions.length > 1
                        ? `${g.substitutions.length} substitutions`
                        : `${g.substitutions[0]?.count ?? 0} occurrences`}
                  </span>
                </button>
              ))}

              {grouping?.singles.map((p) => {
                const f = files[p];
                return (
                  <button
                    key={p}
                    type="button"
                    className="jac-diffm-file"
                    data-on={selection?.kind === "file" && selection.path === p}
                    onClick={() => setSelection({ kind: "file", path: p })}
                  >
                    <span className="jac-diffm-file-name">{p.split("/").pop()}</span>
                    <span className="jac-diffm-file-dir">
                      {p.split("/").slice(0, -1).join("/")}
                    </span>
                    {f ? (
                      <span className="jac-diffm-file-stat">
                        <b>+{f.added}</b>
                        <i>−{f.removed}</i>
                      </span>
                    ) : null}
                  </button>
                );
              })}
            </nav>
          ) : null}

          <div className="jac-diffm-body" ref={bodyRef}>
            {!ready ? (
              <p className="jac-diffm-empty">
                reading {progress}/{paths.length} paths…
              </p>
            ) : group ? (
              <GroupView
                group={group}
                files={files}
                repo={repo}
                humans={humans}
                split={split}
                cursor={cursor}
                expanded={expanded}
                onToggleGap={(i) =>
                  setExpanded((prev) => {
                    const next = new Set(prev);
                    if (next.has(i)) next.delete(i);
                    else next.add(i);
                    return next;
                  })
                }
                onOpenFile={(p) => setSelection({ kind: "file", path: p })}
              />
            ) : file ? (
              <RemarkZone
                repo={repo}
                anchor={{ kind: "file", id: file.path }}
                humans={humans}
              >
                {file.note ? <p className="jac-diffm-note">{file.note}</p> : null}
                {file.added === 0 && file.removed === 0 ? (
                  <p className="jac-diffm-note">
                    No lines changed here — shown for context.
                  </p>
                ) : null}
                <DiffTable
                  rows={file.rows}
                  split={split}
                  cursor={cursor}
                  expanded={expanded}
                  onToggle={(i) =>
                    setExpanded((prev) => {
                      const next = new Set(prev);
                      if (next.has(i)) next.delete(i);
                      else next.add(i);
                      return next;
                    })
                  }
                />
              </RemarkZone>
            ) : (
              <p className="jac-diffm-empty">nothing to show.</p>
            )}
          </div>
        </div>

        <footer className="jac-diffm-foot">
          <span className="jac-small">
            <kbd>n</kbd> <kbd>p</kbd> hunk · <kbd>j</kbd> <kbd>k</kbd> line ·{" "}
            <kbd>[</kbd> <kbd>]</kbd> file · <kbd>?</kbd> keys
          </span>
          <span className="jac-spacer" />
          <span className="jac-small">
            select a line and press <kbd>⌥S</kbd> to say something about it
          </span>
        </footer>

        {showKeys ? (
          <div className="jac-diffm-keys" onClick={() => setShowKeys(false)}>
            <dl>
              <dt>
                <kbd>n</kbd> <kbd>p</kbd>
              </dt>
              <dd>next / previous hunk — the unit a reviewer moves in</dd>
              <dt>
                <kbd>j</kbd> <kbd>k</kbd> <kbd>↓</kbd> <kbd>↑</kbd>
              </dt>
              <dd>one line at a time</dd>
              <dt>
                <kbd>g</kbd> <kbd>G</kbd>
              </dt>
              <dd>first / last line</dd>
              <dt>
                <kbd>[</kbd> <kbd>]</kbd>
              </dt>
              <dd>previous / next file or fold</dd>
              <dt>
                <kbd>e</kbd>
              </dt>
              <dd>expand the collapsed stretch under the cursor</dd>
              <dt>
                <kbd>s</kbd>
              </dt>
              <dd>unified / split</dd>
              <dt>
                <kbd>⌥S</kbd>
              </dt>
              <dd>say something about the selected text</dd>
              <dt>
                <kbd>esc</kbd>
              </dt>
              <dd>close</dd>
            </dl>
          </div>
        ) : null}
      </div>
    </div>
  );
}

/** Decisions governing a whole fold — the union across its paths. */
function groupGovern(
  group: JudgedGroup | null,
  files: Record<string, FileDiff>,
): DecisionDto[] {
  if (!group) return [];
  const seen = new Map<string, DecisionDto>();
  for (const p of group.paths) {
    for (const d of files[p]?.governedBy ?? []) seen.set(d.id, d);
  }
  return [...seen.values()];
}

function Governed({ decisions }: { decisions: DecisionDto[] }) {
  if (decisions.length === 0) {
    return (
      <span className="jac-tag jac-tag--warn" title="no decision claims this path">
        ungoverned
      </span>
    );
  }
  return (
    <>
      {decisions.map((d) => (
        <span
          key={d.id}
          className={`jac-tag${d.state === "unsettled" ? " jac-tag--warn" : ""}`}
          title={d.title}
        >
          {d.state === "unsettled" ? "⚠ unsettled" : "settled"} · {d.title}
        </span>
      ))}
    </>
  );
}

/**
 * One fold: the substitution described once, one representative diff, and the
 * list of everything that received exactly it.
 */
function GroupView({
  group,
  files,
  repo,
  humans,
  split,
  cursor,
  expanded,
  onToggleGap,
  onOpenFile,
}: {
  group: JudgedGroup;
  files: Record<string, FileDiff>;
  repo: string;
  humans: ActorView[];
  split: boolean;
  cursor: number;
  expanded: Set<number>;
  onToggleGap: (index: number) => void;
  onOpenFile: (path: string) => void;
}) {
  const sample = files[group.paths[0] as string];
  return (
    <RemarkZone repo={repo} anchor={{ kind: "file", id: group.paths[0] as string }} humans={humans}>
      {group.judged ? (
        <div className="jac-diffm-judged">
          <p className="jac-panel-label">
            Grouped by a model — {group.judged.from.length} spellings
          </p>
          <p className="jac-diffm-judged-why">{group.judged.why}</p>
          <p className="jac-small">
            A machine&apos;s reading, not a proof. The folds below were each
            established by exact match; only the claim that they are{" "}
            <em>one change</em> came from the model. Nothing was hidden to make
            it fit — a file whose changes were not fully accounted for is still
            listed on its own.
          </p>
        </div>
      ) : null}

      <div className="jac-diffm-subs">
        {group.substitutions.map((s) => (
          <div key={`${s.before}->${s.after}`} className="jac-diffm-sub">
            <code className="jac-diffm-sub-before">{s.before.trim() || "␠"}</code>
            <span className="jac-diffm-sub-arrow">→</span>
            <code className="jac-diffm-sub-after">{s.after.trim() || "␠"}</code>
            <span className="jac-diffm-sub-count">
              {s.count} {s.count === 1 ? "line" : "lines"}
            </span>
          </div>
        ))}
      </div>

      <p className="jac-diffm-note">
        Shown once. Every file below received exactly this and nothing else — a
        file with any other change is listed separately, never folded in.
      </p>

      {sample ? (
        <>
          <p className="jac-diffm-sample-label">
            as it appears in <b>{sample.path}</b>
          </p>
          <DiffTable
            rows={sample.rows}
            split={split}
            cursor={cursor}
            expanded={expanded}
            onToggle={onToggleGap}
          />
        </>
      ) : null}

      <ul className="jac-diffm-members">
        {group.paths.map((p) => {
          const f = files[p];
          return (
            <li key={p}>
              <button type="button" onClick={() => onOpenFile(p)}>
                <span className="jac-diffm-member-path">{p}</span>
                {f ? (
                  <span className="jac-diffm-file-stat">
                    <b>+{f.added}</b>
                    <i>−{f.removed}</i>
                  </span>
                ) : null}
              </button>
            </li>
          );
        })}
      </ul>
    </RemarkZone>
  );
}

function DiffTable({
  rows,
  split,
  cursor,
  expanded,
  onToggle,
}: {
  rows: (DiffRow | null)[];
  split: boolean;
  /** Row the keyboard is on, or -1 when nobody has pressed a key. */
  cursor: number;
  expanded: Set<number>;
  onToggle: (index: number) => void;
}) {
  const spans = useMemo(() => pairChanges(rows), [rows]);
  return (
    <table className="jac-diffm-table" data-split={split}>
      <tbody>
        {rows.map((row, i) =>
          row === null ? (
            <Elision
              key={`gap-${i}`}
              index={i}
              open={expanded.has(i)}
              cursor={cursor === i}
              onToggle={() => onToggle(i)}
            />
          ) : split ? (
            <SplitRow
              key={`r-${i}`}
              index={i}
              row={row}
              spans={spans.get(i)}
              cursor={cursor === i}
            />
          ) : (
            <UnifiedRow
              key={`r-${i}`}
              index={i}
              row={row}
              spans={spans.get(i)}
              cursor={cursor === i}
            />
          ),
        )}
      </tbody>
    </table>
  );
}

/** Directory dimmed, filename bright — the eye wants the leaf. */
function PathName({ path }: { path: string }) {
  const parts = path.split("/");
  const name = parts.pop();
  return (
    <span className="jac-diffm-path">
      {parts.length ? <span className="jac-diffm-path-dir">{parts.join("/")}/</span> : null}
      <b>{name}</b>
    </span>
  );
}

/** The proportional bar every reviewer reads before the lines themselves. */
function StatStrip({ added, removed }: { added: number; removed: number }) {
  const total = Math.max(1, added + removed);
  const blocks = 12;
  const filled = Math.round((added / total) * blocks);
  return (
    <span className="jac-diffm-stat">
      <b>+{added}</b>
      <i>−{removed}</i>
      <span className="jac-diffm-bar" aria-hidden="true">
        {Array.from({ length: blocks }, (_, i) => (
          <span key={i} data-kind={i < filled ? "add" : "del"} />
        ))}
      </span>
    </span>
  );
}

function Elision({
  index,
  open,
  cursor,
  onToggle,
}: {
  index: number;
  open: boolean;
  cursor: boolean;
  onToggle: () => void;
}) {
  return (
    <tr className="jac-diffm-gap" data-row={index} data-cursor={cursor}>
      <td colSpan={4}>
        <button type="button" onClick={onToggle}>
          {open ? "hide unchanged" : "unchanged lines"}
          {cursor ? <kbd>e</kbd> : null}
        </button>
      </td>
    </tr>
  );
}

function Text({ text, spans }: { text: string; spans?: Span[] }) {
  if (!spans) return <>{text || " "}</>;
  return (
    <>
      {spans.map((s, i) =>
        s.changed ? (
          <mark key={i} className="jac-diffm-word">
            {s.text}
          </mark>
        ) : (
          <span key={i}>{s.text}</span>
        ),
      )}
    </>
  );
}

function UnifiedRow({
  index,
  row,
  spans,
  cursor,
}: {
  index: number;
  row: DiffRow;
  spans?: Span[];
  cursor: boolean;
}) {
  return (
    <tr className="jac-diffm-row" data-kind={row.kind} data-row={index} data-cursor={cursor}>
      <td className="jac-diffm-ln">{row.a ?? ""}</td>
      <td className="jac-diffm-ln">{row.b ?? ""}</td>
      <td className="jac-diffm-mark" aria-hidden="true">
        {row.kind === "add" ? "+" : row.kind === "del" ? "−" : ""}
      </td>
      <td className="jac-diffm-text">
        <Text text={row.text} spans={spans} />
      </td>
    </tr>
  );
}

function SplitRow({
  index,
  row,
  spans,
  cursor,
}: {
  index: number;
  row: DiffRow;
  spans?: Span[];
  cursor: boolean;
}) {
  const left = row.kind !== "add";
  const right = row.kind !== "del";
  return (
    <tr
      className="jac-diffm-row"
      data-kind={row.kind}
      data-split="1"
      data-row={index}
      data-cursor={cursor}
    >
      <td className="jac-diffm-ln">{row.a ?? ""}</td>
      <td className="jac-diffm-text" data-side="a" data-kind={left ? row.kind : "none"}>
        {left ? <Text text={row.text} spans={spans} /> : null}
      </td>
      <td className="jac-diffm-ln">{row.b ?? ""}</td>
      <td className="jac-diffm-text" data-side="b" data-kind={right ? row.kind : "none"}>
        {right ? <Text text={row.text} spans={spans} /> : null}
      </td>
    </tr>
  );
}