Source Control for Claude Code: Git status above the chat box, a panel with changes, commit, a history graph, branches and worktrees, plus fetch, pull, push…

English · Deutsch
A mod for Claude Code (desktop app and terminal) that makes Git as visible and as easy to drive as VS Code's Source Control. It is built for working on one repository from several machines: you see at a glance whether there is something to pull or push before the copies drift apart.
The band above the chat box: always there, minimal:

⎇ main 2↓ 1↑ ✎ 3 · 4 min ago [ ↻ Sync 2↓ 1↑ ] ◨
⎇ main: the current branch (or abc1234 (detached))2↓: commits on the remote that are not here yet (pull)1↑: local commits not on the remote yet (push)✎ 3: changed files4 min ago: when the remote was last checked (fetched). Yellow means stale or offline. A green ✓ shows only when everything is clean, in sync and freshly checked.| State | Button |
|---|---|
| behind or ahead of the remote | ↻ Sync (pull, then push) |
| branch without upstream | ⇡ Publish |
| local changes only | ✓ Commit… (opens the panel at the message field) |
| conflict, or a merge/rebase in progress | Resolve… / Finish… (opens the panel) |
◨ opens or closes the Source Control panel beside the chat.The Source Control panel (docked on the right; above the prompt in narrow terminals):
<img src="docs/images/panel-history.png" width="400" alt="The panel with the history graph: incoming and outgoing commits, branches merging"> <img src="docs/images/panel-conflict.png" width="400" alt="The panel during a merge conflict, with Ask Claude to resolve">
Source Control ↻ Fetch ↓ Pull ↑ Push
⎇ main → origin/main 1↓ 1↑ · checked just now
origin · github.com/manuel-will/cc-source-control
────────────────────────────────────────────────────────────────
Message (Enter to commit on “main”)
✓ Commit (all 1) [ ↻ Sync 1↓ 1↑ ]
▾ CHANGES 1 + all
M app.js +
▾ HISTORY
○ Incoming Changes 1 commit
● PC: Readme ⟨origin/main⟩
│ ○ Outgoing Changes 1 commit
│ ● Laptop: notes ⟨main⟩
● ╯ Docs: section 3
▾ BRANCHES 3 · ▾ WORKTREES 2 · ▾ STASHES 1
+ and −, or all at once.main and origin/main in their lane's color. Merges and branches get lanes of their own that join where they forked. In the terminal it reads like git log --graph.local only or deleted on remote. Also worktrees (Claude's included) with their changes, and stashes.+. Or hand the work to Claude with “Ask Claude to resolve”; “Abort merge” restores the previous state.Once on every machine, in Claude Code:
/plugin marketplace add manuel-will/cc-source-control
/plugin install source-control@cc-source-control
/reload-plugins
Or in a terminal:
claude plugin marketplace add manuel-will/cc-source-control
claude plugin install source-control@cc-source-control --scope user
With --scope user the mod is on in every project. When Claude Desktop asks where to install, pick user (all projects). If /git works in one folder only, the mod is installed only there: bash scripts/install-user.sh installs it for every project. If /git is still missing in some projects, bash scripts/diagnose.sh names the reason for each, such as "disableAllHooks": true in the project's settings (mods are made of hooks) or the plugin turned off there. The note “4 userConfig options not yet set” during installation is harmless: without values of your own the defaults apply (see Settings). If the mod does not show up right away, restart Claude Code or the desktop app. It needs Claude Code 2.1.287 or newer.
Update after a push to this repository:
claude plugin marketplace update cc-source-control
claude plugin update source-control@cc-source-control
Then restart Claude Code.
| What | How |
|---|---|
| Open the panel (focus in the message field) | /git or ◨ |
| Focus the band (terminal) | ctrl+x tab, then Tab between buttons, Enter presses |
| Back to the prompt | Esc |
| Close the panel | ◨, ctrl+x x, or /git again while it has focus |
| Actions | /git sync, /git pull, /git push, /git fetch, /git publish |
| Commit | /git commit <message> |
| Summary | /git status |
/config)| Option | Default | Meaning |
|---|---|---|
| Fetch interval (minutes) | 10 | background git fetch, so ↓ is right. 0 = off |
| Status poll (seconds) | 30 | local git status (no network). Claude's commands refresh at once |
| Stale after (hours) | 12 | when the last fetch is marked yellow |
| Language | auto | auto follows Claude Code's language; en or de sets it |
auto, Claude Code's language setting decides first (for example "language": "german" in ~/.claude/settings.json), then the locale (LC_ALL, LC_MESSAGES, LANG), then the system's language. Any other language gets English. More languages are a catalog each in source-control/hooks/i18n.ts.GIT_TERMINAL_PROMPT=0, GCM_INTERACTIVE=never, SSH in batch mode). If it fails, the band says offline. Several sessions in one repository do not fetch twice: the mod looks at FETCH_HEAD.--no-optional-locks.--no-rebase (a merge, as VS Code does) unless you configured pull.rebase yourself.MIT, see LICENSE.
cc-source-control/ ← repository = marketplace
├─ .claude-plugin/marketplace.json
└─ source-control/ ← the mod
├─ .claude-plugin/plugin.json
├─ hooks/ register.tsx, i18n.ts, git.ts, parse.ts, snapshot.ts, actions.ts, ui/
├─ types/index.d.ts contract for $.state
└─ tests/ claude plugin test
claude --plugin-dir ./source-control. Saved changes reload; if not, restart the session. For the desktop app set CLAUDE_CODE_PLUGIN_DIRS and CLAUDE_CODE_PLUGIN_DIR_WATCH=1 in the env block of ~/.claude/settings.json.scripts/playground.sh sets up a throwaway repository under ~/sc-playground, with a remote of its own and a second clone playing the other machine. Open ~/sc-playground/work in Claude Code once and keep the session open. Then scripts/playground.sh behind (or ahead, diverged, conflict, overwrite, dirty, unpublished, stale, unfetched, offline, worktrees, detached, many, merges, clean) puts it in that state, and scripts/playground.sh tour runs through them all. The mod refreshes within 30 s, at once with /git status. claude plugin validate ./source-control claude plugin test ./source-control tsc -p source-control # once the mod has been loaded ``bash scripts/diagnose.sh checks, for every project Claude Code knows, whether the mod can load there.hooks/register.tsx 877 lines1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, PaneOpenArgs, Register, RenderSurface, Timer, UiOpenResult, UiPane } from 'claude-code'
6
7import type { ScProbe, ScSnapshot, ScView } from '../types'
8import { runAction, runningLabelOf } from './actions'
9import type { ActionName, ActionOutcome } from './actions'
10import { pickLanguage, setLanguage, t } from './i18n'
11import type { Language } from './i18n'
12import { gitOf } from './git'
13import type { Git, ProcessRun } from './git'
14import type { RepoPaths } from './parse'
15import { readSnapshot, repoOf, withDetails } from './snapshot'
16import type { Files } from './snapshot'
17import { bandView } from './ui/band'
18import type { Handlers } from './ui/handlers'
19import { changeCountOf, countsOf, fetchAgeOf, operationTextOf } from './ui/model'
20import { PANE_ID, PANE_ROWS, PANE_TITLE, paneView } from './ui/pane'
21import { kitOf } from './ui/theme'
22
23const INITIAL_VIEW: ScView = {
24 isPaneOpen: false,
25 busy: null,
26 isFetching: false,
27 fetchError: null,
28 notice: null,
29 folded: [],
30 commitField: 0,
31}
32
33const PROBE_REF = { plugin: 'source-control', key: 'probe' } as const
34const SNAPSHOT_REF = { plugin: 'source-control', key: 'snapshot' } as const
35const PROBE = atom(PROBE_REF, 'unknown' as ScProbe)
36const SNAPSHOT = atom(SNAPSHOT_REF, null as ScSnapshot | null)
37const VIEW = atom({ plugin: 'source-control', key: 'view' } as const, INITIAL_VIEW)
38
39const COMMAND = 'git'
40const WATCH_MS = 2_000
41const REFRESH_AFTER_TOOL_MS = 400
42const QUIET_SSH = 'ssh -o BatchMode=yes -o ConnectTimeout=15'
43const QUIET_ACTIONS: readonly ActionName[] = ['stage', 'unstage', 'stageAll', 'unstageAll']
44const REMOTE_ACTIONS: readonly ActionName[] = ['fetch', 'pull', 'push', 'sync', 'publish']
45
46/**
47 * The engine as session.start bound it; every timer, press and later hook
48 * reaches the engine through it.
49 */
50type Host = {
51 run: ProcessRun
52 files: Files
53 now: () => Promise<number>
54 after: (ms: number, fn: () => void) => Timer
55 every: (ms: number, fn: () => void) => Timer
56 cwd: () => Promise<string>
57 sshEnv: () => Promise<readonly (string | undefined)[]>
58 setProbe: (probe: ScProbe) => Promise<unknown>
59 setSnapshot: (snapshot: ScSnapshot | null) => Promise<unknown>
60 getSnapshot: () => Promise<ScSnapshot | null>
61 getView: () => Promise<ScView>
62 updateView: (change: (view: ScView) => ScView) => Promise<unknown>
63 toast: (text: string) => void
64 log: (text: string) => void
65 open: (pane: PaneOpenArgs) => Promise<UiOpenResult>
66 close: (id: string) => Promise<void>
67 panes: () => Promise<readonly UiPane[]>
68 surfaces: () => Promise<readonly RenderSurface[]>
69 readManifest: () => Promise<string>
70 write: (path: string, text: string) => Promise<void>
71 focus: (key: string) => Promise<unknown>
72 submitPrompt: (text: string) => Promise<unknown>
73}
74
75function bind($: EngineInterface): Host {
76 return {
77 run: (argv, init) => $.process.run(argv, init),
78 files: {
79 mtimeOf: path => $.fs.stat(path).then(stat => stat.mtimeMs, () => null),
80 exists: path => $.fs.exists(path),
81 names: path => $.fs.list(path).then(entries => entries.map(entry => entry.name)),
82 },
83 now: () => $.clock.now(),
84 after: (ms, fn) => $.clock.after(ms, fn),
85 every: (ms, fn) => $.clock.every(ms, fn),
86 cwd: () => $.session.cwd(),
87 sshEnv: () => Promise.all([$.env.get('GIT_SSH_COMMAND'), $.env.get('GIT_SSH')]),
88 setProbe: async probe => {
89 if ((await read($, PROBE)) !== probe) {
90 await $.state.set(PROBE_REF, probe)
91 }
92 },
93 setSnapshot: snapshot => $.state.set(SNAPSHOT_REF, snapshot),
94 getSnapshot: () => read($, SNAPSHOT),
95 getView: () => read($, VIEW),
96 updateView: async change => {
97 const view = (await read($, VIEW)) ?? INITIAL_VIEW
98
99 // every write draws the band and the panel again
100 if (JSON.stringify(change(view)) === JSON.stringify(view)) {
101 return view
102 }
103
104 return update($, VIEW, current => change(current ?? INITIAL_VIEW))
105 },
106 toast: text => $.ui.toast(text, { timeoutMs: 6000 }),
107 log: text => $.ui.log(text, { to: 'debug' }),
108 open: pane => $.ui.open(pane),
109 close: id => $.ui.close({ id }),
110 panes: () => $.ui.panes(),
111 surfaces: () => $.session.surfaces(),
112 readManifest: () => $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).then(text => String(text)),
113 write: (path, text) => $.fs.write(path, text),
114 focus: key => $.ui.focus({ requestId: PANE_ID, key }),
115 submitPrompt: text => $.prompt.submit({ text }),
116 }
117}
118
119const numberOf = (value: unknown, fallback: number): number => {
120 const parsed = typeof value === 'number' ? value : Number(value)
121
122 return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
123}
124
125/**
126 * Source Control: the band above the prompt, the panel beside the
127 * transcript, `/git`, a refresh after Claude's commands and edits, and a
128 * background fetch.
129 */
130export const register: Register = (on, options) => {
131 const pollMs = Math.max(5, numberOf(options.pollSeconds, 30)) * 1000
132 const fetchEveryMs = numberOf(options.fetchIntervalMinutes, 10) * 60_000
133 const staleAfterMs = Math.max(1, numberOf(options.staleAfterHours, 12)) * 3_600_000
134
135 let host: Host | null = null
136 let paths: RepoPaths | null = null
137 let probedCwd: string | null = null
138 let refreshing: Promise<void> | null = null
139 let isRefreshQueued = false
140 let refreshTimer: Timer | null = null
141 let isActing = false
142 let lastQuietFetch = 0
143 let quietEnv: Record<string, string> | null = null
144 let hasWarnedBehind = false
145 let draft = ''
146 let lastOpen: string | null = null
147 let bandDraws = 0
148 let language: Language = 'en'
149 let paneDraws = 0
150 const bandSurfaces = new Set<RenderSurface>()
151 const paneSurfaces = new Set<RenderSurface>()
152 let timers: Timer[] = []
153 let version = '?'
154 let watchedSign = ''
155 let absentSign = ''
156 let fetchErrorAt = 0
157
158 /**
159 * The files git touches on every commit, checkout, stage, fetch, pull and
160 * merge: stat'ing them every few seconds notices a change made anywhere
161 * (a terminal, another tool, a script) without running git.
162 */
163 async function signOf(engine: Host, repo: RepoPaths): Promise<string> {
164 const watched = [
165 `${repo.gitDir}/HEAD`,
166 `${repo.gitDir}/index`,
167 `${repo.gitDir}/logs/HEAD`,
168 `${repo.gitDir}/MERGE_HEAD`,
169 `${repo.gitDir}/FETCH_HEAD`,
170 `${repo.commonDir}/FETCH_HEAD`,
171 `${repo.commonDir}/packed-refs`,
172 ]
173
174 const times = await Promise.all(watched.map(path => engine.files.mtimeOf(path).catch(() => null)))
175
176 return times.map(time => time ?? '-').join('|')
177 }
178
179 /**
180 * Where a repository appears in the folder (a clone, `git init`), stat'ed
181 * while there is none: a folder rebuilt in place is found within seconds,
182 * without running git. One that appears in a parent folder is left to the
183 * poll.
184 */
185 async function absentSignOf(engine: Host, cwd: string): Promise<string> {
186 const watched = [`${cwd}/.git`, `${cwd}/.git/HEAD`, `${cwd}/.git/index`]
187 const times = await Promise.all(watched.map(path => engine.files.mtimeOf(path).catch(() => null)))
188
189 return [cwd, ...times.map(time => time ?? '-')].join('|')
190 }
191
192 async function watch() {
193 const engine = host
194 const repo = paths
195
196 if (!engine || refreshing) {
197 return
198 }
199
200 if (!repo) {
201 const cwd = await engine.cwd().catch(() => probedCwd ?? '')
202
203 if ((await absentSignOf(engine, cwd)) !== absentSign) {
204 scheduleRefresh(0)
205 }
206
207 return
208 }
209
210 const sign = await signOf(engine, repo)
211
212 if (sign !== watchedSign) {
213 watchedSign = sign
214 // the repository changed (a pull, a new clone, another fetch): a failed
215 // fetch's back-off no longer says anything about the next one
216 lastQuietFetch = 0
217 scheduleRefresh(0)
218 }
219
220 await maybeFetch()
221 }
222
223 /**
224 * In a playground repository (one holding `.git/sc-tour`) the mod reports
225 * what it last showed, so a scripted tour knows when to take its picture.
226 */
227 async function reportTour(engine: Host, snapshot: ScSnapshot) {
228 const marker = `${snapshot.gitDir}/sc-tour`
229
230 if (!(await engine.files.exists(marker).catch(() => false))) {
231 return
232 }
233
234 const view = await engine.getView()
235
236 await engine
237 .write(
238 `${snapshot.gitDir}/sc-tour-state.json`,
239 JSON.stringify({
240 version,
241 readAt: snapshot.readAt,
242 branch: snapshot.branch,
243 ahead: snapshot.ahead,
244 behind: snapshot.behind,
245 fetchedAt: snapshot.fetchedAt,
246 fetchError: view.fetchError,
247 isFetching: view.isFetching,
248 hasDetails: snapshot.details !== null,
249 }),
250 )
251 .catch(() => undefined)
252 }
253
254 /**
255 * A reading as the band and the panel draw it: all of it but the time it
256 * was read, which shows only as the last fetch's age and staleness.
257 */
258 const drawingOf = (snapshot: ScSnapshot | null): string =>
259 snapshot === null ? 'null' : JSON.stringify({ ...snapshot, readAt: null, fetchAge: fetchAgeOf(snapshot, staleAfterMs) })
260
261 /**
262 * Keeps `snapshot` when it draws differently from the one kept: every
263 * write draws the band and the panel again, and a redraw can swallow a
264 * click on the desktop.
265 */
266 async function show(engine: Host, snapshot: ScSnapshot | null) {
267 if (drawingOf(await engine.getSnapshot()) !== drawingOf(snapshot)) {
268 await engine.setSnapshot(snapshot)
269 }
270 }
271
272 const gitFor = (engine: Host): Git => gitOf(engine.run, () => ({ cwd: paths?.root }))
273
274 async function refreshOnce(engine: Host) {
275 const cwd = await engine.cwd().catch(() => probedCwd ?? '')
276
277 if (paths === null || cwd !== probedCwd) {
278 probedCwd = cwd
279 // taken before git looks: a clone landing while it looks still counts
280 absentSign = await absentSignOf(engine, cwd)
281 paths = await repoOf(gitFor(engine), cwd)
282 await engine.setProbe(paths ? 'repo' : 'none')
283 }
284
285 if (paths === null) {
286 await show(engine, null)
287
288 return
289 }
290
291 const git = gitFor(engine)
292 const snapshot = await readSnapshot(git, engine.files, paths, await engine.now())
293
294 if (snapshot === null) {
295 paths = null
296 // the next watch looks again
297 absentSign = ''
298 await engine.setProbe('none')
299 await show(engine, null)
300
301 return
302 }
303
304 let view = await engine.getView()
305
306 // a fetch that landed after the last failed one (anyone's) means the
307 // remote is reachable again
308 if (view.fetchError !== null && snapshot.fetchedAt !== null && snapshot.fetchedAt > fetchErrorAt) {
309 await engine.updateView(current => ({ ...current, fetchError: null }))
310 view = await engine.getView()
311 }
312
313 const read = view.isPaneOpen ? await withDetails(git, engine.files, snapshot) : snapshot
314
315 await show(engine, read)
316 await reportTour(engine, read)
317 }
318
319 /**
320 * Reads the repository again. A call while a reading runs queues one more
321 * and waits for it: what the caller finds kept afterwards was read after
322 * it called.
323 */
324 function refresh(): Promise<void> {
325 const engine = host
326
327 if (!engine) {
328 return Promise.resolve()
329 }
330
331 if (refreshing) {
332 isRefreshQueued = true
333
334 return refreshing
335 }
336
337 refreshing = (async () => {
338 try {
339 do {
340 isRefreshQueued = false
341 await refreshOnce(engine)
342 } while (isRefreshQueued)
343 } catch (error) {
344 engine.log(`refresh failed: ${error instanceof Error ? error.message : String(error)}`)
345 } finally {
346 refreshing = null
347 }
348 })()
349
350 return refreshing
351 }
352
353 function scheduleRefresh(delayMs: number) {
354 if (!host || refreshTimer) {
355 return
356 }
357
358 refreshTimer = host.after(delayMs, () => {
359 refreshTimer = null
360 void refresh()
361 })
362 }
363
364 async function quietEnvOf(engine: Host): Promise<Record<string, string>> {
365 if (quietEnv) {
366 return quietEnv
367 }
368
369 const [[ownCommand, ownSsh] = [], configured] = await Promise.all([
370 engine.sshEnv().catch((): readonly (string | undefined)[] => []),
371 gitFor(engine)(['config', '--get', 'core.sshCommand']),
372 ])
373
374 quietEnv = !ownCommand && !ownSsh && !configured.ok ? { GIT_SSH_COMMAND: QUIET_SSH } : {}
375
376 return quietEnv
377 }
378
379 async function act(
380 name: ActionName,
381 arg: { message?: string; paths?: readonly string[] } = {},
382 isQuiet = false,
383 ): Promise<ActionOutcome | null> {
384 const engine = host
385
386 if (!engine || !paths) {
387 return null
388 }
389
390 if (isActing) {
391 if (!isQuiet) {
392 engine.toast(t().busy)
393 }
394
395 return null
396 }
397
398 isActing = true
399
400 const showsBusy = !isQuiet && !QUIET_ACTIONS.includes(name)
401
402 await engine.updateView(view => ({
403 ...view,
404 busy: showsBusy ? runningLabelOf(name) : view.busy,
405 isFetching: isQuiet ? true : view.isFetching,
406 notice: isQuiet ? view.notice : null,
407 }))
408
409 let settle = (view: ScView): ScView => view
410
411 try {
412 const git = gitFor(engine)
413 const repo = paths
414 const snapshot = (await readSnapshot(git, engine.files, repo, await engine.now())) ?? (await engine.getSnapshot())
415
416 if (!snapshot) {
417 return null
418 }
419
420 const outcome = await runAction(
421 {
422 git,
423 snapshot,
424 reread: async () => readSnapshot(git, engine.files, repo, await engine.now()),
425 quietEnv: isQuiet ? await quietEnvOf(engine) : {},
426 },
427 name,
428 arg,
429 isQuiet,
430 )
431
432 const isNoticed = !isQuiet && outcome.text !== ''
433 const hasReachedRemote = outcome.ok && REMOTE_ACTIONS.includes(name)
434 const hasFetchFailed = name === 'fetch' && !outcome.ok
435 const at = await engine.now()
436
437 if (hasFetchFailed) {
438 fetchErrorAt = at
439 }
440
441 settle = view => ({
442 ...view,
443 fetchError: hasReachedRemote ? null : hasFetchFailed ? outcome.text : view.fetchError,
444 notice: isNoticed
445 ? { tone: outcome.ok ? 'success' : 'error', text: outcome.text, at, retry: outcome.retry ?? null }
446 : view.notice,
447 commitField: name === 'commit' && outcome.ok ? view.commitField + 1 : view.commitField,
448 })
449
450 if (name === 'commit' && outcome.ok) {
451 draft = ''
452 }
453
454 if (isNoticed) {
455 engine.toast(outcome.text)
456 }
457
458 return outcome
459 } finally {
460 isActing = false
461 // the new reading first, then the outcome and the end of busy in one
462 // write: busy ended before the reading lands brings the old button
463 // back for a frame
464 await refresh()
465 await engine.updateView(view => ({ ...settle(view), busy: null, isFetching: false }))
466 }
467 }
468
469 async function maybeFetch(isStart = false) {
470 const engine = host
471
472 if (!engine || !paths || isActing || fetchEveryMs <= 0) {
473 return
474 }
475
476 const snapshot = await engine.getSnapshot()
477
478 if (!snapshot || snapshot.remoteName === null) {
479 return
480 }
481
482 const now = await engine.now()
483 const age = snapshot.fetchedAt === null ? Number.POSITIVE_INFINITY : now - snapshot.fetchedAt
484 const retryMs = Math.min(fetchEveryMs, 5 * 60_000)
485
486 if (age < fetchEveryMs || now - lastQuietFetch < retryMs) {
487 if (isStart) {
488 await warnIfBehind(engine)
489 }
490
491 return
492 }
493
494 // a failed fetch waits a while before the next try; a good one does not
495 lastQuietFetch = now
496
497 const fetched = await act('fetch', {}, true)
498
499 if (fetched?.ok) {
500 lastQuietFetch = 0
501 }
502
503 if (isStart) {
504 await warnIfBehind(engine)
505 }
506 }
507
508 async function warnIfBehind(engine: Host) {
509 const snapshot = await engine.getSnapshot()
510
511 if (hasWarnedBehind || !snapshot || snapshot.behind === 0 || snapshot.upstream === null) {
512 return
513 }
514
515 hasWarnedBehind = true
516 engine.toast(t().behindToast(snapshot.behind, snapshot.upstream))
517 }
518
519 async function setPaneOpen(isOpen: boolean) {
520 await host?.updateView(view => ({ ...view, isPaneOpen: isOpen }))
521 }
522
523 async function openPane(isFocused: boolean): Promise<UiOpenResult | null> {
524 const engine = host
525
526 if (!engine) {
527 return null
528 }
529
530 await setPaneOpen(true)
531
532 const opened = await engine.open({ id: PANE_ID, title: PANE_TITLE, rows: PANE_ROWS, ...(isFocused ? { focus: true as const } : {}) })
533
534 lastOpen = opened.isPlaced ? 'placed' : opened.reason
535 await refresh()
536
537 return opened
538 }
539
540 /**
541 * The panel's open, focus and close in one: closed opens it focused (the
542 * terminal gives a pane the keyboard no other way); open but without the
543 * keyboard takes it when `isFocusing`; otherwise it closes.
544 *
545 * @returns whether the panel is open afterwards
546 */
547 async function togglePane(isFocusing: boolean): Promise<{ isOpen: boolean; reason: string | null }> {
548 const engine = host
549
550 if (!engine) {
551 return { isOpen: false, reason: t().notReady }
552 }
553
554 const pane = (await engine.panes()).find(one => one.id === PANE_ID)
555
556 if (pane && pane.isPlaced && isFocusing && !pane.isFocused) {
557 await engine.open({ id: PANE_ID, title: PANE_TITLE, focus: true })
558
559 return { isOpen: true, reason: null }
560 }
561
562 if (pane && pane.isPlaced) {
563 await engine.close(PANE_ID).catch(() => undefined)
564 await setPaneOpen(false)
565
566 return { isOpen: false, reason: null }
567 }
568
569 const opened = await openPane(true)
570
571 return { isOpen: true, reason: opened && !opened.isPlaced ? opened.reason : null }
572 }
573
574 async function commitWith(message: string | undefined) {
575 const engine = host
576 const text = (message ?? draft).trim()
577
578 if (!engine) {
579 return
580 }
581
582 const snapshot = await engine.getSnapshot()
583 const isMerging = snapshot?.operation === 'merge' && snapshot.conflicts.length === 0
584
585 if (text === '' && !isMerging) {
586 engine.toast(t().enterMessageFirst)
587
588 const view = await engine.getView()
589
590 if (!view.isPaneOpen) {
591 await openPane(true)
592 }
593
594 await engine.focus(`message-${view.commitField}`).catch(() => undefined)
595
596 return
597 }
598
599 await act('commit', { message: text })
600 }
601
602 const handlers: Handlers = {
603 act: (name, arg) => {
604 void act(name, arg)
605 },
606 primary: kind => {
607 if (kind === 'sync' || kind === 'publish') {
608 void act(kind)
609 } else {
610 void openPane(true)
611 }
612 },
613 togglePane: () => {
614 void togglePane(false).then(result => {
615 if (result.reason !== null) {
616 host?.toast(t().paneNotShown(result.reason))
617 }
618 })
619 },
620 toggleFold: id => {
621 void host?.updateView(view => ({
622 ...view,
623 folded: view.folded.includes(id) ? view.folded.filter(one => one !== id) : [...view.folded, id],
624 }))
625 },
626 commit: message => {
627 void commitWith(message)
628 },
629 draft: text => {
630 draft = text
631 },
632 askClaude: () => {
633 void askClaude()
634 },
635 }
636
637 async function askClaude() {
638 const engine = host
639 const snapshot = await engine?.getSnapshot()
640
641 if (!engine || !snapshot || snapshot.conflicts.length === 0) {
642 return
643 }
644
645 const files = snapshot.conflicts.map(file => file.path).join(', ')
646 const operation = snapshot.operation ?? 'merge'
647
648 const finish = operation === 'merge' ? t().conflictFinishMerge : t().conflictFinishOther(operation)
649 const text = t().conflictPrompt(operation, files, finish)
650
651 await engine.submitPrompt(text).catch(() => undefined)
652 engine.toast(t().claudeOnIt)
653 }
654
655 function summaryOf(snapshot: ScSnapshot): string {
656 const parts = [
657 t().summaryBranch(snapshot.branch ?? `${snapshot.head ?? '?'} (detached)`),
658 snapshot.upstream ? t().summaryUpstream(snapshot.upstream) : t().noUpstream,
659 countsOf(snapshot) || t().inSync,
660 t().changedFiles(changeCountOf(snapshot)),
661 operationTextOf(snapshot),
662 ].filter((part): part is string => typeof part === 'string' && part !== '')
663
664 return parts.join(' · ')
665 }
666
667 on('session.start', async ($, e, next) => {
668 const engine = bind($)
669
670 host = engine
671 paths = null
672 probedCwd = null
673
674 // the language: the plugin's option, Claude Code's own language setting,
675 // the locale, the runtime's locale; English where none says German
676 const settings = await $.settings.read().catch(() => ({}) as Record<string, unknown>)
677 const locale = (() => {
678 try {
679 return Intl.DateTimeFormat().resolvedOptions().locale
680 } catch {
681 return undefined
682 }
683 })()
684
685 language = pickLanguage([
686 options.language,
687 settings.language,
688 await $.env.get('LC_ALL'),
689 await $.env.get('LC_MESSAGES'),
690 await $.env.get('LANG'),
691 locale,
692 ])
693 setLanguage(language)
694
695 for (const timer of timers) {
696 timer.cancel()
697 }
698
699 await $.command
700 .register({
701 name: COMMAND,
702 description: t().commandDescription,
703 argumentHint: t().commandHint,
704 })
705 .catch(() => undefined)
706
707 const panes = await $.ui.panes().catch((): readonly UiPane[] => [])
708
709 await engine.updateView(view => ({
710 ...view,
711 isPaneOpen: panes.some(pane => pane.id === PANE_ID),
712 busy: null,
713 isFetching: false,
714 }))
715
716 version = await engine
717 .readManifest()
718 .then(text => String((JSON.parse(text) as { version?: unknown }).version ?? '?'))
719 .catch(() => '?')
720
721 timers = [
722 engine.every(pollMs, () => scheduleRefresh(0)),
723 engine.every(WATCH_MS, () => {
724 void watch()
725 }),
726 engine.after(0, () => {
727 void refresh().then(() => maybeFetch(true))
728 }),
729 ]
730
731 return next(e)
732 })
733
734 // the menu lists the options in the session's language (the manifest can
735 // only hold one, English)
736 on('config.describe', async ($, e, next) => {
737 const described = await next(e)
738 const field = e.key.startsWith('source-control.') ? e.key.slice('source-control.'.length) : null
739 const words: readonly string[] | undefined = field === null ? undefined : (t().config as Record<string, readonly string[] | undefined>)[field]
740 const [label, description] = words ?? []
741
742 return label === undefined ? described : { ...described, label, ...(description === undefined ? {} : { description }) }
743 })
744
745 on('turn.complete', async ($, e, next) => {
746 const result = await next(e)
747
748 if (e.agentId === undefined) {
749 scheduleRefresh(150)
750 }
751
752 return result
753 })
754
755 on('tool.call', { tool: /^(Bash|PowerShell|Edit|Write|NotebookEdit)$/ }, async ($, e, next) => {
756 try {
757 return await next(e)
758 } finally {
759 scheduleRefresh(REFRESH_AFTER_TOOL_MS)
760 }
761 })
762
763 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
764 const result = await next(e)
765
766 await setPaneOpen(false)
767
768 return result
769 })
770
771 on('command.run', { command: COMMAND }, async ($, e) => {
772 if (!host) {
773 return { text: t().notReadyYet }
774 }
775
776 if (!paths) {
777 await refresh()
778 }
779
780 if (!paths) {
781 return { text: t().noRepository }
782 }
783
784 const args = e.args.trim()
785 const [verb = ''] = args.split(/\s+/)
786
787 if (verb === '') {
788 const toggled = await togglePane(true)
789
790 if (toggled.reason !== null) {
791 return { text: t().couldNotShow(toggled.reason) }
792 }
793
794 return { text: toggled.isOpen ? t().opened : t().closed }
795 }
796
797 if (verb === 'debug') {
798 const [surfaces, panes, probe] = await Promise.all([
799 host.surfaces().catch(() => []),
800 host.panes().catch((): readonly UiPane[] => []),
801 host.getSnapshot(),
802 ])
803
804 return {
805 text: [
806 `version: ${version}`,
807 `surfaces: ${surfaces.join(', ') || '(none)'}`,
808 `band: ${bandDraws} draws${bandSurfaces.size > 0 ? ` on ${[...bandSurfaces].join(', ')}` : ''}`,
809 `pane: ${paneDraws} draws${paneSurfaces.size > 0 ? ` on ${[...paneSurfaces].join(', ')}` : ''}, last open: ${lastOpen ?? '–'}`,
810 `panes: ${panes.map(one => `${one.id} (shown ${one.isShown}, placed ${one.isPlaced})`).join('; ') || '(none)'}`,
811 `repo: ${probe ? `${probe.root} · ${probe.branch ?? 'detached'}` : 'none read'}`,
812 `language: ${language}`,
813 ].join('\n'),
814 }
815 }
816
817 if (verb === 'status') {
818 await refresh()
819 const snapshot = await host.getSnapshot()
820
821 return { text: snapshot ? summaryOf(snapshot) : t().noRepository }
822 }
823
824 if (verb === 'commit') {
825 const message = args.slice('commit'.length).trim()
826
827 if (message === '') {
828 return { text: t().commitUsage }
829 }
830
831 const outcome = await act('commit', { message })
832
833 return { text: outcome?.text ?? t().busy }
834 }
835
836 const verbs: readonly ActionName[] = ['fetch', 'pull', 'push', 'sync', 'publish']
837 const name = verbs.find(one => one === verb)
838
839 if (!name) {
840 return { text: t().usage }
841 }
842
843 const outcome = await act(name)
844
845 return { text: outcome?.text || t().busy }
846 })
847
848 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
849 bandDraws += 1
850 bandSurfaces.add(e.surface)
851
852 if (e.props.hasSurvey) {
853 return next(e)
854 }
855
856 const [probe, snapshot, view] = await Promise.all([read($, PROBE), read($, SNAPSHOT), read($, VIEW)])
857
858 if (probe !== 'repo' || snapshot === null) {
859 return next(e)
860 }
861
862 const kit = kitOf($.ui.resolve(e), e.surface, e.props.bodyColumns)
863
864 return bandView(kit, snapshot, view ?? INITIAL_VIEW, handlers, staleAfterMs)
865 })
866
867 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
868 paneDraws += 1
869 paneSurfaces.add(e.surface)
870
871 const [snapshot, view] = await Promise.all([read($, SNAPSHOT), read($, VIEW)])
872 const kit = kitOf($.ui.resolve(e), e.surface, Math.max(10, e.props.bodyColumns - 2))
873
874 return paneView(kit, snapshot, view ?? INITIAL_VIEW, handlers, staleAfterMs, e.props.placement === 'dock')
875 })
876}
877hooks/actions.ts 390 lines1import type { ScSnapshot } from '../types'
2import { firstLineOf } from './format'
3import { t } from './i18n'
4import type { Catalog } from './i18n'
5import { ACTION_ENV, QUIET_ENV } from './git'
6import type { Git, GitResult } from './git'
7
8export type ActionName =
9 | 'fetch'
10 | 'pull'
11 | 'pullAutostash'
12 | 'push'
13 | 'sync'
14 | 'publish'
15 | 'commit'
16 | 'stage'
17 | 'unstage'
18 | 'stageAll'
19 | 'unstageAll'
20 | 'continueOperation'
21 | 'abortOperation'
22
23/**
24 * What the person sees while an action runs (`Pulling…`).
25 */
26export function runningLabelOf(name: ActionName): string {
27 return t().running[name]
28}
29
30/**
31 * The operation in progress as the git command that drives it.
32 */
33const OPERATION_COMMANDS: Readonly<Record<NonNullable<ScSnapshot['operation']>, string>> = {
34 merge: 'merge',
35 rebase: 'rebase',
36 'cherry-pick': 'cherry-pick',
37 revert: 'revert',
38}
39
40/**
41 * The operation in progress in words (`Merge`, `Rebase`, …).
42 */
43export const OPERATION_NAMES: Readonly<Record<NonNullable<ScSnapshot['operation']>, string>> = {
44 merge: 'Merge',
45 rebase: 'Rebase',
46 'cherry-pick': 'Cherry-Pick',
47 revert: 'Revert',
48}
49
50/**
51 * How an action ended: whether it worked, the line to show (empty for the
52 * quiet ones, stage and unstage), and a follow-up worth offering.
53 */
54export type ActionOutcome = { ok: boolean; text: string; retry?: 'autostash' }
55
56/**
57 * What an action works with: the runner, the repository as last read, a
58 * fresh reading on demand, and the environment for a quiet fetch.
59 */
60export type ActionContext = {
61 git: Git
62 snapshot: ScSnapshot
63 reread: () => Promise<ScSnapshot | null>
64 quietEnv: Readonly<Record<string, string>>
65}
66
67const NETWORK_MS = 180_000
68const QUIET_NETWORK_MS = 45_000
69
70const EXPLANATIONS: readonly (readonly [RegExp, keyof Catalog['explain']])[] = [
71 [/Please tell me who you are|empty ident name|unable to auto-detect email/i, 'identity'],
72 [/Need to specify how to reconcile|Not possible to fast-forward|diverging branches/i, 'diverged'],
73 [/would be overwritten by|Please commit your changes or stash them|cannot pull with rebase: You have unstaged changes/i, 'overwrite'],
74 [/CONFLICT|Automatic merge failed|could not apply|fix conflicts/i, 'conflict'],
75 [/\[rejected\]|non-fast-forward|fetch first|Updates were rejected/i, 'rejected'],
76 [/Authentication failed|terminal prompts disabled|could not read Username|Permission denied \(publickey|Host key verification failed|invalid credentials/i, 'auth'],
77 [/Could not resolve host|unable to access|Connection timed out|Network is unreachable|Could not read from remote repository|Connection refused|Operation timed out/i, 'unreachable'],
78 [/timed out|timeout/i, 'timeout'],
79 [/has no upstream branch|no upstream configured|no tracking information/i, 'noUpstream'],
80 [/No configured push destination|does not appear to be a git repository|No such remote|No remote repository specified/i, 'noRemote'],
81 [/index\.lock|Unable to create .*\.lock/i, 'locked'],
82 [/nothing to commit|no changes added to commit/i, 'nothingToCommit'],
83 [/unmerged files|You have not concluded your merge|Committing is not possible because you have unmerged/i, 'unmerged'],
84]
85
86/**
87 * A failed run in words: the matching explanation, else git's first line.
88 */
89export function explain(result: Pick<GitResult, 'out' | 'err'>): string {
90 const said = `${result.err}\n${result.out}`
91 const found = EXPLANATIONS.find(([pattern]) => pattern.test(said))
92
93 return found ? t().explain[found[1]] : firstLineOf(result.err) || firstLineOf(result.out) || t().gitFailed
94}
95
96const OVERWRITE = /would be overwritten by|Please commit your changes or stash them|cannot pull with rebase: You have unstaged changes/i
97
98const failed = (result: GitResult): ActionOutcome => ({
99 ok: false,
100 text: explain(result),
101 ...(OVERWRITE.test(`${result.err}\n${result.out}`) ? { retry: 'autostash' as const } : {}),
102})
103
104async function headOf(git: Git): Promise<string | null> {
105 const head = await git(['rev-parse', '--verify', '-q', 'HEAD'])
106
107 return head.ok ? head.out.trim() : null
108}
109
110async function countSince(git: Git, from: string | null): Promise<number | null> {
111 if (!from) {
112 return null
113 }
114
115 const counted = await git(['rev-list', '--count', `${from}..HEAD`])
116
117 return counted.ok ? Number(counted.out.trim()) : null
118}
119
120async function fetchRemote(ctx: ActionContext, isQuiet: boolean): Promise<ActionOutcome> {
121 const { git, snapshot } = ctx
122
123 if (!snapshot.remoteName) {
124 return { ok: false, text: t().noRemote }
125 }
126
127 const fetched = await git(['fetch', '--prune', snapshot.remoteName], {
128 env: isQuiet ? { ...QUIET_ENV, ...ctx.quietEnv } : ACTION_ENV,
129 timeoutMs: isQuiet ? QUIET_NETWORK_MS : NETWORK_MS,
130 })
131
132 if (!fetched.ok) {
133 return failed(fetched)
134 }
135
136 const after = await ctx.reread()
137 const fresh = after ? after.behind - snapshot.behind : 0
138
139 return {
140 ok: true,
141 text:
142 fresh > 0
143 ? t().fetchedNew(fresh, after?.upstream ?? null)
144 : t().fetchedNothing,
145 }
146}
147
148async function pull(ctx: ActionContext, isAutostash = false): Promise<ActionOutcome> {
149 const { git, snapshot } = ctx
150
151 if (!snapshot.upstream) {
152 return { ok: false, text: t().noUpstreamPublish }
153 }
154
155 const configured = await git(['config', '--get', 'pull.rebase'])
156 const before = await headOf(git)
157 const strategy = configured.ok ? [] : ['--no-rebase']
158
159 const pulled = await git(['pull', ...strategy, ...(isAutostash ? ['--autostash'] : [])], {
160 env: ACTION_ENV,
161 timeoutMs: NETWORK_MS,
162 })
163
164 if (!pulled.ok) {
165 return failed(pulled)
166 }
167
168 const count = await countSince(git, before)
169
170 return {
171 ok: true,
172 text:
173 count === null || count === 0
174 ? t().pulledNothing
175 : t().pulled(count),
176 }
177}
178
179async function publish(ctx: ActionContext): Promise<ActionOutcome> {
180 const { git, snapshot } = ctx
181
182 if (!snapshot.remoteName) {
183 return { ok: false, text: t().noRemote }
184 }
185
186 if (!snapshot.branch) {
187 return { ok: false, text: t().detachedHead }
188 }
189
190 const pushed = await git(['push', '-u', snapshot.remoteName, 'HEAD'], {
191 env: ACTION_ENV,
192 timeoutMs: NETWORK_MS,
193 })
194
195 return pushed.ok
196 ? { ok: true, text: t().published(`${snapshot.remoteName}/${snapshot.branch}`) }
197 : failed(pushed)
198}
199
200async function push(ctx: ActionContext): Promise<ActionOutcome> {
201 const { git, snapshot } = ctx
202
203 if (!snapshot.upstream) {
204 return publish(ctx)
205 }
206
207 const pushed = await git(['push'], { env: ACTION_ENV, timeoutMs: NETWORK_MS })
208
209 if (!pushed.ok) {
210 return failed(pushed)
211 }
212
213 return {
214 ok: true,
215 text:
216 snapshot.ahead > 0
217 ? t().pushed(snapshot.ahead, snapshot.upstream ?? '')
218 : t().pushedNothing,
219 }
220}
221
222async function sync(ctx: ActionContext): Promise<ActionOutcome> {
223 if (!ctx.snapshot.upstream) {
224 return publish(ctx)
225 }
226
227 const pulled = await pull(ctx)
228
229 if (!pulled.ok) {
230 return pulled
231 }
232
233 const after = await ctx.reread()
234
235 if (!after || after.ahead === 0) {
236 return { ok: true, text: pulled.text.replace(/^Pull/, 'Sync') }
237 }
238
239 const pushed = await push({ ...ctx, snapshot: after })
240
241 return pushed.ok
242 ? { ok: true, text: t().synced(after.ahead) }
243 : pushed
244}
245
246async function commit(ctx: ActionContext, message: string): Promise<ActionOutcome> {
247 const { git, snapshot } = ctx
248 const text = message.trim()
249 const isMerging = snapshot.operation === 'merge' && snapshot.conflicts.length === 0
250
251 if (text === '' && isMerging) {
252 const merged = await git(['commit', '--no-edit'], { env: ACTION_ENV, timeoutMs: 60_000 })
253
254 return merged.ok ? { ok: true, text: t().mergeDone } : failed(merged)
255 }
256
257 if (text === '') {
258 return { ok: false, text: t().enterMessage }
259 }
260
261 const hasStaged = snapshot.staged.length > 0
262
263 if (!hasStaged && snapshot.unstaged.length === 0 && snapshot.operation !== 'merge') {
264 return { ok: false, text: t().nothingToCommit }
265 }
266
267 if (!hasStaged && snapshot.unstaged.length > 0) {
268 const added = await git(['add', '-A'], { env: ACTION_ENV })
269
270 if (!added.ok) {
271 return failed(added)
272 }
273 }
274
275 const committed = await git(['commit', '-m', text], { env: ACTION_ENV, timeoutMs: 60_000 })
276
277 if (!committed.ok) {
278 return failed(committed)
279 }
280
281 const made = await git(['log', '-1', '--format=%h %s'])
282
283 return { ok: true, text: t().committed(made.ok ? made.out.trim() : text) }
284}
285
286const PATH_ENV = { ...ACTION_ENV, GIT_LITERAL_PATHSPECS: '1' }
287
288async function stage(ctx: ActionContext, paths: readonly string[]): Promise<ActionOutcome> {
289 const added = await ctx.git(['add', '-A', '--', ...paths], { env: PATH_ENV })
290
291 return added.ok ? { ok: true, text: '' } : failed(added)
292}
293
294async function unstage(ctx: ActionContext, paths: readonly string[]): Promise<ActionOutcome> {
295 const { git, snapshot } = ctx
296
297 const argv = snapshot.isUnborn
298 ? ['rm', '-r', '--cached', '-q', '--', ...paths]
299 : ['restore', '--staged', '--', ...paths]
300
301 let removed = await git(argv, { env: PATH_ENV })
302
303 if (!removed.ok && !snapshot.isUnborn) {
304 removed = await git(['reset', '-q', 'HEAD', '--', ...paths], { env: PATH_ENV })
305 }
306
307 return removed.ok ? { ok: true, text: '' } : failed(removed)
308}
309
310async function continueOperation(ctx: ActionContext, message: string): Promise<ActionOutcome> {
311 const { git, snapshot } = ctx
312 const operation = snapshot.operation
313
314 if (operation === null) {
315 return { ok: false, text: t().noOperation }
316 }
317
318 if (snapshot.conflicts.length > 0) {
319 return { ok: false, text: t().resolveFirst }
320 }
321
322 if (operation === 'merge') {
323 return commit(ctx, message)
324 }
325
326 const continued = await git([OPERATION_COMMANDS[operation], '--continue'], { env: ACTION_ENV, timeoutMs: 120_000 })
327
328 return continued.ok ? { ok: true, text: t().operationDone(OPERATION_NAMES[operation]) } : failed(continued)
329}
330
331async function abortOperation(ctx: ActionContext): Promise<ActionOutcome> {
332 const { git, snapshot } = ctx
333 const operation = snapshot.operation
334
335 if (operation === null) {
336 return { ok: false, text: t().noOperation }
337 }
338
339 const aborted = await git([OPERATION_COMMANDS[operation], '--abort'], { env: ACTION_ENV, timeoutMs: 60_000 })
340
341 return aborted.ok
342 ? { ok: true, text: t().operationAborted(OPERATION_NAMES[operation]) }
343 : failed(aborted)
344}
345
346/**
347 * Runs one action against the repository.
348 *
349 * @param ctx the runner, the snapshot and a fresh reading on demand
350 * @param name which action
351 * @param arg the commit message, or the paths to stage or unstage
352 * @param isQuiet a fetch nobody asked for: no prompts, a shorter timeout
353 * @returns whether it worked and what to tell the person
354 */
355export function runAction(
356 ctx: ActionContext,
357 name: ActionName,
358 arg: { message?: string; paths?: readonly string[] } = {},
359 isQuiet = false,
360): Promise<ActionOutcome> {
361 switch (name) {
362 case 'fetch':
363 return fetchRemote(ctx, isQuiet)
364 case 'pull':
365 return pull(ctx)
366 case 'pullAutostash':
367 return pull(ctx, true)
368 case 'push':
369 return push(ctx)
370 case 'sync':
371 return sync(ctx)
372 case 'publish':
373 return publish(ctx)
374 case 'commit':
375 return commit(ctx, arg.message ?? '')
376 case 'stage':
377 return stage(ctx, arg.paths ?? [])
378 case 'unstage':
379 return unstage(ctx, arg.paths ?? [])
380 case 'stageAll':
381 return stage(ctx, ['.'])
382 case 'unstageAll':
383 return unstage(ctx, ['.'])
384 case 'continueOperation':
385 return continueOperation(ctx, arg.message ?? '')
386 case 'abortOperation':
387 return abortOperation(ctx)
388 }
389}
390hooks/i18n.ts 403 lines1/**
2 * Every word the mod shows, in English and German. English is the default;
3 * German where the person's language is German (the plugin's `language`
4 * option, Claude Code's `language` setting, the locale, in that order).
5 */
6
7export type Language = 'en' | 'de'
8
9/**
10 * `1 commit`, `3 commits`: the count and the form that fits it.
11 */
12const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
13
14const en = {
15 // how long ago
16 justNow: 'just now',
17 minutesAgo: (n: number) => `${n} min ago`,
18 hoursAgo: (n: number) => `${n} h ago`,
19 daysAgo: (n: number) => (n === 1 ? '1 day ago' : `${n} days ago`),
20
21 commits: (n: number) => count(n, 'commit', 'commits'),
22 conflicts: (n: number) => count(n, 'conflict', 'conflicts'),
23 oneConflict: 'conflict',
24
25 // what is going on
26 checkingRemote: 'checking remote…',
27 offline: 'offline',
28 neverChecked: 'never checked',
29 checked: (ago: string) => `checked ${ago}`,
30 unpublished: 'not published',
31 noUpstream: 'no upstream',
32 noCommitsYet: 'no commits yet',
33 operationRunning: (operation: string) => `${operation} in progress`,
34 allConflictsResolved: 'all conflicts resolved',
35 fetchFailed: (reason: string) => `Fetch failed: ${reason}`,
36 worktreeOf: (path: string) => `Worktree of ${path}`,
37 hooksHint: 'Note: repository hooks do not run for actions from this panel.',
38
39 // the band's button
40 resolve: 'Resolve…',
41 resolveShort: 'Resolve',
42 finish: 'Finish…',
43 finishShort: 'Finish',
44 publish: 'Publish',
45 sync: (counts: string) => `Sync ${counts}`,
46 commitEllipsis: 'Commit…',
47
48 // the panel
49 title: 'Source Control',
50 titleNarrow: 'Git',
51 fetch: 'Fetch',
52 pull: 'Pull',
53 push: 'Push',
54 noRepository: 'No Git repository in this folder.',
55 messagePlaceholder: (branch: string) => `Message (Enter to commit on “${branch}”)`,
56 askClaude: 'Ask Claude to resolve',
57 abortOperation: (operation: string) => `Abort ${operation}`,
58 finishOperation: (operation: string) => `Finish ${operation}`,
59 commitStaged: (n: number) => `Commit (${n})`,
60 commitAll: (n: number) => `Commit (all ${n})`,
61 commit: 'Commit',
62 publishBranch: 'Publish Branch',
63 syncChanges: (counts: string) => `Sync Changes ${counts}`,
64 pullAutostash: 'Pull with autostash',
65 conflictsSection: 'Conflicts',
66 conflictsHint: 'Resolve a file, then stage it with + to mark it resolved.',
67 stagedSection: 'Staged',
68 changesSection: 'Changes',
69 historySection: 'History',
70 branchesSection: 'Branches',
71 worktreesSection: 'Worktrees',
72 stashesSection: 'Stashes',
73 all: 'all',
74 noChanges: 'No changes',
75 noOtherChanges: 'No other changes',
76 allStaged: 'Everything staged',
77 loading: 'loading…',
78 noCommits: 'No commits yet',
79 incoming: 'Incoming Changes',
80 outgoing: 'Outgoing Changes',
81 more: (n: number) => `… ${n} more`,
82 goneOnRemote: 'deleted on remote',
83 localOnly: 'local only',
84 missing: 'missing',
85 clean: 'clean',
86
87 // running
88 running: {
89 fetch: 'Fetching…',
90 pull: 'Pulling…',
91 pullAutostash: 'Pulling…',
92 push: 'Pushing…',
93 sync: 'Syncing…',
94 publish: 'Publishing…',
95 commit: 'Committing…',
96 stage: 'Staging…',
97 unstage: 'Unstaging…',
98 stageAll: 'Staging…',
99 unstageAll: 'Unstaging…',
100 continueOperation: 'Finishing…',
101 abortOperation: 'Aborting…',
102 },
103
104 // outcomes
105 fetchedNew: (n: number, upstream: string | null) =>
106 `Fetch: ${count(n, 'new commit', 'new commits')} on ${upstream ?? 'the remote'}`,
107 fetchedNothing: 'Fetch done: nothing new',
108 pulledNothing: 'Pull done: already up to date',
109 pulled: (n: number) => `Pull: ${count(n, 'commit', 'commits')} received`,
110 published: (target: string) => `Branch published: ${target}`,
111 pushed: (n: number, upstream: string) => `Push: ${count(n, 'commit', 'commits')} to ${upstream}`,
112 pushedNothing: 'Push done: nothing to push',
113 synced: (n: number) => `Sync done: ${count(n, 'commit', 'commits')} pushed`,
114 mergeDone: 'Merge finished',
115 operationDone: (operation: string) => `${operation} finished`,
116 operationAborted: (operation: string) => `${operation} aborted: previous state restored`,
117 committed: (what: string) => `Committed: ${what}`,
118 enterMessage: 'Please enter a commit message.',
119 enterMessageFirst: 'Please enter a commit message first.',
120 nothingToCommit: 'Nothing to commit.',
121 noOperation: 'No merge or rebase in progress.',
122 resolveFirst: 'Resolve and stage all conflicts first.',
123 noRemote: 'No remote set up.',
124 noUpstreamPublish: 'The branch has no upstream: publish it first.',
125 detachedHead: 'No branch checked out (detached HEAD).',
126 gitFailed: 'Git ended with an error.',
127 busy: 'A Git action is already running.',
128
129 // git's errors, explained
130 explain: {
131 identity: 'Git does not know your name: set `git config --global user.name` and `user.email`.',
132 diverged: 'Cannot pull: the branches have diverged.',
133 overwrite: 'Pull stopped: local changes would be overwritten. Commit or stash them first.',
134 conflict: 'Merge conflict: resolve the files under “Conflicts”, stage them and commit.',
135 rejected: 'Push rejected: the remote has new commits. Pull first (Sync).',
136 auth: 'Sign-in failed. Run `git fetch` once in a terminal to store your credentials.',
137 unreachable: 'Remote not reachable (offline?).',
138 timeout: 'Timed out: Git did not answer in time.',
139 noUpstream: 'The branch has no upstream: publish it first.',
140 noRemote: 'No remote set up.',
141 locked: 'Git is busy (index.lock). Try again in a moment.',
142 nothingToCommit: 'Nothing to commit.',
143 unmerged: 'Resolve and stage all conflicts first.',
144 },
145
146 // toasts and /git
147 behindToast: (n: number, upstream: string) =>
148 `${count(n, 'commit', 'commits')} on ${upstream} not here yet. Sync above the chat box.`,
149 claudeOnIt: 'Claude is on the conflicts.',
150 paneNotShown: (reason: string) => `Panel not shown: ${reason}`,
151 notReady: 'not ready',
152 commandDescription: 'Source Control: toggle the panel, or sync, pull, push, fetch, publish, status, commit <message>',
153 commandHint: '[sync|pull|push|fetch|publish|status|commit <message>]',
154 notReadyYet: 'Source Control is not ready yet.',
155 couldNotShow: (reason: string) => `Source Control could not be shown: ${reason}`,
156 opened: 'Source Control opened',
157 closed: 'Source Control closed',
158 commitUsage: 'Usage: /git commit <message>',
159 usage: 'Available: /git · /git sync|pull|push|fetch|publish|status · /git commit <message>',
160 summaryBranch: (name: string) => `Branch ${name}`,
161 summaryUpstream: (name: string) => `Upstream ${name}`,
162 inSync: 'in sync',
163 changedFiles: (n: number) => count(n, 'changed file', 'changed files'),
164
165 // what Claude is asked to do with conflicts
166 conflictPrompt: (operation: string, files: string, finish: string) =>
167 `There are conflicts in this Git repository (${operation}) in: ${files}. ` +
168 'Please resolve them sensibly, keeping both sides where that fits, remove all conflict markers, ' +
169 `stage the files and ${finish}. Ask me if a decision is unclear.`,
170 conflictFinishMerge: 'finish the merge with `git commit --no-edit`',
171 conflictFinishOther: (operation: string) => `continue with \`git ${operation} --continue\``,
172
173 // /config, as the menu lists the plugin's options
174 config: {
175 fetchIntervalMinutes: ['Fetch interval (minutes)', 'How often `git fetch` runs in the background, so ↓ counts are right. 0 turns it off.'],
176 pollSeconds: ['Status poll (seconds)', 'How often the local Git status is read (no network). Claude’s commands and your actions refresh at once anyway.'],
177 staleAfterHours: ['Stale after (hours)', 'When the last fetch counts as old and is marked yellow.'],
178 language: ['Language', '`auto` follows Claude Code’s language; `en` or `de` sets it.'],
179 },
180}
181
182export type Catalog = typeof en
183
184const de: Catalog = {
185 justNow: 'gerade eben',
186 minutesAgo: n => `vor ${n} min`,
187 hoursAgo: n => `vor ${n} Std.`,
188 daysAgo: n => (n === 1 ? 'vor 1 Tag' : `vor ${n} Tagen`),
189
190 commits: n => count(n, 'Commit', 'Commits'),
191 conflicts: n => count(n, 'Konflikt', 'Konflikte'),
192 oneConflict: 'Konflikt',
193
194 checkingRemote: 'prüfe Remote…',
195 offline: 'offline',
196 neverChecked: 'nie geprüft',
197 checked: ago => `geprüft ${ago}`,
198 unpublished: 'nicht veröffentlicht',
199 noUpstream: 'kein Upstream',
200 noCommitsYet: 'noch keine Commits',
201 operationRunning: operation => `${operation} läuft`,
202 allConflictsResolved: 'alle Konflikte gelöst',
203 fetchFailed: reason => `Fetch fehlgeschlagen: ${reason}`,
204 worktreeOf: path => `Worktree von ${path}`,
205 hooksHint: 'Hinweis: Repo-Hooks laufen bei Aktionen aus diesem Panel nicht.',
206
207 resolve: 'Auflösen…',
208 resolveShort: 'Auflösen',
209 finish: 'Abschließen…',
210 finishShort: 'Abschließen',
211 publish: 'Veröffentlichen',
212 sync: counts => `Sync ${counts}`,
213 commitEllipsis: 'Commit…',
214
215 title: 'Source Control',
216 titleNarrow: 'Git',
217 fetch: 'Fetch',
218 pull: 'Pull',
219 push: 'Push',
220 noRepository: 'Kein Git-Repository in diesem Ordner.',
221 messagePlaceholder: branch => `Nachricht (Enter: Commit auf „${branch}“)`,
222 askClaude: 'Claude: Konflikte lösen',
223 abortOperation: operation => `${operation} abbrechen`,
224 finishOperation: operation => `${operation} abschließen`,
225 commitStaged: n => `Commit (${n})`,
226 commitAll: n => `Commit (alle ${n})`,
227 commit: 'Commit',
228 publishBranch: 'Branch veröffentlichen',
229 syncChanges: counts => `Sync Changes ${counts}`,
230 pullAutostash: 'Mit Autostash pullen',
231 conflictsSection: 'Konflikte',
232 conflictsHint: 'Datei lösen, dann mit + als gelöst stagen.',
233 stagedSection: 'Gestagt',
234 changesSection: 'Änderungen',
235 historySection: 'Verlauf',
236 branchesSection: 'Branches',
237 worktreesSection: 'Worktrees',
238 stashesSection: 'Stashes',
239 all: 'alle',
240 noChanges: 'Keine Änderungen',
241 noOtherChanges: 'Keine weiteren Änderungen',
242 allStaged: 'Alles gestaged',
243 loading: 'lädt…',
244 noCommits: 'Noch keine Commits',
245 incoming: 'Eingehende Änderungen',
246 outgoing: 'Ausgehende Änderungen',
247 more: n => `… ${n} weitere`,
248 goneOnRemote: 'auf Remote gelöscht',
249 localOnly: 'nur lokal',
250 missing: 'fehlt',
251 clean: 'sauber',
252
253 running: {
254 fetch: 'Fetch läuft…',
255 pull: 'Pull läuft…',
256 pullAutostash: 'Pull läuft…',
257 push: 'Push läuft…',
258 sync: 'Sync läuft…',
259 publish: 'Veröffentliche…',
260 commit: 'Commit läuft…',
261 stage: 'Stage…',
262 unstage: 'Unstage…',
263 stageAll: 'Stage…',
264 unstageAll: 'Unstage…',
265 continueOperation: 'Schließe ab…',
266 abortOperation: 'Breche ab…',
267 },
268
269 fetchedNew: (n, upstream) => `Fetch: ${count(n, 'neuer Commit', 'neue Commits')} auf ${upstream ?? 'dem Remote'}`,
270 fetchedNothing: 'Fetch abgeschlossen: nichts Neues',
271 pulledNothing: 'Pull abgeschlossen: bereits aktuell',
272 pulled: n => `Pull: ${count(n, 'Commit', 'Commits')} geholt`,
273 published: target => `Branch veröffentlicht: ${target}`,
274 pushed: (n, upstream) => `Push: ${count(n, 'Commit', 'Commits')} nach ${upstream}`,
275 pushedNothing: 'Push abgeschlossen: nichts zu pushen',
276 synced: n => `Sync abgeschlossen: ${count(n, 'Commit', 'Commits')} gepusht`,
277 mergeDone: 'Merge abgeschlossen',
278 operationDone: operation => `${operation} abgeschlossen`,
279 operationAborted: operation => `${operation} abgebrochen: alter Stand wiederhergestellt`,
280 committed: what => `Commit erstellt: ${what}`,
281 enterMessage: 'Bitte eine Commit-Nachricht eingeben.',
282 enterMessageFirst: 'Bitte zuerst eine Commit-Nachricht eingeben.',
283 nothingToCommit: 'Nichts zu committen.',
284 noOperation: 'Es läuft kein Merge oder Rebase.',
285 resolveFirst: 'Erst alle Konflikte lösen und stagen.',
286 noRemote: 'Kein Remote eingerichtet.',
287 noUpstreamPublish: 'Der Branch hat keinen Upstream: erst veröffentlichen.',
288 detachedHead: 'Kein Branch ausgecheckt (detached HEAD).',
289 gitFailed: 'Git ist mit einem Fehler beendet.',
290 busy: 'Es läuft bereits eine Git-Aktion.',
291
292 explain: {
293 identity: 'Git kennt deinen Namen nicht: `git config --global user.name` und `user.email` setzen.',
294 diverged: 'Pull nicht möglich: Die Branches sind auseinandergelaufen.',
295 overwrite: 'Pull abgebrochen: Lokale Änderungen würden überschrieben. Erst committen oder stashen.',
296 conflict: 'Merge-Konflikt: Dateien unter „Konflikte“ lösen, stagen und committen.',
297 rejected: 'Push abgelehnt: Auf dem Remote gibt es neue Commits. Erst pullen (Sync).',
298 auth: 'Anmeldung fehlgeschlagen. Einmal im Terminal `git fetch` ausführen, um Zugangsdaten zu hinterlegen.',
299 unreachable: 'Remote nicht erreichbar (offline?).',
300 timeout: 'Zeitüberschreitung: Git hat nicht rechtzeitig geantwortet.',
301 noUpstream: 'Der Branch hat keinen Upstream: erst veröffentlichen.',
302 noRemote: 'Kein Remote eingerichtet.',
303 locked: 'Git ist gerade beschäftigt (index.lock). Gleich noch einmal versuchen.',
304 nothingToCommit: 'Nichts zu committen.',
305 unmerged: 'Erst alle Konflikte lösen und stagen.',
306 },
307
308 behindToast: (n, upstream) =>
309 `${count(n, 'Commit', 'Commits')} auf ${upstream}, die hier noch fehlen. Sync über der Chatbox.`,
310 claudeOnIt: 'Claude kümmert sich um die Konflikte.',
311 paneNotShown: reason => `Panel nicht angezeigt: ${reason}`,
312 notReady: 'nicht bereit',
313 commandDescription: 'Source Control: Panel öffnen/schließen, oder sync, pull, push, fetch, publish, status, commit <Nachricht>',
314 commandHint: '[sync|pull|push|fetch|publish|status|commit <Nachricht>]',
315 notReadyYet: 'Source Control ist noch nicht bereit.',
316 couldNotShow: reason => `Source Control konnte nicht angezeigt werden: ${reason}`,
317 opened: 'Source Control geöffnet',
318 closed: 'Source Control geschlossen',
319 commitUsage: 'Bitte so aufrufen: /git commit <Nachricht>',
320 usage: 'Möglich: /git · /git sync|pull|push|fetch|publish|status · /git commit <Nachricht>',
321 summaryBranch: name => `Branch ${name}`,
322 summaryUpstream: name => `Upstream ${name}`,
323 inSync: 'synchron',
324 changedFiles: n => `${n} geänderte ${n === 1 ? 'Datei' : 'Dateien'}`,
325
326 conflictPrompt: (operation, files, finish) =>
327 `Im Git-Repository gibt es Konflikte (${operation}) in: ${files}. ` +
328 'Bitte löse sie sinnvoll und behalte dabei beide Seiten, wo es passt, entferne alle Konfliktmarker, ' +
329 `stage die Dateien und ${finish}. Frag mich, wenn eine Entscheidung unklar ist.`,
330 conflictFinishMerge: 'schließe den Merge mit `git commit --no-edit` ab',
331 conflictFinishOther: operation => `setze mit \`git ${operation} --continue\` fort`,
332
333 config: {
334 fetchIntervalMinutes: ['Fetch-Intervall (Minuten)', 'Wie oft im Hintergrund `git fetch` läuft, damit ↓-Zähler stimmen. 0 schaltet den Hintergrund-Fetch ab.'],
335 pollSeconds: ['Status-Abfrage (Sekunden)', 'Wie oft der lokale Git-Status gelesen wird (ohne Netzwerk). Nach Claudes Befehlen und eigenen Aktionen wird ohnehin sofort aufgefrischt.'],
336 staleAfterHours: ['Veraltet nach (Stunden)', 'Ab wann der letzte Fetch als veraltet gilt und gelb markiert wird.'],
337 language: ['Sprache', '`auto` folgt der Sprache von Claude Code; `en` oder `de` legt sie fest.'],
338 },
339}
340
341const CATALOGS: Readonly<Record<Language, Catalog>> = { en, de }
342
343let current: Language = 'en'
344
345/**
346 * The words in the language the session speaks.
347 */
348export function t(): Catalog {
349 return CATALOGS[current]
350}
351
352/**
353 * Sets the language every later `t()` speaks.
354 */
355export function setLanguage(language: Language): void {
356 current = language
357}
358
359/**
360 * The language a hint names, if it names one the mod speaks: `de`, `de_DE`,
361 * `de-AT`, `german`, `Deutsch` are German, `en…` and `english` English.
362 */
363export function languageOf(hint: unknown): Language | null {
364 if (typeof hint !== 'string') {
365 return null
366 }
367
368 const said = hint.trim().toLowerCase()
369
370 if (/^(de([_.-]|$)|german|deutsch)/.test(said)) {
371 return 'de'
372 }
373
374 if (/^(en([_.-]|$)|english)/.test(said)) {
375 return 'en'
376 }
377
378 return null
379}
380
381/**
382 * The session's language from its hints, first match wins: the plugin's
383 * own option (unless `auto`), Claude Code's `language` setting, the locale
384 * variables, the runtime's locale. A language the mod does not speak (a
385 * Japanese setting, a French locale) is English.
386 */
387export function pickLanguage(hints: readonly unknown[]): Language {
388 for (const hint of hints) {
389 const language = languageOf(hint)
390
391 if (language !== null) {
392 return language
393 }
394
395 // a set language the mod does not speak still decides: English
396 if (typeof hint === 'string' && hint.trim() !== '' && hint.trim().toLowerCase() !== 'auto' && !/^(c|posix)([_.-]|$)/i.test(hint.trim())) {
397 return 'en'
398 }
399 }
400
401 return 'en'
402}
403hooks/git.ts 102 lines1import type { ProcessRunInit, ProcessRunResult } from 'claude-code'
2
3/**
4 * `$.process.run`, as session.start bound it.
5 */
6export type ProcessRun = (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
7
8/**
9 * One git run's outcome; a child that could not start or timed out reads as
10 * `code: -1` with the reason in `err`, so a caller never has to catch.
11 */
12export type GitResult = {
13 ok: boolean
14 code: number
15 out: string
16 err: string
17}
18
19export type GitOptions = {
20 cwd?: string
21 timeoutMs?: number
22 env?: Record<string, string>
23}
24
25/**
26 * Runs git with an argv after `git`, in the repository unless told otherwise.
27 */
28export type Git = (args: readonly string[], options?: GitOptions) => Promise<GitResult>
29
30/**
31 * The environment every git child gets: the C locale so messages and
32 * numbers parse the same everywhere, and never a terminal prompt (there is
33 * no terminal; a prompt would only hang until the timeout).
34 */
35export const BASE_ENV: Readonly<Record<string, string>> = {
36 LC_ALL: 'C',
37 LANGUAGE: '',
38 GIT_TERMINAL_PROMPT: '0',
39}
40
41/**
42 * Laid over BASE_ENV for work nobody asked for (the background fetch): no
43 * credential window either, and ssh gives up instead of asking.
44 */
45export const QUIET_ENV: Readonly<Record<string, string>> = {
46 GCM_INTERACTIVE: 'never',
47 GIT_ASKPASS: '',
48 SSH_ASKPASS: '',
49}
50
51/**
52 * Laid over BASE_ENV for the actions the person starts: no editor ever opens
53 * (a merge commit keeps git's message, a rebase runs non-interactively).
54 */
55export const ACTION_ENV: Readonly<Record<string, string>> = {
56 GIT_EDITOR: 'true',
57 GIT_SEQUENCE_EDITOR: 'true',
58 GIT_MERGE_AUTOEDIT: 'no',
59}
60
61/**
62 * The flag every read carries, so a background read never takes the index
63 * lock from a git the person or Claude is running.
64 */
65export const NO_LOCKS = '--no-optional-locks'
66
67/**
68 * A git runner over `$.process.run`.
69 *
70 * @param run `$.process.run`
71 * @param defaults the repository's directory once known, and env to lay over
72 * BASE_ENV for every run
73 * @returns the runner
74 */
75export function gitOf(run: ProcessRun, defaults: () => { cwd?: string }): Git {
76 return async (args, options = {}) => {
77 const cwd = options.cwd ?? defaults().cwd
78
79 try {
80 const result = await run(['git', ...args], {
81 ...(cwd ? { cwd } : {}),
82 env: { ...BASE_ENV, ...options.env },
83 timeoutMs: options.timeoutMs ?? 15_000,
84 })
85
86 return {
87 ok: result.exitCode === 0,
88 code: result.exitCode,
89 out: result.stdout,
90 err: result.stderr,
91 }
92 } catch (error) {
93 return {
94 ok: false,
95 code: -1,
96 out: '',
97 err: error instanceof Error ? error.message : String(error),
98 }
99 }
100 }
101}
102hooks/parse.ts 310 lines1import type {
2 ScBranch,
3 ScCommit,
4 ScFile,
5 ScStash,
6 ScWorktree,
7} from '../types'
8
9/**
10 * What `git status --porcelain=v2 --branch --show-stash -z` says.
11 */
12export type StatusReading = {
13 oid: string | null
14 isUnborn: boolean
15 branch: string | null
16 upstream: string | null
17 ahead: number
18 behind: number
19 stashCount: number
20 staged: ScFile[]
21 unstaged: ScFile[]
22 conflicts: ScFile[]
23}
24
25/**
26 * The repository's three directories as `git rev-parse` names them.
27 */
28export type RepoPaths = {
29 root: string
30 gitDir: string
31 commonDir: string
32}
33
34const STAGE_LETTERS: Readonly<Record<string, ScFile['letter']>> = {
35 M: 'M',
36 T: 'M',
37 A: 'A',
38 D: 'D',
39 R: 'R',
40 C: 'A',
41}
42
43/**
44 * Splits `text` at its first `count - 1` spaces, the last field keeping the
45 * rest (a path may hold spaces).
46 */
47function fieldsOf(text: string, count: number): string[] {
48 const fields: string[] = []
49 let rest = text
50
51 while (fields.length < count - 1) {
52 const at = rest.indexOf(' ')
53
54 if (at < 0) {
55 break
56 }
57
58 fields.push(rest.slice(0, at))
59 rest = rest.slice(at + 1)
60 }
61
62 fields.push(rest)
63
64 return fields
65}
66
67function fileOf(path: string, from: string | null, letter: ScFile['letter']): ScFile {
68 return { path, from, letter }
69}
70
71/**
72 * Reads `git status --porcelain=v2 --branch --show-stash -z`: the branch
73 * headers, then one record per changed path, NUL-terminated, a rename's
74 * original path in the record after it.
75 *
76 * @param out git's standard output
77 * @returns the branch, its upstream and counts, and the files by list
78 */
79export function parseStatus(out: string): StatusReading {
80 const reading: StatusReading = {
81 oid: null,
82 isUnborn: false,
83 branch: null,
84 upstream: null,
85 ahead: 0,
86 behind: 0,
87 stashCount: 0,
88 staged: [],
89 unstaged: [],
90 conflicts: [],
91 }
92
93 const records = out.split('\0')
94
95 for (let at = 0; at < records.length; at += 1) {
96 const record = records[at] ?? ''
97
98 if (record === '') {
99 continue
100 }
101
102 if (record.startsWith('# ')) {
103 const [name = '', ...rest] = record.slice(2).split(' ')
104 const value = rest.join(' ')
105
106 if (name === 'branch.oid') {
107 reading.isUnborn = value === '(initial)'
108 reading.oid = reading.isUnborn ? null : value
109 } else if (name === 'branch.head') {
110 reading.branch = value === '(detached)' ? null : value
111 } else if (name === 'branch.upstream') {
112 reading.upstream = value
113 } else if (name === 'branch.ab') {
114 const counts = /^\+(\d+) -(\d+)$/.exec(value)
115
116 reading.ahead = Number(counts?.[1] ?? 0)
117 reading.behind = Number(counts?.[2] ?? 0)
118 } else if (name === 'stash') {
119 reading.stashCount = Number(value) || 0
120 }
121
122 continue
123 }
124
125 const kind = record[0]
126
127 if (kind === '1' || kind === '2') {
128 const fields = fieldsOf(record, kind === '1' ? 9 : 10)
129 const xy = fields[1] ?? '..'
130 const path = fields[fields.length - 1] ?? ''
131 const from = kind === '2' ? (records[at + 1] ?? null) : null
132
133 if (kind === '2') {
134 at += 1
135 }
136
137 const staged = STAGE_LETTERS[xy[0] ?? '.']
138 const unstaged = STAGE_LETTERS[xy[1] ?? '.']
139
140 if (staged) {
141 reading.staged.push(fileOf(path, from, staged))
142 }
143
144 if (unstaged) {
145 reading.unstaged.push(fileOf(path, staged === 'R' ? null : from, unstaged === 'R' ? 'M' : unstaged))
146 }
147 } else if (kind === 'u') {
148 const fields = fieldsOf(record, 11)
149
150 reading.conflicts.push(fileOf(fields[10] ?? '', null, 'C'))
151 } else if (kind === '?') {
152 reading.unstaged.push(fileOf(record.slice(2), null, 'U'))
153 }
154 }
155
156 return reading
157}
158
159/**
160 * Reads `git rev-parse --path-format=absolute --show-toplevel --git-dir
161 * --git-common-dir`: three absolute paths, or null for anything else (a git
162 * too old for `--path-format` echoes the flag back).
163 */
164export function parseRepoPaths(out: string): RepoPaths | null {
165 const lines = out.split(/\r?\n/).filter(line => line !== '')
166 const isAbsolute = (path: string) => /^(\/|[A-Za-z]:[\\/]|\\\\)/.test(path)
167
168 if (lines.length !== 3 || !lines.every(isAbsolute)) {
169 return null
170 }
171
172 const [root = '', gitDir = '', commonDir = ''] = lines
173
174 return { root, gitDir, commonDir }
175}
176
177/**
178 * The format `parseLog` reads: hash, short hash, commit time, decorations
179 * (full ref names), subject; fields split by US, records by RS.
180 */
181export const LOG_FORMAT = '--format=%H%x1f%h%x1f%ct%x1f%P%x1f%D%x1f%s%x1e'
182
183/**
184 * Reads `git log --decorate=full` in LOG_FORMAT: one commit per record, its
185 * decorations sorted into local branches, remote branches and tags.
186 *
187 * @param out git's standard output
188 * @param side which side of the upstream these commits are on
189 * @returns the commits, newest first
190 */
191export function parseLog(out: string, side: ScCommit['side']): ScCommit[] {
192 return out
193 .split('\x1e')
194 .map(record => record.replace(/^\s+/, ''))
195 .filter(record => record !== '')
196 .map(record => {
197 const [hash = '', short = '', time = '0', parents = '', refs = '', ...subject] = record.split('\x1f')
198 const commit: ScCommit = {
199 hash,
200 short,
201 parents: parents.split(' ').filter(parent => parent !== ''),
202 subject: subject.join('\x1f'),
203 when: Number(time) * 1000,
204 side,
205 heads: [],
206 remotes: [],
207 tags: [],
208 isHead: false,
209 }
210
211 for (const part of refs.split(', ').filter(ref => ref !== '')) {
212 if (part === 'HEAD') {
213 commit.isHead = true
214 } else if (part.startsWith('HEAD -> ')) {
215 commit.isHead = true
216 commit.heads.push(part.slice('HEAD -> refs/heads/'.length))
217 } else if (part.startsWith('tag: refs/tags/')) {
218 commit.tags.push(part.slice('tag: refs/tags/'.length))
219 } else if (part.startsWith('refs/heads/')) {
220 commit.heads.push(part.slice('refs/heads/'.length))
221 } else if (part.startsWith('refs/remotes/') && !part.endsWith('/HEAD')) {
222 commit.remotes.push(part.slice('refs/remotes/'.length))
223 }
224 }
225
226 return commit
227 })
228}
229
230/**
231 * The format `parseBranches` reads, one branch per line.
232 */
233export const BRANCH_FORMAT =
234 '--format=%(HEAD)%1f%(refname:short)%1f%(upstream:short)%1f%(upstream:track,nobracket)'
235
236/**
237 * Reads `git for-each-ref refs/heads` in BRANCH_FORMAT.
238 */
239export function parseBranches(out: string): ScBranch[] {
240 return out
241 .split(/\r?\n/)
242 .filter(line => line.includes('\x1f'))
243 .map(line => {
244 const [head = '', name = '', upstream = '', track = ''] = line.split('\x1f')
245
246 return {
247 name,
248 upstream: upstream === '' ? null : upstream,
249 ahead: Number(/ahead (\d+)/.exec(track)?.[1] ?? 0),
250 behind: Number(/behind (\d+)/.exec(track)?.[1] ?? 0),
251 isGone: track === 'gone',
252 isCurrent: head === '*',
253 }
254 })
255}
256
257/**
258 * Reads `git worktree list --porcelain`: blocks split by a blank line, the
259 * main worktree first.
260 *
261 * @param out git's standard output
262 * @param current the session's worktree root, to mark it
263 */
264export function parseWorktrees(out: string, current: string): ScWorktree[] {
265 const samePath = (a: string, b: string) => a.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase() === b.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
266
267 return out
268 .split(/\r?\n\r?\n/)
269 .map(block => block.split(/\r?\n/).filter(line => line !== ''))
270 .filter(lines => lines[0]?.startsWith('worktree ') === true)
271 .filter(lines => !lines.includes('bare'))
272 .map((lines, index) => {
273 const value = (name: string) => lines.find(line => line === name || line.startsWith(`${name} `))
274 const path = (value('worktree') ?? '').slice('worktree '.length)
275 const branch = value('branch')?.slice('branch refs/heads/'.length) ?? null
276
277 return {
278 path,
279 branch,
280 head: (value('HEAD') ?? '').slice('HEAD '.length, 'HEAD '.length + 7),
281 isCurrent: samePath(path, current),
282 isMain: index === 0,
283 isPrunable: value('prunable') !== undefined,
284 isLocked: value('locked') !== undefined,
285 dirty: null,
286 }
287 })
288}
289
290/**
291 * Reads `git stash list --format=%gd%x1f%s`.
292 */
293export function parseStashes(out: string): ScStash[] {
294 return out
295 .split(/\r?\n/)
296 .filter(line => line.includes('\x1f'))
297 .map(line => {
298 const [ref = '', ...subject] = line.split('\x1f')
299
300 return { ref, subject: subject.join(' ') }
301 })
302}
303
304/**
305 * Counts the changed paths `git status --porcelain` lists, one per line.
306 */
307export function countLines(out: string): number {
308 return out.split(/\r?\n/).filter(line => line.trim() !== '').length
309}
310hooks/snapshot.ts 233 lines1import type { ScCommit, ScDetails, ScSnapshot } from '../types'
2import { remoteShownOf } from './format'
3import { NO_LOCKS } from './git'
4import type { Git } from './git'
5import {
6 BRANCH_FORMAT,
7 countLines,
8 LOG_FORMAT,
9 parseBranches,
10 parseLog,
11 parseRepoPaths,
12 parseStashes,
13 parseStatus,
14 parseWorktrees,
15} from './parse'
16import type { RepoPaths } from './parse'
17
18/**
19 * The file system calls a reading needs, as session.start bound them.
20 */
21export type Files = {
22 mtimeOf: (path: string) => Promise<number | null>
23 exists: (path: string) => Promise<boolean>
24 names: (path: string) => Promise<readonly string[]>
25}
26
27const HISTORY_LIMIT = 12
28const INCOMING_LIMIT = 10
29const WORKTREE_STATUS_LIMIT = 8
30
31/**
32 * The repository a directory is in, or null outside one.
33 *
34 * @param git a runner in that directory
35 * @param cwd the directory
36 */
37export async function repoOf(git: Git, cwd: string): Promise<RepoPaths | null> {
38 const found = await git(
39 [NO_LOCKS, 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir'],
40 { cwd },
41 )
42
43 if (found.ok) {
44 return parseRepoPaths(found.out)
45 }
46
47 const isOldGit = /path-format/.test(found.err)
48
49 if (!isOldGit) {
50 return null
51 }
52
53 const old = await git([NO_LOCKS, 'rev-parse', '--show-toplevel', '--absolute-git-dir'], { cwd })
54 const [root = '', gitDir = ''] = old.out.split(/\r?\n/)
55
56 return old.ok && root && gitDir ? { root, gitDir, commonDir: gitDir } : null
57}
58
59async function operationOf(files: Files, gitDir: string): Promise<ScSnapshot['operation']> {
60 const marks: [string, ScSnapshot['operation']][] = [
61 ['rebase-merge', 'rebase'],
62 ['rebase-apply', 'rebase'],
63 ['MERGE_HEAD', 'merge'],
64 ['CHERRY_PICK_HEAD', 'cherry-pick'],
65 ['REVERT_HEAD', 'revert'],
66 ]
67
68 for (const [name, operation] of marks) {
69 if (await files.exists(`${gitDir}/${name}`).catch(() => false)) {
70 return operation
71 }
72 }
73
74 return null
75}
76
77/**
78 * When the repository was last fetched: FETCH_HEAD's change time, which
79 * every fetch and pull writes, whoever ran it.
80 */
81async function fetchedAtOf(files: Files, paths: RepoPaths): Promise<number | null> {
82 const times = await Promise.all(
83 [...new Set([paths.gitDir, paths.commonDir])].map(dir =>
84 files.mtimeOf(`${dir}/FETCH_HEAD`).catch(() => null),
85 ),
86 )
87
88 const known = times.filter((time): time is number => typeof time === 'number')
89
90 return known.length === 0 ? null : Math.max(...known)
91}
92
93/**
94 * The band's reading: one `git status`, the operation in progress, the last
95 * fetch, and the remote when the branch has no upstream.
96 *
97 * @param git a runner in the repository
98 * @param files the file system calls
99 * @param paths the repository
100 * @param now the time of the reading
101 * @returns the snapshot, or null when git could not read the repository
102 */
103export async function readSnapshot(
104 git: Git,
105 files: Files,
106 paths: RepoPaths,
107 now: number,
108): Promise<ScSnapshot | null> {
109 let status = await git([NO_LOCKS, 'status', '--porcelain=v2', '--branch', '--show-stash', '-z'])
110
111 if (!status.ok && /show-stash/.test(status.err)) {
112 status = await git([NO_LOCKS, 'status', '--porcelain=v2', '--branch', '-z'])
113 }
114
115 if (!status.ok) {
116 return null
117 }
118
119 const reading = parseStatus(status.out)
120
121 const [operation, fetchedAt, remotes] = await Promise.all([
122 operationOf(files, paths.gitDir),
123 fetchedAtOf(files, paths),
124 git(['remote']).then(result => result.out.split(/\r?\n/).filter(name => name !== '')),
125 ])
126
127 const upstreamRemote = reading.upstream
128 ? (remotes.find(name => reading.upstream?.startsWith(`${name}/`)) ?? null)
129 : null
130
131 return {
132 root: paths.root,
133 gitDir: paths.gitDir,
134 commonDir: paths.commonDir,
135 isLinkedWorktree: paths.gitDir !== paths.commonDir,
136 branch: reading.branch,
137 head: reading.oid ? reading.oid.slice(0, 7) : null,
138 isUnborn: reading.isUnborn,
139 upstream: reading.upstream,
140 remoteName: upstreamRemote ?? (remotes.includes('origin') ? 'origin' : (remotes[0] ?? null)),
141 ahead: reading.ahead,
142 behind: reading.behind,
143 staged: reading.staged,
144 unstaged: reading.unstaged,
145 conflicts: reading.conflicts,
146 stashCount: reading.stashCount,
147 operation,
148 fetchedAt,
149 readAt: now,
150 details: null,
151 }
152}
153
154/**
155 * Whether the repository runs hooks of its own: a hooks path set in its own
156 * or the global config (Husky, lefthook), or a hook beside the samples.
157 *
158 * Read from the config files themselves: the engine runs a mod's git with
159 * its own `core.hooksPath` to keep repository hooks off.
160 */
161async function hasHooksOf(git: Git, files: Files, snapshot: ScSnapshot): Promise<boolean> {
162 const local = await git(['config', '--file', `${snapshot.commonDir}/config`, '--get', 'core.hooksPath'])
163 const configured = local.ok ? local : await git(['config', '--global', '--get', 'core.hooksPath'])
164 const path = configured.ok ? configured.out.trim() : ''
165
166 if (path !== '') {
167 return true
168 }
169
170 const names = await files.names(`${snapshot.commonDir}/hooks`).catch((): readonly string[] => [])
171
172 return names.some(name => !name.endsWith('.sample'))
173}
174
175/**
176 * The panel's reading on top of a snapshot: the history with incoming and
177 * outgoing commits marked, branches, worktrees, stashes, the remote's
178 * address and whether the repository has hooks.
179 *
180 * @param git a runner in the repository
181 * @param files the file system calls
182 * @param snapshot the band's reading this builds on
183 * @returns the snapshot with its details filled in
184 */
185export async function withDetails(git: Git, files: Files, snapshot: ScSnapshot): Promise<ScSnapshot> {
186 const hasUpstream = snapshot.upstream !== null && !snapshot.isUnborn
187
188 const [history, outgoing, incoming, branches, worktrees, stashes, remoteUrl, hasHooks] = await Promise.all([
189 snapshot.isUnborn ? null : git([NO_LOCKS, 'log', '--topo-order', '--decorate=full', LOG_FORMAT, `-n${HISTORY_LIMIT}`, 'HEAD', '--']),
190 hasUpstream ? git([NO_LOCKS, 'rev-list', '--max-count=200', '@{upstream}..HEAD', '--']) : null,
191 hasUpstream && snapshot.behind > 0
192 ? git([NO_LOCKS, 'log', '--topo-order', '--decorate=full', LOG_FORMAT, `-n${INCOMING_LIMIT}`, 'HEAD..@{upstream}', '--'])
193 : null,
194 git([NO_LOCKS, 'for-each-ref', BRANCH_FORMAT, 'refs/heads']),
195 git([NO_LOCKS, 'worktree', 'list', '--porcelain']),
196 git([NO_LOCKS, 'stash', 'list', '--format=%gd%x1f%s', '-n20']),
197 snapshot.remoteName ? git(['remote', 'get-url', snapshot.remoteName]) : null,
198 hasHooksOf(git, files, snapshot),
199 ])
200
201 const outgoingSet = new Set((outgoing?.out ?? '').split(/\r?\n/).filter(hash => hash !== ''))
202
203 const historyCommits: ScCommit[] = parseLog(history?.ok ? history.out : '', 'shared').map(commit =>
204 outgoingSet.has(commit.hash) ? { ...commit, side: 'outgoing' } : commit,
205 )
206
207 const listed = parseWorktrees(worktrees.ok ? worktrees.out : '', snapshot.root)
208
209 const counted = await Promise.all(
210 listed.map(async (tree, index) => {
211 if (tree.isPrunable || index >= WORKTREE_STATUS_LIMIT) {
212 return tree
213 }
214
215 const dirty = await git([NO_LOCKS, 'status', '--porcelain'], { cwd: tree.path, timeoutMs: 8_000 })
216
217 return dirty.ok ? { ...tree, dirty: countLines(dirty.out) } : tree
218 }),
219 )
220
221 const details: ScDetails = {
222 incoming: parseLog(incoming?.ok ? incoming.out : '', 'incoming'),
223 history: historyCommits,
224 branches: parseBranches(branches.ok ? branches.out : ''),
225 worktrees: counted,
226 stashes: parseStashes(stashes.ok ? stashes.out : ''),
227 remote: remoteUrl?.ok ? remoteShownOf(remoteUrl.out) : null,
228 hasHooks,
229 }
230
231 return { ...snapshot, details }
232}
233hooks/ui/band.tsx 183 lines1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { RenderElement } from 'claude-code'
5
6import type { ScSnapshot, ScView } from '../../types'
7import { clean, cutEnd } from '../format'
8import type { Handlers } from './handlers'
9import {
10 branchTextOf,
11 changeCountOf,
12 freshnessOf,
13 isConfirmedSettled,
14 operationTextOf,
15 primaryOf,
16} from './model'
17import type { Primary } from './model'
18import { t } from '../i18n'
19import { glyphOf, icon } from './icons'
20import type { IconName } from './icons'
21import { COLOR, GLYPH, VSCODE_BLUE } from './theme'
22import type { Kit } from './theme'
23
24/**
25 * Below this many cells the band drops its words and the fetch age.
26 */
27export const NARROW_COLUMNS = 60
28
29/**
30 * The terminal draws its own `[-]` (collapse the band) over the band's last
31 * cells; the band keeps them free.
32 */
33const ENGINE_MARK_COLUMNS = 4
34
35/**
36 * The main button. On the terminal VS Code's blue: a blue box holding a
37 * plain Button, a glyph in front. Where the app draws native buttons
38 * (desktop, editor, phone) the app's own primary button, so it sits among
39 * the app's other buttons as one of them.
40 *
41 * @param kit the surface's elements
42 * @param key the Button's address
43 * @param iconName the glyph before the label on the terminal, or null
44 * @param label what the button says
45 * @param onPress what it does
46 * @param isBusy drawn dim while an action runs
47 * @param isWide on the terminal as wide as its row (the panel's, as VS Code's
48 * action button); the app's button keeps its own size
49 */
50export function primaryButton(
51 kit: Kit,
52 key: string,
53 iconName: IconName | null,
54 label: string,
55 onPress: () => void,
56 isBusy: boolean,
57 isWide = false,
58): RenderElement {
59 const { Box, Button } = kit
60
61 // the app's own button keeps the app's size, next to its neighbours
62 if (kit.surface !== 'terminal') {
63 return (
64 <Box flexDirection="row">
65 <Button key={key} variant="primary" dimColor={isBusy} onPress={onPress}>
66 {label === '' ? ' ' : label}
67 </Button>
68 </Box>
69 )
70 }
71
72 const shown = iconName !== null ? [glyphOf(iconName), label].filter(part => part !== '').join(' ') : label
73
74 return (
75 <Box
76 flexDirection="row"
77 alignItems="center"
78 backgroundColor={VSCODE_BLUE}
79 paddingX={1}
80 {...(isWide ? { flexGrow: 1, justifyContent: 'center' as const } : {})}
81 >
82 <Button key={key} plain dimColor={isBusy} onPress={onPress}>
83 {shown === '' ? ' ' : shown}
84 </Button>
85 </Box>
86 )
87}
88
89/**
90 * The status half of the band: branch, counts, changes, what is going on,
91 * and how fresh the remote side is.
92 */
93function statusText(kit: Kit, snapshot: ScSnapshot, view: ScView, staleAfterMs: number): RenderElement {
94 const { Text } = kit
95 const isNarrow = kit.columns < NARROW_COLUMNS
96 const changes = changeCountOf(snapshot)
97 const operation = operationTextOf(snapshot)
98 const freshness = isNarrow ? null : freshnessOf(snapshot, view, staleAfterMs)
99 const isUnpublished = snapshot.upstream === null && snapshot.branch !== null && snapshot.remoteName !== null && !snapshot.isUnborn
100 const branchRoom = Math.max(8, Math.floor(kit.columns / 3))
101
102 const { Box } = kit
103
104 // the desktop draws the pencil as an icon (the glyph is an emoji there),
105 // so the line breaks into the text before it and the text after it
106 const hasIcons = kit.Svg !== null
107
108 return (
109 <Box flexDirection="row" alignItems="center">
110 {icon(kit, 'branch', 'muted', COLOR.muted)}
111 <Text wrap="truncate-end">
112 <Text bold>{cutEnd(clean(branchTextOf(snapshot)), branchRoom)}</Text>
113 {snapshot.behind > 0 && <Text color={COLOR.behind}>{` ${snapshot.behind}${GLYPH.behind}`}</Text>}
114 {snapshot.ahead > 0 && <Text color={COLOR.ahead}>{` ${snapshot.ahead}${GLYPH.ahead}`}</Text>}
115 {changes > 0 && !hasIcons && <Text>{` ${GLYPH.changes} ${changes}`}</Text>}
116 </Text>
117 {changes > 0 && hasIcons && (
118 <Box flexDirection="row" alignItems="center" marginLeft={2}>
119 {icon(kit, 'edit', 'muted')}
120 <Text>{`${changes}`}</Text>
121 </Box>
122 )}
123 <Text wrap="truncate-end">
124 {isConfirmedSettled(snapshot, view, staleAfterMs) && <Text color={COLOR.ok}>{` ${GLYPH.clean}`}</Text>}
125 {operation !== null && <Text color={COLOR.error}>{` · ${operation}`}</Text>}
126 {isUnpublished && !isNarrow && <Text color={COLOR.muted}>{` · ${t().unpublished}`}</Text>}
127 {view.busy !== null && <Text color={COLOR.muted}>{` · ${view.busy}`}</Text>}
128 {view.busy === null && freshness !== null && (
129 <Text color={freshness.isWarning ? COLOR.warn : COLOR.muted}>{` · ${freshness.text}`}</Text>
130 )}
131 </Text>
132 </Box>
133 )
134}
135
136/**
137 * The band above the prompt: status on the left; the blue button for the
138 * one thing to do now and the panel toggle on the right.
139 *
140 * @param kit the surface's elements and the band's width
141 * @param snapshot the repository as last read
142 * @param view the drawings' own state
143 * @param handlers what the buttons do
144 * @param staleAfterMs when the last fetch counts as old
145 * @returns the band's tree
146 */
147export function bandView(
148 kit: Kit,
149 snapshot: ScSnapshot,
150 view: ScView,
151 handlers: Handlers,
152 staleAfterMs: number,
153): RenderElement {
154 const { Box, Button } = kit
155 const primary: Primary | null = primaryOf(snapshot)
156 const isNarrow = kit.columns < NARROW_COLUMNS
157 const isBusy = view.busy !== null
158
159 const primaryLabel = primary === null ? '' : isNarrow ? primary.short : primary.label
160 const busyLabel = view.busy ?? ''
161
162 return (
163 <Box flexDirection="row" width="100%" paddingRight={kit.surface === 'terminal' ? ENGINE_MARK_COLUMNS : 0}>
164 <Box flexShrink={1}>{statusText(kit, snapshot, view, staleAfterMs)}</Box>
165 <Box flexGrow={1} />
166 {primary !== null && (
167 <Box marginLeft={1}>
168 {primaryButton(kit, 'primary', isBusy ? null : primary.icon, isBusy ? busyLabel : primaryLabel, () => {
169 if (!isBusy) {
170 handlers.primary(primary.kind)
171 }
172 }, isBusy)}
173 </Box>
174 )}
175 <Box marginLeft={1}>
176 <Button key="panel" plain dimColor={!view.isPaneOpen} onPress={() => handlers.togglePane()}>
177 {GLYPH.panel}
178 </Button>
179 </Box>
180 </Box>
181 )
182}
183hooks/ui/handlers.ts 23 lines1import type { ActionName } from '../actions'
2import type { Primary } from './model'
3
4/**
5 * What the drawings' controls do; register binds them to the engine.
6 */
7export type Handlers = {
8 /** Runs one action (fetch, pull, push, stage, …). */
9 act: (name: ActionName, arg?: { message?: string; paths?: readonly string[] }) => void
10 /** The blue button's press for its kind. */
11 primary: (kind: Primary['kind']) => void
12 /** Opens the panel, or closes it when open. */
13 togglePane: () => void
14 /** Folds or unfolds one of the panel's sections. */
15 toggleFold: (id: string) => void
16 /** Commits with the message the field holds, or the one typed so far. */
17 commit: (message?: string) => void
18 /** Keeps the commit field's text as the person types. */
19 draft: (text: string) => void
20 /** Hands the conflicts to Claude as a prompt of its own. */
21 askClaude: () => void
22}
23hooks/ui/model.ts 176 lines1import type { ScSnapshot, ScView } from '../../types'
2import { agoOf } from '../format'
3import { t } from '../i18n'
4import type { IconName } from './icons'
5
6/**
7 * The blue button's job, as VS Code's action button picks it: resolve a
8 * conflict, publish a branch, sync, or commit.
9 */
10export type Primary = {
11 kind: 'resolve' | 'publish' | 'sync' | 'commit'
12 icon: IconName
13 label: string
14 short: string
15}
16
17/**
18 * How many files have changes of any kind.
19 */
20export function changeCountOf(snapshot: ScSnapshot): number {
21 return snapshot.staged.length + snapshot.unstaged.length + snapshot.conflicts.length
22}
23
24/**
25 * `3↓ 1↑`, leaving out a side at zero; empty when in step.
26 */
27export function countsOf(snapshot: Pick<ScSnapshot, 'ahead' | 'behind'>): string {
28 return [
29 snapshot.behind > 0 ? `${snapshot.behind}↓` : '',
30 snapshot.ahead > 0 ? `${snapshot.ahead}↑` : '',
31 ]
32 .filter(part => part !== '')
33 .join(' ')
34}
35
36/**
37 * Whether the branch can be published: a named branch with commits, no
38 * upstream, and a remote to push to.
39 */
40export function canPublish(snapshot: ScSnapshot): boolean {
41 return snapshot.upstream === null && snapshot.branch !== null && !snapshot.isUnborn && snapshot.remoteName !== null
42}
43
44/**
45 * The band's blue button for this state, or null when there is nothing to do.
46 */
47export function primaryOf(snapshot: ScSnapshot): Primary | null {
48 if (snapshot.conflicts.length > 0) {
49 return { kind: 'resolve', icon: 'conflict', label: t().resolve, short: t().resolveShort }
50 }
51
52 if (snapshot.operation !== null) {
53 return { kind: 'resolve', icon: 'commit', label: t().finish, short: t().finishShort }
54 }
55
56 if (canPublish(snapshot)) {
57 return { kind: 'publish', icon: 'publish', label: t().publish, short: '' }
58 }
59
60 const counts = countsOf(snapshot)
61
62 if (snapshot.upstream !== null && counts !== '') {
63 return { kind: 'sync', icon: 'sync', label: t().sync(counts), short: counts }
64 }
65
66 if (changeCountOf(snapshot) > 0) {
67 return { kind: 'commit', icon: 'commit', label: t().commitEllipsis, short: '' }
68 }
69
70 return null
71}
72
73/**
74 * The operation in progress, in words.
75 */
76export function operationTextOf(snapshot: ScSnapshot): string | null {
77 if (snapshot.conflicts.length > 0) {
78 return snapshot.conflicts.length === 1 ? t().oneConflict : t().conflicts(snapshot.conflicts.length)
79 }
80
81 switch (snapshot.operation) {
82 case 'merge':
83 return t().operationRunning('Merge')
84 case 'rebase':
85 return t().operationRunning('Rebase')
86 case 'cherry-pick':
87 return t().operationRunning('Cherry-Pick')
88 case 'revert':
89 return t().operationRunning('Revert')
90 default:
91 return null
92 }
93}
94
95/**
96 * How fresh the remote side is: being fetched, failed, never fetched, or
97 * how long ago (`isAge`), flagged once older than `staleAfterMs`.
98 */
99export function freshnessOf(
100 snapshot: ScSnapshot,
101 view: ScView,
102 staleAfterMs: number,
103): { text: string; isWarning: boolean; isAge: boolean } | null {
104 if (view.isFetching) {
105 return { text: t().checkingRemote, isWarning: false, isAge: false }
106 }
107
108 if (snapshot.remoteName === null) {
109 return null
110 }
111
112 if (view.fetchError !== null) {
113 return { text: t().offline, isWarning: true, isAge: false }
114 }
115
116 const age = fetchAgeOf(snapshot, staleAfterMs)
117
118 if (age === null) {
119 return { text: t().neverChecked, isWarning: true, isAge: false }
120 }
121
122 return { ...age, isAge: true }
123}
124
125/**
126 * How long before the reading the last fetch was, in words, flagged once
127 * older than `staleAfterMs`; null when it never ran or there is no remote
128 * (nothing is shown then). All a drawing shows of the reading's time.
129 */
130export function fetchAgeOf(snapshot: ScSnapshot, staleAfterMs: number): { text: string; isWarning: boolean } | null {
131 if (snapshot.fetchedAt === null || snapshot.remoteName === null) {
132 return null
133 }
134
135 return {
136 text: agoOf(snapshot.fetchedAt, snapshot.readAt),
137 isWarning: snapshot.readAt - snapshot.fetchedAt > staleAfterMs,
138 }
139}
140
141/**
142 * Whether the band may say all is well: settled, and the remote side known
143 * and fresh (an unknown state is never shown as good).
144 */
145export function isConfirmedSettled(snapshot: ScSnapshot, view: ScView, staleAfterMs: number): boolean {
146 const freshness = freshnessOf(snapshot, view, staleAfterMs)
147
148 return isSettled(snapshot) && view.busy === null && (freshness === null || (freshness.isAge && !freshness.isWarning))
149}
150
151/**
152 * Whether the repository is in step and clean: nothing to commit, pull or
153 * push, no conflict.
154 */
155export function isSettled(snapshot: ScSnapshot): boolean {
156 return (
157 changeCountOf(snapshot) === 0 &&
158 snapshot.operation === null &&
159 snapshot.ahead === 0 &&
160 snapshot.behind === 0 &&
161 (snapshot.upstream !== null || snapshot.remoteName === null || snapshot.isUnborn)
162 )
163}
164
165/**
166 * The branch as the band names it: its name, or the commit for a detached
167 * HEAD, or `main (neu)` style for a repository without commits.
168 */
169export function branchTextOf(snapshot: ScSnapshot): string {
170 if (snapshot.branch !== null) {
171 return snapshot.branch
172 }
173
174 return snapshot.head ? `${snapshot.head} (detached)` : '(detached)'
175}
176hooks/ui/pane.tsx 744 lines1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { RenderElement } from 'claude-code'
5
6import type { ScBranch, ScCommit, ScFile, ScSnapshot, ScStash, ScView, ScWorktree } from '../../types'
7import { OPERATION_NAMES } from '../actions'
8import { t } from '../i18n'
9import { clean, cutEnd, cutStart, shortPathOf } from '../format'
10import { primaryButton } from './band'
11import { icon, tintedIcon } from './icons'
12import { graphCells, graphSource, LANE_COLORS, LANE_THEME, LANE_WIDTH, layoutGraph, ROW_HEIGHT } from './graph'
13import type { GraphEntry, GraphRow } from './graph'
14import type { Handlers } from './handlers'
15import { branchTextOf, canPublish, changeCountOf, countsOf, freshnessOf, operationTextOf } from './model'
16import { COLOR, GLYPH, LETTER_COLOR } from './theme'
17import type { Kit } from './theme'
18
19/**
20 * The panel's id, its `requestId` in `ui.render`.
21 */
22export const PANE_ID = 'source-control'
23
24/**
25 * The panel's title, shown on its tab while several panes are open.
26 */
27export const PANE_TITLE = 'Source Control'
28
29/**
30 * How tall the panel asks to be when the surface seats it inline above the
31 * prompt (a docked pane takes the transcript's height anyway).
32 */
33export const PANE_ROWS = 24
34
35/**
36 * Inline, the engine draws its close mark at the first row's right edge.
37 */
38const INLINE_MARK_COLUMNS = 2
39
40function rule(kit: Kit): RenderElement {
41 const { Text } = kit
42
43 return (
44 <Text dimColor wrap="truncate-end">
45 {GLYPH.rule.repeat(Math.max(1, kit.columns))}
46 </Text>
47 )
48}
49
50function dimLine(kit: Kit, text: string): RenderElement {
51 const { Text } = kit
52
53 return (
54 <Text dimColor wrap="truncate-end">
55 {text}
56 </Text>
57 )
58}
59
60/**
61 * A section's foldable header: VS Code's capitals on the terminal, the
62 * app's sentence case with a quiet count where it draws native controls.
63 */
64function sectionHeader(
65 kit: Kit,
66 handlers: Handlers,
67 id: string,
68 title: string,
69 count: number | null,
70 isFolded: boolean,
71 tail: RenderElement | null = null,
72): RenderElement {
73 const { Box, Button, Text } = kit
74 const fold = isFolded ? GLYPH.closed : GLYPH.open
75
76 if (kit.surface === 'terminal') {
77 return (
78 <Box flexDirection="row" marginTop={1}>
79 <Button key={`fold:${id}`} plain dimColor onPress={() => handlers.toggleFold(id)}>
80 {`${fold} ${title.toUpperCase()}${count === null ? '' : ` ${count}`}`}
81 </Button>
82 <Box flexGrow={1} />
83 {tail}
84 </Box>
85 )
86 }
87
88 return (
89 <Box flexDirection="row" alignItems="center" marginTop={1}>
90 <Button key={`fold:${id}`} plain onPress={() => handlers.toggleFold(id)}>
91 {`${fold} ${title}`}
92 </Button>
93 {count !== null && (
94 <Box marginLeft={1}>
95 <Text dimColor>{String(count)}</Text>
96 </Box>
97 )}
98 <Box flexGrow={1} />
99 {tail}
100 </Box>
101 )
102}
103
104function fileRow(kit: Kit, handlers: Handlers, file: ScFile, action: 'stage' | 'unstage'): RenderElement {
105 const { Box, Button, Text } = kit
106 const room = Math.max(8, kit.columns - 2 - 3)
107 const paths = file.from !== null && action === 'unstage' ? [file.path, file.from] : [file.path]
108 const shown = file.from !== null ? `${file.path} ← ${file.from}` : file.path
109
110 // where the surface has a pointer, + and − show on the hovered row, as
111 // VS Code's do; the terminal keeps them in sight for the keyboard
112 const hasPointer = kit.Svg !== null
113
114 return (
115 <Box key={`row:${action}:${file.path}`} flexDirection="row">
116 <Text color={LETTER_COLOR[file.letter] ?? COLOR.muted}>{`${file.letter} `}</Text>
117 <Text wrap="truncate-start">{cutStart(clean(shown), room)}</Text>
118 <Box flexGrow={1} />
119 <Box marginLeft={2} {...(hasPointer ? { display: 'none' as const, hover: { display: 'flex' as const } } : {})}>
120 <Button
121 key={`${action}:${file.path}`}
122 plain
123 dimColor
124 onPress={() => handlers.act(action, { paths })}
125 >
126 {action === 'stage' ? GLYPH.stage : GLYPH.unstage}
127 </Button>
128 </Box>
129 </Box>
130 )
131}
132
133/**
134 * The remote's line: its name, then its address, a link to the web page
135 * where the address is a plain host and path (GitHub, GitLab, …).
136 */
137function remoteRow(kit: Kit, name: string, address: string): RenderElement {
138 const { Box, Text } = kit
139 const href = /^[a-z0-9.-]+\.[a-z]{2,}\/[\w.\/-]+$/i.test(address) ? new URL(`https://${address}`).href : null
140 const { Link } = kit
141
142 return (
143 <Box flexDirection="row">
144 <Box flexShrink={0}>
145 <Text dimColor>{`${clean(name)} ·`}</Text>
146 </Box>
147 <Box marginLeft={1} flexShrink={1}>
148 {href !== null && Link !== null ? (
149 <Link href={href} label={clean(address)} />
150 ) : (
151 <Text dimColor wrap="truncate-end">
152 {clean(address)}
153 </Text>
154 )}
155 </Box>
156 </Box>
157 )
158}
159
160/**
161 * A branch on a history row. The terminal draws VS Code's colored pill;
162 * the app, whose controls are round, the branch's icon (a cloud for a
163 * remote one) and its name in the color, without a block behind.
164 */
165function badge(kit: Kit, name: string, color: { text: string; hex: string }, side: 'local' | 'remote'): RenderElement {
166 const { Box, Text } = kit
167 const mark = tintedIcon(kit, side === 'local' ? 'branch' : 'cloud', color.hex)
168
169 if (mark === null) {
170 return (
171 <Text backgroundColor={color.text} color={COLOR.badgeText}>
172 {` ${clean(name)} `}
173 </Text>
174 )
175 }
176
177 return (
178 <Box flexDirection="row" alignItems="center">
179 {mark}
180 <Text color={color.text}>{clean(name)}</Text>
181 </Box>
182 )
183}
184
185/**
186 * The graph's cells in front of a history row: a row of the lanes as SVG
187 * where the surface draws it, else `git log --graph`-like glyphs.
188 */
189function lanes(kit: Kit, row: GraphRow, width: number): RenderElement {
190 const { Box, Svg, Text } = kit
191
192 if (Svg !== null) {
193 return (
194 <Box marginRight={1}>
195 <Svg source={graphSource(row, width)} alt="│" width={width * LANE_WIDTH} height={ROW_HEIGHT} />
196 </Box>
197 )
198 }
199
200 return (
201 <Text>
202 {graphCells(row, width).map(cell =>
203 cell.color === null ? `${cell.glyph} ` : <Text color={LANE_THEME[cell.color]}>{`${cell.glyph} `}</Text>,
204 )}
205 </Text>
206 )
207}
208
209/**
210 * How many cells the graph takes in front of a row.
211 */
212function lanesColumns(kit: Kit, width: number): number {
213 return kit.Svg === null ? width * 2 : Math.ceil((width * LANE_WIDTH) / 8) + 1
214}
215
216/**
217 * A branch pill's color: its lane's, as VS Code colors them, where a merge
218 * opened the lane; on the main lanes blue for a local branch, purple for a
219 * remote one.
220 */
221function pillColorOf(kit: Kit, row: GraphRow, side: 'local' | 'remote'): { text: string; hex: string } {
222 const lane = row.nodeColor
223
224 if (lane === 'grey' || lane === 'blue' || lane === 'purple') {
225 return side === 'local'
226 ? { text: COLOR.local, hex: LANE_COLORS.blue }
227 : { text: COLOR.remote, hex: LANE_COLORS.purple }
228 }
229
230 // the terminal takes theme colors, the app the lane's own
231 return { text: kit.surface === 'terminal' ? LANE_THEME[lane] : rgbOf(LANE_COLORS[lane]), hex: LANE_COLORS[lane] }
232}
233
234function rgbOf(hex: string): string {
235 const value = Number.parseInt(hex.slice(1), 16)
236
237 return `rgb(${(value >> 16) & 255},${(value >> 8) & 255},${value & 255})`
238}
239
240function commitRow(kit: Kit, commit: ScCommit, row: GraphRow, width: number): RenderElement {
241 const { Box, Text } = kit
242
243 const names = [
244 ...commit.heads.map(name => ({ name, side: 'local' as const, color: pillColorOf(kit, row, 'local') })),
245 ...commit.remotes.map(name => ({ name, side: 'remote' as const, color: pillColorOf(kit, row, 'remote') })),
246 ]
247
248 const badgeRoom = Math.floor(kit.columns / 2)
249 const shownNames: typeof names = []
250 let used = 0
251
252 for (const named of names) {
253 const nameWidth = named.name.length + 3
254
255 if (used + nameWidth > badgeRoom) {
256 break
257 }
258
259 shownNames.push(named)
260 used += nameWidth
261 }
262
263 const room = Math.max(8, kit.columns - lanesColumns(kit, width) - used)
264
265 return (
266 <Box flexDirection="row" alignItems="center">
267 {lanes(kit, row, width)}
268 <Text bold={commit.isHead} dimColor={commit.side === 'shared' && !commit.isHead} wrap="truncate-end">
269 {cutEnd(clean(commit.subject), room)}
270 </Text>
271 <Box flexGrow={1} />
272 {shownNames.map(named => (
273 <Box marginLeft={kit.surface === 'terminal' ? 1 : 2}>{badge(kit, named.name, named.color, named.side)}</Box>
274 ))}
275 </Box>
276 )
277}
278
279function virtualRow(kit: Kit, label: string, color: string, tail: string, row: GraphRow, width: number): RenderElement {
280 const { Box, Text } = kit
281
282 return (
283 <Box flexDirection="row" alignItems="center">
284 {lanes(kit, row, width)}
285 <Text color={color}>{label}</Text>
286 <Box flexGrow={1} />
287 <Text dimColor>{tail}</Text>
288 </Box>
289 )
290}
291
292function moreRow(kit: Kit, text: string, row: GraphRow, width: number): RenderElement {
293 const { Box, Text } = kit
294
295 return (
296 <Box flexDirection="row" alignItems="center">
297 {lanes(kit, row, width)}
298 <Text dimColor>{text}</Text>
299 </Box>
300 )
301}
302
303function branchRow(kit: Kit, branch: ScBranch, isConfirmed: boolean): RenderElement {
304 const { Box, Text } = kit
305 const counts = countsOf(branch)
306
307 const tail = branch.isGone ? (
308 <Text dimColor>{t().goneOnRemote}</Text>
309 ) : branch.upstream === null ? (
310 <Text dimColor>{t().localOnly}</Text>
311 ) : counts !== '' ? (
312 <Text>
313 {branch.behind > 0 && <Text color={COLOR.behind}>{`${branch.behind}${GLYPH.behind}`}</Text>}
314 {branch.behind > 0 && branch.ahead > 0 && ' '}
315 {branch.ahead > 0 && <Text color={COLOR.ahead}>{`${branch.ahead}${GLYPH.ahead}`}</Text>}
316 </Text>
317 ) : (
318 <Text color={isConfirmed ? COLOR.ok : COLOR.muted}>{GLYPH.clean}</Text>
319 )
320
321 return (
322 <Box flexDirection="row">
323 <Text color={branch.isCurrent ? COLOR.local : COLOR.muted}>{branch.isCurrent ? '* ' : ' '}</Text>
324 <Text bold={branch.isCurrent} wrap="truncate-end">
325 {cutEnd(clean(branch.name), Math.max(8, kit.columns - 24))}
326 </Text>
327 <Box flexGrow={1} />
328 {tail}
329 </Box>
330 )
331}
332
333function worktreeRow(kit: Kit, tree: ScWorktree): RenderElement {
334 const { Box, Text } = kit
335
336 const state = tree.isPrunable ? (
337 <Text dimColor>{t().missing}</Text>
338 ) : tree.dirty === null ? (
339 <Text dimColor>–</Text>
340 ) : tree.dirty > 0 ? (
341 <Box flexDirection="row" alignItems="center">
342 {icon(kit, 'edit', 'muted')}
343 <Text>{`${tree.dirty}`}</Text>
344 </Box>
345 ) : (
346 <Text dimColor>{t().clean}</Text>
347 )
348
349 const branch = tree.branch ?? `${tree.head} (detached)`
350 const pathRoom = Math.max(8, kit.columns - 4 - Math.min(branch.length, 18) - 10)
351
352 return (
353 <Box flexDirection="row">
354 <Text color={tree.isCurrent ? COLOR.local : COLOR.muted}>{tree.isCurrent ? '* ' : ' '}</Text>
355 <Text bold={tree.isCurrent} wrap="truncate-start">
356 {cutStart(clean(shortPathOf(tree.path)), pathRoom)}
357 </Text>
358 <Box flexGrow={1} />
359 <Text color={COLOR.local}>{cutEnd(clean(branch), 18)}</Text>
360 <Box marginLeft={1}>{state}</Box>
361 </Box>
362 )
363}
364
365function stashRow(kit: Kit, stash: ScStash): RenderElement {
366 const { Text } = kit
367
368 return (
369 <Text wrap="truncate-end">
370 <Text dimColor>{`${stash.ref} `}</Text>
371 {cutEnd(clean(stash.subject), Math.max(8, kit.columns - stash.ref.length - 1))}
372 </Text>
373 )
374}
375
376function headerRow(kit: Kit, handlers: Handlers, isBusy: boolean, isDocked: boolean): RenderElement {
377 const { Box, Button, Text } = kit
378 const isNarrow = kit.columns < 44
379
380 const hasChrome = kit.Svg !== null
381
382 const tool = (key: string, iconName: 'fetch' | 'pull' | 'push', label: string, onPress: () => void) => (
383 <Box flexDirection="row" alignItems="center" marginLeft={hasChrome ? 2 : 1}>
384 {hasChrome && icon(kit, iconName)}
385 <Button key={key} plain dimColor onPress={() => (isBusy ? undefined : onPress())}>
386 {hasChrome ? label : `${iconName === 'fetch' ? GLYPH.sync : iconName === 'pull' ? GLYPH.behind : GLYPH.ahead} ${label}`}
387 </Button>
388 </Box>
389 )
390
391 return (
392 <Box flexDirection="row" paddingRight={isDocked ? 0 : INLINE_MARK_COLUMNS}>
393 {!hasChrome && <Text bold>{isNarrow ? t().titleNarrow : t().title}</Text>}
394 <Box flexGrow={1} />
395 {tool('tool:fetch', 'fetch', t().fetch, () => handlers.act('fetch'))}
396 {tool('tool:pull', 'pull', t().pull, () => handlers.act('pull'))}
397 {tool('tool:push', 'push', t().push, () => handlers.act('push'))}
398 </Box>
399 )
400}
401
402function infoRow(kit: Kit, snapshot: ScSnapshot, view: ScView, staleAfterMs: number): RenderElement {
403 const { Box, Text } = kit
404 const freshness = freshnessOf(snapshot, view, staleAfterMs)
405
406 return (
407 <Box flexDirection="row" alignItems="center">
408 {icon(kit, 'branch', 'muted', COLOR.muted)}
409 <Text wrap="truncate-end">
410 <Text bold>{clean(branchTextOf(snapshot))}</Text>
411 {snapshot.upstream !== null ? (
412 <Text>
413 <Text color={COLOR.muted}>{' → '}</Text>
414 <Text color={COLOR.remote}>{clean(snapshot.upstream)}</Text>
415 </Text>
416 ) : (
417 <Text color={COLOR.muted}>{` · ${snapshot.isUnborn ? t().noCommitsYet : t().noUpstream}`}</Text>
418 )}
419 {snapshot.behind > 0 && <Text color={COLOR.behind}>{` ${snapshot.behind}${GLYPH.behind}`}</Text>}
420 {snapshot.ahead > 0 && <Text color={COLOR.ahead}>{` ${snapshot.ahead}${GLYPH.ahead}`}</Text>}
421 {freshness !== null && (
422 <Text color={freshness.isWarning ? COLOR.warn : COLOR.muted}>
423 {` · ${freshness.isAge ? t().checked(freshness.text) : freshness.text}`}
424 </Text>
425 )}
426 </Text>
427 </Box>
428 )
429}
430
431function actionRow(kit: Kit, snapshot: ScSnapshot, view: ScView, handlers: Handlers): RenderElement | null {
432 const { Box, Button } = kit
433 const isBusy = view.busy !== null
434 const changes = changeCountOf(snapshot)
435 const counts = countsOf(snapshot)
436 const canSync = snapshot.upstream !== null && counts !== ''
437 const elements: RenderElement[] = []
438
439 const operation = snapshot.operation
440 const abort = (label: string) => (
441 <Box marginLeft={1}>
442 <Button key="pane-abort" onPress={() => handlers.act('abortOperation')}>
443 {label}
444 </Button>
445 </Box>
446 )
447
448 if (isBusy) {
449 elements.push(primaryButton(kit, 'pane-primary', null, view.busy ?? '', () => undefined, true, true))
450 } else if (snapshot.conflicts.length > 0) {
451 elements.push(primaryButton(kit, 'pane-primary', 'conflict', t().askClaude, () => handlers.askClaude(), false, true))
452
453 if (operation !== null) {
454 elements.push(abort(t().abortOperation(OPERATION_NAMES[operation])))
455 }
456 } else if (operation !== null) {
457 elements.push(
458 primaryButton(
459 kit,
460 'pane-primary',
461 'commit',
462 t().finishOperation(OPERATION_NAMES[operation]),
463 () => (operation === 'merge' ? handlers.commit() : handlers.act('continueOperation')),
464 false,
465 true,
466 ),
467 )
468 elements.push(abort(t().abortOperation(OPERATION_NAMES[operation])))
469 } else if (changes > 0) {
470 const label =
471 snapshot.staged.length > 0
472 ? t().commitStaged(snapshot.staged.length)
473 : snapshot.unstaged.length > 0
474 ? t().commitAll(snapshot.unstaged.length)
475 : t().commit
476
477 elements.push(primaryButton(kit, 'pane-primary', 'commit', label, () => handlers.commit(), false, true))
478
479 if (canSync) {
480 elements.push(
481 <Box marginLeft={1}>
482 <Button key="pane-sync" onPress={() => handlers.act('sync')}>
483 {kit.Svg === null ? `${GLYPH.sync} ${t().sync(counts)}` : t().sync(counts)}
484 </Button>
485 </Box>,
486 )
487 }
488 } else if (canPublish(snapshot)) {
489 elements.push(primaryButton(kit, 'pane-primary', 'publish', t().publishBranch, () => handlers.act('publish'), false, true))
490 } else if (canSync) {
491 elements.push(primaryButton(kit, 'pane-primary', 'sync', t().syncChanges(counts), () => handlers.act('sync'), false, true))
492 }
493
494 return elements.length === 0 ? null : <Box flexDirection="row" marginTop={kit.Svg === null ? 0 : 1}>{elements}</Box>
495}
496
497/**
498 * How many incoming commits the history lists before it sums up the rest.
499 */
500const INCOMING_SHOWN = 5
501
502function historyRows(kit: Kit, snapshot: ScSnapshot): RenderElement[] {
503 const details = snapshot.details
504
505 if (details === null) {
506 return [dimLine(kit, t().loading)]
507 }
508
509 type Entry =
510 | { kind: 'header'; label: string; color: string; tail: string; graph: GraphEntry }
511 | { kind: 'commit'; commit: ScCommit; graph: GraphEntry }
512 | { kind: 'more'; text: string; graph: GraphEntry }
513
514 const asGraph = (commit: ScCommit): GraphEntry => ({ kind: 'commit', hash: commit.hash, parents: commit.parents, side: commit.side })
515 const entries: Entry[] = []
516 const shownIncoming = details.incoming.slice(0, INCOMING_SHOWN)
517
518 if (details.incoming.length > 0 || snapshot.behind > 0) {
519 entries.push({
520 kind: 'header',
521 label: t().incoming,
522 color: COLOR.remote,
523 tail: t().commits(snapshot.behind),
524 graph: { kind: 'header', next: details.incoming[0]?.hash ?? null, side: 'incoming' },
525 })
526 entries.push(...shownIncoming.map(commit => ({ kind: 'commit' as const, commit, graph: asGraph(commit) })))
527
528 const rest = snapshot.behind - shownIncoming.length
529
530 if (rest > 0) {
531 const hidden = details.incoming.slice(INCOMING_SHOWN).map(commit => ({ hash: commit.hash, parents: commit.parents }))
532
533 entries.push({ kind: 'more', text: t().more(rest), graph: { kind: 'gap', hidden } })
534 }
535 }
536
537 if (snapshot.ahead > 0) {
538 entries.push({
539 kind: 'header',
540 label: t().outgoing,
541 color: COLOR.local,
542 tail: t().commits(snapshot.ahead),
543 graph: { kind: 'header', next: details.history[0]?.hash ?? null, side: 'outgoing' },
544 })
545 }
546
547 entries.push(...details.history.map(commit => ({ kind: 'commit' as const, commit, graph: asGraph(commit) })))
548
549 if (entries.length === 0) {
550 return [dimLine(kit, t().noCommits)]
551 }
552
553 const known = new Set([...details.incoming, ...details.history].map(commit => commit.hash))
554 const fallback = details.history.find(commit => commit.side === 'shared')?.hash ?? null
555 const { rows, width } = layoutGraph(entries.map(entry => entry.graph), known, fallback)
556
557 return entries.map((entry, index) => {
558 const row = rows[index] as GraphRow
559
560 if (entry.kind === 'header') {
561 return virtualRow(kit, entry.label, entry.color, entry.tail, row, width)
562 }
563
564 if (entry.kind === 'more') {
565 return moreRow(kit, entry.text, row, width)
566 }
567
568 return commitRow(kit, entry.commit, row, width)
569 })
570}
571
572/**
573 * The Source Control panel: header and toolbar, branch line, notices, the
574 * commit field and the blue button, the changes, the history, branches,
575 * worktrees and stashes; each section folds.
576 *
577 * @param kit the surface's elements and the body's width
578 * @param snapshot the repository as last read, null outside one
579 * @param view the drawings' own state
580 * @param handlers what the controls do
581 * @param staleAfterMs when the last fetch counts as old
582 * @param isDocked docked beside the transcript, where the engine draws its
583 * close mark in the first row
584 * @returns the panel's tree
585 */
586export function paneView(
587 kit: Kit,
588 snapshot: ScSnapshot | null,
589 view: ScView,
590 handlers: Handlers,
591 staleAfterMs: number,
592 isDocked: boolean,
593): RenderElement {
594 const { Box, Button, Text } = kit
595 const isBusy = view.busy !== null
596 const isFolded = (id: string) => view.folded.includes(id)
597 const top = headerRow(kit, handlers, isBusy, isDocked)
598 // the terminal draws a docked pane's close mark in its first row; the
599 // desktop puts it in a title bar of its own
600 const hasMarkRow = isDocked && kit.Svg === null
601
602 if (snapshot === null) {
603 return (
604 <Box flexDirection="column" paddingX={1} paddingTop={hasMarkRow ? 1 : 0}>
605 {top}
606 {dimLine(kit, t().noRepository)}
607 </Box>
608 )
609 }
610
611 const details = snapshot.details
612 const conflictCount = snapshot.conflicts.length
613
614 const operation =
615 snapshot.operation !== null
616 ? `${t().operationRunning(OPERATION_NAMES[snapshot.operation])} · ${conflictCount > 0 ? t().conflicts(conflictCount) : t().allConflictsResolved}`
617 : operationTextOf(snapshot)
618 const notice = view.notice
619 const noticeColor = notice?.tone === 'error' ? COLOR.error : notice?.tone === 'success' ? COLOR.ok : COLOR.muted
620 const target = snapshot.branch ?? 'HEAD'
621 // the message field only while there is something to commit (or a merge
622 // to conclude), so a clean repository shows no commit control at all
623 const canCommit = changeCountOf(snapshot) > 0 || snapshot.operation === 'merge'
624 const { Input } = kit
625
626 const sections: (RenderElement | null | false)[] = []
627
628 if (snapshot.conflicts.length > 0) {
629 sections.push(sectionHeader(kit, handlers, 'conflicts', t().conflictsSection, snapshot.conflicts.length, isFolded('conflicts')))
630
631 if (!isFolded('conflicts')) {
632 sections.push(dimLine(kit, t().conflictsHint))
633 sections.push(...snapshot.conflicts.map(file => fileRow(kit, handlers, file, 'stage')))
634 }
635 }
636
637 if (snapshot.staged.length > 0) {
638 sections.push(
639 sectionHeader(
640 kit,
641 handlers,
642 'staged',
643 t().stagedSection,
644 snapshot.staged.length,
645 isFolded('staged'),
646 <Button key="unstage-all" plain dimColor onPress={() => handlers.act('unstageAll')}>
647 {`${GLYPH.unstage} ${t().all}`}
648 </Button>,
649 ),
650 )
651
652 if (!isFolded('staged')) {
653 sections.push(...snapshot.staged.map(file => fileRow(kit, handlers, file, 'unstage')))
654 }
655 }
656
657 sections.push(
658 sectionHeader(
659 kit,
660 handlers,
661 'changes',
662 t().changesSection,
663 snapshot.unstaged.length,
664 isFolded('changes'),
665 snapshot.unstaged.length > 0 ? (
666 <Button key="stage-all" plain dimColor onPress={() => handlers.act('stageAll')}>
667 {`${GLYPH.stage} ${t().all}`}
668 </Button>
669 ) : null,
670 ),
671 )
672
673 if (!isFolded('changes')) {
674 sections.push(
675 ...(snapshot.unstaged.length > 0
676 ? snapshot.unstaged.map(file => fileRow(kit, handlers, file, 'stage'))
677 : [dimLine(kit, changeCountOf(snapshot) === 0 ? `${t().noChanges} ${GLYPH.clean}` : conflictCount > 0 ? t().noOtherChanges : t().allStaged)]),
678 )
679 }
680
681 sections.push(sectionHeader(kit, handlers, 'history', t().historySection, null, isFolded('history')))
682
683 if (!isFolded('history')) {
684 sections.push(...historyRows(kit, snapshot))
685 }
686
687 if (details !== null) {
688 sections.push(sectionHeader(kit, handlers, 'branches', t().branchesSection, details.branches.length, isFolded('branches')))
689
690 if (!isFolded('branches')) {
691 sections.push(...details.branches.map(branch => branchRow(kit, branch, view.fetchError === null)))
692 }
693
694 if (details.worktrees.length > 1) {
695 sections.push(sectionHeader(kit, handlers, 'worktrees', t().worktreesSection, details.worktrees.length, isFolded('worktrees')))
696
697 if (!isFolded('worktrees')) {
698 sections.push(...details.worktrees.map(tree => worktreeRow(kit, tree)))
699 }
700 }
701
702 if (details.stashes.length > 0) {
703 sections.push(sectionHeader(kit, handlers, 'stashes', t().stashesSection, details.stashes.length, isFolded('stashes')))
704
705 if (!isFolded('stashes')) {
706 sections.push(...details.stashes.map(stash => stashRow(kit, stash)))
707 }
708 }
709 }
710
711 return (
712 <Box flexDirection="column" paddingX={1} paddingTop={hasMarkRow ? 1 : 0}>
713 {top}
714 {infoRow(kit, snapshot, view, staleAfterMs)}
715 {details?.remote != null && remoteRow(kit, snapshot.remoteName ?? 'remote', details.remote)}
716 {snapshot.isLinkedWorktree && dimLine(kit, t().worktreeOf(shortPathOf(snapshot.commonDir.replace(/[\\/]\.git[\\/]?$/, ''))))}
717 {operation !== null && <Text color={conflictCount > 0 ? COLOR.error : COLOR.warn} wrap="wrap">{operation}</Text>}
718 {view.fetchError !== null && <Text color={COLOR.warn} wrap="wrap">{t().fetchFailed(view.fetchError)}</Text>}
719 {notice !== null && notice.text !== view.fetchError && <Text color={noticeColor} wrap="wrap">{notice.text}</Text>}
720 {notice?.retry === 'autostash' && !isBusy && (
721 <Box flexDirection="row">
722 <Button key="retry-autostash" onPress={() => handlers.act('pullAutostash')}>
723 {t().pullAutostash}
724 </Button>
725 </Box>
726 )}
727 {kit.Svg === null ? rule(kit) : <Box marginTop={1} />}
728 {Input !== null && canCommit && (
729 <Input
730 key={`message-${view.commitField}`}
731 placeholder={t().messagePlaceholder(target)}
732 submitLabel={kit.Svg === null ? 'commit' : '↵'}
733 autoFocus
734 onInput={value => handlers.draft(value)}
735 onSubmit={value => handlers.commit(value)}
736 />
737 )}
738 {actionRow(kit, snapshot, view, handlers)}
739 {details?.hasHooks === true && dimLine(kit, t().hooksHint)}
740 {sections}
741 </Box>
742 )
743}
744hooks/ui/theme.ts 91 lines1import type { ElementTable, RenderSurface } from 'claude-code'
2
3/**
4 * VS Code's button blue (#0078D4): the one color not taken from the theme,
5 * because it is the point of the primary button.
6 */
7export const VSCODE_BLUE = 'rgb(0,120,212)'
8
9/**
10 * Theme keys, so light and dark themes both read right (the /diff mod's
11 * palette): blue for local and outgoing, purple for the remote side.
12 */
13export const COLOR = {
14 local: 'suggestion',
15 remote: 'merged',
16 behind: 'warning',
17 ahead: 'suggestion',
18 ok: 'success',
19 warn: 'warning',
20 error: 'error',
21 muted: 'inactive',
22 added: 'diffAddedWord',
23 removed: 'diffRemovedWord',
24 badgeText: 'inverseText',
25} as const
26
27/**
28 * Single-width glyphs only: emoji shift the terminal's columns.
29 */
30export const GLYPH = {
31 branch: '⎇',
32 behind: '↓',
33 ahead: '↑',
34 sync: '↻',
35 publish: '⇡',
36 changes: '✎',
37 clean: '✓',
38 commit: '✓',
39 panel: '◨',
40 open: '▾',
41 closed: '▸',
42 node: '●',
43 virtual: '○',
44 rule: '─',
45 stage: '+',
46 unstage: '−',
47} as const
48
49/**
50 * The status letters in VS Code's colors.
51 */
52export const LETTER_COLOR: Readonly<Record<string, string>> = {
53 M: 'warning',
54 A: 'success',
55 U: 'success',
56 R: 'success',
57 D: 'error',
58 C: 'error',
59}
60
61/**
62 * The elements every drawing uses, from the surface's table; `Input` where
63 * the surface has one (not the mobile app).
64 */
65export type Kit = {
66 Box: ElementTable['Box']
67 Text: ElementTable['Text']
68 Button: ElementTable['Button']
69 Input: ElementTable<'terminal'>['Input'] | null
70 Svg: ElementTable<'desktop'>['Svg'] | null
71 Link: ElementTable['Link'] | null
72 surface: RenderSurface
73 columns: number
74}
75
76/**
77 * The kit for one drawing.
78 *
79 * @param table what `$.ui.resolve(e)` returned
80 * @param surface `e.surface`
81 * @param columns the cells the drawing has across
82 */
83export function kitOf(table: ElementTable, surface: RenderSurface, columns: number): Kit {
84 // every table is completed to all names (a missing element draws a
85 // fragment), so the surface, not the table, says what is really drawn
86 const Input = surface !== 'mobile' && 'Input' in table ? table.Input : null
87 const Svg = surface !== 'terminal' && 'Svg' in table ? table.Svg : null
88
89 return { Box: table.Box, Text: table.Text, Button: table.Button, Input, Svg, Link: table.Link, surface, columns }
90}
91hooks/format.ts 112 lines1import { t } from './i18n'
2
3const MINUTE = 60_000
4const HOUR = 60 * MINUTE
5const DAY = 24 * HOUR
6
7/**
8 * How long ago `then` was, as the band and the panel say it.
9 *
10 * @param then milliseconds since the epoch
11 * @param now milliseconds since the epoch
12 * @returns `just now`, `4 min ago`, `2 h ago`, `3 days ago` (or German)
13 */
14export function agoOf(then: number, now: number): string {
15 const age = Math.max(0, now - then)
16
17 if (age < MINUTE) {
18 return t().justNow
19 }
20
21 if (age < HOUR) {
22 return t().minutesAgo(Math.floor(age / MINUTE))
23 }
24
25 if (age < DAY) {
26 return t().hoursAgo(Math.floor(age / HOUR))
27 }
28
29 return t().daysAgo(Math.floor(age / DAY))
30}
31
32/**
33 * Text with control characters (escape sequences, tabs, newlines) turned
34 * into spaces, so a branch or file name can never restyle the terminal.
35 */
36export function clean(text: string): string {
37 // eslint-disable-next-line no-control-regex
38 return text.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
39}
40
41/**
42 * Text cut to `width` cells from its start, `…` standing for what was cut,
43 * so the end of a path (the file's name) stays visible.
44 */
45export function cutStart(text: string, width: number): string {
46 const chars = [...text]
47
48 if (chars.length <= width) {
49 return text
50 }
51
52 return width <= 1 ? '…' : `…${chars.slice(chars.length - (width - 1)).join('')}`
53}
54
55/**
56 * Text cut to `width` cells from its end, `…` standing for what was cut.
57 */
58export function cutEnd(text: string, width: number): string {
59 const chars = [...text]
60
61 if (chars.length <= width) {
62 return text
63 }
64
65 return width <= 1 ? '…' : `${chars.slice(0, width - 1).join('')}…`
66}
67
68/**
69 * The first line of git's message worth showing: `error:`, `fatal:` and
70 * `hint:` prefixes dropped, blank and progress lines skipped.
71 */
72export function firstLineOf(text: string): string {
73 const lines = text
74 .split(/\r?\n|\r/)
75 .map(line => line.replace(/^(error|fatal|hint|warning):\s*/i, '').trim())
76 .filter(line => line !== '' && !/^(remote: )?(Counting|Compressing|Enumerating|Receiving|Resolving|Writing|Total) /.test(line))
77
78 return clean(lines[0] ?? '')
79}
80
81/**
82 * A remote's URL fit to show: user names and tokens dropped, the scheme
83 * and `.git` left off (`github.com/owner/repo`).
84 */
85export function remoteShownOf(url: string): string {
86 const trimmed = url.trim()
87
88 const scp = /^[^@/\s]+@([^:/\s]+):(.+)$/.exec(trimmed)
89
90 const shown = scp
91 ? `${scp[1]}/${scp[2]}`
92 : trimmed.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/^[^@/]*@/, '')
93
94 return clean(shown.replace(/\.git$/, '').replace(/\/+$/, ''))
95}
96
97/**
98 * A worktree's path fit to show: the last two segments under `…/`.
99 */
100export function shortPathOf(path: string): string {
101 const parts = path.split(/[\\/]/).filter(part => part !== '')
102
103 return parts.length <= 2 ? path : `…/${parts.slice(-2).join('/')}`
104}
105
106/**
107 * `1 Commit`, `3 Commits`, and so on for any word whose plural adds `s`.
108 */
109export function plural(count: number, one: string, many: string): string {
110 return `${count} ${count === 1 ? one : many}`
111}
112