SLOPSHOPPER

commit-walk

A Claude Code mod: walk a Git repository worktree by worktree and commit by commit in a pane, each changed file as a diff or as the whole file before and…

paneguardcommandprocesstimer
v1.17.0MITupdated 2026-10-24mahdava/commit-walk
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · commit-walk
│ ┃ Commit walk ✕ › fix the failing auth test and add an audit log call │ ┃ ✿ [ Refresh ] [ Debug ] │ ┃ Following the newest commit ⏺ Read(src/auth.ts) │ ┃ No worktree to show. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ │Worktree none ⏺ Bash(bun test) │ ┃ ╰─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ │ ┃ ## ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ### #..# ## │ ┃ ##..###..####..# ✻ Worked for 42s · done 4:20 PM │ ┃ #..#@///// #..# │ ┃ ###@//●//●//####== › /commit-walk │ ┃ ==#....@///ᵕ///#...#=== ⎿ commit-walk: Commit walk opened. │ ┃ ==### @////// #..##== │ ┃ === I I oo │ ┃ I Iooo │ ┃ ooo ooo │ ┃ ooooo │ ┃ ●━━━━━━●━━━━━━●━━━━━━● │ ┃ │ ┃ Commit Walk │ ┃ │ ┃ No commits on this worktree yet. │ ┃ The pane follows the first one as soon as it ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Commit walk
✿ [ Refresh ] [ Debug ] Following the newest commit No worktree to show. ╭──────────────────────────────────────────────────────────╮ │Worktree none ▾ │ ╰──────────────────────────────────────────────────────────╯ ## ### #..# ## ##..###..####..# #..#@///// #..# ###@//●//●//####== ==#....@///ᵕ///#...#=== ==### @////// #..##== === I I oo I Iooo ooo ooo ooooo ●━━━━━━●━━━━━━●━━━━━━● Commit Walk No commits on this worktree yet. The pane follows the first one as soon as it lands. Press r to look again.
README

<picture><img src="assets/sunflower-emoji.svg" alt="" width="24" height="24" align="top"></picture> What is Commit Walk?

Commit Walk is a Claude Code mod, a plugin that runs inside Claude Code and draws its own pane, for walking a Git repository worktree by worktree and commit by commit. Pick a worktree, a commit and one of its changed files, then read the file as a diff, or as the whole file after or before the commit.

It is built for watching an agent's work as it lands. The pane follows new commits while Claude edits, shows what is not committed yet, staged and unstaged, one step after the newest commit, and it can step back through history without leaving the session.

Everything comes from git and is only read. The plugin gives the model no tool, writes nothing to the repository and makes no network requests.

<picture><img src="assets/sunflower-emoji.svg" alt="" width="24" height="24" align="top"></picture> How to install

/plugin install commit-walk --marketplace mahdava/commit-walk

Answer y when it asks to add the marketplace. There is no need to clone this repository: the command fetches Commit Walk from GitHub into Claude Code's own plugins folder. Cloning is only for working on the plugin itself, as docs/DEVELOP.md describes.

You need Claude Code 2.1.292 or later, which runs mods, and Git 2.36 or later. Commit Walk works in Claude Code in the terminal, the desktop app, VS Code and the mobile app. Installing from a shell or the desktop app, and removing it, are in docs/INSTALL.md.

<picture><img src="assets/sunflower-emoji.svg" alt="" width="24" height="24" align="top"></picture> How to update

From a shell:

claude plugin marketplace update commit-walk
claude plugin update commit-walk@commit-walk

Then run /reload-plugins in any open session, or start a new one.

<picture><img src="assets/sunflower-emoji.svg" alt="" width="24" height="24" align="top"></picture> How to use Commit Walk

Run /commit-walk in a Claude Code session started inside a Git repository. It opens the pane, or closes it when it is open. The pane takes the keyboard when it opens; later, click it, or press ctrl+x then Tab, to give the keyboard back to it.

Every row and button can also be clicked: a list unfolds when you click its row, and folds again when you pick an entry.

KeyAction
o / nOlder commit / newer commit
hCommit range: all, not in main, since the base branch, in turn
j / kNext file / previous file
d / a / bFile diff / file after / file before
rRefresh
xDebug: shows or hides the debug section
EscClose the pane
TabNext control
EnterPress the focused row or button
ArrowsScroll the pane

<picture><img src="assets/sunflower-emoji.svg" alt="" width="24" height="24" align="top"></picture> Resources

  • Guide: the commands, what the pane shows, how it follows new commits, and its limits
  • Install: requirements, installing from a shell or the desktop app, updating and removing
  • Git commands: every git command it runs, where they are in the code, and how to check your repository's git hooks
  • Troubleshooting: the debug section, and fixes for common problems
  • Develop: the files, the checks and working on a local copy

Found a problem, or have an idea? Open an issue.

<picture><img src="assets/sunflower-emoji.svg" alt="" width="24" height="24" align="top"></picture> Credits

<picture><img src="assets/sunflower-emoji.svg" alt="" width="24" height="24" align="top"></picture> License

The code and documentation are under the MIT License. The logos, the keyboard picture and the sunflower drawings are © Lucia Nazzaro, all rights reserved; ARTWORK-LICENSE.md says what that covers and allows. Third-party work keeps its own licence, as THIRD-PARTY-NOTICES.md lists.

Source 18 files
hooks/register.tsx 392 lines
1// commit-walk: a pane for reading a repository worktree by worktree and
2// commit by commit, each changed file as a diff or as the whole file before
3// and after. Everything shown comes from git; nothing is written anywhere.
4//
5// This file holds everything that uses `$`: the hooks, the state, loading and
6// stepping. Claude Code before 2.1.300 follows `$` only into functions of the
7// file that holds it, so this part stays in one file. The rest lives in:
8//   pane/       the pane, one part per file, put together in pane/index.tsx;
9//               it draws from the values read here and calls the actions built here
10//   state.ts    constants and the first values of the state
11//   git.ts      the git commands and their parsers
12//   labels.ts   worktrees, commits and files as one line of text
13//   welcome.ts  the welcome art; window.ts  the rows a long list shows
14//
15// Shape: the pane draws from two values the host keeps for the session
16// (`selection`, where the person is; `snapshot`, what was loaded for it), so
17// a hot reload keeps the place. Loading happens in handlers and events, never
18// while drawing; a load that a newer one overtook is dropped.
19
20import { atom, read, update } from 'claude-code'
21import type { EngineInterface, Register } from 'claude-code'
22
23import type { About, ChangedFile, Commit, Content, Expandable, GitTrace, RangeMode, Ranges, Selection, ViewMode, Worktree } from '../types'
24import { createRepo, type GitRun, type Repo } from './git'
25import { drawFailure, drawPane } from './pane'
26import { frameOf, type Actions, type PaneValues } from './pane/frame'
27import {
28  EMPTY_SNAPSHOT, GIT_TIMEOUT_MS, INITIAL_SELECTION, PANE, PANE_COLUMNS, REFRESH_DELAY_MS, TITLE, TRACE_LENGTH, UNCOMMITTED, WRITING_TOOLS,
29  type Depth,
30} from './state'
31import type { ListScroll } from './window'
32
33const selection = atom({ plugin: 'commit-walk', key: 'selection' } as const, INITIAL_SELECTION)
34const snapshot = atom({ plugin: 'commit-walk', key: 'snapshot' } as const, EMPTY_SNAPSHOT)
35const expanded = atom({ plugin: 'commit-walk', key: 'expanded' } as const, null as Expandable | null)
36const scroll = atom({ plugin: 'commit-walk', key: 'scroll' } as const, {} as Record<string, ListScroll>)
37const debugOn = atom({ plugin: 'commit-walk', key: 'debug' } as const, false)
38const trace = atom({ plugin: 'commit-walk', key: 'trace' } as const, [] as GitTrace[])
39const about = atom({ plugin: 'commit-walk', key: 'about' } as const, { pluginVersion: null, engineVersion: null, cwd: '' } as About)
40
41/** The session's working directory: where the repository is looked for. */
42let sessionCwd = ''
43/** Bumped by every load; a load whose ticket is no longer current writes nothing. */
44let generation = 0
45let refreshTimer: { cancel(): void } | null = null
46/** How deep the pending refresh reads, while one is pending. */
47let refreshDepth: 'repo' | 'changes' | null = null
48
49// ── The hooks ────────────────────────────────────────────────────────────
50
51export const register: Register = on => {
52  on('session.start', async ($, e, next) => {
53    sessionCwd = e.cwd
54    await update($, about, () => ({ pluginVersion: null, engineVersion: null, cwd: e.cwd }))
55    inBackground($, aboutHere($, e.cwd).then(found => update($, about, () => found)))
56    // Named after the plugin. (/review is refused: it is an alias of the built-in /code-review.)
57    try {
58      await $.command.register({
59        name: 'commit-walk',
60        description: 'Open or close the commit walk pane: worktrees, commits and files of this repository',
61        argumentHint: '[refresh|close|debug]',
62        immediate: true,
63      })
64    } catch (cause) {
65      $.ui.log(`commit-walk: /commit-walk is unavailable: ${messageOf(cause)}`)
66    }
67    return next(e)
68  })
69
70  on('command.run', { command: 'commit-walk' }, async ($, e) => {
71    const arg = e.args.trim()
72    const isOpen = await isPaneOpen($)
73    if (arg === 'close' || (arg === '' && isOpen)) {
74      cancelRefresh()
75      await $.ui.close({ id: PANE })
76      return { text: 'Commit walk closed.' }
77    }
78    if (arg === 'debug') {
79      const isOn = !(await read($, debugOn))
80      await update($, debugOn, () => isOn)
81      if (!isOpen) {
82        await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true, columns: PANE_COLUMNS })
83        await load($, 'repo')
84      }
85      return { text: `Commit walk debug section ${isOn ? 'shown' : 'hidden'}.` }
86    }
87    if (arg !== '' && arg !== 'refresh') return { text: 'Usage: /commit-walk [refresh|close|debug]' }
88
89    // Open first so the pane shows its loading line, then answer once it has loaded.
90    const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true, columns: PANE_COLUMNS })
91    await load($, 'repo')
92    if (!opened.isPlaced) return { text: `Commit walk is open but not drawn yet: ${opened.reason}` }
93    return { text: arg === 'refresh' ? 'Commit walk refreshed.' : 'Commit walk opened.' }
94  })
95
96  on('ui.close', { id: PANE }, async ($, e, next) => {
97    cancelRefresh()
98    return next(e)
99  })
100
101  on('tool.call', { tool: WRITING_TOOLS }, async ($, e, next) => {
102    const result = await next(e)
103    if (!result.deny && result.isError !== true && result.isReadOnly !== true) scheduleRefresh($, 'changes')
104    return result
105  })
106
107  on('turn.complete', async ($, e, next) => {
108    const result = await next(e)
109    scheduleRefresh($, 'repo')
110    return result
111  })
112
113  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
114    const place = { bodyColumns: e.props.bodyColumns, bodyRows: e.props.scroll.bodyRows, placement: e.props.placement, surface: e.surface }
115    const ui = $.ui.resolve(e)
116    try {
117      return drawPane(frameOf(await paneValues($), actionsFor($), ui, place))
118    } catch (cause) {
119      return drawFailure(ui, messageOf(cause))
120    }
121  })
122}
123
124// ── What the pane draws from, and what its buttons do ────────────────────
125
126async function paneValues($: EngineInterface): Promise<PaneValues> {
127  const isDebug = await read($, debugOn)
128  return {
129    sel: await read($, selection),
130    snap: await read($, snapshot),
131    open: await read($, expanded),
132    scrolled: await read($, scroll),
133    isDebug,
134    gitRuns: isDebug ? await read($, trace) : [],
135    where: isDebug ? await read($, about) : null,
136  }
137}
138
139function actionsFor($: EngineInterface): Actions {
140  return {
141    load: (depth, change) => inBackground($, load($, depth, change)),
142    stepCommit: delta => inBackground($, stepCommit($, delta)),
143    stepFile: delta => inBackground($, stepFile($, delta)),
144    toggleDebug: () => inBackground($, update($, debugOn, now => !now)),
145    toggleList: which => inBackground($, update($, expanded, now => (now === which ? null : which))),
146    foldLists: () => inBackground($, update($, expanded, () => null)),
147    scrollList: (prefix, at) => inBackground($, update($, scroll, all => ({ ...all, [prefix]: at }))),
148  }
149}
150
151// ── Git through the host ─────────────────────────────────────────────────
152
153function repoFor($: EngineInterface): Repo {
154  return createRepo(async (cwd, args): Promise<GitRun> => {
155    const started = Date.now()
156    // No optional locks: reading must never contend with an agent's index writes.
157    const result = await $.process.run(['git', '-c', 'core.quotepath=off', '--no-pager', ...args], {
158      cwd,
159      timeoutMs: GIT_TIMEOUT_MS,
160      env: { GIT_OPTIONAL_LOCKS: '0', GIT_PAGER: 'cat', LC_ALL: 'C' },
161    })
162    const entry: GitTrace = {
163      args: args.join(' '),
164      cwd,
165      exitCode: result.exitCode,
166      ms: Date.now() - started,
167      error: result.exitCode === 0 ? null : result.stderr.trim().split('\n')[0] ?? '',
168    }
169    await update($, trace, list => [...list, entry].slice(-TRACE_LENGTH))
170    return result
171  })
172}
173
174/** The plugin's version from its manifest and the engine's, for the debug section; null where unknown. */
175async function aboutHere($: EngineInterface, cwd: string): Promise<About> {
176  let pluginVersion: string | null = null
177  let engineVersion: string | null = null
178  try {
179    const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: unknown }
180    pluginVersion = typeof manifest.version === 'string' ? manifest.version : null
181  } catch {
182    pluginVersion = null
183  }
184  try {
185    // Absent before Claude Code 2.1.300: the call rejects there and the version reads unknown.
186    engineVersion = (await $.session.version()).version
187  } catch {
188    engineVersion = null
189  }
190  return { pluginVersion, engineVersion, cwd }
191}
192
193function messageOf(cause: unknown): string {
194  return cause instanceof Error ? cause.message : String(cause)
195}
196
197/**
198 * Starts `work` without waiting for it. A failure shows in the pane's error
199 * line instead of vanishing, or in the transcript when even that fails.
200 */
201function inBackground($: EngineInterface, work: Promise<unknown>) {
202  void work.catch(async cause => {
203    try {
204      await update($, snapshot, snap => ({ ...snap, isLoading: false, error: messageOf(cause) }))
205    } catch {
206      try {
207        $.ui.log(`commit-walk: ${messageOf(cause)}`)
208      } catch {
209        // Nowhere left to say it.
210      }
211    }
212  })
213}
214
215// ── Loading ──────────────────────────────────────────────────────────────
216
217async function contentFor(repo: Repo, cwd: string, commit: Commit, file: ChangedFile, mode: ViewMode): Promise<Content> {
218  if (mode === 'after') return repo.after(cwd, commit, file)
219  if (mode === 'before') return repo.before(cwd, commit, file)
220  return repo.diff(cwd, commit, file)
221}
222
223async function uncommittedContentFor(repo: Repo, cwd: string, file: ChangedFile, mode: ViewMode, head: string | null): Promise<Content> {
224  if (mode === 'after') return repo.uncommittedAfter(cwd, file)
225  if (mode === 'before') return repo.uncommittedBefore(cwd, file, head)
226  return repo.uncommittedDiff(cwd, file)
227}
228
229function chooseWorktree(worktrees: Worktree[], wanted: string | null, root: string): Worktree | undefined {
230  return worktrees.find(wt => wt.path === wanted) ?? worktrees.find(wt => wt.path === root) ?? worktrees[0]
231}
232
233async function load($: EngineInterface, depth: Depth, change: Partial<Selection> = {}) {
234  const ticket = ++generation
235  const was = await read($, selection)
236  const before = await read($, snapshot)
237  const want: Selection = { ...was, ...change }
238  await update($, snapshot, snap => ({ ...snap, isLoading: true }))
239
240  // Kept outside the try: a failure part way keeps what did load and says what failed.
241  let { repoRoot, worktrees, commits, ranges, files, uncommitted, head } = before
242  try {
243    const repo = repoFor($)
244
245    // A refresh after an edit reads the commits again only when HEAD moved.
246    let level = depth
247    if (level === 'changes') {
248      const shown = repoRoot === null ? undefined : chooseWorktree(worktrees, want.worktree, repoRoot)
249      if (!shown || (await repo.head(shown.path)) !== before.head) level = 'repo'
250    }
251
252    if (level === 'repo' || repoRoot === null) {
253      repoRoot = await repo.toplevel(sessionCwd || '.')
254      worktrees = await repo.worktrees(repoRoot)
255    }
256    const worktree = chooseWorktree(worktrees, want.worktree, repoRoot)
257    if (!worktree) throw new Error('No worktree to show.')
258    const isNewWorktree = worktree.path !== was.worktree
259
260    const isNewRange = want.range !== was.range
261    const isRepo = level === 'repo' || repoRoot !== before.repoRoot || isNewWorktree
262    if (isRepo) {
263      ranges = await repo.ranges(worktree.path)
264    }
265    // A range the worktree does not offer reads as its whole history.
266    const range: RangeMode = rangeRef(ranges, want.range) === null ? 'all' : want.range
267    if (isRepo || isNewRange) {
268      commits = await repo.log(worktree.path, rangeRef(ranges, range))
269    }
270    if (isRepo || level === 'changes') {
271      head = await repo.head(worktree.path)
272      uncommitted = await repo.uncommitted(worktree.path)
273    }
274
275    // An explicit pick wins; otherwise keep the place, or follow the newest commit. The
276    // uncommitted changes are kept while there are any, and shown when there is no commit.
277    const keepCommit = 'commit' in change ? want.commit : isNewWorktree || was.isFollowing ? null : was.commit
278    const isUncommitted = uncommitted.length > 0 && (keepCommit === UNCOMMITTED || commits.length === 0)
279    const commit = isUncommitted ? undefined : (commits.find(one => one.sha === keepCommit) ?? commits[0])
280    const wasHere = isUncommitted ? was.commit === UNCOMMITTED : commit !== undefined && commit.sha === was.commit
281
282    if (isUncommitted) {
283      files = uncommitted
284    } else if (commit && (!wasHere || isNewWorktree || level === 'repo' || level === 'commit')) {
285      files = await repo.changes(worktree.path, commit)
286    } else if (!commit) {
287      files = []
288    }
289    const keepFile = 'file' in change ? want.file : wasHere ? was.file : null
290    const file = files.find(one => one.path === keepFile) ?? files[0]
291
292    // A refresh that left a commit's file and view as they were keeps what they show.
293    const isSameView = level === 'changes' && !isUncommitted && wasHere && file?.path === was.file && want.mode === was.mode
294    let content: Content | null = null
295    if (isSameView) content = before.content
296    else if (isUncommitted && file) content = await uncommittedContentFor(repo, worktree.path, file, want.mode, head)
297    else if (commit && file) content = await contentFor(repo, worktree.path, commit, file, want.mode)
298    else if (commit) content = { kind: 'note', text: 'This commit changes no files (an empty or merge-only commit).' }
299    else content = { kind: 'note', text: 'This worktree has no commits yet.' }
300
301    if (ticket !== generation) return
302    await update($, selection, () => ({
303      worktree: worktree.path,
304      commit: isUncommitted ? UNCOMMITTED : (commit?.sha ?? null),
305      file: file?.path ?? null,
306      mode: want.mode,
307      isFollowing: commit !== undefined && commit.sha === commits[0]?.sha,
308      // The preference, not the fallback: a worktree without the range does not reset it.
309      range: want.range,
310    }))
311    await update($, snapshot, () => ({
312      repoRoot,
313      worktrees,
314      commits,
315      ranges,
316      range,
317      files,
318      uncommitted,
319      head,
320      content,
321      isLoading: false,
322      error: null,
323      loadedAt: Date.now(),
324    }))
325  } catch (cause) {
326    if (ticket !== generation) return
327    const reached = { repoRoot, worktrees, commits, ranges, uncommitted, head }
328    await update($, snapshot, snap => ({ ...snap, ...reached, isLoading: false, error: messageOf(cause) }))
329  }
330}
331
332/** The ref a range starts after, or null for the whole history or a range the worktree does not offer. */
333function rangeRef(ranges: Ranges, mode: RangeMode): string | null {
334  if (mode === 'notInDefault') return ranges.notInDefault?.ref ?? null
335  if (mode === 'base') return ranges.base?.ref ?? null
336  return null
337}
338
339// ── Refreshing ───────────────────────────────────────────────────────────
340
341async function isPaneOpen($: EngineInterface): Promise<boolean> {
342  return (await $.ui.panes()).some(pane => pane.id === PANE)
343}
344
345/**
346 * A refresh shortly after an edit (`changes`) or a turn's end (`repo`, which
347 * also sees new worktrees and branches). Refreshes that come close together
348 * run once, as deep as the deepest of them.
349 */
350function scheduleRefresh($: EngineInterface, depth: 'repo' | 'changes') {
351  const deepest = refreshDepth === 'repo' ? 'repo' : depth
352  cancelRefresh()
353  refreshDepth = deepest
354  refreshTimer = $.clock.after(REFRESH_DELAY_MS, () => {
355    refreshTimer = null
356    refreshDepth = null
357    inBackground($, refreshIfOpen($, deepest))
358  })
359}
360
361function cancelRefresh() {
362  refreshTimer?.cancel()
363  refreshTimer = null
364  refreshDepth = null
365}
366
367async function refreshIfOpen($: EngineInterface, depth: 'repo' | 'changes') {
368  if (await isPaneOpen($)) await load($, depth)
369}
370
371// ── Stepping (handlers: they read the current values, never drawn ones) ──
372
373/** One step older (1) or newer (-1); the uncommitted changes, when there are any, come after the newest commit. */
374async function stepCommit($: EngineInterface, delta: 1 | -1) {
375  const sel = await read($, selection)
376  const { commits, uncommitted } = await read($, snapshot)
377  const order = [...(uncommitted.length > 0 ? [UNCOMMITTED] : []), ...commits.map(one => one.sha)]
378  const at = order.indexOf(sel.commit ?? '')
379  const target = order[at + delta]
380  if (at < 0 || target === undefined) return
381  await load($, 'commit', { commit: target, file: null })
382}
383
384async function stepFile($: EngineInterface, delta: 1 | -1) {
385  const sel = await read($, selection)
386  const { files } = await read($, snapshot)
387  const at = files.findIndex(one => one.path === sel.file)
388  const target = files[at + delta]
389  if (at < 0 || !target) return
390  await load($, 'content', { file: target.path })
391}
392
hooks/git.ts 522 lines
1// Git for the commit-walk pane: the commands it runs and the parsers for
2// their output. No engine calls here: `createRepo` takes the runner, so the
3// tests drive it with canned git output.
4
5import type { ChangedFile, Commit, Content, FileStatus, Ranges, Stage, Worktree } from '../types'
6
7/** What one git run answers (the shape of `$.process.run`'s result). */
8export type GitRun = { exitCode: number; stdout: string; stderr: string; isStdoutTruncated?: boolean }
9
10/** Runs `git <args>` in `cwd`. */
11export type GitRunner = (cwd: string, args: readonly string[]) => Promise<GitRun>
12
13/** The most characters the code area is handed: the drawing holds 100,000 in all. */
14export const SOURCE_BUDGET = 60_000
15/** Commits listed per worktree. */
16export const MAX_COMMITS = 100
17/** Context lines when a full-file diff does not fit the budget. */
18const NEAR_CONTEXT = 20
19/** A blob larger than this is not read into the pane. */
20const MAX_BLOB_BYTES = 4_000_000
21/** Untracked files whose lines are counted, one git run each; the rest read as "new file". */
22const MAX_UNTRACKED_COUNTED = 50
23/** Context that takes in a whole file: the diff then holds every line of both sides. */
24const WHOLE_FILE = 1_000_000
25
26const FIELD = '\x1f'
27const LOG_FORMAT = ['%H', '%h', '%P', '%an', '%ar', '%s'].join('%x1f')
28const STATUSES = new Set<FileStatus>(['A', 'M', 'D', 'R', 'C', 'T', 'U'])
29
30export class GitError extends Error {
31  constructor(args: readonly string[], run: GitRun) {
32    const why = run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`
33    super(`git ${args[0] ?? ''}: ${why}`)
34    this.name = 'GitError'
35  }
36}
37
38// ── Parsers ──────────────────────────────────────────────────────────────
39
40/** `git worktree list --porcelain -z`: NUL-ended lines, an empty one between records. */
41export function parseWorktrees(text: string): Worktree[] {
42  const out: Worktree[] = []
43  let record: string[] = []
44  const flush = () => {
45    const path = record.find(line => line.startsWith('worktree '))?.slice(9)
46    const isBare = record.includes('bare')
47    const isPrunable = record.some(line => line === 'prunable' || line.startsWith('prunable '))
48    if (path && !isBare && !isPrunable) {
49      const ref = record.find(line => line.startsWith('branch '))?.slice(7)
50      out.push({
51        path,
52        head: record.find(line => line.startsWith('HEAD '))?.slice(5) ?? '',
53        branch: ref ? ref.replace(/^refs\/heads\//, '') : null,
54        isLocked: record.some(line => line === 'locked' || line.startsWith('locked ')),
55      })
56    }
57    record = []
58  }
59  for (const line of text.split('\0')) {
60    if (line === '') flush()
61    else record.push(line)
62  }
63  flush()
64  return out
65}
66
67/** `git log -z --format=LOG_FORMAT`: NUL between commits, 0x1f between fields. */
68export function parseLog(text: string): Commit[] {
69  return text
70    .split('\0')
71    .map(record => record.replace(/^\n+|\n+$/g, ''))
72    .filter(record => record !== '')
73    .map(record => {
74      const [sha = '', shortSha = '', parents = '', author = '', when = '', ...subject] = record.split(FIELD)
75      return {
76        sha,
77        shortSha,
78        parents: parents.split(' ').filter(Boolean),
79        author,
80        when,
81        subject: subject.join(FIELD),
82      }
83    })
84    .filter(commit => /^[0-9a-f]{7,64}$/.test(commit.sha))
85}
86
87type NameStatus = { status: FileStatus; path: string; oldPath: string | null }
88
89/** `git diff-tree -z --name-status`: a status token, then one path (two for R and C). */
90export function parseNameStatus(text: string): NameStatus[] {
91  const tokens = text.split('\0')
92  const out: NameStatus[] = []
93  for (let i = 0; i < tokens.length; i++) {
94    const token = tokens[i] ?? ''
95    const letter = token.charAt(0) as FileStatus
96    if (!STATUSES.has(letter)) continue
97    if (letter === 'R' || letter === 'C') {
98      const from = tokens[i + 1] ?? ''
99      const to = tokens[i + 2] ?? ''
100      i += 2
101      if (to !== '') out.push({ status: letter, path: to, oldPath: from })
102    } else {
103      const path = tokens[i + 1] ?? ''
104      i += 1
105      if (path !== '') out.push({ status: letter, path, oldPath: null })
106    }
107  }
108  return out
109}
110
111type NumStat = { path: string; added: number; deleted: number; isBinary: boolean }
112
113/** `git diff-tree -z --numstat`: `a\td\tpath`, or `a\td\t` then old and new paths for a rename. */
114export function parseNumstat(text: string): NumStat[] {
115  const tokens = text.split('\0')
116  const out: NumStat[] = []
117  for (let i = 0; i < tokens.length; i++) {
118    const match = /^(-|\d+)\t(-|\d+)\t(.*)$/s.exec(tokens[i] ?? '')
119    if (!match) continue
120    const [, added = '', deleted = '', inline = ''] = match
121    let path = inline
122    if (inline === '') {
123      path = tokens[i + 2] ?? ''
124      i += 2
125    }
126    const isBinary = added === '-' && deleted === '-'
127    out.push({ path, added: isBinary ? 0 : Number(added), deleted: isBinary ? 0 : Number(deleted), isBinary })
128  }
129  return out
130}
131
132type StatusEntry = { xy: string; path: string; oldPath: string | null }
133
134/**
135 * `git status --porcelain=v1 -z`: `XY path`, NUL-ended; a rename or copy is
136 * followed by its old path. X is the staged change, Y the unstaged one.
137 */
138export function parseStatus(text: string): StatusEntry[] {
139  const tokens = text.split('\0')
140  const out: StatusEntry[] = []
141  for (let i = 0; i < tokens.length; i++) {
142    const token = tokens[i] ?? ''
143    if (token.length < 4 || token.charAt(2) !== ' ') continue
144    const xy = token.slice(0, 2)
145    const path = token.slice(3)
146    const isMoved = /[RC]/.test(xy)
147    const oldPath = isMoved ? (tokens[i + 1] ?? null) : null
148    if (isMoved) i += 1
149    out.push({ xy, path, oldPath })
150  }
151  return out
152}
153
154/** The one letter a file row shows for an uncommitted file's status: the staged change's, else the unstaged one's. */
155export function statusOf(xy: string): FileStatus {
156  if (xy === '??') return 'A'
157  const letter = (xy.charAt(0) === ' ' ? xy.charAt(1) : xy.charAt(0)) as FileStatus
158  return STATUSES.has(letter) ? letter : 'M'
159}
160
161/**
162 * The new side of a whole-file diff (`--unified` past the file's length):
163 * its unchanged and added lines, in order. How the pane shows a file as it
164 * is on disk using git alone.
165 */
166export function afterFromHunks(hunks: string): string {
167  const kept: string[] = []
168  for (const line of hunks.split('\n')) {
169    if (line.startsWith(' ') || line.startsWith('+')) kept.push(line.slice(1))
170  }
171  return kept.join('\n')
172}
173
174/** Joins name-status and numstat rows by path. */
175export function joinChanges(names: NameStatus[], stats: NumStat[]): ChangedFile[] {
176  const byPath = new Map(stats.map(stat => [stat.path, stat]))
177  return names.map(name => {
178    const stat = byPath.get(name.path)
179    return {
180      ...name,
181      isBinary: stat?.isBinary ?? false,
182      added: stat?.added ?? 0,
183      deleted: stat?.deleted ?? 0,
184    }
185  })
186}
187
188// ── Fitting text into the pane ───────────────────────────────────────────
189
190/** The hunks of a one-file patch: everything from the first `@@` line on. */
191export function hunksOf(patch: string): string {
192  const lines = patch.split('\n')
193  const first = lines.findIndex(line => line.startsWith('@@'))
194  return first < 0 ? '' : lines.slice(first).join('\n').replace(/\n+$/, '')
195}
196
197const HUNK = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/
198
199/**
200 * Keeps whole lines of `hunks` up to `budget` characters. A hunk cut short
201 * gets a header counting the lines kept, so it still parses as a hunk.
202 */
203export function fitHunks(hunks: string, budget = SOURCE_BUDGET): { source: string; isTruncated: boolean } {
204  if (hunks.length <= budget) return { source: hunks, isTruncated: false }
205  const lines = hunks.split('\n')
206  const kept: string[] = []
207  let used = 0
208  let header = -1
209  let oldCount = 0
210  let newCount = 0
211  for (const line of lines) {
212    if (used + line.length + 1 > budget) break
213    if (line.startsWith('@@')) {
214      header = kept.length
215      oldCount = 0
216      newCount = 0
217    } else if (line.startsWith('-')) oldCount++
218    else if (line.startsWith('+')) newCount++
219    else if (line.startsWith(' ')) {
220      oldCount++
221      newCount++
222    }
223    kept.push(line)
224    used += line.length + 1
225  }
226  if (header >= 0) {
227    const match = HUNK.exec(kept[header] ?? '')
228    if (match) {
229      const [, oldStart = '1', newStart = '1', rest = ''] = match
230      kept[header] = `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@${rest}`
231    }
232  }
233  while (kept.length > 0 && (kept[kept.length - 1] ?? '').startsWith('@@')) kept.pop()
234  return { source: kept.join('\n'), isTruncated: true }
235}
236
237/** Keeps whole lines of `text` up to `budget` characters. */
238export function fitText(text: string, budget = SOURCE_BUDGET): { source: string; isTruncated: boolean } {
239  const trimmed = text.replace(/\n+$/, '')
240  if (trimmed.length <= budget) return { source: trimmed, isTruncated: false }
241  const cut = trimmed.lastIndexOf('\n', budget)
242  return { source: trimmed.slice(0, cut > 0 ? cut : budget), isTruncated: true }
243}
244
245// ── The repository ───────────────────────────────────────────────────────
246
247export function createRepo(run: GitRunner) {
248  const emptyTrees = new Map<string, string>()
249
250  async function git(cwd: string, args: readonly string[]): Promise<GitRun> {
251    const result = await run(cwd, args)
252    if (result.exitCode !== 0) throw new GitError(args, result)
253    return result
254  }
255
256  /** The tree a root commit is compared with: git's empty tree, in this repo's hash. */
257  async function emptyTree(cwd: string): Promise<string> {
258    const known = emptyTrees.get(cwd)
259    if (known) return known
260    const sha = (await git(cwd, ['hash-object', '-t', 'tree', '/dev/null'])).stdout.trim()
261    emptyTrees.set(cwd, sha)
262    return sha
263  }
264
265  async function baseOf(cwd: string, commit: Commit): Promise<string> {
266    return commit.parents[0] ?? (await emptyTree(cwd))
267  }
268
269  /** A diff command's output. `--no-index` exits 1 when the two sides differ, which is not a failure. */
270  async function gitDiff(cwd: string, args: readonly string[]): Promise<GitRun> {
271    const result = await run(cwd, args)
272    if (result.exitCode === 0 || (result.exitCode === 1 && args.includes('--no-index'))) return result
273    throw new GitError(args, result)
274  }
275
276  /**
277   * A one-file diff fitted to `budget`: the whole file as context when it
278   * fits, else NEAR_CONTEXT lines around each change. Null when there are no
279   * textual changes, `binary` for a binary file.
280   */
281  async function fitPatch(cwd: string, argsFor: (context: number) => string[], budget = SOURCE_BUDGET) {
282    const patch = async (context: number) => {
283      const out = (await gitDiff(cwd, argsFor(context))).stdout
284      return { hunks: hunksOf(out), isBinary: /^Binary files /m.test(out) }
285    }
286    let { hunks, isBinary } = await patch(WHOLE_FILE)
287    if (isBinary) return 'binary' as const
288    let context: 'full' | 'near' = 'full'
289    if (hunks.length > budget) {
290      hunks = (await patch(NEAR_CONTEXT)).hunks
291      context = 'near'
292    }
293    if (hunks === '') return null
294    return { context, ...fitHunks(hunks, budget) }
295  }
296
297  /** HEAD's SHA, or null before the first commit. */
298  async function head(cwd: string): Promise<string | null> {
299    const found = await run(cwd, ['rev-parse', '--verify', '--quiet', 'HEAD'])
300    return found.exitCode === 0 ? found.stdout.trim() || null : null
301  }
302
303  /** An untracked file's line counts, from git's diff of it against nothing. */
304  async function untrackedStat(cwd: string, path: string): Promise<{ added: number; deleted: number; isBinary: boolean }> {
305    const out = (await gitDiff(cwd, ['diff', '--no-index', '--numstat', '--', '/dev/null', path])).stdout
306    const match = /^(-|\d+)\t(-|\d+)\t/.exec(out)
307    if (!match || match[1] === '-') return { added: 0, deleted: 0, isBinary: match !== null }
308    return { added: Number(match[1]), deleted: Number(match[2]), isBinary: false }
309  }
310
311  /** Reads one blob as text, or says why it is not shown. */
312  async function blob(cwd: string, rev: string, path: string): Promise<Content> {
313    const spec = `${rev}:${path}`
314    const size = Number((await git(cwd, ['cat-file', '-s', spec])).stdout.trim())
315    if (size > MAX_BLOB_BYTES) {
316      return { kind: 'note', text: `${path} is ${Math.round(size / 1_000_000)} MB; too large to show here.` }
317    }
318    const result = await git(cwd, ['cat-file', 'blob', spec])
319    if (result.stdout.slice(0, 8000).includes('\0')) return { kind: 'note', text: 'Binary file: no text to show.' }
320    const fitted = fitText(result.stdout)
321    return { kind: 'file', path, source: fitted.source, isTruncated: fitted.isTruncated || result.isStdoutTruncated === true }
322  }
323
324  return {
325    async toplevel(cwd: string): Promise<string> {
326      return (await git(cwd, ['rev-parse', '--show-toplevel'])).stdout.trim()
327    },
328
329    async worktrees(root: string): Promise<Worktree[]> {
330      return parseWorktrees((await git(root, ['worktree', 'list', '--porcelain', '-z'])).stdout)
331    },
332
333    /**
334     * The ranges a worktree can start its commit list from: the default branch
335     * (origin's HEAD, else main or master, local before origin's) and the base branch, the
336     * nearest other local branch HEAD grew from. Each with the number of
337     * HEAD's commits it does not have; null where there is none.
338     */
339    async ranges(cwd: string): Promise<Ranges> {
340      const branch = (await run(cwd, ['branch', '--show-current'])).stdout.trim() || null
341      const exists = async (ref: string) => (await run(cwd, ['rev-parse', '--verify', '--quiet', ref])).exitCode === 0
342      let defaultRef: string | null = null
343      const originHead = await run(cwd, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
344      if (originHead.exitCode === 0 && originHead.stdout.trim() !== '') defaultRef = originHead.stdout.trim()
345      else {
346        // No origin/HEAD (a clone that never set it): main or master, local before remote.
347        for (const [ref, name] of [['refs/heads/main', 'main'], ['refs/heads/master', 'master'], ['refs/remotes/origin/main', 'origin/main'], ['refs/remotes/origin/master', 'origin/master']] as const) {
348          if (await exists(ref)) {
349            defaultRef = name
350            break
351          }
352        }
353      }
354
355      const countSince = async (ref: string) => {
356        const counted = await run(cwd, ['rev-list', '--count', `${ref}..HEAD`])
357        return counted.exitCode === 0 ? Number(counted.stdout.trim()) : null
358      }
359      const defaultAhead = defaultRef ? await countSince(defaultRef) : null
360      const defaultName = defaultRef?.replace(/^origin\//, '') ?? null
361      // Offered wherever there is a default branch: on it, or on a branch level with it, the
362      // range is empty, and the pane shows its welcome screen until a commit lands.
363      const notInDefault = defaultRef && defaultAhead !== null ? { ref: defaultRef, count: defaultAhead } : null
364      const isOnDefault = defaultName !== null && branch === defaultName
365
366      // One command for every branch's distance (git 2.41+); older git offers no base.
367      const listed = await run(cwd, ['for-each-ref', '--format=%(refname:short)%09%(ahead-behind:HEAD)', 'refs/heads'])
368      let base: { ref: string; count: number } | null = null
369      if (listed.exitCode === 0) {
370        let best: { ref: string; mine: number; theirs: number } | null = null
371        for (const line of listed.stdout.split('\n')) {
372          const [ref = '', counts = ''] = line.split('\t')
373          const [theirs, mine] = counts.split(' ').map(Number)
374          // `mine`: HEAD's commits the branch lacks. 0 means it already holds HEAD (this branch, or one built on it).
375          if (ref === '' || ref === branch || mine === undefined || theirs === undefined || !(mine > 0)) continue
376          if (!best || mine < best.mine || (mine === best.mine && theirs < best.theirs)) best = { ref, mine, theirs }
377        }
378        // A base that is the default branch, or as far back as it, adds nothing to "not in default".
379        if (best && best.ref !== defaultName && (defaultAhead === null || best.mine < defaultAhead)) {
380          base = { ref: best.ref, count: best.mine }
381        }
382      }
383      return { branch, isOnDefault, notInDefault, base }
384    },
385
386    /** The commits of HEAD, newest first; with `since`, only those not reachable from it (`since..HEAD`). */
387    async log(cwd: string, since: string | null = null): Promise<Commit[]> {
388      const args = ['log', '-z', `--max-count=${MAX_COMMITS}`, `--format=${LOG_FORMAT}`, since ? `${since}..HEAD` : 'HEAD', '--']
389      const result = await run(cwd, args)
390      // A branch with no commits yet has nothing to list; anything else is an error.
391      if (result.exitCode !== 0 && /does not have any commits|unknown revision/.test(result.stderr)) return []
392      if (result.exitCode !== 0) throw new GitError(args, result)
393      return parseLog(result.stdout)
394    },
395
396    async changes(cwd: string, commit: Commit): Promise<ChangedFile[]> {
397      const base = await baseOf(cwd, commit)
398      const common = ['diff-tree', '-r', '-M', '-z', '--no-ext-diff', base, commit.sha]
399      const [names, stats] = await Promise.all([
400        git(cwd, [...common, '--name-status']),
401        git(cwd, [...common, '--numstat']),
402      ])
403      return joinChanges(parseNameStatus(names.stdout), parseNumstat(stats.stdout))
404    },
405
406    async diff(cwd: string, commit: Commit, file: ChangedFile): Promise<Content> {
407      if (file.isBinary) return { kind: 'note', text: 'Binary file: no text diff.' }
408      const base = await baseOf(cwd, commit)
409      const paths = file.oldPath && file.oldPath !== file.path ? [file.oldPath, file.path] : [file.path]
410      const fitted = await fitPatch(cwd, context => ['diff-tree', '-p', '-M', '--no-color', '--no-ext-diff', `--unified=${context}`, base, commit.sha, '--', ...paths])
411      if (fitted === 'binary') return { kind: 'note', text: 'Binary file: no text diff.' }
412      if (fitted === null) {
413        const what = file.status === 'R' || file.status === 'C' ? 'Renamed without changes.' : 'No textual changes (mode or type only).'
414        return { kind: 'note', text: what }
415      }
416      return { kind: 'diff', ...fitted }
417    },
418
419    async after(cwd: string, commit: Commit, file: ChangedFile): Promise<Content> {
420      if (file.status === 'D') return { kind: 'note', text: 'This commit deletes the file.' }
421      return blob(cwd, commit.sha, file.path)
422    },
423
424    async before(cwd: string, commit: Commit, file: ChangedFile): Promise<Content> {
425      if (file.status === 'A') return { kind: 'note', text: 'The file did not exist before this commit.' }
426      const parent = commit.parents[0]
427      if (!parent) return { kind: 'note', text: 'Root commit: there is no earlier version.' }
428      return blob(cwd, parent, file.oldPath ?? file.path)
429    },
430
431    // ── Uncommitted changes: staged, unstaged and untracked, against HEAD ──
432
433    head,
434
435    /**
436     * The worktree's uncommitted files, as `git status` lists them (so what
437     * .gitignore skips is left out), with line counts against HEAD, or
438     * against the empty tree before the first commit.
439     */
440    async uncommitted(cwd: string): Promise<ChangedFile[]> {
441      const listed = parseStatus((await git(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])).stdout)
442      if (listed.length === 0) return []
443      const base = (await head(cwd)) ?? (await emptyTree(cwd))
444      const stats = parseNumstat((await git(cwd, ['diff', '-M', '-z', '--numstat', '--no-ext-diff', base])).stdout)
445      const byPath = new Map(stats.map(stat => [stat.path, stat]))
446      const untracked = listed.filter(one => one.xy === '??').slice(0, MAX_UNTRACKED_COUNTED)
447      const counted = new Map(await Promise.all(untracked.map(async one => [one.path, await untrackedStat(cwd, one.path)] as const)))
448      return listed.map(one => {
449        const stat = byPath.get(one.path) ?? counted.get(one.path)
450        return {
451          status: statusOf(one.xy),
452          path: one.path,
453          oldPath: one.oldPath,
454          xy: one.xy,
455          isBinary: stat?.isBinary ?? false,
456          added: stat?.added ?? 0,
457          deleted: stat?.deleted ?? 0,
458          ...(one.xy === '??' && !stat ? { isUncounted: true } : {}),
459        }
460      })
461    },
462
463    /** An uncommitted file's diff: the staged part against HEAD, the unstaged part against what is staged. */
464    async uncommittedDiff(cwd: string, file: ChangedFile): Promise<Content> {
465      if (file.isBinary) return { kind: 'note', text: 'Binary file: no text diff.' }
466      const xy = file.xy ?? '  '
467      const paths = file.oldPath && file.oldPath !== file.path ? [file.oldPath, file.path] : [file.path]
468      const common = ['--no-color', '--no-ext-diff']
469      const wanted: { stage: Stage; argsFor: (context: number) => string[] }[] = []
470      if (xy === '??') {
471        wanted.push({ stage: 'unstaged', argsFor: context => ['diff', '--no-index', ...common, `--unified=${context}`, '--', '/dev/null', file.path] })
472      } else {
473        if (xy.charAt(0) !== ' ') wanted.push({ stage: 'staged', argsFor: context => ['diff', '--cached', '-M', ...common, `--unified=${context}`, '--', ...paths] })
474        if (xy.charAt(1) !== ' ') wanted.push({ stage: 'unstaged', argsFor: context => ['diff', ...common, `--unified=${context}`, '--', file.path] })
475      }
476      const budget = Math.floor(SOURCE_BUDGET / Math.max(1, wanted.length))
477      const parts: Extract<Content, { kind: 'parts' }>['parts'] = []
478      for (const one of wanted) {
479        const fitted = await fitPatch(cwd, one.argsFor, budget)
480        if (fitted === 'binary') return { kind: 'note', text: 'Binary file: no text diff.' }
481        if (fitted) parts.push({ stage: one.stage, ...fitted })
482      }
483      if (parts.length > 0) return { kind: 'parts', parts }
484      if (xy === '??') return { kind: 'note', text: 'A new, empty file.' }
485      return { kind: 'note', text: /[RC]/.test(xy) ? 'Renamed without changes.' : 'No textual changes (mode or type only).' }
486    },
487
488    /** An uncommitted file as it is now: rebuilt from git's whole-file diff when it differs from what is staged. */
489    async uncommittedAfter(cwd: string, file: ChangedFile): Promise<Content> {
490      const xy = file.xy ?? '  '
491      if (xy.charAt(1) === 'D' || xy === 'D ') return { kind: 'note', text: 'The file is deleted: there is nothing on disk to show.' }
492      if (file.isBinary) return { kind: 'note', text: 'Binary file: no text to show.' }
493      if (xy === '??' || xy.charAt(1) !== ' ') {
494        const args =
495          xy === '??'
496            ? ['diff', '--no-index', '--no-color', '--no-ext-diff', `--unified=${WHOLE_FILE}`, '--', '/dev/null', file.path]
497            : ['diff', '--no-color', '--no-ext-diff', `--unified=${WHOLE_FILE}`, '--', file.path]
498        const result = await gitDiff(cwd, args)
499        if (/^Binary files /m.test(result.stdout)) return { kind: 'note', text: 'Binary file: no text to show.' }
500        const hunks = hunksOf(result.stdout)
501        if (hunks !== '') {
502          const fitted = fitText(afterFromHunks(hunks))
503          return { kind: 'file', path: file.path, source: fitted.source, isTruncated: fitted.isTruncated || result.isStdoutTruncated === true }
504        }
505        if (xy === '??') return { kind: 'note', text: 'A new, empty file.' }
506      }
507      // What is on disk matches what is staged, so the staged copy shows it.
508      return blob(cwd, '', file.path)
509    },
510
511    /** An uncommitted file as the last commit has it. */
512    async uncommittedBefore(cwd: string, file: ChangedFile, headSha: string | null): Promise<Content> {
513      if (headSha === null) return { kind: 'note', text: 'No commits yet: there is no earlier version.' }
514      const xy = file.xy ?? '  '
515      if (xy === '??' || xy.charAt(0) === 'A') return { kind: 'note', text: 'A new file: the last commit does not have it.' }
516      return blob(cwd, headSha, file.oldPath ?? file.path)
517    },
518  }
519}
520
521export type Repo = ReturnType<typeof createRepo>
522
hooks/pane/index.tsx 128 lines
1// The pane, one screen at a time: no repository, still reading, nothing to
2// walk (the welcome screen), or the walk itself.
3
4import type { ElementTable } from 'claude-code'
5
6import { printable } from '../printable'
7import { commitSection, worktreePicker } from './commits'
8import { debugButton, debugSection } from './debug'
9import { fileSection } from './files'
10import type { Frame } from './frame'
11import { header } from './header'
12import { rangeRow } from './ranges'
13import { contentView, viewButtons } from './view'
14import { chooseArt, welcomeArt } from './welcome'
15
16export function drawPane(f: Frame) {
17  const { Box, Text, Button } = f.ui
18  const { snap } = f
19
20  if (snap.repoRoot === null) {
21    const art = chooseArt(f)
22    if (snap.error) {
23      return (
24        <Box flexDirection="column" gap={1}>
25          {welcomeArt(f, art.source)}
26          <Text bold>No repository to review</Text>
27          <Text color="red">{snap.error}</Text>
28          <Text dimColor>Start Claude Code inside a Git repository, then run /commit-walk.</Text>
29          <Box flexDirection="row" gap={1}>
30            <Button key="refresh" hotkey="r" onPress={() => void f.act.load('repo')}>Try again</Button>
31            {debugButton(f)}
32          </Box>
33          {debugSection(f, art.tier)}
34        </Box>
35      )
36    }
37    return (
38      <Box flexDirection="column" gap={1}>
39        {welcomeArt(f, art.source)}
40        <Text dimColor>Reading the repository…</Text>
41        {debugSection(f, art.tier)}
42      </Box>
43    )
44  }
45
46  // Uncommitted work is walked even where the range holds no commits (on the default branch, before the first commit).
47  if (snap.commits.length === 0 && snap.uncommitted.length === 0) return nothingToWalk(f, snap.repoRoot)
48
49  // The debug section sits under the header here: below the diff it would be off screen.
50  return (
51    <Box flexDirection="column" gap={1}>
52      {header(f, snap.repoRoot)}
53      {debugSection(f, null)}
54      {worktreePicker(f)}
55      {commitSection(f)}
56      {fileSection(f)}
57      {viewButtons(f)}
58      {contentView(f)}
59      <Text dimColor wrap="truncate-end">keys: o/n commit · k/j file · d/a/b view · h range · r refresh · x debug · Esc close</Text>
60    </Box>
61  )
62}
63
64/** When drawing the pane threw: what failed and what to do, instead of a blank pane. */
65export function drawFailure(ui: ElementTable, message: string) {
66  const { Box, Text } = ui
67  return (
68    <Box flexDirection="column" gap={1}>
69      <Text bold>Commit walk could not draw this view</Text>
70      <Text>
71        Something went wrong inside commit-walk, not in your repository. Run /commit-walk refresh to try again, or
72        press Esc to close the pane.
73      </Text>
74      <Text dimColor>If it keeps happening, please report it with the detail below: github.com/mahdava/commit-walk/issues</Text>
75      <Text color="red">{`Detail: ${printable(message)}`}</Text>
76    </Box>
77  )
78}
79
80/**
81 * Nothing to walk: the welcome art, until a commit lands. Under "not in
82 * default" that is the default branch itself (no work happens there) or a
83 * branch level with it; otherwise a worktree with no commits at all.
84 */
85function nothingToWalk(f: Frame, repoRoot: string) {
86  const { Box, Text, Button } = f.ui
87  const { snap, sel } = f
88  const worktree = snap.worktrees.find(wt => wt.path === sel.worktree)
89  const where = worktree?.branch ?? 'this worktree'
90  const defaultRef = snap.ranges.notInDefault?.ref ?? 'the default branch'
91  const isNotInDefault = snap.range === 'notInDefault'
92  const art = chooseArt(f)
93  const showAll = (
94    <Box>
95      <Button key="show-all" onPress={() => void f.act.load('commit', { range: 'all', commit: null, file: null })}>
96        Show all commits
97      </Button>
98    </Box>
99  )
100  return (
101    <Box flexDirection="column" gap={1}>
102      {header(f, repoRoot)}
103      {debugSection(f, art.tier)}
104      {worktreePicker(f)}
105      {rangeRow(f)}
106      {welcomeArt(f, art.source)}
107      {isNotInDefault && snap.ranges.isOnDefault ? (
108        <Box flexDirection="column" gap={1}>
109          <Text>You're on {where}. Work happens on branches, so there is nothing new to walk here.</Text>
110          <Text dimColor>Pick a worktree above, or show the whole history.</Text>
111          {showAll}
112        </Box>
113      ) : isNotInDefault ? (
114        <Box flexDirection="column" gap={1}>
115          <Text>No commits on {where} beyond {defaultRef} yet.</Text>
116          <Text dimColor>The pane follows the first one as soon as it lands. Press r to look again.</Text>
117          {showAll}
118        </Box>
119      ) : (
120        <Box flexDirection="column">
121          <Text>No commits on {where} yet.</Text>
122          <Text dimColor>The pane follows the first one as soon as it lands. Press r to look again.</Text>
123        </Box>
124      )}
125    </Box>
126  )
127}
128
hooks/pane/frame.ts 91 lines
1// What every part of the pane draws with: the values read for this draw, the
2// actions its buttons take, the surface's elements, and the room it has.
3//
4// The pane's parts never take `$`: Claude Code before 2.1.300 follows `$`
5// only into functions of the file that holds it. register.tsx reads the
6// values and builds the actions; the parts here only draw.
7
8import type { ElementTable } from 'claude-code'
9
10import type { About, Expandable, GitTrace, Selection, Snapshot } from '../../types'
11import { printable, printableLine } from '../printable'
12import type { Depth } from '../state'
13import type { ListScroll } from '../window'
14
15/** Everything the pane draws from, read once per draw. The debug values only while it is shown. */
16export type PaneValues = {
17  sel: Selection
18  snap: Snapshot
19  open: Expandable | null
20  scrolled: Record<string, ListScroll>
21  isDebug: boolean
22  gitRuns: GitTrace[]
23  where: About | null
24}
25
26/** What the pane's buttons do; each starts its work and returns at once. */
27export type Actions = {
28  load: (depth: Depth, change?: Partial<Selection>) => void
29  stepCommit: (delta: 1 | -1) => void
30  stepFile: (delta: 1 | -1) => void
31  toggleDebug: () => void
32  toggleList: (which: Expandable) => void
33  foldLists: () => void
34  scrollList: (prefix: string, at: ListScroll) => void
35}
36
37/** Where the pane is drawn, from the render event. */
38export type Place = { bodyColumns: number; bodyRows: number; placement: string; surface: string }
39
40export type Frame = PaneValues & {
41  act: Actions
42  /** The surface's elements: Box, Text, Button, Code, which draw control characters as symbols. */
43  ui: ElementTable
44  place: Place
45  /** Columns to draw in, never fewer than 24. */
46  width: number
47  /** A framed list's inner width: lists are whole-row buttons, so a row is that wide. */
48  inner: number
49}
50
51export function frameOf(values: PaneValues, act: Actions, ui: ElementTable, place: Place): Frame {
52  const width = Math.max(24, place.bodyColumns)
53  return { ...values, act, ui: drawable(ui), place, width, inner: Math.max(10, width - 2) }
54}
55
56type Props = Record<string, unknown> & { children?: unknown }
57type Element = (props: Props) => unknown
58
59/** `children` with each string in it cleaned: nested elements and their keys are left as they are. */
60function cleanChildren(children: unknown, clean: (text: string) => string): unknown {
61  if (typeof children === 'string') return clean(children)
62  if (Array.isArray(children)) return children.map(child => cleanChildren(child, clean))
63  return children
64}
65
66/**
67 * The surface's elements, with every string they draw made safe (see
68 * printable.ts): every part of the pane draws through these, so no text from
69 * git can blank it. Keys are not drawn and stay as they are, so a row still
70 * names its real path.
71 */
72function drawable(ui: ElementTable): ElementTable {
73  const real = ui as unknown as Record<'Text' | 'Button' | 'Code', Element>
74  return {
75    ...ui,
76    Text: (props: Props) => real.Text({ ...props, children: cleanChildren(props.children, printable) }),
77    Button: (props: Props) => real.Button({ ...props, children: cleanChildren(props.children, printableLine) }),
78    Code: (props: Props) =>
79      real.Code({
80        ...props,
81        source: typeof props.source === 'string' ? printable(props.source) : props.source,
82        path: typeof props.path === 'string' ? printableLine(props.path) : props.path,
83      }),
84  } as unknown as ElementTable
85}
86
87/** `text` padded or cut to exactly `room` columns, a framed row by default. */
88export function pad(f: Frame, text: string, room = f.inner): string {
89  return text.padEnd(room).slice(0, room)
90}
91
hooks/state.ts 48 lines
1// The pane's constants and the first values of what the host keeps for it.
2//
3// The atoms themselves are declared in register.tsx, the one file that reads
4// and writes them: Claude Code requires an atom read or written in a file to
5// be declared in that same file, so it can list what each module touches.
6
7import type { Selection, Snapshot } from '../types'
8
9/**
10 * How much to load again: `repo` re-reads worktrees and commits (open,
11 * refresh, another worktree); `changes` the uncommitted files, or all of
12 * `repo` when HEAD moved (a refresh after an edit); `commit` the changed
13 * files; `content` the code area alone (another file or view).
14 */
15export type Depth = 'repo' | 'changes' | 'commit' | 'content'
16
17export const PANE = 'commit-walk'
18/** What the selection names for the uncommitted changes, in place of a commit's SHA. */
19export const UNCOMMITTED = 'uncommitted'
20export const TITLE = 'Commit walk'
21/** Width the dock asks for beside a fullscreen transcript. */
22export const PANE_COLUMNS = 84
23/** Debounce for refreshes after edits and turns. */
24export const REFRESH_DELAY_MS = 400
25/** Rows a list shows at once; more page with its edge rows. */
26export const LIST_ROWS = 8
27export const GIT_TIMEOUT_MS = 20_000
28/** Git commands the debug section keeps. */
29export const TRACE_LENGTH = 10
30/** Tools whose successful, non-read-only calls may have changed the repository. */
31export const WRITING_TOOLS = ['Edit', 'Write', 'NotebookEdit', 'Bash'] as const
32
33export const INITIAL_SELECTION: Selection = { worktree: null, commit: null, file: null, mode: 'diff', isFollowing: true, range: 'notInDefault' }
34export const EMPTY_SNAPSHOT: Snapshot = {
35  repoRoot: null,
36  worktrees: [],
37  commits: [],
38  ranges: { branch: null, isOnDefault: false, notInDefault: null, base: null },
39  range: 'all',
40  files: [],
41  uncommitted: [],
42  head: null,
43  content: null,
44  isLoading: false,
45  error: null,
46  loadedAt: null,
47}
48
hooks/window.ts 47 lines
1// Long lists are drawn as a window of rows around the selected entry, with a
2// row at either end that steps past the edge, so a list of any length stays
3// a few rows tall in the pane.
4
5export type ListWindow<T> = {
6  /** The entries drawn. */
7  items: T[]
8  /** Index of the first drawn entry in the whole list. */
9  start: number
10  /** Entries left out before and after the window. */
11  before: number
12  after: number
13}
14
15/**
16 * The window of `items` around index `at` that fits `max` rows, the two edge
17 * rows included. A list that fits is drawn whole.
18 */
19export function windowAround<T>(items: readonly T[], at: number, max: number): ListWindow<T> {
20  if (items.length <= max) return { items: [...items], start: 0, before: 0, after: 0 }
21  const size = Math.max(1, max - 2)
22  const centre = Math.min(Math.max(at, 0), items.length - 1)
23  const start = Math.min(Math.max(centre - Math.floor(size / 2), 0), items.length - size)
24  const end = start + size
25  return { items: items.slice(start, end), start, before: start, after: items.length - end }
26}
27
28/** Where a list was scrolled to, and which entry was selected then. */
29export type ListScroll = { start: number; anchor: string | null }
30
31/**
32 * The first row a list of `length` entries draws, `size` rows at a time. A
33 * scroll the person made holds while the selection is the one it was made
34 * with, or while a new selection is in view; a selection out of view brings
35 * the list back around it.
36 */
37export function scrollStart(length: number, at: number, size: number, scrolled: ListScroll | undefined, selected: string | null): number {
38  if (length <= size) return 0
39  const last = length - size
40  const clamp = (n: number) => Math.min(Math.max(n, 0), last)
41  const centred = clamp(Math.max(at, 0) - Math.floor(size / 2))
42  if (scrolled === undefined) return centred
43  const start = clamp(scrolled.start)
44  if (scrolled.anchor === selected) return start
45  return at >= start && at < start + size ? start : centred
46}
47
hooks/printable.ts 73 lines
1// Text from git made safe to draw, and honest to read.
2//
3// Claude Code refuses a whole drawing when any of its text holds a control
4// character other than tab, line feed and carriage return, and the pane comes
5// out blank. A commit subject, an author, a path or a file can hold one (a
6// colour code, a form feed), so each is shown as its visible symbol instead:
7// ESC as ␛, form feed as ␌.
8//
9// Some other characters draw as nothing, or reorder the text around them, so
10// code can read differently from what it does ("Trojan Source"). Each is shown
11// as a short marker such as ⟨RLO⟩, so the pane shows the text in the order
12// git holds it. The zero-width joiner and non-joiner are left alone: emoji and
13// scripts such as Persian and Hindi need them.
14
15/** What Claude Code refuses to draw: C0 controls but tab, LF and CR; DEL; the C1 controls. */
16const UNDRAWABLE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/
17
18/** Characters that draw as nothing or reorder text, by the marker each is shown as. */
19const INVISIBLE: Record<string, string> = {
20  '­': 'SHY',
21  '؜': 'ALM',
22  '᠎': 'MVS',
23  '​': 'ZWSP',
24  '‎': 'LRM',
25  '‏': 'RLM',
26  '
': 'LS',
27  '
': 'PS',
28  '‪': 'LRE',
29  '‫': 'RLE',
30  '‬': 'PDF',
31  '‭': 'LRO',
32  '‮': 'RLO',
33  '⁠': 'WJ',
34  '⁡': 'FA',
35  '⁢': 'IT',
36  '⁣': 'IS',
37  '⁤': 'IP',
38  '⁦': 'LRI',
39  '⁧': 'RLI',
40  '⁨': 'FSI',
41  '⁩': 'PDI',
42  '': 'BOM',
43}
44
45const HIDDEN = new RegExp(`${UNDRAWABLE.source.slice(0, -1)}${Object.keys(INVISIBLE).join('')}]`)
46const HIDDEN_ALL = new RegExp(HIDDEN.source, 'g')
47const BREAKS = /[\t\n\r]/g
48
49/** A hidden character's symbol: its marker, its Control Pictures glyph, or � for the C1 range, which has none. */
50function symbolOf(char: string): string {
51  const marker = INVISIBLE[char]
52  if (marker) return `⟨${marker}⟩`
53  const code = char.charCodeAt(0)
54  if (code < 0x20) return String.fromCharCode(0x2400 + code)
55  if (code === 0x7f) return '␡'
56  return '�'
57}
58
59/** Whether `text` holds a character the pane shows as a symbol or marker. */
60export function hasHidden(text: string): boolean {
61  return HIDDEN.test(text)
62}
63
64/** Text for a block (a file, a diff, a message): tabs and line breaks stay. */
65export function printable(text: string): string {
66  return text.replace(HIDDEN_ALL, symbolOf)
67}
68
69/** Text for one line (a button's label): tabs and line breaks become spaces too. */
70export function printableLine(text: string): string {
71  return printable(text).replace(BREAKS, ' ')
72}
73
hooks/pane/commits.tsx 78 lines
1// The worktree and the commit: each a foldable list, the commit with its
2// author, date and position, and Older and Newer. The uncommitted changes
3// are the list's first row and the step after the newest commit.
4
5import type { Commit } from '../../types'
6import { commitLabel, fileCount, uncommittedLabel, worktreeLabel } from '../labels'
7import { UNCOMMITTED } from '../state'
8import type { Frame } from './frame'
9import { foldable } from './list'
10import { rangeRow } from './ranges'
11
12export function worktreePicker(f: Frame) {
13  const { snap, sel } = f
14  const worktree = snap.worktrees.find(wt => wt.path === sel.worktree)
15  return foldable(f, {
16    which: 'worktree',
17    prefix: 'worktree',
18    label: 'Worktree  ',
19    current: worktree ? worktreeLabel(worktree) : 'none',
20    items: snap.worktrees,
21    at: snap.worktrees.findIndex(wt => wt.path === worktree?.path),
22    keyOf: wt => wt.path,
23    textOf: worktreeLabel,
24    noun: { one: 'worktree', many: 'worktrees' },
25    onPick: wt => void f.act.load('repo', { worktree: wt.path, commit: null, file: null }),
26  })
27}
28
29/** A row of the commit list: a commit, or the uncommitted changes. */
30type Row = Commit | typeof UNCOMMITTED
31
32export function commitSection(f: Frame) {
33  const { Box, Text, Button } = f.ui
34  const { snap, sel } = f
35  const isUncommitted = sel.commit === UNCOMMITTED
36  const pending = snap.uncommitted.length
37  // The rows: the uncommitted changes first while there are any, then the commits, newest first.
38  const rows: Row[] = [...(pending > 0 ? [UNCOMMITTED as Row] : []), ...snap.commits]
39  const at = snap.commits.findIndex(one => one.sha === sel.commit)
40  const commit = snap.commits[at]
41  const keyOf = (row: Row) => (row === UNCOMMITTED ? UNCOMMITTED : row.sha)
42  const textOf = (row: Row) => (row === UNCOMMITTED ? uncommittedLabel(pending) : commitLabel(row))
43  // From the newest commit, Newer steps to the uncommitted changes, and says so.
44  const isNewest = at === 0
45  const newerLabel = isNewest && pending > 0 ? 'Uncommitted' : 'Newer'
46  return (
47    <Box flexDirection="column">
48      {rangeRow(f)}
49      {foldable(f, {
50        which: 'commit',
51        prefix: 'commit',
52        label: 'Commit    ',
53        current: isUncommitted ? textOf(UNCOMMITTED) : commit ? commitLabel(commit) : 'none',
54        items: rows,
55        at: rows.findIndex(row => keyOf(row) === sel.commit),
56        keyOf,
57        textOf,
58        noun: { one: 'commit', many: 'commits' },
59        onPick: row => void f.act.load('commit', { commit: keyOf(row), file: null }),
60      })}
61      {isUncommitted ? (
62        <Text dimColor wrap="truncate-end">{`Not committed yet · ${fileCount(pending)}`}</Text>
63      ) : commit ? (
64        <Text dimColor wrap="truncate-end">
65          {commit.author} · {commit.when}
66          {commit.parents.length > 1 ? ' · merge, against its first parent' : ''}
67          {commit.parents.length === 0 ? ' · root commit' : ''}
68          {` · ${at + 1} of ${snap.commits.length}`}
69        </Text>
70      ) : null}
71      <Box flexDirection="row" gap={1}>
72        <Button key="older" hotkey="o" dimColor={isUncommitted ? snap.commits.length === 0 : at >= snap.commits.length - 1} onPress={() => void f.act.stepCommit(1)}>Older</Button>
73        <Button key="newer" hotkey="n" dimColor={isUncommitted || (isNewest && pending === 0) || at < 0} onPress={() => void f.act.stepCommit(-1)}>{newerLabel}</Button>
74      </Box>
75    </Box>
76  )
77}
78
hooks/pane/debug.tsx 44 lines
1// The debug section: where the pane runs, what it holds, the last git
2// commands. Drawn on every screen while it is on.
3
4import type { Frame } from './frame'
5
6export function debugButton(f: Frame) {
7  const { Button } = f.ui
8  return (
9    <Button key="debug" hotkey="x" dimColor onPress={() => void f.act.toggleDebug()}>
10      {f.isDebug ? 'Hide debug' : 'Debug'}
11    </Button>
12  )
13}
14
15/** The section itself, or nothing while it is off. `artTier`: the welcome art on screen, if any. */
16export function debugSection(f: Frame, artTier: string | null) {
17  const { Box, Text } = f.ui
18  const { sel, snap, open, gitRuns, where } = f
19  if (!f.isDebug || where === null) return null
20  const line = (text: string, key: string) => <Text key={key} dimColor wrap="truncate-end">{text}</Text>
21  const counts = `${snap.worktrees.length} worktrees · ${snap.commits.length} commits · ${snap.files.length} files`
22  const age = snap.loadedAt === null ? 'never loaded' : `loaded ${Math.round((Date.now() - snap.loadedAt) / 1000)}s ago`
23  return (
24    <Box flexDirection="column" borderStyle="single" borderDimColor>
25      <Text bold dimColor>Debug</Text>
26      {line(`commit-walk ${where.pluginVersion ?? '?'} · Claude Code ${where.engineVersion ?? 'unknown (before 2.1.300)'} · ${f.place.surface}`, 'd-versions')}
27      {line(`pane ${f.place.bodyColumns}×${f.place.bodyRows} ${f.place.placement}${artTier ? ` · welcome art: ${artTier}` : ''}`, 'd-pane')}
28      {line(`cwd ${where.cwd}`, 'd-cwd')}
29      {line(`repo ${snap.repoRoot ?? 'none'}`, 'd-repo')}
30      {line(`worktree ${sel.worktree ?? '-'} · commit ${sel.commit?.slice(0, 7) ?? '-'} · file ${sel.file ?? '-'}`, 'd-sel')}
31      {line(`view ${sel.mode} · ${sel.isFollowing ? 'following' : 'pinned'} · open list ${open ?? 'none'}`, 'd-view')}
32      {line(`range shown ${snap.range} · preferred ${sel.range}`, 'd-range')}
33      {line(`${counts} · ${age}${snap.isLoading ? ' · loading' : ''}`, 'd-counts')}
34      {snap.error ? <Text key="d-error" color="red" wrap="truncate-end">error {snap.error}</Text> : null}
35      {line(gitRuns.length === 0 ? 'git: nothing run yet' : `git, last ${gitRuns.length}, newest last:`, 'd-git')}
36      {gitRuns.map((run, i) => (
37        <Text key={`d-git-${i}`} color={run.exitCode === 0 ? undefined : 'red'} dimColor={run.exitCode === 0} wrap="truncate-end">
38          {`  ${String(run.exitCode).padStart(3)} ${String(run.ms).padStart(5)}ms  ${run.args}${run.error ? `  · ${run.error}` : ''}`}
39        </Text>
40      ))}
41    </Box>
42  )
43}
44
hooks/pane/files.tsx 46 lines
1// The commit's changed files: Prev and Next, the list with each file's status
2// letter in colour (git's two letters for uncommitted files), and the
3// selected file's whole name when its row is cut.
4
5import { fileName, fileText } from '../labels'
6import { LIST_ROWS } from '../state'
7import type { Frame } from './frame'
8import { rowList } from './list'
9
10/** The colour of git's status letter in the file list. */
11const STATUS_COLOR: Partial<Record<string, string>> = { A: '#2EA043', D: '#EF4444', R: '#3B82F6', C: '#3B82F6', M: '#E08A00' }
12
13export function fileSection(f: Frame) {
14  const { Box, Text, Button } = f.ui
15  const { snap, sel } = f
16  const at = snap.files.findIndex(one => one.path === sel.file)
17  const file = snap.files[at]
18  return (
19    <Box flexDirection="column">
20      <Box flexDirection="row" gap={1}>
21        <Text>Files     </Text>
22        <Button key="prev-file" hotkey="k" dimColor={at <= 0} onPress={() => void f.act.stepFile(-1)}>Prev</Button>
23        <Button key="next-file" hotkey="j" dimColor={at < 0 || at >= snap.files.length - 1} onPress={() => void f.act.stepFile(1)}>Next</Button>
24        <Text dimColor>{snap.files.length > 0 ? `${at + 1} of ${snap.files.length}` : 'none'}</Text>
25      </Box>
26      <Box flexDirection="column" borderStyle="round" borderDimColor>
27        {rowList(f, {
28          prefix: 'file',
29          items: snap.files,
30          at,
31          keyOf: one => one.path,
32          // An uncommitted file shows git's two letters, staged then unstaged (` M`, `MM`, `??`).
33          tagOf: one => ({ text: one.xy ?? one.status, color: STATUS_COLOR[one.status] }),
34          textOf: fileText,
35          noun: { one: 'file', many: 'files' },
36          onPick: one => void f.act.load('content', { file: one.path }),
37        })}
38      </Box>
39      {file && fileText(file).length > f.inner - (snap.files.length > LIST_ROWS ? 2 : 0) - 6 ? (
40        // The selected file's whole name when its row is cut: renames and deep paths, for every surface and the keyboard.
41        <Text dimColor wrap="wrap">{`▶ ${fileName(file)}`}</Text>
42      ) : null}
43    </Box>
44  )
45}
46
hooks/pane/header.tsx 32 lines
1// The top of the pane: the repository's name, Refresh and Debug, and whether
2// the pane follows the newest commit or shows the uncommitted changes.
3
4import { baseName, clip } from '../labels'
5import { UNCOMMITTED } from '../state'
6import { TONE_COLOR } from '../welcome'
7import { debugButton } from './debug'
8import type { Frame } from './frame'
9
10export function header(f: Frame, repoRoot: string) {
11  const { Box, Text, Button } = f.ui
12  const { sel, snap } = f
13  return (
14    <Box flexDirection="column">
15      <Box flexDirection="row" gap={1}>
16        {/* The name gives way to the buttons: in a narrow pane it is cut with … rather than wrapped mid-word. */}
17        <Box flexShrink={1} minWidth={0}>
18          <Text bold wrap="truncate-end">
19            <Text color={TONE_COLOR.petal}>✿</Text> {clip(baseName(repoRoot), f.width - 16)}
20          </Text>
21        </Box>
22        <Box flexShrink={0} flexDirection="row" gap={1}>
23          <Button key="refresh" hotkey="r" onPress={() => void f.act.load('repo')}>Refresh</Button>
24          {debugButton(f)}
25        </Box>
26      </Box>
27      <Text dimColor wrap="truncate-end">{snap.isLoading ? 'Loading…' : sel.commit === UNCOMMITTED ? 'Showing what is not committed yet' : sel.isFollowing ? 'Following the newest commit' : 'Pinned to a past commit'}</Text>
28      {snap.error ? <Text color="red">{snap.error}</Text> : null}
29    </Box>
30  )
31}
32
hooks/pane/ranges.tsx 37 lines
1// Which commits the list starts from: every option the worktree offers, one
2// button each, the chosen one primary. Hidden where only the whole history is
3// on offer.
4
5import type { RangeMode } from '../../types'
6import type { Frame } from './frame'
7
8export function rangeRow(f: Frame) {
9  const { Box, Text, Button } = f.ui
10  const { snap } = f
11  const options: { mode: RangeMode; label: string }[] = [
12    { mode: 'all', label: 'all' },
13    ...(snap.ranges.notInDefault ? [{ mode: 'notInDefault' as const, label: `not in ${snap.ranges.notInDefault.ref} · ${snap.ranges.notInDefault.count}` }] : []),
14    ...(snap.ranges.base ? [{ mode: 'base' as const, label: `since ${snap.ranges.base.ref} · ${snap.ranges.base.count}` }] : []),
15  ]
16  if (options.length < 2) return null
17  // `h` cycles through them: it sits on the option after the chosen one.
18  // Switching keeps the selected commit when the new range has it.
19  const next = options[(options.findIndex(option => option.mode === snap.range) + 1) % options.length]?.mode
20  return (
21    <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
22      <Text dimColor>Commits  </Text>
23      {options.map(option => (
24        <Button
25          key={`range-${option.mode}`}
26          {...(option.mode === next ? { hotkey: 'h' } : {})}
27          variant={snap.range === option.mode ? 'primary' : undefined}
28          dimColor={snap.range !== option.mode}
29          onPress={() => void f.act.load('commit', { range: option.mode })}
30        >
31          {option.label}
32        </Button>
33      ))}
34    </Box>
35  )
36}
37