jacquardSnapshot

← snapshot

19346 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 {
  describe,
  editsOf,
  groupFiles,
  type DiffGroup,
  type FileEdits,
} from "@/lib/diff-group";
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,
  );
  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 grouping = useMemo(
    () => (ready ? groupFiles(paths, (p) => files[p]?.edits) : null),
    [ready, paths, files],
  );

  // Land on the biggest fold when nothing specific was asked for: that is the
  // thing a reviewer most wants dealt with first.
  useEffect(() => {
    if (selection || !grouping) return;
    const first = grouping.groups[0];
    setSelection(
      first
        ? { kind: "group", id: first.id }
        : grouping.singles[0]
          ? { kind: "file", path: grouping.singles[0] }
          : null,
    );
  }, [grouping, selection]);

  useEffect(() => {
    bodyRef.current?.scrollTo({ top: 0 });
  }, [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],
  );

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        onClose();
        return;
      }
      if ((e.target as HTMLElement | null)?.closest?.("input, textarea, select")) return;
      if (e.key === "]" || (e.altKey && e.key === "ArrowRight")) step(1);
      else if (e.key === "[" || (e.altKey && e.key === "ArrowLeft")) step(-1);
      else if (e.key === "s" && !e.altKey && !e.metaKey) setSplit((v) => !v);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose, step]);

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

  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">
                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` : ""}
              </p>

              {grouping?.groups.map((g) => (
                <button
                  key={g.id}
                  type="button"
                  className="jac-diffm-file jac-diffm-fold"
                  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">
                    {describe(g.substitutions[0] as never)}
                  </span>
                  <span className="jac-diffm-file-dir">
                    {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}
                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} />
              </RemarkZone>
            ) : (
              <p className="jac-diffm-empty">nothing to show.</p>
            )}
          </div>
        </div>

        <footer className="jac-diffm-foot">
          <span className="jac-small">
            <kbd>[</kbd> <kbd>]</kbd> step · <kbd>s</kbd> split · <kbd>esc</kbd> close
          </span>
          <span className="jac-spacer" />
          <span className="jac-small">
            select a line and press <kbd>⌥S</kbd> to say something about it
          </span>
        </footer>
      </div>
    </div>
  );
}

/** Decisions governing a whole fold — the union across its paths. */
function groupGovern(
  group: DiffGroup | 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,
  onOpenFile,
}: {
  group: DiffGroup;
  files: Record<string, FileDiff>;
  repo: string;
  humans: ActorView[];
  split: boolean;
  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}>
      <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} />
        </>
      ) : 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 }: { rows: (DiffRow | null)[]; split: boolean }) {
  const [expanded, setExpanded] = useState<Set<number>>(new Set());
  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}`}
              open={expanded.has(i)}
              onToggle={() =>
                setExpanded((prev) => {
                  const next = new Set(prev);
                  if (next.has(i)) next.delete(i);
                  else next.add(i);
                  return next;
                })
              }
            />
          ) : split ? (
            <SplitRow key={`r-${i}`} row={row} spans={spans.get(i)} />
          ) : (
            <UnifiedRow key={`r-${i}`} row={row} spans={spans.get(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({ open, onToggle }: { open: boolean; onToggle: () => void }) {
  return (
    <tr className="jac-diffm-gap">
      <td colSpan={4}>
        <button type="button" onClick={onToggle}>
          {open ? "hide unchanged" : "unchanged lines"}
        </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({ row, spans }: { row: DiffRow; spans?: Span[] }) {
  return (
    <tr className="jac-diffm-row" data-kind={row.kind}>
      <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({ row, spans }: { row: DiffRow; spans?: Span[] }) {
  const left = row.kind !== "add";
  const right = row.kind !== "del";
  return (
    <tr className="jac-diffm-row" data-kind={row.kind} data-split="1">
      <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>
  );
}