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

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:
Mextension/api/patchHost/implementation.js | 24+++++++++++++++++++++---
Mextension/api/patchHost/schema.json | 9+++++++++
Mextension/background.js | 5+++++
Mextension/review/review.css | 14+++++++++++++-
Mextension/review/review.html | 2+-
Mextension/review/review.js | 53+++++++++++++++++++++++++++++++++++++++--------------
Mtests/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");