SLOPSHOPPER

git-file-tree

A git pane for Claude Code: review what Claude changed (per prompt, uncommitted or the whole branch), read diffs and rendered Markdown, then stage, commit…

newpanespinnerguardcommandtoast
v0.3.0MITupdated 2026-10-09CosminBd/claude-git-file-tree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-file-tree
│ ┃ Files ✕ › fix the failing auth test and add an audit log call │ ┃ no commits yet │ ┃ vs HEAD ⏺ Read(src/auth.ts) │ ┃ a: view v: base h: History f: Filter ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Message: what this commit does ⏎ commit │ ┃ w: Write message c: Commit Commit & push ✻ Worked for 42s · done 4:20 PM │ ┃ Nothing staged: press + before a file, or │ ┃ Stage all, to choose what to commit. › /files │ ┃ p: Push branch Create PR │ ┃ Click a file to preview. Click the pane (or │ ┃ ctrl+x tab) for the letter keys. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits toggle

Draws

Pane · Files
no commits yet vs HEAD a: view v: base h: History f: Filter Message: what this commit does ⏎ commit w: Write message c: Commit Commit & push [ ] Amend last c Nothing staged: press + before a file, or Stage all, to choose what to commit. p: Push branch Create PR Click a file to preview. Click the pane (or ctrl+x tab) for the letter keys.
README

claude-git-file-tree

claude-git-file-tree is a git pane for Claude Code. The pane shows next to the conversation. Use it to examine the changes that Claude makes. Then use it to stage, commit, and push the changes.

Purpose

The purpose of this mod is to make the AI development workflow simpler.

When Claude writes your code, your work changes. You write less code. You examine more changes, and you commit them. For these tasks, you usually open an IDE or a git client, for example GitKraken or Fork.

This mod does the most frequent of these tasks in Claude Code. It can partially replace your IDE and your git client. You do not need a second app or a second terminal.

The changed files, staged and not staged, with the commit box

The pane shows:

  • The changed files, with a letter and a color for each type of change.
  • The diff of each file.
  • The source of each file, with syntax highlighting.
  • Markdown files, rendered. The pane has a built-in Markdown viewer.
  • The commit history of the current branch.
  • The git operations for the current branch: stage, commit, push, and create a PR.

Install

  1. Start Claude Code.
  2. Type this command:
   /plugin install git-file-tree --marketplace CosminBd/claude-git-file-tree
  1. Type y to add the marketplace.
  2. Select a scope. The user scope makes the pane available in all your projects.

Open the pane

  • Click ▤ Files at the right of the status line, below the prompt.
  • Or, type /files.
  • To open one file, type /files path/to/file.

To use the letter keys, click the pane first. You can also push ctrl+x tab.

In fullscreen mode, the pane shows next to the conversation. In other modes, the pane shows above the prompt.

Examine the changes

KeyFunction
vChange what the tree compares with. See the table below
aShow only the changed files, or show all the files
fFind files by name. For example, chk finds checkout.ts
hShow the commit history

The tree can compare with three references:

ViewThe tree shows
UncommittedThe changes against HEAD. This is the default view
BranchAll the changes of the branch against main or master
PromptOnly the changes since your last prompt

The letter before each file shows the type of change:

LetterChange
MModified
AAdded
UNew. Git does not track the file
DDeleted
RRenamed

The tree refreshes after each edit that Claude makes. While the pane is open, the tree also refreshes every 3 seconds.

Changes since your last prompt

The Prompt view shows only the files that changed during the last turn. It also shows new files that git does not track.

When you send a prompt, the mod records a git tree of your files. The mod uses its own index (.git/git-file-tree-*.index). Your staged changes do not change.

Examine a file

Click a file to open it. A changed file opens on its diff.

The diff of a file, with syntax highlighting

KeyFunction
dShow the diff
sShow the source, with syntax highlighting
mShow the rendered Markdown
n / pGo to the next or the previous changed file
tStage or unstage the file
xDiscard the changes to the file. Push x two times to confirm
cCopy the path of the file
oOpen the file in its default app
bGo back to the tree

Markdown viewer

A Markdown file opens rendered. The Markdown viewer fits the text to the width of the pane:

A Markdown file, rendered: headings, a table, and a code block with syntax highlighting

  • Each heading shows its level.
  • Table cells wrap to the width of the pane. If a table is too wide, each row shows as a list of fields.
  • Code blocks have syntax highlighting.

Terminals that can show images (kitty, Ghostty) show PNG files in the pane.

Commit and push

The git operations are available only in the Uncommitted view.

To stage changes:

  1. Click + before a file or a folder. The changes go to the Staged section.
  2. To unstage a file or a folder, click − before it.
  3. To stage all the changes, click Stage all.

To commit the staged changes:

  1. Type the commit message in the Message field.
  2. Click Commit, or push c.
  3. To commit and push, click Commit & push.

Claude can write the commit message for you:

  1. Click Write message, or push w.
  2. Claude writes the message from the staged diff and from this conversation. The message can tell why you made the changes.
  3. Examine the message and edit it.
  4. Click Commit.

To change the last commit, select Amend last commit before you click Commit.

The buttons below the commit box show only when they can do an operation:

ButtonFunction
PushPush the branch. The key is p
Push branchPush a branch that has no upstream. The upstream becomes origin
Force pushPush with --force-with-lease --force-if-includes. Use it after an amend
PullPull with --ff-only
FetchFetch from the remote
Undo commitRemove the last commit if it is not pushed (git reset --soft). The changes stay staged
Create PRCreate a PR for the branch. You must install the GitHub CLI (gh) and log in
PR #14 openOpen the PR of the branch in the browser

The line below the buttons shows the result of the last operation. If an operation fails, the line shows the git message.

Safety

  • The mod operates only on the current branch. It does not change branches, merge, or rebase.
  • Force push, undo commit, and discard need two clicks in 4 seconds.
  • Git operates with the repository hooks off.
  • Git cannot ask for a password. If the remote needs a password, use an SSH key or a credential helper.

History

Push h to show the last 50 commits of the current branch. The commits that are not pushed show first.

The history: the commits that are not pushed, then the commits on origin/main

To examine a commit:

  1. Click the commit. The tree shows only the files that the commit changed.
  2. Click a file. The diff compares the commit with its parent.
  3. To go back to your changes, push v.

Tokens

The pane does not use the model. It runs git and reads files on your computer. The /files command adds no text to the conversation.

Only Write message uses the model. It sends the staged diff (up to 24,000 characters) and the subjects of the last 8 commits. It also sends the conversation.

Requirements

  • Claude Code with support for mods (function hooks).
  • Git. Start Claude Code in a git repository.
  • For pull requests: the GitHub CLI (gh). You must log in.

Development

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT

Source 11 files
hooks/register.tsx 142 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import * as A from './actions'
5import type { Io } from './actions'
6import * as S from './state'
7import { renderFooter, renderPane } from './view'
8
9/** How often git is read while the pane is open; four times slower while it is closed. */
10const POLL_MS = 3000
11
12const ui = atom({ plugin: 'git-file-tree', key: 'ui' } as const, S.INITIAL)
13// The session keeps this value across reloads: a field added since it was written reads as its default.
14
15export const register: Register = on => {
16  let ticks = 0
17  /** The engine's closures, built once the session starts; every hook after it works through them. */
18  let io: Io | null = null
19
20  on('session.start', async ($, e, next) => {
21    const engine: Io = {
22      get: async () => ({ ...S.INITIAL, ...(await read($, ui)) }),
23      set: change => update($, ui, state => change({ ...S.INITIAL, ...state })),
24      run: (argv, cwd, init) => $.process.run(argv, { ...(cwd === undefined ? {} : { cwd }), ...init }),
25      now: () => $.clock.now(),
26      writeText: (path, text) => $.fs.write(path, text),
27      stat: path => $.fs.stat(path),
28      readText: path => $.fs.read(path),
29      readBase64: async path => (await $.fs.read(path, { as: 'bytes' })).base64,
30      storeGet: key => $.store.get(key),
31      storeSet: (key, value) => $.store.set(key, value),
32      cwd: () => $.session.cwd(),
33      scroll: args => $.ui.scroll(args),
34      focus: args => $.ui.focus(args),
35      open: args => $.ui.open(args),
36      close: id => $.ui.close({ id }),
37      toast: text => $.ui.toast(text),
38      copy: async (text, surface) => (await $.ui.copy({ text, surface })).isCopied,
39      fork: async prompt => {
40        const reply = await $.model.fork({ prompt })
41
42        return reply.isAnswered ? { text: reply.text } : { reason: reply.reason }
43      },
44    }
45    io = engine
46
47    await $.command.register({
48      name: 'files',
49      description: 'Open the git pane: changed files, diffs, commit and push',
50      argumentHint: '[path]',
51      immediate: true,
52    })
53    await A.loadPrefs(engine)
54    void A.takePromptBase(engine, 'session').catch(() => undefined)
55    void A.refresh(engine).catch(() => undefined)
56    $.clock.every(POLL_MS, () => {
57      ticks += 1
58      void (async () => {
59        await A.expireConfirm(engine)
60        if ((await S.pick(engine, 'isOpen')) || ticks % 4 === 0) await A.refresh(engine)
61      })().catch(() => undefined)
62    })
63
64    return next(e)
65  })
66
67  on('command.run', { command: 'files' }, async ($, e) => {
68    if (io === null) return { text: 'The file tree is still starting.' }
69    const isPlaced = await A.openPane(io)
70    const target = e.args.trim()
71    if (isPlaced && target !== '') {
72      await A.refresh(io)
73      const snap = await S.pick(io, 'snapshot')
74      const cwd = await io.cwd()
75      const absolute = target.startsWith('/') ? target : `${cwd}/${target}`
76      const root = snap?.root ?? cwd
77      const relative = absolute.startsWith(`${root}/`) ? absolute.slice(root.length + 1).replace(/^\.\//, '') : target
78      await A.openFile(io, relative)
79    }
80
81    // No text: nothing of this reaches the model.
82    return {}
83  })
84
85  // Each prompt starts a fresh record of what Claude reads, writes and commits.
86  on('prompt.submit', async ($, e, next) => {
87    if (io !== null) await A.clearActivity(io).catch(() => undefined)
88    // Not awaited: the snapshot lands long before Claude's first edit, and the prompt goes at once.
89    if (io !== null) void A.takePromptBase(io, 'prompt').catch(() => undefined)
90
91    return next(e)
92  }).catch(($, e, next) => next(e))
93
94  // After Claude's reads, edits and commands: mark the files and refresh, never holding up the tool.
95  on('tool.call', async ($, e, next) => {
96    const result = await next(e)
97    if (io === null) return result
98    const tool = String(e.tool)
99    if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') {
100      const path = (e as { file_path?: unknown }).file_path
101      void A.noteEdit(io, typeof path === 'string' ? path : null).catch(() => undefined)
102    } else if (tool === 'NotebookEdit') {
103      const path = (e as { notebook_path?: unknown }).notebook_path
104      void A.noteEdit(io, typeof path === 'string' ? path : null).catch(() => undefined)
105    } else if (tool === 'Read') {
106      const path = (e as { file_path?: unknown }).file_path
107      if (typeof path === 'string') void A.noteRead(io, path).catch(() => undefined)
108    } else if (tool === 'Bash') {
109      const command = (e as { command?: unknown }).command
110      const done = result as { text?: string; isReadOnly?: true; isError?: true }
111      void A.noteShell(io, typeof command === 'string' ? command : '', done.text ?? '', done.isReadOnly === true, done.isError === true)
112        .catch(() => undefined)
113    }
114
115    return result
116  }).catch(($, e, next) => next(e))
117
118  on('ui.close', { id: 'files' }, async ($, e, next) => {
119    const result = await next(e)
120    await update($, ui, state => ({ ...state, isOpen: false }))
121
122    return result
123  }).catch(($, e, next) => next(e))
124
125  on('ui.render', { component: 'Pane', requestId: 'files' }, async ($, e, next) => {
126    if (io === null) return next(e)
127    // Reads go through this drawing's own `$`, so a later write draws it again.
128    const drawing: Io = { ...io, get: async () => ({ ...S.INITIAL, ...(await read($, ui)) }) }
129
130    return renderPane({ io: drawing, t: $.ui.resolve(e), e })
131  })
132
133  // The Files button, at the right of the prompt footer beside the mode labels.
134  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
135    if (io === null) return next(e)
136    const drawing: Io = { ...io, get: async () => ({ ...S.INITIAL, ...(await read($, ui)) }) }
137    const footer = await renderFooter({ io: drawing, t: $.ui.resolve(e), e }, e.props.modes)
138
139    return footer ?? next(e)
140  })
141}
142
hooks/actions.ts 645 lines
1import type { PaneOpenArgs, RenderSurface, UiFocusArgs, UiScrollArgs } from 'claude-code'
2
3import type { ActivityKind, BaseMode, ChangedFile, CommitInfo, GitUi, PrForm, Preview, PreviewMode, Snapshot } from '../types'
4import { ancestors, isCommit, mergeActivity, pathsIn, toRelative } from './activity'
5import { defaultBranch, EMPTY_TREE, listAll, listCommits, listTree, scan } from './git'
6import type { Git, RunResult } from './git'
7import { defaultMode, loadPreview, modesFor, pngDimensions } from './preview'
8import type { PreviewDeps } from './preview'
9import * as S from './state'
10import { cleanMessage, CONFIRM_MS, discardCommands, findTemplates, messagePrompt, outcomeOf, parsePrView, pathsOf } from './ops'
11import type { Outcome } from './ops'
12import { reviewOrder } from './tree'
13
14/**
15 * What the actions need from the engine, as closures a hook builds where `$` is in scope
16 * (a mod spells `$` only at its call sites, never passes it on).
17 */
18export type Io = S.StateIo & {
19  run: (argv: string[], cwd?: string, init?: RunInit) => Promise<RunResult>
20  now: () => Promise<number>
21  writeText: (path: string, text: string) => Promise<void>
22  stat: (path: string) => Promise<{ kind: string; size: number }>
23  readText: (path: string) => Promise<string>
24  readBase64: (path: string) => Promise<string>
25  storeGet: (key: string) => Promise<unknown>
26  storeSet: (key: string, value: unknown) => Promise<void>
27  cwd: () => Promise<string>
28  scroll: (args: UiScrollArgs) => Promise<unknown>
29  focus: (args: UiFocusArgs) => Promise<unknown>
30  open: (args: PaneOpenArgs) => Promise<{ isPlaced: boolean }>
31  close: (id: string) => Promise<void>
32  toast: (text: string) => void
33  copy: (text: string, surface: RenderSurface) => Promise<boolean>
34  /** Asks the session's own model, over this conversation, with no tools: its reply, or why there is none. */
35  fork: (prompt: string) => Promise<{ text: string } | { reason: string }>
36}
37
38export type RunInit = { env?: Record<string, string>; stdin?: string; timeoutMs?: number }
39
40const gitOf = (io: Io): Git => (args, cwd) => io.run(['git', '-c', 'core.quotepath=off', '-c', 'color.ui=never', ...args], cwd)
41
42const previewDeps = (io: Io): PreviewDeps => ({
43  git: gitOf(io),
44  size: async path => {
45    try {
46      const stat = await io.stat(path)
47
48      return stat.kind === 'file' ? stat.size : null
49    } catch {
50      return null
51    }
52  },
53  readText: path => io.readText(path),
54  pngSize: async path => {
55    try {
56      return pngDimensions((await io.readBase64(path)).slice(0, 32))
57    } catch {
58      return null
59    }
60  },
61})
62
63const savePrefs = async (io: Io): Promise<void> => {
64  const prefs: S.Prefs = { view: await S.pick(io, 'view'), baseMode: await S.pick(io, 'baseMode') }
65  await io.storeSet(S.PREFS_KEY, prefs).catch(() => undefined)
66}
67
68export const loadPrefs = async (io: Io): Promise<void> => {
69  const prefs = ((await io.storeGet(S.PREFS_KEY).catch(() => undefined)) ?? {}) as S.Prefs
70  if (prefs.view === 'changes' || prefs.view === 'all') await S.put(io, 'view', () => prefs.view ?? 'changes')
71  if (prefs.baseMode !== undefined && BASE_ORDER.includes(prefs.baseMode)) await S.put(io, 'baseMode', () => prefs.baseMode ?? 'head')
72}
73
74const failed = (cwd: string, error: unknown, baseMode: Snapshot['baseMode']): Snapshot => ({
75  root: cwd, branch: '', baseMode, baseRev: '', baseLabel: '', files: [],
76  error: 'git-failed', message: error instanceof Error ? error.message : String(error),
77})
78
79const sameJson = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b)
80
81/** Shows `path` in the preview screen, in `mode` (or the file's default), at `page`. */
82export const openFile = async (io: Io, path: string, mode?: PreviewMode, page = 0, keepScroll = false): Promise<void> => {
83  const snap = await S.pick(io, 'snapshot')
84  if (snap === null || snap.error !== undefined) return
85  const file = snap.files.find(f => f.path === path)
86  const chosen = mode ?? defaultMode(path, file)
87  let next: Preview
88  try {
89    next = await loadPreview(previewDeps(io), snap, path, chosen, page)
90  } catch (error) {
91    next = {
92      path, mode: chosen, modes: modesFor(path, file), kind: 'note', text: '', page: 0, pages: 1,
93      note: `Could not read it: ${error instanceof Error ? error.message : String(error)}`,
94    }
95  }
96  const shown = await S.pick(io, 'preview')
97  if (!sameJson(shown, next)) await S.put(io, 'preview', () => next)
98  await S.put(io, 'screen', () => 'preview')
99  if (!keepScroll) await io.scroll({ to: 'start', in: S.PANE }).catch(() => undefined)
100}
101
102let isRefreshing = false
103let isQueued = false
104
105/**
106 * The working tree as a git tree, untracked files in and ignored ones out, written through an index
107 * of the mod's own (`name`) so the real one, and what is staged, stays as it is. Null outside a repository.
108 */
109const workTree = async (io: Io, cwd: string, name: string): Promise<string | null> => {
110  const paths = await runGit(io, cwd, ['rev-parse', '--git-path', 'index', '--git-path', `git-file-tree-${name}.index`])
111  const [real, own] = paths.stdout.split('\n')
112  if (paths.exitCode !== 0 || !real || !own) return null
113  // Starting from a copy of the real index, git rehashes only the files that changed.
114  await io.run(['cp', real, own], cwd).catch(() => undefined)
115  const env = { GIT_INDEX_FILE: own }
116  if ((await runGit(io, cwd, ['add', '-A'], { env })).exitCode !== 0) return null
117  const tree = await runGit(io, cwd, ['write-tree'], { env })
118
119  return tree.exitCode === 0 ? tree.stdout.trim() : null
120}
121
122/** Keeps the working tree as the prompt view's base: at each prompt, and once when the session starts. */
123export const takePromptBase = async (io: Io, at: 'prompt' | 'session'): Promise<void> => {
124  if (at === 'session' && (await S.pick(io, 'promptBase')) !== null) return
125  const tree = await workTree(io, await io.cwd(), 'prompt')
126  if (tree !== null) await S.put(io, 'promptBase', () => ({ tree, at }))
127}
128
129const refreshOnce = async (io: Io, forcePreview: string | null): Promise<void> => {
130  const mode = await S.pick(io, 'baseMode')
131  const cwd = await io.cwd()
132  const commit = await S.pick(io, 'commit')
133  const base = mode === 'prompt' && commit === null ? await S.pick(io, 'promptBase') : null
134  const now = base !== null ? await workTree(io, cwd, 'now') : null
135  const prompt = base !== null && now !== null ? { before: base.tree, now, label: base.at === 'prompt' ? 'your last prompt' : 'the session start' } : null
136  const next = await scan(gitOf(io), io.run, cwd, mode, commit, prompt).catch(error => failed(cwd, error, mode))
137  const previous = await S.pick(io, 'snapshot')
138  const hasChanged = !sameJson(previous, next)
139  if (hasChanged) await S.put(io, 'snapshot', () => next)
140
141  if ((await S.pick(io, 'view')) === 'all' && next.error === undefined) {
142    const list = await (commit ? listTree(gitOf(io), next.root, commit.sha) : listAll(gitOf(io), next.root)).catch(() => null)
143    if (list !== null && !sameJson(await S.pick(io, 'allFiles'), list)) await S.put(io, 'allFiles', () => list)
144  }
145
146  await reloadPrBody(io)
147
148  // Keep an open preview current with what is on disk now.
149  const shown = await S.pick(io, 'preview')
150  const isPreviewing = (await S.pick(io, 'screen')) === 'preview'
151  if (shown !== null && isPreviewing && (hasChanged || forcePreview === shown.path)) {
152    await openFile(io, shown.path, shown.mode, shown.page, true)
153  }
154}
155
156/** Reads git again; calls that land while one runs fold into one more pass. */
157export const refresh = async (io: Io, forcePreview: string | null = null): Promise<void> => {
158  if (isRefreshing) {
159    isQueued = true
160
161    return
162  }
163  isRefreshing = true
164  try {
165    if ((await S.pick(io, 'snapshot')) === null) await S.put(io, 'isLoading', () => true)
166    let pass = 0
167    do {
168      isQueued = false
169      await refreshOnce(io, pass === 0 ? forcePreview : null)
170      pass += 1
171    } while (isQueued && pass < 3)
172  } finally {
173    isRefreshing = false
174    await S.put(io, 'isLoading', () => false)
175  }
176}
177
178/** Claude wrote `absolutePath`: mark it and refresh the tree. */
179export const noteEdit = async (io: Io, absolutePath: string | null): Promise<void> => {
180  let relative: string | null = null
181  if (absolutePath !== null) {
182    const snap = await S.pick(io, 'snapshot')
183    const root = snap?.root ?? (await io.cwd())
184    relative = absolutePath.startsWith(`${root}/`) ? absolutePath.slice(root.length + 1) : null
185    if (relative !== null && relative !== (await S.pick(io, 'touched'))) await S.put(io, 'touched', () => relative)
186    if (relative !== null) await noteActivity(io, 'write', [relative])
187  }
188  await refresh(io, relative)
189}
190
191const rootOf = async (io: Io): Promise<string> => {
192  const snap = await S.pick(io, 'snapshot')
193
194  return snap?.root ?? (await io.cwd())
195}
196
197/** Marks `paths` (root-relative) with what Claude did, opening the folders they sit in. */
198export const noteActivity = async (io: Io, kind: ActivityKind, paths: readonly string[]): Promise<void> => {
199  if (paths.length === 0) return
200  const folders = new Set(paths.flatMap(ancestors))
201  await io.set(state => ({
202    ...state,
203    activity: mergeActivity(state.activity, kind, paths),
204    collapsed: state.collapsed.filter(folder => !folders.has(folder)),
205    expanded: [...new Set([...state.expanded, ...folders])],
206  }))
207}
208
209/** Claude read a file with its Read tool. */
210export const noteRead = async (io: Io, absolutePath: string): Promise<void> => {
211  const path = toRelative(absolutePath, await rootOf(io))
212  if (path !== null) await noteActivity(io, 'read', [path])
213}
214
215/** What a shell command did: a commit marks the committed files; a read-only one, the files it named or printed. */
216export const noteShell = async (io: Io, command: string, output: string, isReadOnly: boolean, isError: boolean): Promise<void> => {
217  if (isCommit(command) && !isError) {
218    await refresh(io)
219    const root = await rootOf(io)
220    const shown = await gitOf(io)(['show', '--name-only', '--format=', '--no-renames', 'HEAD'], root)
221    if (shown.exitCode === 0) await noteActivity(io, 'commit', shown.stdout.split('\n').map(line => line.trim()).filter(Boolean))
222
223    return
224  }
225  if (isReadOnly) {
226    const root = await rootOf(io)
227    const cwd = await io.cwd()
228    const base = cwd.startsWith(`${root}/`) ? `${cwd.slice(root.length + 1)}/` : ''
229    const known = new Set(await listAll(gitOf(io), root).catch(() => []))
230    await noteActivity(io, 'read', pathsIn(command, output, known, root, base))
231
232    return
233  }
234  await noteEdit(io, null)
235}
236
237/** A new prompt: what Claude did before it is no longer news. */
238export const clearActivity = async (io: Io): Promise<void> => {
239  if ((await S.pick(io, 'activity')).length > 0) await S.put(io, 'activity', () => [])
240}
241
242/** Hands a file or a URL to the system: `open` on macOS, `xdg-open` on Linux. */
243const openExternal = async (io: Io, target: string): Promise<boolean> => {
244  for (const opener of ['open', 'xdg-open']) {
245    const result = await io.run([opener, target]).catch(() => null)
246    if (result !== null && result.exitCode === 0) return true
247  }
248
249  return false
250}
251
252/** Opens `path` (root-relative) in the app the system picks for it. */
253export const openInApp = async (io: Io, path: string): Promise<void> => {
254  const isOpened = await openExternal(io, `${await rootOf(io)}/${path}`)
255  io.toast(isOpened ? `Opened ${path.slice(path.lastIndexOf('/') + 1)}` : 'Could not open it: no app is set for this file')
256}
257
258export const openUrl = async (io: Io, url: string): Promise<void> => {
259  if (!(await openExternal(io, url))) io.toast('Could not open the browser')
260}
261
262// Git operations ------------------------------------------------------------------------
263
264/** Network operations may take this long before they are stopped. */
265const NETWORK_MS = 120_000
266
267const putGit = (io: Io, change: (git: GitUi) => GitUi) => S.put(io, 'git', change)
268
269/** Runs git for an operation: no prompt can wait for an answer nobody can give. */
270const runGit = (io: Io, root: string, args: string[], init: RunInit = {}): Promise<RunResult> =>
271  io.run(['git', '-c', 'core.quotepath=off', '-c', 'color.ui=never', ...args], root, {
272    ...init,
273    env: { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', ...init.env },
274  })
275
276/** A command's outcome: `okText` (or its own first line) when it worked, git's words when it did not. */
277const outcome = (result: RunResult, okText: string, useOutput = false): Outcome =>
278  result.exitCode === 0
279    ? outcomeOf(true, useOutput ? result.stdout : '', okText)
280    : outcomeOf(false, `${result.stderr}\n${result.stdout}`, `git exited with ${result.exitCode}`)
281
282/** Runs one operation at a time: its outcome on the status line, the tree read again after. */
283const operate = async (io: Io, busy: string, work: (root: string) => Promise<Outcome>): Promise<boolean> => {
284  if ((await S.pick(io, 'git')).busy !== null) return false
285  await putGit(io, git => ({ ...git, busy, confirm: null }))
286  let result: Outcome
287  try {
288    result = await work(await rootOf(io))
289  } catch (error) {
290    result = { ok: false, text: error instanceof Error ? error.message : String(error) }
291  }
292  await putGit(io, git => ({ ...git, busy: null, notice: result }))
293  await refresh(io)
294
295  return result.ok
296}
297
298/** The first press of a destructive action arms it; a second within CONFIRM_MS goes ahead. */
299const confirmed = async (io: Io, key: string): Promise<boolean> => {
300  const now = await io.now()
301  const { confirm } = await S.pick(io, 'git')
302  if (confirm !== null && confirm.key === key && now - confirm.at < CONFIRM_MS) return true
303  await putGit(io, git => ({ ...git, confirm: { key, at: now } }))
304
305  return false
306}
307
308/** Disarms a destructive press nobody repeated (the poll calls it). */
309export const expireConfirm = async (io: Io): Promise<void> => {
310  const { confirm } = await S.pick(io, 'git')
311  if (confirm !== null && (await io.now()) - confirm.at >= CONFIRM_MS) await putGit(io, git => ({ ...git, confirm: null }))
312}
313
314const describe = (paths: readonly string[]): string =>
315  paths.length === 1 ? (paths[0] === '.' ? 'all changes' : (paths[0] ?? '')) : `${paths.length} paths`
316
317/** Stages files or folders (root-relative); new files are added, deleted ones removed. */
318export const stage = (io: Io, paths: readonly string[]): Promise<boolean> =>
319  operate(io, 'Staging…', async root => outcome(await runGit(io, root, ['add', '-A', '--', ...paths]), `Staged ${describe(paths)}`))
320
321export const unstage = (io: Io, paths: readonly string[]): Promise<boolean> =>
322  operate(io, 'Unstaging…', async root => {
323    const hasHead = (await S.pick(io, 'snapshot'))?.baseRev !== EMPTY_TREE
324    const args = hasHead ? ['restore', '--staged', '--', ...paths] : ['rm', '--cached', '-r', '-q', '--', ...paths]
325
326    return outcome(await runGit(io, root, args), `Unstaged ${describe(paths)}`)
327  })
328
329export const stageFile = (io: Io, file: ChangedFile) => stage(io, pathsOf(file))
330export const unstageFile = (io: Io, file: ChangedFile) => unstage(io, pathsOf(file))
331
332/** Throws away a file's changes, back to HEAD, on a second press. */
333export const discard = async (io: Io, file: ChangedFile): Promise<void> => {
334  if (!(await confirmed(io, `discard:${file.path}`))) return
335  const isDone = await operate(io, 'Discarding…', async root => {
336    for (const args of discardCommands(file)) {
337      const result = await runGit(io, root, args)
338      if (result.exitCode !== 0) return outcome(result, '')
339    }
340
341    return { ok: true, text: `Discarded the changes to ${file.path}` }
342  })
343  if (isDone) await back(io)
344}
345
346export const setMessage = (io: Io, message: string) => putGit(io, git => ({ ...git, message }))
347
348/** Amend on: the message starts as the last commit's, when none is written yet. */
349export const toggleAmend = async (io: Io): Promise<void> => {
350  const { amend, message } = await S.pick(io, 'git')
351  let next = message
352  if (!amend && message.trim() === '') {
353    const last = await runGit(io, await rootOf(io), ['log', '-1', '--format=%B']).catch(() => null)
354    if (last !== null && last.exitCode === 0) next = last.stdout.trim()
355  }
356  await putGit(io, git => ({ ...git, amend: !amend, message: next }))
357}
358
359/** Why the session's model wrote no message, in words for the status line. */
360const NO_MESSAGE: Record<string, string> = {
361  'nothing-to-fork': 'Claude has not answered yet in this session: there is no conversation to write the message from.',
362  'empty-reply': 'Claude replied with nothing. Try again.',
363  aborted: 'Writing the message was stopped.',
364}
365
366/** Asks Claude, over this conversation, for the message of what is staged, and puts it in the field to edit. */
367export const writeMessage = async (io: Io): Promise<void> => {
368  const { amend } = await S.pick(io, 'git')
369  const staged = ((await S.pick(io, 'snapshot'))?.files ?? []).filter(file => file.staged)
370  if (staged.length === 0 && !amend) {
371    await putGit(io, git => ({ ...git, notice: { ok: false, text: 'Stage the files to commit first.' } }))
372    return
373  }
374  await operate(io, 'Claude is writing the message…', async root => {
375    // An amend's message covers the whole commit it replaces: the staged changes on top of HEAD's parent.
376    const base = amend ? ['HEAD^'] : []
377    const [stat, diff, log, last] = await Promise.all([
378      runGit(io, root, ['diff', '--cached', '--stat', ...base]),
379      runGit(io, root, ['diff', '--cached', ...base]),
380      runGit(io, root, ['log', '-8', '--format=%s']),
381      amend ? runGit(io, root, ['log', '-1', '--format=%B']) : Promise.resolve(null),
382    ])
383    if (diff.exitCode !== 0) return outcome(diff, '')
384    const prompt = messagePrompt({
385      stat: stat.stdout,
386      diff: diff.stdout,
387      subjects: log.exitCode === 0 ? log.stdout.split('\n').filter(line => line.trim() !== '') : [],
388      amend: last !== null && last.exitCode === 0 ? last.stdout.trim() : null,
389    })
390    const reply = await io.fork(prompt)
391    if ('reason' in reply) return { ok: false, text: NO_MESSAGE[reply.reason] ?? `Claude could not write the message (${reply.reason}).` }
392    const message = cleanMessage(reply.text)
393    if (message === '') return { ok: false, text: NO_MESSAGE['empty-reply'] ?? '' }
394    await putGit(io, git => ({ ...git, message }))
395
396    return { ok: true, text: 'Claude wrote the message. Edit it, then commit.' }
397  })
398}
399
400/** Commits what is staged with the written message; then pushes, for Commit & push. */
401export const commit = async (io: Io, thenPush = false): Promise<void> => {
402  const { message, amend } = await S.pick(io, 'git')
403  const staged = ((await S.pick(io, 'snapshot'))?.files ?? []).filter(file => file.staged)
404  if (message.trim() === '') {
405    await putGit(io, git => ({ ...git, notice: { ok: false, text: 'Write a commit message first.' } }))
406    return
407  }
408  if (staged.length === 0 && !amend) {
409    await putGit(io, git => ({ ...git, notice: { ok: false, text: 'Stage the files to commit first.' } }))
410    return
411  }
412  const isDone = await operate(io, 'Committing…', async root => {
413    const args = amend ? ['commit', '--amend', '-F', '-'] : ['commit', '-F', '-']
414    const result = await runGit(io, root, args, { stdin: message.trim() + '\n' })
415    if (result.exitCode !== 0) return outcome(result, '')
416    const head = await runGit(io, root, ['log', '-1', '--format=%h %s'])
417
418    return { ok: true, text: `${amend ? 'Amended' : 'Committed'} ${head.stdout.trim()}` }
419  })
420  if (!isDone) return
421  await putGit(io, git => ({ ...git, message: '', amend: false }))
422  if (thenPush) await push(io)
423}
424
425/** The arguments of a push: a branch without an upstream gets one on the first remote. */
426const pushArgs = async (io: Io, root: string, force: boolean): Promise<string[] | Outcome> => {
427  const snap = await S.pick(io, 'snapshot')
428  if (snap?.upstream !== undefined) return force ? ['push', '--force-with-lease', '--force-if-includes'] : ['push']
429  const remotes = (await runGit(io, root, ['remote'])).stdout.split('\n').map(r => r.trim()).filter(Boolean)
430  const remote = remotes.includes('origin') ? 'origin' : remotes[0]
431  if (remote === undefined) return { ok: false, text: 'This repository has no remote to push to.' }
432
433  return ['push', '-u', remote, 'HEAD']
434}
435
436/** Pushes the branch; a force push (with lease) on a second press. */
437export const push = async (io: Io, force = false): Promise<void> => {
438  if (force && !(await confirmed(io, 'force'))) return
439  const isDone = await operate(io, force ? 'Force pushing…' : 'Pushing…', async root => {
440    const args = await pushArgs(io, root, force)
441    if (!Array.isArray(args)) return args
442    const snap = await S.pick(io, 'snapshot')
443
444    return outcome(await runGit(io, root, args, { timeoutMs: NETWORK_MS }), `${force ? 'Force pushed' : 'Pushed'} ${snap?.branch ?? ''}`.trim())
445  })
446  if (isDone) await refreshPr(io)
447}
448
449/** Pulls fast-forward only: a diverged branch is left for the person to settle. */
450export const pull = (io: Io) =>
451  operate(io, 'Pulling…', async root => outcome(await runGit(io, root, ['pull', '--ff-only'], { timeoutMs: NETWORK_MS }), 'Pulled', true))
452
453export const fetch = (io: Io) =>
454  operate(io, 'Fetching…', async root => outcome(await runGit(io, root, ['fetch'], { timeoutMs: NETWORK_MS }), 'Fetched'))
455
456/** Undoes the last (unpushed) commit on a second press; its changes stay staged. */
457export const undoCommit = async (io: Io): Promise<void> => {
458  if (!(await confirmed(io, 'undo'))) return
459  await operate(io, 'Undoing…', async root =>
460    outcome(await runGit(io, root, ['reset', '--soft', 'HEAD~1']), 'Undid the last commit. Its changes are staged.'),
461  )
462}
463
464// Pull requests -------------------------------------------------------------------------
465
466/** Asks once whether `gh` is there and signed in, and which branch PRs go to. */
467export const checkGh = async (io: Io): Promise<void> => {
468  const known = await S.pick(io, 'git')
469  if (known.hasGh !== null) return
470  const root = await rootOf(io)
471  const auth = await io.run(['gh', 'auth', 'status'], root, { timeoutMs: 15_000 }).catch(() => null)
472  const target = await defaultBranch(gitOf(io), root).catch(() => null)
473  await putGit(io, git => ({ ...git, hasGh: auth !== null && auth.exitCode === 0, base: target === null ? null : target.replace(/^origin\//, '') }))
474  await refreshPr(io)
475}
476
477/** Reads the branch's PR, when `gh` can: on opening the pane and after a push. */
478export const refreshPr = async (io: Io): Promise<void> => {
479  if ((await S.pick(io, 'git')).hasGh !== true) return
480  const view = await io.run(['gh', 'pr', 'view', '--json', 'number,url,state,isDraft'], await rootOf(io), { timeoutMs: 20_000 }).catch(() => null)
481  const pr = view !== null && view.exitCode === 0 ? parsePrView(view.stdout) : null
482  // gh answers this when no remote is on GitHub: no PR can be made here.
483  const isGithub = view === null || !/none of the git remotes/i.test(view.stderr)
484  await putGit(io, git => ({ ...git, pr, hasGh: isGithub }))
485}
486
487/** Opens the PR form: title from the last commit, the description from the repository's template. */
488export const openPrForm = async (io: Io): Promise<void> => {
489  const root = await rootOf(io)
490  const gitDir = (await runGit(io, root, ['rev-parse', '--absolute-git-dir'])).stdout.trim()
491  const templates = findTemplates(await listAll(gitOf(io), root).catch(() => []))
492  const template = templates[0] ?? null
493  const body = template === null ? '' : await io.readText(`${root}/${template}`).catch(() => '')
494  const bodyFile = `${gitDir}/PR_EDITMSG.md`
495  await io.writeText(bodyFile, body)
496  const title = (await runGit(io, root, ['log', '-1', '--format=%s'])).stdout.trim()
497  const base = (await S.pick(io, 'git')).base ?? 'main'
498  await io.set(state => ({
499    ...state,
500    screen: 'pr',
501    prForm: { title, base, draft: false, templates, template, bodyFile, body },
502    git: { ...state.git, notice: null },
503  }))
504  await io.scroll({ to: 'start', in: S.PANE }).catch(() => undefined)
505}
506
507export const editPrForm = (io: Io, change: (form: PrForm) => PrForm) =>
508  S.put(io, 'prForm', form => (form === null ? null : change(form)))
509
510/** Starts the description again from another template. */
511export const chooseTemplate = async (io: Io, template: string): Promise<void> => {
512  const form = await S.pick(io, 'prForm')
513  if (form === null) return
514  const body = await io.readText(`${await rootOf(io)}/${template}`).catch(() => '')
515  await io.writeText(form.bodyFile, body)
516  await editPrForm(io, f => ({ ...f, template, body }))
517}
518
519/** Opens the description in the person's editor; the form reads it back as the pane refreshes. */
520export const editPrBody = async (io: Io): Promise<void> => {
521  const form = await S.pick(io, 'prForm')
522  if (form === null) return
523  io.toast((await openExternal(io, form.bodyFile)) ? 'Edit the description, save it, and come back' : 'Could not open an editor')
524}
525
526const reloadPrBody = async (io: Io): Promise<void> => {
527  const form = await S.pick(io, 'prForm')
528  if (form === null || (await S.pick(io, 'screen')) !== 'pr') return
529  const body = await io.readText(form.bodyFile).catch(() => form.body)
530  if (body !== form.body) await editPrForm(io, f => ({ ...f, body }))
531}
532
533/** Pushes the branch when the remote lacks it, then creates the PR with `gh`. */
534export const createPr = async (io: Io): Promise<void> => {
535  const form = await S.pick(io, 'prForm')
536  if (form === null) return
537  if (form.title.trim() === '') {
538    await putGit(io, git => ({ ...git, notice: { ok: false, text: 'Write a title first.' } }))
539    return
540  }
541  const isDone = await operate(io, 'Creating the PR…', async root => {
542    const snap = await S.pick(io, 'snapshot')
543    if (snap?.upstream === undefined || (snap.ahead ?? 0) > 0) {
544      const args = await pushArgs(io, root, false)
545      if (!Array.isArray(args)) return args
546      const pushed = await runGit(io, root, args, { timeoutMs: NETWORK_MS })
547      if (pushed.exitCode !== 0) return outcome(pushed, '')
548    }
549    const args = ['pr', 'create', '--title', form.title.trim(), '--body-file', form.bodyFile, '--base', form.base.trim() || 'main']
550    if (form.draft) args.push('--draft')
551    const created = await io.run(['gh', ...args], root, { timeoutMs: NETWORK_MS })
552    if (created.exitCode !== 0) return outcomeOf(false, `${created.stderr}\n${created.stdout}`, 'gh pr create failed')
553    const url = created.stdout.trim().split('\n').pop() ?? ''
554
555    return { ok: true, text: `Created ${url}` }
556  })
557  if (!isDone) return
558  await io.set(state => ({ ...state, screen: 'tree', prForm: null }))
559  await refreshPr(io)
560}
561
562/** Opens the history screen and reads the recent commits. */
563export const showLog = async (io: Io): Promise<void> => {
564  await S.put(io, 'screen', () => 'log')
565  await io.scroll({ to: 'start', in: S.PANE }).catch(() => undefined)
566  const log = await listCommits(gitOf(io), await rootOf(io)).catch(() => [])
567  await S.put(io, 'log', () => log)
568}
569
570/** Shows one past commit in the tree, or the working changes again (null). */
571export const showCommit = async (io: Io, commit: CommitInfo | null): Promise<void> => {
572  await io.set(state => ({ ...state, commit, preview: null, screen: 'tree', allFiles: null }))
573  await io.scroll({ to: 'start', in: S.PANE }).catch(() => undefined)
574  await refresh(io)
575}
576
577/** Opens the filter with the cursor in it, or closes it. */
578export const toggleFilter = async (io: Io): Promise<void> => {
579  const filter = await S.put(io, 'filter', value => (value === null ? '' : null))
580  if (filter !== null) await io.focus({ requestId: S.PANE, key: 'filter-input' }).catch(() => undefined)
581}
582
583export const back = async (io: Io): Promise<void> => {
584  const shown = await S.pick(io, 'preview')
585  await S.put(io, 'screen', () => 'tree')
586  if (shown === null) return
587  const key = `row:${shown.path}`
588  await io.scroll({ to: { key }, in: S.PANE, block: 'center' }).catch(() => undefined)
589  await io.focus({ requestId: S.PANE, key }).catch(() => undefined)
590}
591
592/** Moves to the next (1) or previous (-1) changed file in review order. */
593export const step = async (io: Io, delta: 1 | -1): Promise<void> => {
594  const snap = await S.pick(io, 'snapshot')
595  if (snap === null) return
596  const order = reviewOrder(snap.files)
597  if (order.length === 0) return
598  const shown = await S.pick(io, 'preview')
599  const at = shown === null ? -1 : order.indexOf(shown.path)
600  const index = at < 0 ? (delta === 1 ? 0 : order.length - 1) : Math.min(Math.max(at + delta, 0), order.length - 1)
601  const path = order[index]
602  if (path === undefined || (at === index && shown !== null)) return
603  const file = snap.files.find(f => f.path === path)
604  const keep = shown !== null && modesFor(path, file).includes(shown.mode) ? shown.mode : undefined
605  await openFile(io, path, keep)
606}
607
608export const toggleDir = async (io: Io, path: string, isOpenNow: boolean): Promise<void> => {
609  const without = (list: string[]) => list.filter(p => p !== path)
610  await io.set(state => ({
611    ...state,
612    collapsed: isOpenNow ? [...without(state.collapsed), path] : without(state.collapsed),
613    expanded: isOpenNow ? without(state.expanded) : [...without(state.expanded), path],
614  }))
615}
616
617export const toggleView = async (io: Io): Promise<void> => {
618  const next = (await S.pick(io, 'view')) === 'all' ? 'changes' : 'all'
619  await S.put(io, 'view', () => next)
620  await savePrefs(io)
621  if (next === 'all') await refresh(io)
622}
623
624/** The order v walks the comparisons in. */
625export const BASE_ORDER: readonly BaseMode[] = ['head', 'branch', 'prompt']
626
627export const toggleBase = async (io: Io): Promise<void> => {
628  await S.put(io, 'baseMode', mode => BASE_ORDER[(BASE_ORDER.indexOf(mode) + 1) % BASE_ORDER.length] ?? 'head')
629  await savePrefs(io)
630  await refresh(io)
631}
632
633export const openPane = async (io: Io): Promise<boolean> => {
634  const opened = await io.open({ id: S.PANE, title: 'Files', focus: true, columns: 64, rows: 26 })
635  await S.put(io, 'isOpen', () => opened.isPlaced)
636  if (opened.isPlaced) void refresh(io).then(() => checkGh(io)).catch(() => undefined)
637
638  return opened.isPlaced
639}
640
641export const closePane = async (io: Io): Promise<void> => {
642  await io.close(S.PANE).catch(() => undefined)
643  await S.put(io, 'isOpen', () => false)
644}
645
hooks/state.ts 44 lines
1import type { BaseMode, GitUi, UiState, View } from '../types'
2
3export const PANE = 'files'
4
5export const GIT_INITIAL: GitUi = { busy: null, notice: null, confirm: null, message: '', amend: false, hasGh: null, pr: null, base: null }
6
7export const INITIAL: UiState = {
8  snapshot: null,
9  allFiles: null,
10  view: 'changes',
11  baseMode: 'head',
12  promptBase: null,
13  collapsed: [],
14  expanded: [],
15  screen: 'tree',
16  commit: null,
17  log: null,
18  git: GIT_INITIAL,
19  prForm: null,
20  preview: null,
21  touched: null,
22  activity: [],
23  filter: null,
24  isOpen: false,
25  isLoading: false,
26}
27
28/** The state half of the engine's closures (see `Io` in actions.ts). */
29export type StateIo = {
30  get: () => Promise<UiState>
31  set: (change: (state: UiState) => UiState) => Promise<UiState>
32}
33
34/** Reads one field of the state. */
35export const pick = async <K extends keyof UiState>(io: StateIo, key: K): Promise<UiState[K]> => (await io.get())[key]
36
37/** Writes one field of the state from its current value; resolves the new value. */
38export const put = async <K extends keyof UiState>(io: StateIo, key: K, change: (value: UiState[K]) => UiState[K]): Promise<UiState[K]> =>
39  (await io.set(state => ({ ...state, [key]: change(state[key]) })))[key]
40
41/** Preferences kept across sessions in `$.store`. */
42export const PREFS_KEY = 'prefs'
43export type Prefs = { view?: View; baseMode?: BaseMode }
44
hooks/view.tsx 802 lines
1import type { ElementTable, RenderElement, RenderInput } from 'claude-code'
2
3import type { Activity, ActivityKind, ChangedFile, CommitInfo, FileStatus, PreviewMode, Snapshot } from '../types'
4import * as A from './actions'
5import type { Io } from './actions'
6import { displayWidth, layoutTable, parseBlocks } from './markdown'
7import type { Span } from './markdown'
8import * as S from './state'
9import { ancestors } from './activity'
10import { prLabel } from './ops'
11import { buildTree, flatten, matches, reviewOrder } from './tree'
12import type { Row } from './tree'
13
14/** A row's stage or unstage button, at the left of the row. */
15type Gutter = { sign: '+' | '−'; paths: string[] } | null
16
17/** What a drawing works from: the engine's closures (reads subscribing this drawing), the surface's elements, the input. */
18export type Ctx = { io: Io; t: ElementTable; e: RenderInput }
19
20/** Rows drawn at most, so a huge expanded tree stays inside one drawing. */
21const MAX_ROWS = 1500
22
23const STATUS: Record<FileStatus, { letter: string; color: string; label: string }> = {
24  modified: { letter: 'M', color: 'warning', label: 'modified' },
25  added: { letter: 'A', color: 'success', label: 'added' },
26  untracked: { letter: 'U', color: 'success', label: 'new' },
27  deleted: { letter: 'D', color: 'error', label: 'deleted' },
28  renamed: { letter: 'R', color: 'suggestion', label: 'renamed' },
29  conflict: { letter: '!', color: 'error', label: 'conflict' },
30}
31
32/** What Claude did to a file, by color: reads and searches purple, writes orange, commits green. */
33const ACTIVITY: Record<ActivityKind, { color: string }> = {
34  read: { color: 'merged' },
35  write: { color: 'claude' },
36  commit: { color: 'success' },
37}
38
39/** The activity marks the rows draw: each file's latest, and the latest of all. */
40type Marks = { byPath: Map<string, Activity>; latest: number; all: readonly Activity[] }
41
42/** ● on the latest thing Claude did, • on the rest. */
43const mark = (c: Ctx, entry: Activity | undefined, latest: number) => {
44  const { Text } = c.t
45  if (entry === undefined) return ''
46
47  return <Text color={ACTIVITY[entry.kind].color} bold={entry.seq === latest}>{entry.seq === latest ? ' ●' : ' •'}</Text>
48}
49
50const MODE_LABEL: Record<PreviewMode, { label: string; hotkey: string }> = {
51  diff: { label: 'Diff', hotkey: 'd' },
52  source: { label: 'Source', hotkey: 's' },
53  rendered: { label: 'Rendered', hotkey: 'm' },
54}
55
56const basename = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
57const dirname = (path: string): string => path.slice(0, path.lastIndexOf('/') + 1)
58
59const totals = (files: readonly ChangedFile[]) => {
60  const byStatus = new Map<FileStatus, number>()
61  let added = 0
62  let removed = 0
63  for (const file of files) {
64    byStatus.set(file.status, (byStatus.get(file.status) ?? 0) + 1)
65    added += file.added ?? 0
66    removed += file.removed ?? 0
67  }
68
69  return { byStatus, added, removed }
70}
71
72const ORDER: FileStatus[] = ['modified', 'added', 'untracked', 'renamed', 'deleted', 'conflict']
73
74/** `3M 2U 1D  +120 −30`, each part in its status color: the Text children of a Button or Box. */
75const summaryParts = (c: Ctx, files: readonly ChangedFile[]) => {
76  const { Text } = c.t
77  const { byStatus, added, removed } = totals(files)
78  const parts = ORDER.filter(status => byStatus.has(status)).map(status => (
79    <Text color={STATUS[status].color}>{` ${byStatus.get(status)}${STATUS[status].letter}`}</Text>
80  ))
81  if (added > 0) parts.push(<Text color="success">{`  +${added}`}</Text>)
82  if (removed > 0) parts.push(<Text color="error">{` −${removed}`}</Text>)
83
84  return parts
85}
86
87const stats = (c: Ctx, file: ChangedFile) => {
88  const { Text } = c.t
89  if (file.isBinary) return [<Text dimColor> binary</Text>]
90  const parts = []
91  if ((file.added ?? 0) > 0) parts.push(<Text color="success">{` +${file.added}`}</Text>)
92  if ((file.removed ?? 0) > 0) parts.push(<Text color="error">{` −${file.removed}`}</Text>)
93
94  return parts
95}
96
97/** The row with its stage (+) or unstage (−) button before it; a row that has neither keeps the column. */
98const withGutter = (c: Ctx, row: Row, element: RenderElement, gutter: Gutter | undefined) => {
99  const { Box, Button, Text } = c.t
100  if (gutter === undefined) return element
101  if (gutter === null) return <Box key={`line:${row.key}`}><Text>{'  '}</Text>{element}</Box>
102  const isStage = gutter.sign === '+'
103
104  return (
105    <Box key={`line:${row.key}`}>
106      <Button key={`${isStage ? 'stage' : 'unstage'}:${row.path}`} plain onPress={() => (isStage ? A.stage(c.io, gutter.paths) : A.unstage(c.io, gutter.paths))}>
107        <Text dimColor>{gutter.sign}</Text>
108      </Button>
109      <Text> </Text>
110      {element}
111    </Box>
112  )
113}
114
115const rowElement = (c: Ctx, row: Row, selected: string | null, marks: Marks, isAll: boolean, gutter?: Gutter) =>
116  withGutter(c, row, rowButton(c, row, selected, marks, isAll), gutter)
117
118const rowButton = (c: Ctx, row: Row, selected: string | null, marks: Marks, isAll: boolean) => {
119  const { Button, Text } = c.t
120  const indent = '  '.repeat(row.depth)
121
122  if (row.isDir) {
123    const showCount = row.changes > 0 && (!row.isOpen || isAll)
124
125    return (
126      <Button key={row.key} plain onPress={() => A.toggleDir(c.io, row.path, row.isOpen)}>
127        {indent}
128        <Text color="subtle">{row.isOpen ? '▾ ' : '▸ '}</Text>
129        <Text bold={row.changes > 0}>{row.label}</Text>
130        {showCount ? <Text color="warning">{` ${row.changes}`}</Text> : ''}
131        {row.isOpen ? '' : mark(c, latestIn(marks.all, row.path), marks.latest)}
132      </Button>
133    )
134  }
135
136  const status = row.file ? STATUS[row.file.status] : null
137  const isDeleted = row.file?.status === 'deleted'
138
139  return (
140    <Button key={row.key} plain onPress={() => A.openFile(c.io, row.path)}>
141      {indent}
142      <Text color={status?.color ?? 'subtle'} bold>{status ? `${status.letter} ` : '  '}</Text>
143      <Text
144        {...(status ? { color: status.color } : {})}
145        bold={row.path === selected}
146        strikethrough={isDeleted}
147        dimColor={isDeleted}
148      >
149        {row.label}
150      </Text>
151      {...(row.file ? stats(c, row.file) : [])}
152      {mark(c, marks.byPath.get(row.path), marks.latest)}
153    </Button>
154  )
155}
156
157/** The latest activity inside the folder `path` (ends in `/`), for a closed folder's row. */
158const latestIn = (activity: readonly Activity[], path: string): Activity | undefined => {
159  let found: Activity | undefined
160  for (const entry of activity) if (entry.path.startsWith(path) && (found === undefined || entry.seq > found.seq)) found = entry
161
162  return found
163}
164
165/** `feature/x → origin/feature/x ↑2 ↓1`: the branch, where it pushes, and how far apart they are. */
166const branchLine = (c: Ctx, snap: { branch: string; upstream?: string; ahead?: number; behind?: number }) => {
167  const { Text } = c.t
168  const parts = [<Text bold>{snap.branch}</Text>]
169  if (snap.upstream === undefined) return parts
170  parts.push(<Text dimColor>{` → ${snap.upstream}`}</Text>)
171  const ahead = snap.ahead ?? 0
172  const behind = snap.behind ?? 0
173  if (ahead === 0 && behind === 0) parts.push(<Text dimColor> ✓ up to date</Text>)
174  if (ahead > 0) parts.push(<Text color="warning">{` ↑${ahead}`}</Text>)
175  if (behind > 0) parts.push(<Text color="suggestion">{` ↓${behind}`}</Text>)
176
177  return parts
178}
179
180/** The git operations work on the working changes of the current branch: not on a past commit, not on the whole branch. */
181const isGitMode = async (c: Ctx, snap: Snapshot): Promise<boolean> =>
182  snap.commit === undefined && snap.error === undefined && (await S.pick(c.io, 'baseMode')) === 'head'
183
184/** A toggle drawn as all its options, the current one bold: `Changed · All`. */
185const choice = (c: Ctx, options: readonly string[], active: number) => {
186  const { Text } = c.t
187
188  return options.flatMap((option, index) => [
189    ...(index > 0 ? [<Text dimColor> · </Text>] : []),
190    index === active ? <Text bold>{option}</Text> : <Text dimColor>{option}</Text>,
191  ])
192}
193
194/** Outside git mode: the keys that lead back to stage, commit and push. */
195const gitHint = (isAll: boolean, isCommit: boolean, baseMode: string): string => {
196  const steps: string[] = []
197  if (isAll) steps.push('a for Changed')
198  if (isCommit) steps.push('v for Working changes')
199  else if (baseMode === 'branch') steps.push('v twice for Uncommitted')
200  else if (baseMode !== 'head') steps.push('v for Uncommitted')
201
202  return `Stage, commit and push: press ${steps.join(', then ')}.`
203}
204
205/** What a destructive button says after its first press. */
206const CONFIRM_TEXT: Record<string, string> = { force: 'Press again to force push', undo: 'Press again to undo the last commit' }
207
208/** The line under the toolbar: the running operation, a press to repeat, or how the last one went. */
209const statusLine = async (c: Ctx): Promise<RenderElement | null> => {
210  const { Box, Text, Button } = c.t
211  const git = await S.pick(c.io, 'git')
212  if (git.busy !== null) return <Text dimColor>{git.busy}</Text>
213  if (git.confirm !== null) {
214    const text = CONFIRM_TEXT[git.confirm.key] ?? 'Press x again to discard the changes'
215
216    return <Text color="warning">{text}</Text>
217  }
218  if (git.notice === null) return null
219  const notice = git.notice
220
221  return (
222    <Box flexDirection="column">
223      <Box columnGap={2}>
224        <Text color={notice.ok ? 'success' : 'error'}>{`${notice.ok ? '✓' : '✗'} ${notice.text}`}</Text>
225        <Button key="notice-close" plain onPress={() => S.put(c.io, 'git', g => ({ ...g, notice: null }))}><Text dimColor>✕</Text></Button>
226      </Box>
227      {notice.detail !== undefined ? <Text dimColor>{notice.detail}</Text> : null}
228    </Box>
229  )
230}
231
232/** Why Push cannot run now, or null when it can (or Force push stands in its place). */
233const pushBlocker = (snap: Snapshot, isDetached: boolean, isDiverged: boolean): string | null => {
234  if (isDetached) return 'Push: not on a branch'
235  if (isDiverged) return null
236  if (snap.upstream !== undefined && (snap.ahead ?? 0) === 0) return 'Push: nothing to push, commit first'
237
238  return null
239}
240
241/** Push, pull, fetch, undo and the PR. Push always shows: a dim line says why it cannot run yet. */
242const syncRow = async (c: Ctx, snap: Snapshot): Promise<RenderElement> => {
243  const { Box, Button, Text } = c.t
244  const git = await S.pick(c.io, 'git')
245  const ahead = snap.ahead ?? 0
246  const behind = snap.behind ?? 0
247  const hasUpstream = snap.upstream !== undefined
248  const isDetached = snap.branch === 'HEAD' || snap.branch === ''
249  const isDiverged = ahead > 0 && behind > 0
250  const canPr = git.hasGh === true && git.pr === null && !isDetached && snap.branch !== (git.base ?? 'main')
251  const blocker = pushBlocker(snap, isDetached, isDiverged)
252
253  return (
254    <Box flexWrap="wrap" columnGap={2}>
255      {blocker !== null ? (
256        <Text dimColor>{blocker}</Text>
257      ) : !isDiverged ? (
258        <Button key="push" plain hotkey="p" onPress={() => A.push(c.io)}>{hasUpstream ? 'Push' : 'Push branch'}</Button>
259      ) : null}
260      {isDiverged ? <Button key="force-push" plain onPress={() => A.push(c.io, true)}>Force push</Button> : null}
261      {behind > 0 && !isDiverged ? <Button key="pull" plain onPress={() => A.pull(c.io)}>Pull</Button> : null}
262      {hasUpstream ? <Button key="fetch" plain onPress={() => A.fetch(c.io)}>Fetch</Button> : null}
263      {ahead > 0 ? <Button key="undo" plain onPress={() => A.undoCommit(c.io)}>Undo commit</Button> : null}
264      {git.pr !== null ? (
265        <Button key="pr-open" plain onPress={() => A.openUrl(c.io, git.pr?.url ?? '')}>{prLabel(git.pr)}</Button>
266      ) : canPr ? (
267        <Button key="pr-create" plain onPress={() => A.openPrForm(c.io)}>Create PR</Button>
268      ) : null}
269    </Box>
270  )
271}
272
273/** The message and the commit buttons, always; with nothing staged a dim line says how to stage. */
274const commitBox = async (c: Ctx, stagedCount: number): Promise<RenderElement | null> => {
275  const table = c.t
276  const { Box, Button, Text } = table
277  const git = await S.pick(c.io, 'git')
278  if (!('Input' in table)) return null
279  const { Input } = table
280
281  return (
282    <Box flexDirection="column">
283      <Input
284        key="message"
285        label="Message"
286        placeholder="what this commit does"
287        value={git.message}
288        submitLabel="commit"
289        onInput={value => A.setMessage(c.io, value)}
290        onSubmit={async value => {
291          await A.setMessage(c.io, value)
292          await A.commit(c.io)
293        }}
294      />
295      <Box flexWrap="wrap" columnGap={2}>
296        <Button key="write-message" plain hotkey="w" onPress={() => A.writeMessage(c.io)}>Write message</Button>
297        <Button key="commit" plain hotkey="c" onPress={() => A.commit(c.io)}>{git.amend ? 'Amend' : 'Commit'}</Button>
298        <Button key="commit-push" plain onPress={() => A.commit(c.io, true)}>{git.amend ? 'Amend & push' : 'Commit & push'}</Button>
299        <Button key="amend" plain onPress={() => A.toggleAmend(c.io)}>{git.amend ? '[x] Amend last commit' : '[ ] Amend last commit'}</Button>
300      </Box>
301      {stagedCount === 0 && !git.amend ? <Text dimColor>Nothing staged: press + before a file, or Stage all, to choose what to commit.</Text> : null}
302    </Box>
303  )
304}
305
306const renderTree = async (c: Ctx): Promise<RenderElement> => {
307  const table = c.t
308  const { Box, Text, Button } = table
309  const snap = await S.pick(c.io, 'snapshot')
310  const isLoading = await S.pick(c.io, 'isLoading')
311  const isFocused = c.e.component === 'Pane' && c.e.props.isFocused
312
313  if (snap === null) {
314    return <Text dimColor>{isLoading ? 'Reading git…' : 'Nothing read yet.'}</Text>
315  }
316  if (snap.error !== undefined) {
317    return (
318      <Box flexDirection="column">
319        <Text color="warning">{snap.error === 'not-a-repo' ? 'Not a git repository' : 'git failed'}</Text>
320        <Text dimColor>{snap.message ?? ''}</Text>
321        <Button key="refresh" plain hotkey="r" onPress={() => A.refresh(c.io)}>Try again</Button>
322      </Box>
323    )
324  }
325
326  const view = await S.pick(c.io, 'view')
327  const isAll = view === 'all'
328  const baseMode = await S.pick(c.io, 'baseMode')
329  const filter = await S.pick(c.io, 'filter')
330  const activity = await S.pick(c.io, 'activity')
331  const marks: Marks = {
332    byPath: new Map(activity.map(entry => [entry.path, entry])),
333    latest: activity.reduce((max, entry) => Math.max(max, entry.seq), 0),
334    all: activity,
335  }
336  const shown = await S.pick(c.io, 'preview')
337  const changes = new Map(snap.files.map(f => [f.path, f]))
338
339  // The changes, and every file Claude read, wrote or committed since the prompt.
340  // A past commit shows its own files only.
341  const isCommit = snap.commit !== undefined
342  let paths: string[] = [...new Set([...snap.files.map(f => f.path), ...(isCommit ? [] : activity.map(entry => entry.path))])]
343  if (isAll) paths = [...new Set([...((await S.pick(c.io, 'allFiles')) ?? []), ...paths])]
344  const isFiltering = filter !== null && filter.trim() !== ''
345  if (isFiltering) paths = paths.filter(path => matches(path, filter))
346
347  const activeFolders = new Set(activity.flatMap(entry => ancestors(entry.path)))
348  const collapsed = new Set(await S.pick(c.io, 'collapsed'))
349  const expanded = new Set(await S.pick(c.io, 'expanded'))
350  const options = {
351    // Folders holding changes start open in both views; the rest of the repository starts closed.
352    isOpen: (path: string, count: number) => isFiltering || (!collapsed.has(path) && (count > 0 || activeFolders.has(path) || (isAll && expanded.has(path)))),
353    isCompact: true,
354  }
355
356  // The working changes split in two, as git keeps them: staged, and the rest.
357  const gitMode = !isAll && (await isGitMode(c, snap))
358  const staged = snap.files.filter(file => file.staged)
359  const stagedPaths = new Set(staged.map(file => file.path))
360  const unstagedChanges = new Map(snap.files.filter(file => file.unstaged || !file.staged).map(file => [file.path, file]))
361  const stagedRows = gitMode
362    ? flatten(buildTree(paths.filter(path => stagedPaths.has(path)), new Map(staged.map(file => [file.path, file]))), options).map(row => ({
363        ...row,
364        key: row.key.replace(/^row:/, 'staged:'),
365      }))
366    : []
367  const rows = gitMode
368    ? flatten(buildTree(paths.filter(path => unstagedChanges.has(path) || !changes.has(path)), unstagedChanges), options)
369    : flatten(buildTree(paths, changes), options)
370  const hidden = Math.max(0, stagedRows.length + rows.length - MAX_ROWS)
371  const gutterOf = (row: Row, sign: '+' | '−', section: ReadonlyMap<string, ChangedFile>): Gutter => {
372    if (row.isDir) return row.changes > 0 ? { sign, paths: [row.path] } : null
373    const file = section.get(row.path)
374
375    return file === undefined ? null : { sign, paths: file.status === 'renamed' && file.from && sign === '−' ? [file.from, file.path] : [file.path] }
376  }
377
378  const filterField = (() => {
379    if (filter === null) return null
380    if (!('Input' in table)) return null
381    const { Input } = table
382
383    return (
384      <Box columnGap={1}>
385        <Input
386          key="filter-input"
387          label="Filter"
388          placeholder="type a name, Enter opens the first match"
389          value={filter}
390          autoFocus
391          submitLabel="open"
392          onInput={value => S.put(c.io, 'filter', () => value)}
393          onSubmit={value => {
394            const first = rows.find(row => !row.isDir && matches(row.path, value))
395            if (first) void A.openFile(c.io, first.path)
396          }}
397        />
398        <Button key="filter-clear" plain onPress={() => S.put(c.io, 'filter', () => null)}>✕</Button>
399      </Box>
400    )
401  })()
402
403  let empty: RenderElement | null = null
404  if (rows.length === 0 && stagedRows.length === 0) {
405    empty = isFiltering ? (
406      <Text dimColor>No file matches “{filter}”.</Text>
407    ) : isAll ? (
408      <Text dimColor>No files.</Text>
409    ) : isCommit ? (
410      <Text dimColor>No file changes in this commit.</Text>
411    ) : (
412      <Box flexDirection="column">
413        <Text color="success">{snap.baseMode === 'prompt' ? `✓ No changes since ${snap.baseLabel}` : `✓ No changes against ${snap.baseLabel}`}</Text>
414        <Text dimColor>Press a to browse all files, or v to change what the tree compares with.</Text>
415      </Box>
416    )
417  }
418
419  return (
420    <Box flexDirection="column">
421      <Box flexWrap="wrap">{...branchLine(c, snap)}</Box>
422      <Box flexWrap="wrap">
423        {snap.commit ? <Text>{`${snap.commit.short} ${snap.commit.subject}`}</Text> : <Text dimColor>{snap.baseMode === 'prompt' ? `since ${snap.baseLabel}` : `vs ${snap.baseLabel}`}</Text>}
424        <Text dimColor>{snap.files.length > 0 ? `  ${snap.files.length} changed ` : ''}</Text>
425        {...summaryParts(c, snap.files)}
426      </Box>
427      <Box flexWrap="wrap" columnGap={2}>
428        <Button key="view" plain hotkey="a" onPress={() => A.toggleView(c.io)}>{...choice(c, ['Changed', 'All'], isAll ? 1 : 0)}</Button>
429        {isCommit ? (
430          <Button key="base" plain hotkey="v" onPress={() => A.showCommit(c.io, null)}>Working changes</Button>
431        ) : (
432          <Button key="base" plain hotkey="v" onPress={() => A.toggleBase(c.io)}>{...choice(c, ['Uncommitted', 'Branch', 'Prompt'], A.BASE_ORDER.indexOf(baseMode))}</Button>
433        )}
434        <Button key="history" plain hotkey="h" onPress={() => A.showLog(c.io)}>History</Button>
435        <Button key="filter" plain hotkey="f" onPress={() => A.toggleFilter(c.io)}>Filter</Button>
436      </Box>
437      {filterField}
438      {stagedRows.length > 0 ? (
439        <Box flexDirection="column" marginTop={1}>
440          <Box columnGap={2}>
441            <Text bold>{`Staged ${staged.length}`}</Text>
442            <Button key="unstage-all" plain onPress={() => A.unstage(c.io, staged.flatMap(file => (file.status === 'renamed' && file.from ? [file.from, file.path] : [file.path])))}>
443              Unstage all
444            </Button>
445          </Box>
446          {...stagedRows.slice(0, MAX_ROWS).map(row => rowElement(c, row, shown?.path ?? null, marks, isAll, gutterOf(row, '−', new Map(staged.map(f => [f.path, f])))))}
447        </Box>
448      ) : null}
449      <Box flexDirection="column" marginTop={1}>
450        {gitMode && unstagedChanges.size > 0 ? (
451          <Box columnGap={2}>
452            <Text bold>{`Changes ${unstagedChanges.size}`}</Text>
453            <Button key="stage-all" plain onPress={() => A.stage(c.io, ['.'])}>Stage all</Button>
454          </Box>
455        ) : null}
456        {empty}
457        {...rows
458          .slice(0, Math.max(0, MAX_ROWS - stagedRows.length))
459          .map(row => rowElement(c, row, shown?.path ?? null, marks, isAll, gitMode ? gutterOf(row, '+', unstagedChanges) : undefined))}
460        {hidden > 0 ? <Text dimColor>{`… ${hidden} more rows. Use the filter (f) to narrow.`}</Text> : null}
461      </Box>
462      {/* The git operations last, under the files they act on. */}
463      <Box flexDirection="column" marginTop={1}>
464        {gitMode ? await commitBox(c, staged.length) : null}
465        {gitMode ? await syncRow(c, snap) : <Text dimColor>{gitHint(isAll, isCommit, baseMode)}</Text>}
466        {gitMode ? await statusLine(c) : null}
467      </Box>
468      {isFocused ? null : <Text dimColor>Click a file to preview. Click the pane (or ctrl+x tab) for the letter keys.</Text>}
469    </Box>
470  )
471}
472
473/** Styled spans as Text pieces. */
474const spanElements = (c: Ctx, spans: readonly Span[]) => {
475  const { Text } = c.t
476
477  return spans.map(span => {
478    const style: { bold?: boolean; italic?: boolean; strikethrough?: boolean; underline?: boolean; dimColor?: boolean; color?: string } = {}
479    if (span.bold) style.bold = true
480    if (span.italic) style.italic = true
481    if (span.strike) style.strikethrough = true
482    if (span.dim) style.dimColor = true
483    if (span.code) style.color = 'permission'
484    if (span.link) {
485      style.color = 'suggestion'
486      style.underline = true
487    }
488
489    return Object.keys(style).length === 0 ? span.text : <Text {...style}>{span.text}</Text>
490  })
491}
492
493/**
494 * A markdown page: headings and tables drawn here, fitted to the pane's `width`;
495 * code fences as highlighted code; the rest (paragraphs, lists, quotes) by the engine's renderer.
496 */
497const renderMarkdown = (c: Ctx, text: string, width: number): RenderElement => {
498  const { Box, Text, Code, Markdown } = c.t
499  const blocks = parseBlocks(text)
500
501  return (
502    <Box flexDirection="column">
503      {...blocks.map((block, index) => {
504        const gap = index === 0 ? 0 : 1
505        switch (block.kind) {
506          case 'heading': {
507            const title = spanElements(c, block.spans)
508            const ruleWidth = Math.max(4, Math.min(width, displayWidth(block.spans.map(s => s.text).join('')) + 2))
509            if (block.level === 1) {
510              return (
511                <Box flexDirection="column" marginTop={gap}>
512                  <Text bold color="claude">{...title}</Text>
513                  <Text color="claude">{'━'.repeat(width)}</Text>
514                </Box>
515              )
516            }
517            if (block.level === 2) {
518              return (
519                <Box flexDirection="column" marginTop={gap}>
520                  <Text bold color="claude">{...title}</Text>
521                  <Text dimColor>{'─'.repeat(ruleWidth)}</Text>
522                </Box>
523              )
524            }
525
526            return (
527              <Box marginTop={gap}>
528                <Text bold>
529                  <Text color="claude">{`${'#'.repeat(block.level)} `}</Text>
530                  {...title}
531                </Text>
532              </Box>
533            )
534          }
535          case 'table':
536            return (
537              <Box flexDirection="column" marginTop={gap}>
538                {...layoutTable(block, width).map(line => <Text wrap="truncate-end">{...spanElements(c, line)}</Text>)}
539              </Box>
540            )
541          case 'code':
542            return (
543              <Box flexDirection="column" marginTop={gap}>
544                {block.language !== '' ? <Text dimColor>{block.language}</Text> : null}
545                {block.language !== '' ? <Code source={block.source} language={block.language} /> : <Code source={block.source} />}
546              </Box>
547            )
548          default:
549            return (
550              <Box marginTop={gap}>
551                <Markdown text={block.text} />
552              </Box>
553            )
554        }
555      })}
556    </Box>
557  )
558}
559
560const renderPreview = async (c: Ctx): Promise<RenderElement> => {
561  const table = c.t
562  const { Box, Text, Button, Code } = table
563  const shown = await S.pick(c.io, 'preview')
564  const snap = await S.pick(c.io, 'snapshot')
565  if (shown === null || snap === null) return renderTree(c)
566
567  const file = snap.files.find(f => f.path === shown.path)
568  const status = file ? STATUS[file.status] : null
569  const order = reviewOrder(snap.files)
570  const position = order.indexOf(shown.path)
571  const bodyColumns = c.e.component === 'Pane' ? c.e.props.bodyColumns : 60
572  const gitMode = await isGitMode(c, snap)
573
574  let content: RenderElement
575  switch (shown.kind) {
576    case 'diff':
577      content = <Code source={shown.text} format="diff" path={shown.path} />
578      break
579    case 'code':
580      content = <Code source={shown.text} path={shown.path} startLine={shown.firstLine ?? 1} />
581      break
582    case 'markdown':
583      content = renderMarkdown(c, shown.text, Math.max(20, bodyColumns - 1))
584      break
585    case 'image': {
586      const image = shown.image
587      if (image !== undefined && 'Image' in c.t) {
588        const { Image } = c.t
589        const columns = Math.max(8, Math.min(255, bodyColumns - 2))
590        // Terminal cells are about twice as tall as wide.
591        const rows = Math.max(4, Math.min(60, Math.round((columns * image.height) / Math.max(1, image.width) / 2)))
592        content = (
593          <Box flexDirection="column">
594            <Image source={{ file: image.file, format: 'png' }} columns={columns} rows={rows} alt={shown.note ?? 'PNG image'} />
595          </Box>
596        )
597      } else {
598        content = <Text dimColor>{shown.note ?? 'Image'}</Text>
599      }
600      break
601    }
602    default:
603      content = <Text dimColor>{shown.note ?? ''}</Text>
604  }
605
606  const pager =
607    shown.pages > 1 ? (
608      <Box columnGap={2} flexWrap="wrap" marginTop={1}>
609        {shown.page > 0 ? (
610          <Button key="page-prev" plain hotkey="k" onPress={() => A.openFile(c.io, shown.path, shown.mode, shown.page - 1)}>Previous page</Button>
611        ) : null}
612        <Text dimColor>
613          {`page ${shown.page + 1} of ${shown.pages}`}
614          {shown.firstLine !== undefined && shown.totalLines !== undefined
615            ? ` · from line ${shown.firstLine} of ${shown.totalLines}`
616            : ''}
617        </Text>
618        {shown.page < shown.pages - 1 ? (
619          <Button key="page-next" plain hotkey="j" onPress={() => A.openFile(c.io, shown.path, shown.mode, shown.page + 1)}>Next page</Button>
620        ) : null}
621      </Box>
622    ) : null
623
624  return (
625    <Box flexDirection="column">
626      <Box flexWrap="wrap" columnGap={2}>
627        <Button key="back" plain hotkey="b" autoFocus onPress={() => A.back(c.io)}>‹ Back</Button>
628        {...shown.modes.map(mode => (
629          <Button key={`mode:${mode}`} plain hotkey={MODE_LABEL[mode].hotkey} onPress={() => A.openFile(c.io, shown.path, mode)}>
630            {mode === shown.mode ? <Text bold color="claude">{MODE_LABEL[mode].label}</Text> : MODE_LABEL[mode].label}
631          </Button>
632        ))}
633        {order.length > 0 ? <Button key="prev" plain hotkey="p" onPress={() => A.step(c.io, -1)}>Prev</Button> : null}
634        {order.length > 0 ? <Button key="next" plain hotkey="n" onPress={() => A.step(c.io, 1)}>Next</Button> : null}
635        <Button
636          key="copy"
637          plain
638          hotkey="c"
639          onPress={async press => {
640            const isCopied = await c.io.copy(shown.path, press.surface)
641            c.io.toast(isCopied ? `Copied ${shown.path}` : 'Could not copy the path')
642          }}
643        >
644          Copy path
645        </Button>
646        {file?.status === 'deleted' || snap.commit !== undefined ? null : (
647          <Button key="open-app" plain hotkey="o" onPress={() => A.openInApp(c.io, shown.path)}>Open</Button>
648        )}
649        {file !== undefined && gitMode ? (
650          <Button key="stage-toggle" plain hotkey="t" onPress={() => (file.unstaged ? A.stageFile(c.io, file) : A.unstageFile(c.io, file))}>
651            {file.unstaged ? 'Stage' : 'Unstage'}
652          </Button>
653        ) : null}
654        {file !== undefined && gitMode && file.status !== 'conflict' ? (
655          <Button key="discard" plain hotkey="x" onPress={() => A.discard(c.io, file)}>Discard</Button>
656        ) : null}
657      </Box>
658      {gitMode ? await statusLine(c) : null}
659      <Box flexWrap="wrap" marginTop={1}>
660        <Text dimColor>{dirname(shown.path)}</Text>
661        <Text bold {...(status ? { color: status.color } : {})}>{basename(shown.path)}</Text>
662        {status ? <Text color={status.color}>{`  ${status.label}`}</Text> : ''}
663        {file?.status === 'renamed' && file.from ? <Text dimColor>{` from ${file.from}`}</Text> : ''}
664        {...(file ? stats(c, file) : [])}
665        {position >= 0 ? <Text dimColor>{`  ${position + 1}/${order.length}`}</Text> : ''}
666      </Box>
667      {shown.kind === 'image' && shown.note ? <Text dimColor>{shown.note}</Text> : null}
668      <Box flexDirection="column" marginTop={1}>
669        {content}
670      </Box>
671      {pager}
672    </Box>
673  )
674}
675
676/** One commit of the history, bold while the tree shows it. */
677const commitRow = (c: Ctx, commit: CommitInfo, shown: CommitInfo | null) => {
678  const { Button, Text } = c.t
679  const isShown = shown?.sha === commit.sha
680
681  return (
682    <Button key={`commit:${commit.sha}`} plain {...(isShown ? { autoFocus: true as const } : {})} onPress={() => A.showCommit(c.io, commit)}>
683      <Text dimColor>{`${commit.short} `}</Text>
684      {isShown ? <Text bold>{commit.subject}</Text> : commit.subject}
685      <Text dimColor>{`  ${commit.when}`}</Text>
686    </Button>
687  )
688}
689
690/**
691 * The history screen: the working changes, then the latest commits, newest first: those not
692 * pushed in a section of their own above those the remote has, where a remote can say.
693 */
694const renderLog = async (c: Ctx): Promise<RenderElement> => {
695  const { Box, Text, Button } = c.t
696  const log = await S.pick(c.io, 'log')
697  const shown = await S.pick(c.io, 'commit')
698  const upstream = (await S.pick(c.io, 'snapshot'))?.upstream
699  const isKnown = (log ?? []).some(commit => commit.isPushed !== undefined)
700  const unpushed = (log ?? []).filter(commit => commit.isPushed === false)
701  const pushed = (log ?? []).filter(commit => commit.isPushed !== false)
702
703  return (
704    <Box flexDirection="column">
705      <Button key="back" plain hotkey="b" onPress={() => S.put(c.io, 'screen', () => 'tree')}>‹ Back</Button>
706      <Box flexDirection="column" marginTop={1}>
707        <Button key="commit:working" plain {...(shown === null ? { autoFocus: true as const } : {})} onPress={() => A.showCommit(c.io, null)}>
708          {shown === null ? <Text bold>Working changes</Text> : 'Working changes'}
709        </Button>
710        {log === null ? <Text dimColor>Reading history…</Text> : null}
711        {...(isKnown ? [] : log ?? []).map(commit => commitRow(c, commit, shown))}
712      </Box>
713      {isKnown && unpushed.length > 0 ? (
714        <Box flexDirection="column" marginTop={1}>
715          <Text bold color="warning">{`Not pushed ${unpushed.length}`}</Text>
716          {...unpushed.map(commit => commitRow(c, commit, shown))}
717        </Box>
718      ) : null}
719      {isKnown && pushed.length > 0 ? (
720        <Box flexDirection="column" marginTop={1}>
721          <Text bold>{upstream === undefined ? 'On the remote' : `On ${upstream}`}</Text>
722          {...pushed.map(commit => commitRow(c, commit, shown))}
723        </Box>
724      ) : null}
725    </Box>
726  )
727}
728
729/** The PR form: a title, the base branch, draft or not, and the description from the template. */
730const renderPr = async (c: Ctx): Promise<RenderElement> => {
731  const table = c.t
732  const { Box, Text, Button } = table
733  const form = await S.pick(c.io, 'prForm')
734  const snap = await S.pick(c.io, 'snapshot')
735  if (form === null || !('Input' in table)) return renderTree(c)
736  const { Input } = table
737  const bodyColumns = c.e.component === 'Pane' ? c.e.props.bodyColumns : 60
738  const back = () => c.io.set(state => ({ ...state, screen: 'tree', prForm: null }))
739
740  return (
741    <Box flexDirection="column">
742      <Button key="back" plain hotkey="b" onPress={back}>‹ Back</Button>
743      <Box flexWrap="wrap" marginTop={1}>
744        <Text bold>Create PR</Text>
745        <Text dimColor>{` ${snap?.branch ?? ''} → ${form.base}`}</Text>
746      </Box>
747      <Box flexDirection="column" marginTop={1}>
748        <Input key="pr-title" label="Title" value={form.title} autoFocus onInput={value => A.editPrForm(c.io, f => ({ ...f, title: value }))} onSubmit={() => A.createPr(c.io)} submitLabel="create" />
749        <Input key="pr-base" label="Base" value={form.base} onInput={value => A.editPrForm(c.io, f => ({ ...f, base: value }))} onSubmit={() => A.createPr(c.io)} submitLabel="create" />
750      </Box>
751      <Box flexWrap="wrap" columnGap={2}>
752        <Button key="pr-draft" plain onPress={() => A.editPrForm(c.io, f => ({ ...f, draft: !f.draft }))}>{form.draft ? '[x] Draft' : '[ ] Draft'}</Button>
753        <Button key="pr-edit" plain hotkey="e" onPress={() => A.editPrBody(c.io)}>Edit description</Button>
754        <Button key="pr-submit" plain onPress={() => A.createPr(c.io)}>{form.draft ? 'Create draft PR' : 'Create PR'}</Button>
755      </Box>
756      {form.templates.length > 1 ? (
757        <Box flexWrap="wrap" columnGap={2}>
758          <Text dimColor>Template</Text>
759          {...form.templates.map(template => (
760            <Button key={`pr-template:${template}`} plain onPress={() => A.chooseTemplate(c.io, template)}>
761              {template === form.template ? <Text bold>{basename(template)}</Text> : basename(template)}
762            </Button>
763          ))}
764        </Box>
765      ) : null}
766      {await statusLine(c)}
767      <Box flexDirection="column" marginTop={1}>
768        {form.body.trim() === '' ? <Text dimColor>No description. Push e to write one.</Text> : renderMarkdown(c, form.body, Math.max(20, bodyColumns - 1))}
769      </Box>
770    </Box>
771  )
772}
773
774export const renderPane = async (c: Ctx): Promise<RenderElement> => {
775  const screen = await S.pick(c.io, 'screen')
776
777  return screen === 'preview' ? renderPreview(c) : screen === 'log' ? renderLog(c) : screen === 'pr' ? renderPr(c) : renderTree(c)
778}
779
780/**
781 * The Files button at the right of the prompt footer, beside the mode labels (`auto mode`):
782 * the labels the engine had, then the button that opens or hides the pane.
783 */
784export const renderFooter = async (c: Ctx, modes: readonly string[]): Promise<RenderElement | null> => {
785  const snap = await S.pick(c.io, 'snapshot')
786  if (snap === null || snap.error !== undefined) return null
787  const { Box, Text, Button } = c.t
788  const isOpen = await S.pick(c.io, 'isOpen')
789
790  return (
791    <Box columnGap={2}>
792      {modes.length > 0 ? <Text dimColor>{modes.join(' & ')}</Text> : null}
793      <Button key="toggle" plain onPress={() => (isOpen ? A.closePane(c.io) : A.openPane(c.io))}>
794        <Text color="claude">▤ </Text>
795        <Text>{isOpen ? 'Hide files' : 'Files'}</Text>
796        {snap.files.length === 0 ? <Text dimColor> clean</Text> : ''}
797        {...summaryParts(c, snap.files)}
798      </Button>
799    </Box>
800  )
801}
802
hooks/activity.ts 82 lines
1import type { Activity, ActivityKind } from '../types'
2
3/** Activity kept at most: the oldest goes first. */
4const MAX_ACTIVITY = 300
5
6/** Files one search marks at most, so a broad `grep -r` does not light up the whole tree. */
7const MAX_PER_CALL = 40
8
9/** Output lines looked through for paths. */
10const MAX_LINES = 3000
11
12/** A shell command that makes a commit: `git commit`, `git -c k=v commit`, after `&&` or `;`. */
13export const isCommit = (command: string): boolean =>
14  /(^|[\s;&|(])git(\s+-[cC]\s+\S+|\s+--?[\w-]+(=\S+)?)*\s+commit(\s|$)/.test(command) && !/\s--dry-run\b/.test(command)
15
16/** `a/b/c.ts` → `a/`, `a/b/`: the folders a row sits in, as the tree names them. */
17export const ancestors = (path: string): string[] => {
18  const parts = path.split('/').slice(0, -1)
19
20  return parts.map((_, index) => `${parts.slice(0, index + 1).join('/')}/`)
21}
22
23/** Resolves `.` and `..` in a slash path. */
24const normalize = (path: string): string => {
25  const out: string[] = []
26  for (const part of path.split('/')) {
27    if (part === '' || part === '.') continue
28    if (part === '..') out.pop()
29    else out.push(part)
30  }
31
32  return out.join('/')
33}
34
35/** `path` root-relative, or null when it lies outside `root`; `base` is the root-relative folder a relative path starts from. */
36export const toRelative = (path: string, root: string, base = ''): string | null => {
37  if (path.startsWith('/')) return path.startsWith(`${root}/`) ? normalize(path.slice(root.length + 1)) : null
38  if (path.startsWith('~')) return null
39
40  return normalize(base + path)
41}
42
43/**
44 * The repository files a read-only shell command named or printed: the command's own words,
45 * and each output line whole or before its first `:` (grep's `path:line:`). Only paths in `known` count.
46 */
47export const pathsIn = (command: string, output: string, known: ReadonlySet<string>, root: string, base: string): string[] => {
48  const found: string[] = []
49  const consider = (raw: string) => {
50    if (found.length >= MAX_PER_CALL) return
51    const word = raw.trim().replace(/^['"]+|['"]+$/g, '')
52    if (word === '' || word.startsWith('-')) return
53    const path = toRelative(word, root, base)
54    if (path !== null && known.has(path) && !found.includes(path)) found.push(path)
55  }
56
57  for (const word of command.split(/[\s'"=;|&()<>]+/)) consider(word)
58  for (const line of output.split('\n').slice(0, MAX_LINES)) {
59    consider(line)
60    const colon = line.indexOf(':')
61    if (colon > 0) consider(line.slice(0, colon))
62  }
63
64  return found
65}
66
67/**
68 * Adds what Claude just did to the list, latest last. A read never hides a write or a commit
69 * of the same file; a write and a commit replace each other.
70 */
71export const mergeActivity = (activity: readonly Activity[], kind: ActivityKind, paths: readonly string[]): Activity[] => {
72  let seq = activity.reduce((max, entry) => Math.max(max, entry.seq), 0)
73  const byPath = new Map(activity.map(entry => [entry.path, entry]))
74  for (const path of paths) {
75    const before = byPath.get(path)
76    seq += 1
77    byPath.set(path, { path, kind: kind === 'read' && before !== undefined ? before.kind : kind, seq })
78  }
79
80  return [...byPath.values()].sort((a, b) => a.seq - b.seq).slice(-MAX_ACTIVITY)
81}
82
hooks/git.ts 387 lines
1import type { BaseMode, ChangedFile, CommitInfo, FileStatus, Snapshot } from '../types'
2
3/** git's well-known empty tree: the base of a repository with no commit yet. */
4export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
5
6export type RunResult = { exitCode: number; stdout: string; stderr: string }
7
8/** Runs git with these arguments in the given directory. */
9export type Git = (args: string[], cwd?: string) => Promise<RunResult>
10
11const BINARY_EXTENSIONS = new Set([
12  'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico', 'bmp', 'tiff', 'psd',
13  'pdf', 'zip', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar', 'jar', 'war',
14  'woff', 'woff2', 'ttf', 'otf', 'eot',
15  'mp3', 'mp4', 'mov', 'avi', 'webm', 'wav', 'ogg', 'flac',
16  'exe', 'dll', 'so', 'dylib', 'bin', 'o', 'a', 'class', 'wasm',
17  'sqlite', 'db', 'phar',
18])
19
20export const extensionOf = (path: string): string => {
21  const name = path.slice(path.lastIndexOf('/') + 1)
22  const dot = name.lastIndexOf('.')
23
24  return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''
25}
26
27export const looksBinary = (path: string): boolean => BINARY_EXTENSIONS.has(extensionOf(path))
28
29const splitZ = (text: string): string[] => {
30  const parts = text.split('\0')
31  if (parts[parts.length - 1] === '') parts.pop()
32
33  return parts
34}
35
36const statusFromPorcelain = (x: string, y: string): FileStatus | null => {
37  const xy = x + y
38  if (xy === '??') return 'untracked'
39  if (xy === '!!') return null
40  if (x === 'U' || y === 'U' || xy === 'AA' || xy === 'DD') return 'conflict'
41  if (x === 'R' || y === 'R') return 'renamed'
42  // Added to the index, then removed from disk: it exists nowhere.
43  if (x === 'A' && y === 'D') return null
44  if (x === 'D' || y === 'D') return 'deleted'
45  if (x === 'A' || x === 'C') return 'added'
46
47  return 'modified'
48}
49
50/** Parses `git status --porcelain=v1 -z`. */
51export const parseStatus = (stdout: string): ChangedFile[] => {
52  const parts = splitZ(stdout)
53  const files: ChangedFile[] = []
54
55  for (let i = 0; i < parts.length; i++) {
56    const entry = parts[i] ?? ''
57    if (entry.length < 4) continue
58    const x = entry[0] ?? ' '
59    const y = entry[1] ?? ' '
60    const path = entry.slice(3)
61    const status = statusFromPorcelain(x, y)
62    // A rename or copy carries its source as the next entry.
63    const from = x === 'R' || x === 'C' || y === 'R' || y === 'C' ? parts[++i] : undefined
64    if (status === null) continue
65    const file: ChangedFile = status === 'renamed' && from !== undefined ? { path, status, from } : { path, status }
66    // X is the index, Y the working tree; `??` lives in the working tree alone.
67    if (x !== ' ' && x !== '?') file.staged = true
68    if (y !== ' ' || x === '?') file.unstaged = true
69    // A conflict is resolved in the working tree before it can be staged.
70    if (status === 'conflict') delete file.staged
71    files.push(file)
72  }
73
74  return files
75}
76
77const statusFromLetter = (letter: string): FileStatus => {
78  switch (letter) {
79    case 'A':
80    case 'C':
81      return 'added'
82    case 'D':
83      return 'deleted'
84    case 'R':
85      return 'renamed'
86    case 'U':
87      return 'conflict'
88    default:
89      return 'modified'
90  }
91}
92
93/** Parses `git diff --name-status -z`. */
94export const parseNameStatus = (stdout: string): ChangedFile[] => {
95  const parts = splitZ(stdout)
96  const files: ChangedFile[] = []
97
98  for (let i = 0; i < parts.length; i++) {
99    const letter = (parts[i] ?? '').charAt(0)
100    if (letter === 'R' || letter === 'C') {
101      const from = parts[++i] ?? ''
102      const path = parts[++i] ?? ''
103      files.push(letter === 'R' ? { path, status: 'renamed', from } : { path, status: 'added' })
104      continue
105    }
106    const path = parts[++i]
107    if (path === undefined) break
108    files.push({ path, status: statusFromLetter(letter) })
109  }
110
111  return files
112}
113
114export type NumStat = { added: number; removed: number; isBinary: boolean }
115
116/** Parses `git diff --numstat -z` into counts by (new) path. */
117export const parseNumstat = (stdout: string): Map<string, NumStat> => {
118  const parts = splitZ(stdout)
119  const stats = new Map<string, NumStat>()
120
121  for (let i = 0; i < parts.length; i++) {
122    const [added = '', removed = '', path = ''] = (parts[i] ?? '').split('\t')
123    const isBinary = added === '-'
124    const stat = { added: isBinary ? 0 : Number(added), removed: isBinary ? 0 : Number(removed), isBinary }
125    if (path !== '') {
126      stats.set(path, stat)
127      continue
128    }
129    // A rename: the paths follow as two entries of their own.
130    i += 1
131    const newPath = parts[++i]
132    if (newPath !== undefined) stats.set(newPath, stat)
133  }
134
135  return stats
136}
137
138/** Parses `wc -l` output into line counts by path. */
139export const parseLineCounts = (stdout: string): Map<string, number> => {
140  const counts = new Map<string, number>()
141
142  for (const line of stdout.split('\n')) {
143    const match = /^\s*(\d+)\s(.+)$/.exec(line)
144    if (match === null) continue
145    const [, count = '0', path = ''] = match
146    if (path === 'total') continue
147    counts.set(path, Number(count))
148  }
149
150  return counts
151}
152
153const byPath = (a: ChangedFile, b: ChangedFile): number => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
154
155const ok = (result: RunResult): string | null => (result.exitCode === 0 ? result.stdout.trim() : null)
156
157/** The default branch to compare a feature branch against: origin's HEAD, else main or master. */
158export const defaultBranch = async (git: Git, root: string): Promise<string | null> => {
159  const remoteHead = ok(await git(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], root))
160  if (remoteHead) return remoteHead
161  for (const candidate of ['main', 'master', 'origin/main', 'origin/master']) {
162    const found = await git(['rev-parse', '--verify', '--quiet', candidate], root)
163    if (found.exitCode === 0) return candidate
164  }
165
166  return null
167}
168
169const untrackedLineCounts = async (
170  run: (argv: string[], cwd: string) => Promise<RunResult>,
171  root: string,
172  files: ChangedFile[],
173): Promise<void> => {
174  const counted = files.filter(f => f.status === 'untracked' && !looksBinary(f.path)).slice(0, 300)
175  if (counted.length === 0) return
176  const result = await run(['wc', '-l', '--', ...counted.map(f => f.path)], root)
177  const counts = parseLineCounts(result.stdout)
178  for (const file of counted) {
179    const lines = counts.get(file.path)
180    if (lines !== undefined) file.added = lines
181  }
182}
183
184const applyStats = (files: ChangedFile[], stats: ReadonlyMap<string, NumStat>): void => {
185  for (const file of files) {
186    const stat = stats.get(file.path)
187    if (stat === undefined) continue
188    file.added = stat.added
189    file.removed = stat.removed
190    if (stat.isBinary) file.isBinary = true
191  }
192}
193
194/** Parses `git log --format=%H%x1f%h%x1f%ar%x1f%s`. */
195export const parseLog = (stdout: string): CommitInfo[] =>
196  stdout
197    .split('\n')
198    .map(line => line.split('\x1f'))
199    .filter(parts => parts.length >= 4 && parts[0] !== '')
200    .map(([sha = '', short = '', when = '', ...subject]) => ({ sha, short, when, subject: subject.join('\x1f') }))
201
202/**
203 * The commits of HEAD that are not pushed: those its upstream lacks, or with no upstream those
204 * no remote branch has. Null when there is no remote to compare with.
205 */
206const unpushedShas = async (git: Git, root: string, count: number): Promise<Set<string> | null> => {
207  const hasUpstream = ok(await git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], root)) !== null
208  if (!hasUpstream && !ok(await git(['remote'], root))) return null
209  const range = hasUpstream ? ['@{u}..HEAD'] : ['HEAD', '--not', '--remotes']
210  const listed = await git(['rev-list', `--max-count=${count}`, ...range], root)
211  if (listed.exitCode !== 0) return null
212
213  return new Set(listed.stdout.split('\n').filter(Boolean))
214}
215
216/** The latest commits of the current branch, newest first, each marked pushed or not where a remote says. */
217export const listCommits = async (git: Git, root: string, count = 50): Promise<CommitInfo[]> => {
218  const commits = parseLog((await git(['log', `-n${count}`, '--format=%H%x1f%h%x1f%ar%x1f%s'], root)).stdout)
219  const unpushed = await unpushedShas(git, root, count)
220
221  return unpushed === null ? commits : commits.map(commit => ({ ...commit, isPushed: !unpushed.has(commit.sha) }))
222}
223
224/** Every file in a commit, root-relative. */
225export const listTree = async (git: Git, root: string, sha: string): Promise<string[]> =>
226  splitZ((await git(['ls-tree', '-r', '--name-only', '-z', sha], root)).stdout)
227
228/** Reads what changed in the repository around `cwd`, against the chosen base, or what one commit changed. */
229export const scan = async (
230  git: Git,
231  run: (argv: string[], cwd: string) => Promise<RunResult>,
232  cwd: string,
233  baseMode: BaseMode,
234  commit: CommitInfo | null = null,
235  prompt: { before: string; now: string; label: string } | null = null,
236): Promise<Snapshot> => {
237  const top = await git(['rev-parse', '--show-toplevel'], cwd)
238  if (top.exitCode !== 0) {
239    return {
240      root: cwd, branch: '', baseMode, baseRev: '', baseLabel: '', files: [],
241      error: 'not-a-repo', message: 'Not inside a git repository.',
242    }
243  }
244  const root = top.stdout.trim()
245
246  const current = ok(await git(['branch', '--show-current'], root))
247  const hasHead = (await git(['rev-parse', '--verify', '--quiet', 'HEAD'], root)).exitCode === 0
248  const short = hasHead ? ok(await git(['rev-parse', '--short', 'HEAD'], root)) : null
249  const branch = current || (short ? `detached at ${short}` : 'no commits yet')
250
251  const tracking = current && hasHead ? await upstreamOf(git, root) : {}
252
253  if (commit !== null) {
254    // One past commit against its first parent (the empty tree for the first commit).
255    const parent = ok(await git(['rev-parse', '--verify', '--quiet', `${commit.sha}^`], root))
256    const base = parent ?? EMPTY_TREE
257    const changed = await git(['diff', '--name-status', '-z', '-M', base, commit.sha], root)
258    if (changed.exitCode !== 0) {
259      return {
260        root, branch, baseMode, baseRev: base, baseLabel: '', files: [], commit,
261        error: 'git-failed', message: changed.stderr.trim() || `Could not read commit ${commit.short}.`,
262      }
263    }
264    const files = parseNameStatus(changed.stdout)
265    applyStats(files, parseNumstat((await git(['diff', '--numstat', '-z', '-M', base, commit.sha], root)).stdout))
266
267    return { root, branch, ...tracking, baseMode, baseRev: base, baseLabel: parent ? `${commit.short}^` : 'empty tree', files: files.sort(byPath), commit }
268  }
269
270  if (baseMode === 'prompt' && prompt !== null) {
271    // Two trees, both with the untracked files: what changed on disk between them, whoever changed it.
272    const changed = await git(['diff', '--name-status', '-z', '-M', prompt.before, prompt.now], root)
273    if (changed.exitCode !== 0) {
274      return {
275        root, branch, ...tracking, baseMode, baseRev: prompt.before, baseLabel: prompt.label, files: [],
276        error: 'git-failed', message: changed.stderr.trim() || 'Could not compare with the last prompt.',
277      }
278    }
279    const files = parseNameStatus(changed.stdout)
280    applyStats(files, parseNumstat((await git(['diff', '--numstat', '-z', '-M', prompt.before, prompt.now], root)).stdout))
281
282    return { root, branch, ...tracking, baseMode, baseRev: prompt.before, toRev: prompt.now, baseLabel: prompt.label, files: files.sort(byPath) }
283  }
284
285  let baseRev = hasHead ? 'HEAD' : EMPTY_TREE
286  let baseLabel = hasHead ? 'HEAD' : 'empty tree'
287  let effectiveMode: BaseMode = 'head'
288
289  if (baseMode === 'branch' && hasHead) {
290    const target = await defaultBranch(git, root)
291    const mergeBase = target ? ok(await git(['merge-base', 'HEAD', target], root)) : null
292    if (target && mergeBase) {
293      baseRev = mergeBase
294      baseLabel = target.replace(/^origin\//, '')
295      effectiveMode = 'branch'
296    }
297  }
298
299  let files: ChangedFile[]
300  if (effectiveMode === 'head') {
301    const status = await git(['status', '--porcelain=v1', '-z', '--untracked-files=all'], root)
302    if (status.exitCode !== 0) {
303      return {
304        root, branch, baseMode, baseRev, baseLabel, files: [],
305        error: 'git-failed', message: status.stderr.trim() || 'git status failed.',
306      }
307    }
308    files = parseStatus(status.stdout)
309  } else {
310    const changed = await git(['diff', '--name-status', '-z', '-M', baseRev], root)
311    const untracked = await git(['ls-files', '--others', '--exclude-standard', '-z'], root)
312    files = [
313      ...parseNameStatus(changed.stdout),
314      ...splitZ(untracked.stdout).map((path): ChangedFile => ({ path, status: 'untracked' })),
315    ]
316  }
317
318  const numstat = await git(['diff', '--numstat', '-z', '-M', baseRev], root)
319  applyStats(files, parseNumstat(numstat.stdout))
320  await untrackedLineCounts(run, root, files)
321
322  // In the HEAD view git status lists a file staged and edited once; keep one row per path.
323  const unique = new Map(files.map(f => [f.path, f]))
324
325  return { root, branch, ...tracking, baseMode: effectiveMode, baseRev, baseLabel, files: [...unique.values()].sort(byPath) }
326}
327
328/** `2\t1` from `rev-list --left-right --count`: ahead, then behind. */
329export const parseAheadBehind = (text: string): { ahead: number; behind: number } | null => {
330  const match = /^(\d+)\s+(\d+)\s*$/.exec(text.trim())
331
332  return match === null ? null : { ahead: Number(match[1]), behind: Number(match[2]) }
333}
334
335/** The branch's upstream and how far apart they are, as of the last fetch (git is never asked to fetch). */
336const upstreamOf = async (git: Git, root: string): Promise<Pick<Snapshot, 'upstream' | 'ahead' | 'behind'>> => {
337  const upstream = ok(await git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], root))
338  if (!upstream) return {}
339  const counts = parseAheadBehind(ok(await git(['rev-list', '--left-right', '--count', 'HEAD...@{u}'], root)) ?? '')
340
341  return counts === null ? { upstream } : { upstream, ...counts }
342}
343
344/** Every file git knows of or would add, root-relative. */
345export const listAll = async (git: Git, root: string): Promise<string[]> => {
346  const result = await git(['ls-files', '-z', '--cached', '--others', '--exclude-standard'], root)
347
348  return [...new Set(splitZ(result.stdout))]
349}
350
351/** Keeps a unified diff's hunks and drops the file headers before them. */
352export const hunksOnly = (diff: string): string => {
353  const start = diff.search(/^@@ /m)
354
355  return start < 0 ? '' : diff.slice(start)
356}
357
358/** The diff of one file against the base, hunks only. */
359export const fileDiff = async (git: Git, snapshot: Snapshot, file: ChangedFile | undefined, path: string): Promise<string> => {
360  const root = snapshot.root
361  if (file?.status === 'untracked') {
362    const result = await git(['diff', '--no-color', '--no-index', '--', '/dev/null', path], root)
363
364    return hunksOnly(result.stdout)
365  }
366  const paths = file?.status === 'renamed' && file.from ? [file.from, path] : [path]
367  const end = snapshot.commit?.sha ?? snapshot.toRev
368  const revs = end === undefined ? [snapshot.baseRev] : [snapshot.baseRev, end]
369  const result = await git(['diff', '--no-color', '-M', ...revs, '--', ...paths], root)
370
371  return hunksOnly(result.stdout)
372}
373
374/** The file as a commit has it; null when the commit lacks it. */
375export const contentAt = async (git: Git, root: string, rev: string, path: string): Promise<string | null> => {
376  const result = await git(['show', `${rev}:${path}`], root)
377
378  return result.exitCode === 0 ? result.stdout : null
379}
380
381/** The file as the base has it (for a deleted file's source). */
382export const baseContent = async (git: Git, snapshot: Snapshot, path: string): Promise<string | null> => {
383  const result = await git(['show', `${snapshot.baseRev}:${path}`], snapshot.root)
384
385  return result.exitCode === 0 ? result.stdout : null
386}
387
hooks/preview.ts 218 lines
1import type { ChangedFile, Preview, PreviewMode, Snapshot } from '../types'
2import { baseContent, contentAt, extensionOf, fileDiff, looksBinary } from './git'
3import type { Git } from './git'
4import { blockPageRanges } from './markdown'
5
6/** Characters one page draws: a drawing holds 100,000, and the header needs a little. */
7export const PAGE_CHARS = 60_000
8const PAGE_LINES = 2_000
9/** A rendered markdown page is smaller: table rules and padding add to what it draws. */
10const MARKDOWN_PAGE_CHARS = 30_000
11const MAX_FILE_BYTES = 3 * 1024 * 1024
12
13export type PreviewDeps = {
14  git: Git
15  /** The file's size in bytes, or null when it is missing. */
16  size: (absolutePath: string) => Promise<number | null>
17  readText: (absolutePath: string) => Promise<string>
18  /** A PNG's pixel size, or null when it is not one. */
19  pngSize: (absolutePath: string) => Promise<{ width: number; height: number } | null>
20}
21
22export const isMarkdown = (path: string): boolean => ['md', 'markdown', 'mdx'].includes(extensionOf(path))
23
24/** The modes a file offers, in toolbar order. */
25export const modesFor = (path: string, file: ChangedFile | undefined): PreviewMode[] => {
26  const modes: PreviewMode[] = []
27  if (file !== undefined && !file.isBinary && !looksBinary(path)) modes.push('diff')
28  modes.push('source')
29  if (isMarkdown(path)) modes.push('rendered')
30
31  return modes
32}
33
34/** What a file opens in: its changes when it has some, else its content. */
35export const defaultMode = (path: string, file: ChangedFile | undefined): PreviewMode => {
36  const markdownMode: PreviewMode = isMarkdown(path) ? 'rendered' : 'source'
37  if (file === undefined) return markdownMode
38  if (file.status === 'added' || file.status === 'untracked') return markdownMode
39  if (file.isBinary || looksBinary(path)) return 'source'
40
41  return 'diff'
42}
43
44/** Splits lines into pages of at most `budget` characters and `maxLines` lines: [start, end) each. */
45export const pageRanges = (lines: readonly string[], budget = PAGE_CHARS, maxLines = PAGE_LINES): Array<[number, number]> => {
46  const ranges: Array<[number, number]> = []
47  let start = 0
48  let size = 0
49
50  lines.forEach((line, index) => {
51    const cost = line.length + 1
52    if (index > start && (size + cost > budget || index - start >= maxLines)) {
53      ranges.push([start, index])
54      start = index
55      size = 0
56    }
57    size += cost
58  })
59  if (start < lines.length || ranges.length === 0) ranges.push([start, lines.length])
60
61  return ranges
62}
63
64const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/
65
66/** Cuts a hunk longer than `budget` into hunks of its own, each with a header that parses. */
67export const splitHunk = (hunk: string, budget = PAGE_CHARS): string[] => {
68  if (hunk.length <= budget) return [hunk]
69  const [header = '', ...body] = hunk.split('\n')
70  const match = HUNK_HEADER.exec(header)
71  if (match === null) return [hunk]
72  let oldLine = Number(match[1])
73  let newLine = Number(match[3])
74  const tail = match[5] ?? ''
75  const pieces: string[] = []
76
77  for (const [start, end] of pageRanges(body, budget - header.length - 2)) {
78    const lines = body.slice(start, end)
79    const oldCount = lines.filter(l => l.startsWith(' ') || l.startsWith('-')).length
80    const newCount = lines.filter(l => l.startsWith(' ') || l.startsWith('+')).length
81    pieces.push([`@@ -${oldLine},${oldCount} +${newLine},${newCount} @@${tail}`, ...lines].join('\n'))
82    oldLine += oldCount
83    newLine += newCount
84  }
85
86  return pieces
87}
88
89/** Pages a diff on hunk boundaries, so every page is hunks that parse. */
90export const diffPages = (diff: string, budget = PAGE_CHARS): string[] => {
91  const hunks = diff
92    .replace(/\n$/, '')
93    .split(/\n(?=@@ )/)
94    .filter(h => h.startsWith('@@ '))
95    .flatMap(h => splitHunk(h, budget))
96  const pages: string[] = []
97  let page: string[] = []
98  let size = 0
99
100  for (const hunk of hunks) {
101    if (page.length > 0 && size + hunk.length + 1 > budget) {
102      pages.push(page.join('\n'))
103      page = []
104      size = 0
105    }
106    page.push(hunk)
107    size += hunk.length + 1
108  }
109  if (page.length > 0) pages.push(page.join('\n'))
110
111  return pages
112}
113
114const formatBytes = (bytes: number): string =>
115  bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`
116
117const clampPage = (page: number, pages: number): number => Math.min(Math.max(0, page), Math.max(0, pages - 1))
118
119/** Loads what the preview screen draws for `path` in `mode`, one page of it. */
120export const loadPreview = async (
121  deps: PreviewDeps,
122  snapshot: Snapshot,
123  path: string,
124  mode: PreviewMode,
125  page = 0,
126): Promise<Preview> => {
127  const file = snapshot.files.find(f => f.path === path)
128  const modes = modesFor(path, file)
129  const shownMode = modes.includes(mode) ? mode : defaultMode(path, file)
130  const base = { path, mode: shownMode, modes, page: 0, pages: 1 }
131  const note = (text: string): Preview => ({ ...base, kind: 'note', text: '', note: text })
132
133  if (shownMode === 'diff') {
134    const diff = await fileDiff(deps.git, snapshot, file, path)
135    if (diff === '') return note(file?.status === 'renamed' ? `Renamed from ${file.from ?? '?'}, content unchanged.` : 'No line changes (mode or whitespace only).')
136    const pages = diffPages(diff)
137    const at = clampPage(page, pages.length)
138
139    return { ...base, kind: 'diff', text: pages[at] ?? '', page: at, pages: pages.length }
140  }
141
142  const absolute = `${snapshot.root}/${path}`
143  let text: string
144  if (file?.status === 'deleted') {
145    const old = await baseContent(deps.git, snapshot, path)
146    if (old === null) return note('Deleted; its last version could not be read.')
147    text = old
148  } else if (snapshot.commit !== undefined) {
149    // A past commit: the file as that commit has it.
150    if (looksBinary(path) || file?.isBinary) return note('Binary file.')
151    const old = await contentAt(deps.git, snapshot.root, snapshot.commit.sha, path)
152    if (old === null) return note(`Not in commit ${snapshot.commit.short}.`)
153    if (old.length > MAX_FILE_BYTES) return note('Too large to preview.')
154    if (old === '') return note('Empty file.')
155    text = old
156  } else {
157    const size = await deps.size(absolute)
158    if (size === null) return note('File not found on disk.')
159    if (extensionOf(path) === 'png') {
160      const dimensions = await deps.pngSize(absolute)
161      if (dimensions !== null) {
162        return { ...base, kind: 'image', text: '', image: { file: absolute, ...dimensions }, note: `${dimensions.width}×${dimensions.height} PNG, ${formatBytes(size)}` }
163      }
164    }
165    if (looksBinary(path) || file?.isBinary) return note(`Binary file, ${formatBytes(size)}.`)
166    if (size > MAX_FILE_BYTES) return note(`Too large to preview (${formatBytes(size)}).`)
167    if (size === 0) return note('Empty file.')
168    text = await deps.readText(absolute)
169  }
170  if (text.includes('\0')) return note('Binary file.')
171
172  const lines = text.replace(/\n$/, '').split('\n')
173  const ranges = shownMode === 'rendered' ? blockPageRanges(lines, MARKDOWN_PAGE_CHARS) : pageRanges(lines)
174  const at = clampPage(page, ranges.length)
175  const [start, end] = ranges[at] ?? [0, lines.length]
176
177  return {
178    ...base,
179    kind: shownMode === 'rendered' ? 'markdown' : 'code',
180    text: lines.slice(start, end).join('\n'),
181    page: at,
182    pages: ranges.length,
183    firstLine: start + 1,
184    totalLines: lines.length,
185  }
186}
187
188const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
189
190const decodeBase64 = (text: string): number[] => {
191  const bytes: number[] = []
192  let buffer = 0
193  let bits = 0
194  for (const char of text) {
195    const value = BASE64.indexOf(char)
196    if (value < 0) continue
197    buffer = (buffer << 6) | value
198    bits += 6
199    if (bits >= 8) {
200      bits -= 8
201      bytes.push((buffer >> bits) & 0xff)
202    }
203  }
204
205  return bytes
206}
207
208/** Reads a PNG's width and height from its header bytes (base64 of the file). */
209export const pngDimensions = (base64Head: string): { width: number; height: number } | null => {
210  const bytes = decodeBase64(base64Head)
211  const signature = [0x89, 0x50, 0x4e, 0x47]
212  if (bytes.length < 24 || signature.some((b, i) => bytes[i] !== b)) return null
213  const read32 = (at: number): number =>
214    (((bytes[at] ?? 0) << 24) | ((bytes[at + 1] ?? 0) << 16) | ((bytes[at + 2] ?? 0) << 8) | (bytes[at + 3] ?? 0)) >>> 0
215
216  return { width: read32(16), height: read32(20) }
217}
218
hooks/ops.ts 99 lines
1import type { ChangedFile, PrInfo } from '../types'
2
3/** How long a destructive press waits for its second press. */
4export const CONFIRM_MS = 4000
5
6/** What a git operation came to, as the status line shows it. */
7export type Outcome = { ok: boolean; text: string; detail?: string }
8
9const DETAIL_LINES = 12
10
11/** git's words as a status line: the first meaningful line, the rest kept for a failure. */
12export const outcomeOf = (ok: boolean, output: string, fallback: string): Outcome => {
13  const lines = output
14    .split('\n')
15    .map(line => line.replace(/^(error|fatal|hint|remote): /, '').trimEnd())
16    .filter(line => line.trim() !== '')
17  const [first = fallback, ...rest] = lines
18  const detail = rest.slice(0, DETAIL_LINES).join('\n')
19
20  return ok || detail === '' ? { ok, text: first } : { ok, text: first, detail }
21}
22
23/** The paths a stage or unstage of `file` touches: a rename moves both its paths. */
24export const pathsOf = (file: ChangedFile): string[] => (file.status === 'renamed' && file.from ? [file.from, file.path] : [file.path])
25
26/**
27 * The commands that throw away the changes to `file`, back to HEAD: a new or added file is
28 * deleted, a rename is undone, anything else is restored in the index and on disk.
29 */
30export const discardCommands = (file: ChangedFile): string[][] => {
31  switch (file.status) {
32    case 'untracked':
33      return [['clean', '-f', '--', file.path]]
34    case 'added':
35      return [['rm', '-f', '--', file.path]]
36    case 'renamed':
37      return [
38        ['rm', '-f', '--', file.path],
39        ['restore', '--source=HEAD', '--staged', '--worktree', '--', file.from ?? file.path],
40      ]
41    default:
42      return [['restore', '--source=HEAD', '--staged', '--worktree', '--', file.path]]
43  }
44}
45
46/** `gh pr view --json number,url,state,isDraft`, or null when there is no PR. */
47export const parsePrView = (stdout: string): PrInfo | null => {
48  try {
49    const value = JSON.parse(stdout) as Partial<PrInfo>
50    if (typeof value.number !== 'number' || typeof value.url !== 'string') return null
51
52    return { number: value.number, url: value.url, state: String(value.state ?? 'OPEN'), isDraft: value.isDraft === true }
53  } catch {
54    return null
55  }
56}
57
58/** The PR templates among the repository's files, in the places GitHub reads them. */
59export const findTemplates = (paths: readonly string[]): string[] =>
60  paths
61    .filter(path => /^(\.github\/|docs\/)?pull_request_template\.md$/i.test(path) || /^\.github\/pull_request_template\/[^/]+\.md$/i.test(path))
62    .sort((a, b) => a.localeCompare(b))
63
64/** A PR's state as the header says it: `PR #14 open`, `PR #14 draft`, `PR #14 merged`. */
65export const prLabel = (pr: PrInfo): string => `PR #${pr.number} ${pr.isDraft && pr.state === 'OPEN' ? 'draft' : pr.state.toLowerCase()}`
66
67/** How much of the staged diff the message prompt carries; the file list always goes whole. */
68export const MESSAGE_DIFF_CHARS = 24_000
69
70/**
71 * The prompt that asks the session's model for a commit message: the staged changes, and the
72 * repository's last subjects so the message reads like the rest of its history.
73 */
74export const messagePrompt = (input: { stat: string; diff: string; subjects: readonly string[]; amend: string | null }): string => {
75  const diff = input.diff.length > MESSAGE_DIFF_CHARS ? `${input.diff.slice(0, MESSAGE_DIFF_CHARS)}\n[… the rest of the diff is left out]` : input.diff
76
77  return [
78    'Write the git commit message for the staged changes below. Use what you know from this conversation about why they were made.',
79    input.amend === null ? '' : `This replaces the last commit (amend). Its message was:\n${input.amend}`,
80    input.subjects.length > 0 ? `Write it in the style of the repository's recent commits:\n${input.subjects.map(subject => `- ${subject}`).join('\n')}` : '',
81    'A subject line in the imperative, at most 60 characters, with no type prefix (feat:, fix:) and no closing period. Then, when the why is not obvious from the subject, a blank line and a short body in plain sentences saying why, wrapped at 72 columns.',
82    'Reply with the message alone: no quotes, no code fence, nothing before or after it.',
83    `Staged files:\n${input.stat.trim()}`,
84    `Staged diff:\n${diff.trim()}`,
85  ]
86    .filter(part => part !== '')
87    .join('\n\n')
88}
89
90/** The model's reply as a commit message: a code fence or wrapping quotes taken off. */
91export const cleanMessage = (reply: string): string => {
92  let text = reply.trim()
93  const fenced = /^```[^\n]*\n([\s\S]*?)\n```$/.exec(text)
94  if (fenced) text = (fenced[1] ?? '').trim()
95  if (/^(["'`]).*\1$/s.test(text)) text = text.slice(1, -1).trim()
96
97  return text
98}
99
hooks/tree.ts 130 lines
1import type { ChangedFile } from '../types'
2
3type Node = {
4  name: string
5  /** Root-relative; a folder's ends in `/`. */
6  path: string
7  isDir: boolean
8  children: Map<string, Node>
9  file?: ChangedFile
10  /** Changed files at or below this node. */
11  changes: number
12}
13
14export type Row = {
15  key: string
16  path: string
17  /** What the row shows: a name, or a compacted folder chain (`app/Http/`). */
18  label: string
19  depth: number
20  isDir: boolean
21  isOpen: boolean
22  changes: number
23  file?: ChangedFile
24}
25
26export type FlattenOptions = {
27  /** Whether a folder (path ending in `/`, holding `changes` changed files) shows its children. */
28  isOpen: (path: string, changes: number) => boolean
29  /** Folds a chain of single-folder folders into one row, as editors do. */
30  isCompact: boolean
31}
32
33const newNode = (name: string, path: string, isDir: boolean): Node => ({
34  name, path, isDir, children: new Map(), changes: 0,
35})
36
37/** Builds the folder tree over `paths`, marking each changed file. */
38export const buildTree = (paths: Iterable<string>, changes: ReadonlyMap<string, ChangedFile>): Node => {
39  const root = newNode('', '', true)
40
41  for (const path of paths) {
42    const parts = path.split('/')
43    let node = root
44    let prefix = ''
45    parts.forEach((part, index) => {
46      const isLast = index === parts.length - 1
47      prefix += isLast ? part : `${part}/`
48      let child = node.children.get(part + (isLast ? '' : '/'))
49      if (child === undefined) {
50        child = newNode(part, prefix, !isLast)
51        node.children.set(part + (isLast ? '' : '/'), child)
52      }
53      node = child
54    })
55    const file = changes.get(path)
56    if (file !== undefined) node.file = file
57  }
58
59  const count = (node: Node): number => {
60    node.changes = node.isDir ? [...node.children.values()].reduce((sum, child) => sum + count(child), 0) : node.file ? 1 : 0
61
62    return node.changes
63  }
64  count(root)
65
66  return root
67}
68
69const sorted = (node: Node): Node[] =>
70  [...node.children.values()].sort((a, b) => {
71    if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
72
73    return a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true })
74  })
75
76/** Lays the tree out as the rows the pane draws, top to bottom. */
77export const flatten = (root: Node, options: FlattenOptions): Row[] => {
78  const rows: Row[] = []
79
80  const walk = (node: Node, depth: number): void => {
81    for (const child of sorted(node)) {
82      if (!child.isDir) {
83        rows.push({
84          key: `row:${child.path}`, path: child.path, label: child.name, depth,
85          isDir: false, isOpen: false, changes: child.changes, ...(child.file ? { file: child.file } : {}),
86        })
87        continue
88      }
89      let shown = child
90      let label = `${child.name}/`
91      while (options.isCompact && shown.children.size === 1) {
92        const only = [...shown.children.values()][0]
93        if (only === undefined || !only.isDir) break
94        shown = only
95        label += `${only.name}/`
96      }
97      const isOpen = options.isOpen(shown.path, shown.changes)
98      rows.push({ key: `row:${shown.path}`, path: shown.path, label, depth, isDir: true, isOpen, changes: shown.changes })
99      if (isOpen) walk(shown, depth + 1)
100    }
101  }
102  walk(root, 0)
103
104  return rows
105}
106
107/** Case-insensitive subsequence match, so `lancon` finds `LandingController.php`. */
108export const matches = (path: string, filter: string): boolean => {
109  const needle = filter.trim().toLowerCase()
110  if (needle === '') return true
111  const hay = path.toLowerCase()
112  if (hay.includes(needle)) return true
113  let at = 0
114  for (const char of needle) {
115    if (char === ' ') continue
116    at = hay.indexOf(char, at)
117    if (at < 0) return false
118    at += 1
119  }
120
121  return true
122}
123
124/** The changed files in the order the changes view lists them (for next and previous). */
125export const reviewOrder = (files: readonly ChangedFile[]): string[] => {
126  const tree = buildTree(files.map(f => f.path), new Map(files.map(f => [f.path, f])))
127
128  return flatten(tree, { isOpen: () => true, isCompact: false }).filter(row => !row.isDir).map(row => row.path)
129}
130
hooks/markdown.ts 597 lines
1/**
2 * The parts of markdown the pane draws itself: headings (the engine's are bold body text),
3 * tables (the engine sizes them to the terminal, not the pane) and code fences.
4 * Everything else goes to the engine's `Markdown` as prose.
5 */
6
7export type Span = {
8  text: string
9  bold?: boolean
10  italic?: boolean
11  strike?: boolean
12  code?: boolean
13  link?: boolean
14  /** Table rules and the dim labels of the stacked layout. */
15  dim?: boolean
16}
17
18type Style = Omit<Span, 'text'>
19
20export type Align = 'left' | 'center' | 'right'
21
22export type Block =
23  | { kind: 'heading'; level: number; spans: Span[]; start: number; end: number }
24  | { kind: 'table'; align: Align[]; header: Span[][]; rows: Span[][][]; start: number; end: number }
25  | { kind: 'code'; language: string; source: string; start: number; end: number }
26  | { kind: 'prose'; text: string; start: number; end: number }
27
28/** One drawn row of a table: styled pieces that add up to the width it was laid out for. */
29export type Line = Span[]
30
31// Inline ----------------------------------------------------------------------------------
32
33const PUNCTUATION = /[!-/:-@[-`{-~]/
34const WORD = /[\p{L}\p{N}]/u
35
36const pushSpan = (out: Span[], span: Span): void => {
37  if (span.text === '') return
38  const last = out[out.length - 1]
39  if (last !== undefined && sameStyle(last, span)) last.text += span.text
40  else out.push({ ...span })
41}
42
43const styleKeys = ['bold', 'italic', 'strike', 'code', 'link', 'dim'] as const
44
45const sameStyle = (a: Style, b: Style): boolean => styleKeys.every(key => Boolean(a[key]) === Boolean(b[key]))
46
47/** Where the `]` closing the `[` at `open` is, brackets nesting; -1 when none. */
48const closingBracket = (src: string, open: number): number => {
49  let depth = 0
50  for (let i = open; i < src.length; i += 1) {
51    if (src[i] === '\\') {
52      i += 1
53      continue
54    }
55    if (src[i] === '[') depth += 1
56    if (src[i] === ']') {
57      depth -= 1
58      if (depth === 0) return i
59    }
60  }
61
62  return -1
63}
64
65/** Bold, italic, strikethrough, code and links, as styled spans; the markers dropped. */
66export const parseInline = (src: string, style: Style = {}): Span[] => {
67  const out: Span[] = []
68  let buffer = ''
69  const flush = () => {
70    pushSpan(out, { text: buffer, ...style })
71    buffer = ''
72  }
73  const nest = (inner: string, extra: Style) => {
74    flush()
75    for (const span of parseInline(inner, { ...style, ...extra })) pushSpan(out, span)
76  }
77  let i = 0
78
79  while (i < src.length) {
80    const char = src[i] ?? ''
81    const nextChar = src[i + 1] ?? ''
82
83    if (char === '\\' && PUNCTUATION.test(nextChar)) {
84      buffer += nextChar
85      i += 2
86      continue
87    }
88
89    if (char === '`') {
90      let ticks = 1
91      while (src[i + ticks] === '`') ticks += 1
92      const fence = '`'.repeat(ticks)
93      const end = src.indexOf(fence, i + ticks)
94      if (end > i) {
95        flush()
96        const inner = src.slice(i + ticks, end)
97        pushSpan(out, { text: /^ .* $/.test(inner) ? inner.slice(1, -1) : inner, ...style, code: true })
98        i = end + ticks
99      } else {
100        buffer += fence
101        i += ticks
102      }
103      continue
104    }
105
106    const pair = src.slice(i, i + 2)
107    if ((pair === '**' || pair === '__' || pair === '~~') && src[i + 2] !== undefined && src[i + 2] !== ' ') {
108      let end = src.indexOf(pair, i + 2)
109      // `***both***`: the bold closes on the last two stars.
110      while (end > 0 && src[end + 2] === pair[0]) end += 1
111      const isWordUnderscore = pair === '__' && (WORD.test(src[i - 1] ?? '') || WORD.test(src[end + 2] ?? ''))
112      if (end > i + 2 && !isWordUnderscore) {
113        nest(src.slice(i + 2, end), pair === '~~' ? { strike: true } : { bold: true })
114        i = end + 2
115        continue
116      }
117    }
118
119    if ((char === '*' || char === '_') && nextChar !== '' && nextChar !== ' ' && nextChar !== char) {
120      const opensMidWord = char === '_' && WORD.test(src[i - 1] ?? '')
121      // The closing mark: not half of a `**` inside, not after a space.
122      let end = src.indexOf(char, i + 1)
123      while (end > 0 && (src[end + 1] === char || src[end - 1] === ' ')) {
124        end = src.indexOf(char, src[end + 1] === char ? end + 2 : end + 1)
125      }
126      const closesMidWord = char === '_' && end > 0 && WORD.test(src[end + 1] ?? '')
127      if (end > i + 1 && src[end - 1] !== ' ' && !opensMidWord && !closesMidWord) {
128        nest(src.slice(i + 1, end), { italic: true })
129        i = end + 1
130        continue
131      }
132    }
133
134    const isImage = char === '!' && nextChar === '['
135    if (char === '[' || isImage) {
136      const open = isImage ? i + 1 : i
137      const close = closingBracket(src, open)
138      if (close > open && src[close + 1] === '(') {
139        const end = src.indexOf(')', close + 2)
140        if (end > close) {
141          const label = src.slice(open + 1, close)
142          nest(label === '' ? src.slice(close + 2, end) : label, { link: true })
143          i = end + 1
144          continue
145        }
146      }
147    }
148
149    if (char === '<') {
150      const end = src.indexOf('>', i + 1)
151      const inner = end > i ? src.slice(i + 1, end) : ''
152      if (/^(https?:|mailto:|file:)\S+$/.test(inner)) {
153        flush()
154        pushSpan(out, { text: inner, ...style, link: true })
155        i = end + 1
156        continue
157      }
158    }
159
160    buffer += char
161    i += 1
162  }
163  flush()
164
165  return out
166}
167
168// Width -----------------------------------------------------------------------------------
169
170const isZeroWidth = (cp: number): boolean =>
171  (cp >= 0x300 && cp <= 0x36f) || cp === 0x200b || cp === 0x200c || cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f)
172
173const isWide = (cp: number): boolean =>
174  (cp >= 0x1100 && cp <= 0x115f) ||
175  (cp >= 0x2e80 && cp <= 0xa4cf) ||
176  (cp >= 0xac00 && cp <= 0xd7a3) ||
177  (cp >= 0xf900 && cp <= 0xfaff) ||
178  (cp >= 0xfe30 && cp <= 0xfe4f) ||
179  (cp >= 0xff00 && cp <= 0xff60) ||
180  (cp >= 0xffe0 && cp <= 0xffe6) ||
181  (cp >= 0x1f300 && cp <= 0x1faff) ||
182  (cp >= 0x20000 && cp <= 0x3fffd)
183
184const charWidth = (char: string): number => {
185  const cp = char.codePointAt(0) ?? 0
186
187  return isZeroWidth(cp) ? 0 : isWide(cp) ? 2 : 1
188}
189
190/** Columns `text` takes in a terminal. */
191export const displayWidth = (text: string): number => {
192  let width = 0
193  for (const char of text) width += charWidth(char)
194
195  return width
196}
197
198const spansWidth = (spans: readonly Span[]): number => spans.reduce((sum, span) => sum + displayWidth(span.text), 0)
199
200const spansText = (spans: readonly Span[]): string => spans.map(span => span.text).join('')
201
202// Wrapping --------------------------------------------------------------------------------
203
204type Piece = { text: string; style: Style; isSpace: boolean }
205
206const styleOf = (span: Span): Style => {
207  const { text: _text, ...style } = span
208
209  return style
210}
211
212/** Splits spans into words and runs of spaces, each keeping its span's style. */
213const pieces = (spans: readonly Span[]): Piece[] =>
214  spans.flatMap(span =>
215    (span.text.match(/\s+|\S+/g) ?? []).map(text => ({ text: /^\s/.test(text) ? ' ' : text, style: styleOf(span), isSpace: /^\s/.test(text) })),
216  )
217
218/** Cuts `text` into chunks of at most `width` columns. */
219const hardBreak = (text: string, width: number): string[] => {
220  const chunks: string[] = []
221  let chunk = ''
222  let used = 0
223  for (const char of text) {
224    const w = charWidth(char)
225    if (used + w > width && chunk !== '') {
226      chunks.push(chunk)
227      chunk = ''
228      used = 0
229    }
230    chunk += char
231    used += w
232  }
233  if (chunk !== '') chunks.push(chunk)
234
235  return chunks
236}
237
238/** Wraps styled spans to lines of at most `width` columns, breaking words only when they cannot fit. */
239export const wrapSpans = (spans: readonly Span[], width: number): Line[] => {
240  const lines: Line[] = []
241  let line: Line = []
242  let used = 0
243  const breakLine = () => {
244    lines.push(line)
245    line = []
246    used = 0
247  }
248
249  for (const piece of pieces(spans)) {
250    if (piece.isSpace) {
251      if (used > 0 && used < width) {
252        pushSpan(line, { text: ' ', ...piece.style })
253        used += 1
254      }
255      continue
256    }
257    const w = displayWidth(piece.text)
258    if (used + w > width && used > 0) {
259      // Drop the space the line ended on.
260      const last = line[line.length - 1]
261      if (last !== undefined && last.text.endsWith(' ')) {
262        last.text = last.text.slice(0, -1)
263        if (last.text === '') line.pop()
264      }
265      breakLine()
266    }
267    if (w <= width) {
268      pushSpan(line, { text: piece.text, ...piece.style })
269      used += w
270      continue
271    }
272    const chunks = hardBreak(piece.text, width)
273    chunks.forEach((chunk, index) => {
274      pushSpan(line, { text: chunk, ...piece.style })
275      used += displayWidth(chunk)
276      if (index < chunks.length - 1) breakLine()
277    })
278  }
279  if (line.length > 0 || lines.length === 0) lines.push(line)
280
281  return lines.map(l => {
282    const last = l[l.length - 1]
283    if (last !== undefined && last.text.endsWith(' ')) last.text = last.text.replace(/ +$/, '')
284
285    return l.filter(span => span.text !== '')
286  })
287}
288
289// Blocks ----------------------------------------------------------------------------------
290
291const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^\s`]*)/
292const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/
293const DELIMITER = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
294const LIST_OR_QUOTE = /^\s*([-*+]|\d+[.)]|>)(\s|$)/
295
296/** A table row's cells: pipes split them, except escaped ones and ones inside code. */
297export const splitRow = (line: string): string[] => {
298  let text = line.trim()
299  if (text.startsWith('|')) text = text.slice(1)
300  if (text.endsWith('|') && !text.endsWith('\\|')) text = text.slice(0, -1)
301  const cells: string[] = []
302  let cell = ''
303  let inCode = false
304  for (let i = 0; i < text.length; i += 1) {
305    const char = text[i] ?? ''
306    if (char === '\\' && text[i + 1] === '|') {
307      cell += '|'
308      i += 1
309      continue
310    }
311    if (char === '`') inCode = !inCode
312    if (char === '|' && !inCode) {
313      cells.push(cell.trim())
314      cell = ''
315      continue
316    }
317    cell += char
318  }
319  cells.push(cell.trim())
320
321  return cells
322}
323
324const alignOf = (cell: string): Align => {
325  const text = cell.trim()
326  if (text.startsWith(':') && text.endsWith(':')) return 'center'
327  if (text.endsWith(':')) return 'right'
328
329  return 'left'
330}
331
332const isTableStart = (lines: readonly string[], at: number): boolean => {
333  const head = lines[at] ?? ''
334  const rule = lines[at + 1] ?? ''
335  if (!head.includes('|') || !DELIMITER.test(rule) || !rule.includes('-')) return false
336
337  return splitRow(head).length === splitRow(rule).length
338}
339
340/** The document as blocks, each knowing its lines `[start, end)`. */
341export const parseBlocks = (text: string): Block[] => {
342  const lines = text.split('\n')
343  const blocks: Block[] = []
344  let prose: string[] = []
345  let proseStart = 0
346
347  const flushProse = (end: number) => {
348    let first = 0
349    let last = prose.length
350    while (first < last && (prose[first] ?? '').trim() === '') first += 1
351    while (last > first && (prose[last - 1] ?? '').trim() === '') last -= 1
352    if (first < last) blocks.push({ kind: 'prose', text: prose.slice(first, last).join('\n'), start: proseStart, end })
353    prose = []
354  }
355  const addProse = (line: string, at: number) => {
356    if (prose.length === 0) proseStart = at
357    prose.push(line)
358  }
359
360  let i = 0
361  // Front matter reads as YAML.
362  if (lines[0] === '---') {
363    const close = lines.findIndex((line, index) => index > 0 && (line === '---' || line === '...'))
364    if (close > 0) {
365      blocks.push({ kind: 'code', language: 'yaml', source: lines.slice(1, close).join('\n'), start: 0, end: close + 1 })
366      i = close + 1
367    }
368  }
369
370  while (i < lines.length) {
371    const line = lines[i] ?? ''
372
373    const fence = FENCE.exec(line)
374    if (fence !== null) {
375      const marker = fence[1] ?? '```'
376      let end = i + 1
377      while (end < lines.length && !new RegExp(`^ {0,3}${marker[0] === '`' ? '`' : '~'}{${marker.length},}\\s*$`).test(lines[end] ?? '')) end += 1
378      flushProse(i)
379      blocks.push({ kind: 'code', language: fence[2] ?? '', source: lines.slice(i + 1, end).join('\n'), start: i, end: Math.min(end + 1, lines.length) })
380      i = end + 1
381      continue
382    }
383
384    const heading = HEADING.exec(line)
385    if (heading !== null) {
386      flushProse(i)
387      blocks.push({ kind: 'heading', level: (heading[1] ?? '#').length, spans: parseInline((heading[2] ?? '').trim()), start: i, end: i + 1 })
388      i += 1
389      continue
390    }
391
392    // Setext: a lone line of text over === or ---.
393    const previous = prose[prose.length - 1]
394    const beforePrevious = prose.length > 1 ? prose[prose.length - 2] : ''
395    const isLoneText =
396      previous !== undefined && previous.trim() !== '' && (beforePrevious ?? '').trim() === '' && !LIST_OR_QUOTE.test(previous) && !previous.includes('|')
397    if (isLoneText && (/^ {0,3}=+\s*$/.test(line) || /^ {0,3}-{2,}\s*$/.test(line))) {
398      prose.pop()
399      flushProse(i - 1)
400      blocks.push({ kind: 'heading', level: line.trim().startsWith('=') ? 1 : 2, spans: parseInline(previous.trim()), start: i - 1, end: i + 1 })
401      i += 1
402      continue
403    }
404
405    if (isTableStart(lines, i)) {
406      flushProse(i)
407      const header = splitRow(line)
408      const align = splitRow(lines[i + 1] ?? '').map(alignOf)
409      const rows: Span[][][] = []
410      let end = i + 2
411      while (end < lines.length) {
412        const row = lines[end] ?? ''
413        if (row.trim() === '' || FENCE.test(row) || HEADING.test(row)) break
414        const cells = splitRow(row)
415        rows.push(header.map((_, column) => parseInline(cells[column] ?? '')))
416        end += 1
417      }
418      blocks.push({ kind: 'table', align, header: header.map(cell => parseInline(cell)), rows, start: i, end })
419      i = end
420      continue
421    }
422
423    addProse(line, i)
424    i += 1
425  }
426  flushProse(lines.length)
427
428  return blocks
429}
430
431/**
432 * Pages a markdown file on block boundaries, so no page starts inside a table or a fence:
433 * `[start, end)` line ranges of at most `budget` characters, a block larger than that cut by lines.
434 */
435export const blockPageRanges = (lines: readonly string[], budget: number): Array<[number, number]> => {
436  const blocks = parseBlocks(lines.join('\n'))
437  const ranges: Array<[number, number]> = []
438  let start = 0
439  let size = 0
440  const costOf = (from: number, to: number) => lines.slice(from, to).reduce((sum, line) => sum + line.length + 1, 0)
441
442  // Blank lines between blocks belong to the block after them.
443  const edges = blocks.map((block, index) => (index === 0 ? 0 : block.start)).concat(lines.length)
444  for (let index = 0; index < edges.length - 1; index += 1) {
445    const from = edges[index] ?? 0
446    const to = edges[index + 1] ?? lines.length
447    const cost = costOf(from, to)
448    if (size > 0 && size + cost > budget) {
449      ranges.push([start, from])
450      start = from
451      size = 0
452    }
453    if (cost > budget) {
454      // One block too large for a page: cut it by lines.
455      let at = from
456      let used = 0
457      for (let line = from; line < to; line += 1) {
458        const lineCost = (lines[line] ?? '').length + 1
459        if (line > at && used + lineCost > budget) {
460          ranges.push([start, line])
461          start = line
462          at = line
463          used = 0
464        }
465        used += lineCost
466      }
467      size = used
468      continue
469    }
470    size += cost
471  }
472  if (start < lines.length || ranges.length === 0) ranges.push([start, lines.length])
473
474  return ranges
475}
476
477// Tables ----------------------------------------------------------------------------------
478
479const BORDER = { top: ['┌', '┬', '┐'], middle: ['├', '┼', '┤'], bottom: ['└', '┴', '┘'] } as const
480
481const rule = (widths: readonly number[], [left, cross, right]: readonly [string, string, string]): Line => [
482  { text: left + widths.map(w => '─'.repeat(w + 2)).join(cross) + right, dim: true },
483]
484
485const pad = (line: Line, width: number, align: Align): Line => {
486  const gap = Math.max(0, width - spansWidth(line))
487  const before = align === 'right' ? gap : align === 'center' ? Math.floor(gap / 2) : 0
488  const after = gap - before
489  const out: Line = []
490  if (before > 0) out.push({ text: ' '.repeat(before) })
491  for (const span of line) pushSpan(out, span)
492  if (after > 0) pushSpan(out, { text: ' '.repeat(after) })
493
494  return out
495}
496
497/** One table row as drawn lines, each cell wrapped to its column. */
498const gridRow = (cells: readonly Span[][], widths: readonly number[], align: readonly Align[], isHeader: boolean): Line[] => {
499  const wrapped = widths.map((width, column) => {
500    const spans = (cells[column] ?? []).map(span => (isHeader ? { ...span, bold: true } : span))
501
502    return wrapSpans(spans, width)
503  })
504  const height = Math.max(1, ...wrapped.map(cell => cell.length))
505  const lines: Line[] = []
506  for (let row = 0; row < height; row += 1) {
507    const line: Line = [{ text: '│', dim: true }]
508    widths.forEach((width, column) => {
509      line.push({ text: ' ' })
510      for (const span of pad(wrapped[column]?.[row] ?? [], width, align[column] ?? 'left')) line.push(span)
511      line.push({ text: ' ' }, { text: '│', dim: true })
512    })
513    lines.push(line)
514  }
515
516  return lines
517}
518
519/** How wide each column is drawn in `width` columns, or null when even the narrowest layout overflows. */
520export const columnWidths = (header: readonly Span[][], rows: readonly Span[][][], width: number): number[] | null => {
521  const columns = header.length
522  const all = [header, ...rows]
523  const natural = header.map((_, column) => Math.max(1, ...all.map(row => spansWidth(row[column] ?? []))))
524  const room = width - (3 * columns + 1)
525  if (natural.reduce((a, b) => a + b, 0) <= room) return natural
526
527  // The narrowest a column goes: its longest word whole when they all fit, else cut at a cap;
528  // a grid that would chop short words reads worse than cards.
529  const longestWord = header.map((_, column) =>
530    Math.max(1, ...all.flatMap(row => spansText(row[column] ?? []).split(/\s+/).map(displayWidth))),
531  )
532  const leastFor = (cap: number) => natural.map((n, column) => Math.min(n, Math.max(3, Math.min(cap, longestWord[column] ?? 1))))
533  const sum = (list: readonly number[]) => list.reduce((a, b) => a + b, 0)
534  const least = [Infinity, 30, 20].map(leastFor).find(list => sum(list) <= room)
535  if (least === undefined) return null
536  const leastSum = sum(least)
537
538  // Share what is left by how much more each column wants.
539  const want = natural.map((n, column) => n - (least[column] ?? 0))
540  const wantSum = want.reduce((a, b) => a + b, 0)
541  const spare = room - leastSum
542  const widths = least.map((l, column) => l + Math.floor(((want[column] ?? 0) * spare) / Math.max(1, wantSum)))
543  let left = room - widths.reduce((a, b) => a + b, 0)
544  while (left > 0) {
545    let best = -1
546    widths.forEach((w, column) => {
547      if (w < (natural[column] ?? 0) && (best < 0 || (natural[column] ?? 0) - w > (natural[best] ?? 0) - (widths[best] ?? 0))) best = column
548    })
549    if (best < 0) break
550    widths[best] = (widths[best] ?? 0) + 1
551    left -= 1
552  }
553
554  return widths
555}
556
557/** A table that cannot fit as a grid: each row a card of `Header  value` lines. */
558const cards = (header: readonly Span[][], rows: readonly Span[][][], width: number): Line[] => {
559  const labelWidth = Math.min(Math.max(...header.map(spansWidth), 1), Math.floor(width / 3))
560  const lines: Line[] = []
561  rows.forEach((row, index) => {
562    if (index > 0) lines.push([{ text: '─'.repeat(width), dim: true }])
563    header.forEach((label, column) => {
564      const labelLines = wrapSpans(label.map(span => ({ ...span, bold: true, dim: true })), labelWidth)
565      const valueLines = wrapSpans(row[column] ?? [], Math.max(1, width - labelWidth - 2))
566      const height = Math.max(labelLines.length, valueLines.length)
567      for (let at = 0; at < height; at += 1) {
568        const line = pad(labelLines[at] ?? [], labelWidth, 'left')
569        line.push({ text: '  ' })
570        for (const span of valueLines[at] ?? []) line.push(span)
571        lines.push(line.filter(span => span.text !== ''))
572      }
573    })
574  })
575
576  return lines
577}
578
579/** A table laid out in `width` columns: a grid when it fits, else stacked cards. */
580export const layoutTable = (table: Extract<Block, { kind: 'table' }>, width: number): Line[] => {
581  const widths = columnWidths(table.header, table.rows, width)
582  if (widths === null) return cards(table.header, table.rows, width)
583
584  const headerLines = gridRow(table.header, widths, table.header.map((_, column) => (table.align[column] === 'right' ? 'right' : 'center')), true)
585  const bodies = table.rows.map(row => gridRow(row, widths, table.align, false))
586  // Rules between rows only where some row wraps: one-line rows read fine without.
587  const isWrapped = bodies.some(lines => lines.length > 1)
588  const lines: Line[] = [rule(widths, BORDER.top), ...headerLines, rule(widths, BORDER.middle)]
589  bodies.forEach((body, index) => {
590    if (index > 0 && isWrapped) lines.push(rule(widths, BORDER.middle))
591    lines.push(...body)
592  })
593  lines.push(rule(widths, BORDER.bottom))
594
595  return lines
596}
597
types/index.d.ts 171 lines
1/** How a file differs from the base the tree compares against. */
2export type FileStatus =
3  | 'modified'
4  | 'added'
5  | 'untracked'
6  | 'deleted'
7  | 'renamed'
8  | 'conflict'
9
10/** One changed file, its path relative to the repository root. */
11export type ChangedFile = {
12  path: string
13  status: FileStatus
14  /** The old path of a rename. */
15  from?: string
16  added?: number
17  removed?: number
18  isBinary?: boolean
19  /** The index holds a change to it (uncommitted view only). */
20  staged?: boolean
21  /** The working tree holds a change the index lacks (uncommitted view only). */
22  unstaged?: boolean
23}
24
25/**
26 * `head`: uncommitted work against HEAD. `branch`: everything since the branch left the default branch.
27 * `prompt`: what changed on disk since the last prompt (or the session start).
28 */
29export type BaseMode = 'head' | 'branch' | 'prompt'
30
31/** The working tree as a git tree, taken when a prompt was sent (or the session started). */
32export type PromptBase = { tree: string; at: 'prompt' | 'session' }
33
34/** One commit, as the history lists it. */
35export type CommitInfo = {
36  sha: string
37  short: string
38  subject: string
39  /** `2 days ago`. */
40  when: string
41  /** Whether a remote has it: its upstream's branch, or any remote branch. Absent with no remote. */
42  isPushed?: boolean
43}
44
45export type Snapshot = {
46  root: string
47  branch: string
48  baseMode: BaseMode
49  /** The revision diffs are taken against (HEAD, a merge base, or the empty tree). */
50  baseRev: string
51  /** What the header says the tree compares against: `HEAD`, `main`. */
52  baseLabel: string
53  files: ChangedFile[]
54  /** Set when the tree shows one past commit (against its parent) instead of the working tree. */
55  commit?: CommitInfo
56  /** The tree diffs end at instead of the working tree: the files as they are now, in the prompt view. */
57  toRev?: string
58  /** The branch's upstream (`origin/main`), when it has one. */
59  upstream?: string
60  /** Commits on the branch the upstream lacks, and the reverse, as of the last fetch. */
61  ahead?: number
62  behind?: number
63  error?: 'not-a-repo' | 'git-failed'
64  message?: string
65}
66
67export type View = 'changes' | 'all'
68
69export type Screen = 'tree' | 'preview' | 'log' | 'pr'
70
71/** The branch's pull request, as `gh pr view` reports it. */
72export type PrInfo = { number: number; url: string; state: string; isDraft: boolean }
73
74/** What the git controls draw from. */
75export type GitUi = {
76  /** The operation running (`Pushing…`); the other controls wait while it runs. */
77  busy: string | null
78  /** What the last operation came to: its first line, and the rest of git's words on a failure. */
79  notice: { ok: boolean; text: string; detail?: string } | null
80  /** The destructive press waiting for a second one (`discard:<path>`, `force`, `undo`), and when it was armed. */
81  confirm: { key: string; at: number } | null
82  message: string
83  amend: boolean
84  /** Whether `gh` is installed and signed in; null until asked. */
85  hasGh: boolean | null
86  pr: PrInfo | null
87  /** The branch PRs go to (`main`); null until read. */
88  base: string | null
89}
90
91/** The pull request form. */
92export type PrForm = {
93  title: string
94  base: string
95  draft: boolean
96  /** The repository's PR templates, root-relative, and the one the description started from. */
97  templates: string[]
98  template: string | null
99  /** Where the description is kept for editing: a file in the git directory. */
100  bodyFile: string
101  /** The description as last read from that file. */
102  body: string
103}
104
105export type PreviewMode = 'diff' | 'source' | 'rendered'
106
107export type PreviewImage = { file: string; width: number; height: number }
108
109export type Preview = {
110  path: string
111  mode: PreviewMode
112  /** The modes this file offers, in toolbar order. */
113  modes: PreviewMode[]
114  kind: 'code' | 'diff' | 'markdown' | 'image' | 'note'
115  text: string
116  page: number
117  pages: number
118  /** The 1-based line the page starts at, for a source page. */
119  firstLine?: number
120  totalLines?: number
121  note?: string
122  image?: PreviewImage
123}
124
125/** What Claude did to a file: read or searched it, wrote it, committed it. */
126export type ActivityKind = 'read' | 'write' | 'commit'
127
128export type Activity = {
129  /** Root-relative. */
130  path: string
131  kind: ActivityKind
132  /** Rises with each action; the highest is the latest. */
133  seq: number
134}
135
136/** Everything the pane and the band draw from: one value, so every write redraws both. */
137export type UiState = {
138  snapshot: Snapshot | null
139  allFiles: string[] | null
140  view: View
141  baseMode: BaseMode
142  /** The working tree when the last prompt was sent; null until the session's first snapshot. */
143  promptBase: PromptBase | null
144  /** Folders the person folded in the changes view. */
145  collapsed: string[]
146  /** Folders the person opened in the all-files view. */
147  expanded: string[]
148  screen: Screen
149  /** The past commit the tree shows; null for the working changes. */
150  commit: CommitInfo | null
151  /** The recent commits the history screen lists; null until read. */
152  log: CommitInfo[] | null
153  git: GitUi
154  prForm: PrForm | null
155  preview: Preview | null
156  /** The file Claude edited last, root-relative. */
157  touched: string | null
158  /** The files Claude read, wrote or committed since the last prompt. */
159  activity: Activity[]
160  /** The filter text; null while the filter field is closed. */
161  filter: string | null
162  isOpen: boolean
163  isLoading: boolean
164}
165
166declare module 'claude-code' {
167  interface PluginState {
168    'git-file-tree': { ui: UiState }
169  }
170}
171