gabriel / musehub public
proposal-detail.ts typescript
307 lines 14.2 KB
Raw
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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