SLOPSHOPPER

git-changes

Source Control and Run and Debug pane for every folder in the project's .code-workspace

newpanebandguardcommandtoast
v0.4.1no licenseupdated 2026-10-23Donwaztok/claude-mods/plugins/git-changes
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-changes
│ ┃ Source Control ✕ › fix the failing auth test and add an audit log call │ ┃ SOURCE CONTROL 0 │ ┃ No folders found (no .code-workspace or git ⏺ Read(src/auth.ts) │ ┃ repo here). ⎿ Read 6 lines │ ┃ ───────────────────────────────────────────… ⏺ Update(src/auth.ts) │ ┃ ▶ run-pick ⎿ 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-changes │ ┃ ⎿ git-changes: Source Control pane opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ⟨Claude Code's own drawing⟩ ⎇ Source Control ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ⎇ Source Control
Pane · Source Control
SOURCE CONTROL 0 No folders found (no .code-workspace or git repo here). ──────────────────────────────────────────────────────── ▶ run-pick
README

claude-mods

Mods para o Claude Code.

git-changes

Painel Source Control no estilo do VS Code. Lê os folders do .code-workspace da pasta onde o Claude abriu (sem ele, usa o próprio repositório ou os repositórios das subpastas) e mostra, por projeto, a branch, ↑/↓ em relação ao remoto, e os grupos Staged Changes e Changes com a letra de status nas cores do VS Code. Clicar num arquivo mostra o diff do arquivo inteiro; ← Back (ou b) volta.

Painel git-changes

Diff de um arquivo

  • Em cada arquivo: +/− faz stage/unstage, ↶ descarta as mudanças (arquivo novo é apagado), sempre pedindo confirmação, e ⋯ abre um menu com Reveal in File Explorer.
  • Abre sozinho a partir de 144 colunas, ou com /git-changes.
  • Atualiza a cada 4 s e depois de cada ferramenta que o Claude roda.
  • Com o painel aberto, a faixa de plugins acima do prompt (a do ponytail, por exemplo) vai para o rodapé do painel e libera espaço. Os botões de outros plugins viram texto; Review e Audit do ponytail continuam clicáveis. Board, Details e os níveis ficam na faixa original, que volta ao fechar o painel.
  • Abaixo da faixa de plugins fica Run and Debug, com o que o VS Code roda: as launch.configurations e compounds e as tasks do .code-workspace e do .vscode/launch.json/.vscode/tasks.json de cada pasta, e os scripts do package.json de cada pasta (rodados com npm, pnpm, yarn ou bun conforme o lock file). É uma linha só, como no VS Code: clicar no nome abre a lista para cima e escolhe o que rodar, ▶ roda, ■ para, e Output mostra a saída (← Back volta). Um +N diz quantos outros estão rodando. Roda configurações node (runtimeExecutable/runtimeArgs ou program/args) e node-terminal, tasks shell, process e npm (com dependsOn), com cwd, env, ${workspaceFolder}, ${workspaceFolder:nome} e ${env:X}; os outros tipos aparecem apagados. Roda sem debugger, e os processos morrem quando o Claude fecha ou o plugin recarrega.
  • Fechado no ✕, a faixa volta com um botão ⎇ Source Control para reabrir.
  • Para pegar a faixa de outro plugin, o git-changes precisa vir antes dele em enabledPlugins no ~/.claude/settings.json (os plugins são chamados nessa ordem): ``json "enabledPlugins": { "git-changes@donwaztok": true, "ponytail@ponytail": true } ``

usage-line

Barras animadas de uso ao lado da dica do prompt: modelo, contexto, limites de 5h/7d e custo da sessão. Aparece na última linha dos prints acima.

Instalar

No prompt do Claude Code:

/plugin install git-changes --marketplace Donwaztok/claude-mods
/plugin install usage-line --marketplace Donwaztok/claude-mods

Responda y para adicionar o marketplace e escolha o escopo de usuário (vale para todas as sessões).

Pelo terminal:

claude plugin install git-changes --marketplace Donwaztok/claude-mods
claude plugin install usage-line --marketplace Donwaztok/claude-mods

No app desktop, onde /plugin não roda: clique em + ao lado da caixa do prompt, depois Plugins, Add marketplace com Donwaztok/claude-mods, depois Add plugin.

Desenvolver

claude plugin validate plugins/git-changes
claude plugin test plugins/git-changes
Source 2 files
hooks/register.tsx 884 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Change, DiffView, Launch, Repo } from '../types'
5
6const PANE = 'git-changes'
7const TITLE = 'Source Control'
8const repos = atom({ plugin: 'git-changes', key: 'repos' } as const, [])
9const collapsed = atom({ plugin: 'git-changes', key: 'collapsed' } as const, [])
10const diff = atom({ plugin: 'git-changes', key: 'diff' } as const, null)
11const launches = atom({ plugin: 'git-changes', key: 'launches' } as const, [])
12const selected = atom({ plugin: 'git-changes', key: 'selected' } as const, null)
13
14// While the pane is drawn, the band above the prompt (as the other plugins drew it) shows inside it.
15// Module variables, not $.state: a tree holds the other plugins' press handles, and a reload redraws both sites.
16let isShown = false
17let band: any = null
18let bandKey = ''
19
20// VS Code's gitDecoration colors
21const COLORS: Record<string, string> = {
22  M: '#e2c08d',
23  A: '#81b88b',
24  U: '#73c991',
25  D: '#c74e39',
26  R: '#73c991',
27  C: '#e4676b',
28  T: '#e2c08d',
29}
30
31// A file row's text past its width is cut, its last letters fading out.
32// shortcut: fades toward a dark background, as the engine does not say the theme; a light theme gets a dark tail.
33const BG = [0x1e, 0x1e, 0x1e]
34const TEXT = '#cccccc'
35// the dimmed path's color, where its fade starts
36const DIM = '#8c8c8c'
37const FADE = 4
38
39export type Span = { text: string; color: string; dim?: boolean; strike?: boolean }
40
41const mix = (hex: string, k: number) =>
42  '#' +
43  [1, 3, 5]
44    .map((at, j) => Math.round(parseInt(hex.slice(at, at + 2), 16) * (1 - k) + BG[j]! * k))
45    .map(n => n.toString(16).padStart(2, '0'))
46    .join('')
47
48export function fadeOut(spans: Span[], width: number): Span[] {
49  const total = spans.reduce((n, s) => n + [...s.text].length, 0)
50  if (total <= width) return spans
51  const steps = Math.min(FADE, Math.max(0, width))
52  let left = width - steps
53  const head: Span[] = []
54  const tail: Span[] = []
55  for (const s of spans) {
56    const chars = [...s.text]
57    const kept = chars.slice(0, Math.max(0, left))
58    if (kept.length) head.push({ ...s, text: kept.join('') })
59    left -= kept.length
60    for (const ch of chars.slice(kept.length)) if (tail.length < steps) tail.push({ ...s, text: ch })
61  }
62  return [...head, ...tail.map((s, i) => ({ ...s, dim: false, color: mix(s.color, (i + 1) / (steps + 1)) }))]
63}
64
65// .code-workspace is JSONC: drop comments and trailing commas outside strings.
66export function parseJsonc(text: string): any {
67  let out = ''
68  for (let i = 0; i < text.length; i++) {
69    const c = text[i]
70    if (c === '"') {
71      let j = i + 1
72      while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1
73      out += text.slice(i, j + 1)
74      i = j
75    } else if (c === '/' && text[i + 1] === '/') {
76      while (i < text.length && text[i] !== '\n') i++
77      out += '\n'
78    } else if (c === '/' && text[i + 1] === '*') {
79      i = text.indexOf('*/', i + 2)
80      if (i < 0) break
81      i++
82    } else out += c
83  }
84  return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'))
85}
86
87export function join(base: string, rel: string): string {
88  const parts = (rel.startsWith('/') ? rel : `${base}/${rel}`).split('/')
89  const out: string[] = []
90  for (const p of parts) {
91    if (p === '' || p === '.') continue
92    if (p === '..') out.pop()
93    else out.push(p)
94  }
95  return '/' + out.join('/')
96}
97
98const basename = (p: string) => p.split('/').filter(Boolean).pop() ?? p
99
100// `git status --porcelain=v1 -b -z` → branch info and VS Code's two groups.
101export function parseStatus(out: string) {
102  const entries = out.split('\0')
103  let branch = ''
104  let ahead = 0
105  let behind = 0
106  const staged: Change[] = []
107  const changes: Change[] = []
108  for (let i = 0; i < entries.length; i++) {
109    const e = entries[i]
110    if (!e) continue
111    if (e.startsWith('## ')) {
112      const head = e.slice(3)
113      branch = head.startsWith('No commits yet on ')
114        ? head.slice(18)
115        : head.split(/\.\.\.| /)[0] ?? ''
116      ahead = Number(/ahead (\d+)/.exec(head)?.[1] ?? 0)
117      behind = Number(/behind (\d+)/.exec(head)?.[1] ?? 0)
118      continue
119    }
120    const x = e.charAt(0)
121    const y = e.charAt(1)
122    const path = e.slice(3)
123    // the original path follows a rename
124    const orig = x === 'R' || x === 'C' ? entries[++i] : undefined
125    if (x === '!') continue
126    if (x === '?') changes.push({ path, code: 'U' })
127    else if (x === 'U' || y === 'U' || (x === y && (x === 'A' || x === 'D')))
128      changes.push({ path, code: 'C' })
129    else {
130      if (x !== ' ') staged.push(orig ? { path, code: x, orig } : { path, code: x })
131      if (y !== ' ') changes.push({ path, code: y })
132    }
133  }
134  return { branch, ahead, behind, staged, changes }
135}
136
137type Folder = { name: string; path: string }
138
139// The folders, and the workspace file itself when there is one (its `launch` and `tasks` blocks).
140async function findFolders($: any): Promise<{ folders: Folder[]; ws?: { name: string; json: any } }> {
141  const cwd: string = await $.session.cwd()
142  const entries = await $.fs.list(cwd)
143  const ws = entries
144    .filter((f: any) => f.name.endsWith('.code-workspace'))
145    .sort((a: any, b: any) => a.name.localeCompare(b.name))[0]
146  if (ws) {
147    const json = parseJsonc(await $.fs.read(`${cwd}/${ws.name}`))
148    const folders = (json.folders ?? []).map((f: any) => {
149      const path = join(cwd, f.path ?? '.')
150      return { name: f.name ?? basename(path), path }
151    })
152    return { folders, ws: { name: ws.name.replace(/\.code-workspace$/, ''), json } }
153  }
154  return { folders: await findRepos($, cwd, entries) }
155}
156
157async function findRepos($: any, cwd: string, entries: any[]): Promise<Folder[]> {
158  if (entries.some((f: any) => f.name === '.git')) return [{ name: basename(cwd), path: cwd }]
159  // No workspace file and not a repo: every child repo, like opening the parent folder.
160  const kids = entries.filter((f: any) => f.kind === 'directory')
161  const repos = await Promise.all(
162    kids.map(async (f: any) => ((await $.fs.exists(`${cwd}/${f.name}/.git`)) ? f.name : null)),
163  )
164  return repos.filter(Boolean).map((n: string) => ({ name: n, path: join(cwd, n) }))
165}
166
167// One git pass at a time; callers during a pass wait for that pass.
168let inFlight: Promise<void> | null = null
169const refresh = ($: any) =>
170  (inFlight ??= scan($)
171    .catch(err => $.ui.log(`git-changes: ${err}`))
172    .finally(() => (inFlight = null)))
173
174// ${workspaceFolder} is the file's folder (a workspace file's: its first folder); ${env:X} waits for the run.
175const vars = (base: string, folders: Folder[]) => (s: unknown) =>
176  String(s)
177    .replace(/\$\{workspaceFolder(?::([^}]+))?\}/g, (m, n) => (n ? folders.find(f => f.name === n)?.path ?? m : base))
178    .replace(/\$\{workspaceFolderBasename\}/g, basename(base))
179
180// a null env value unsets the variable in VS Code; here it is just left out
181const envOf = (env: any, sub: (s: unknown) => string) =>
182  Object.fromEntries(Object.entries(env ?? {}).filter(([, v]) => v != null).map(([k, v]) => [k, sub(v)]))
183
184// launch.json: Run and Debug's configurations and compounds.
185export function toLaunches(launch: any, source: string, base: string, folders: Folder[]): Launch[] {
186  const sub = vars(base, folders)
187  const configs: Launch[] = (launch?.configurations ?? []).map((c: any) => {
188    const key = `${source}:${c.name}`
189    const args = [c.runtimeArgs ?? [], c.program ?? [], c.args ?? []].flat().map(sub)
190    const argv =
191      c.type === 'node-terminal' && c.command
192        ? ['sh', '-c', sub(c.command)]
193        : /^(pwa-)?node$/.test(c.type) && c.request === 'launch' && (c.runtimeExecutable || c.program)
194          ? [sub(c.runtimeExecutable ?? 'node'), ...args]
195          : undefined
196    if (!argv) return { key, name: c.name, source, kind: 'launch', type: c.type }
197    return { key, name: c.name, source, kind: 'launch', argv, cwd: sub(c.cwd ?? base), env: envOf(c.env, sub) }
198  })
199  const compounds: Launch[] = (launch?.compounds ?? []).map((c: any) => ({
200    key: `${source}:${c.name}`,
201    name: c.name,
202    source,
203    kind: 'launch',
204    parts: (c.configurations ?? []).map((p: any) => (typeof p === 'string' ? `${source}:${p}` : `${p.folder}:${p.name}`)),
205  }))
206  return [...configs, ...compounds]
207}
208
209// A shell task's args are quoted as VS Code does for an arg holding spaces.
210const quote = (a: string) => (/\s/.test(a) ? `'${a.replaceAll("'", `'\\''`)}'` : a)
211
212// tasks.json: shell, process and npm tasks; dependsOn starts the named tasks beside it.
213// shortcut: no per-OS blocks, problem matchers or dependsOrder "sequence"; add them when a task needs them.
214export function toTasks(tasks: any, source: string, base: string, folders: Folder[], pm = 'npm'): Launch[] {
215  const sub = vars(base, folders)
216  return (tasks?.tasks ?? []).map((t: any) => {
217    const name: string = t.label ?? (t.script ? `npm: ${t.script}` : String(t.command))
218    const key = `${source}:task:${name}`
219    const parts = [t.dependsOn ?? []].flat().map((d: any) => `${source}:task:${typeof d === 'string' ? d : d.label}`)
220    const args = (t.args ?? []).map((a: any) => sub(typeof a === 'string' ? a : a.value))
221    const argv =
222      t.type === 'npm' && t.script
223        ? [pm, 'run', t.script]
224        : t.command && t.type === 'process'
225          ? [sub(t.command), ...args]
226          : t.command
227            ? ['sh', '-c', [sub(t.command), ...args.map(quote)].join(' ')]
228            : undefined
229    const cwd = sub(t.options?.cwd ?? (t.type === 'npm' && t.path ? `${base}/${t.path}` : base))
230    return {
231      key,
232      name,
233      source,
234      kind: 'task',
235      ...(argv ? { argv, cwd, env: envOf(t.options?.env, sub) } : !parts.length && { type: t.type }),
236      ...(parts.length && { parts }),
237    }
238  })
239}
240
241// package.json scripts, run by the package manager its lock file names, as VS Code's NPM Scripts view.
242export function toScripts(pkg: any, source: string, base: string, pm: string): Launch[] {
243  return Object.keys(pkg?.scripts ?? {}).map(name => ({
244    key: `${source}:npm:${name}`,
245    name,
246    source,
247    kind: 'npm',
248    argv: [pm, 'run', name],
249    cwd: base,
250  }))
251}
252
253const packageManager = (names: Set<string>) =>
254  names.has('pnpm-lock.yaml') ? 'pnpm' : names.has('yarn.lock') ? 'yarn' : names.has('bun.lock') || names.has('bun.lockb') ? 'bun' : 'npm'
255
256async function findLaunches($: any, found: Folder[], ws?: { name: string; json: any }): Promise<Launch[]> {
257  const cwd: string = await $.session.cwd()
258  // A folder that is not a repo still runs, as VS Code opens any folder.
259  const folders = found.length ? found : [{ name: basename(cwd), path: cwd }]
260  const names = async (dir: string) => new Set<string>((await $.fs.list(dir).catch(() => [])).map((f: any) => f.name))
261  // A broken file hides its own entries, not the pane.
262  const json = async (file: string) => {
263    try {
264      return parseJsonc(await $.fs.read(file))
265    } catch (err) {
266      $.ui.log(`git-changes: ${file}: ${err}`, { to: 'debug' })
267    }
268  }
269  const own = await Promise.all(
270    folders.map(async f => {
271      const here = await names(f.path)
272      const vscode = here.has('.vscode') ? await names(`${f.path}/.vscode`) : new Set()
273      const pm = packageManager(here)
274      return {
275        launches: vscode.has('launch.json') ? toLaunches(await json(`${f.path}/.vscode/launch.json`), f.name, f.path, folders) : [],
276        tasks: vscode.has('tasks.json') ? toTasks(await json(`${f.path}/.vscode/tasks.json`), f.name, f.path, folders, pm) : [],
277        scripts: here.has('package.json') ? toScripts(await json(`${f.path}/package.json`), f.name, f.path, pm) : [],
278      }
279    }),
280  )
281  const first = folders[0]!.path
282  return [
283    ...(ws ? toLaunches(ws.json.launch, ws.name, first, folders) : []),
284    ...own.flatMap(o => o.launches),
285    ...(ws ? toTasks(ws.json.tasks, ws.name, first, folders) : []),
286    ...own.flatMap(o => o.tasks),
287    ...own.flatMap(o => o.scripts),
288  ]
289}
290
291async function scan($: any) {
292  {
293    const { folders, ws } = await findFolders($)
294    const nextLaunches = await findLaunches($, folders, ws)
295    if (JSON.stringify(await read($, launches)) !== JSON.stringify(nextLaunches)) await update($, launches, () => nextLaunches)
296    const next: Repo[] = await Promise.all(
297      folders.map(async f => {
298        const run = await $.process
299          .run(['git', 'status', '--porcelain=v1', '-b', '-z', '-uall'], { cwd: f.path })
300          .catch(() => ({ exitCode: 1, stdout: '' }))
301        if (run.exitCode !== 0)
302          return { ...f, branch: '', ahead: 0, behind: 0, isRepo: false, staged: [], changes: [] }
303        return { ...f, isRepo: true, ...parseStatus(run.stdout) }
304      }),
305    )
306    const prev = await read($, repos)
307    if (JSON.stringify(prev) !== JSON.stringify(next)) await update($, repos, () => next)
308  }
309}
310
311// Code draws at most 100000 characters, and a hunk cut midway does not parse.
312const MAX_DIFF = 95000
313
314async function openDiff($: any, repo: Repo, change: Change, isStaged: boolean) {
315  const gitDiff = async (context: string) => {
316    const argv = isStaged
317      ? ['git', 'diff', context, '--cached', '--', change.path]
318      : change.code === 'U'
319        ? ['git', 'diff', context, '--no-index', '--', '/dev/null', change.path]
320        : ['git', 'diff', context, '--', change.path]
321    const run = await $.process.run(argv, { cwd: repo.path })
322    const at = run.stdout.indexOf('@@')
323    // Drop the `diff --git` / `index` header: Code reads hunks only.
324    return { hunks: at < 0 ? '' : run.stdout.slice(at), raw: run.stdout || run.stderr }
325  }
326  // Whole file around the changes, as VS Code's inline diff; a huge file falls back to 3 lines.
327  let isFullFile = true
328  let out = await gitDiff('--unified=100000')
329  if (out.hunks.length > MAX_DIFF) {
330    isFullFile = false
331    out = await gitDiff('--unified=3')
332  }
333  const view: DiffView = {
334    repo: repo.name,
335    path: change.path,
336    isStaged,
337    isFullFile,
338    text: out.hunks.slice(0, MAX_DIFF) || out.raw || '(no textual diff)',
339  }
340  await update($, diff, () => view)
341}
342
343const openPane = ($: any) => $.ui.open({ id: PANE, title: TITLE, columns: 48 })
344
345// The engine draws another plugin's Buttons only in the site that drew them, so in the pane
346// they become their text, and the ones ponytail runs as commands become this plugin's own.
347// shortcut: only ponytail's Review and Audit stay pressable; add keys here when a band adds commands.
348const BAND_KEYS = ['review', 'audit']
349
350const bandAction = ($: any, key: string) => $.command.run({ command: `ponytail:ponytail-${key}`, args: '' })
351
352export function unbutton(node: any, keys: string[]): any {
353  if (!node || typeof node !== 'object') return node
354  // The engine's own part of the band stays out of the pane.
355  if (node.type === 'engine') return null
356  if (node.type === 'Button') {
357    if (BAND_KEYS.includes(node.props?.key)) keys.push(node.props.key)
358    return node.children?.length ? { type: 'Text', children: node.children } : null
359  }
360  // The band sized itself to its own width; the pane is narrower.
361  const { width, ...props } = node.props ?? {}
362  const children = node.children?.map((c: any) => unbutton(c, keys)).filter((c: any) => c !== null)
363  return { ...node, props, ...(children && { children }) }
364}
365
366// Runs a git action, toasts its failure, then rescans so the pane follows.
367async function git($: any, repo: Repo, args: string[], doing?: string) {
368  if (doing) $.ui.toast(`${repo.name}: ${doing}…`)
369  const run = await $.process
370    .run(['git', ...args], { cwd: repo.path, env: { GIT_TERMINAL_PROMPT: '0' } })
371    .catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: String(err) }))
372  if (run.exitCode !== 0) $.ui.toast(`${repo.name}: git ${args[0]} failed: ${(run.stderr || run.stdout).trim().split('\n').pop()}`)
373  else if (doing) $.ui.toast(`${repo.name}: ${args[0]} done`)
374  await refresh($)
375}
376
377// Push/pull/discard waiting for a yes, as VS Code asks first. Module variable: a reload drops the question.
378let pending: { repo: string; op: 'push' | 'pull' } | { repo: string; op: 'discard'; change: Change } | null = null
379const ask = ($: any, value: typeof pending) => {
380  pending = value
381  $.ui.invalidate('ui.render')
382}
383
384const stage = ($: any, repo: Repo, paths: string[]) => git($, repo, ['add', '-A', '--', ...paths])
385// reset, unlike restore --staged, also works before the first commit
386const unstage = ($: any, repo: Repo, items: Change[]) =>
387  git($, repo, ['reset', '-q', '--', ...items.flatMap(c => (c.orig ? [c.path, c.orig] : [c.path]))])
388// The working tree back to the index; an untracked file is deleted, as VS Code's Discard Changes.
389const discard = ($: any, repo: Repo, c: Change) =>
390  git($, repo, c.code === 'U' ? ['clean', '-fq', '--', c.path] : ['checkout', '-q', '--', c.path])
391
392// The file row whose ⋯ menu is open. Module variable, as pending.
393let menu: string | null = null
394const setMenu = ($: any, key: string | null) => {
395  menu = key
396  $.ui.invalidate('ui.render')
397}
398
399// The freedesktop file manager selects the file (Dolphin, Nautilus...); else xdg-open shows its folder; else macOS's open -R.
400async function reveal($: any, repo: Repo, c: Change) {
401  const file = join(repo.path, c.path)
402  const dir = file.slice(0, file.lastIndexOf('/')) || '/'
403  const shown = c.code === 'D' ? dir : file
404  // encoded per segment: dbus-send splits an array on commas
405  const uri = 'file://' + shown.split('/').map(encodeURIComponent).join('/')
406  for (const argv of [
407    ['dbus-send', '--session', '--print-reply', '--dest=org.freedesktop.FileManager1', '/org/freedesktop/FileManager1',
408      'org.freedesktop.FileManager1.ShowItems', `array:string:${uri}`, 'string:'],
409    ['xdg-open', dir],
410    ['open', '-R', shown],
411  ]) {
412    const run = await $.process.run(argv, { cwd: repo.path }).catch(() => null)
413    if (run?.exitCode === 0) return
414  }
415  $.ui.toast(`${repo.name}: no file manager found to show ${c.path}`)
416}
417
418// The children Run and Debug started, by launch key. Module variables: a reload ends them, as the engine kills a module's children.
419type Run = { stream: any; out: string; code?: number | null; signal?: string | null }
420const runs = new Map<string, Run>()
421// The launch whose output the pane shows.
422let output: string | null = null
423const showOutput = ($: any, key: string | null) => {
424  output = key
425  $.ui.invalidate('ui.render')
426}
427
428// Run and Debug's list, open above its line. Module variable, as menu.
429let isPicking = false
430const setPicking = ($: any, value: boolean) => {
431  isPicking = value
432  $.ui.invalidate('ui.render')
433}
434
435const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g
436const MAX_OUTPUT = 50000
437const isRunning = (key: string) => runs.has(key) && !('code' in runs.get(key)!)
438
439// ${env:X} from the host environment, which $.env reads only by literal name.
440async function hostEnv($: any, texts: string[]) {
441  const names = [...new Set(texts.flatMap(t => [...t.matchAll(/\$\{env:([^}]+)\}/g)].map(m => m[1]!)))]
442  const values = await Promise.all(
443    names.map(n => $.process.run(['printenv', n]).then((r: any) => r.stdout.replace(/\n$/, ''), () => '')),
444  )
445  return (t: string) => t.replace(/\$\{env:([^}]+)\}/g, (_, n) => values[names.indexOf(n)] ?? '')
446}
447
448async function start($: any, l: Launch, all: Launch[]) {
449  // a compound, or a task's dependsOn
450  for (const k of l.parts ?? []) {
451    const part = all.find(x => x.key === k)
452    if (part?.argv || part?.parts) void start($, part, all)
453    else $.ui.toast(`${l.name}: nothing runnable named ${k.slice(k.indexOf(':') + 1)}`)
454  }
455  if (!l.argv || isRunning(l.key)) return
456  const env = l.env ?? {}
457  const sub = await hostEnv($, [...l.argv, l.cwd ?? '', ...Object.values(env)])
458  const run: Run = {
459    stream: $.process.spawn({
460      argv: l.argv.map(sub),
461      cwd: l.cwd && sub(l.cwd),
462      env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, sub(v)])),
463    }),
464    out: `$ ${l.argv.join(' ')}\n`,
465  }
466  runs.set(l.key, run)
467  $.ui.invalidate('ui.render')
468  try {
469    // read by hand: the loop's return value is { code, signal }, which stream.result can miss
470    let step
471    while (!(step = await run.stream.next()).done) {
472      run.out = (run.out + step.value.text.replace(ANSI, '')).slice(-MAX_OUTPUT)
473      if (output === l.key) $.ui.invalidate('ui.render')
474    }
475    Object.assign(run, step.value)
476  } catch (err) {
477    // stopped (the stream closed early) or never started
478    run.out += `\n${err}\n`
479    run.code ??= null
480    run.signal ??= 'stopped'
481  }
482  // always ended, so ▶ can start it again
483  run.code ??= null
484  run.out += `\n[exited${run.signal ? ` by ${run.signal}` : ` with ${run.code}`}]\n`
485  if (run.code) $.ui.toast(`${l.name}: exited with ${run.code}`)
486  $.ui.invalidate('ui.render')
487}
488
489// Closing the stream kills the child.
490const stop = (key: string) => void runs.get(key)?.stream.return(undefined)
491
492const toggle = ($: any, key: string) =>
493  update($, collapsed, list => (list.includes(key) ? list.filter(k => k !== key) : [...list, key]))
494
495export const register: Register = on => {
496  on('session.start', async ($, e, next) => {
497    await $.command.register({
498      name: 'git-changes',
499      description: 'Show the Source Control pane (git changes of the workspace folders)',
500    })
501    void refresh($)
502    $.clock.every(4000, () => void refresh($))
503    void openPane($)
504    return next(e)
505  })
506
507  on('command.run', { command: 'git-changes' }, async $ => {
508    await refresh($)
509    await openPane($)
510    return { text: 'Source Control pane opened.' }
511  })
512
513  // Claude's own edits show up without waiting for the timer.
514  on('tool.call', async ($, e, next) => {
515    const ran = await next(e)
516    void refresh($)
517    return ran
518  })
519
520  on('ui.close', async ($, e, next) => {
521    const closed = await next(e)
522    if (e.id === PANE) {
523      isShown = false
524      $.ui.invalidate('ui.render')
525    }
526    return closed
527  })
528
529  // Pane open: the band's rows move into it, so the prompt gets the space. Closed: the band plus a reopen button.
530  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
531    if (e.props.hasSurvey) return next(e)
532    const below = await next(e).catch(() => undefined)
533    const { Box, Button } = $.ui.resolve(e)
534    if (isShown) {
535      const key = JSON.stringify(below ?? null)
536      if (key !== bandKey) {
537        band = below ?? null
538        bandKey = key
539        $.ui.invalidate('ui.render')
540      }
541      return <Box />
542    }
543    const reopen = (
544      <Box flexDirection="row" justifyContent="flex-end">
545        <Button key="git-changes-open" plain onPress={() => void openPane($)}>
546          ⎇ Source Control
547        </Button>
548      </Box>
549    )
550    return below ? <Box flexDirection="column">{below}{reopen}</Box> : reopen
551  })
552
553  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
554    const { Box, Text, Button, Code } = $.ui.resolve(e)
555    if (!isShown) {
556      // Drawn means placed: now the band can move in here.
557      isShown = true
558      $.ui.invalidate('ui.render')
559    }
560
561    const rule = (
562      <Text dimColor wrap="truncate-end">
563        {'─'.repeat(e.props.bodyColumns)}
564      </Text>
565    )
566    const configs = await read($, launches)
567    const closed = await read($, collapsed)
568    const pick = await read($, selected)
569    const picked = configs.find(l => l.key === pick) ?? configs.find(l => l.argv || l.parts) ?? configs[0]
570
571    const tagOf = (l: Launch) =>
572      !(l.argv || l.parts) ? `${l.type ?? '?'} (unsupported)` : l.kind === 'launch' ? l.source : `${l.kind} · ${l.source}`
573    const statusOf = (key: string) => {
574      const run = runs.get(key)
575      if (isRunning(key)) return <Text color="#89d185">●</Text>
576      return run && run.code !== 0 ? <Text color="#f14c4c">{run.signal ?? run.code}</Text> : null
577    }
578
579    // Run and Debug as VS Code's: one line, ▶ / ■ for the picked entry, its name opening the list above it.
580    const runSection = () => {
581      if (!picked) return <Text dimColor>Nothing to run</Text>
582      const isLive = isRunning(picked.key)
583      const isRunnable = !!(picked.argv || picked.parts)
584      // the others running, out of sight while the list is closed
585      const others = [...runs.keys()].filter(k => isRunning(k) && k !== picked.key).length
586      return (
587        <Box flexDirection="column">
588          {isPicking &&
589            configs.map(l => (
590              <Box key={`pick-row:${l.key}`} flexDirection="row" justifyContent="space-between">
591                <Button
592                  plain
593                  key={`pick:${l.key}`}
594                  onPress={() => {
595                    setPicking($, false)
596                    void update($, selected, () => l.key)
597                  }}
598                >
599                  <Text bold={l.key === picked.key} dimColor={!(l.argv || l.parts)} wrap="truncate-end">
600                    {l.key === picked.key ? '› ' : '  '}
601                    {l.name}
602                  </Text>
603                </Button>
604                <Box flexDirection="row" gap={1} flexShrink={0}>
605                  {statusOf(l.key)}
606                  <Text dimColor>{tagOf(l)}</Text>
607                </Box>
608              </Box>
609            ))}
610          <Box flexDirection="row" justifyContent="space-between">
611            <Box flexDirection="row" gap={1} flexShrink={1}>
612              {isRunnable ? (
613                <Button
614                  plain
615                  key={`${isLive ? 'stop' : 'run'}:${picked.key}`}
616                  label={isLive ? '■' : '▶'}
617                  onPress={() => (isLive ? stop(picked.key) : void start($, picked, configs))}
618                />
619              ) : (
620                <Text dimColor>·</Text>
621              )}
622              <Button plain key="run-pick" onPress={() => setPicking($, !isPicking)}>
623                <Text bold wrap="truncate-end">
624                  {picked.name}
625                </Text>{' '}
626                {isPicking ? '▴' : '▾'}
627              </Button>
628            </Box>
629            <Box flexDirection="row" gap={1} flexShrink={0}>
630              {statusOf(picked.key)}
631              {runs.has(picked.key) && (
632                <Button plain key={`output:${picked.key}`} label="Output" onPress={() => showOutput($, picked.key)} />
633              )}
634              {others > 0 && <Text dimColor>+{others}</Text>}
635            </Box>
636          </Box>
637        </Box>
638      )
639    }
640
641    // The band, then Run and Debug, at the pane's bottom edge.
642    const withBand = (body: any) => {
643      const keys: string[] = []
644      const text = band && unbutton(band, keys)
645      return (
646        <Box flexDirection="column" minHeight={e.props.scroll.bodyRows}>
647          <Box flexDirection="column" flexGrow={1}>
648            {body}
649          </Box>
650          {band && rule}
651          {band && (
652            <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
653              {text}
654              {keys.map(k => (
655                <Button key={`band:${k}`} plain onPress={() => void bandAction($, k)}>
656                  {k[0]!.toUpperCase() + k.slice(1)}
657                </Button>
658              ))}
659            </Box>
660          )}
661          {rule}
662          {runSection()}
663        </Box>
664      )
665    }
666
667    const shown = output ? configs.find(l => l.key === output) : undefined
668    const run = output ? runs.get(output) : undefined
669    if (shown && run) {
670      const isLive = isRunning(shown.key)
671      return withBand(
672        <Box flexDirection="column">
673          <Box flexDirection="row" gap={1}>
674            <Button key="back" hotkey="b" onPress={() => showOutput($, null)}>
675              ← Back
676            </Button>
677            <Text bold wrap="truncate-end">
678              {shown.name}
679            </Text>
680            <Text dimColor>{isLive ? '(running)' : '(exited)'}</Text>
681          </Box>
682          <Code source={run.out.split('\n').slice(-e.props.scroll.bodyRows).join('\n')} format="source" wrap="truncate-end" />
683        </Box>,
684      )
685    }
686
687    const view = await read($, diff)
688
689    if (view) {
690      return withBand(
691        <Box flexDirection="column">
692          <Box flexDirection="row" gap={1}>
693            <Button key="back" hotkey="b" onPress={() => void update($, diff, () => null)}>
694              ← Back
695            </Button>
696            <Text bold wrap="truncate-start">
697              {basename(view.path)}
698            </Text>
699            <Text dimColor>{view.isStaged ? '(Index)' : '(Working Tree)'}</Text>
700          </Box>
701          <Text dimColor wrap="truncate-start">
702            {view.repo}/{view.path}
703          </Text>
704          {!view.isFullFile && <Text dimColor>Large file: showing changed hunks only.</Text>}
705          <Code source={view.text} path={view.path} format={view.text.startsWith('@@') ? 'diff' : 'source'} wrap="truncate-end" />
706        </Box>,
707      )
708    }
709
710    const list = await read($, repos)
711    const total = list.reduce((n, r) => n + r.staged.length + r.changes.length, 0)
712
713    const fileRow = (repo: Repo, c: Change, isStaged: boolean) => {
714      const dir = c.path.includes('/') ? c.path.slice(0, c.path.lastIndexOf('/')) : ''
715      const isDeleted = c.code === 'D'
716      const rowKey = `${repo.name}:${isStaged ? 's' : 'w'}:${c.path}`
717      // one line: the indent, the name, a space, then ⋯ ↶ + M (staged: ⋯ − M)
718      const width = e.props.bodyColumns - 4 - 1 - (isStaged ? 5 : 7)
719      const label = fadeOut(
720        [
721          { text: basename(c.path), color: COLORS[c.code] ?? TEXT, strike: isDeleted },
722          ...(dir ? [{ text: ` ${dir}`, color: DIM, dim: true }] : []),
723        ],
724        width,
725      )
726      return (
727        <Box key={`row:${rowKey}`} flexDirection="column">
728          <Box flexDirection="row" justifyContent="space-between">
729            <Box flexDirection="row" flexShrink={1}>
730              <Text>{'    '}</Text>
731              <Button
732                plain
733                key={rowKey}
734                onPress={() => void openDiff($, repo, c, isStaged)}
735              >
736                <Text wrap="truncate-end">
737                  {label.map(l => (
738                    <Text color={l.dim ? undefined : l.color} dimColor={l.dim} strikethrough={l.strike}>
739                      {l.text}
740                    </Text>
741                  ))}
742                </Text>
743              </Button>
744            </Box>
745            <Box flexDirection="row" gap={1} flexShrink={0}>
746              <Button plain key={`menu:${rowKey}`} label="⋯" onPress={() => setMenu($, menu === rowKey ? null : rowKey)} />
747              {!isStaged && (
748                <Button
749                  plain
750                  key={`discard:${repo.name}:${c.path}`}
751                  label="↶"
752                  onPress={() => ask($, { repo: repo.name, op: 'discard', change: c })}
753                />
754              )}
755              <Button
756                plain
757                key={`${isStaged ? 'unstage' : 'stage'}:${repo.name}:${c.path}`}
758                label={isStaged ? '−' : '+'}
759                onPress={() => void (isStaged ? unstage($, repo, [c]) : stage($, repo, [c.path]))}
760              />
761              <Text color={COLORS[c.code]}>{c.code}</Text>
762            </Box>
763          </Box>
764          {menu === rowKey && (
765            <Box flexDirection="column">
766              <Box flexDirection="row">
767                <Text>{'      '}</Text>
768                <Button
769                  plain
770                  key={`reveal:${rowKey}`}
771                  label="› Reveal in File Explorer"
772                  onPress={() => {
773                    setMenu($, null)
774                    void reveal($, repo, c)
775                  }}
776                />
777              </Box>
778              <Box flexDirection="row">
779                <Text>{'      '}</Text>
780                <Button plain key={`menu-close:${rowKey}`} label="› Close" onPress={() => setMenu($, null)} />
781              </Box>
782            </Box>
783          )}
784        </Box>
785      )
786    }
787
788    const group = (repo: Repo, label: string, items: Change[], isStaged: boolean) => {
789      if (items.length === 0) return null
790      const key = `${repo.name}:${isStaged ? 'staged' : 'changes'}`
791      const isOpen = !closed.includes(key)
792      return (
793        <Box key={`g:${key}`} flexDirection="column">
794          <Box flexDirection="row" justifyContent="space-between">
795            <Box flexDirection="row">
796              <Text>{'  '}</Text>
797              <Button plain key={key} onPress={() => void toggle($, key)}>
798                {isOpen ? '▾' : '▸'} {label}
799              </Button>
800            </Box>
801            <Box flexDirection="row" gap={1}>
802              <Button
803                plain
804                key={`${isStaged ? 'unstage' : 'stage'}-all:${repo.name}`}
805                label={isStaged ? '−' : '+'}
806                onPress={() => void (isStaged ? git($, repo, ['reset', '-q']) : git($, repo, ['add', '-A']))}
807              />
808              <Text dimColor>{items.length}</Text>
809            </Box>
810          </Box>
811          {isOpen && items.map(c => fileRow(repo, c, isStaged))}
812        </Box>
813      )
814    }
815
816    return withBand(
817      <Box flexDirection="column">
818        <Box flexDirection="row" justifyContent="space-between">
819          <Text bold>SOURCE CONTROL</Text>
820          <Text dimColor>{total}</Text>
821        </Box>
822        {list.length === 0 && <Text dimColor>No folders found (no .code-workspace or git repo here).</Text>}
823        {list.map(repo => {
824          const isOpen = !closed.includes(repo.name)
825          const count = repo.staged.length + repo.changes.length
826          return (
827            <Box key={`repo:${repo.name}`} flexDirection="column">
828              <Box flexDirection="row" justifyContent="space-between">
829                <Button plain key={repo.name} onPress={() => void toggle($, repo.name)}>
830                  {isOpen ? '▾' : '▸'} <Text bold>{repo.name}</Text>{' '}
831                  <Text dimColor>
832                    {repo.isRepo ? ` ${repo.branch}` : 'not a git repo'}
833                  </Text>
834                </Button>
835                <Box flexDirection="row" gap={1}>
836                  {repo.behind > 0 && (
837                    <Button plain key={`pull:${repo.name}`} label={`↓${repo.behind} Pull`} onPress={() => ask($, { repo: repo.name, op: 'pull' })} />
838                  )}
839                  {repo.ahead > 0 && (
840                    <Button plain key={`push:${repo.name}`} label={`↑${repo.ahead} Push`} onPress={() => ask($, { repo: repo.name, op: 'push' })} />
841                  )}
842                  {count > 0 && <Text color="#3794ff">{count}</Text>}
843                </Box>
844              </Box>
845              {pending?.repo === repo.name && (
846                <Box flexDirection="row" gap={1}>
847                  <Text color="#3794ff">
848                    {'  '}
849                    {pending.op === 'discard'
850                      ? pending.change.code === 'U'
851                        ? `Delete untracked ${basename(pending.change.path)}?`
852                        : `Discard changes in ${basename(pending.change.path)}?`
853                      : pending.op === 'push'
854                        ? `Push ${repo.ahead} commit${repo.ahead === 1 ? '' : 's'} from ${repo.branch}?`
855                        : `Pull ${repo.behind} commit${repo.behind === 1 ? '' : 's'} into ${repo.branch}?`}
856                  </Text>
857                  <Button
858                    key={`confirm:${repo.name}`}
859                    hotkey="y"
860                    onPress={() => {
861                      const p = pending!
862                      ask($, null)
863                      if (p.op === 'discard') void discard($, repo, p.change)
864                      else void git($, repo, [p.op], p.op === 'push' ? 'pushing' : 'pulling')
865                    }}
866                  >
867                    Yes
868                  </Button>
869                  <Button key={`cancel:${repo.name}`} hotkey="n" onPress={() => ask($, null)}>
870                    No
871                  </Button>
872                </Box>
873              )}
874              {isOpen && repo.isRepo && count === 0 && <Text dimColor>{'    '}No changes</Text>}
875              {isOpen && group(repo, 'Staged Changes', repo.staged, true)}
876              {isOpen && group(repo, 'Changes', repo.changes, false)}
877            </Box>
878          )
879        })}
880      </Box>,
881    )
882  })
883}
884
types/index.d.ts 38 lines
1export type Change = { path: string; code: string; orig?: string }
2export type Repo = {
3  name: string
4  path: string
5  branch: string
6  ahead: number
7  behind: number
8  isRepo: boolean
9  staged: Change[]
10  changes: Change[]
11}
12// A launch configuration, task or npm script as Run and Debug lists it: a command, others to start, or a type this pane cannot run.
13export type Launch = {
14  key: string
15  name: string
16  source: string
17  kind: 'launch' | 'task' | 'npm'
18  type?: string
19  argv?: string[]
20  cwd?: string
21  env?: Record<string, string>
22  parts?: string[]
23}
24export type DiffView = { repo: string; path: string; isStaged: boolean; isFullFile: boolean; text: string }
25
26declare module 'claude-code' {
27  interface PluginState {
28    'git-changes': {
29      repos: Repo[]
30      collapsed: string[]
31      diff: DiffView | null
32      launches: Launch[]
33      // the Run and Debug entry ▶ starts
34      selected: string | null
35    }
36  }
37}
38