/*
* diff.sr.ht frontend bundle.
*
* Consumes the SSR DOM contract:
* <script id="compare-data" type="application/json">{...}</script>
* <div id="tree-root"> (file tree sidebar)
* <div id="diff-root"> (per-file diff sections)
* <button data-diff-layout="split"> / <button data-diff-layout="stacked">
* <button data-diff-wrap> (long-line wrapping toggle)
*
* Rendering uses @pierre/diffs (FileDiff vanilla class) for each file and
* @pierre/trees (FileTree vanilla class) for the sidebar. Both render on the
* main thread (no worker pool passed) with the pure-JS Shiki engine, so the
* bundle is fully self-contained (no wasm / no runtime asset fetches).
*/
import { FileDiff, parsePatchFiles } from "@pierre/diffs";
import type { FileDiffMetadata, FileDiffOptions, ThemesType } from "@pierre/diffs";
import { FileTree } from "@pierre/trees";
import type { GitStatus, GitStatusEntry } from "@pierre/trees";
// ---- DOM contract types (produced by the Go web layer) --------------------
type Layout = "split" | "stacked";
interface FileEntry {
path: string;
oldPath: string;
status: string; // "A" | "M" | "D" | "R" | ...
additions: number;
deletions: number;
binary: boolean;
}
interface CompareData {
mode: "compare" | "commit";
patch: string;
truncated: boolean;
files: FileEntry[];
spec: { base: string; head: string; threeDot: boolean };
}
// ---- Constants ------------------------------------------------------------
const LAYOUT_KEY = "compare.layout";
const WRAP_KEY = "compare.wrap";
const DEFAULT_LAYOUT: Layout = "split";
// Auto light/dark: FileDiff selects the entry matching `themeType`. With
// themeType "system" it follows prefers-color-scheme at runtime.
const THEME: ThemesType = { light: "github-light", dark: "github-dark" };
// ---- Small helpers --------------------------------------------------------
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
className?: string,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}
function readLayout(): Layout {
try {
const v = localStorage.getItem(LAYOUT_KEY);
if (v === "split" || v === "stacked") return v;
} catch {
/* localStorage may be unavailable (private mode); fall through */
}
return DEFAULT_LAYOUT;
}
function writeLayout(layout: Layout): void {
try {
localStorage.setItem(LAYOUT_KEY, layout);
} catch {
/* ignore persistence failures */
}
}
function readWrap(): boolean {
try {
return localStorage.getItem(WRAP_KEY) === "true";
} catch {
/* localStorage may be unavailable (private mode); fall through */
}
return false;
}
function writeWrap(wrap: boolean): void {
try {
localStorage.setItem(WRAP_KEY, String(wrap));
} catch {
/* ignore persistence failures */
}
}
// "stacked" is the user-facing name for the unified diff style.
function diffStyleFor(layout: Layout): "unified" | "split" {
return layout === "split" ? "split" : "unified";
}
function diffOptionsFor(
layout: Layout,
wrap: boolean,
): FileDiffOptions<undefined> {
return {
theme: THEME,
themeType: "system",
diffStyle: diffStyleFor(layout),
overflow: wrap ? "wrap" : "scroll",
// We render our own header row above, so disable the library header.
disableFileHeader: true,
};
}
function gitStatusFor(status: string): GitStatus {
switch (status.toUpperCase()[0]) {
case "A":
return "added";
case "D":
return "deleted";
case "R":
return "renamed";
case "M":
default:
return "modified";
}
}
function sectionId(index: number): string {
return `file-${index}`;
}
// ---- Main -----------------------------------------------------------------
function readData(): CompareData | null {
const script = document.getElementById("compare-data");
if (!script || !script.textContent) return null;
try {
return JSON.parse(script.textContent) as CompareData;
} catch (err) {
console.error("compare: failed to parse #compare-data", err);
return null;
}
}
function buildFileHeader(index: number, file: FileEntry): HTMLElement {
const header = el("div", "diff-file-header");
const path = el("span", "diff-file-path");
if (file.oldPath && file.oldPath !== file.path) {
path.append(
el("span", "diff-file-oldpath", file.oldPath),
el("span", "diff-file-arrow", " → "),
el("span", "diff-file-newpath", file.path),
);
} else {
path.textContent = file.path;
}
header.append(path);
const stats = el("span", "diff-file-stats");
if (file.additions > 0) {
stats.append(el("span", "diff-file-add", `+${file.additions}`));
}
if (file.deletions > 0) {
stats.append(el("span", "diff-file-del", `-${file.deletions}`));
}
header.append(stats);
if (file.binary) {
header.append(el("span", "diff-file-badge diff-file-binary", "BIN"));
}
header.dataset.fileIndex = String(index);
return header;
}
function renderDiffs(
root: HTMLElement,
data: CompareData,
metaByPath: Map<string, FileDiffMetadata>,
layout: Layout,
wrap: boolean,
): FileDiff[] {
root.textContent = "";
const instances: FileDiff[] = [];
if (data.files.length === 0) {
root.append(el("div", "diff-empty", "No changes to display."));
return instances;
}
data.files.forEach((file, index) => {
const section = el("section", "diff-file");
section.id = sectionId(index);
section.dataset.path = file.path;
section.append(buildFileHeader(index, file));
const body = el("div", "diff-file-body");
section.append(body);
root.append(section);
if (file.binary) {
body.append(el("div", "diff-file-note", "Binary file not shown."));
return;
}
const meta = metaByPath.get(file.path);
if (!meta) {
body.append(el("div", "diff-file-note", "No textual diff available."));
return;
}
const instance = new FileDiff(diffOptionsFor(layout, wrap));
instance.render({ fileDiff: meta, containerWrapper: body });
instances.push(instance);
});
return instances;
}
function buildTree(
root: HTMLElement,
data: CompareData,
): FileTree | null {
root.textContent = "";
if (data.files.length === 0) return null;
const paths = data.files.map((f) => f.path);
const gitStatus: GitStatusEntry[] = data.files.map((f) => ({
path: f.path,
status: gitStatusFor(f.status),
}));
const indexByPath = new Map(data.files.map((f, i) => [f.path, i]));
const tree = new FileTree({
paths,
gitStatus,
initialExpansion: "open",
// "compact" preset: 24px rows + 0.8 density factor (tighter level-gap,
// row-gap and padding) so the sidebar reads as a dense file index rather
// than an airy list — matching the reference diff UIs.
density: "compact",
onSelectionChange: (selected) => {
const path = selected[0];
if (path == null) return;
const index = indexByPath.get(String(path));
if (index == null) return;
const section = document.getElementById(sectionId(index));
section?.scrollIntoView({ behavior: "smooth", block: "start" });
},
});
tree.render({ containerWrapper: root });
sizeTreeRoot(root, tree);
return tree;
}
// The @pierre/trees FileTree virtualizes its rows into a scroll viewport, so the
// mount needs a *definite* height or it collapses to zero and renders nothing
// (its README sets `mount.style.height` for exactly this reason — the inner
// container flexes to fill the mount, so a mount of height 0 shows nothing).
//
// Sizing the mount to the full viewport (the naive fix) leaves a tall band of
// the tree's own background below the last row whenever the tree is shorter than
// the screen. Instead we size the mount to the tree's *content* height, capped at
// the viewport: a short tree hugs its rows, a long tree scrolls within the cap.
//
// The content height can't be read directly (the inner container flexes to fill
// whatever height the mount has, so its scrollHeight just echoes the mount). We
// momentarily pin the mount to 1px so the virtualizer's scroll spacer reports the
// true total content height, read it, then apply the capped value. This is
// synchronous — no paint happens between the pin and the restore, so it doesn't
// flicker. Re-runs on window resize and on every tree change (expand/collapse).
function sizeTreeRoot(root: HTMLElement, tree: FileTree): void {
const cap = () => Math.max(160, window.innerHeight - 24);
const contentHeight = (): number | null => {
const container = root.firstElementChild as HTMLElement | null;
const scroll = container?.shadowRoot?.querySelector<HTMLElement>(
'[data-file-tree-virtualized-scroll]',
);
if (!scroll) return null;
const prev = root.style.height;
root.style.height = "1px";
const content = scroll.scrollHeight;
root.style.height = prev;
return content;
};
const apply = () => {
const content = contentHeight();
root.style.height = `${Math.min(cap(), content ?? cap())}px`;
};
apply();
window.addEventListener("resize", apply);
// Fires on expand/collapse and any other tree mutation, so the mount keeps
// hugging its content as rows appear and disappear.
tree.subscribe(apply);
}
function wireDiffControls(
layoutButtons: HTMLButtonElement[],
wrapButton: HTMLButtonElement | null,
getInstances: () => FileDiff[],
initialLayout: Layout,
initialWrap: boolean,
): void {
let layout = initialLayout;
let wrap = initialWrap;
const reflectControls = () => {
for (const btn of layoutButtons) {
btn.classList.toggle("active", btn.dataset.diffLayout === layout);
}
wrapButton?.classList.toggle("active", wrap);
wrapButton?.setAttribute("aria-pressed", String(wrap));
};
const rerender = () => {
for (const instance of getInstances()) {
instance.setOptions(diffOptionsFor(layout, wrap));
instance.rerender();
}
};
for (const btn of layoutButtons) {
btn.addEventListener("click", (ev) => {
ev.preventDefault();
const next = btn.dataset.diffLayout;
if (next !== "split" && next !== "stacked") return;
layout = next;
writeLayout(layout);
reflectControls();
rerender();
});
}
wrapButton?.addEventListener("click", (ev) => {
ev.preventDefault();
wrap = !wrap;
writeWrap(wrap);
reflectControls();
rerender();
});
// Reflect the initial (possibly persisted) options on the controls.
reflectControls();
}
function main(): void {
const data = readData();
if (!data) return;
const diffRoot = document.getElementById("diff-root");
const treeRoot = document.getElementById("tree-root");
if (!diffRoot) return;
const layout = readLayout();
const wrap = readWrap();
// parsePatchFiles returns one ParsedPatch per commit; each has a `files`
// array of FileDiffMetadata. Flatten across patches and index by path.
const metaByPath = new Map<string, FileDiffMetadata>();
if (data.patch) {
try {
for (const patch of parsePatchFiles(data.patch)) {
for (const meta of patch.files) {
metaByPath.set(meta.name, meta);
}
}
} catch (err) {
console.error("compare: failed to parse patch", err);
}
}
const instances = renderDiffs(diffRoot, data, metaByPath, layout, wrap);
if (treeRoot) buildTree(treeRoot, data);
const buttons = Array.from(
document.querySelectorAll<HTMLButtonElement>("button[data-diff-layout]"),
);
const wrapButton = document.querySelector<HTMLButtonElement>(
"button[data-diff-wrap]",
);
if (buttons.length > 0 || wrapButton) {
wireDiffControls(buttons, wrapButton, () => instances, layout, wrap);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", main);
} else {
main();
}