thunderbird-patch-review

Simple email patch review tool for Thunderbird

git clone git://mccd.space/thunderbird-patch-review
commit 2df389571ea748c01f4a29c01c1e8febef1ad13c
parent 5f606fe8d258da706b0db17c98f76ad036d242d2
Author: Pi Agent <agent@pi.local>
Date:   Sun, 19 Jul 2026 10:37:08 +0200

review: probe apply state and gray out Apply on applied/conflict

When a repository is selected (and after a successful apply) ask the
patchHost to classify the series against HEAD:

  applied   -> green checkmark, button reads '✓ Applied' and is disabled
  conflict  -> red, button reads 'Apply ✗' and is disabled; tooltip
               carries the git apply --check output
  applicable-> normal Apply

The pre-check runs in the repo-display refresh path that already fires
on Browse pick and on init, so the button reflects the current tree
without a separate signal. Download is left enabled in every state so
the series can still be exported when it won't apply.

Diffstat:
Mextension/background.js | 11+++++++++++
Mextension/review/review.css | 36++++++++++++++++++++++++++++++++++++
Mextension/review/review.js | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 139 insertions(+), 0 deletions(-)
diff --git a/extension/background.js b/extension/background.js
@@ -214,6 +214,17 @@ async function handle(request) {
       return host().apply(request.repo, config.strategy || "am3", patches);
     }
 
+    // Forwarded to patchHost.check(): classifies the series as already
+    // applied, applicable, or conflicting against the repository's HEAD.
+    case "check-series": {
+      const config = await getConfig();
+      const patches = [];
+      for (const messageId of request.messageIds) {
+        patches.push(await rawMessage(messageId));
+      }
+      return host().check(request.repo, config.strategy || "am3", patches);
+    }
+
     case "open-editor": {
       const config = await getConfig();
       return host().openEditor(request.repo, config.editor || "", request.files || []);
diff --git a/extension/review/review.css b/extension/review/review.css
@@ -157,6 +157,42 @@ button.primary:disabled:hover { opacity: 0.5; }
 button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
 button.primary:hover { color: #fff; opacity: 0.9; }
 
+/* Apply-button states from the pre-check (patchHost.check): the button
+   turns green with a checkmark when the series is already in HEAD, red
+   when it won't apply, disabled in both. State-checking is the transient
+   "querying git" phase. The menu-toggle plants itself in the same split,
+   so it shares the deafened look. */
+#btn-apply.state-checking { cursor: progress; opacity: 0.85; }
+
+#btn-apply.state-applied,
+#btn-apply.state-applied:hover {
+  background: #dcfce7;
+  border-color: #1a7f37;
+  color: #1a7f37;
+}
+
+#btn-apply.state-conflict,
+#btn-apply.state-conflict:hover {
+  background: #ffebe9;
+  border-color: #cf222e;
+  color: #cf222e;
+}
+
+@media (prefers-color-scheme: dark) {
+  #btn-apply.state-applied,
+  #btn-apply.state-applied:hover {
+    background: #0d2818;
+    border-color: #2ea043;
+    color: #3fb950;
+  }
+  #btn-apply.state-conflict,
+  #btn-apply.state-conflict:hover {
+    background: #2d1216;
+    border-color: #f85149;
+    color: #ff7b72;
+  }
+}
+
 .split {
   position: relative;
   display: flex;
diff --git a/extension/review/review.js b/extension/review/review.js
@@ -14,6 +14,7 @@ const state = {
   comments: [], // locator -> comment, per series entry
   current: 0,
   repo: "",
+  applyStatus: null, // "applicable"|"applied"|"conflict"|null (no repo / unknown)
 };
 
 async function bg(request) {
@@ -599,6 +600,75 @@ function onSendConfirmBackdrop(e) {
 document.addEventListener("keydown", (e) => {
   if (e.key === "Escape" && !$("#send-confirm").hidden) closeSendConfirm();
 });
+
+// ---------------------------------------------------------------------------
+// Apply-status probe
+
+// When a repository is selected (and after a successful apply) classify
+// the series against HEAD: "applied" (already in HEAD, green check, apply
+// disabled), "conflict" (won't apply, red, apply disabled), or
+// "applicable" (normal Apply). The pre-check is advisory; the actual Apply
+// runs git am regardless, but the state gates the buttons.
+async function refreshApplyState() {
+  const apply = $("#btn-apply");
+  const modifyBtn = document.querySelector(
+    "#apply-split .menu button[data-mode='modify']"
+  );
+  // Leave the menu-toggle and the Download dropdown item alone: a series
+  // that's already applied or that conflicts still deserves to be exported.
+  const resetButton = () => {
+    apply.classList.remove("state-checking", "state-applied", "state-conflict");
+    apply.disabled = false;
+    apply.textContent = "Apply";
+    apply.title = "Apply the patches to the repository (git am)";
+    if (modifyBtn) modifyBtn.disabled = false;
+  };
+
+  const messageIds = state.series
+    .filter((p) => !p.isCover && p.hasDiff)
+    .map((p) => p.id);
+  if (!state.repo || !messageIds.length) {
+    state.applyStatus = null;
+    resetButton();
+    return;
+  }
+
+  // Disable while probing so a fast double-click can't fire Apply at the
+  // stale state we're about to overwrite.
+  resetButton();
+  apply.disabled = true;
+  apply.classList.add("state-checking");
+  apply.textContent = "checking…";
+  apply.title = "Checking whether the series applies…";
+
+  const response = await bg({ type: "check-series", messageIds, repo: state.repo });
+  if (!response.ok) {
+    // Prefer not to block Apply over a flaky pre-check; the actual Apply
+    // will report the real error.
+    state.applyStatus = null;
+    resetButton();
+    showStatus(response.error || "could not check the apply state", { error: true });
+    return;
+  }
+  state.applyStatus = response.status || null;
+  resetButton();
+  if (state.applyStatus === "applied") {
+    apply.classList.add("state-applied");
+    apply.disabled = true;
+    apply.textContent = "✓ Applied";
+    apply.title = "This series is already applied to the repository.";
+    if (modifyBtn) modifyBtn.disabled = true;
+  } else if (state.applyStatus === "conflict") {
+    apply.classList.add("state-conflict");
+    apply.disabled = true;
+    apply.textContent = "Apply ✗";
+    apply.title =
+      "This series does not apply cleanly to the repository.\n\n" +
+      (response.output || "(no detail)");
+    if (modifyBtn) modifyBtn.disabled = true;
+  }
+}
+
 $("#btn-apply").addEventListener("click", () => applySeries("apply"));
 
 document.addEventListener("click", (e) => {
@@ -614,6 +684,20 @@ async function applySeries(mode) {
     return;
   }
 
+  // Download bypasses the repo entirely; the other modes apply the
+  // patches, so honor a negative pre-check by refusing up front. (The
+  // buttons involved are already disabled when this state is set, but a
+  // stray keyboard shortcut or a future caller could still reach us.)
+  if (mode !== "download" && (state.applyStatus === "applied" || state.applyStatus === "conflict")) {
+    showStatus(
+      state.applyStatus === "applied"
+        ? "This series is already applied to the repository."
+        : "This series does not apply cleanly to the repository.",
+      { error: true }
+    );
+    return;
+  }
+
   if (mode === "download") {
     const response = await bg({ type: "download-series", messageIds, filename: state.slug });
     if (!response.ok) {
@@ -657,6 +741,11 @@ async function applySeries(mode) {
     .join("\n\n");
   showStatus(text, { error: !response.ok, editor: response.ok ? repo : null });
 
+  // Re-evaluate the apply status once the series is in: a successful
+  // apply flips it to "applied" (and grays the button back out).
+  if (response.ok) {
+    await refreshApplyState();
+  }
   if (response.ok && mode === "modify") {
     await openEditor(repo);
   }
@@ -685,6 +774,7 @@ async function refreshRepoDisplay(path) {
     state.repo = "";
     chip.classList.remove("error");
     chip.textContent = "Choose a repository…";
+    await refreshApplyState();
     return;
   }
   chip.classList.remove("error");
@@ -694,12 +784,14 @@ async function refreshRepoDisplay(path) {
     state.repo = "";
     chip.classList.add("error");
     chip.textContent = response.error || "not a git repository";
+    await refreshApplyState();
     return;
   }
   state.repo = response.repo || path;
   state.repoBranch = response.branch || null;
   chip.classList.remove("error");
   renderRepoChip(response.name, response.branch);
+  await refreshApplyState();
 }
 
 // The chip reads <name> · <branch> with the branch in a muted color.