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…

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.
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 pane shows:
/plugin install git-file-tree --marketplace CosminBd/claude-git-file-tree
y to add the marketplace./files./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.
| Key | Function |
|---|---|
v | Change what the tree compares with. See the table below |
a | Show only the changed files, or show all the files |
f | Find files by name. For example, chk finds checkout.ts |
h | Show the commit history |
The tree can compare with three references:
| View | The tree shows |
|---|---|
| Uncommitted | The changes against HEAD. This is the default view |
| Branch | All the changes of the branch against main or master |
| Prompt | Only the changes since your last prompt |
The letter before each file shows the type of change:
| Letter | Change |
|---|---|
| M | Modified |
| A | Added |
| U | New. Git does not track the file |
| D | Deleted |
| R | Renamed |
The tree refreshes after each edit that Claude makes. While the pane is open, the tree also refreshes every 3 seconds.
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.
Click a file to open it. A changed file opens on its diff.

| Key | Function |
|---|---|
d | Show the diff |
s | Show the source, with syntax highlighting |
m | Show the rendered Markdown |
n / p | Go to the next or the previous changed file |
t | Stage or unstage the file |
x | Discard the changes to the file. Push x two times to confirm |
c | Copy the path of the file |
o | Open the file in its default app |
b | Go back to the tree |
A Markdown file opens rendered. The Markdown viewer fits the text to the width of the pane:

Terminals that can show images (kitty, Ghostty) show PNG files in the pane.
The git operations are available only in the Uncommitted view.
To stage changes:
To commit the staged changes:
c.Claude can write the commit message for you:
w.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:
| Button | Function |
|---|---|
| Push | Push the branch. The key is p |
| Push branch | Push a branch that has no upstream. The upstream becomes origin |
| Force push | Push with --force-with-lease --force-if-includes. Use it after an amend |
| Pull | Pull with --ff-only |
| Fetch | Fetch from the remote |
| Undo commit | Remove the last commit if it is not pushed (git reset --soft). The changes stay staged |
| Create PR | Create a PR for the branch. You must install the GitHub CLI (gh) and log in |
| PR #14 open | Open 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.
Push h to show the last 50 commits of the current branch. The commits that are not pushed show first.

To examine a commit:
v.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.
gh). You must log in.claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT
hooks/register.tsx 142 lines1import { 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}
142hooks/actions.ts 645 lines1import 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}
645hooks/state.ts 44 lines1import 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 }
44hooks/view.tsx 802 lines1import 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}
802hooks/activity.ts 82 lines1import 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}
82hooks/git.ts 387 lines1import 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}
387hooks/preview.ts 218 lines1import 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}
218hooks/ops.ts 99 lines1import 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}
99hooks/tree.ts 130 lines1import 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}
130hooks/markdown.ts 597 lines1/**
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}
597types/index.d.ts 171 lines1/** 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