thunderbird-patch-review
Simple email patch review tool for Thunderbird
git clone git://mccd.space/thunderbird-patch-reviewcommit 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:
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.