thunderbird-patch-review
Easy-to-use patch review interface for Thunderbird
Contribute: ~marcc/thunderbird-review-plugin@lists.sr.ht
git clone git://mccd.space/thunderbird-patch-review
| Log | Files | Refs | README | LICENSE |
review.js (25360B)
1 // Review tab: renders a patch series with inline, hunk-level commenting.
2
3 import { parsePatchEmail } from "../modules/diff-parse.js";
4 import { computeIntraline } from "../modules/intraline.js";
5 import { formatReview } from "../modules/reply-format.js";
6 import * as store from "../modules/review-store.js";
7 import { SOURCEHUT_STATUSES } from "../modules/sourcehut.js";
8
9 const $ = (sel) => document.querySelector(sel);
10
11 const state = {
12 series: [], // from background: get-series
13 parsed: [], // parsePatchEmail per series entry
14 comments: [], // locator -> comment, per series entry
15 current: 0,
16 repo: "",
17 };
18
19 async function bg(request) {
20 const response = await messenger.runtime.sendMessage(request);
21 if (!response) {
22 throw new Error("no response from background script");
23 }
24 return response;
25 }
26
27 // ---------------------------------------------------------------------------
28 // Boot
29
30 async function init() {
31 const messageId = Number(new URLSearchParams(location.search).get("mid"));
32 const response = await bg({ type: "get-series", messageId });
33 if (!response.ok) {
34 showStatus(response.error || "unknown error", { error: true });
35 return;
36 }
37
38 state.series = response.series;
39 state.repo = response.repo || "";
40 state.parsed = state.series.map((p) => parsePatchEmail(p.body));
41 state.comments = await Promise.all(
42 state.series.map((p) => store.loadComments(p.headerMessageId))
43 );
44 state.current = Math.max(0, state.series.findIndex((p) => !p.isCover));
45
46 const anchor = state.series.find((p) => !p.isCover) || state.series[0];
47 document.title = `Review: ${anchor.title}`;
48 state.navEl = $("#series-nav");
49 renderNav();
50 renderSeriesTitle();
51 refreshRepoDisplay(state.repo);
52 state.slug =
53 anchor.title.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) ||
54 "patchset";
55
56 state.isSourcehut = Boolean(response.isSourcehut);
57 if (state.isSourcehut) {
58 const select = $("#srht-select");
59 for (const status of SOURCEHUT_STATUSES) {
60 const option = document.createElement("option");
61 option.value = status;
62 option.textContent = status;
63 select.appendChild(option);
64 }
65 $("#srht-status").hidden = false;
66 }
67
68 renderPatch();
69 updateCount();
70 }
71
72 // ---------------------------------------------------------------------------
73 // Rendering
74
75 // The top-left title summarises the whole series under review: the patch
76 // count and the total draft comments across it. Updated on init, on patch
77 // switch, and whenever comments change.
78 function renderSeriesTitle() {
79 if (!state.series.length) {
80 $("#series-title").textContent = "Review";
81 return;
82 }
83 const n = state.series.length;
84 const total = state.comments.reduce((sum, c) => sum + Object.keys(c).length, 0);
85 $("#series-title").textContent =
86 `Review Patchset (${n}); ${total} comment${total === 1 ? "" : "s"}`;
87 }
88
89 function renderNav() {
90 const nav = state.navEl;
91 if (!nav) return;
92 nav.textContent = "";
93
94 state.series.forEach((patch, i) => {
95 const pill = document.createElement("span");
96 pill.className = "pill" + (i === state.current ? " active" : "");
97 pill.textContent = patch.isCover
98 ? "cover"
99 : patch.n !== null && patch.m
100 ? `${patch.n}/${patch.m}`
101 : "patch";
102 const count = Object.keys(state.comments[i]).length;
103 if (count) {
104 const badge = document.createElement("span");
105 badge.className = "badge";
106 badge.textContent = count;
107 pill.appendChild(badge);
108 }
109 pill.title = patch.subject;
110 pill.addEventListener("click", () => {
111 state.current = i;
112 renderNav();
113 renderSeriesTitle();
114 renderPatch();
115 });
116 nav.appendChild(pill);
117 });
118 }
119
120 function renderPatch() {
121 const content = $("#patch");
122 content.textContent = "";
123 const i = state.current;
124 const patch = state.series[i];
125 const parsed = state.parsed[i];
126
127 // The patch box holds the commit message (and per-patch comment + file
128 // diffs); the patch selector itself lives in the top bar (#series-nav).
129 const box = document.createElement("div");
130 box.className = "patch-box";
131
132 // Commit message (or cover letter text) with a whole-patch comment. Never
133 // fall back to the raw body for a patch — that would print the whole diff
134 // above the interactive one (common case: single-line commit messages,
135 // whose body starts directly at the "---" scissors).
136 const msg = document.createElement("div");
137 msg.className = "commit-message";
138
139 // Per-patch metadata header: who sent it and the subject line, mirroring
140 // the top of a git-format-patch / mail message. The commit message body
141 // follows below.
142 const headers = document.createElement("dl");
143 headers.className = "patch-headers";
144 headerRow("Subject", patch.subject || "", headers);
145 msg.appendChild(headers);
146
147 const body = document.createElement("div");
148 body.className = "commit-message-body";
149 body.textContent = patch.isCover
150 ? parsed.commitMessage || patch.body
151 : parsed.commitMessage || patch.title;
152 msg.appendChild(body);
153 const existingGeneral = state.comments[i][store.GENERAL];
154 // Only offer "Comment on patch" when there is no draft yet; once a comment
155 // exists the readonly card (double-click / click to edit) is the only
156 // entry point, so we don't also surface an "Edit comment" button.
157 if (!existingGeneral) {
158 const generalBtn = document.createElement("button");
159 generalBtn.className = "btn-general";
160 generalBtn.textContent = "Add Comment";
161 generalBtn.addEventListener("click", () => openGeneralEditor(msg, generalBtn));
162 msg.appendChild(generalBtn);
163 }
164 box.appendChild(msg);
165
166 if (existingGeneral) {
167 box.appendChild(commentCard(existingGeneral.text, () => openGeneralEditor(msg, null)));
168 }
169 content.appendChild(box);
170
171 parsed.files.forEach((file, fileIndex) => {
172 const details = document.createElement("details");
173 details.className = "file";
174 details.open = true;
175
176 const summary = document.createElement("summary");
177 summary.textContent = fileLabel(file);
178 details.appendChild(summary);
179
180 if (file.isBinary) {
181 details.appendChild(binaryBlock(file, parsed));
182 } else {
183 details.appendChild(renderFileTable(file, fileIndex));
184 }
185 content.appendChild(details);
186 });
187
188 if (!parsed.files.length && !patch.isCover) {
189 const note = document.createElement("div");
190 note.className = "file-note";
191 note.textContent = "No diff found in this message.";
192 content.appendChild(note);
193 }
194 }
195
196 function headerRow(name, value, into) {
197 const dt = document.createElement("dt");
198 dt.textContent = `${name}:`;
199 const dd = document.createElement("dd");
200 // Email-ish / path-ish values render better in a monospace face.
201 const code = document.createElement("code");
202 code.textContent = value || "\u00a0";
203 dd.appendChild(code);
204 into.append(dt, dd);
205 }
206
207 function fileLabel(file) {
208 if (file.isRename) return `${file.oldPath} → ${file.newPath}`;
209 if (file.isNew) return `${file.displayPath} (new file)`;
210 if (file.isDeleted) return `${file.oldPath} (deleted)`;
211 return file.displayPath;
212 }
213
214 function binaryBlock(file, parsed) {
215 const wrap = document.createElement("div");
216 wrap.className = "binary-note";
217
218 const note = document.createElement("div");
219 note.className = "file-note";
220 note.textContent =
221 "Binary file changed — cannot review line-by-line. The patch carries the encoded blob, not reviewable content.";
222 wrap.appendChild(note);
223
224 const show = document.createElement("button");
225 show.textContent = "Display raw patch";
226 show.addEventListener("click", () => {
227 if (wrap.querySelector("pre")) {
228 wrap.querySelector("pre").remove();
229 show.textContent = "Display raw patch";
230 return;
231 }
232 const pre = document.createElement("pre");
233 pre.className = "binary-raw";
234 pre.textContent = rawFilePatch(file, parsed);
235 show.before(pre);
236 show.textContent = "Hide raw patch";
237 });
238 wrap.appendChild(show);
239 return wrap;
240 }
241
242 function rawFilePatch(file, parsed) {
243 const lines = parsed.bodyLines || [];
244 const start = file.bodyLine ?? 0;
245 if (!lines.length) return "";
246 let end = lines.length;
247 for (let i = start + 1; i < lines.length; i++) {
248 if (/^diff --git /.test(lines[i])) { end = i; break; }
249 }
250 // Trim the trailing git signature if present.
251 for (let i = end - 1; i > start; i--) {
252 if (/^-- ?$/.test(lines[i])) { end = i; break; }
253 }
254 return lines.slice(start, end).join("\n").trimEnd();
255 }
256
257 function renderFileTable(file, fileIndex) {
258 const table = document.createElement("table");
259 table.className = "diff";
260 const tbody = document.createElement("tbody");
261 table.appendChild(tbody);
262
263 file.hunks.forEach((hunk, hunkIndex) => {
264 const headerRow = document.createElement("tr");
265 headerRow.className = "hunk-header";
266 const headerCell = document.createElement("td");
267 headerCell.colSpan = 3;
268 headerCell.textContent = hunk.header;
269 headerRow.appendChild(headerCell);
270 tbody.appendChild(headerRow);
271
272 const intraline = computeIntraline(hunk.lines);
273
274 hunk.lines.forEach((line, lineIndex) => {
275 const loc = store.locator(fileIndex, hunkIndex, lineIndex);
276 const row = document.createElement("tr");
277 row.className = `line ${line.origin}`;
278 row.dataset.loc = loc;
279
280 const oldGutter = document.createElement("td");
281 oldGutter.className = "gutter";
282 oldGutter.textContent = line.oldLine ?? "";
283 const newGutter = document.createElement("td");
284 newGutter.className = "gutter";
285 newGutter.textContent = line.newLine ?? "";
286 row.append(oldGutter, newGutter, codeCell(line, intraline[lineIndex]));
287 row.addEventListener("click", () => toggleEditor(row, loc));
288 tbody.appendChild(row);
289
290 const existing = state.comments[state.current][loc];
291 if (existing) {
292 row.classList.add("commented");
293 tbody.appendChild(commentRow(existing.text, row, loc));
294 }
295 });
296 });
297
298 return table;
299 }
300
301 /** Code cell, with the intra-line changed span wrapped for a stronger tint. */
302 function codeCell(line, range) {
303 const td = document.createElement("td");
304 td.className = "code";
305 if (!range) {
306 td.textContent = line.raw || " ";
307 return td;
308 }
309 // range addresses line.text; raw carries the +/- prefix, hence the offset.
310 const start = range.start + 1;
311 const end = range.end + 1;
312 td.append(line.raw.slice(0, start));
313 const hl = document.createElement("span");
314 hl.className = "hl";
315 hl.textContent = line.raw.slice(start, end);
316 td.append(hl, line.raw.slice(end));
317 return td;
318 }
319
320 function commentCard(text, onEdit) {
321 const box = document.createElement("div");
322 box.className = "comment-box readonly";
323 const body = document.createElement("div");
324 body.className = "comment-text";
325 body.textContent = text;
326 box.appendChild(body);
327 box.addEventListener("dblclick", onEdit);
328 box.title = "Double-click to edit";
329 return box;
330 }
331
332 function commentRow(text, lineRow, loc) {
333 const tr = document.createElement("tr");
334 tr.className = "comment-row readonly";
335 const td = document.createElement("td");
336 td.colSpan = 3;
337 td.appendChild(commentCard(text, () => toggleEditor(lineRow, loc)));
338 tr.appendChild(td);
339 return tr;
340 }
341
342 // ---------------------------------------------------------------------------
343 // Comment editing
344
345 function closeEditors() {
346 document.querySelectorAll(".editor-row, .general-editor").forEach((el) => el.remove());
347 document.querySelectorAll(".readonly[hidden]").forEach((el) => {
348 el.hidden = false;
349 });
350 }
351
352 function toggleEditor(lineRow, loc) {
353 const open = lineRow.nextElementSibling;
354 if (open && open.classList.contains("editor-row")) {
355 closeEditors();
356 return;
357 }
358 closeEditors();
359
360 const existing = state.comments[state.current][loc];
361 const ro = findReadonly(lineRow);
362 if (ro) ro.hidden = true;
363
364 const tr = document.createElement("tr");
365 tr.className = "comment-row editor-row";
366 const td = document.createElement("td");
367 td.colSpan = 3;
368 td.appendChild(
369 editorBox(existing ? existing.text : "", loc, () => {
370 tr.remove();
371 if (ro) ro.hidden = false;
372 })
373 );
374 tr.appendChild(td);
375 lineRow.after(tr);
376 tr.querySelector("textarea").focus();
377 }
378
379 // A line's readonly comment row, if any, is always the element immediately
380 // after it (renderFileTable appends the comment row right after the line
381 // row). Only that one belongs to this line — walking further would grab some
382 // later line's comment and hide the wrong card.
383 function findReadonly(lineRow) {
384 const next = lineRow.nextElementSibling;
385 return next && next.classList.contains("readonly") ? next : null;
386 }
387
388 function openGeneralEditor(anchorEl, triggerBtn) {
389 closeEditors();
390 const existing = state.comments[state.current][store.GENERAL];
391 // The readonly general comment, if any, is the element right after the
392 // commit-message block. Keep a reference back from the anchor so we hide
393 // exactly that one and not some unrelated .readonly down the page.
394 const ro = anchorEl.nextElementSibling?.classList?.contains("readonly")
395 ? anchorEl.nextElementSibling
396 : null;
397 if (ro) ro.hidden = true;
398 // While the editor is open the trigger button ("Comment on patch") is
399 // hidden; cancel re-shows it. Post re-renders, which drops it anyway when
400 // a comment now exists.
401 if (triggerBtn) triggerBtn.hidden = true;
402 const restoreTrigger = () => {
403 if (triggerBtn) triggerBtn.hidden = false;
404 };
405 const box = editorBox(existing ? existing.text : "", store.GENERAL, () => {
406 box.remove();
407 if (ro) ro.hidden = false;
408 restoreTrigger();
409 });
410 box.classList.add("general-editor");
411 anchorEl.after(box);
412 box.querySelector("textarea").focus();
413 }
414
415 function editorBox(initial, loc, close) {
416 const box = document.createElement("div");
417 box.className = "comment-box";
418
419 const textarea = document.createElement("textarea");
420 textarea.value = initial;
421 textarea.placeholder = "Write a review comment… (Ctrl+Enter to save)";
422 box.appendChild(textarea);
423
424 const actions = document.createElement("div");
425 actions.className = "comment-actions";
426
427 const save = async () => {
428 const i = state.current;
429 state.comments[i] = await store.saveComment(
430 state.series[i].headerMessageId,
431 loc,
432 textarea.value
433 );
434 close();
435 renderPatch();
436 updateCount();
437 };
438
439 if (initial) {
440 const del = document.createElement("button");
441 del.textContent = "Delete";
442 del.addEventListener("click", async () => {
443 textarea.value = "";
444 await save();
445 });
446 actions.appendChild(del);
447 }
448
449 const cancel = document.createElement("button");
450 cancel.textContent = "Cancel";
451 cancel.addEventListener("click", close);
452 actions.appendChild(cancel);
453
454 const saveBtn = document.createElement("button");
455 saveBtn.className = "primary";
456 saveBtn.textContent = "Save";
457 saveBtn.addEventListener("click", save);
458 actions.appendChild(saveBtn);
459
460 textarea.addEventListener("keydown", (e) => {
461 if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
462 save();
463 } else if (e.key === "Escape") {
464 close();
465 }
466 });
467
468 box.appendChild(actions);
469 return box;
470 }
471
472 function updateCount() {
473 const total = state.comments.reduce((sum, c) => sum + Object.keys(c).length, 0);
474 // The discard button only makes sense once there are drafts to lose.
475 $("#btn-discard").hidden = total === 0;
476 // Send review needs something to send: a draft comment, or — for a
477 // sourcehut series — at least a patchset-status update (which can go
478 // out as a stand-alone reply). Non-sourcehut with no comments is
479 // disabled so the modal cannot be opened with nothing to do.
480 const canSend = total > 0 || state.isSourcehut;
481 $("#btn-send").disabled = !canSend;
482 $("#send-split").querySelector(".menu-toggle").disabled = !canSend;
483 // The title is independent of the button-pluralization below; update it
484 // first so a failure in the latter cannot leave the count stale.
485 renderSeriesTitle();
486 }
487
488 $("#btn-discard").addEventListener("click", async () => {
489 closeMenus();
490 const total = state.comments.reduce((sum, c) => sum + Object.keys(c).length, 0);
491 if (total === 0) return;
492 const fill = total === 1 ? `the 1 draft comment` : `all ${total} draft comments`;
493 if (!window.confirm(`Discard ${fill} in this series? This cannot be undone.`)) {
494 return;
495 }
496 await Promise.all(state.series.map((p) => store.clearComments(p.headerMessageId)));
497 state.comments = state.series.map(() => ({}));
498 renderPatch();
499 updateCount();
500 });
501
502 // ---------------------------------------------------------------------------
503 // Send review / apply series
504
505 async function sendReview(mode) {
506 const status =
507 !$("#srht-status").hidden && $("#srht-enable").checked ? $("#srht-select").value : null;
508
509 const replies = [];
510 state.series.forEach((patch, i) => {
511 const body = formatReview(state.parsed[i], state.comments[i]);
512 if (body) {
513 replies.push({ messageId: patch.id, body });
514 }
515 });
516 if (!replies.length) {
517 if (!status) {
518 showStatus("No comments yet — click a diff line to add one.");
519 return;
520 }
521 // Status-only review (e.g. APPROVED): reply to the cover letter when
522 // there is one (the series is ordered cover first), body left to you.
523 replies.push({ messageId: state.series[0].id, body: "" });
524 }
525
526 const response = await bg({ type: "send-review", mode, replies, status });
527 if (!response.ok) {
528 showStatus(response.error, { error: true });
529 return;
530 }
531 if (mode === "send") {
532 // The review went out: drop the now-sent draft comments and close up.
533 await Promise.all(state.series.map((p) => store.clearComments(p.headerMessageId)));
534 const tab = await messenger.tabs.getCurrent();
535 await messenger.tabs.remove(tab.id);
536 }
537 }
538
539 function closeMenus() {
540 document.querySelectorAll(".split .menu").forEach((menu) => (menu.hidden = true));
541 }
542
543 function setupSplit(splitId, onPick) {
544 const split = document.getElementById(splitId);
545 const menu = split.querySelector(".menu");
546 split.querySelector(".menu-toggle").addEventListener("click", (e) => {
547 e.stopPropagation();
548 const wasHidden = menu.hidden;
549 closeMenus();
550 menu.hidden = !wasHidden;
551 });
552 menu.querySelectorAll("button").forEach((button) => {
553 if (button.dataset.bypass) return;
554 button.addEventListener("click", () => {
555 closeMenus();
556 onPick(button.dataset.mode);
557 });
558 });
559 }
560
561 setupSplit("send-split", (mode) => openSendConfirm(mode));
562 setupSplit("apply-split", applySeries);
563 $("#btn-send").addEventListener("click", () => openSendConfirm("send"));
564
565 $("#btn-send-cancel").addEventListener("click", closeSendConfirm);
566 $("#btn-send-confirm").addEventListener("click", async () => {
567 const mode = state.pendingSendMode;
568 closeSendConfirm();
569 await sendReview(mode);
570 });
571
572 function openSendConfirm(mode) {
573 closeMenus();
574 state.pendingSendMode = mode;
575 $("#send-confirm").addEventListener("click", onSendConfirmBackdrop);
576 const n = state.series.length;
577 const commented = state.series.filter((_, i) =>
578 Object.keys(state.comments[i]).length
579 ).length;
580 const verbSent = mode === "preview" ? "open in compose" : "send review";
581 $("#send-confirm-title").textContent = mode === "preview" ? "Preview review" : "Send review";
582 $("#btn-send-confirm").textContent = mode === "preview" ? "Open in compose" : "Send review";
583 $("#send-confirm-summary").textContent =
584 `${commented} of ${n} patch${n === 1 ? "" : "es"} has a comment; ` +
585 `click ${verbSent} to continue.`;
586 $("#send-confirm").hidden = false;
587 }
588
589 function closeSendConfirm() {
590 $("#send-confirm").hidden = true;
591 state.pendingSendMode = null;
592 $("#send-confirm").removeEventListener("click", onSendConfirmBackdrop);
593 }
594
595 function onSendConfirmBackdrop(e) {
596 if (e.target === $("#send-confirm")) closeSendConfirm();
597 }
598
599 document.addEventListener("keydown", (e) => {
600 if (e.key === "Escape" && !$("#send-confirm").hidden) closeSendConfirm();
601 });
602 $("#btn-apply").addEventListener("click", () => applySeries("apply"));
603
604 document.addEventListener("click", (e) => {
605 if (!e.target.closest(".split")) {
606 closeMenus();
607 }
608 });
609
610 async function applySeries(mode) {
611 const messageIds = state.series.filter((p) => !p.isCover && p.hasDiff).map((p) => p.id);
612 if (!messageIds.length) {
613 showStatus("Nothing here: no patch in this series contains a diff.", { error: true });
614 return;
615 }
616
617 if (mode === "download") {
618 const response = await bg({ type: "download-series", messageIds, filename: state.slug });
619 if (!response.ok) {
620 showStatus(response.error, { error: true });
621 }
622 return;
623 }
624
625 const repo = state.repo;
626 if (!repo || $("#repo-path").classList.contains("error")) {
627 showStatus("Choose a git repository", { error: true });
628 return;
629 }
630
631 showStatus("Applying series…");
632 const fresh = await bg({ type: "describe-repo", repo });
633 if (fresh.ok && state.repoBranch && fresh.branch && fresh.branch !== state.repoBranch) {
634 // The branch moved under us (another checkout, a rebase, etc.) between
635 // the Browse pick and the Apply click. Show the new branch and ask the
636 // user to confirm by re-applying, rather than silently writing onto
637 // an unexpected branch.
638 state.repoBranch = fresh.branch;
639 renderRepoChip(fresh.name, fresh.branch);
640 showStatus(
641 `The repository's branch changed to "${fresh.branch}" since you chose it; ` +
642 "click Apply again to confirm.",
643 { error: true }
644 );
645 return;
646 }
647 if (!fresh.ok) {
648 // The repo disappeared between the Browse and the Apply.
649 refreshRepoDisplay(repo);
650 showStatus(fresh.error || "The repository is no longer readable.", { error: true });
651 return;
652 }
653
654 const response = await bg({ type: "apply-series", messageIds, repo });
655 const text = [response.ok ? "Series applied." : "Apply failed.", response.output || response.error]
656 .filter(Boolean)
657 .join("\n\n");
658 showStatus(text, { error: !response.ok, editor: response.ok ? repo : null });
659
660 if (response.ok && mode === "modify") {
661 await openEditor(repo);
662 }
663 }
664
665 $("#repo-path").addEventListener("click", async () => {
666 const response = await bg({ type: "pick-repo", start: state.repo || "" });
667 if (!response.ok) {
668 showStatus(
669 response.error || "No directory chooser available — pick the path manually.",
670 { error: true }
671 );
672 return;
673 }
674 if (response.cancelled) {
675 return;
676 }
677 await refreshRepoDisplay(response.output);
678 });
679
680 // Resolve the chosen path against git and render the toolbar pill. A
681 // non-repo path turns the chip red and shows the error, and blocks Apply.
682 async function refreshRepoDisplay(path) {
683 const chip = $("#repo-path");
684 if (!path) {
685 state.repo = "";
686 chip.classList.remove("error");
687 chip.textContent = "Choose a repository…";
688 return;
689 }
690 chip.classList.remove("error");
691 chip.textContent = "resolving…";
692 const response = await bg({ type: "describe-repo", repo: path });
693 if (!response.ok) {
694 state.repo = "";
695 chip.classList.add("error");
696 chip.textContent = response.error || "not a git repository";
697 return;
698 }
699 state.repo = response.repo || path;
700 state.repoBranch = response.branch || null;
701 chip.classList.remove("error");
702 renderRepoChip(response.name, response.branch);
703 }
704
705 // The chip reads <name> · <branch> with the branch in a muted color.
706 function renderRepoChip(name, branch) {
707 const chip = $("#repo-path");
708 chip.textContent = "";
709 chip.appendChild(document.createTextNode(name));
710 const sep = document.createElement("span");
711 sep.className = "repo-sep";
712 sep.textContent = " · ";
713 chip.appendChild(sep);
714 const br = document.createElement("span");
715 br.className = "repo-branch";
716 br.textContent = branch;
717 chip.appendChild(br);
718 }
719
720 async function openEditor(repo) {
721 const files = [...new Set(
722 state.parsed.flatMap((p) => p.files.map((f) => f.displayPath)).filter(Boolean)
723 )];
724 const response = await bg({ type: "open-editor", repo, files });
725 if (!response.ok) {
726 showStatus(response.error || response.output || "Could not open the editor.", { error: true });
727 } else {
728 hideStatus();
729 }
730 }
731
732 $("#btn-open-editor").addEventListener("click", () => openEditor(state.repo));
733
734 // ---------------------------------------------------------------------------
735 // Status panel
736
737 let statusTimer = null;
738
739 function showStatus(text, { error = false, editor = null } = {}) {
740 if (statusTimer) {
741 clearTimeout(statusTimer);
742 statusTimer = null;
743 }
744 $("#status-text").textContent = text;
745 $("#status-text").classList.toggle("error", error);
746 $("#btn-open-editor").hidden = !editor;
747 $("#status").hidden = false;
748 // Auto-dismiss non-error notices after a few seconds; errors stay until closed.
749 if (!error) {
750 statusTimer = setTimeout(hideStatus, 5000);
751 }
752 }
753
754 function hideStatus() {
755 if (statusTimer) {
756 clearTimeout(statusTimer);
757 statusTimer = null;
758 }
759 $("#status").hidden = true;
760 }
761
762 $("#btn-status-close").addEventListener("click", hideStatus);
763
764 init().catch((e) => {
765 document.title = "Review: error";
766 const titleEl = $("#series-title");
767 if (titleEl) titleEl.textContent = "Error";
768 if (state.navEl) state.navEl.textContent = "";
769 showStatus(e.message || String(e), { error: true });
770 });