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 a56cd849f169464a1a47182decefb2841ecf54e4
parent b74938caa712b4665d2f3479481c0c0ff3e7f079
Author: Pi Agent <agent@pi.local>
Date: Sat, 18 Jul 2026 22:05:40 +0200
review: display the chosen repo's name and branch instead of a path
Replace the editable repository path field with a read-only chip that
shows the repository's name and current branch (e.g. "thunderbird-review-ui
· main"). Browse is now the only way to pick a repo, and the chip turns red
with the git error if the chosen directory is not a git worktree; Apply
refuses while the chip is in the error state.
Backed by a new patchHost.describe(repo) experiment function that resolves
a path to {ok, repo, name, branch}, surfaced through a describe-repo
background message; the previous requireRepo check is folded into it.
Diffstat:
| M | extension/api/patchHost/implementation.js | | | 24 | +++++++++++++++++++++--- |
| M | extension/api/patchHost/schema.json | | | 9 | +++++++++ |
| M | extension/background.js | | | 5 | +++++ |
| M | extension/review/review.css | | | 14 | +++++++++++++- |
| M | extension/review/review.html | | | 2 | +- |
| M | extension/review/review.js | | | 53 | +++++++++++++++++++++++++++++++++++++++-------------- |
| M | tests/run.mjs | | | 2 | +- |
7 files changed, 89 insertions(+), 20 deletions(-)
diff --git a/extension/api/patchHost/implementation.js b/extension/api/patchHost/implementation.js
@@ -56,7 +56,10 @@ async function findGit() {
}
}
-async function requireRepo(git, repo) {
+// Verify repo is a git worktree and return a short description for the
+// toolbar. The name is the top-level dir basename (what the user picked);
+// the branch is the checked-out branch or detached short SHA.
+async function describeRepo(git, repo) {
if (!repo) {
throw new Error("no repository path given");
}
@@ -64,6 +67,15 @@ async function requireRepo(git, repo) {
if (check.exitCode !== 0) {
throw new Error(`not a git repository: ${repo}`);
}
+ const showToplevel = await run(git, ["-C", repo, "rev-parse", "--show-toplevel"]);
+ const toplevel = showToplevel.output.trim();
+ const name = toplevel ? toplevel.replace(/[\\/]+$/, "").split(/[\\/]/).pop() : repo;
+ let branch = await run(git, ["-C", repo, "symbolic-ref", "--short", "HEAD"]);
+ if (branch.exitCode !== 0) {
+ // Detached HEAD: fall back to the short commit hash.
+ branch = await run(git, ["-C", repo, "rev-parse", "--short", "HEAD"]);
+ }
+ return { ok: true, repo: toplevel || repo, name, branch: branch.output.trim() };
}
async function ping() {
@@ -72,9 +84,14 @@ async function ping() {
return { ok: true, git, version: version.output.trim() };
}
+async function describe(repo) {
+ const git = await findGit();
+ return describeRepo(git, repo);
+}
+
async function apply(repo, strategy, patches) {
const git = await findGit();
- await requireRepo(git, repo);
+ await describeRepo(git, repo);
if (!patches.length) {
throw new Error("no patches in request");
}
@@ -144,7 +161,7 @@ async function pickDir(start) {
async function openEditor(repo, editor, files) {
const git = await findGit();
- await requireRepo(git, repo);
+ await describeRepo(git, repo);
const env = Subprocess.getEnvironment();
let command = (editor || "").trim() || (env.EDITOR || "").trim();
if (!command) {
@@ -189,6 +206,7 @@ var patchHost = class extends ExtensionAPI {
return {
patchHost: {
ping: () => guarded(ping),
+ describe: (repo) => guarded(() => describe(repo)),
apply: (repo, strategy, patches) => guarded(() => apply(repo, strategy, patches)),
pickDir: (start) => guarded(() => pickDir(start)),
openEditor: (repo, editor, files) => guarded(() => openEditor(repo, editor, files)),
diff --git a/extension/api/patchHost/schema.json b/extension/api/patchHost/schema.json
@@ -11,6 +11,15 @@
"parameters": []
},
{
+ "name": "describe",
+ "type": "function",
+ "async": true,
+ "description": "Resolve a path to its repository name and current branch; resolves to {ok, repo, name, branch} or {ok:false, error}.",
+ "parameters": [
+ { "name": "repo", "type": "string" }
+ ]
+ },
+ {
"name": "apply",
"type": "function",
"async": true,
diff --git a/extension/background.js b/extension/background.js
@@ -248,6 +248,11 @@ async function handle(request) {
// Native directory chooser; the repo field doubles as its start dir.
return host().pickDir(request.start || "");
+ case "describe-repo":
+ // Resolve a path to its repository name + current branch (or an error
+ // for not-a-repo). Used by the toolbar to display the target repo.
+ return host().describe(request.repo || "");
+
case "ping-host":
return host().ping();
diff --git a/extension/review/review.css b/extension/review/review.css
@@ -73,12 +73,24 @@ body {
#repo-path {
width: 22rem;
max-width: 40vw;
- padding: 0.25rem 0.5rem;
+ padding: 0.3rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--fg);
font-family: ui-monospace, monospace;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ user-select: none;
+}
+
+#repo-path.error {
+ border-color: #cf222e;
+ color: #cf222e;
+}
+@media (prefers-color-scheme: dark) {
+ #repo-path.error { border-color: #f85149; color: #f85149; }
}
/* A joined cluster: the repo input, Browse, and the Apply split read as one
diff --git a/extension/review/review.html b/extension/review/review.html
@@ -10,7 +10,7 @@
<div id="series-title">Loading…</div>
<div id="actions">
<div class="cluster" id="repo-cluster">
- <input id="repo-path" type="text" placeholder="repository path" spellcheck="false">
+ <span id="repo-path" title="Target repository">no repository chosen</span>
<button id="btn-browse" title="Choose the repository directory">Browse…</button>
<div class="split" id="apply-split">
<button id="btn-apply" title="Apply the patches to the repository (git am)">Apply</button>
diff --git a/extension/review/review.js b/extension/review/review.js
@@ -48,7 +48,7 @@ async function init() {
const m = anchor.m ? ` (${state.series.filter((p) => !p.isCover).length}/${anchor.m} patches)` : "";
$("#series-title").textContent = anchor.title + m;
document.title = `Review: ${anchor.title}`;
- $("#repo-path").value = state.repo;
+ refreshRepoDisplay(state.repo);
state.slug =
anchor.title.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) ||
"patchset";
@@ -607,10 +607,9 @@ async function applySeries(mode) {
return;
}
- const repo = $("#repo-path").value.trim();
- if (!repo) {
- showStatus("Enter the path of the repository to apply the series to.", { error: true });
- $("#repo-path").focus();
+ const repo = state.repo;
+ if (!repo || $("#repo-path").classList.contains("error")) {
+ showStatus("Choose a valid git repository with Browse… first.", { error: true });
return;
}
@@ -627,17 +626,43 @@ async function applySeries(mode) {
}
$("#btn-browse").addEventListener("click", async () => {
- const response = await bg({ type: "pick-repo", start: $("#repo-path").value.trim() });
- if (response.ok && response.output) {
- // Some choosers (osascript) return a trailing slash; keep the field editable.
- $("#repo-path").value = response.output.replace(/(.)\/$/, "$1");
- } else if (!response.ok) {
- showStatus(response.error || "No directory chooser available — type the path instead.", {
- error: true,
- });
+ const response = await bg({ type: "pick-repo", start: state.repo || "" });
+ if (!response.ok) {
+ showStatus(
+ response.error || "No directory chooser available — pick the path manually.",
+ { error: true }
+ );
+ return;
}
+ if (response.cancelled) {
+ return;
+ }
+ await refreshRepoDisplay(response.output);
});
+// Resolve the chosen path against git and render the toolbar pill. A
+// non-repo path turns the chip red and shows the error, and blocks Apply.
+async function refreshRepoDisplay(path) {
+ const chip = $("#repo-path");
+ if (!path) {
+ state.repo = "";
+ chip.classList.remove("error");
+ chip.textContent = "no repository chosen";
+ return;
+ }
+ chip.classList.remove("error");
+ chip.textContent = "resolving…";
+ const response = await bg({ type: "describe-repo", repo: path });
+ if (!response.ok) {
+ state.repo = "";
+ chip.classList.add("error");
+ chip.textContent = response.error || "not a git repository";
+ return;
+ }
+ state.repo = response.repo || path;
+ chip.textContent = `${response.name} · ${response.branch}`;
+}
+
async function openEditor(repo) {
const files = [...new Set(
state.parsed.flatMap((p) => p.files.map((f) => f.displayPath)).filter(Boolean)
@@ -650,7 +675,7 @@ async function openEditor(repo) {
}
}
-$("#btn-open-editor").addEventListener("click", () => openEditor($("#repo-path").value.trim()));
+$("#btn-open-editor").addEventListener("click", () => openEditor(state.repo));
// ---------------------------------------------------------------------------
// Status panel
diff --git a/tests/run.mjs b/tests/run.mjs
@@ -352,7 +352,7 @@ function check(name, cond, extra = "") {
const schema = JSON.parse(readFileSync(join(root, experiment.schema), "utf8"));
const names = schema[0].functions.map((f) => f.name).sort().join(",");
- check("experiment: schema functions", names === "apply,openEditor,pickDir,ping");
+ check("experiment: schema functions", names === "apply,describe,openEditor,pickDir,ping");
check("experiment: schema functions async", schema[0].functions.every((f) => f.async));
const source = readFileSync(join(root, experiment.parent.script), "utf8");