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
| Log | Files | Refs | README | LICENSE |
commit 0a3e906c666337b0a51a6b16f1391c300e41a127 parent b56adf51c3e0a5e448f59bcbfbf6b04509439982 Author: Pi Agent <agent@pi.local> Date: Sat, 18 Jul 2026 12:49:18 +0200 feat: move Review to the toolbar button, support thread selection, add Download patchset Switch from message_display_action (per-message header) to browser_action (main toolbar) so multi-message and thread selections are covered. The Review button lights up on the current mail-tab selection (with a fallback to the displayed message in standalone message tabs). Add an Apply-series drop-down with 'Modify and apply' (apply then open the touched files in the editor) and 'Download patchset' (save the raw series as an mbox), implemented via a generic .split button component and a new download-series background handler using the downloads permission. Diffstat:
| M | README | | | 32 | +++++++++++++++++++++----------- |
| M | extension/background.js | | | 85 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------ |
| M | extension/manifest.json | | | 5 | +++-- |
| M | extension/review/review.css | | | 14 | +++++++------- |
| M | extension/review/review.html | | | 15 | +++++++++++---- |
| M | extension/review/review.js | | | 76 | ++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------- |
| M | patch-review.1 | | | 22 | ++++++++++++++++++---- |
7 files changed, 182 insertions(+), 67 deletions(-)
diff --git a/README b/README
@@ -10,13 +10,13 @@ SYNOPSIS
DESCRIPTION
patch-review brings a review interface to git-by-email workflows
- (git-send-email(1) style). When a displayed message looks like a patch
- -- a [PATCH] subject tag or a unified diff in the body -- the Review
- button in the message header becomes active. It opens a dedicated
- review tab that shows the whole series: cover letter, per-patch commit
- messages, and every file and hunk rendered as a colored diff. Within
- replaced lines, the span that actually changed gets a stronger
- highlight, computed from the common prefix and suffix of each
+ (git-send-email(1) style). When the selected messages (or thread) look
+ like a patch -- a [PATCH] subject tag or a unified diff in the body --
+ the Review button in the main toolbar becomes active. It opens a
+ dedicated review tab that shows the whole series: cover letter, per-
+ patch commit messages, and every file and hunk rendered as a colored
+ diff. Within replaced lines, the span that actually changed gets a
+ stronger highlight, computed from the common prefix and suffix of each
deleted/added line pair.
Clicking a diff line attaches an inline comment to it; comments are
@@ -29,9 +29,11 @@ DESCRIPTION
Preview in compose instead, which leaves the reply drafts open in
compose windows. A reply that fails to send also stays open, so
nothing is lost. Apply series hands the raw patch messages to the
- native host, which runs git am --3way in the configured repository and
- can then launch your editor. On conflict the host runs git am --abort
- and reports the output, leaving the repository untouched.
+ native host, which runs git am --3way in the configured repository. On
+ conflict the host runs git am --abort and reports the output, leaving
+ the repository untouched. Its drop-down offers Modify and apply --
+ apply, then open the touched files in your editor -- and Download
+ patchset -- save the raw series as one mbox file, ready for git am.
INSTALLATION
1. Native host
@@ -87,7 +89,8 @@ SOURCEHUT INTEGRATION
the cover letter (or first patch) carrying just the header.
REVIEW WORKFLOW
- 1. Open any message of the series; press Review.
+ 1. Select any message of the series -- or the whole
+ thread -- and press Review in the main toolbar.
2. The tab collects the sibling patches from the same folder
(same series tag, version, and patch count).
3. Click lines, write comments; Ctrl+Enter saves.
@@ -121,6 +124,13 @@ CAVEATS
git worktree preview are not supported yet. Series collection searches
the folder of the opened message only.
+CONTRIBUTING
+ Disclaimer: this plugin was vibe coded -- written by an AI agent under
+ human direction.
+
+ Contributions and bug reports go to the mailing list:
+ <~marcc/thunderbird-review-plugin@lists.sr.ht>
+
AUTHOR
Marc Coquand <marc@coquand.email>
diff --git a/extension/background.js b/extension/background.js
@@ -28,28 +28,62 @@ async function getConfig() {
}
// ---------------------------------------------------------------------------
-// Detection: enable the "Review" button only on patch messages.
+// Detection: the single toolbar ("Review") button lights up when the current
+// selection — or the displayed message, in standalone message tabs — looks
+// like a patch. (The message-header button was dropped: the multi-message
+// summary view has no header toolbar, so it could never cover threads.)
-messenger.messageDisplay.onMessageDisplayed.addListener(async (tab, message) => {
+messenger.browserAction.disable();
+
+async function updateAction(tabId, messages) {
try {
const { isPatchMessage, parseSubject, getPlainBody } = await mods();
- let patch = Boolean(parseSubject(message.subject));
- if (!patch) {
- // Subject gave no hint; look for a diff in the body.
- patch = isPatchMessage(message.subject, await getPlainBody(message.id));
+ let patch = messages.some((message) => Boolean(parseSubject(message.subject)));
+ if (!patch && messages.length === 1) {
+ // Subject gave no hint; look for a diff in the body (single message
+ // only — fetching every body of a large selection would be wasteful).
+ patch = isPatchMessage(messages[0].subject, await getPlainBody(messages[0].id));
}
- await messenger.messageDisplayAction[patch ? "enable" : "disable"](tab.id);
+ await messenger.browserAction[patch ? "enable" : "disable"](tabId);
} catch (e) {
console.warn("patch-review: detection failed", e);
}
-});
+}
+
+messenger.mailTabs.onSelectedMessagesChanged.addListener((tab, selected) =>
+ updateAction(tab.id, (selected && selected.messages) || [])
+);
+
+// Covers messages opened in their own tab or window, where no mail-tab
+// selection exists.
+messenger.messageDisplay.onMessageDisplayed.addListener((tab, message) =>
+ updateAction(tab.id, [message])
+);
-messenger.messageDisplayAction.onClicked.addListener(async (tab) => {
- const message = await messenger.messageDisplay.getDisplayedMessage(tab.id);
+async function openReview(messageId) {
+ await messenger.tabs.create({
+ url: messenger.runtime.getURL(`review/review.html?mid=${messageId}`),
+ });
+}
+
+messenger.browserAction.onClicked.addListener(async (tab) => {
+ let list = [];
+ try {
+ const selected = await messenger.mailTabs.getSelectedMessages(tab.id);
+ list = (selected && selected.messages) || [];
+ } catch (e) {
+ // Not a mail tab; fall through to the displayed message.
+ }
+ if (!list.length) {
+ const displayed = await messenger.messageDisplay.getDisplayedMessage(tab.id);
+ if (displayed) {
+ list = [displayed];
+ }
+ }
+ const { parseSubject } = await mods();
+ const message = list.find((header) => parseSubject(header.subject)) || list[0];
if (message) {
- await messenger.tabs.create({
- url: messenger.runtime.getURL(`review/review.html?mid=${message.id}`),
- });
+ await openReview(message.id);
}
});
@@ -181,6 +215,31 @@ async function handle(request) {
});
}
+ case "download-series": {
+ const parts = [];
+ for (const messageId of request.messageIds) {
+ const raw = await rawMessage(messageId);
+ parts.push(raw.endsWith("\n") ? raw : raw + "\n");
+ }
+ const url = URL.createObjectURL(new Blob(parts, { type: "application/mbox" }));
+ try {
+ await messenger.downloads.download({
+ url,
+ filename: `${request.filename || "patchset"}.mbox`,
+ saveAs: true,
+ });
+ } catch (e) {
+ if (/cancel/i.test(e.message || "")) {
+ return { ok: true, cancelled: true };
+ }
+ throw e;
+ } finally {
+ // Give the download time to read the blob before releasing it.
+ setTimeout(() => URL.revokeObjectURL(url), 120000);
+ }
+ return { ok: true };
+ }
+
case "pick-repo":
// Native directory chooser; the host reuses the repo field as start dir.
return m.callHost("pickdir", { repo: request.start || "" });
diff --git a/extension/manifest.json b/extension/manifest.json
@@ -18,8 +18,8 @@
"48": "icons/review.svg",
"96": "icons/review.svg"
},
- "message_display_action": {
- "default_title": "Review patch",
+ "browser_action": {
+ "default_title": "Review selected patches",
"default_label": "Review",
"default_icon": "icons/review.svg"
},
@@ -33,6 +33,7 @@
"storage",
"compose",
"compose.send",
+ "downloads",
"tabs",
"nativeMessaging"
]
diff --git a/extension/review/review.css b/extension/review/review.css
@@ -112,24 +112,24 @@ button:hover { border-color: var(--accent); color: var(--accent); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover { color: #fff; opacity: 0.9; }
-#send-split {
+.split {
position: relative;
display: flex;
}
-#send-split #btn-send {
+.split > button:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
-#send-split #btn-send-menu {
+.split > .menu-toggle {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
- border-left: 1px solid rgba(255, 255, 255, 0.35);
+ border-left-color: rgba(255, 255, 255, 0.35);
padding: 0.3rem 0.45rem;
}
-#send-menu {
+.split .menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
@@ -144,7 +144,7 @@ button.primary:hover { color: #fff; opacity: 0.9; }
overflow: hidden;
}
-#send-menu button {
+.split .menu button {
border: 0;
border-radius: 0;
background: none;
@@ -152,7 +152,7 @@ button.primary:hover { color: #fff; opacity: 0.9; }
padding: 0.45rem 0.8rem;
}
-#send-menu button:hover {
+.split .menu button:hover {
background: var(--hunk-bg);
color: var(--fg);
}
diff --git a/extension/review/review.html b/extension/review/review.html
@@ -12,17 +12,24 @@
<span id="comment-count" title="Draft comments">0 comments</span>
<input id="repo-path" type="text" placeholder="repository path" spellcheck="false">
<button id="btn-browse" title="Choose the repository directory">Browse…</button>
- <button id="btn-apply" title="git am the series into the repository and open your editor">Apply series</button>
+ <div class="split" id="apply-split">
+ <button id="btn-apply" class="primary" title="git am the series into the repository">Apply series</button>
+ <button class="menu-toggle primary" title="Apply options">▾</button>
+ <div class="menu" hidden>
+ <button data-mode="modify">Modify and apply</button>
+ <button data-mode="download">Download patchset…</button>
+ </div>
+ </div>
<label id="srht-status" hidden
title="Sourcehut list detected: add an X-Sourcehut-Patchset-Update header to the review">
<input id="srht-enable" type="checkbox">
set status
<select id="srht-select"></select>
</label>
- <div id="send-split">
+ <div class="split" id="send-split">
<button id="btn-send" class="primary" title="Send one reply per commented patch">Send review</button>
- <button id="btn-send-menu" class="primary" title="Send options">▾</button>
- <div id="send-menu" hidden>
+ <button class="menu-toggle primary" title="Send options">▾</button>
+ <div class="menu" hidden>
<button data-mode="send">Send now</button>
<button data-mode="preview">Preview in compose…</button>
</div>
diff --git a/extension/review/review.js b/extension/review/review.js
@@ -49,6 +49,9 @@ async function init() {
$("#series-title").textContent = anchor.title + m;
document.title = `Review: ${anchor.title}`;
$("#repo-path").value = state.repo;
+ state.slug =
+ anchor.title.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) ||
+ "patchset";
if (response.isSourcehut) {
const select = $("#srht-select");
@@ -376,31 +379,53 @@ async function sendReview(mode) {
}
}
-function closeSendMenu() {
- $("#send-menu").hidden = true;
+function closeMenus() {
+ document.querySelectorAll(".split .menu").forEach((menu) => (menu.hidden = true));
}
+function setupSplit(splitId, onPick) {
+ const split = document.getElementById(splitId);
+ const menu = split.querySelector(".menu");
+ split.querySelector(".menu-toggle").addEventListener("click", (e) => {
+ e.stopPropagation();
+ const wasHidden = menu.hidden;
+ closeMenus();
+ menu.hidden = !wasHidden;
+ });
+ menu.querySelectorAll("button").forEach((button) => {
+ button.addEventListener("click", () => {
+ closeMenus();
+ onPick(button.dataset.mode);
+ });
+ });
+}
+
+setupSplit("send-split", sendReview);
+setupSplit("apply-split", applySeries);
$("#btn-send").addEventListener("click", () => sendReview("send"));
+$("#btn-apply").addEventListener("click", () => applySeries("apply"));
-$("#btn-send-menu").addEventListener("click", (e) => {
- e.stopPropagation();
- $("#send-menu").hidden = !$("#send-menu").hidden;
+document.addEventListener("click", (e) => {
+ if (!e.target.closest(".split")) {
+ closeMenus();
+ }
});
-document.querySelectorAll("#send-menu button").forEach((button) => {
- button.addEventListener("click", () => {
- closeSendMenu();
- sendReview(button.dataset.mode);
- });
-});
+async function applySeries(mode) {
+ const messageIds = state.series.filter((p) => !p.isCover && p.hasDiff).map((p) => p.id);
+ if (!messageIds.length) {
+ showStatus("Nothing here: no patch in this series contains a diff.", { error: true });
+ return;
+ }
-document.addEventListener("click", (e) => {
- if (!e.target.closest("#send-split")) {
- closeSendMenu();
+ if (mode === "download") {
+ const response = await bg({ type: "download-series", messageIds, filename: state.slug });
+ if (!response.ok) {
+ showStatus(response.error, { error: true });
+ }
+ return;
}
-});
-$("#btn-apply").addEventListener("click", async () => {
const repo = $("#repo-path").value.trim();
if (!repo) {
showStatus("Enter the path of the repository to apply the series to.", { error: true });
@@ -408,19 +433,17 @@ $("#btn-apply").addEventListener("click", async () => {
return;
}
- const messageIds = state.series.filter((p) => !p.isCover && p.hasDiff).map((p) => p.id);
- if (!messageIds.length) {
- showStatus("Nothing to apply: no patch in this series contains a diff.", { error: true });
- return;
- }
-
showStatus("Applying series…");
const response = await bg({ type: "apply-series", messageIds, repo });
const text = [response.ok ? "Series applied." : "Apply failed.", response.output || response.error]
.filter(Boolean)
.join("\n\n");
showStatus(text, { error: !response.ok, editor: response.ok ? repo : null });
-});
+
+ if (response.ok && mode === "modify") {
+ await openEditor(repo);
+ }
+}
$("#btn-browse").addEventListener("click", async () => {
const response = await bg({ type: "pick-repo", start: $("#repo-path").value.trim() });
@@ -434,8 +457,7 @@ $("#btn-browse").addEventListener("click", async () => {
}
});
-$("#btn-open-editor").addEventListener("click", async () => {
- const repo = $("#repo-path").value.trim();
+async function openEditor(repo) {
const files = [...new Set(
state.parsed.flatMap((p) => p.files.map((f) => f.displayPath)).filter(Boolean)
)];
@@ -445,7 +467,9 @@ $("#btn-open-editor").addEventListener("click", async () => {
} else {
hideStatus();
}
-});
+}
+
+$("#btn-open-editor").addEventListener("click", () => openEditor($("#repo-path").value.trim()));
// ---------------------------------------------------------------------------
// Status panel
diff --git a/patch-review.1 b/patch-review.1
@@ -13,11 +13,11 @@ series to a local repository with
brings a review interface to git-by-email workflows
.RB ( git\-send\-email (1)
style).
-When a displayed message looks like a patch \(em a
+When the selected messages (or thread) look like a patch \(em a
.B [PATCH]
subject tag or a unified diff in the body \(em the
.B Review
-button in the message header becomes active.
+button in the main toolbar becomes active.
It opens a dedicated review tab that shows the whole series:
cover letter, per-patch commit messages, and every file and hunk rendered
as a colored diff.
@@ -39,10 +39,16 @@ A reply that fails to send also stays open, so nothing is lost.
.B Apply series
hands the raw patch messages to the native host, which runs
.B git am \-\-3way
-in the configured repository and can then launch your editor.
+in the configured repository.
On conflict the host runs
.B git am \-\-abort
and reports the output, leaving the repository untouched.
+Its drop-down offers
+.I "Modify and apply"
+\(em apply, then open the touched files in your editor \(em and
+.I "Download patchset"
+\(em save the raw series as one mbox file, ready for
+.BR "git am" .
.SH INSTALLATION
.TP
1. Native host
@@ -121,7 +127,8 @@ then sends a single, empty reply to the cover letter (or first patch)
carrying just the header.
.SH REVIEW WORKFLOW
.nf
-1. Open any message of the series; press Review.
+1. Select any message of the series \(em or the whole
+ thread \(em and press Review in the main toolbar.
2. The tab collects the sibling patches from the same folder
(same series tag, version, and patch count).
3. Click lines, write comments; Ctrl+Enter saves.
@@ -163,6 +170,13 @@ Regenerate fixtures with
Patches arriving as attachments, HTML-only mail, and reviewing from a git
worktree preview are not supported yet.
Series collection searches the folder of the opened message only.
+.SH CONTRIBUTING
+Disclaimer: this plugin was vibe coded \(em written by an AI agent under
+human direction.
+.PP
+Contributions and bug reports go to the mailing list:
+.MT ~marcc/thunderbird\-review\-plugin@lists.sr.ht
+.ME
.SH AUTHOR
Marc Coquand
.MT marc@coquand.email