A lazygit-style side pane: every worktree of the repository and its local branches as rows you click. A worktree row moves the session there with /cd; a branch…

A small lazygit-style side pane for Claude Code: every worktree of the repository the session is in, and its local branches, as rows you can click.
app
Worktrees (3)
● app main ~2
app-feature feature/login locked
app-spike spike
Branches (4)
* main ↓2
+ feature/login ↑3 ↓1
+ spike
old gone
[ refresh ] [ close ]
Run /git-sidebar to open it, /git-sidebar stop to close it, and /git-sidebar on|off to disable the whole mod (so meus-mods can control it too) — off also closes the pane if it is open.
In the fullscreen layout the engine docks the pane beside the transcript, floor to ceiling, splitting the screen in two. That layout needs a terminal at least 110 columns wide; turn it on with /tui fullscreen (the session restarts and resumes) or CLAUDE_CODE_NO_FLICKER=1. On the classic main-screen layout the same pane sits above the prompt instead, with the same rows and the same actions.
The pane width is a request: columns (default 44) sets it when the pane opens, and a width you drag the dock to wins.
| Row | Click or Enter |
|---|---|
| worktree | moves the session there with the engine's own /cd (the ● row is where you are) |
| branch | shows its upstream, age and last 5 commits, plus one action |
→ switch | git switch <branch> in the current worktree, only when that worktree has no uncommitted changes |
→ open worktree | when another worktree has the branch checked out (+), /cd there, since git would refuse the switch |
refresh (r) | reads git again |
close | closes the pane |
Mouse clicks land in the fullscreen layout. Everywhere else, focus the pane (ctrl+x tab), then move with Tab and press with Enter. Esc hands the keyboard back to the prompt and leaves the pane open.
The pane refreshes itself when it opens, after every /cd, and after each of Claude's turns, so a branch or worktree Claude just created shows up. While it is open, the status line under the prompt reads git: <branch> · N worktrees · N branches.
Markers: ● the worktree the session is in, * its branch, + a branch checked out in another worktree, ~N uncommitted paths, ↑/↓ ahead/behind its upstream, gone when the upstream branch was deleted.
Only git, by argv (no shell), through $.process.run: rev-parse --show-toplevel, worktree list --porcelain, for-each-ref refs/heads, status --porcelain in each worktree (the first 12), log -n5 for a selected branch, and switch when you press it. It never commits, stashes, deletes or fetches.
columns: number width asked for the docked pane, 28-120 (default 44)
maxBranches: number local branches listed, most recently committed first (default 40)
openOnStart: boolean open the pane when a session starts (default false)
Declared in .claude-plugin/plugin.json (userConfig). Set them in /config, in user settings (~/.claude/settings.json, never project settings), with --settings <file> or in managed settings, under the plugin's full id:
{ "pluginConfigs": { "git-sidebar@skills-dir": { "options": { "columns": 56 } } } }
With --plugin-dir the id is plain git-sidebar. A pane opened on its own at session start stays hidden below 144 columns until you run /git-sidebar.
npx claude-code-templates@latest --mod ui/git-sidebar
claude
It is written to .claude/skills/git-sidebar/, which Claude Code auto-loads as git-sidebar@skills-dir in a trusted project (accept the trust prompt once; claude -p never shows it). For one session with hot reload: claude --plugin-dir .claude/skills/git-sidebar. claude plugin validate .claude/skills/git-sidebar prints every event it hooks and every $ call it makes.
If /git-sidebar is missing from the typeahead, the mod did not load: claude --debug and look for hooks module git-sidebar@… loaded in ~/.claude/debug/latest.
claude plugin test git-sidebar
The tests answer $.process.run with a fake git, mount the pane on the terminal surface and press its rows.
Requirements. Mods are on by default in Claude Code 2.1.287+. Typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
Vendored from davila7/claude-code-templates (cli-tool/components/mods/ui/git-sidebar), MIT licensed. Added the isEnabled toggle above so it fits the on/off convention of every other mod in this repo.
hooks/register.tsx 442 lines1/**
2 * git-sidebar — Claude Mod
3 *
4 * A small lazygit-style pane: every worktree of the repository the session is
5 * in, and its local branches, as rows you can click (or Tab to and press
6 * Enter). `/git-sidebar` opens it; in the fullscreen layout (`/tui fullscreen`)
7 * the engine docks it beside the transcript, floor to ceiling, otherwise it
8 * sits above the prompt.
9 *
10 * - a worktree row moves the session there, through the engine's own `/cd`
11 * - a branch row shows its last commits and what can be done with it:
12 * `switch` (clean tree only) or `open worktree` when another worktree has it
13 *
14 * A second tab, "Arquivos", shows the cwd as a lazy tree marked with what Claude read
15 * or wrote this session and with `git status` (pure parts in ./filetree.ts).
16 *
17 * Git runs through `$.process.run` by argv (no shell); every read is in
18 * ./git.ts. Nothing here writes to the repository except `git switch`, which
19 * the mod refuses while the current worktree has uncommitted changes.
20 *
21 * Needs Claude Code >= 2.1.287.
22 *
23 * Options (pluginConfigs["git-sidebar@skills-dir"].options):
24 * columns: number width asked for the docked pane (default 44)
25 * maxBranches: number most recent local branches listed (default 40)
26 * openOnStart: boolean open the pane when the session starts (default false)
27 *
28 * Vendored from davila7/claude-code-templates (cli-tool/components/mods/ui/git-sidebar),
29 * MIT licensed. Added: an isEnabled toggle (/git-sidebar on|off) so meus-mods can control it.
30 */
31import { atom, read, update } from 'claude-code'
32import type { EngineInterface, ProcessRunResult, Register } from 'claude-code'
33import { fileRows, filesRoot, noteTouch, readFiles, toggleDir } from './files.ts'
34import type { Mark } from './filetree.ts'
35import {
36 STATUS,
37 TOPLEVEL,
38 WORKTREES,
39 basename,
40 branchAction,
41 branchesArgv,
42 countDirty,
43 currentWorktree,
44 fit,
45 logArgv,
46 maxBranches,
47 paneColumns,
48 parseBranches,
49 parseLog,
50 parseWorktrees,
51 switchArgv,
52 trackLabel,
53} from './git.ts'
54import type { Commit, Snapshot } from './git.ts'
55
56const PANE = 'git'
57const COMMAND = 'git-sidebar'
58// worktrees whose `git status` is read on each refresh; the rest show no count
59const MAX_STATUS_READS = 12
60
61const isEnabledRef = atom({ plugin: 'git-sidebar', key: 'isEnabled' } as const, true)
62
63type Run = (argv: readonly string[], cwd?: string) => Promise<ProcessRunResult>
64
65// Pane state lives at module level: hooks and ui.render are separate calls, and one session draws one pane.
66let snap: Snapshot | undefined
67let error: string | undefined
68let selected: string | undefined
69let commits: Commit[] = []
70let isOpen = false
71
72// "Arquivos" tab: tree of the session cwd, marked with what Claude touched and what git sees changed (./files.ts)
73let tab: 'branches' | 'files' = 'branches'
74
75const MARK_COLOR: Record<Mark, string> = { M: 'yellow', A: 'green', D: 'red', R: 'blue', U: 'red', '?': 'magenta' }
76
77const firstLine = (text: string) => text.trim().split('\n')[0] ?? ''
78
79async function readSnapshot(run: Run, branchLimit: number): Promise<void> {
80 const top = await run(TOPLEVEL)
81 if (top.exitCode !== 0) {
82 snap = undefined
83 error = firstLine(top.stderr) || 'not a git repository'
84 return
85 }
86 const [wts, refs] = await Promise.all([run(WORKTREES), run(branchesArgv(branchLimit))])
87 const worktrees = wts.exitCode === 0 ? parseWorktrees(wts.stdout) : []
88 await Promise.all(
89 worktrees
90 .filter(wt => !wt.bare && !wt.prunable)
91 .slice(0, MAX_STATUS_READS)
92 .map(async wt => {
93 const st = await run(STATUS, wt.path).catch(() => undefined)
94 if (st?.exitCode === 0) wt.dirty = countDirty(st.stdout)
95 }),
96 )
97 snap = {
98 root: top.stdout.trim(),
99 worktrees,
100 branches: refs.exitCode === 0 ? parseBranches(refs.stdout) : [],
101 }
102 error = refs.exitCode === 0 ? undefined : firstLine(refs.stderr)
103 if (selected && !snap.branches.some(b => b.name === selected)) {
104 selected = undefined
105 commits = []
106 }
107}
108
109function statusText(): string | undefined {
110 if (!isOpen) return undefined
111 if (!snap) return `git: ${error ?? 'loading'}`
112 const here = currentWorktree(snap)
113 const where = here?.branch ?? (here?.detached ? `detached ${here.head.slice(0, 7)}` : basename(snap.root))
114 const n = snap.worktrees.length
115 return `git: ${where} · ${n} worktree${n === 1 ? '' : 's'} · ${snap.branches.length} branches`
116}
117
118// Reads git again, and the file tree too while that tab shows: every refresh path goes through here.
119async function refresh($: EngineInterface, branchLimit: number): Promise<void> {
120 await readSnapshot((argv, cwd) => $.process.run(argv, { cwd }), branchLimit).catch(err => {
121 error = String(err)
122 })
123 if (tab === 'files') await readFiles($)
124}
125
126export const register: Register = (on, options) => {
127 const columns = paneColumns(options.columns)
128 const branchLimit = maxBranches(options.maxBranches)
129 const openOnStart = options.openOnStart === true
130
131 on('session.start', async ($, e, next) => {
132 const saved = await $.store.get('isEnabled').catch(() => undefined)
133 await update($, isEnabledRef, () => (typeof saved === 'boolean' ? saved : isEnabledRef.initial))
134 const r = await next(e)
135 await $.command
136 .register({
137 name: COMMAND,
138 description: 'Worktrees and branches in a side pane; click one to go there (stop closes, on/off toggles the mod)',
139 argumentHint: '[stop|refresh|on|off]',
140 immediate: true,
141 })
142 .catch(err => $.ui.log(`git-sidebar: /${COMMAND} not registered: ${err}`))
143 $.ui.log(`git-sidebar loaded: /${COMMAND} opens the pane`, { to: 'debug' })
144 if (openOnStart && (await read($, isEnabledRef))) {
145 await refresh($, branchLimit)
146 isOpen = true
147 // unasked, the engine keeps it undrawn below 144 columns until the person opens it
148 await $.ui.open({ id: PANE, title: 'git', columns }).catch(err => {
149 isOpen = false
150 $.ui.log(`git-sidebar: pane not opened: ${err}`)
151 })
152 $.ui.status(statusText())
153 }
154 return r
155 })
156
157 on('command.run', { command: COMMAND }, async ($, e) => {
158 const arg = e.args.trim().toLowerCase()
159
160 if (arg === 'off') {
161 await update($, isEnabledRef, () => false)
162 await $.store.set('isEnabled', false) // $.state is per session; the store survives a restart
163 if (isOpen) {
164 await $.ui.close({ id: PANE }).catch(() => undefined)
165 isOpen = false
166 $.ui.status(undefined)
167 }
168 return { text: 'git-sidebar desativado.' }
169 }
170
171 // on also opens the pane: it is never drawn unasked, so a bare state flip showed nothing
172 if (arg === 'on') {
173 await update($, isEnabledRef, () => true)
174 await $.store.set('isEnabled', true)
175 } else if (!(await read($, isEnabledRef))) {
176 return { text: 'git-sidebar esta desativado. Uso: /git-sidebar on' }
177 }
178
179 if (arg === 'stop' || arg === 'close') {
180 await $.ui.close({ id: PANE }).catch(() => undefined)
181 isOpen = false
182 $.ui.status(undefined)
183 return { text: 'git-sidebar closed' }
184 }
185 await refresh($, branchLimit)
186 isOpen = true
187 await $.ui.open({ id: PANE, title: 'git', focus: true, columns })
188 $.ui.status(statusText())
189 $.ui.invalidate('ui.render')
190 const hint = e.presentation?.isFullscreen
191 ? 'click a row, or Tab and Enter'
192 : 'drawn above the prompt; /tui fullscreen docks it beside the transcript'
193 const found = snap ? `${snap.worktrees.length} worktrees, ${snap.branches.length} branches` : error
194 const prefix = arg === 'on' ? 'git-sidebar ativado. ' : ''
195 return { text: `${prefix}${found} · ${hint} · /${COMMAND} stop closes` }
196 })
197
198 // the session moved (a row of ours, or the person's own /cd): read the new worktree
199 on('command.run', { command: 'cd' }, async ($, e, next) => {
200 const r = await next(e)
201 if (!isOpen) return r
202 await refresh($, branchLimit)
203 $.ui.status(statusText())
204 $.ui.invalidate('ui.render')
205 return r
206 })
207
208 // Claude may have made a branch or a worktree during the turn
209 on('turn.complete', async ($, e, next) => {
210 const r = await next(e)
211 if (!isOpen || e.agentId) return r
212 await refresh($, branchLimit)
213 $.ui.status(statusText())
214 $.ui.invalidate('ui.render')
215 return r
216 })
217
218 // every file Claude reads or writes gets a mark in the tree; recorded even while the pane is closed
219 on('tool.call', { tool: ['Read', 'Edit', 'Write'] }, async ($, e, next) => {
220 const isShown = isOpen && tab === 'files'
221 if (noteTouch(await $.session.cwd(), e.tool, e.file_path, isShown)) $.ui.invalidate('ui.render')
222 return next(e)
223 }).catch(($, e, next) => next(e)) // fail open: a bookkeeping failure never blocks the tool call
224
225 on('ui.close', async ($, e, next) => {
226 if (e.id !== PANE) return next(e)
227 const r = await next(e)
228 isOpen = false
229 $.ui.status(undefined)
230 return r
231 })
232
233 on('ui.press', async ($, e, next) => {
234 if (e.plugin !== $.plugin.name || e.requestId !== PANE) return next(e)
235 const r = await next(e)
236 const key = e.element
237 const s = snap
238
239 if (key === 'close') {
240 await $.ui.close({ id: PANE }).catch(() => undefined)
241 return r
242 }
243 if (key === 'tab:branches') {
244 tab = 'branches'
245 } else if (key === 'tab:files') {
246 tab = 'files'
247 await readFiles($)
248 } else if (tab === 'files' && key.startsWith('f:')) {
249 await toggleDir($, key.slice(2))
250 } else if (key === 'refresh') {
251 await refresh($, branchLimit)
252 } else if (s && key.startsWith('wt:')) {
253 const wt = s.worktrees.find(w => w.path === key.slice(3))
254 if (wt && wt.path === currentWorktree(s)?.path) $.ui.toast('git-sidebar: already in this worktree')
255 else if (wt) {
256 await $.command
257 .run({ command: 'cd', args: wt.path })
258 .catch(err => $.ui.toast(`git-sidebar: /cd failed: ${err}`))
259 }
260 } else if (s && key.startsWith('br:')) {
261 const branch = s.branches.find(b => b.name === key.slice(3))
262 if (branch && selected === branch.name) {
263 selected = undefined
264 commits = []
265 } else if (branch) {
266 selected = branch.name
267 const log = await $.process.run(logArgv(branch.name)).catch(() => undefined)
268 commits = log?.exitCode === 0 ? parseLog(log.stdout) : []
269 }
270 } else if (s && selected && key === 'open') {
271 const branch = s.branches.find(b => b.name === selected)
272 if (branch?.worktree) {
273 await $.command
274 .run({ command: 'cd', args: branch.worktree })
275 .catch(err => $.ui.toast(`git-sidebar: /cd failed: ${err}`))
276 }
277 } else if (s && selected && key === 'switch') {
278 const target = selected
279 const st = await $.process.run(STATUS)
280 if (st.exitCode !== 0 || countDirty(st.stdout) > 0) {
281 $.ui.toast(`git-sidebar: uncommitted changes here; commit or stash before switching to ${target}`)
282 } else {
283 const sw = await $.process.run(switchArgv(target))
284 if (sw.exitCode === 0) $.ui.toast(`git-sidebar: switched to ${target}`)
285 else $.ui.toast(`git-sidebar: ${firstLine(sw.stderr) || 'git switch failed'}`)
286 await refresh($, branchLimit)
287 }
288 }
289 $.ui.status(statusText())
290 $.ui.invalidate('ui.render')
291 return r
292 })
293
294 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
295 if (e.requestId !== PANE) return next(e)
296 const { Box, Text, Button } = $.ui.resolve(e)
297 const width = Math.max(20, e.props.bodyColumns - 1)
298 const noop = () => {}
299 const toolbar = (
300 <Box key="toolbar" flexDirection="row" columnGap={1}>
301 <Button key="refresh" label="refresh" hotkey="r" onPress={noop} />
302 <Button key="close" label="close" onPress={noop} />
303 </Box>
304 )
305
306 const tabs = (
307 <Box key="tabs" flexDirection="row" columnGap={1}>
308 <Button key="tab:branches" label="Branches" plain dimColor={tab !== 'branches'} onPress={noop} />
309 <Button key="tab:files" label="Arquivos" plain dimColor={tab !== 'files'} onPress={noop} />
310 </Box>
311 )
312
313 if (tab === 'files') {
314 return (
315 <Box flexDirection="column">
316 {tabs}
317 <Text dimColor>{fit(basename(filesRoot()) || 'arquivos', width)}</Text>
318 {fileRows().length === 0 ? <Text dimColor>(vazio)</Text> : null}
319 {fileRows().map(r => {
320 const marks = (r.touch ? 2 : 0) + (r.git ? 2 : 0)
321 const indent = ' '.repeat(r.depth)
322 const label = fit(`${indent}${r.kind === 'dir' ? (r.expanded ? '▾ ' : '▸ ') : ' '}${r.name}`, width - marks)
323 return (
324 <Box key={`f-row:${r.path}`} flexDirection="row">
325 {r.kind === 'dir' ? (
326 <Button key={`f:${r.path}`} plain label={label} onPress={noop} />
327 ) : (
328 <Text dimColor={!r.touch && !r.git}>{label}</Text>
329 )}
330 {r.touch ? <Text color={r.touch === 'write' ? 'green' : 'cyan'}>{r.touch === 'write' ? ' ●' : ' ○'}</Text> : null}
331 {r.git ? <Text color={MARK_COLOR[r.git]}>{` ${r.git}`}</Text> : null}
332 </Box>
333 )
334 })}
335 <Box key="foot" marginTop={1} flexDirection="column">
336 {toolbar}
337 <Text dimColor>{fit('○ lido ● escrito M A D ? git', width)}</Text>
338 </Box>
339 </Box>
340 )
341 }
342
343 if (!snap) {
344 return (
345 <Box flexDirection="column">
346 {tabs}
347 <Text color="red">{fit(error ?? 'reading the repository…', width)}</Text>
348 {toolbar}
349 </Box>
350 )
351 }
352
353 const s = snap
354 const here = currentWorktree(s)
355 const chosen = selected ? s.branches.find(b => b.name === selected) : undefined
356 const action = chosen ? branchAction(s, chosen) : undefined
357
358 return (
359 <Box flexDirection="column">
360 {tabs}
361 <Text bold>{fit(basename(s.worktrees[0]?.path ?? s.root), width)}</Text>
362 {error ? <Text color="red">{fit(error, width)}</Text> : null}
363
364 <Box key="wt-head" marginTop={1}>
365 <Text bold color="cyan">{`Worktrees (${s.worktrees.length})`}</Text>
366 </Box>
367 {s.worktrees.map(wt => {
368 const isHere = wt === here
369 const name = wt.branch ?? (wt.bare ? '(bare)' : `(${wt.head.slice(0, 7)})`)
370 const dirty = wt.dirty ? ` ~${wt.dirty}` : ''
371 const flags = `${wt.locked ? ' locked' : ''}${wt.prunable ? ' prunable' : ''}`
372 const room = width - 2 - dirty.length - flags.length
373 return (
374 <Box key={`wt-row:${wt.path}`} flexDirection="row">
375 <Text color="green">{isHere ? '● ' : ' '}</Text>
376 <Button
377 key={`wt:${wt.path}`}
378 plain
379 dimColor={!isHere}
380 label={fit(`${basename(wt.path)} ${name}`, room)}
381 onPress={noop}
382 />
383 {dirty ? <Text color="yellow">{dirty}</Text> : null}
384 {flags ? <Text color="red">{flags}</Text> : null}
385 </Box>
386 )
387 })}
388
389 <Box key="br-head" marginTop={1}>
390 <Text bold color="cyan">{`Branches (${s.branches.length})`}</Text>
391 </Box>
392 {s.branches.map(b => {
393 const isHere = here?.branch === b.name
394 const marker = isHere ? '* ' : b.worktree ? '+ ' : ' '
395 const track = trackLabel(b)
396 const room = width - 2 - (track ? track.length + 1 : 0)
397 return (
398 <Box key={`br-row:${b.name}`} flexDirection="row">
399 <Text color={isHere ? 'green' : 'blue'}>{marker}</Text>
400 <Button
401 key={`br:${b.name}`}
402 plain
403 dimColor={!isHere && selected !== b.name}
404 label={fit(b.name, room)}
405 onPress={noop}
406 />
407 {track ? <Text color={b.gone ? 'red' : 'magenta'}>{` ${track}`}</Text> : null}
408 </Box>
409 )
410 })}
411
412 {chosen ? (
413 <Box key="detail" flexDirection="column" marginTop={1} borderStyle="round" borderDimColor paddingX={1}>
414 <Text bold>{fit(chosen.name, width - 4)}</Text>
415 <Text dimColor>
416 {fit(`${chosen.upstream ?? 'no upstream'} · ${chosen.date}`, width - 4)}
417 </Text>
418 {commits.map((c, i) => (
419 <Text key={`c:${i}`} wrap="truncate-end">
420 <Text color="yellow">{c.sha}</Text>
421 {` ${c.subject}`}
422 </Text>
423 ))}
424 {action?.kind === 'switch' ? (
425 <Button key="switch" label="switch" hotkey="s" onPress={noop} />
426 ) : action?.kind === 'open' ? (
427 <Button key="open" label="open worktree" hotkey="o" onPress={noop} />
428 ) : (
429 <Text dimColor>checked out here</Text>
430 )}
431 </Box>
432 ) : null}
433
434 <Box key="foot" marginTop={1} flexDirection="column">
435 {toolbar}
436 <Text dimColor>{fit('● here * current + other worktree', width)}</Text>
437 </Box>
438 </Box>
439 )
440 })
441}
442hooks/files.ts 74 lines1// State and reads of the "Arquivos" tab; the pure parts live in ./filetree.ts.
2import type { EngineInterface } from 'claude-code'
3import { PREFIX, buildRows, parsePorcelain, recordTouch } from './filetree.ts'
4import type { Entry, GitMarks, Row, Touched } from './filetree.ts'
5import { STATUS } from './git.ts'
6
7// Module level for the same reason as the pane state in register.tsx: one session draws one pane.
8let filesCwd = ''
9let listing = new Map<string, readonly Entry[]>()
10let expanded = new Set<string>()
11let gitMarks: GitMarks = new Map()
12let rows: Row[] = []
13// per cwd: the paths are relative to it, and a /cd must not mix two trees up
14const touchedByCwd = new Map<string, Touched>()
15
16export const fileRows = (): readonly Row[] => rows
17export const filesRoot = (): string => filesCwd
18
19async function listDir($: EngineInterface, dir: string): Promise<void> {
20 try {
21 const entries = await $.fs.list(dir || undefined)
22 listing.set(dir, entries.map(({ name, kind }) => ({ name, kind })))
23 } catch {
24 listing.delete(dir) // fail open: the tree shows without this directory
25 }
26}
27
28function redrawRows(): void {
29 rows = buildRows(listing, expanded, gitMarks, touchedByCwd.get(filesCwd) ?? new Map())
30}
31
32/** Lists the session cwd again (and every open folder) and reads `git status` for the marks. */
33export async function readFiles($: EngineInterface): Promise<void> {
34 try {
35 const cwd = await $.session.cwd()
36 if (cwd !== filesCwd) {
37 filesCwd = cwd
38 listing = new Map()
39 expanded = new Set()
40 }
41 const [st, prefix] = await Promise.all([
42 $.process.run(STATUS).catch(() => undefined),
43 $.process.run(PREFIX).catch(() => undefined),
44 ])
45 const root = prefix?.exitCode === 0 ? prefix.stdout.trim() : ''
46 gitMarks = st?.exitCode === 0 ? parsePorcelain(st.stdout, root) : new Map()
47 await Promise.all(['', ...expanded].map(dir => listDir($, dir)))
48 } catch {
49 gitMarks = new Map()
50 }
51 redrawRows()
52}
53
54/** Opens or closes the folder at `path`; anything else is ignored. */
55export async function toggleDir($: EngineInterface, path: string): Promise<void> {
56 if (!rows.some(r => r.path === path && r.kind === 'dir')) return
57 if (!expanded.delete(path)) {
58 expanded.add(path)
59 await listDir($, path)
60 }
61 redrawRows()
62}
63
64/** Records a Read/Edit/Write; true when the shown tree changed and needs a redraw.
65 * A hidden tab skips the rebuild: readFiles rebuilds the rows when the tab is shown again. */
66export function noteTouch(cwd: string, tool: string, file: unknown, isShown: boolean): boolean {
67 const touched = touchedByCwd.get(cwd) ?? new Map()
68 touchedByCwd.set(cwd, touched)
69 recordTouch(touched, cwd, tool, file)
70 if (!isShown || cwd !== filesCwd) return false
71 redrawRows()
72 return true
73}
74hooks/filetree.ts 112 lines1// Pure parts of the "Arquivos" tab: paths -> tree rows, `git status --porcelain` -> marks,
2// tool calls -> the set of files Claude touched. No engine calls, so the tests run it as is.
3// Adapted from data-goblin/claude-code-filetree @003545c (MIT)
4
5export type Mark = 'M' | 'A' | 'D' | 'R' | 'U' | '?'
6export type Touch = 'read' | 'write'
7export type GitMarks = ReadonlyMap<string, Mark>
8export type Touched = Map<string, Touch>
9export type Entry = { name: string; kind: 'file' | 'dir' | 'other' }
10/** directory (cwd-relative, '' for the root) -> its entries; a dir that failed to list is absent */
11export type Listing = ReadonlyMap<string, readonly Entry[]>
12
13export type Row = {
14 /** cwd-relative, forward slashes: the key git status and the touched set use */
15 path: string
16 name: string
17 depth: number
18 kind: 'file' | 'dir'
19 expanded: boolean
20 git?: Mark
21 touch?: Touch
22}
23
24export const PREFIX = ['git', 'rev-parse', '--show-prefix']
25const SKIP = new Set(['.git', 'node_modules'])
26const TRACKED = ['Read', 'Edit', 'Write']
27
28const slashes = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
29
30/** `file` as a path under `cwd` (forward slashes), or undefined when it is outside or is the cwd. */
31export function relativeTo(cwd: string, file: string): string | undefined {
32 const base = slashes(cwd)
33 const path = slashes(file)
34 const fold = /^[a-z]:/i.test(base) // Windows drive paths compare case-insensitively
35 const head = path.slice(0, base.length + 1)
36 const want = `${base}/`
37 if (fold ? head.toLowerCase() !== want.toLowerCase() : head !== want) return undefined
38 return path.slice(want.length) || undefined
39}
40
41/** Notes a Read/Edit/Write on `file`; a write is never downgraded to a read. */
42export function recordTouch(touched: Touched, cwd: string, tool: string, file: unknown): void {
43 if (!TRACKED.includes(tool) || typeof file !== 'string') return
44 const rel = relativeTo(cwd, file)
45 if (!rel) return
46 if (tool === 'Read') {
47 if (!touched.has(rel)) touched.set(rel, 'read')
48 } else touched.set(rel, 'write')
49}
50
51const unquote = (p: string) => (p.startsWith('"') && p.endsWith('"') ? p.slice(1, -1) : p)
52
53function markOf(xy: string): Mark {
54 if (xy === '??') return '?'
55 if (xy.includes('U') || xy === 'AA' || xy === 'DD') return 'U'
56 if (xy.includes('D')) return 'D'
57 if (xy.includes('R')) return 'R'
58 if (xy.includes('A')) return 'A'
59 return 'M'
60}
61
62/** Parses `git status --porcelain` (paths are repo-relative); `prefix` is the cwd's `--show-prefix`. */
63export function parsePorcelain(out: string, prefix: string): Map<string, Mark> {
64 const marks = new Map<string, Mark>()
65 for (const line of out.split('\n')) {
66 if (line.length < 4) continue
67 const raw = line.slice(3)
68 const path = unquote(raw.includes(' -> ') ? raw.slice(raw.indexOf(' -> ') + 4) : raw)
69 if (!path.startsWith(prefix)) continue
70 marks.set(path.slice(prefix.length), markOf(line.slice(0, 2)))
71 }
72 return marks
73}
74
75const RANK: Record<Mark, number> = { U: 3, D: 2, R: 1, A: 1, M: 1, '?': 1 }
76
77/** The mark a folder shows for what is inside it: conflict, then delete, then the first seen. */
78export function mergeMark(a: Mark | undefined, b: Mark): Mark {
79 return a && RANK[a] >= RANK[b] ? a : b
80}
81
82function inside<T>(map: ReadonlyMap<string, T>, dir: string): T[] {
83 const head = `${dir}/`
84 return [...map].filter(([k]) => k.startsWith(head)).map(([, v]) => v)
85}
86
87/** Flattens the listing into the visible rows: dirs first, then files, both by name. */
88export function buildRows(listing: Listing, expanded: ReadonlySet<string>, git: GitMarks, touched: ReadonlyMap<string, Touch>): Row[] {
89 const rows: Row[] = []
90 const walk = (dir: string, depth: number) => {
91 const entries = (listing.get(dir) ?? []).filter(e => !SKIP.has(e.name))
92 const isDir = (e: Entry) => e.kind === 'dir'
93 entries.sort((a, b) => Number(isDir(b)) - Number(isDir(a)) || a.name.localeCompare(b.name))
94 for (const e of entries) {
95 const path = dir ? `${dir}/${e.name}` : e.name
96 const row: Row = { path, name: e.name, depth, kind: isDir(e) ? 'dir' : 'file', expanded: isDir(e) && expanded.has(path) }
97 if (isDir(e)) {
98 row.git = inside(git, path).reduce<Mark | undefined>(mergeMark, undefined)
99 const t = inside(touched, path)
100 row.touch = t.includes('write') ? 'write' : t.includes('read') ? 'read' : undefined
101 } else {
102 row.git = git.get(path)
103 row.touch = touched.get(path)
104 }
105 rows.push(row)
106 if (row.expanded) walk(path, depth + 1)
107 }
108 }
109 walk('', 0)
110 return rows
111}
112hooks/git.ts 199 lines1// Pure git plumbing for git-sidebar: the argv each read runs and the parsers
2// for their output. No engine calls here, so tests/git.test.ts runs it as is.
3
4export type Worktree = {
5 path: string
6 head: string
7 /** short branch name; absent when detached or bare */
8 branch?: string
9 detached: boolean
10 bare: boolean
11 locked: boolean
12 prunable: boolean
13 /** changed paths from `git status --porcelain`; undefined when not read */
14 dirty?: number
15}
16
17export type Branch = {
18 name: string
19 upstream?: string
20 ahead: number
21 behind: number
22 /** the upstream was deleted on the remote */
23 gone: boolean
24 /** committer date, relative ("3 days ago") */
25 date: string
26 /** absolute path of the worktree that has it checked out, if any */
27 worktree?: string
28}
29
30export type Commit = { sha: string; subject: string; date: string }
31
32export type Snapshot = {
33 /** toplevel of the worktree the session is in */
34 root: string
35 worktrees: Worktree[]
36 branches: Branch[]
37}
38
39export const TOPLEVEL = ['git', 'rev-parse', '--show-toplevel']
40export const WORKTREES = ['git', 'worktree', 'list', '--porcelain']
41export const STATUS = ['git', 'status', '--porcelain']
42
43const SEP = '%09'
44
45export function branchesArgv(max: number): string[] {
46 const fields = [
47 '%(refname:short)',
48 '%(upstream:short)',
49 '%(upstream:track,nobracket)',
50 '%(committerdate:relative)',
51 '%(worktreepath)',
52 ]
53 return [
54 'git',
55 'for-each-ref',
56 '--sort=-committerdate',
57 `--count=${Math.max(1, Math.floor(max))}`,
58 `--format=${fields.join(SEP)}`,
59 'refs/heads',
60 ]
61}
62
63export function logArgv(branch: string, count = 5): string[] {
64 return ['git', 'log', `-n${count}`, '--format=%h%x09%s%x09%cr', `refs/heads/${branch}`, '--']
65}
66
67export function switchArgv(branch: string): string[] {
68 return ['git', 'switch', '--no-guess', branch]
69}
70
71const stripHeads = (ref: string) => ref.replace(/^refs\/heads\//, '')
72
73/** Parses `git worktree list --porcelain`: blank-line separated records. */
74export function parseWorktrees(out: string): Worktree[] {
75 const list: Worktree[] = []
76 for (const block of out.split(/\n\s*\n/)) {
77 const lines = block.split('\n').filter(Boolean)
78 const first = lines[0]
79 if (!first?.startsWith('worktree ')) continue
80 const wt: Worktree = {
81 path: first.slice('worktree '.length),
82 head: '',
83 detached: false,
84 bare: false,
85 locked: false,
86 prunable: false,
87 }
88 for (const line of lines.slice(1)) {
89 if (line.startsWith('HEAD ')) wt.head = line.slice(5)
90 else if (line.startsWith('branch ')) wt.branch = stripHeads(line.slice(7))
91 else if (line === 'detached') wt.detached = true
92 else if (line === 'bare') wt.bare = true
93 else if (line === 'locked' || line.startsWith('locked ')) wt.locked = true
94 else if (line === 'prunable' || line.startsWith('prunable ')) wt.prunable = true
95 }
96 list.push(wt)
97 }
98 return list
99}
100
101/** Reads `ahead 2, behind 1` / `gone` / `` from %(upstream:track,nobracket). */
102export function parseTrack(track: string): { ahead: number; behind: number; gone: boolean } {
103 const ahead = /ahead (\d+)/.exec(track)
104 const behind = /behind (\d+)/.exec(track)
105 return {
106 ahead: ahead ? Number(ahead[1]) : 0,
107 behind: behind ? Number(behind[1]) : 0,
108 gone: track.trim() === 'gone',
109 }
110}
111
112/** Parses the tab-separated lines `branchesArgv` prints. */
113export function parseBranches(out: string): Branch[] {
114 const list: Branch[] = []
115 for (const line of out.split('\n')) {
116 if (!line.trim()) continue
117 const [name, upstream = '', track = '', date = '', worktree = ''] = line.split('\t')
118 if (!name) continue
119 list.push({
120 name,
121 upstream: upstream || undefined,
122 ...parseTrack(track),
123 date,
124 worktree: worktree || undefined,
125 })
126 }
127 return list
128}
129
130export function parseLog(out: string): Commit[] {
131 return out
132 .split('\n')
133 .filter(Boolean)
134 .map(line => {
135 const [sha = '', subject = '', date = ''] = line.split('\t')
136 return { sha, subject, date }
137 })
138}
139
140/** Lines of `git status --porcelain`: how many paths changed. */
141export function countDirty(out: string): number {
142 return out.split('\n').filter(line => line.trim() !== '').length
143}
144
145export function basename(path: string): string {
146 const parts = path.replace(/[\\/]+$/, '').split(/[\\/]/)
147 return parts[parts.length - 1] || path
148}
149
150/** Cuts `text` to `width` cells with an ellipsis; never below 1. */
151export function fit(text: string, width: number): string {
152 const w = Math.max(1, Math.floor(width))
153 const chars = Array.from(text)
154 if (chars.length <= w) return text
155 return chars.slice(0, Math.max(0, w - 1)).join('') + '…'
156}
157
158export function trackLabel(b: Pick<Branch, 'ahead' | 'behind' | 'gone' | 'upstream'>): string {
159 if (b.gone) return 'gone'
160 if (!b.upstream) return ''
161 const parts: string[] = []
162 if (b.ahead) parts.push(`↑${b.ahead}`)
163 if (b.behind) parts.push(`↓${b.behind}`)
164 return parts.join(' ') || '✓'
165}
166
167/** Paths compare equal across a trailing slash (git and the session disagree). */
168export function samePath(a: string | undefined, b: string | undefined): boolean {
169 if (!a || !b) return false
170 return a.replace(/[\\/]+$/, '') === b.replace(/[\\/]+$/, '')
171}
172
173export function currentWorktree(snap: Snapshot): Worktree | undefined {
174 return snap.worktrees.find(wt => samePath(wt.path, snap.root))
175}
176
177/** What pressing a branch row can offer next. */
178export type BranchAction =
179 | { kind: 'current' }
180 | { kind: 'open'; path: string }
181 | { kind: 'switch' }
182
183export function branchAction(snap: Snapshot, branch: Branch): BranchAction {
184 if (branch.worktree && samePath(branch.worktree, snap.root)) return { kind: 'current' }
185 if (branch.worktree) return { kind: 'open', path: branch.worktree }
186 return { kind: 'switch' }
187}
188
189/** A pane width the options can ask for, clamped to something drawable. */
190export function paneColumns(value: unknown): number {
191 const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 44
192 return Math.min(120, Math.max(28, n))
193}
194
195export function maxBranches(value: unknown): number {
196 const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 40
197 return Math.min(200, Math.max(1, n))
198}
199types/index.d.ts 12 lines1export type GitSidebarState = {
2 isEnabled: boolean
3}
4
5declare module 'claude-code' {
6 interface PluginState {
7 'git-sidebar': {
8 isEnabled: boolean
9 }
10 }
11}
12