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