();
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}
`;
}
// ── 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 = `
`;
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();
}