SLOPSHOPPER

repo-worktree

A git dashboard: repos in the chat's folder are watched on their own, every other repo on the computer is listed to add. Worktrees, branches, ahead/behind…

newpanecommandtoastprocesstimer
v0.3.4no licenseupdated 2026-10-09yahyashareef48/claude-mods/plugins/repo-worktree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · repo-worktree
│ ┃ Repos ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Repos 1 repo · 15:34 Add repo ▾ [ Refresh ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ╭────────────────────────────────────────╮ ⏺ Update(src/auth.ts) │ ┃ │ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ │ ⏺ Bash(bun test) │ ┃ ╰────────────────────────────────────────╯ ⎿ 3 pass, 1 fail │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /repos │ ⎿ repo-worktree: Repos: 1 repo watched │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Repos
Repos 1 repo · 15:34 Add repo ▾ [ Refresh ] ╭────────────────────────────────────────────────────────╮ │ │ │ │ ╰────────────────────────────────────────────────────────╯
README

claude-mods

Claude Code mods (function-hook plugins), tuned to look and work well in the Claude desktop app as well as the terminal.

ModWhat it doesBased on
replay-theaterA live timeline of everything Claude does in a turn — commands with their output, reads, searches and edits with diffs — as rounded chips in a pane, five at a time, with filters and a detail card.anthropics/claude-code-playground (Apache-2.0)
repo-worktreeA compact git dashboard. Every repo in the chat's folder is watched on its own; every other repo on the computer is one pick away in the Add repo menu. One chip per repo, worktrees nested under it: a status dot, ↓behind ↑ahead, changed files, the branch as a pill, and the one action that fits (Pull, Push, Publish or Sync). Fetched every minute; double-click a repo to open it in VS Code. Everything survives restarts.—

Install

Needs Claude Code 2.1.287 or later.

/plugin marketplace add yahyashareef48/claude-mods
/plugin install replay-theater@claude-mods
/plugin install repo-worktree@claude-mods

Then run /reload-plugins (or start a new session).

  • /replay opens Replay Theater, or press Replay in the bar above the prompt while Claude works to watch it live.
  • /repos opens Repo Worktree. Repos in the chat's folder are added on their own (one you unwatch, with the ✕ that shows on hover, stays unwatched); the Add repo menu lists the rest of the repos on the computer — folders Claude Code has worked in, then a scan of your home folder and other drives (4 levels deep, skipping node_modules, AppData and the like), rescanned daily or from the menu. /watch re-adds the current folder's repos, /watch <path> adds another. Click a row's ● count to list its changed files. Sync fetches, pulls fast-forward only, then pushes; a diverged branch stops with a message instead of merging. Opening in VS Code needs the code command on your PATH.

Developing

Each mod lives in plugins/<name>/ with its own tests:

claude plugin validate plugins/replay-theater
claude plugin test plugins/replay-theater

Desktop app note. If the desktop app bundles an older Claude Code than your terminal, it may run the copy made when the mod was installed (under ~/.claude/plugins/cache/claude-mods/<mod>/<version>/) rather than this folder. After editing, bump the mod's version and reinstall, or copy the changed files into that folder, then /reload-plugins.

License

plugins/replay-theater keeps its upstream Apache-2.0 license (see its LICENSE and the changes listed in its README). plugins/repo-worktree is original to this repo.

Source 1 files
hooks/repo-worktree.mjs 719 lines
1// Repo Worktree: a git dashboard. The repos in the chat's folder are watched
2// on their own; every other repo on this computer is listed to pick from.
3// Each repo is a card with a row per worktree (its branch, ahead/behind its
4// upstream, changed files) and Pull, Push and Sync; the other local branches
5// sit below. Fetched and refreshed every minute. The watched list, what was
6// unwatched, what was found and whether the pane was open are kept in the
7// plugin's store, so they come back after a restart.
8//
9// Claude Code 2.1.287+ function hooks.
10
11const PANE_ID = "repo-worktree";
12const REFRESH_MS = 60_000;
13const DOUBLE_PRESS_MS = 450;
14const MAX_FILES = 50;
15const STORE_REPOS = "repos";
16const STORE_OPEN = "paneOpen";
17const STORE_DISMISSED = "dismissed";
18const STORE_FOUND = "discovered";
19const RESCAN_MS = 24 * 60 * 60_000;
20const SCAN_DEPTH = 4;
21const SCAN_BUDGET = 3000;
22
23// Folders a repo is never under, or too big to walk for nothing.
24const SKIP_DIRS = new Set([
25  "node_modules", "appdata", "library", "applications", "program files", "program files (x86)", "windows",
26  "programdata", "$recycle.bin", "system volume information", "vendor", "dist", "build", "target", "out",
27  "venv", "env", "__pycache__", "site-packages", "pictures", "music", "videos", "movies", "snap",
28]);
29
30// `repos` is the watched list (main worktree roots), in the order added;
31// `dismissed` repos the person unwatched, never added again on their own;
32// `data` what git last said about each; `found` the repos discovered on this
33// computer; the rest is the pane's own.
34const state = {
35  repos: [], dismissed: [], data: {}, expanded: {}, messages: {}, busy: {},
36  lastPress: {}, flash: {}, isOpen: false, isLoaded: false, isRefreshing: false, refreshedAt: 0, addError: "",
37  found: [], foundAt: 0, isScanning: false,
38};
39
40// ── git ───────────────────────────────────────────────────────────────────
41
42// Forward slashes, no trailing slash, but a drive's root keeps its own ("C:/":
43// "C:" alone would be that drive's current folder).
44const norm = (p) => {
45  const s = String(p ?? "").trim().replace(/^"|"$/g, "").replace(/\\/g, "/").replace(/\/+$/, "");
46  return /^[A-Za-z]:$/.test(s) ? `${s}/` : s;
47};
48const join = (dir, name) => (dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`);
49const baseName = (p) => norm(p).split("/").pop() || norm(p);
50const isWindowsPath = (p) => /^[A-Za-z]:\//.test(norm(p));
51const samePath = (a, b) => (isWindowsPath(a) ? norm(a).toLowerCase() === norm(b).toLowerCase() : norm(a) === norm(b));
52
53// Never asks for a password (a fetch with no credentials fails instead of
54// hanging) and takes no optional locks, so a refresh never blocks the user's
55// own git. Long paths on: Git for Windows has them off, and a long branch
56// name then fails a fetch with "Filename too long".
57const GIT_ENV = {
58  GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0",
59  GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "core.longpaths", GIT_CONFIG_VALUE_0: "true",
60};
61
62async function git($, cwd, args, timeoutMs = 30_000) {
63  try {
64    const r = await $.process.run(["git", ...args], { cwd, env: GIT_ENV, timeoutMs });
65    return { ok: r.exitCode === 0, out: r.stdout ?? "", err: (r.stderr ?? "").trim() };
66  } catch (error) {
67    return { ok: false, out: "", err: String(error?.message ?? error) };
68  }
69}
70
71// The main worktree's root for any folder inside a repo or one of its
72// worktrees: the first entry `git worktree list` gives.
73async function resolveRepo($, path) {
74  const top = await git($, path, ["rev-parse", "--show-toplevel"]);
75  if (!top.ok) return { error: `Not a git repository: ${path}` };
76  const list = await git($, norm(top.out), ["worktree", "list", "--porcelain"]);
77  const first = list.ok ? parseWorktrees(list.out)[0] : undefined;
78  return { root: norm(first?.path ?? top.out) };
79}
80
81function parseWorktrees(text) {
82  return text.split(/\r?\n\r?\n/).map((block) => {
83    const wt = {};
84    for (const line of block.split(/\r?\n/)) {
85      const [key, ...rest] = line.split(" ");
86      const value = rest.join(" ");
87      if (key === "worktree") wt.path = norm(value);
88      else if (key === "branch") wt.branch = value.replace(/^refs\/heads\//, "");
89      else if (key === "detached") wt.detached = true;
90      else if (key === "bare") wt.bare = true;
91      else if (key === "prunable") wt.prunable = true;
92      else if (key === "HEAD") wt.head = value;
93    }
94    return wt;
95  }).filter((wt) => wt.path);
96}
97
98
99// `git status --porcelain=v2 --branch`: the branch, its upstream, ahead and
100// behind, and one entry per changed path.
101function parseStatus(text) {
102  const s = { upstream: "", ahead: 0, behind: 0, files: [] };
103  for (const line of text.split(/\r?\n/)) {
104    if (!line) continue;
105    if (line.startsWith("# branch.head ")) s.head = line.slice(14);
106    else if (line.startsWith("# branch.upstream ")) s.upstream = line.slice(18);
107    else if (line.startsWith("# branch.ab ")) {
108      const m = line.match(/\+(\d+) -(\d+)/);
109      if (m) { s.ahead = Number(m[1]); s.behind = Number(m[2]); }
110    } else if (line[0] === "1" || line[0] === "2") {
111      const f = line.split(" ");
112      const xy = f[1];
113      const path = f.slice(line[0] === "1" ? 8 : 9).join(" ").split("\t")[0];
114      const code = xy[0] !== "." ? xy[0] : xy[1];
115      s.files.push({ code, path, staged: xy[0] !== "." });
116    } else if (line[0] === "u") {
117      s.files.push({ code: "U", path: line.split(" ").slice(10).join(" ") });
118    } else if (line[0] === "?") {
119      s.files.push({ code: "?", path: line.slice(2) });
120    }
121  }
122  return s;
123}
124
125function parseNumstat(text) {
126  let add = 0, del = 0;
127  for (const line of text.split(/\r?\n/)) {
128    const [a, d] = line.split("\t");
129    if (/^\d+$/.test(a)) add += Number(a);
130    if (/^\d+$/.test(d)) del += Number(d);
131  }
132  return { add, del };
133}
134
135// Local branches: their upstream and how far ahead or behind it they are.
136function parseBranches(text) {
137  return text.split(/\r?\n/).filter(Boolean).map((line) => {
138    const [name, upstream, track] = line.split("\t");
139    const ahead = Number(track?.match(/ahead (\d+)/)?.[1] ?? 0);
140    const behind = Number(track?.match(/behind (\d+)/)?.[1] ?? 0);
141    return { name, upstream: upstream || "", ahead, behind, gone: track === "gone" };
142  });
143}
144
145async function loadWorktree($, wt) {
146  const [status, numstat] = await Promise.all([
147    git($, wt.path, ["status", "--porcelain=v2", "--branch"]),
148    git($, wt.path, ["diff", "--numstat", "HEAD"]),
149  ]);
150  if (!status.ok) return { ...wt, error: status.err || "git status failed" };
151  const loaded = { ...wt, ...parseStatus(status.out), ...parseNumstat(numstat.out) };
152  return loaded.branch ? loaded : { ...loaded, ...(await detachedFrom($, wt.path)) };
153}
154
155// A detached worktree checked out from a ref (`git checkout origin/main`):
156// that ref, read from HEAD's reflog, and how far HEAD is behind or ahead of it.
157async function detachedFrom($, path) {
158  const log = await git($, path, ["reflog", "-1", "--format=%gs"]);
159  const ref = log.out.trim().match(/ to (\S+)$/)?.[1];
160  if (!ref || /^[0-9a-f]{7,40}$/i.test(ref)) return {};
161  const ok = await git($, path, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
162  if (!ok.ok) return {};
163  const [behind, ahead] = await Promise.all([
164    git($, path, ["rev-list", "--count", `HEAD..${ref}`]),
165    git($, path, ["rev-list", "--count", `${ref}..HEAD`]),
166  ]);
167  return { detachedFrom: ref, behind: Number(behind.out.trim()) || 0, ahead: Number(ahead.out.trim()) || 0 };
168}
169
170async function loadRepo($, root) {
171  const list = await git($, root, ["worktree", "list", "--porcelain"]);
172  if (!list.ok) return { root, name: baseName(root), error: list.err || "Not a git repository", worktrees: [], branches: [] };
173  const wts = parseWorktrees(list.out).filter((wt) => !wt.bare && !wt.prunable);
174  const [worktrees, refs] = await Promise.all([
175    Promise.all(wts.map((wt) => loadWorktree($, wt))),
176    git($, root, ["for-each-ref", "--format=%(refname:short)%09%(upstream:short)%09%(upstream:track,nobracket)", "refs/heads"]),
177  ]);
178  const checkedOut = new Set(worktrees.map((wt) => wt.branch).filter(Boolean));
179  const branches = refs.ok ? parseBranches(refs.out).filter((b) => !checkedOut.has(b.name)) : [];
180  return { root, name: baseName(root), worktrees, branches, loadedAt: Date.now() };
181}
182
183async function fetchRepo($, root) {
184  const r = await git($, root, ["fetch", "--all", "--prune", "--quiet"], 120_000);
185  if (r.ok) return "";
186  // A remote branch whose name the file system can't hold (a `"` on
187  // Windows) fails the prune alone: fetch again without it.
188  if (/could not delete references/i.test(r.err)) {
189    const plain = await git($, root, ["fetch", "--all", "--quiet"], 120_000);
190    return plain.ok ? "" : plain.err.split(/\r?\n/).pop();
191  }
192  return r.err.split(/\r?\n/).pop();
193}
194
195// ── Finding repos ─────────────────────────────────────────────────────────
196
197const pathKey = (p) => (isWindowsPath(p) ? norm(p).toLowerCase() : norm(p));
198const isWatched = (root) => state.repos.some((r) => samePath(r, root));
199const isDismissed = (root) => state.dismissed.some((r) => samePath(r, root));
200
201// Folders holding a `.git`, breadth first from `bases`, `depth` levels down,
202// at most `budget` folders listed. A repo is not walked into. `.git` is a
203// folder in a repo's main checkout and a file in a worktree or submodule.
204async function findRepos($, bases, { depth = SCAN_DEPTH, budget = SCAN_BUDGET } = {}) {
205  const found = [];
206  const queue = bases.map((p) => ({ path: norm(p), level: 0 }));
207  const seen = new Set();
208  let listed = 0;
209  while (queue.length && listed < budget) {
210    const { path, level } = queue.shift();
211    if (seen.has(pathKey(path))) continue;
212    seen.add(pathKey(path));
213    let entries;
214    try { entries = await $.fs.list(path); } catch { continue; }
215    listed++;
216    const dotGit = entries.find((en) => en.name === ".git");
217    if (dotGit) { found.push({ path, mtimeMs: dotGit.mtimeMs || 0, isMain: dotGit.kind === "dir" }); continue; }
218    if (level >= depth) continue;
219    for (const en of entries) {
220      const name = en.name.toLowerCase();
221      if (en.kind !== "dir" || en.isLink || name.startsWith(".") || SKIP_DIRS.has(name)) continue;
222      queue.push({ path: join(path, en.name), level: level + 1 });
223    }
224  }
225  return found;
226}
227
228async function homeDir($) {
229  return norm((await $.env.get("USERPROFILE")) || (await $.env.get("HOME")) || "");
230}
231
232// Watches the repo `base` is in and every repo below it. On its own (the
233// chat's folder at start) a repo the person unwatched stays unwatched; asked
234// for (Watch this folder, /watch) it comes back.
235async function watchFolder($, base, { isManual = false } = {}) {
236  const roots = [];
237  const here = await resolveRepo($, base);
238  if (!here.error) roots.push(here.root);
239  for (const hit of await findRepos($, [base], { budget: 1500 })) {
240    const r = await resolveRepo($, hit.path);
241    if (!r.error) roots.push(r.root);
242  }
243  const added = [];
244  for (const root of roots) {
245    if (isWatched(root) || added.some((a) => samePath(a, root))) continue;
246    if (isDismissed(root) && !isManual) continue;
247    if (isManual) state.dismissed = state.dismissed.filter((d) => !samePath(d, root));
248    state.repos.push(root);
249    added.push(root);
250  }
251  if (added.length) {
252    await save($);
253    $.ui.invalidate("ui.render");
254    await Promise.all(added.map((root) => refreshRepo($, root)));
255  }
256  return { added, found: roots.length };
257}
258
259// Every repo on this computer worth offering: folders Claude Code has worked
260// in (~/.claude.json) first, then a walk of the home folder and the other
261// drives. Kept in the store and walked again once a day or on Rescan.
262async function discover($, { force = false } = {}) {
263  if (state.isScanning) return;
264  if (!force && state.foundAt && Date.now() - state.foundAt < RESCAN_MS) return;
265  state.isScanning = true;
266  $.ui.invalidate("ui.render");
267  try {
268    const home = await homeDir($);
269    const recent = [];
270    try {
271      const config = JSON.parse(await $.fs.read(join(home, ".claude.json")));
272      for (const p of Object.keys(config.projects ?? {})) {
273        try {
274          const stat = await $.fs.stat(join(norm(p), ".git"));
275          if (stat.kind === "dir") recent.push({ path: norm(p), mtimeMs: stat.mtimeMs || 0, isRecent: true });
276        } catch { /* not a repo's root */ }
277      }
278    } catch { /* no config to read */ }
279    const bases = home ? [home] : [];
280    if (isWindowsPath(home)) {
281      for (const d of "DEFGHIJ") {
282        try { if (await $.fs.exists(`${d}:/`)) bases.push(`${d}:/`); } catch { /* no such drive */ }
283      }
284    }
285    const scanned = (await findRepos($, bases)).filter((r) => r.isMain);
286    const byKey = new Map();
287    for (const r of [...recent, ...scanned]) if (!byKey.has(pathKey(r.path))) byKey.set(pathKey(r.path), r);
288    state.found = [...byKey.values()]
289      .sort((a, b) => Number(!!b.isRecent) - Number(!!a.isRecent) || b.mtimeMs - a.mtimeMs)
290      .map(({ path, isRecent }) => ({ path, isRecent: !!isRecent }));
291    state.foundAt = Date.now();
292    try { await $.store.set(STORE_FOUND, { at: state.foundAt, repos: state.found }); } catch { /* best effort */ }
293  } catch { /* nothing found this time: offered again on Rescan */ } finally {
294    state.isScanning = false;
295    $.ui.invalidate("ui.render");
296  }
297}
298
299// ── Watching and refreshing ───────────────────────────────────────────────
300
301async function save($) {
302  try {
303    await $.store.set(STORE_REPOS, state.repos);
304    await $.store.set(STORE_DISMISSED, state.dismissed);
305    await $.store.set(STORE_OPEN, state.isOpen);
306  } catch { /* best effort */ }
307}
308
309async function refreshRepo($, root, { fetch = true } = {}) {
310  const fetchError = fetch ? await fetchRepo($, root) : state.data[root]?.fetchError ?? "";
311  state.data[root] = { ...(await loadRepo($, root)), fetchError };
312  $.ui.invalidate("ui.render");
313}
314
315async function refreshAll($, { fetch = true } = {}) {
316  if (state.isRefreshing) return;
317  state.isRefreshing = true;
318  // A success note ("Pushed") lasts until the next refresh; an error stays.
319  for (const [path, m] of Object.entries(state.messages)) if (!m.isError) delete state.messages[path];
320  $.ui.invalidate("ui.render");
321  try { await Promise.all(state.repos.map((root) => refreshRepo($, root, { fetch }))); }
322  finally {
323    state.isRefreshing = false;
324    state.refreshedAt = Date.now();
325    $.ui.invalidate("ui.render");
326  }
327}
328
329async function addRepo($, input) {
330  const path = norm(input);
331  state.addError = "";
332  if (!path) return;
333  const found = await resolveRepo($, path);
334  if (found.error) { state.addError = found.error; $.ui.invalidate("ui.render"); return; }
335  if (isWatched(found.root)) { state.addError = `Already watching ${baseName(found.root)}`; $.ui.invalidate("ui.render"); return; }
336  state.dismissed = state.dismissed.filter((d) => !samePath(d, found.root));
337  state.repos.push(found.root);
338  await save($);
339  $.ui.invalidate("ui.render");
340  await refreshRepo($, found.root);
341}
342
343// Unwatching is remembered, so the chat's folder doesn't add it back.
344async function removeRepo($, root) {
345  state.repos = state.repos.filter((r) => r !== root);
346  if (!isDismissed(root)) state.dismissed.push(root);
347  delete state.data[root];
348  await save($);
349  $.ui.invalidate("ui.render");
350}
351
352
353async function openPane($, focus) {
354  state.isOpen = true;
355  await save($);
356  // `focus` only when asked for: the host refuses `focus: false`.
357  await $.ui.open({ id: PANE_ID, title: "Repos", ...(focus ? { focus: true } : {}), closeOnEscape: true, columns: 72, rows: 24 });
358  $.ui.invalidate("ui.render");
359}
360
361// ── Actions ───────────────────────────────────────────────────────────────
362
363// An error gets a line under its row, kept until the next action.
364function say($, wtPath, text, isError = false) {
365  state.messages[wtPath] = { text, isError };
366  $.ui.invalidate("ui.render");
367}
368
369// Success is said on the button itself ("Synced ✓") for a moment, so nothing
370// moves: no line comes and goes.
371const FLASH_MS = 2200;
372function flash($, wtPath, text) {
373  state.flash[wtPath] = text;
374  $.ui.invalidate("ui.render");
375  try {
376    $.clock.after(FLASH_MS, () => {
377      if (state.flash[wtPath] !== text) return;
378      delete state.flash[wtPath];
379      $.ui.invalidate("ui.render");
380    });
381  } catch { /* cleared at the next refresh instead */ }
382}
383
384const lastLine = (r) => (r.err || r.out).trim().split(/\r?\n/).filter(Boolean).pop() ?? "";
385
386async function push($, wt) {
387  if (wt.upstream) return git($, wt.path, ["push"], 120_000);
388  const remotes = await git($, wt.path, ["remote"]);
389  const remote = remotes.out.split(/\r?\n/).filter(Boolean)[0];
390  if (!remote) return { ok: false, err: "No remote to push to" };
391  return git($, wt.path, ["push", "-u", remote, wt.branch], 120_000);
392}
393
394async function act($, root, wt, kind) {
395  if (state.busy[wt.path]) return;
396  if (!wt.branch && kind !== "advance") { say($, wt.path, "Detached HEAD: check out a branch first", true); return; }
397  state.busy[wt.path] = kind;
398  delete state.messages[wt.path];
399  $.ui.invalidate("ui.render");
400  try {
401    let r;
402    if (kind === "advance") {
403      // A detached worktree moves to the latest of the ref it came from.
404      await fetchRepo($, root);
405      r = await git($, wt.path, ["checkout", "--detach", wt.detachedFrom], 60_000);
406      if (r.ok) flash($, wt.path, "Pulled ✓"); else say($, wt.path, `Couldn't move: ${lastLine(r)}`, true);
407    } else if (kind === "pull") {
408      r = await git($, wt.path, ["pull", "--ff-only"], 120_000);
409      if (r.ok) flash($, wt.path, "Pulled ✓"); else say($, wt.path, `Pull stopped: ${lastLine(r)}`, true);
410    } else if (kind === "push") {
411      r = await push($, wt);
412      if (r.ok) flash($, wt.path, wt.upstream ? "Pushed ✓" : "Published ✓"); else say($, wt.path, `Push failed: ${lastLine(r)}`, true);
413    } else {
414      // Sync: fetch, fast-forward, then push. A diverged branch stops here
415      // and says so instead of merging or rebasing on its own.
416      await fetchRepo($, root);
417      const fresh = await loadWorktree($, wt);
418      if (fresh.upstream && fresh.behind > 0) {
419        r = await git($, wt.path, ["pull", "--ff-only"], 120_000);
420        if (!r.ok) {
421          say($, wt.path, fresh.ahead > 0 ? `Diverged from ${fresh.upstream} (${fresh.ahead} ahead, ${fresh.behind} behind): rebase or merge first` : `Pull stopped: ${lastLine(r)}`, true);
422          return;
423        }
424      }
425      const now = await loadWorktree($, wt);
426      if (!now.upstream || now.ahead > 0) {
427        r = await push($, now);
428        if (!r.ok) { say($, wt.path, `Push failed: ${lastLine(r)}`, true); return; }
429      }
430      flash($, wt.path, "Synced ✓");
431    }
432  } finally {
433    delete state.busy[wt.path];
434    await refreshRepo($, root, { fetch: false });
435  }
436}
437
438// One press arms, a second within DOUBLE_PRESS_MS opens the repo in VS Code.
439async function pressRepo($, root) {
440  const now = Date.now();
441  const last = state.lastPress[root] ?? 0;
442  state.lastPress[root] = now;
443  if (now - last > DOUBLE_PRESS_MS) return;
444  state.lastPress[root] = 0;
445  const argv = isWindowsPath(root) ? ["cmd.exe", "/d", "/c", "code", root] : ["code", root];
446  try {
447    const r = await $.process.run(argv, { cwd: root, timeoutMs: 15_000 });
448    if (r.exitCode !== 0) await $.ui.toast(`Couldn't open VS Code: ${(r.stderr || r.stdout).trim() || "is `code` on your PATH?"}`);
449  } catch (error) {
450    await $.ui.toast(`Couldn't open VS Code: ${error?.message ?? error}`);
451  }
452}
453
454// ── View ──────────────────────────────────────────────────────────────────
455// After a source list: one chip per repo with its worktrees nested under it,
456// each row a status dot, a name, ahead/behind, the branch as a pill and the
457// one action that fits. Details (changed files, errors) wait to be asked for.
458
459const GREEN = "#34c759", AMBER = "#ff9f0a", RED = "#ff453a", GRAY = "#8e8e93";
460const STATUS_ALT = { [GREEN]: "Up to date", [AMBER]: "Behind or changed", [RED]: "Error", [GRAY]: "On a bare commit" };
461const CHIP_BORDER = "rgba(142, 142, 147, 0.28)";
462const BLUE = "#58a6ff";
463const FILE_COLOR = { M: "warning", A: "success", D: "error", R: "suggestion", C: "suggestion", U: "error", T: "warning", "?": "subtle" };
464
465const timeOf = (ms) => (ms ? new Date(ms).toTimeString().slice(0, 5) : "");
466const plural = (n, one, many = one + "s") => `${n} ${n === 1 ? one : many}`;
467const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
468// The part of a git error worth reading: what follows its last colon.
469const shortError = (s) => clip(String(s).trim().split(/:\s+/).filter(Boolean).pop() ?? String(s), 60);
470
471function dot(color) {
472  return `<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4" fill="${color}"/></svg>`;
473}
474
475const escapeXml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c]);
476
477// A label's width at 11.5px in the UI font, by character class (an image
478// can't measure text): narrow letters, wide ones, capitals, digits, the rest.
479const PILL_FONT = 11.5;
480function textWidth(s) {
481  let em = 0;
482  for (const c of s) {
483    if ("iIlj.,:;'|!".includes(c)) em += 0.25;
484    else if (" ·".includes(c)) em += 0.27;
485    else if ("frt-/()[]".includes(c)) em += 0.36;
486    else if ("mwMW".includes(c)) em += 0.84;
487    else if (/[A-Z]/.test(c)) em += 0.62;
488    else if (/[0-9]/.test(c)) em += 0.55;
489    else if (c === "_") em += 0.48;
490    else em += 0.52;
491  }
492  return em * PILL_FONT;
493}
494
495// The branch as a rounded pill with a branch mark, drawn as an image: a
496// Box's background has square corners. The text is held to the measured
497// width (`textLength`), so the pill fits it with even room at both ends.
498function pill(label, color = BLUE) {
499  const text = clip(label, 32);
500  const tw = Math.ceil(textWidth(text));
501  const w = 19 + tw + 8;
502  const icon = `<g fill="none" stroke="${color}" stroke-width="1.2" stroke-linecap="round"><circle cx="9" cy="5.5" r="1.4"/><circle cx="9" cy="12.5" r="1.4"/><circle cx="14" cy="7" r="1.4"/><path d="M9 6.9v4.2M14 8.4c0 2-2.5 2.2-4.6 3.3"/></g>`;
503  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="18" viewBox="0 0 ${w} 18"><rect x="0.5" y="0.5" width="${w - 1}" height="17" rx="8.5" fill="${color}" fill-opacity="0.13" stroke="${color}" stroke-opacity="0.45"/>${icon}<text x="19" y="12.6" textLength="${tw}" lengthAdjust="spacingAndGlyphs" font-family="-apple-system, 'Segoe UI', Helvetica, Arial, sans-serif" font-size="${PILL_FONT}" fill="${color}">${escapeXml(text)}</text></svg>`;
504  return { svg, width: w };
505}
506
507function pillFor(wt) {
508  if (wt.branch) return { ...pill(wt.branch), alt: `Branch ${wt.branch}` };
509  const label = wt.detachedFrom ? `${wt.detachedFrom} · detached` : `${String(wt.head ?? "").slice(0, 7)} · detached`;
510  return { ...pill(label, wt.detachedFrom ? BLUE : GRAY), alt: `Detached at ${wt.detachedFrom ?? String(wt.head ?? "").slice(0, 7)}` };
511}
512
513function statusColor(repo, wt) {
514  if (wt.error || (repo.fetchError && !wt.isChild)) return RED;
515  if (!wt.branch && !wt.detachedFrom) return GRAY;
516  if (wt.behind || wt.files?.length) return AMBER;
517  return GREEN;
518}
519
520// The one button a row offers, as a source list does: Publish a branch with
521// no upstream, Pull when behind, Push when ahead, Sync otherwise. A detached
522// worktree that is clean and only behind its ref can move up to it.
523function primaryAction(wt) {
524  if (wt.error) return null;
525  if (!wt.branch) {
526    return wt.detachedFrom && wt.behind && !wt.ahead && !wt.files?.length ? { kind: "advance", label: "Pull" } : null;
527  }
528  if (!wt.upstream) return { kind: "push", label: "Publish" };
529  if (wt.behind && !wt.ahead) return { kind: "pull", label: "Pull" };
530  if (wt.ahead && !wt.behind) return { kind: "push", label: "Push" };
531  return { kind: "sync", label: "Sync" };
532}
533
534const BUSY_LABEL = { pull: "Pulling…", push: "Pushing…", sync: "Syncing…", advance: "Pulling…" };
535
536function worktreeRow(ui, $, repo, wt, isChild) {
537  const { Box, Text, Button, Svg } = ui;
538  const busy = state.busy[wt.path];
539  const msg = state.messages[wt.path];
540  const changes = wt.files?.length ?? 0;
541  const isOpen = !!state.expanded[wt.path];
542  const action = primaryAction(wt);
543  // A worktree named after its repo (`WebApp-API-linkedin-spec-725`) drops the
544  // repo's name: it already sits in that repo's card.
545  const own = baseName(wt.path);
546  const name = !isChild ? repo.name
547    : own.toLowerCase().startsWith(`${repo.name.toLowerCase()}-`) ? own.slice(repo.name.length + 1) : own;
548  const others = repo.worktrees.length - 1;
549  const note = wt.error ? shortError(wt.error) : !isChild && repo.fetchError ? `Fetch failed: ${shortError(repo.fetchError)}` : "";
550
551  const line = Box({ key: "line", flexDirection: "row", alignItems: "center", gap: 1, children: [
552    // The dot needs an alt: the desktop draws no image without one.
553    Svg({ key: "dot", source: dot(statusColor(repo, { ...wt, isChild })), alt: STATUS_ALT[statusColor(repo, { ...wt, isChild })], width: 8, height: 8 }),
554    isChild
555      ? Text({ key: "name", wrap: "truncate-end", children: name })
556      : Button({ key: `open:${repo.root}`, label: name, plain: true, onPress: () => pressRepo($, repo.root) }),
557    !isChild && others > 0 ? Text({ key: "count", dimColor: true, children: plural(others, "worktree") }) : null,
558    Box({ key: "gap", flexGrow: 1 }),
559    changes ? Button({ key: `files:${wt.path}`, label: `● ${changes}`, plain: true, dimColor: !isOpen,
560      onPress: () => { state.expanded[wt.path] = !isOpen; $.ui.invalidate("ui.render"); } }) : null,
561    wt.behind ? Text({ key: "behind", color: "warning", bold: true, children: `↓${wt.behind}` }) : null,
562    wt.ahead ? Text({ key: "ahead", color: "suggestion", bold: true, children: `↑${wt.ahead}` }) : null,
563    (() => { const p = pillFor(wt); return Svg({ key: "branch", source: p.svg, alt: p.alt, width: p.width, height: 18 }); })(),
564    // The action button, which also says how its last press went ("Synced ✓")
565    // for a moment, even where no action is left to offer.
566    action || state.flash[wt.path]
567      ? Button({
568          key: `${action?.kind ?? "done"}:${wt.path}`,
569          label: busy ? BUSY_LABEL[busy] : state.flash[wt.path] ?? action.label,
570          variant: "secondary", dimColor: !!busy || !action,
571          onPress: () => { if (action) act($, repo.root, wt, action.kind); },
572        })
573      : null,
574    // Unwatch shows while the pointer is on the row (the keyed "line").
575    !isChild ? Box({ display: "none", hover: { display: "flex" }, children: [
576      Button({ key: `unwatch:${repo.root}`, label: "✕", plain: true, dimColor: true, onPress: () => removeRepo($, repo.root) }),
577    ] }) : null,
578  ].filter(Boolean) });
579
580  // A worktree is a chip of its own inside its repo's card.
581  const chip = isChild ? { borderStyle: "round", borderColor: CHIP_BORDER, paddingX: 1, marginLeft: 2 } : {};
582  return Box({ key: `wt:${wt.path}`, flexDirection: "column", ...chip, children: [
583    line,
584    note ? Text({ key: "note", color: "error", dimColor: true, wrap: "truncate-end", children: `   ${note}` }) : null,
585    msg ? Text({ key: "msg", color: msg.isError ? "error" : undefined, dimColor: !msg.isError, wrap: "truncate-end", children: `   ${msg.text}` }) : null,
586    isOpen && changes ? Box({ key: "files", flexDirection: "column", paddingLeft: 3, children: wt.files.slice(0, MAX_FILES).map((f, n) =>
587      Box({ key: `f${n}`, flexDirection: "row", gap: 1, children: [
588        Text({ key: "code", color: FILE_COLOR[f.code] ?? "subtle", bold: true, children: f.code === "?" ? "U" : f.code }),
589        Text({ key: "path", dimColor: true, wrap: "truncate-start", children: f.path }),
590      ] })) }) : null,
591  ].filter(Boolean) });
592}
593
594function repoCard(ui, $, root) {
595  const { Box, Text } = ui;
596  const repo = state.data[root];
597  const children = repo
598    ? [
599        ...repo.worktrees.slice(0, 1).map((wt) => worktreeRow(ui, $, repo, wt, false)),
600        ...repo.worktrees.slice(1).map((wt) => worktreeRow(ui, $, repo, wt, true)),
601        repo.error && !repo.worktrees.length ? Text({ key: "error", color: "error", dimColor: true, children: `${baseName(root)}: ${shortError(repo.error)}` }) : null,
602      ].filter(Boolean)
603    : [Text({ key: "loading", dimColor: true, children: `${baseName(root)}  ·  loading…` })];
604  const hasChips = (repo?.worktrees.length ?? 0) > 1;
605  // Even room on every side, so a row's button never meets the border.
606  return Box({ key: `repo:${root}`, flexDirection: "column", gap: hasChips ? 1 : 0, padding: 1, borderStyle: "round", borderColor: CHIP_BORDER, children });
607}
608
609// The unwatched repos on this computer, as one menu; picking one watches it.
610function addMenu(ui, $) {
611  const { Select } = ui;
612  if (!Select) return null;
613  const candidates = state.found.filter((f) => !isWatched(f.path));
614  const parent = (p) => baseName(norm(p).slice(0, norm(p).lastIndexOf("/")));
615  const options = [
616    { value: "", label: state.isScanning ? "Looking for repos…" : candidates.length ? `Add repo (${candidates.length})` : "Add repo" },
617    ...candidates.map((f) => ({ value: f.path, label: `${baseName(f.path)}  ·  ${parent(f.path)}` })),
618    { value: "__rescan__", label: "Rescan this computer" },
619  ];
620  return Select({ key: "add", options, value: "", onSelect: (value) => {
621    if (value === "__rescan__") return discover($, { force: true });
622    if (value) return addRepo($, value);
623  } });
624}
625
626function paneView($, e) {
627  const ui = $.ui.resolve(e);
628  const { Box, Text, Button } = ui;
629  const behind = Object.values(state.data).reduce((n, r) => n + (r.worktrees ?? []).reduce((m, wt) => m + (wt.behind || 0), 0), 0);
630  const sub = [
631    plural(state.repos.length, "repo"),
632    behind ? `${behind} behind` : "",
633    state.isRefreshing ? "refreshing…" : timeOf(state.refreshedAt),
634  ].filter(Boolean).join(" · ");
635
636  const header = Box({ key: "header", flexDirection: "row", alignItems: "center", gap: 1, paddingX: 1, children: [
637    Text({ key: "title", bold: true, children: "Repos" }),
638    Text({ key: "sub", dimColor: true, children: sub }),
639    Box({ key: "gap", flexGrow: 1 }),
640    addMenu(ui, $),
641    Button({ key: "refresh", label: "Refresh", variant: "secondary", dimColor: state.isRefreshing, onPress: () => refreshAll($) }),
642  ].filter(Boolean) });
643
644  return Box({ flexDirection: "column", gap: 1, padding: 1, children: [
645    header,
646    state.addError ? Text({ key: "add-error", color: "error", dimColor: true, children: `  ${state.addError}` }) : null,
647    ...(state.repos.length
648      ? state.repos.map((root) => repoCard(ui, $, root))
649      : [Text({ key: "empty", dimColor: true, children: "  No repos yet. Add one from the menu." })]),
650  ].filter(Boolean) });
651}
652
653// ── Hooks ─────────────────────────────────────────────────────────────────
654
655let stopTimer;
656
657export function register(on) {
658  on("session.start", async ($, e, next) => {
659    const r = await next(e);
660    await $.command.register({ name: "repos", description: "Repo Worktree: show the repos you watch" });
661    await $.command.register({ name: "watch", description: "Repo Worktree: watch a repo (this folder, or a path)", argumentHint: "[path]" });
662    try {
663      const saved = await $.store.get(STORE_REPOS);
664      state.repos = Array.isArray(saved) ? saved.map(norm) : [];
665      const dismissed = await $.store.get(STORE_DISMISSED);
666      state.dismissed = Array.isArray(dismissed) ? dismissed.map(norm) : [];
667      state.isOpen = (await $.store.get(STORE_OPEN)) === true;
668      const found = await $.store.get(STORE_FOUND);
669      if (found && Array.isArray(found.repos)) { state.found = found.repos; state.foundAt = Number(found.at) || 0; }
670    } catch { /* start empty */ }
671    state.isLoaded = true;
672    if (state.isOpen) { try { await openPane($, false); } catch { /* no room yet */ } }
673    // The chat's folder first: its repos are watched on their own. Then the
674    // rest refresh, and the computer is searched for more when it's due.
675    void (async () => {
676      try { await watchFolder($, e.cwd); } catch { /* a folder we can't read */ }
677      try { await refreshAll($); } catch { /* shown per repo */ }
678      try { await discover($); } catch { /* offered again on Rescan */ }
679    })();
680    stopTimer?.();
681    const timer = $.clock.every(REFRESH_MS, () => { void refreshAll($); });
682    stopTimer = () => timer.cancel();
683    return r;
684  });
685
686  on("command.run", { command: "repos" }, async ($) => {
687    await openPane($, true);
688    void refreshAll($);
689    void discover($);
690    return { text: `Repos: ${plural(state.repos.length, "repo")} watched` };
691  });
692
693  // `/watch` alone: every repo in the chat's folder; `/watch <path>`: that one.
694  on("command.run", { command: "watch" }, async ($, e) => {
695    const arg = e.args?.trim();
696    if (arg) {
697      await addRepo($, arg);
698      if (state.addError) return { text: `Repos: ${state.addError}` };
699      await openPane($, true);
700      return { text: `Repos: watching ${baseName(state.repos[state.repos.length - 1])}` };
701    }
702    const { added, found } = await watchFolder($, await $.session.cwd(), { isManual: true });
703    await openPane($, true);
704    if (!found) return { text: "Repos: no repos in this folder" };
705    return { text: added.length ? `Repos: watching ${added.map(baseName).join(", ")}` : "Repos: every repo in this folder is already watched" };
706  });
707
708  on("ui.render", { component: "Pane" }, ($, e, next) => {
709    if (e.requestId !== PANE_ID) return next(e);
710    return paneView($, e);
711  });
712
713  // The person closed the pane: remember it closed for next time.
714  on("ui.close", async ($, e, next) => {
715    if (e.id === PANE_ID || e.requestId === PANE_ID) { state.isOpen = false; await save($); }
716    return next(e);
717  });
718}
719