thunderbird-patch-review

Simple email patch review tool for Thunderbird

git clone git://mccd.space/thunderbird-patch-review

implementation.js (24999B)

      1 // Privileged WebExtension Experiment behind Apply series, Browse... and
      2 // Open editor. It ships inside the .xpi, so installing the extension is the
      3 // whole install — there is no separate native messaging host.
      4 //
      5 // This file runs with full chrome privileges in Thunderbird's parent
      6 // process; everything privileged the add-on does lives here. It sticks to
      7 // long-stable platform APIs — Subprocess to run git and the editor,
      8 // nsIFilePicker for the directory chooser, IOUtils/PathUtils for the patch
      9 // workspace — and the fallbacks below cover the differences between
     10 // Thunderbird 115 and current.
     11 
     12 "use strict";
     13 
     14 /* globals ExtensionAPI, Services, IOUtils, PathUtils, AppConstants, ChromeUtils, Cc, Ci */
     15 
     16 // Platform modules moved from .jsm to .sys.mjs across the supported
     17 // Thunderbird range; try the ESM spelling first.
     18 function importModule(name) {
     19   try {
     20     return ChromeUtils.importESModule(`resource://gre/modules/${name}.sys.mjs`);
     21   } catch (e) {
     22     return ChromeUtils.import(`resource://gre/modules/${name}.jsm`);
     23   }
     24 }
     25 
     26 const { Subprocess } = importModule("Subprocess");
     27 
     28 /** Run a command to completion, stderr folded into stdout. */
     29 async function run(command, args) {
     30   const proc = await Subprocess.call({
     31     command,
     32     arguments: args,
     33     stderr: "stdout",
     34   });
     35   let output = "";
     36   let chunk;
     37   while ((chunk = await proc.stdout.readString())) {
     38     output += chunk;
     39   }
     40   const { exitCode } = await proc.wait();
     41   return { exitCode, output };
     42 }
     43 
     44 // Return a path to a freshly created empty directory git can be pointed at
     45 // via `-c core.hooksPath=<path>` to silence every hook for a single
     46 // invocation. git am fires applypatch-msg, pre-applypatch and
     47 // post-applypatch; `git worktree add` fires post-checkout.
     48 //
     49 // The Apply button is a deliberate human act, so the user's own hooks run
     50 // there as part of a normal apply — their workflow expects them. The
     51 // "Apply without hooks" dropdown item (apply()'s noHooks arg) instead
     52 // points git at an empty hooks dir, for applying untrusted content where
     53 // even a deliberate apply shouldn't run user code on the patch text. The
     54 // automated check() probe always suppresses (no human act at all).
     55 //
     56 // core.hooksPath is read per invocation (the -c value is never written to
     57 // the user's config), and an empty existing dir has no hook files for git
     58 // to find, on every platform. A purely nonexistent path would also work on
     59 // POSIX but is less portable (Windows treats `/dev/null`-style paths
     60 // oddly), so we make a real empty directory instead. Callers remove it in
     61 // their finally.
     62 async function emptyHooksDir() {
     63   const dir = PathUtils.join(
     64     PathUtils.tempDir,
     65     `patch-review.hooks.${Date.now()}.${Math.floor(Math.random() * 1e9)}`
     66   );
     67   await IOUtils.makeDirectory(dir, { permissions: 0o700 });
     68   return dir;
     69 }
     70 
     71 // Detect a `git am` already in progress in the user's repository: an
     72 // unfinished am leaves a rebase-apply/ directory inside the git dir, and
     73 // a fresh `git am` exits non-zero on it ("a previous am session is in
     74 // progress"). apply()'s abort path would then tear that session down,
     75 // discarding the user's in-progress conflict resolution; the guard before
     76 // apply() refuses instead. Resolve the git dir absolutely so a linked
     77 // worktree's own per-worktree git dir is checked (the path --git-path
     78 // rebase-apply would return for the worktree, not the common .git).
     79 // --absolute-git-dir lands in git 2.31 (2021); fall back to relative
     80 // --git-dir (absolute for linked worktrees, ".git" for the main repo) on
     81 // older builds, joined against repo. Returns the rebase-apply path if a
     82 // session is in progress, else null.
     83 async function amInProgress(git, repo) {
     84   let gd = await run(git, ["-C", repo, "rev-parse", "--absolute-git-dir"]);
     85   if (gd.exitCode !== 0 || !gd.output.trim()) {
     86     gd = await run(git, ["-C", repo, "rev-parse", "--git-dir"]);
     87     if (gd.exitCode !== 0) return null;
     88   }
     89   let dir = gd.output.trim();
     90   if (!dir) return null;
     91   if (!PathUtils.isAbsolute(dir)) dir = PathUtils.join(repo, dir);
     92   const ra = PathUtils.join(dir, "rebase-apply");
     93   return (await IOUtils.exists(ra)) ? ra : null;
     94 }
     95 
     96 async function findGit() {
     97   try {
     98     return await Subprocess.pathSearch("git", Subprocess.getEnvironment());
     99   } catch (e) {
    100     // Thunderbird launched from a desktop entry or .app bundle may see a
    101     // minimal PATH; try the usual install locations before giving up.
    102     for (const candidate of ["/usr/local/bin/git", "/opt/homebrew/bin/git", "/usr/bin/git"]) {
    103       if (await IOUtils.exists(candidate)) {
    104         return candidate;
    105       }
    106     }
    107     throw new Error("git not found in PATH");
    108   }
    109 }
    110 
    111 // Verify repo is a git worktree and return a short description for the
    112 // toolbar. The name is the top-level dir basename (what the user picked);
    113 // the branch is the checked-out branch or detached short SHA.
    114 async function describeRepo(git, repo) {
    115   if (!repo) {
    116     throw new Error("no repository path given");
    117   }
    118   const check = await run(git, ["-C", repo, "rev-parse", "--is-inside-work-tree"]);
    119   if (check.exitCode !== 0) {
    120     throw new Error(`not a git repository: ${repo}`);
    121   }
    122   const showToplevel = await run(git, ["-C", repo, "rev-parse", "--show-toplevel"]);
    123   const toplevel = showToplevel.output.trim();
    124   const name = toplevel ? toplevel.replace(/[\\/]+$/, "").split(/[\\/]/).pop() : repo;
    125   let branch = await run(git, ["-C", repo, "symbolic-ref", "--short", "HEAD"]);
    126   if (branch.exitCode !== 0) {
    127     // Detached HEAD: fall back to the short commit hash.
    128     branch = await run(git, ["-C", repo, "rev-parse", "--short", "HEAD"]);
    129   }
    130   return { ok: true, repo: toplevel || repo, name, branch: branch.output.trim() };
    131 }
    132 
    133 async function ping() {
    134   const git = await findGit();
    135   const version = await run(git, ["--version"]);
    136   return { ok: true, git, version: version.output.trim() };
    137 }
    138 
    139 async function describe(repo) {
    140   const git = await findGit();
    141   return describeRepo(git, repo);
    142 }
    143 
    144 async function apply(repo, strategy, patches, noHooks) {
    145   const git = await findGit();
    146   await describeRepo(git, repo);
    147   if (!patches.length) {
    148     throw new Error("no patches in request");
    149   }
    150 
    151   // Refuse before touching the repo if a previous `git am` is mid-series
    152   // here: a fresh `git am` would fail immediately ("a previous am session
    153   // is in progress") and the abort below would then reset the user's
    154   // branch and discard their ongoing conflict resolution — exactly the
    155   // session this add-on did not start. Surface git's own diagnostic
    156   // location and leave the working tree untouched, so the user resolves it
    157   // themselves (git am --continue / --skip / --abort) and applies again.
    158   const inProgress = await amInProgress(git, repo);
    159   if (inProgress) {
    160     return {
    161       ok: false,
    162       output:
    163         "A previous `git am` session is already in progress in this repository\n" +
    164         `        (${inProgress}).\n` +
    165         "Resolve it yourself — git am --continue, --skip, or --abort — then\n" +
    166         "apply again. This add-on will not run git am --abort against a\n" +
    167         "session it did not start, so your in-progress resolution is left\n" +
    168         "untouched.\n",
    169     };
    170   }
    171 
    172   const work = PathUtils.join(
    173     PathUtils.tempDir,
    174     `patch-review.${Date.now()}.${Math.floor(Math.random() * 1e9)}`
    175   );
    176   await IOUtils.makeDirectory(work, { permissions: 0o700 });
    177   // Hooks: the Apply button is a deliberate human act, so the user's own
    178   // hooks run as part of a normal apply (their workflow expects them).
    179   // "Apply without hooks" (noHooks) instead points git at an empty hooks
    180   // dir for both am and its rollback, for applying untrusted content
    181   // where even a deliberate apply shouldn't run user code on the patch.
    182   // The automated check() probe always suppresses; apply() defaults to
    183   // running hooks. The -c value is per-invocation only, never written to
    184   // the user's config, so their hooks are intact for normal git use.
    185   const hooks = noHooks ? await emptyHooksDir() : null;
    186   try {
    187     const hp = hooks ? ["-c", `core.hooksPath=${hooks}`] : [];
    188     const args = ["-C", repo, "-c", "advice.mergeConflict=false", "-c", "advice.amWorkDir=false", ...hp, "am"];
    189     if (strategy !== "am") {
    190       args.push("--3way");
    191     }
    192     for (const [i, patch] of patches.entries()) {
    193       const file = PathUtils.join(work, `${String(i + 1).padStart(4, "0")}.patch`);
    194       await IOUtils.writeUTF8(file, patch);
    195       args.push(file);
    196     }
    197     const am = await run(git, args);
    198     if (am.exitCode === 0) {
    199       return { ok: true, output: am.output };
    200     }
    201     const abort = await run(git, ["-C", repo, "-c", "advice.mergeConflict=false", "-c", "advice.amWorkDir=false", ...hp, "am", "--abort"]);
    202     return {
    203       ok: false,
    204       output:
    205         am.output +
    206         abort.output +
    207         "(git am --abort ran; the repository is back to its previous state)\n",
    208     };
    209   } finally {
    210     IOUtils.remove(work, { recursive: true }).catch(() => {});
    211     if (hooks) IOUtils.remove(hooks, { recursive: true }).catch(() => {});
    212   }
    213 }
    214 
    215 // Map each path a patch series touches to its FINAL post-series state,
    216 // taken from the right side of the last `index <old>..<new>` line that
    217 // carried the path. Last-write-wins across the multi-patch mbox (a
    218 // sequel that re-touches a file overrides the earlier patch's target).
    219 //
    220 // Returns a Map<path, { blob: "<hex>" } | { deleted: true }>. Returns
    221 // null when the input had no parseable path or when a path git rendered
    222 // quoted was seen (paths with spaces or other special chars aren't
    223 // handled — bail so the caller falls through to the git am worktree
    224 // instead of silently misclassifying).
    225 //
    226 // We extract the path from `+++ b/path`/`--- a/path` rather than the
    227 // `diff --git a/path b/path` header, so a future rename (a/old b/new)
    228 // maps the target to the new name and the old name is recorded deleted.
    229 function collectFinalBlobs(text) {
    230   const finals = new Map();
    231   // Mail transport commonly delivers CRLF (Thunderbird's getRaw does); a
    232   // trailing \r would defeat the $-anchored path regexes below, since in
    233   // JS `.` matches neither \r nor \n. A path genuinely ending in \r is
    234   // git-quoted and hits the quoted-path bail, so stripping is safe.
    235   const lines = text.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
    236   let block = null;
    237   let unsupported = false;
    238   const flush = (b) => {
    239     if (!b || !b.targetPath) return;
    240     if (b.isDelete) {
    241       finals.set(b.targetPath, { deleted: true });
    242     } else if (b.targetBlob) {
    243       finals.set(b.targetPath, { blob: b.targetBlob });
    244     }
    245   };
    246   for (const line of lines) {
    247     if (line.startsWith("diff --git ")) {
    248       flush(block);
    249       block = { targetPath: null, srcPath: null, targetBlob: null, isDelete: false };
    250       continue;
    251     }
    252     if (!block) continue;
    253     if (line.startsWith("deleted file mode ")) { block.isDelete = true; continue; }
    254     if (line === "+++ /dev/null") { block.isDelete = true; continue; }
    255     // git quotes paths with spaces or other special chars, either as
    256     // `+++ "b/path with space"` or `+++ b/"path with space"` (and the
    257     // analogous forms for ---). We don't unquote; bail so the caller
    258     // falls through to git am instead of misclassifying.
    259     const linePath = line.replace(/^(?:\+\+\+ (?:b\/)?|--- (?:a\/)?)/, "").replace(/^\/dev\/null$/, "");
    260     if ((line.startsWith("+++") || line.startsWith("---")) && linePath.startsWith('"')) {
    261       unsupported = true;
    262       break;
    263     }
    264     let m;
    265     if ((m = /^index ([0-9a-f]+)\.\.([0-9a-f]+)/.exec(line))) {
    266       block.targetBlob = m[2];
    267       continue;
    268     }
    269     if ((m = /^--- a\/(.+)$/.exec(line))) {
    270       block.srcPath = m[1];
    271       if (block.targetPath == null) block.targetPath = m[1];
    272       continue;
    273     }
    274     if ((m = /^\+\+\+ b\/(.+)$/.exec(line))) {
    275       const newPath = m[1];
    276       // The post-patch path differs from the source => a rename. The
    277       // source vanishes; the target takes the (likely unchanged) blob.
    278       if (block.srcPath && block.srcPath !== newPath) {
    279         finals.set(block.srcPath, { deleted: true });
    280       }
    281       block.targetPath = newPath;
    282       block.isDelete = false;
    283       continue;
    284     }
    285   }
    286   flush(block);
    287   if (unsupported) return null;
    288   return finals.size ? finals : null;
    289 }
    290 
    291 // Match the per-path target blobs against `git ls-tree HEAD -- <paths>`.
    292 // Returns true if every path in `finals` is already at its expected blob
    293 // in HEAD (or absent for deletes). False otherwise. Calls git once with
    294 // every path in one ls-tree to keep the spawn count down.
    295 async function blobsMatchHead(git, repo, finals) {
    296   const paths = [...finals.keys()];
    297   const ls = await run(git, ["-C", repo, "ls-tree", "HEAD", "--", ...paths]);
    298   // ls-tree prints rows like `<mode> blob <hash>\t<path>`; paths absent
    299   // from HEAD print no row at all.
    300   const byPath = new Map();
    301   for (const row of ls.output.split("\n")) {
    302     if (!row) continue;
    303     const m = /^(\S+) (\S+) ([0-9a-f]+)\t(.+)$/.exec(row);
    304     if (m) byPath.set(m[4], m[3]);
    305   }
    306   for (const [path, info] of finals) {
    307     if (info.deleted) {
    308       if (byPath.has(path)) return false;
    309     } else {
    310       const actual = byPath.get(path);
    311       if (!actual) return false;
    312       // git format-patch truncates blob hashes (default 7 hex). The
    313       // ls-tree output is full-length, so a prefix-equal either way marks
    314       // the same blob.
    315       const expected = info.blob;
    316       if (
    317         actual !== expected &&
    318         !actual.startsWith(expected) &&
    319         !expected.startsWith(actual)
    320       ) {
    321         return false;
    322       }
    323     }
    324   }
    325   return true;
    326 }
    327 
    328 // Classify a patch series against a repository without writing anything
    329 // to the user's working tree. apply() runs `git am`, so the most honest
    330 // precheck is to run `git am` too and read what it would do — without
    331 // dirtying the user's repo, by adding a throwaway --detach worktree at
    332 // HEAD and force-removing it afterwards. (The user's branch is never
    333 // touched; we never run am in the user's own worktree.)
    334 //
    335 // Three stages:
    336 //   1. Cheap `git apply --check` against the working tree fast-paths the
    337 //      common "cleanly applicable" and "exactly already applied" cases
    338 //      without paying for a worktree checkout.
    339 //   2. --check couldn't decide, but the series' END STATE per touched
    340 //      path already sits in HEAD. Patches committed with slightly tweaked
    341 //      intermediate content (a doubled-letter typo, mail-relay-mangled
    342 //      blank-context whitespace that the actual commit cleaned up mid-
    343 //      series) won't patch-id-match — but the FINAL tree state per file
    344 //      agrees. This catches the common "patchset already applied with
    345 //      minor diff divergence" case without a worktree checkout.
    346 //   3. Failing the above, run `git am` itself in a fresh --detach worktree
    347 //      to read am's own tri-state.
    348 //
    349 // git am backs the tri-state with stable signals (stage 3):
    350 //   exit 0  + "Patch already applied" / "No changes"  -> "applied"
    351 //   exit 0  (only "Applying: <subject>")              -> "applicable"
    352 //   exit 128 (context mismatch, merge conflict, dirty-index refusal) -> "conflict"
    353 async function check(repo, strategy, patches) {
    354   const git = await findGit();
    355   await describeRepo(git, repo);
    356   if (!patches.length) {
    357     throw new Error("no patches in request");
    358   }
    359 
    360   // Stage 0: dirty working tree. git am refuses to start on a tree with
    361   // staged or unstaged tracked changes (untracked files don't block it),
    362   // so any Apply would fail before the patches are even read — regardless
    363   // of whether the series would otherwise apply. Surface it up front as a
    364   // distinct "dirty" state so the UI can warn in yellow rather than let
    365   // the user click Apply into a refusal. Mirror git's
    366   // require_clean_work_tree: diff-index --quiet HEAD catches both staged
    367   // and unstaged tracked changes in one call (exit 0 clean, 1 dirty, 128
    368   // bad/absent HEAD); --ignore-submodules matches am's own check. The 128
    369   // case falls through to the normal stages, which will report the real
    370   // problem. A short `status -s` listing is folded into `output` for the
    371   // tooltip so the user can see what's keeping the tree dirty.
    372   const dirty = await run(git, ["-C", repo, "diff-index", "--quiet", "--ignore-submodules", "HEAD"]);
    373   if (dirty.exitCode === 1) {
    374     const why = await run(git, ["-C", repo, "status", "-s", "--untracked-files=no"]);
    375     return {
    376       ok: true,
    377       status: "dirty",
    378       output: why.output.trim() || "the working tree has staged or unstaged changes",
    379     };
    380   }
    381 
    382   // Stage 1: cheap --check against the working tree — fast-path the common
    383   // "cleanly applicable" and "exactly already applied" cases without
    384   // paying for a worktree checkout. The probe holds the mboxes
    385   // concatenated: separate per-file args are each checked against the
    386   // *original* tree independently, so a sequel whose context depends on an
    387   // earlier patch fails spuriously; concatenated, git parses the file as a
    388   // series and the contexts line up, just as am does.
    389   const probe = PathUtils.join(
    390     PathUtils.tempDir,
    391     `patch-review.check.${Date.now()}.${Math.floor(Math.random() * 1e9)}.patch`
    392   );
    393   // Unlike git am (whose mailinfo strips mail-transport CRLF), git apply
    394   // takes the probe bytes literally, and a \r on every context line
    395   // matches nothing. Fold transport CRLF to LF; a genuine content \r in
    396   // a CRLF-file patch arrives as \r\r\n and keeps its single \r.
    397   await IOUtils.writeUTF8(probe, patches.join("\n").replace(/\r\n/g, "\n"));
    398   let fwd;
    399   try {
    400     fwd = await run(git, ["-C", repo, "-c", "advice.mergeConflict=false", "-c", "advice.amWorkDir=false", "apply", "--check", probe]);
    401     if (fwd.exitCode === 0) {
    402       return { ok: true, status: "applicable", output: fwd.output };
    403     }
    404     const rev = await run(git, ["-C", repo, "-c", "advice.mergeConflict=false", "-c", "advice.amWorkDir=false", "apply", "--check", "--reverse", probe]);
    405     if (rev.exitCode === 0) {
    406       return { ok: true, status: "applied", output: fwd.output };
    407     }
    408   } finally {
    409     IOUtils.remove(probe).catch(() => {});
    410   }
    411 
    412   // Stage 1.5: end-state check. --check refused in both directions — but
    413   // if every path the series touches is already at its final post-patch
    414   // blob in HEAD (or absent, for deletes), the series has effectively
    415   // landed. git am would still flag a conflict (it can't recreate a file
    416   // that's already there, can't delete a file that's already gone); the
    417   // button would glow red. Compare blob hashes instead — they line up.
    418   //
    419   // collectFinalBlobs returns null when it can't parse all the paths
    420   // (e.g. git quotes paths with spaces), which falls through cleanly.
    421   const finals = collectFinalBlobs(patches.join("\n"));
    422   if (finals) {
    423     const headMatch = await blobsMatchHead(git, repo, finals);
    424     if (headMatch) {
    425       return {
    426         ok: true,
    427         status: "applied",
    428         output: "Series end-state matches HEAD (every touched file is at its target blob).",
    429       };
    430     }
    431   }
    432 
    433   // Stage 2: --check couldn't decide. Run git am for real in a fresh
    434   // --detach worktree, mirroring apply()'s strategy (am or am3). apply()
    435   // runs git am with the same arguments on the same patches, so the probe
    436   // predicts the Apply button's outcome exactly, including the --3way
    437   // reconstruction --check skipped. The user's branch is never touched
    438   // (--detach forks off HEAD; am runs in the temporary worktree only) and
    439   // the worktree is force-removed afterwards.
    440   const work = PathUtils.join(
    441     PathUtils.tempDir,
    442     `patch-review.am.${Date.now()}.${Math.floor(Math.random() * 1e9)}`
    443   );
    444   // Empty hooks dir, created before the worktree so post-checkout fires
    445   // nothing on the checkout, and reused for the am run and its abort so
    446   // applypatch-msg/pre-applypatch/post-applypatch fire nothing either.
    447   const hooks = await emptyHooksDir();
    448   const hp = ["-c", `core.hooksPath=${hooks}`];
    449   const make = await run(git, ["-C", repo, "-c", "advice.mergeConflict=false", "-c", "advice.amWorkDir=false", ...hp, "worktree", "add", "--detach", work]);
    450   if (make.exitCode !== 0) {
    451     // Rare (bare repo, no worktree support, …). Don't trust --check's
    452     // partial answer here — report "conflict" so the user clicks Apply
    453     // and reads am's own error, instead of guessing.
    454     IOUtils.remove(hooks, { recursive: true }).catch(() => {});
    455     return { ok: true, status: "conflict", output: fwd.output + make.output };
    456   }
    457   try {
    458     const file = PathUtils.join(work, "series.mbox");
    459     await IOUtils.writeUTF8(file, patches.join("\n"));
    460     const args = ["-C", work, "-c", "advice.mergeConflict=false", "-c", "advice.amWorkDir=false", ...hp, "am"];
    461     if (strategy !== "am") {
    462       args.push("--3way");
    463     }
    464     args.push(file);
    465     const am = await run(git, args);
    466     if (am.exitCode === 0) {
    467       if (/Patch already applied|No changes --/.test(am.output)) {
    468         return { ok: true, status: "applied", output: am.output };
    469       }
    470       return { ok: true, status: "applicable", output: am.output };
    471     }
    472     // am stopped mid-series (context mismatch, merge conflict, dirty
    473     // tree). Roll the worktree back so its state isn't left dangling, then
    474     // report conflict with am's diagnostic — it names the file and hunk.
    475     await run(git, ["-C", work, "-c", "advice.mergeConflict=false", "-c", "advice.amWorkDir=false", ...hp, "am", "--abort"]);
    476     return { ok: true, status: "conflict", output: am.output };
    477   } finally {
    478     // --force drops the worktree even if am left its working tree dirty.
    479     await run(git, ["-C", repo, "-c", "advice.mergeConflict=false", "-c", "advice.amWorkDir=false", "worktree", "remove", "--force", work]);
    480     IOUtils.remove(work, { recursive: true }).catch(() => {});
    481     IOUtils.remove(hooks, { recursive: true }).catch(() => {});
    482   }
    483 }
    484 
    485 async function pickDir(start) {
    486   const win = Services.wm.getMostRecentWindow(null);
    487   if (!win) {
    488     throw new Error("no window to attach the directory chooser to");
    489   }
    490   const picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
    491   // nsIFilePicker.init took a window before Thunderbird ~125 and takes a
    492   // BrowsingContext after; whichever the running version rejects, throws.
    493   try {
    494     picker.init(win.browsingContext, "Choose repository", Ci.nsIFilePicker.modeGetFolder);
    495   } catch (e) {
    496     picker.init(win, "Choose repository", Ci.nsIFilePicker.modeGetFolder);
    497   }
    498   if (start) {
    499     try {
    500       const dir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
    501       dir.initWithPath(start);
    502       if (dir.exists() && dir.isDirectory()) {
    503         picker.displayDirectory = dir;
    504       }
    505     } catch (e) {
    506       // Not a usable start path; the picker opens at its default.
    507     }
    508   }
    509   const rv = await new Promise((resolve) => picker.open(resolve));
    510   if (rv !== Ci.nsIFilePicker.returnOK || !picker.file) {
    511     return { ok: true, output: "", cancelled: true };
    512   }
    513   return { ok: true, output: picker.file.path };
    514 }
    515 
    516 async function openEditor(repo, editor, files) {
    517   const git = await findGit();
    518   await describeRepo(git, repo);
    519   const env = Subprocess.getEnvironment();
    520   let command = (editor || "").trim() || (env.EDITOR || "").trim();
    521   if (!command) {
    522     // Nothing configured anywhere: open the repository directory instead.
    523     command = { macosx: "open", win: "start" }[AppConstants.platform] || "xdg-open";
    524     files = ["."];
    525   }
    526 
    527   if (AppConstants.platform === "win") {
    528     const line = [command, ...files.map((f) => `"${f}"`)].join(" ");
    529     await Subprocess.call({
    530       command: env.ComSpec || "C:\\Windows\\System32\\cmd.exe",
    531       arguments: ["/s", "/c", line],
    532       workdir: repo,
    533       stderr: "ignore",
    534     });
    535   } else {
    536     // The command may carry flags ("code -n"); let sh split it and append
    537     // the files. Output goes to /dev/null so a chatty editor can never
    538     // fill the pipe, and the editor is not waited on.
    539     await Subprocess.call({
    540       command: "/bin/sh",
    541       arguments: ["-c", `exec ${command} "$@" >/dev/null 2>&1`, "patch-review-edit", ...files],
    542       workdir: repo,
    543       stderr: "ignore",
    544     });
    545   }
    546   return { ok: true, output: `launched: ${command}` };
    547 }
    548 
    549 /** Errors become {ok: false, error} so callers get one uniform shape. */
    550 async function guarded(fn) {
    551   try {
    552     return await fn();
    553   } catch (e) {
    554     return { ok: false, error: e.message || String(e) };
    555   }
    556 }
    557 
    558 var patchHost = class extends ExtensionAPI {
    559   getAPI() {
    560     return {
    561       patchHost: {
    562         ping: () => guarded(ping),
    563         describe: (repo) => guarded(() => describe(repo)),
    564         apply: (repo, strategy, patches, noHooks) => guarded(() => apply(repo, strategy, patches, noHooks)),
    565         check: (repo, strategy, patches) => guarded(() => check(repo, strategy, patches)),
    566         pickDir: (start) => guarded(() => pickDir(start)),
    567         openEditor: (repo, editor, files) => guarded(() => openEditor(repo, editor, files)),
    568       },
    569     };
    570   }
    571 };