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