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

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.
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.
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.
/repo-changes to open it.The mod looks at the folder the session started in. A cd in the shell does not change it.
HEAD. Committed work on a branch does not show.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.
MIT
hooks/register.tsx 524 lines1import 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}
524hooks/logic.ts 234 lines1import 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}
234types/index.d.ts 32 lines1export 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