repoview 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -180,6 +180,17 @@ function initTimezoneToggle() {
180
180
  update();
181
181
  }
182
182
 
183
+ function fallbackCopy(text) {
184
+ const ta = document.createElement("textarea");
185
+ ta.value = text;
186
+ ta.style.position = "fixed";
187
+ ta.style.opacity = "0";
188
+ document.body.appendChild(ta);
189
+ ta.select();
190
+ document.execCommand("copy");
191
+ document.body.removeChild(ta);
192
+ }
193
+
183
194
  function initDiffCollapse() {
184
195
  const wrappers = document.querySelectorAll(".d2h-file-wrapper");
185
196
  if (!wrappers.length) return;
@@ -213,6 +224,34 @@ function initDiffCollapse() {
213
224
  link.textContent = rawName;
214
225
  fileNameEl.textContent = "";
215
226
  fileNameEl.appendChild(link);
227
+
228
+ const copyBtn = document.createElement("button");
229
+ copyBtn.className = "diff-copy-btn";
230
+ copyBtn.type = "button";
231
+ copyBtn.setAttribute("aria-label", "Copy filename");
232
+ copyBtn.innerHTML =
233
+ '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">' +
234
+ '<rect x="5" y="5" width="9" height="9" rx="1.5"/>' +
235
+ '<path d="M3 11V2.5A1.5 1.5 0 0 1 4.5 1H11"/>' +
236
+ "</svg>";
237
+ const svgIcon = copyBtn.innerHTML;
238
+ copyBtn.addEventListener("click", (e) => {
239
+ e.stopPropagation();
240
+ const showSuccess = () => {
241
+ copyBtn.textContent = "\u2713";
242
+ setTimeout(() => { copyBtn.innerHTML = svgIcon; }, 1500);
243
+ };
244
+ if (navigator.clipboard?.writeText) {
245
+ navigator.clipboard.writeText(name).then(showSuccess).catch(() => {
246
+ fallbackCopy(name);
247
+ showSuccess();
248
+ });
249
+ } else {
250
+ fallbackCopy(name);
251
+ showSuccess();
252
+ }
253
+ });
254
+ fileNameEl.appendChild(copyBtn);
216
255
  }
217
256
 
218
257
  const toggle = document.createElement("button");
@@ -276,6 +315,36 @@ function initBaseSelector() {
276
315
  });
277
316
  }
278
317
 
318
+ function initLineHighlight() {
319
+ const hash = location.hash;
320
+ const m = hash.match(/^#L(\d+)$/);
321
+ if (!m) return;
322
+ const lineNum = parseInt(m[1], 10);
323
+
324
+ const codeBlock = document.querySelector(".code-wrap pre code, .code-wrap pre");
325
+ if (!codeBlock) return;
326
+
327
+ const text = codeBlock.textContent;
328
+ const lines = text.split("\n");
329
+ if (lineNum < 1 || lineNum > lines.length) return;
330
+
331
+ // Wrap lines in spans so we can highlight and scroll to the target
332
+ const html = codeBlock.innerHTML;
333
+ const htmlLines = html.split("\n");
334
+ codeBlock.innerHTML = htmlLines
335
+ .map((l, i) => {
336
+ const num = i + 1;
337
+ const cls = num === lineNum ? "line-highlight" : "";
338
+ return `<span class="code-line-wrap ${cls}" id="L${num}">${l}</span>`;
339
+ })
340
+ .join("\n");
341
+
342
+ const target = document.getElementById(`L${lineNum}`);
343
+ if (target) {
344
+ requestAnimationFrame(() => target.scrollIntoView({ block: "center" }));
345
+ }
346
+ }
347
+
279
348
  window.addEventListener("load", () => {
280
349
  preserveQueryParamsOnInternalLinks(["ignored", "watch", "base", "show_all"]);
281
350
  renderMath();
@@ -283,4 +352,5 @@ window.addEventListener("load", () => {
283
352
  initTimezoneToggle();
284
353
  initBaseSelector();
285
354
  initDiffCollapse();
355
+ initLineHighlight();
286
356
  });
@@ -0,0 +1,584 @@
1
+ (() => {
2
+ // --- Reply form ---
3
+ const replyBtn = document.getElementById("review-reply-submit");
4
+ const replyText = document.getElementById("review-reply-text");
5
+
6
+ if (replyBtn && replyText) {
7
+ replyBtn.addEventListener("click", async () => {
8
+ const body = replyText.value.trim();
9
+ if (!body) return;
10
+
11
+ const threadId = replyBtn.dataset.threadId;
12
+ replyBtn.disabled = true;
13
+ replyBtn.textContent = "Sending...";
14
+
15
+ try {
16
+ const res = await fetch(`/review/${encodeURIComponent(threadId)}/messages`, {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify({ body }),
20
+ });
21
+ if (res.ok) {
22
+ replyText.value = "";
23
+ location.reload();
24
+ } else {
25
+ const data = await res.json();
26
+ alert(data.error || "Failed to send reply");
27
+ }
28
+ } catch (e) {
29
+ alert("Network error: " + e.message);
30
+ } finally {
31
+ replyBtn.disabled = false;
32
+ replyBtn.textContent = "Submit Reply";
33
+ }
34
+ });
35
+
36
+ replyText.addEventListener("keydown", (e) => {
37
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
38
+ e.preventDefault();
39
+ replyBtn.click();
40
+ }
41
+ });
42
+ }
43
+
44
+ // --- Shared: open inline comment form below a block ---
45
+ function openCommentForm(block, messageId) {
46
+ const existing = document.querySelector(".review-inline-form");
47
+ if (existing) existing.remove();
48
+
49
+ const lineStart = block.dataset.sourceLineStart;
50
+ const lineEnd = block.dataset.sourceLineEnd;
51
+ const anchorText = block.textContent.slice(0, 80);
52
+ const lineLabel = lineEnd && lineEnd !== lineStart
53
+ ? `lines ${lineStart}-${lineEnd}`
54
+ : `line ${lineStart}`;
55
+
56
+ const form = document.createElement("div");
57
+ form.className = "review-inline-form";
58
+ form.innerHTML = `
59
+ <textarea class="review-inline-textarea" placeholder="Comment on ${lineLabel}..." rows="3"></textarea>
60
+ <div class="review-inline-actions">
61
+ <button class="btn btn-sm review-inline-submit" type="button">Comment</button>
62
+ <button class="btn btn-sm review-inline-cancel" type="button">Cancel</button>
63
+ </div>
64
+ `;
65
+
66
+ block.after(form);
67
+ form.querySelector(".review-inline-textarea").focus();
68
+ form.querySelector(".review-inline-cancel").addEventListener("click", () => form.remove());
69
+
70
+ form.querySelector(".review-inline-submit").addEventListener("click", async () => {
71
+ const body = form.querySelector(".review-inline-textarea").value.trim();
72
+ if (!body) return;
73
+ const threadId = document.querySelector("[data-thread-id]")?.dataset.threadId;
74
+ if (!threadId) return;
75
+ try {
76
+ const res = await fetch(`/review/${encodeURIComponent(threadId)}/comments`, {
77
+ method: "POST",
78
+ headers: { "Content-Type": "application/json" },
79
+ body: JSON.stringify({
80
+ messageId, anchorLine: Number(lineStart),
81
+ anchorEndLine: lineEnd ? Number(lineEnd) : null, anchorText, body,
82
+ }),
83
+ });
84
+ if (res.ok) location.reload();
85
+ else alert((await res.json()).error || "Failed to add comment");
86
+ } catch (err) {
87
+ alert("Network error: " + err.message);
88
+ }
89
+ });
90
+ }
91
+
92
+ // --- Build a map of which blocks have existing comments ---
93
+ function buildCommentMap() {
94
+ // Map: "messageId:anchorLine" → [comment card elements]
95
+ const map = new Map();
96
+ const cards = document.querySelectorAll(".review-comment-card[data-anchor-line]");
97
+ for (const card of cards) {
98
+ const line = card.dataset.anchorLine;
99
+ const msgId = card.dataset.messageId;
100
+ if (!line || !msgId) continue;
101
+ const key = `${msgId}:${line}`;
102
+ if (!map.has(key)) map.set(key, []);
103
+ map.get(key).push(card);
104
+ }
105
+ return map;
106
+ }
107
+
108
+ function findCommentsForBlock(commentMap, messageId, block) {
109
+ const start = Number(block.dataset.sourceLineStart);
110
+ const end = Number(block.dataset.sourceLineEnd) || start;
111
+ const found = [];
112
+ for (const [key, cards] of commentMap) {
113
+ const [mid, lineStr] = key.split(":");
114
+ if (mid !== messageId) continue;
115
+ const line = Number(lineStr);
116
+ if (line >= start && line <= end) found.push(...cards);
117
+ }
118
+ return found;
119
+ }
120
+
121
+ // Show existing comments + add-reply form anchored below a block
122
+ function openCommentThread(block, messageId, existingCards) {
123
+ const existing = document.querySelector(".review-inline-form");
124
+ if (existing) existing.remove();
125
+
126
+ const lineStart = block.dataset.sourceLineStart;
127
+ const lineEnd = block.dataset.sourceLineEnd;
128
+ const anchorText = block.textContent.slice(0, 80);
129
+ const lineLabel = lineEnd && lineEnd !== lineStart
130
+ ? `lines ${lineStart}-${lineEnd}`
131
+ : `line ${lineStart}`;
132
+
133
+ const form = document.createElement("div");
134
+ form.className = "review-inline-form";
135
+
136
+ // Clone existing comment cards into the thread view
137
+ const commentsHtml = existingCards.map((card) => card.outerHTML).join("");
138
+
139
+ form.innerHTML = `
140
+ <div class="review-inline-thread">${commentsHtml}</div>
141
+ <textarea class="review-inline-textarea" placeholder="Reply on ${lineLabel}..." rows="2"></textarea>
142
+ <div class="review-inline-actions">
143
+ <button class="btn btn-sm review-inline-submit" type="button">Comment</button>
144
+ <button class="btn btn-sm review-inline-cancel" type="button">Cancel</button>
145
+ </div>
146
+ `;
147
+
148
+ block.after(form);
149
+ form.querySelector(".review-inline-textarea").focus();
150
+ form.querySelector(".review-inline-cancel").addEventListener("click", () => form.remove());
151
+
152
+ form.querySelector(".review-inline-submit").addEventListener("click", async () => {
153
+ const body = form.querySelector(".review-inline-textarea").value.trim();
154
+ if (!body) return;
155
+ const threadId = document.querySelector("[data-thread-id]")?.dataset.threadId;
156
+ if (!threadId) return;
157
+ try {
158
+ const res = await fetch(`/review/${encodeURIComponent(threadId)}/comments`, {
159
+ method: "POST",
160
+ headers: { "Content-Type": "application/json" },
161
+ body: JSON.stringify({
162
+ messageId, anchorLine: Number(lineStart),
163
+ anchorEndLine: lineEnd ? Number(lineEnd) : null, anchorText, body,
164
+ }),
165
+ });
166
+ if (res.ok) location.reload();
167
+ else alert((await res.json()).error || "Failed to add comment");
168
+ } catch (err) {
169
+ alert("Network error: " + err.message);
170
+ }
171
+ });
172
+ }
173
+
174
+ // --- Inline comments: gutter buttons (desktop) + tap-to-comment (mobile) ---
175
+ function initInlineComments() {
176
+ const isMobile = window.matchMedia("(max-width: 560px)").matches;
177
+ const commentMap = buildCommentMap();
178
+ const agentMessages = document.querySelectorAll(".review-msg-agent .review-msg-content");
179
+
180
+ for (const msgEl of agentMessages) {
181
+ const messageId = msgEl.dataset.messageId;
182
+ if (!messageId) continue;
183
+
184
+ const blocks = msgEl.querySelectorAll("[data-source-line-start]");
185
+ for (const block of blocks) {
186
+ block.style.position = "relative";
187
+ const blockComments = findCommentsForBlock(commentMap, messageId, block);
188
+ const hasComments = blockComments.length > 0;
189
+
190
+ // Add comment count badge on the right if block has comments
191
+ if (hasComments) {
192
+ const badge = document.createElement("span");
193
+ badge.className = "review-comment-badge";
194
+ badge.textContent = blockComments.length;
195
+ badge.title = `${blockComments.length} comment${blockComments.length > 1 ? "s" : ""}`;
196
+ block.appendChild(badge);
197
+ }
198
+
199
+ const handleClick = (e) => {
200
+ if (e.target.closest("a, button, .review-inline-form, .code-ref")) return;
201
+ e.stopPropagation();
202
+ if (hasComments) {
203
+ openCommentThread(block, messageId, blockComments);
204
+ } else {
205
+ openCommentForm(block, messageId);
206
+ }
207
+ };
208
+
209
+ if (isMobile) {
210
+ block.addEventListener("click", handleClick);
211
+ } else {
212
+ // Desktop: gutter "+" button (or comment count acts as button too)
213
+ if (!hasComments) {
214
+ const btn = document.createElement("button");
215
+ btn.className = "review-gutter-btn";
216
+ btn.type = "button";
217
+ btn.textContent = "+";
218
+ btn.title = "Add inline comment";
219
+ btn.addEventListener("click", (e) => {
220
+ e.stopPropagation();
221
+ openCommentForm(block, messageId);
222
+ });
223
+ block.appendChild(btn);
224
+ } else {
225
+ // The badge is clickable on desktop too
226
+ block.querySelector(".review-comment-badge").addEventListener("click", (e) => {
227
+ e.stopPropagation();
228
+ openCommentThread(block, messageId, blockComments);
229
+ });
230
+ // Also show gutter "+" for adding new comment on this block
231
+ const btn = document.createElement("button");
232
+ btn.className = "review-gutter-btn";
233
+ btn.type = "button";
234
+ btn.textContent = "+";
235
+ btn.title = "Add inline comment";
236
+ btn.addEventListener("click", (e) => {
237
+ e.stopPropagation();
238
+ openCommentForm(block, messageId);
239
+ });
240
+ block.appendChild(btn);
241
+ }
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ // --- Resolve / Delete comment buttons ---
248
+ function initCommentActions() {
249
+ const threadId = document.querySelector("[data-thread-id]")?.dataset.threadId;
250
+ if (!threadId) return;
251
+
252
+ document.addEventListener("click", async (e) => {
253
+ const resolveBtn = e.target.closest(".review-resolve-btn");
254
+ if (resolveBtn) {
255
+ const commentId = resolveBtn.dataset.commentId;
256
+ try {
257
+ const res = await fetch(`/review/${encodeURIComponent(threadId)}/comments/${encodeURIComponent(commentId)}`, {
258
+ method: "PATCH",
259
+ headers: { "Content-Type": "application/json" },
260
+ body: JSON.stringify({ resolved: true }),
261
+ });
262
+ if (res.ok) location.reload();
263
+ } catch { /* ignore */ }
264
+ return;
265
+ }
266
+
267
+ const deleteBtn = e.target.closest(".review-delete-comment-btn");
268
+ if (deleteBtn) {
269
+ const commentId = deleteBtn.dataset.commentId;
270
+ if (!confirm("Delete this comment?")) return;
271
+ try {
272
+ const res = await fetch(`/review/${encodeURIComponent(threadId)}/comments/${encodeURIComponent(commentId)}`, {
273
+ method: "DELETE",
274
+ });
275
+ if (res.ok) location.reload();
276
+ } catch { /* ignore */ }
277
+ }
278
+ });
279
+ }
280
+
281
+ // ─── Code reference detection & popup ───────────────────────────────
282
+
283
+ // Match: path/to/file.ext:line or path/to/file.ext:line-endline
284
+ // Must contain a "/" or start with known src-like prefix, and have a real extension
285
+ const CODE_REF_RE = /(?:^|[\s(>`])(([\w./-]+\/[\w./-]+\.[\w]+|[\w.-]+\.(?:js|ts|tsx|jsx|py|rb|go|rs|java|c|cpp|h|hpp|cs|swift|kt|vue|svelte|sh|sql|proto))(?::(\d+)(?:-(\d+))?)?)(?=[\s)<,.:;`]|$)/g;
286
+
287
+ const KNOWN_EXTENSIONS = new Set([
288
+ "js","ts","tsx","jsx","mjs","cjs","py","rb","go","rs","java","c","cpp","h","hpp",
289
+ "cs","swift","kt","vue","svelte","sh","bash","sql","graphql","proto","yaml","yml",
290
+ "toml","json","css","scss","less","html","xml","md","txt","conf","cfg","ini","lock",
291
+ ]);
292
+
293
+ function looksLikeFilePath(str) {
294
+ const ext = str.split(".").pop()?.toLowerCase();
295
+ return ext && KNOWN_EXTENSIONS.has(ext);
296
+ }
297
+
298
+ // Matches a full file reference: path/file.ext or path/file.ext:line or path/file.ext:line-end
299
+ const INLINE_CODE_REF_RE = /^([\w./-]+\/[\w./-]+\.[\w]+|[\w.-]+\.(?:js|ts|tsx|jsx|py|rb|go|rs|java|c|cpp|h|hpp|cs|swift|kt|vue|svelte|sh|sql|proto))(?::(\d+)(?:-(\d+))?)?$/;
300
+
301
+ function makeCodeRefLink(file, line, endLine, displayText) {
302
+ const link = document.createElement("a");
303
+ link.className = "code-ref";
304
+ link.href = `/blob/${encodeURI(file)}${line ? "#L" + line : ""}`;
305
+ link.dataset.file = file;
306
+ link.dataset.line = line || "1";
307
+ if (endLine) link.dataset.endLine = String(endLine);
308
+ link.textContent = displayText;
309
+ link.title = `View ${displayText}`;
310
+ return link;
311
+ }
312
+
313
+ function initCodeRefs() {
314
+ const containers = document.querySelectorAll(".review-msg-content");
315
+ for (const container of containers) {
316
+ linkifyInlineCode(container);
317
+ linkifyTextNodes(container);
318
+ }
319
+
320
+ // Delegate clicks on .code-ref links
321
+ document.addEventListener("click", (e) => {
322
+ const ref = e.target.closest(".code-ref");
323
+ if (!ref) return;
324
+ e.preventDefault();
325
+ e.stopPropagation();
326
+ showCodePopup(ref.dataset.file, Number(ref.dataset.line) || 1, Number(ref.dataset.endLine) || 0);
327
+ });
328
+ }
329
+
330
+ // Turn inline <code>src/foo.ts:45</code> into clickable links
331
+ function linkifyInlineCode(container) {
332
+ // Only match <code> elements that are NOT inside <pre> (i.e. inline code, not code blocks)
333
+ const codeElements = [...container.querySelectorAll("code")].filter(
334
+ (el) => !el.closest("pre") && !el.closest("a"),
335
+ );
336
+
337
+ for (const code of codeElements) {
338
+ const text = code.textContent.trim();
339
+ const m = text.match(INLINE_CODE_REF_RE);
340
+ if (!m) continue;
341
+ const filePath = m[1];
342
+ if (!looksLikeFilePath(filePath)) continue;
343
+
344
+ const line = m[2] ? parseInt(m[2], 10) : null;
345
+ const endLine = m[3] ? parseInt(m[3], 10) : null;
346
+ const link = makeCodeRefLink(filePath, line, endLine, text);
347
+ code.replaceWith(link);
348
+ }
349
+ }
350
+
351
+ // Also linkify bare text references (not inside code/pre/a tags)
352
+ function linkifyTextNodes(container) {
353
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
354
+ acceptNode(node) {
355
+ const parent = node.parentElement;
356
+ if (!parent) return NodeFilter.FILTER_REJECT;
357
+ const tag = parent.tagName;
358
+ if (tag === "CODE" || tag === "PRE" || tag === "A" || tag === "TEXTAREA" || tag === "INPUT") {
359
+ return NodeFilter.FILTER_REJECT;
360
+ }
361
+ return NodeFilter.FILTER_ACCEPT;
362
+ },
363
+ });
364
+
365
+ const textNodes = [];
366
+ while (walker.nextNode()) textNodes.push(walker.currentNode);
367
+
368
+ for (const textNode of textNodes) {
369
+ const text = textNode.textContent;
370
+ CODE_REF_RE.lastIndex = 0;
371
+
372
+ const matches = [];
373
+ let m;
374
+ while ((m = CODE_REF_RE.exec(text)) !== null) {
375
+ const fullMatch = m[1];
376
+ const filePath = m[2];
377
+ if (!looksLikeFilePath(filePath)) continue;
378
+ const offset = m.index + m[0].indexOf(fullMatch);
379
+ matches.push({
380
+ start: offset,
381
+ end: offset + fullMatch.length,
382
+ file: filePath,
383
+ line: m[3] ? parseInt(m[3], 10) : null,
384
+ endLine: m[4] ? parseInt(m[4], 10) : null,
385
+ text: fullMatch,
386
+ });
387
+ }
388
+
389
+ if (!matches.length) continue;
390
+
391
+ const frag = document.createDocumentFragment();
392
+ let cursor = 0;
393
+ for (const match of matches) {
394
+ if (match.start > cursor) {
395
+ frag.appendChild(document.createTextNode(text.slice(cursor, match.start)));
396
+ }
397
+ frag.appendChild(makeCodeRefLink(match.file, match.line, match.endLine, match.text));
398
+ cursor = match.end;
399
+ }
400
+ if (cursor < text.length) {
401
+ frag.appendChild(document.createTextNode(text.slice(cursor)));
402
+ }
403
+ textNode.parentNode.replaceChild(frag, textNode);
404
+ }
405
+ }
406
+
407
+ // ─── Code popup ─────────────────────────────────────────────────────
408
+
409
+ let activePopup = null;
410
+
411
+ function escapeHtml(s) {
412
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
413
+ }
414
+
415
+ async function showCodePopup(file, line, endLine) {
416
+ if (activePopup) { activePopup.remove(); activePopup = null; }
417
+
418
+ const end = endLine || line;
419
+ const overlay = document.createElement("div");
420
+ overlay.className = "code-popup-overlay";
421
+ overlay.innerHTML = `
422
+ <div class="code-popup">
423
+ <div class="code-popup-header">
424
+ <a class="code-popup-filepath" href="/blob/${encodeURI(file)}${line ? "#L" + line : ""}" title="Open in file viewer">${escapeHtml(file)}${line ? ":" + line + (endLine && endLine !== line ? "-" + endLine : "") : ""}</a>
425
+ <span class="spacer"></span>
426
+ <button class="btn btn-sm code-popup-tab active" data-mode="source">Source</button>
427
+ <button class="btn btn-sm code-popup-tab" data-mode="diff">Diff</button>
428
+ <button class="code-popup-close" type="button" aria-label="Close">&times;</button>
429
+ </div>
430
+ <div class="code-popup-body">
431
+ <div class="code-popup-loading">Loading...</div>
432
+ </div>
433
+ </div>
434
+ `;
435
+
436
+ document.body.appendChild(overlay);
437
+ activePopup = overlay;
438
+
439
+ // Close handlers
440
+ overlay.querySelector(".code-popup-close").addEventListener("click", closePopup);
441
+ overlay.addEventListener("click", (e) => {
442
+ if (e.target === overlay) closePopup();
443
+ });
444
+
445
+ // Fetch code context
446
+ // Show more context when no specific line, less when targeting a line
447
+ const ctx = line <= 1 && !endLine ? "100" : "20";
448
+ const params = new URLSearchParams({ file, line: String(line), context: ctx });
449
+ if (endLine) params.set("endLine", String(endLine));
450
+
451
+ let data;
452
+ try {
453
+ const res = await fetch(`/api/code-context?${params}`);
454
+ if (!res.ok) {
455
+ const err = await res.json();
456
+ showPopupError(overlay, err.error || "Failed to load file");
457
+ return;
458
+ }
459
+ data = await res.json();
460
+ } catch (e) {
461
+ showPopupError(overlay, "Network error: " + e.message);
462
+ return;
463
+ }
464
+
465
+ // Render source view
466
+ renderSourceView(overlay, data);
467
+
468
+ // Tab switching
469
+ const tabs = overlay.querySelectorAll(".code-popup-tab");
470
+ for (const tab of tabs) {
471
+ tab.addEventListener("click", () => {
472
+ for (const t of tabs) t.classList.remove("active");
473
+ tab.classList.add("active");
474
+ if (tab.dataset.mode === "diff") {
475
+ renderDiffView(overlay, data);
476
+ } else {
477
+ renderSourceView(overlay, data);
478
+ }
479
+ });
480
+ }
481
+ }
482
+
483
+ function renderSourceView(overlay, data) {
484
+ const body = overlay.querySelector(".code-popup-body");
485
+ const lines = data.lines.map((lineText, i) => {
486
+ const lineNum = data.startLine + i;
487
+ const isHighlight = lineNum >= data.highlightStart && lineNum <= data.highlightEnd;
488
+ const cls = isHighlight ? " highlight" : "";
489
+ return `<tr class="code-line${cls}" id="L${lineNum}"><td class="code-ln">${lineNum}</td><td class="code-content">${escapeHtml(lineText)}</td></tr>`;
490
+ }).join("");
491
+
492
+ body.innerHTML = `<table class="code-table"><tbody>${lines}</tbody></table>`;
493
+
494
+ // Scroll highlighted line into view
495
+ const target = body.querySelector(".code-line.highlight");
496
+ if (target) {
497
+ requestAnimationFrame(() => target.scrollIntoView({ block: "center" }));
498
+ }
499
+
500
+ // Try to apply highlight.js if available
501
+ tryHighlight(body, data.language);
502
+ }
503
+
504
+ function renderDiffView(overlay, data) {
505
+ const body = overlay.querySelector(".code-popup-body");
506
+ if (!data.diff) {
507
+ body.innerHTML = `<div class="code-popup-empty">No uncommitted changes in this file.</div>`;
508
+ return;
509
+ }
510
+
511
+ // Parse unified diff into lines with +/- markers
512
+ const diffLines = data.diff.split("\n");
513
+ const rows = [];
514
+ let inHunk = false;
515
+ let oldLn = 0, newLn = 0;
516
+
517
+ for (const raw of diffLines) {
518
+ if (raw.startsWith("@@")) {
519
+ inHunk = true;
520
+ const m = raw.match(/@@ -(\d+)(?:,\d+)? \+(\d+)/);
521
+ if (m) { oldLn = parseInt(m[1], 10); newLn = parseInt(m[2], 10); }
522
+ rows.push(`<tr class="diff-hunk"><td colspan="3">${escapeHtml(raw)}</td></tr>`);
523
+ continue;
524
+ }
525
+ if (!inHunk) continue;
526
+
527
+ if (raw.startsWith("+")) {
528
+ const inRange = newLn >= data.highlightStart && newLn <= data.highlightEnd;
529
+ rows.push(`<tr class="diff-add${inRange ? " highlight" : ""}"><td class="code-ln">${newLn}</td><td class="diff-marker">+</td><td class="code-content">${escapeHtml(raw.slice(1))}</td></tr>`);
530
+ newLn++;
531
+ } else if (raw.startsWith("-")) {
532
+ rows.push(`<tr class="diff-del"><td class="code-ln">${oldLn}</td><td class="diff-marker">-</td><td class="code-content">${escapeHtml(raw.slice(1))}</td></tr>`);
533
+ oldLn++;
534
+ } else if (raw.startsWith(" ")) {
535
+ const inRange = newLn >= data.highlightStart && newLn <= data.highlightEnd;
536
+ rows.push(`<tr class="diff-ctx${inRange ? " highlight" : ""}"><td class="code-ln">${newLn}</td><td class="diff-marker"> </td><td class="code-content">${escapeHtml(raw.slice(1))}</td></tr>`);
537
+ oldLn++; newLn++;
538
+ }
539
+ }
540
+
541
+ body.innerHTML = `<table class="code-table">${rows.join("")}</table>`;
542
+
543
+ const target = body.querySelector(".highlight");
544
+ if (target) {
545
+ requestAnimationFrame(() => target.scrollIntoView({ block: "center" }));
546
+ }
547
+ }
548
+
549
+ function tryHighlight(body, lang) {
550
+ // Use highlight.js if loaded on the page
551
+ if (!window.hljs) return;
552
+ const cells = body.querySelectorAll(".code-content");
553
+ for (const cell of cells) {
554
+ const text = cell.textContent;
555
+ try {
556
+ const result = lang && window.hljs.getLanguage(lang)
557
+ ? window.hljs.highlight(text, { language: lang })
558
+ : window.hljs.highlightAuto(text);
559
+ cell.innerHTML = result.value;
560
+ } catch { /* ignore */ }
561
+ }
562
+ }
563
+
564
+ function showPopupError(overlay, message) {
565
+ overlay.querySelector(".code-popup-body").innerHTML =
566
+ `<div class="code-popup-empty">${escapeHtml(message)}</div>`;
567
+ }
568
+
569
+ function closePopup() {
570
+ if (activePopup) { activePopup.remove(); activePopup = null; }
571
+ }
572
+
573
+ // Close popup on Escape
574
+ document.addEventListener("keydown", (e) => {
575
+ if (e.key === "Escape") closePopup();
576
+ });
577
+
578
+ // --- Initialize ---
579
+ window.addEventListener("load", () => {
580
+ initInlineComments();
581
+ initCommentActions();
582
+ initCodeRefs();
583
+ });
584
+ })();