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

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 });