repoview 0.5.0 → 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
@@ -315,6 +315,36 @@ function initBaseSelector() {
315
315
  });
316
316
  }
317
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
+
318
348
  window.addEventListener("load", () => {
319
349
  preserveQueryParamsOnInternalLinks(["ignored", "watch", "base", "show_all"]);
320
350
  renderMath();
@@ -322,4 +352,5 @@ window.addEventListener("load", () => {
322
352
  initTimezoneToggle();
323
353
  initBaseSelector();
324
354
  initDiffCollapse();
355
+ initLineHighlight();
325
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
+ })();
package/src/cli.js CHANGED
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { startServer } from "./server.js";
6
+ import { handleReviewCommand } from "./review-cli.js";
6
7
 
7
8
  function printHelp() {
8
9
  // Keep this in sync with README.md
@@ -23,6 +24,13 @@ Options:
23
24
  --no-watch Disable live reload
24
25
  -h, --help Show this help
25
26
 
27
+ Review subcommands:
28
+ repoview review new --title "Title" Create a new review thread
29
+ repoview review post <id> --role agent --body "…" Post a message to a thread
30
+ repoview review post <id> --role agent --file f Post from file
31
+ repoview review read <id> Read thread messages + comments
32
+ repoview review list List all threads
33
+
26
34
  Environment:
27
35
  REPO_ROOT, HOST, PORT
28
36
  `);
@@ -55,6 +63,16 @@ if (help) {
55
63
  process.exit(0);
56
64
  }
57
65
 
66
+ // Handle "review" subcommand
67
+ if (parsed.rest[0] === "review") {
68
+ const repoRootForReview =
69
+ repo ??
70
+ process.env.REPO_ROOT ??
71
+ process.cwd();
72
+ await handleReviewCommand(parsed.rest.slice(1), repoRootForReview);
73
+ process.exit(0);
74
+ }
75
+
58
76
  if (port != null && !Number.isFinite(port)) {
59
77
  process.stderr.write("Invalid --port value\n");
60
78
  process.exit(2);