thunderbird-patch-review
Simple email patch review tool for Thunderbird
git clone git://mccd.space/thunderbird-patch-review| Log | Files | Refs | README | LICENSE | Mail | Artifacts |
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 });