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

Mods para o Claude Code.
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.


/git-changes.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.enabledPlugins no ~/.claude/settings.json (os plugins são chamados nessa ordem): ``json "enabledPlugins": { "git-changes@donwaztok": true, "ponytail@ponytail": true } ``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.
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.
claude plugin validate plugins/git-changes
claude plugin test plugins/git-changeshooks/register.tsx 884 lines1import { 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}
884types/index.d.ts 38 lines1export 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