proposal-detail.ts
typescript
sha256:3c58668648c7323bb9f5c6881cfe6a3f14fc93fcb73b537d253732952a5bf8bf
chore: bump version to 0.2.0rc12
Sonnet 4.6
patch
8 days ago
| 1 | /** |
| 2 | * Proposal detail page — progressive enhancement. |
| 3 | * |
| 4 | * Responsibilities: |
| 5 | * 1. Two-level symbol delta paginator: |
| 6 | * outer — paginate through file groups (GROUPS_PER_PAGE) |
| 7 | * inner — paginate through symbols within each file (SYMS_PER_FILE) |
| 8 | * 2. Copy-to-clipboard for commit SHA chips. |
| 9 | * 3. Merge strategy selector — updates the HTMX button's hx-vals payload. |
| 10 | */ |
| 11 | |
| 12 | const GROUPS_PER_PAGE = 8; |
| 13 | const SYMS_PER_FILE = 8; |
| 14 | |
| 15 | const IC = (name: string, size: number) => |
| 16 | `<svg width="${size}" height="${size}" aria-hidden="true"><use href="#icon-${name}"></use></svg>`; |
| 17 | |
| 18 | // ── Types ───────────────────────────────────────────────────────────────────── |
| 19 | |
| 20 | interface SymGroup { |
| 21 | file: string; |
| 22 | symbols: string[]; |
| 23 | } |
| 24 | |
| 25 | interface SymPaginatorState { |
| 26 | groups: SymGroup[]; |
| 27 | kind: "add" | "mod" | "del"; |
| 28 | outerPage: number; |
| 29 | /** innerPages[i] is the current symbol page for groups[i] */ |
| 30 | innerPages: number[]; |
| 31 | list: HTMLElement; |
| 32 | pager: HTMLElement; |
| 33 | } |
| 34 | |
| 35 | // ── Helpers ─────────────────────────────────────────────────────────────────── |
| 36 | |
| 37 | function esc(s: string): string { |
| 38 | return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); |
| 39 | } |
| 40 | |
| 41 | function shortPath(p: string): string { |
| 42 | const parts = p.split("/"); |
| 43 | return parts.length <= 2 ? p : "…/" + parts.slice(-2).join("/"); |
| 44 | } |
| 45 | |
| 46 | function groupSymbols(names: string[]): SymGroup[] { |
| 47 | const map = new Map<string, string[]>(); |
| 48 | for (const name of names) { |
| 49 | const sep = name.indexOf("::"); |
| 50 | if (sep >= 0) { |
| 51 | const file = name.slice(0, sep); |
| 52 | const sym = name.slice(sep + 2); |
| 53 | const arr = map.get(file) ?? []; |
| 54 | arr.push(sym); |
| 55 | map.set(file, arr); |
| 56 | } else { |
| 57 | if (!map.has(name)) map.set(name, []); |
| 58 | } |
| 59 | } |
| 60 | return Array.from(map.entries()).map(([file, symbols]) => ({ file, symbols })); |
| 61 | } |
| 62 | |
| 63 | // ── Inner symbol pager HTML ─────────────────────────────────────────────────── |
| 64 | |
| 65 | function renderInnerSyms( |
| 66 | syms: string[], |
| 67 | innerPage: number, |
| 68 | kind: "add" | "mod" | "del", |
| 69 | file: string, |
| 70 | ): string { |
| 71 | if (syms.length === 0) return ""; |
| 72 | |
| 73 | const pages = Math.ceil(syms.length / SYMS_PER_FILE); |
| 74 | const start = innerPage * SYMS_PER_FILE; |
| 75 | const slice = syms.slice(start, start + SYMS_PER_FILE); |
| 76 | |
| 77 | const items = slice |
| 78 | .map(s => { |
| 79 | // Reconstruct the full symbol_address (file::symbol) for anchor storage. |
| 80 | // When file is empty (ungrouped symbol), the raw name is already the full address. |
| 81 | const fullAddr = file ? `${file}::${s}` : s; |
| 82 | return `<div class="proposal-sym-item"> |
| 83 | <code class="proposal-sym-item-name proposal-sym-item-name--${kind}">${esc(s)}</code> |
| 84 | <button type="button" class="proposal-sym-anchor-btn" data-sym-addr="${esc(fullAddr)}" title="Anchor a comment to this symbol"> |
| 85 | ${IC("message-square", 9)} |
| 86 | </button> |
| 87 | </div>`; |
| 88 | }) |
| 89 | .join(""); |
| 90 | |
| 91 | if (pages <= 1) { |
| 92 | return `<div class="proposal-sym-items">${items}</div>`; |
| 93 | } |
| 94 | |
| 95 | const end = Math.min(start + SYMS_PER_FILE, syms.length); |
| 96 | const atFirst = innerPage === 0; |
| 97 | const atLast = innerPage >= pages - 1; |
| 98 | |
| 99 | const prev2 = `<button class="proposal-sym-ipager-btn" data-iact="first" ${atFirst ? "disabled" : ""}>${IC("chevrons-left", 9)}</button>`; |
| 100 | const prev1 = `<button class="proposal-sym-ipager-btn" data-iact="prev" ${atFirst ? "disabled" : ""}>${IC("chevron-left", 9)}</button>`; |
| 101 | const next1 = `<button class="proposal-sym-ipager-btn" data-iact="next" ${atLast ? "disabled" : ""}>${IC("chevron-right", 9)}</button>`; |
| 102 | const next2 = `<button class="proposal-sym-ipager-btn" data-iact="last" ${atLast ? "disabled" : ""}>${IC("chevrons-right", 9)}</button>`; |
| 103 | |
| 104 | return `<div class="proposal-sym-items">${items}</div> |
| 105 | <div class="proposal-sym-ipager"> |
| 106 | ${prev2}${prev1} |
| 107 | <span class="proposal-sym-ipager-info">${start + 1}–${end}<span class="proposal-sym-pager-of"> / ${syms.length}</span></span> |
| 108 | ${next1}${next2} |
| 109 | </div>`; |
| 110 | } |
| 111 | |
| 112 | // ── Full page render ────────────────────────────────────────────────────────── |
| 113 | |
| 114 | function renderSymPage(state: SymPaginatorState): void { |
| 115 | const { groups, kind, outerPage, innerPages, list, pager } = state; |
| 116 | const total = groups.length; |
| 117 | const pages = Math.max(1, Math.ceil(total / GROUPS_PER_PAGE)); |
| 118 | const start = outerPage * GROUPS_PER_PAGE; |
| 119 | const slice = groups.slice(start, start + GROUPS_PER_PAGE); |
| 120 | |
| 121 | list.innerHTML = slice.map((g, sliceIdx) => { |
| 122 | const globalIdx = start + sliceIdx; |
| 123 | const innerPage = innerPages[globalIdx] ?? 0; |
| 124 | const count = g.symbols.length; |
| 125 | const short = shortPath(g.file); |
| 126 | const badge = count > 0 ? `<span class="proposal-sym-file-badge">${count}</span>` : ""; |
| 127 | const innerHtml = renderInnerSyms(g.symbols, innerPage, kind, g.file); |
| 128 | |
| 129 | return `<div class="proposal-sym-group-row proposal-sym-group-row--${kind}" data-gidx="${globalIdx}"> |
| 130 | <div class="proposal-sym-file-hd"> |
| 131 | <span class="proposal-sym-file-name" title="${esc(g.file)}">${esc(short)}</span> |
| 132 | ${badge} |
| 133 | </div> |
| 134 | ${innerHtml} |
| 135 | </div>`; |
| 136 | }).join(""); |
| 137 | |
| 138 | // Wire inner pager buttons |
| 139 | list.querySelectorAll<HTMLButtonElement>(".proposal-sym-ipager-btn").forEach(btn => { |
| 140 | btn.addEventListener("click", () => { |
| 141 | const row = btn.closest<HTMLElement>("[data-gidx]"); |
| 142 | const gidx = parseInt(row?.dataset.gidx ?? "0", 10); |
| 143 | const syms = groups[gidx]?.symbols ?? []; |
| 144 | const pages2 = Math.ceil(syms.length / SYMS_PER_FILE); |
| 145 | const cur = state.innerPages[gidx] ?? 0; |
| 146 | const act = btn.dataset.iact; |
| 147 | if (act === "first") state.innerPages[gidx] = 0; |
| 148 | else if (act === "prev") state.innerPages[gidx] = Math.max(0, cur - 1); |
| 149 | else if (act === "next") state.innerPages[gidx] = Math.min(pages2 - 1, cur + 1); |
| 150 | else if (act === "last") state.innerPages[gidx] = pages2 - 1; |
| 151 | renderSymPage(state); // re-render whole list (fast — only 8 groups) |
| 152 | }); |
| 153 | }); |
| 154 | |
| 155 | // ── Outer file-group pager ──────────────────────────────────────────────── |
| 156 | if (pages <= 1) { pager.innerHTML = ""; return; } |
| 157 | const end = Math.min(start + GROUPS_PER_PAGE, total); |
| 158 | const atFirst = outerPage === 0; |
| 159 | const atLast = outerPage >= pages - 1; |
| 160 | |
| 161 | pager.innerHTML = ` |
| 162 | <div class="proposal-sym-pager-inner"> |
| 163 | <button class="proposal-sym-pager-btn" data-oact="first" ${atFirst ? "disabled" : ""} title="First">${IC("chevrons-left", 11)}</button> |
| 164 | <button class="proposal-sym-pager-btn" data-oact="prev" ${atFirst ? "disabled" : ""} title="Previous">${IC("chevron-left", 11)}</button> |
| 165 | <span class="proposal-sym-pager-info">${start + 1}–${end} <span class="proposal-sym-pager-of">of ${total} files</span></span> |
| 166 | <button class="proposal-sym-pager-btn" data-oact="next" ${atLast ? "disabled" : ""} title="Next">${IC("chevron-right", 11)}</button> |
| 167 | <button class="proposal-sym-pager-btn" data-oact="last" ${atLast ? "disabled" : ""} title="Last">${IC("chevrons-right", 11)}</button> |
| 168 | </div>`; |
| 169 | |
| 170 | pager.querySelectorAll<HTMLButtonElement>(".proposal-sym-pager-btn").forEach(btn => { |
| 171 | btn.addEventListener("click", () => { |
| 172 | const act = btn.dataset.oact; |
| 173 | if (act === "first") state.outerPage = 0; |
| 174 | else if (act === "prev") state.outerPage = Math.max(0, outerPage - 1); |
| 175 | else if (act === "next") state.outerPage = Math.min(pages - 1, outerPage + 1); |
| 176 | else if (act === "last") state.outerPage = pages - 1; |
| 177 | // Reset all inner pages when navigating outer page |
| 178 | state.innerPages = new Array(state.groups.length).fill(0); |
| 179 | renderSymPage(state); |
| 180 | }); |
| 181 | }); |
| 182 | } |
| 183 | |
| 184 | function initSymPaginators(data: Record<string, unknown>): void { |
| 185 | const allAdded = (data.symAdded as string[] | undefined) ?? []; |
| 186 | const allModified = (data.symModified as string[] | undefined) ?? []; |
| 187 | const allDeleted = (data.symDeleted as string[] | undefined) ?? []; |
| 188 | |
| 189 | const kindMap: Record<string, string[]> = { |
| 190 | add: allAdded, mod: allModified, del: allDeleted, |
| 191 | }; |
| 192 | |
| 193 | document.querySelectorAll<HTMLElement>("[data-sym-kind]").forEach(group => { |
| 194 | const kind = group.dataset.symKind as "add" | "mod" | "del"; |
| 195 | const names = kindMap[kind] ?? []; |
| 196 | const list = group.querySelector<HTMLElement>(".proposal-sym-list"); |
| 197 | const pager = group.querySelector<HTMLElement>(".proposal-sym-pager"); |
| 198 | if (!list || !pager || !names.length) return; |
| 199 | |
| 200 | const groups = groupSymbols(names); |
| 201 | const state: SymPaginatorState = { |
| 202 | groups, |
| 203 | kind, |
| 204 | outerPage: 0, |
| 205 | innerPages: new Array(groups.length).fill(0), |
| 206 | list, |
| 207 | pager, |
| 208 | }; |
| 209 | renderSymPage(state); |
| 210 | }); |
| 211 | } |
| 212 | |
| 213 | // ── Copy-to-clipboard for SHA chips ────────────────────────────────────────── |
| 214 | |
| 215 | function bindShaCopy(): void { |
| 216 | document.addEventListener("click", async (e: MouseEvent) => { |
| 217 | const btn = (e.target as Element).closest<HTMLElement>("[data-sha]"); |
| 218 | if (!btn) return; |
| 219 | const sha = btn.dataset.sha; |
| 220 | if (!sha) return; |
| 221 | try { |
| 222 | await navigator.clipboard.writeText(sha); |
| 223 | const orig = btn.textContent ?? ""; |
| 224 | btn.textContent = "✓"; |
| 225 | setTimeout(() => { btn.textContent = orig; }, 1500); |
| 226 | } catch { /* clipboard unavailable */ } |
| 227 | }); |
| 228 | } |
| 229 | |
| 230 | // ── Merge strategy selector ─────────────────────────────────────────────────── |
| 231 | |
| 232 | function bindMergeStrategy(): void { |
| 233 | const strategies = document.querySelectorAll<HTMLElement>(".proposal-strategy"); |
| 234 | const mergeBtn = document.querySelector<HTMLButtonElement>("[data-merge-btn]"); |
| 235 | if (!strategies.length || !mergeBtn) return; |
| 236 | |
| 237 | strategies.forEach(strategy => { |
| 238 | strategy.addEventListener("click", () => { |
| 239 | const selected = strategy.dataset.strategy ?? "merge_commit"; |
| 240 | strategies.forEach(s => s.classList.remove("proposal-strategy--active")); |
| 241 | strategy.classList.add("proposal-strategy--active"); |
| 242 | const vals = JSON.stringify({ mergeStrategy: selected, deleteBranch: true }); |
| 243 | mergeBtn.setAttribute("hx-vals", vals); |
| 244 | const labelEl = mergeBtn.querySelector(".proposal-merge-btn-label"); |
| 245 | const label = strategy.querySelector(".proposal-strategy-label")?.textContent ?? "Merge"; |
| 246 | if (labelEl) labelEl.textContent = label; |
| 247 | }); |
| 248 | }); |
| 249 | |
| 250 | const cb = document.getElementById("cb-delete-branch") as HTMLInputElement | null; |
| 251 | cb?.addEventListener("change", () => { |
| 252 | const current = JSON.parse(mergeBtn.getAttribute("hx-vals") ?? "{}") as Record<string, unknown>; |
| 253 | current.deleteBranch = cb.checked; |
| 254 | mergeBtn.setAttribute("hx-vals", JSON.stringify(current)); |
| 255 | }); |
| 256 | } |
| 257 | |
| 258 | // ── Symbol anchor comment binding ───────────────────────────────────────────── |
| 259 | |
| 260 | function bindSymbolAnchor(): void { |
| 261 | const input = document.getElementById("proposal-sym-anchor-input") as HTMLInputElement | null; |
| 262 | const preview = document.getElementById("proposal-sym-anchor-preview") as HTMLElement | null; |
| 263 | const display = document.getElementById("proposal-sym-anchor-display") as HTMLElement | null; |
| 264 | const clear = document.getElementById("proposal-sym-anchor-clear") as HTMLButtonElement | null; |
| 265 | if (!input || !preview || !display) return; |
| 266 | |
| 267 | // Delegate click on any ".proposal-sym-anchor-btn" button — these are rendered dynamically |
| 268 | document.addEventListener("click", (e: MouseEvent) => { |
| 269 | const btn = (e.target as Element).closest<HTMLElement>(".proposal-sym-anchor-btn"); |
| 270 | if (!btn) return; |
| 271 | const addr = btn.dataset.symAddr ?? ""; |
| 272 | if (!addr) return; |
| 273 | input.value = addr; |
| 274 | display.textContent = addr; |
| 275 | preview.hidden = false; |
| 276 | // Scroll to and focus the comment form |
| 277 | document.getElementById("proposal-comment-form")?.scrollIntoView({ behavior: "smooth", block: "nearest" }); |
| 278 | }); |
| 279 | |
| 280 | clear?.addEventListener("click", () => { |
| 281 | input.value = ""; |
| 282 | display.textContent = ""; |
| 283 | preview.hidden = true; |
| 284 | }); |
| 285 | } |
| 286 | |
| 287 | // ── Entry point ─────────────────────────────────────────────────────────────── |
| 288 | |
| 289 | export function initProposalDetail(data?: Record<string, unknown>): void { |
| 290 | initSymPaginators(data ?? {}); |
| 291 | bindShaCopy(); |
| 292 | bindMergeStrategy(); |
| 293 | bindSymbolAnchor(); |
| 294 | } |
File History
1 commit
sha256:3c58668648c7323bb9f5c6881cfe6a3f14fc93fcb73b537d253732952a5bf8bf
chore: bump version to 0.2.0rc12
Sonnet 4.6
patch
8 days ago