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/package.json +7 -2
- package/public/app.css +778 -0
- package/public/app.js +70 -0
- package/public/review.js +584 -0
- package/src/cli.js +18 -0
- package/src/markdown.js +157 -3
- package/src/review-cli.js +245 -0
- package/src/server.js +366 -0
- package/src/views.js +178 -0
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
|
});
|
package/public/review.js
ADDED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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">×</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
|
+
})();
|