/** * Proposal detail page — progressive enhancement. * * Responsibilities: * 1. Two-level symbol delta paginator: * outer — paginate through file groups (GROUPS_PER_PAGE) * inner — paginate through symbols within each file (SYMS_PER_FILE) * 2. Copy-to-clipboard for commit SHA chips. * 3. Merge strategy selector — updates the HTMX button's hx-vals payload. */ const GROUPS_PER_PAGE = 8; const SYMS_PER_FILE = 8; const IC = (name: string, size: number) => ``; // ── Types ───────────────────────────────────────────────────────────────────── interface SymGroup { file: string; symbols: string[]; } interface SymPaginatorState { groups: SymGroup[]; kind: "add" | "mod" | "del"; outerPage: number; /** innerPages[i] is the current symbol page for groups[i] */ innerPages: number[]; list: HTMLElement; pager: HTMLElement; } // ── Helpers ─────────────────────────────────────────────────────────────────── function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } function shortPath(p: string): string { const parts = p.split("/"); return parts.length <= 2 ? p : "…/" + parts.slice(-2).join("/"); } function groupSymbols(names: string[]): SymGroup[] { const map = new Map(); for (const name of names) { const sep = name.indexOf("::"); if (sep >= 0) { const file = name.slice(0, sep); const sym = name.slice(sep + 2); const arr = map.get(file) ?? []; arr.push(sym); map.set(file, arr); } else { if (!map.has(name)) map.set(name, []); } } return Array.from(map.entries()).map(([file, symbols]) => ({ file, symbols })); } // ── Inner symbol pager HTML ─────────────────────────────────────────────────── function renderInnerSyms( syms: string[], innerPage: number, kind: "add" | "mod" | "del", file: string, ): string { if (syms.length === 0) return ""; const pages = Math.ceil(syms.length / SYMS_PER_FILE); const start = innerPage * SYMS_PER_FILE; const slice = syms.slice(start, start + SYMS_PER_FILE); const items = slice .map(s => { // Reconstruct the full symbol_address (file::symbol) for anchor storage. // When file is empty (ungrouped symbol), the raw name is already the full address. const fullAddr = file ? `${file}::${s}` : s; return `
${esc(s)}
`; }) .join(""); if (pages <= 1) { return `
${items}
`; } const end = Math.min(start + SYMS_PER_FILE, syms.length); const atFirst = innerPage === 0; const atLast = innerPage >= pages - 1; const prev2 = ``; const prev1 = ``; const next1 = ``; const next2 = ``; return `
${items}
${prev2}${prev1} ${start + 1}–${end} / ${syms.length} ${next1}${next2}
`; } // ── Full page render ────────────────────────────────────────────────────────── function renderSymPage(state: SymPaginatorState): void { const { groups, kind, outerPage, innerPages, list, pager } = state; const total = groups.length; const pages = Math.max(1, Math.ceil(total / GROUPS_PER_PAGE)); const start = outerPage * GROUPS_PER_PAGE; const slice = groups.slice(start, start + GROUPS_PER_PAGE); list.innerHTML = slice.map((g, sliceIdx) => { const globalIdx = start + sliceIdx; const innerPage = innerPages[globalIdx] ?? 0; const count = g.symbols.length; const short = shortPath(g.file); const badge = count > 0 ? `${count}` : ""; const innerHtml = renderInnerSyms(g.symbols, innerPage, kind, g.file); return `
${esc(short)} ${badge}
${innerHtml}
`; }).join(""); // Wire inner pager buttons list.querySelectorAll(".proposal-sym-ipager-btn").forEach(btn => { btn.addEventListener("click", () => { const row = btn.closest("[data-gidx]"); const gidx = parseInt(row?.dataset.gidx ?? "0", 10); const syms = groups[gidx]?.symbols ?? []; const pages2 = Math.ceil(syms.length / SYMS_PER_FILE); const cur = state.innerPages[gidx] ?? 0; const act = btn.dataset.iact; if (act === "first") state.innerPages[gidx] = 0; else if (act === "prev") state.innerPages[gidx] = Math.max(0, cur - 1); else if (act === "next") state.innerPages[gidx] = Math.min(pages2 - 1, cur + 1); else if (act === "last") state.innerPages[gidx] = pages2 - 1; renderSymPage(state); // re-render whole list (fast — only 8 groups) }); }); // ── Outer file-group pager ──────────────────────────────────────────────── if (pages <= 1) { pager.innerHTML = ""; return; } const end = Math.min(start + GROUPS_PER_PAGE, total); const atFirst = outerPage === 0; const atLast = outerPage >= pages - 1; pager.innerHTML = `
${start + 1}–${end} of ${total} files
`; pager.querySelectorAll(".proposal-sym-pager-btn").forEach(btn => { btn.addEventListener("click", () => { const act = btn.dataset.oact; if (act === "first") state.outerPage = 0; else if (act === "prev") state.outerPage = Math.max(0, outerPage - 1); else if (act === "next") state.outerPage = Math.min(pages - 1, outerPage + 1); else if (act === "last") state.outerPage = pages - 1; // Reset all inner pages when navigating outer page state.innerPages = new Array(state.groups.length).fill(0); renderSymPage(state); }); }); } function initSymPaginators(data: Record): void { const allAdded = (data.symAdded as string[] | undefined) ?? []; const allModified = (data.symModified as string[] | undefined) ?? []; const allDeleted = (data.symDeleted as string[] | undefined) ?? []; const kindMap: Record = { add: allAdded, mod: allModified, del: allDeleted, }; document.querySelectorAll("[data-sym-kind]").forEach(group => { const kind = group.dataset.symKind as "add" | "mod" | "del"; const names = kindMap[kind] ?? []; const list = group.querySelector(".proposal-sym-list"); const pager = group.querySelector(".proposal-sym-pager"); if (!list || !pager || !names.length) return; const groups = groupSymbols(names); const state: SymPaginatorState = { groups, kind, outerPage: 0, innerPages: new Array(groups.length).fill(0), list, pager, }; renderSymPage(state); }); } // ── Copy-to-clipboard for SHA chips ────────────────────────────────────────── function bindShaCopy(): void { document.addEventListener("click", async (e: MouseEvent) => { const btn = (e.target as Element).closest("[data-sha]"); if (!btn) return; const sha = btn.dataset.sha; if (!sha) return; try { await navigator.clipboard.writeText(sha); const orig = btn.textContent ?? ""; btn.textContent = "✓"; setTimeout(() => { btn.textContent = orig; }, 1500); } catch { /* clipboard unavailable */ } }); } // ── Merge strategy selector ─────────────────────────────────────────────────── function bindMergeStrategy(): void { const strategies = document.querySelectorAll(".proposal-strategy"); const mergeBtn = document.querySelector("[data-merge-btn]"); if (!strategies.length || !mergeBtn) return; strategies.forEach(strategy => { strategy.addEventListener("click", () => { const selected = strategy.dataset.strategy ?? "merge_commit"; strategies.forEach(s => s.classList.remove("proposal-strategy--active")); strategy.classList.add("proposal-strategy--active"); const vals = JSON.stringify({ mergeStrategy: selected, deleteBranch: true }); mergeBtn.setAttribute("hx-vals", vals); const labelEl = mergeBtn.querySelector(".proposal-merge-btn-label"); const label = strategy.querySelector(".proposal-strategy-label")?.textContent ?? "Merge"; if (labelEl) labelEl.textContent = label; }); }); const cb = document.getElementById("cb-delete-branch") as HTMLInputElement | null; cb?.addEventListener("change", () => { const current = JSON.parse(mergeBtn.getAttribute("hx-vals") ?? "{}") as Record; current.deleteBranch = cb.checked; mergeBtn.setAttribute("hx-vals", JSON.stringify(current)); }); } // ── Symbol anchor comment binding ───────────────────────────────────────────── function bindSymbolAnchor(): void { const input = document.getElementById("proposal-sym-anchor-input") as HTMLInputElement | null; const preview = document.getElementById("proposal-sym-anchor-preview") as HTMLElement | null; const display = document.getElementById("proposal-sym-anchor-display") as HTMLElement | null; const clear = document.getElementById("proposal-sym-anchor-clear") as HTMLButtonElement | null; if (!input || !preview || !display) return; // Delegate click on any ".proposal-sym-anchor-btn" button — these are rendered dynamically document.addEventListener("click", (e: MouseEvent) => { const btn = (e.target as Element).closest(".proposal-sym-anchor-btn"); if (!btn) return; const addr = btn.dataset.symAddr ?? ""; if (!addr) return; input.value = addr; display.textContent = addr; preview.hidden = false; // Scroll to and focus the comment form document.getElementById("proposal-comment-form")?.scrollIntoView({ behavior: "smooth", block: "nearest" }); }); clear?.addEventListener("click", () => { input.value = ""; display.textContent = ""; preview.hidden = true; }); } // ── Entry point ─────────────────────────────────────────────────────────────── export function initProposalDetail(data?: Record): void { initSymPaginators(data ?? {}); bindShaCopy(); bindMergeStrategy(); bindSymbolAnchor(); }