SLOPSHOPPER

source-control

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…

newpanebandguardcommandtoast
★ 2v0.7.1MITupdated 2026-10-05manuel-will/cc-source-control/source-control
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · source-control
│ ┃ source-control ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Source Control ↻ Fetch ↓ Pull ↑ Push ⏺ Read(src/auth.ts) │ ┃ No Git repository in this folder. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /git │ ⎿ source-control: No Git repository in this folder. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · source-control
Source Control ↻ Fetch ↓ Pull ↑ Push No Git repository in this folder.
README

Source Control for Claude Code

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.

30-second demo

The band above the chat box: always there, minimal:

The band in Claude Desktop: main, 2 to pull, 1 to push, and the Sync button

⎇ 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 files
  • 4 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.
  • The main button does what is due now, as VS Code's does. In the desktop app it is the app's own primary button, in the terminal VS Code's blue:
StateButton
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 progressResolve… / 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
  • Commit: type a message and press Enter, or use the main button. With nothing staged, all changes are committed (smart commit, as in VS Code).
  • Stage and unstage each file with + and −, or all at once.
  • History as VS Code's graph draws it: incoming commits purple, outgoing blue, pills for 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.
  • Branches with ↓/↑, local only or deleted on remote. Also worktrees (Claude's included) with their changes, and stashes.
  • Conflicts have a section of their own. Resolve a file and stage it with +. Or hand the work to Claude with “Ask Claude to resolve”; “Abort merge” restores the previous state.
  • If a pull would overwrite local changes, the panel offers “Pull with autostash”.

Installation

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.

Keyboard and commands

WhatHow
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 promptEsc
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

Settings (/config)

OptionDefaultMeaning
Fetch interval (minutes)10background git fetch, so ↓ is right. 0 = off
Status poll (seconds)30local git status (no network). Claude's commands refresh at once
Stale after (hours)12when the last fetch is marked yellow
Languageautoauto follows Claude Code's language; en or de sets it

Good to know

  • Language: English or German. With 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.
  • Background fetch: when a session opens and every 10 minutes after, never asking for a password (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 lock conflicts: every read runs with --no-optional-locks.
  • Only non-destructive actions: no force push, no reset, no discarding of changes. Pull uses --no-rebase (a merge, as VS Code does) unless you configured pull.rebase yourself.
  • Repository hooks do not run when you commit or push from the mod: Claude Code runs Git for mods with hooks turned off. If the repository has hooks (Husky, for example), the panel says so. If you need them, let Claude commit.
  • Credentials in remote URLs are never shown.
  • Not possible: anything in the desktop app's left project sidebar. That belongs to the app; mods cannot draw there. The band and the panel take its place.

License

MIT, see LICENSE.

Development

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
  • Try it locally: 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.
  • Playground with every state: 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.
  • Checks: `` 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.
Source 15 files
hooks/register.tsx 877 lines
1/* @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}
877
hooks/actions.ts 390 lines
1import 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}
390
hooks/i18n.ts 403 lines
1/**
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}
403
hooks/git.ts 102 lines
1import 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}
102
hooks/parse.ts 310 lines
1import 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}
310
hooks/snapshot.ts 233 lines
1import 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}
233
hooks/ui/band.tsx 183 lines
1/* @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}
183
hooks/ui/handlers.ts 23 lines
1import 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}
23
hooks/ui/model.ts 176 lines
1import 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}
176
hooks/ui/pane.tsx 744 lines
1/* @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}
744
hooks/ui/theme.ts 91 lines
1import 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}
91
hooks/format.ts 112 lines
1import { 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