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…

Claude Code mods (function-hook plugins), tuned to look and work well in the Claude desktop app as well as the terminal.
| Mod | What it does | Based on |
|---|---|---|
replay-theater | A 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-worktree | A 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. | — |
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.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'sversionand reinstall, or copy the changed files into that folder, then/reload-plugins.
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.
hooks/repo-worktree.mjs 719 lines1// 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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