repoview 0.5.0 → 0.6.0

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