/** * git-worktree-gui — Claude plugin. * * A resident pane that shows the repository the session runs in: the commit * topology drawn bottom-up (one SVG drawing on the desktop, text rails on the * terminal), the worktrees grouped by whether * they are safe to delete, the dirty entries of the selected tree and its * file structure. A one-line summary stays in the status line while the pane * is closed. The git work is `../lib/core.js`, shared with the DSH plugin. * * Open or close the pane with `/git-worktrees`; it also opens by itself when * the session starts, wherever the surface has room for it. * * Read-only: git is spawned by argument vector through `$.process.run`. */ import { atom, read, update } from 'claude-code' import type { EngineInterface, Register } from 'claude-code' import { collectState } from '../lib/core.js' import { ago, agoIso, changeBadge, clip, fileRows, graphSvgs, isDirty, paneWidthPx, railRows } from '../lib/glyphs.js' const PANE = 'git-worktree' const COMMAND = 'git-worktrees' const POLL_MS = 5000 /** Tool calls change the tree; refresh after them, at most this often. */ const TOOL_REFRESH_MS = 3000 const GRAPH_STEP = 24 const GIT_TIMEOUT_MS = 8000 const snapshot = atom({ plugin: 'git-worktree-gui', key: 'snapshot' } as const, null) const selected = atom({ plugin: 'git-worktree-gui', key: 'selected' } as const, '') const failure = atom({ plugin: 'git-worktree-gui', key: 'error' } as const, '') const folded = atom({ plugin: 'git-worktree-gui', key: 'folded' } as const, []) const openDirs = atom({ plugin: 'git-worktree-gui', key: 'openDirs' } as const, []) const graphRows = atom({ plugin: 'git-worktree-gui', key: 'graphRows' } as const, GRAPH_STEP) /** What `lib/core.js` runs against, on top of the engine's own process and fs nouns. */ function gitEnv($: EngineInterface) { return { git: async (cwd: string, args: string[]) => { try { const done = await $.process.run(['git', '-C', cwd, '--no-optional-locks', ...args], { cwd, timeoutMs: GIT_TIMEOUT_MS, }) return { ok: done.exitCode === 0, stdout: done.stdout, stderr: done.stderr } } catch (error) { return { ok: false, stdout: '', stderr: String(error) } } }, real: (path: string) => path.replace(/\/+$/, '') || '/', exists: async (path: string) => { try { return await $.fs.exists(path) } catch { return false } }, } } const TONE: Record = { trunk: 'claude', branch: 'warning', current: 'success', none: undefined, } const GROUPS: { key: string; label: string; tone?: string }[] = [ { key: 'uncommitted', label: '有未提交改动', tone: 'warning' }, { key: 'unmerged', label: '未合并到主干', tone: 'warning' }, { key: 'unknown', label: '状态未知(先别删)', tone: 'error' }, { key: 'locked', label: '已锁定' }, { key: 'safe', label: '可安全删除', tone: 'success' }, { key: 'missing', label: '目录已不在', tone: 'inactive' }, { key: 'other', label: '其他' }, ] /** The Git mark of the header (the plugin's icon). */ const GIT_MARK = '' let flight: Promise | undefined let lastKey = '' let lastToolRefresh = 0 /** The status-line entry: where HEAD is, how many trees, how many dirty. */ function summary(body: Record): string | undefined { if (body.ok !== true) return undefined const trees: any[] = body.worktrees ?? [] const dirty = trees.reduce((sum, tree) => sum + (tree.dirtyTotal ?? 0), 0) const head = body.currentBranch !== '' ? body.currentBranch : 'detached' return `⎇ ${head} · ${trees.length} wt${dirty > 0 ? ` · ${dirty} dirty` : ''}` } /** Read the repository again; shared by every caller in flight, quiet when nothing changed. */ function refresh($: EngineInterface, isForced = false): Promise { if (flight !== undefined) return flight flight = (async () => { try { const env = gitEnv($) const home = await $.session.cwd() const picked = await read($, selected) let body = await collectState(env, picked !== '' ? picked : home) // A remembered worktree that is gone is not "no repository": fall back. if (body.ok !== true && body.code === 'not-a-git-repo' && picked !== '') { await update($, selected, () => '') body = await collectState(env, home) } const key = JSON.stringify({ ...body, generatedAt: '' }) if (isForced || key !== lastKey) { lastKey = key await update($, snapshot, () => body) } await update($, failure, () => body.ok === true || body.code === 'not-a-git-repo' ? '' : String(body.error ?? 'read failed'), ) $.ui.status(summary(body)) } catch (error) { await update($, failure, () => (error instanceof Error ? error.message : String(error))) } finally { flight = undefined } })() return flight } async function isOpen($: EngineInterface): Promise { return (await $.ui.panes()).some(pane => pane.id === PANE) } export const register: Register = on => { on('session.start', async ($, e, next) => { await $.command.register({ name: COMMAND, description: 'Show or hide the Git worktree pane', }) $.clock.every(POLL_MS, async () => { if (await isOpen($)) await refresh($) }) void refresh($, true) void $.ui.open({ id: PANE, title: 'Git worktrees' }) return next(e) }) on('command.run', { command: COMMAND }, async $ => { if (await isOpen($)) { await $.ui.close({ id: PANE }) return { text: 'Git worktree pane closed.' } } await $.ui.open({ id: PANE, title: 'Git worktrees' }) await refresh($, true) return { text: 'Git worktree pane opened.' } }).catch(() => ({ text: 'Could not toggle the Git worktree pane.' })) // Tools edit files, commit and switch branches: keep the status line honest // even while the pane is closed. on('tool.call', async ($, e, next) => { const ran = await next(e) const now = await $.clock.now() if (now - lastToolRefresh > TOOL_REFRESH_MS) { lastToolRefresh = now void refresh($) } return ran }).catch(($, e, next) => next(e)) on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => { const { Box, Text, Button } = $.ui.resolve(e) // The surfaces that draw vector images (the desktop) get the graph as one // drawing at fixed pixel geometry, like the DSH panel; the terminal keeps // text rails. // (The resolved table is completed with every element name, a missing one // drawing a fragment, so the surface decides, not the presence of `Svg`.) const Svg = e.surface === 'terminal' ? undefined : ($.ui.resolve(e) as { Svg?: any }).Svg const isGraphic = Svg !== undefined const gap = isGraphic ? 'xs' : 1 const snap = await read($, snapshot) const problem = await read($, failure) const hidden = new Set(await read($, folded)) const open = new Set(await read($, openDirs)) const shown = await read($, graphRows) const toggle = (id: string) => () => update($, folded, list => (list.includes(id) ? list.filter(one => one !== id) : [...list, id])) /** A bordered tag on a graphical surface, `[label]` on the terminal. */ const Chip = (label: string, tone?: string) => isGraphic ? ( {label} ) : ( {`[${label}]`} ) const Section = (id: string, title: string, count: number | undefined, body: unknown) => ( {hidden.has(id) ? null : body} ) const repoName = snap?.repoRoot ? String(snap.repoRoot).split('/').filter(Boolean).pop() : 'Git worktrees' const head = ( {isGraphic ? : ⎇} {repoName} {snap?.ok === true ? ( {`${snap.graph.nodes.length} 个提交 · ${snap.branches.length} 个分支 · 主干 ${snap.trunkBranch || '?'}`} ) : null} {badges.map(([label, tone]) => Chip(label, tone))} {meta === '' ? null : ( {meta} )} {counts.length > 0 ? ( {counts.map(([label, tone]) => Chip(label, tone))} ) : null} {tree.path} ) } const trees: any[] = snap.worktrees const worktreeBody = ( {repoName} {`${trees.length} 棵工作树 · 共享 .git`} {trees.filter(tree => tree.group === 'root').map(treeRow)} {GROUPS.map(group => { const list = trees.filter(tree => tree.group === group.key) if (list.length === 0) return null return ( {group.label} {Chip(String(list.length))} {list.map(treeRow)} ) })} ) // ---- changes const changes: any[] = snap.changes const changesBody = changes.length === 0 ? ( 没有未提交改动 ) : ( {changes.map(change => { const badge = changeBadge(change) const letters = badge.label.length === 1 ? ` ${badge.label} ` : badge.label.slice(0, 3).padEnd(3) return ( {isGraphic ? ( // A filled badge: a Box carries the padding, which a graphical // surface would trim off a Text's own spaces. {badge.label} ) : ( {letters} )} {String(change.path)} ) })} {snap.changesTruncated === true ? {`仅显示前 ${changes.length} 条改动,其余未列出`} : null} ) // ---- files const dirty = new Set(changes.map(change => String(change.path).replace(/\/$/, ''))) const rows = fileRows(snap.files as string[], open) const filesBody = ( {rows.length === 0 ? 没有可显示的文件 : null} {rows.map(row => row.isDir ? ( ) : ( {row.name} {isDirty(dirty, row.path) ? ● : null} ), )} {snap.filesTruncated === true ? {`仅显示前 ${snap.maxFiles} 个文件,其余未列出`} : null} ) return ( {head} {problem === '' ? null : {`连接异常,显示的是上一次的数据:${problem}`}} {Section('graph', 'GRAPH', nodes.length, graphBody)} {Section('worktrees', 'WORKTREES', trees.length, worktreeBody)} {Section('changes', 'CHANGES', changes.length, changesBody)} {Section('files', 'FILES', snap.fileCount, filesBody)} ) }) }