SLOPSHOPPER

git-sidebar

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…

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-20Covre81/claude-code-mods/git-sidebar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-sidebar
│ ┃ git ✕ › fix the failing auth test and add an audit log call │ ┃ Branches Arquivos │ ┃ app ● git-sidebar: git-sidebar loaded: /git-sidebar opens the pane │ ┃ ⏺ Read(src/auth.ts) │ ┃ Worktrees (0) ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Branches (0) ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ refresh ] [ close ] ⎿ 3 pass, 1 fail │ ┃ ● here * current + other worktree │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /git-sidebar │ ⎿ git-sidebar: 0 worktrees, 0 branches · click a row, or Tab and E │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ git-sidebar: git: app · 0 worktrees · 0 branches

Draws

Pane · git
Branches Arquivos app Worktrees (0) Branches (0) [ refresh ] [ close ] ● here * current + other worktree
README

git-sidebar

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.

Where it draws

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.

What a click does

RowClick or Enter
worktreemoves the session there with the engine's own /cd (the ● row is where you are)
branchshows its upstream, age and last 5 commits, plus one action
→ switchgit switch <branch> in the current worktree, only when that worktree has no uncommitted changes
→ open worktreewhen another worktree has the branch checked out (+), /cd there, since git would refuse the switch
refresh (r)reads git again
closecloses 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.

What it runs

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.

Options

  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.

Install

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.

Tests

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.

Source 5 files
hooks/register.tsx 442 lines
1/**
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}
442
hooks/files.ts 74 lines
1// 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}
74
hooks/filetree.ts 112 lines
1// 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}
112
hooks/git.ts 199 lines
1// 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}
199
types/index.d.ts 12 lines
1export type GitSidebarState = {
2  isEnabled: boolean
3}
4
5declare module 'claude-code' {
6  interface PluginState {
7    'git-sidebar': {
8      isEnabled: boolean
9    }
10  }
11}
12