thunderbird-patch-review

Simple email patch review tool for Thunderbird

git clone git://mccd.space/thunderbird-patch-review

review.js (31673B)

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