SLOPSHOPPER

repo-changes

Pane with git changes and diffs for every repo (and .claude/worktrees) inside a folder that is not a git repo

newpaneguardcommandprocesstimer
v0.3.0MITupdated 2026-10-15leonardogub-singular/claude-repo-changes
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · repo-changes
│ ┃ Repo changes ✕ › fix the failing auth test and add an audit log call │ ┃ 0 files in 0 repos 14:20 [ ↻ ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Filter files ⏎ ↵ [ All ] [ Tracked ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Flat ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ No changes in any repo. ⎿ 3 pass, 1 fail │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /repo-changes │ ⎿ repo-changes: Repo changes: 0 files in 0 repos. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Repo changes
0 files in 0 repos 14:20 [ ↻ ] Filter files ⏎ ↵ [ All ] [ Tracked ] Flat No changes in any repo.
README

repo-changes

A Claude Code mod that shows git changes for many repos at once.

Claude Code's Changes pane reads git state from the session folder. When you open a session in a folder that holds several repos side by side (a workspace, not a repo), that pane stays empty. This mod adds a Repo changes pane that scans every child repo, and every worktree under <repo>/.claude/worktrees/, and lists what changed.

What it shows

  • Every repo with uncommitted changes, sorted by name, with its worktrees nested under it.
  • The branch name, and how many commits it is ahead of (↑) or behind (↓) its upstream.
  • Counts per repo: modified, new, deleted and conflicted files.
  • Files under their folder. A big folder (12+ files), or a folder of only new files, folds into one row that you can open.
  • A Flat view: one row per file, its folder after the name. The pane remembers your choice.
  • A filter field, and an All / Tracked switch that hides untracked files.
  • Click a file to see its diff. Use ↑ / ↓ (or k / j) to move between files, and "‹ All files" (or b) to go back.

The pane refreshes after Claude edits a file or runs a command, when a turn ends, and every 30 seconds. The ↻ button refreshes it at once.

Install

Type this at the prompt of a terminal claude session:

/plugin install repo-changes --marketplace leonardogub-singular/claude-repo-changes

Answer y to add the marketplace, then pick the user scope. A mod installed at the user scope also loads in the Claude desktop app's Code tab.

The mod needs a Claude Code build with mods (function hooks). That API is early access and can change between releases.

Use

  • In a session whose folder is not a git repo, the pane opens by itself.
  • Anywhere else, type /repo-changes to open it.

The mod looks at the folder the session started in. A cd in the shell does not change it.

Limits

  • The colors are made for a dark theme. In a light theme the pane looks wrong.
  • The pane uses the app's font and text size. A mod cannot change them.
  • A repo shows 100 rows at first. A "Show 100 more" button under the list shows the next 100.
  • Diffs are against HEAD. Committed work on a branch does not show.

Develop

claude plugin validate .
claude plugin test .

hooks/logic.ts holds the parsing and grouping, with tests in tests/logic.test.ts. hooks/register.tsx holds the hooks and the pane drawing.

License

MIT

Source 3 files
hooks/register.tsx 524 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Repo, RepoFile, Selected } from '../types'
4import {
5  COLORS,
6  badgeOf,
7  buildRows,
8  countKinds,
9  diffStats,
10  flatRows,
11  groupByOwner,
12  matchesFilter,
13  parseBranch,
14  parseStatus,
15  sortFiles,
16  splitPath,
17  toHunks,
18  WORKTREE_SEP,
19} from './logic'
20
21const PANE = 'repo-changes'
22const TITLE = 'Repo changes'
23const POLL_MS = 30_000
24const ROWS_PAGE = 100
25const INDENT = 2
26const AUTO_OPEN_MAX_REPOS = 2
27const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash', 'MultiEdit'])
28
29const REPOS = { plugin: 'repo-changes', key: 'repos' } as const
30const CLEAN_COUNT = { plugin: 'repo-changes', key: 'cleanCount' } as const
31const SELECTED = { plugin: 'repo-changes', key: 'selected' } as const
32const UPDATED_AT = { plugin: 'repo-changes', key: 'updatedAt' } as const
33const OPEN_REPOS = { plugin: 'repo-changes', key: 'openRepos' } as const
34const OPEN_GROUPS = { plugin: 'repo-changes', key: 'openGroups' } as const
35const FILTER = { plugin: 'repo-changes', key: 'filter' } as const
36const TRACKED_ONLY = { plugin: 'repo-changes', key: 'isTrackedOnly' } as const
37const LAST_VIEWED = { plugin: 'repo-changes', key: 'lastViewed' } as const
38const IS_FLAT = { plugin: 'repo-changes', key: 'isFlat' } as const
39const STORE_IS_FLAT = 'isFlat'
40const ROW_LIMITS = { plugin: 'repo-changes', key: 'rowLimits' } as const
41
42type Target = { path: string; label: string }
43
44async function git($: EngineInterface, cwd: string, args: string[]) {
45  try {
46    return await $.process.run(['git', ...args], { cwd, timeoutMs: 15_000 })
47  } catch {
48    return undefined
49  }
50}
51
52async function findTargets($: EngineInterface, root: string): Promise<Target[]> {
53  const targets: Target[] = []
54  if (await $.fs.exists(`${root}/.git`)) targets.push({ path: root, label: '.' })
55  for (const entry of await $.fs.list(root)) {
56    if (entry.kind !== 'dir' || entry.name.startsWith('.')) continue
57    const dir = `${root}/${entry.name}`
58    if (!(await $.fs.exists(`${dir}/.git`))) continue
59    targets.push({ path: dir, label: entry.name })
60    const worktrees = `${dir}/.claude/worktrees`
61    if (!(await $.fs.exists(worktrees))) continue
62    for (const wt of await $.fs.list(worktrees)) {
63      if (wt.kind !== 'dir') continue
64      if (!(await $.fs.exists(`${worktrees}/${wt.name}/.git`))) continue
65      targets.push({ path: `${worktrees}/${wt.name}`, label: `${entry.name} › ${wt.name}` })
66    }
67  }
68  return targets
69}
70
71async function loadDiff($: EngineInterface, repoPath: string, file: RepoFile) {
72  const args =
73    file.status === '??'
74      ? ['diff', '--no-color', '--no-index', '--', '/dev/null', file.path]
75      : ['diff', '--no-color', 'HEAD', '--', file.path]
76  const ran = await git($, repoPath, args)
77  if (!ran) return { source: 'git diff failed.', isDiff: false, adds: 0, dels: 0 }
78  if (!ran.stdout.trim()) {
79    return { source: ran.stderr.trim() || 'No text changes.', isDiff: false, adds: 0, dels: 0 }
80  }
81  const hunks = toHunks(ran.stdout)
82  return { ...hunks, ...(hunks.isDiff ? diffStats(hunks.source) : { adds: 0, dels: 0 }) }
83}
84
85let isRefreshing = false
86let isPending = false
87
88async function refresh($: EngineInterface): Promise<void> {
89  if (isRefreshing) {
90    isPending = true
91    return
92  }
93  isRefreshing = true
94  try {
95    const root = await $.session.root()
96    const targets = await findTargets($, root)
97    const results = await Promise.all(
98      targets.map(async (t): Promise<Repo | undefined> => {
99        const ran = await git($, t.path, ['status', '--porcelain=v1', '-b', '-z', '--untracked-files=all'])
100        if (!ran || ran.exitCode !== 0) return undefined
101        const { branch, files } = parseStatus(ran.stdout)
102        return { path: t.path, label: t.label, branch, files: sortFiles(files) }
103      }),
104    )
105    const found = results.filter((r): r is Repo => r !== undefined)
106    const changed = found
107      .filter(r => r.files.length > 0)
108      .sort((a, b) => b.files.length - a.files.length)
109    await $.state.set(REPOS, changed)
110    await $.state.set(CLEAN_COUNT, found.length - changed.length)
111    await $.state.set(UPDATED_AT, Date.now())
112
113    const { value: sel } = await $.state.get(SELECTED)
114    if (sel) {
115      const repo = changed.find(r => r.path === sel.repoPath)
116      const file = repo?.files.find(f => f.path === sel.file.path)
117      if (!file) {
118        await $.state.set(SELECTED, null)
119      } else {
120        const diff = await loadDiff($, sel.repoPath, file)
121        await $.state.set(SELECTED, { ...sel, file, diff: diff.source, isDiff: diff.isDiff, adds: diff.adds, dels: diff.dels })
122      }
123    }
124  } finally {
125    isRefreshing = false
126    if (isPending) {
127      isPending = false
128      void refresh($)
129    }
130  }
131}
132
133async function select($: EngineInterface, repo: Repo, file: RepoFile) {
134  const diff = await loadDiff($, repo.path, file)
135  const next: Selected = {
136    repoPath: repo.path,
137    repoLabel: repo.label,
138    file,
139    diff: diff.source,
140    isDiff: diff.isDiff,
141    adds: diff.adds,
142    dels: diff.dels,
143  }
144  await $.state.set(SELECTED, next)
145  await $.state.set(LAST_VIEWED, `${repo.path}::${file.path}`)
146}
147
148async function toggleRepo($: EngineInterface, path: string, shown: string[]) {
149  const next = shown.includes(path) ? shown.filter(p => p !== path) : [...shown, path]
150  await $.state.set(OPEN_REPOS, next)
151}
152
153async function toggleGroup($: EngineInterface, key: string) {
154  const { value: open = [] } = await $.state.get(OPEN_GROUPS)
155  await $.state.set(OPEN_GROUPS, open.includes(key) ? open.filter(k => k !== key) : [...open, key])
156}
157
158async function toggleFlat($: EngineInterface) {
159  const { value: isFlat = false } = await $.state.get(IS_FLAT)
160  await $.state.set(IS_FLAT, !isFlat)
161  await $.store.set(STORE_IS_FLAT, !isFlat)
162}
163
164async function showMore($: EngineInterface, repoPath: string) {
165  const { value: limits = {} } = await $.state.get(ROW_LIMITS)
166  await $.state.set(ROW_LIMITS, { ...limits, [repoPath]: (limits[repoPath] ?? ROWS_PAGE) + ROWS_PAGE })
167}
168
169function plural(n: number, one: string, many: string) {
170  return `${n} ${n === 1 ? one : many}`
171}
172
173export const register: Register = on => {
174  on('session.start', async ($, e, next) => {
175    await $.command.register({
176      name: 'repo-changes',
177      description: 'Show git changes of every repo inside this folder',
178    })
179    await $.state.set(IS_FLAT, (await $.store.get(STORE_IS_FLAT)) === true)
180    const root = await $.session.root()
181    if (!(await $.fs.exists(`${root}/.git`))) {
182      void refresh($).then(() => $.ui.open({ id: PANE, title: TITLE }))
183      $.clock.every(POLL_MS, () => void refresh($))
184    }
185    return next(e)
186  })
187
188  on('command.run', { command: 'repo-changes' }, async $ => {
189    await refresh($)
190    await $.ui.open({ id: PANE, title: TITLE })
191    const { value: list = [] } = await $.state.get(REPOS)
192    const files = list.reduce((n, r) => n + r.files.length, 0)
193    return { text: `Repo changes: ${files} files in ${list.length} repos.` }
194  })
195
196  on('tool.call', async ($, e, next) => {
197    const ran = await next(e)
198    if (EDIT_TOOLS.has(String(e.tool))) void refresh($)
199    return ran
200  })
201
202  on('turn.complete', async ($, e, next) => {
203    const done = await next(e)
204    void refresh($)
205    return done
206  })
207
208  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
209    const elements = $.ui.resolve(e)
210    const { Box, Text, Button, Code } = elements
211    const Input = 'Input' in elements ? elements.Input : undefined
212    const { value: all = [] } = await $.state.get(REPOS)
213    const { value: clean = 0 } = await $.state.get(CLEAN_COUNT)
214    const { value: at = 0 } = await $.state.get(UPDATED_AT)
215    const { value: sel = null } = await $.state.get(SELECTED)
216    const { value: openReposRaw = null } = await $.state.get(OPEN_REPOS)
217    const { value: openGroupList = [] } = await $.state.get(OPEN_GROUPS)
218    const { value: filter = '' } = await $.state.get(FILTER)
219    const { value: isTrackedOnly = false } = await $.state.get(TRACKED_ONLY)
220    const { value: lastViewed = null } = await $.state.get(LAST_VIEWED)
221    const { value: isFlat = false } = await $.state.get(IS_FLAT)
222    const { value: rowLimits = {} } = await $.state.get(ROW_LIMITS)
223
224    const time = at ? new Date(at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '…'
225    const isFiltering = filter.trim() !== ''
226    const repos = all
227      .map(r => ({ ...r, files: r.files.filter(f => matchesFilter(f, filter.trim(), isTrackedOnly)) }))
228      .filter(r => r.files.length > 0)
229    const allFiles = repos.flatMap(r => r.files)
230    const totals = countKinds(allFiles)
231    const openRepos = openReposRaw ?? (repos.length <= AUTO_OPEN_MAX_REPOS ? repos.map(r => r.path) : [])
232    const isAllOpen = repos.length > 0 && repos.every(r => openRepos.includes(r.path))
233
234    const renderRepo = (repo: Repo, depth: number) => {
235      const isOpen = isFiltering || openRepos.includes(repo.path)
236      const branch = parseBranch(repo.branch)
237      const counts = countKinds(repo.files)
238      const [owner = repo.label, worktree] = repo.label.split(WORKTREE_SEP)
239      const headPad = INDENT * depth
240      const pad = headPad + INDENT * 2
241      const openGroups = new Set(
242        isFiltering
243          ? repo.files.map(f => splitPath(f.path).dir)
244          : openGroupList.filter(k => k.startsWith(`${repo.path}::`)).map(k => k.slice(repo.path.length + 2)),
245      )
246      const rows = !isOpen ? [] : isFlat ? flatRows(repo.files) : buildRows(repo.files, openGroups)
247      const shown = rows.slice(0, rowLimits[repo.path] ?? ROWS_PAGE)
248      const hidden = rows.length - shown.length
249
250      return (
251        <Box key={repo.path} flexDirection="column">
252          <Box
253            key={`head:${repo.path}`}
254            flexDirection="row"
255            gap={1}
256            paddingLeft={headPad}
257            paddingRight={1}
258            hover={{ backgroundColor: COLORS.hover }}
259          >
260            <Text color={COLORS.faint}>{isOpen ? '▾' : '▸'}</Text>
261            <Box flexShrink={0}>
262              {worktree ? (
263                <Button key={`repo:${repo.path}`} plain label={worktree} onPress={() => void toggleRepo($, repo.path, openRepos)} />
264              ) : (
265                <Button key={`repo:${repo.path}`} plain onPress={() => void toggleRepo($, repo.path, openRepos)}>
266                  <Text bold color={COLORS.text}>
267                    {owner}
268                  </Text>
269                </Button>
270              )}
271            </Box>
272            {branch.name && !(worktree && branch.name.endsWith(worktree)) && (
273              <Text color={COLORS.muted} backgroundColor={COLORS.chip} wrap="truncate-end">
274                {` ${branch.name} `}
275              </Text>
276            )}
277            {branch.ahead > 0 && (
278              <Box flexShrink={0}>
279                <Text color={COLORS.ahead} backgroundColor={COLORS.aheadBg} wrap="truncate-end">
280                  {` ↑${branch.ahead} `}
281                </Text>
282              </Box>
283            )}
284            {branch.behind > 0 && (
285              <Box flexShrink={0}>
286                <Text color={COLORS.behind} backgroundColor={COLORS.behindBg} wrap="truncate-end">
287                  {` ↓${branch.behind} `}
288                </Text>
289              </Box>
290            )}
291            <Box flexGrow={1} />
292            <Box flexShrink={0} flexDirection="row" gap={1}>
293              {counts.modified > 0 && <Text color={COLORS.modified}>{counts.modified}</Text>}
294              {counts.deleted > 0 && <Text color={COLORS.deleted}>−{counts.deleted}</Text>}
295              {counts.conflict > 0 && <Text color={COLORS.conflict}>!{counts.conflict}</Text>}
296              {counts.new > 0 && <Text color={COLORS.new}>+{counts.new}</Text>}
297            </Box>
298          </Box>
299
300          {shown.map(row => {
301            if (row.type === 'dir') {
302              return (
303                <Box paddingLeft={pad}>
304                  <Text color={COLORS.path} wrap="truncate-start">
305                    {row.dir}
306                  </Text>
307                </Box>
308              )
309            }
310            if (row.type === 'group') {
311              const c = countKinds(row.files)
312              return (
313                <Box
314                  key={`grow:${repo.path}::${row.dir}`}
315                  paddingLeft={pad}
316                  paddingRight={1}
317                  flexDirection="row"
318                  gap={1}
319                  hover={{ backgroundColor: COLORS.hover }}
320                >
321                  <Text color={COLORS.faint}>▸</Text>
322                  <Button key={`group:${repo.path}::${row.dir}`} plain label={row.dir} onPress={() => void toggleGroup($, `${repo.path}::${row.dir}`)} />
323                  <Box flexGrow={1} />
324                  {c.modified > 0 && <Text color={COLORS.modified}>{c.modified}</Text>}
325                  {c.deleted > 0 && <Text color={COLORS.deleted}>−{c.deleted}</Text>}
326                  {c.new > 0 && <Text color={COLORS.new}>+{c.new}</Text>}
327                </Box>
328              )
329            }
330            const badge = badgeOf(row.file.status)
331            const rowKey = `${repo.path}::${row.file.path}`
332            return (
333              <Box
334                key={`frow:${rowKey}`}
335                paddingLeft={pad}
336                paddingRight={1}
337                flexDirection="row"
338                gap={1}
339                backgroundColor={rowKey === lastViewed ? COLORS.selected : undefined}
340                hover={{ backgroundColor: rowKey === lastViewed ? COLORS.selected : COLORS.hover }}
341              >
342                <Box width={2} flexShrink={0}>
343                  <Text color={badge.color} bold>
344                    {badge.glyph}
345                  </Text>
346                </Box>
347                <Box flexShrink={0}>
348                  <Button key={`file:${repo.path}::${row.file.path}`} plain label={row.name} onPress={() => void select($, repo, row.file)} />
349                </Box>
350                {isFlat && (
351                  <Text color={COLORS.path} wrap="truncate-start">
352                    {splitPath(row.file.path).dir}
353                  </Text>
354                )}
355              </Box>
356            )
357          })}
358          {hidden > 0 && (
359            <Box paddingLeft={pad} flexDirection="row" gap={1}>
360              <Button
361                key={`more:${repo.path}`}
362                plain
363                label={`Show ${Math.min(hidden, ROWS_PAGE)} more`}
364                onPress={() => void showMore($, repo.path)}
365              />
366              <Text color={COLORS.faint}>of {hidden} hidden</Text>
367            </Box>
368          )}
369        </Box>
370      )
371    }
372
373    if (sel) {
374      const repo = all.find(r => r.path === sel.repoPath)
375      const order = repo ? repo.files : [sel.file]
376      const index = Math.max(0, order.findIndex(f => f.path === sel.file.path))
377      const prev = order[index - 1]
378      const next = order[index + 1]
379      const badge = badgeOf(sel.file.status)
380      const { dir, name } = splitPath(sel.file.path)
381      const nextParts = next ? splitPath(next.path) : undefined
382      const nextBadge = next ? badgeOf(next.status) : undefined
383
384      return (
385        <Box flexDirection="column" gap={1}>
386          <Box flexDirection="row" gap={1} alignItems="center">
387            <Button key="back" label="‹ All files" hotkey="b" onPress={() => void $.state.set(SELECTED, null)} />
388            <Box flexGrow={1} />
389            <Text color={COLORS.faint}>
390              {index + 1} of {order.length}
391            </Text>
392            {prev && repo && <Button key="prev" label="↑" hotkey="k" onPress={() => void select($, repo, prev)} />}
393            {next && repo && <Button key="next" label="↓" hotkey="j" onPress={() => void select($, repo, next)} />}
394          </Box>
395          <Box flexDirection="column">
396            <Box flexDirection="row" gap={1}>
397              <Text color={badge.color} bold>
398                {badge.glyph}
399              </Text>
400              <Text bold color={COLORS.text} wrap="truncate-middle">
401                {name}
402              </Text>
403              <Box flexGrow={1} />
404              {sel.isDiff && <Text color={COLORS.ahead}>+{sel.adds}</Text>}
405              {sel.isDiff && <Text color={COLORS.deleted}>−{sel.dels}</Text>}
406            </Box>
407            <Box paddingLeft={2}>
408              <Text color={COLORS.path} wrap="truncate-start">
409                {sel.repoLabel} / {dir || './'}
410              </Text>
411            </Box>
412          </Box>
413          {sel.isDiff ? (
414            <Code source={sel.diff} format="diff" path={sel.file.path} />
415          ) : (
416            <Text color={COLORS.muted}>{sel.diff}</Text>
417          )}
418          {next && repo && nextParts && nextBadge && (
419            <Box flexDirection="row" gap={1}>
420              <Text color={COLORS.faint}>Next</Text>
421              <Text color={nextBadge.color} bold>
422                {nextBadge.glyph}
423              </Text>
424              <Button key="next-file" plain label={nextParts.name} onPress={() => void select($, repo, next)} />
425              <Text color={COLORS.path} wrap="truncate-start">
426                {nextParts.dir}
427              </Text>
428            </Box>
429          )}
430        </Box>
431      )
432    }
433
434    return (
435      <Box flexDirection="column" gap={1}>
436        <Box flexDirection="column" gap={1}>
437          <Box flexDirection="row" gap={1} alignItems="center">
438            <Text bold color={COLORS.text}>
439              {plural(allFiles.length, 'file', 'files')}
440            </Text>
441            <Text color={COLORS.muted}>in {plural(repos.length, 'repo', 'repos')}</Text>
442            <Box flexGrow={1} />
443            <Text color={COLORS.faint}>{time}</Text>
444            <Button key="refresh" label="↻" onPress={() => void refresh($)} />
445          </Box>
446          <Box flexDirection="row" gap={1} alignItems="center">
447            {Input && (
448            <Box flexGrow={1}>
449              <Input
450                key="filter"
451                placeholder="Filter files"
452                value={filter}
453                submitLabel="↵"
454                onInput={(value: string) => void $.state.set(FILTER, value)}
455                onSubmit={(value: string) => void $.state.set(FILTER, value)}
456              />
457            </Box>
458            )}
459            <Button
460              key="show-all"
461              label="All"
462              variant={isTrackedOnly ? 'secondary' : 'primary'}
463              onPress={() => void $.state.set(TRACKED_ONLY, false)}
464            />
465            <Button
466              key="show-tracked"
467              label="Tracked"
468              variant={isTrackedOnly ? 'primary' : 'secondary'}
469              onPress={() => void $.state.set(TRACKED_ONLY, true)}
470            />
471          </Box>
472          <Box flexDirection="row" gap={2} alignItems="center">
473            <Text color={COLORS.muted} wrap="truncate-end">
474              {totals.modified > 0 ? <Text color={COLORS.modified}>● </Text> : ''}
475              {totals.modified > 0 ? `${totals.modified} modified   ` : ''}
476              {totals.new > 0 ? <Text color={COLORS.new}>● </Text> : ''}
477              {totals.new > 0 ? `${totals.new} new   ` : ''}
478              {totals.deleted > 0 ? <Text color={COLORS.deleted}>● </Text> : ''}
479              {totals.deleted > 0 ? `${totals.deleted} deleted   ` : ''}
480              {totals.conflict > 0 ? <Text color={COLORS.conflict}>● </Text> : ''}
481              {totals.conflict > 0 ? `${totals.conflict} conflict` : ''}
482            </Text>
483            <Box flexGrow={1} />
484            <Button key="toggle-flat" plain label={isFlat ? 'Folders' : 'Flat'} onPress={() => void toggleFlat($)} />
485            {repos.length > 0 && (
486              <Button
487                key="toggle-all"
488                plain
489                label={isAllOpen ? 'Collapse' : 'Expand'}
490                onPress={() => void $.state.set(OPEN_REPOS, isAllOpen ? [] : repos.map(r => r.path))}
491              />
492            )}
493          </Box>
494        </Box>
495
496        {repos.length === 0 && (
497          <Text color={COLORS.muted}>{isFiltering || isTrackedOnly ? 'No file matches.' : 'No changes in any repo.'}</Text>
498        )}
499
500        <Box flexDirection="column">
501          {groupByOwner(repos).map(group => (
502            <Box key={`owner:${group.owner}`} flexDirection="column" marginBottom={1}>
503              {group.main ? (
504                renderRepo(group.main, 0)
505              ) : (
506                <Box flexDirection="row" gap={1}>
507                  <Text color={COLORS.faint}>·</Text>
508                  <Text bold color={COLORS.text}>
509                    {group.owner}
510                  </Text>
511                  <Text color={COLORS.faint}>clean</Text>
512                </Box>
513              )}
514              {group.worktrees.map(worktree => renderRepo(worktree, 1))}
515            </Box>
516          ))}
517        </Box>
518
519        {!isFiltering && clean > 0 && <Text color={COLORS.faint}>{plural(clean, 'repo', 'repos')} clean</Text>}
520      </Box>
521    )
522  })
523}
524
hooks/logic.ts 234 lines
1import type { RepoFile } from '../types'
2
3export const MAX_DIFF_CHARS = 90_000
4const GROUP_MIN_FILES = 12
5const GROUP_MIN_NEW = 3
6const GROUP_DESCEND_SHARE = 0.8
7
8export const COLORS = {
9  modified: '#d9a04e',
10  new: '#7fa9e6',
11  deleted: '#e5776b',
12  conflict: '#c792ea',
13  ahead: '#7fbf8e',
14  aheadBg: '#1f3326',
15  behind: '#e0a370',
16  behindBg: '#3a2c20',
17  accent: '#d97757',
18  text: '#ebe8e3',
19  muted: '#9a968f',
20  faint: '#77736c',
21  path: '#86827b',
22  chip: '#2a2927',
23  hover: '#2c2b29',
24  selected: '#33302c',
25}
26
27export type Kind = 'modified' | 'new' | 'deleted' | 'conflict'
28
29export function kindOf(status: string): Kind {
30  if (status === '??' || status.includes('A')) return 'new'
31  if (status.includes('U') || status === 'AA' || status === 'DD') return 'conflict'
32  if (status.includes('D')) return 'deleted'
33  return 'modified'
34}
35
36export function badgeOf(status: string): { glyph: string; color: string } {
37  const kind = kindOf(status)
38  if (kind === 'new') return { glyph: '+', color: COLORS.new }
39  if (kind === 'deleted') return { glyph: '−', color: COLORS.deleted }
40  if (kind === 'conflict') return { glyph: '!', color: COLORS.conflict }
41  if (status.includes('R')) return { glyph: 'R', color: COLORS.modified }
42  return { glyph: 'M', color: COLORS.modified }
43}
44
45export function parseStatus(stdout: string): { branch: string; files: RepoFile[] } {
46  const parts = stdout.split('\0')
47  let branch = ''
48  const files: RepoFile[] = []
49  for (let i = 0; i < parts.length; i++) {
50    const part = parts[i]
51    if (!part) continue
52    if (part.startsWith('## ')) {
53      branch = part.slice(3)
54      continue
55    }
56    const status = part.slice(0, 2)
57    files.push({ status: status.trim() || status, path: part.slice(3) })
58    if (status[0] === 'R' || status[0] === 'C') i++
59  }
60  return { branch, files }
61}
62
63export type Branch = { name: string; ahead: number; behind: number }
64
65export function parseBranch(raw: string): Branch {
66  const ahead = Number(/ahead (\d+)/.exec(raw)?.[1] ?? 0)
67  const behind = Number(/behind (\d+)/.exec(raw)?.[1] ?? 0)
68  let name = raw.replace(/ \[.*\]$/, '')
69  if (name.startsWith('No commits yet on ')) name = name.slice('No commits yet on '.length)
70  name = name.split('...')[0] ?? name
71  return { name, ahead, behind }
72}
73
74export type Counts = { modified: number; new: number; deleted: number; conflict: number }
75
76export function countKinds(files: RepoFile[]): Counts {
77  const counts: Counts = { modified: 0, new: 0, deleted: 0, conflict: 0 }
78  for (const file of files) counts[kindOf(file.status)]++
79  return counts
80}
81
82export function splitPath(path: string): { dir: string; name: string } {
83  const cut = path.lastIndexOf('/', path.endsWith('/') ? path.length - 2 : path.length)
84  return cut < 0 ? { dir: '', name: path } : { dir: path.slice(0, cut + 1), name: path.slice(cut + 1) }
85}
86
87export type Row =
88  | { type: 'dir'; dir: string }
89  | { type: 'file'; file: RepoFile; name: string }
90  | { type: 'group'; dir: string; files: RepoFile[] }
91
92function ancestors(path: string): string[] {
93  const parts = path.split('/')
94  const out: string[] = []
95  for (let i = 1; i < parts.length; i++) {
96    const dir = parts.slice(0, i).join('/') + '/'
97    if (dir !== '/') out.push(dir)
98  }
99  return out
100}
101
102function groupDirOf(path: string, total: Map<string, number>, fresh: Map<string, number>) {
103  const dirs = ancestors(path)
104  const isFoldable = (d: string) => {
105    const n = total.get(d) ?? 0
106    return n >= GROUP_MIN_FILES || (n >= GROUP_MIN_NEW && fresh.get(d) === n)
107  }
108  let at = dirs.findIndex(isFoldable)
109  if (at < 0) return undefined
110  while (at + 1 < dirs.length) {
111    const here = total.get(dirs[at] as string) ?? 0
112    const below = total.get(dirs[at + 1] as string) ?? 0
113    if (below < here * GROUP_DESCEND_SHARE) break
114    at++
115  }
116  return dirs[at]
117}
118
119export function sortFiles(files: RepoFile[]): RepoFile[] {
120  return [...files].sort((a, b) => {
121    const pa = splitPath(a.path)
122    const pb = splitPath(b.path)
123    if (!pa.dir !== !pb.dir) return pa.dir ? 1 : -1
124    return pa.dir === pb.dir ? pa.name.localeCompare(pb.name) : pa.dir.localeCompare(pb.dir)
125  })
126}
127
128export function buildRows(files: RepoFile[], openGroups: ReadonlySet<string>): Row[] {
129  const total = new Map<string, number>()
130  const fresh = new Map<string, number>()
131  for (const file of files) {
132    for (const dir of ancestors(file.path)) {
133      total.set(dir, (total.get(dir) ?? 0) + 1)
134      if (file.status === '??') fresh.set(dir, (fresh.get(dir) ?? 0) + 1)
135    }
136  }
137
138  const groups = new Map<string, RepoFile[]>()
139  const loose: RepoFile[] = []
140  for (const file of files) {
141    const dir = groupDirOf(file.path, total, fresh)
142    if (dir === undefined) {
143      loose.push(file)
144      continue
145    }
146    groups.set(dir, [...(groups.get(dir) ?? []), file])
147  }
148  for (const [dir, members] of groups) {
149    if (members.length < GROUP_MIN_NEW || openGroups.has(dir)) {
150      loose.push(...members)
151      groups.delete(dir)
152    }
153  }
154
155  type Item = { sortDir: string; sortName: string; row: Row }
156  const items: Item[] = loose.map(file => {
157    const { dir, name } = splitPath(file.path)
158    return { sortDir: dir, sortName: name, row: { type: 'file', file, name } }
159  })
160  for (const [dir, members] of groups) {
161    items.push({ sortDir: dir, sortName: '', row: { type: 'group', dir, files: members } })
162  }
163  items.sort((a, b) => {
164    if (!a.sortDir !== !b.sortDir) return a.sortDir ? 1 : -1
165    return a.sortDir === b.sortDir
166      ? a.sortName.localeCompare(b.sortName)
167      : a.sortDir.localeCompare(b.sortDir)
168  })
169
170  const rows: Row[] = []
171  let lastDir: string | undefined = ''
172  for (const item of items) {
173    if (item.row.type === 'group') {
174      rows.push(item.row)
175      lastDir = undefined
176      continue
177    }
178    if (item.sortDir !== lastDir && item.sortDir) rows.push({ type: 'dir', dir: item.sortDir })
179    lastDir = item.sortDir
180    rows.push(item.row)
181  }
182  return rows
183}
184
185export function toHunks(raw: string): { source: string; isDiff: boolean } {
186  const start = raw.startsWith('@@') ? 0 : raw.indexOf('\n@@') + 1
187  if (start === 0 && !raw.startsWith('@@')) return { source: raw.trim(), isDiff: false }
188  let source = raw.slice(start)
189  if (source.length > MAX_DIFF_CHARS) {
190    const cut = source.lastIndexOf('\n@@', MAX_DIFF_CHARS)
191    if (cut <= 0) return { source: source.slice(0, MAX_DIFF_CHARS), isDiff: false }
192    source = source.slice(0, cut + 1)
193  }
194  return { source: source.replace(/\n$/, ''), isDiff: true }
195}
196
197export function diffStats(hunks: string): { adds: number; dels: number } {
198  let adds = 0
199  let dels = 0
200  for (const line of hunks.split('\n')) {
201    if (line.startsWith('+')) adds++
202    else if (line.startsWith('-')) dels++
203  }
204  return { adds, dels }
205}
206
207export function matchesFilter(file: RepoFile, text: string, isTrackedOnly: boolean): boolean {
208  if (isTrackedOnly && file.status === '??') return false
209  return !text || file.path.toLowerCase().includes(text.toLowerCase())
210}
211
212export const WORKTREE_SEP = ' › '
213
214export type OwnerGroup<R extends { label: string }> = { owner: string; main?: R; worktrees: R[] }
215
216export function groupByOwner<R extends { label: string }>(repos: R[]): OwnerGroup<R>[] {
217  const groups = new Map<string, OwnerGroup<R>>()
218  for (const repo of repos) {
219    const [owner = repo.label, worktree] = repo.label.split(WORKTREE_SEP)
220    const group = groups.get(owner) ?? { owner, worktrees: [] }
221    if (worktree === undefined) group.main = repo
222    else group.worktrees.push(repo)
223    groups.set(owner, group)
224  }
225  const byLabel = (a: R, b: R) => a.label.localeCompare(b.label)
226  return [...groups.values()]
227    .map(g => ({ ...g, worktrees: [...g.worktrees].sort(byLabel) }))
228    .sort((a, b) => a.owner.localeCompare(b.owner))
229}
230
231export function flatRows(files: RepoFile[]): Row[] {
232  return sortFiles(files).map(file => ({ type: 'file', file, name: splitPath(file.path).name }))
233}
234
types/index.d.ts 32 lines
1export type RepoFile = { status: string; path: string }
2
3export type Repo = { path: string; label: string; branch: string; files: RepoFile[] }
4
5export type Selected = {
6  repoPath: string
7  repoLabel: string
8  file: RepoFile
9  diff: string
10  isDiff: boolean
11  adds: number
12  dels: number
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'repo-changes': {
18      repos: Repo[]
19      cleanCount: number
20      selected: Selected | null
21      updatedAt: number
22      openRepos: string[] | null
23      openGroups: string[]
24      filter: string
25      isTrackedOnly: boolean
26      lastViewed: string | null
27      isFlat: boolean
28      rowLimits: Record<string, number>
29    }
30  }
31}
32