A workspace pane for Claude Code: files, a This Turn view of Claude's edits with diff and revert, usage stats, , file changes, tasks, VS Code style source…

<img src="cover.png" alt="Claw$h icon" width="800">
Workspace is a Claude Code mod that adds a pane for keeping up with what's happening in your project while Claude works. It marks the files Claude reads and edits, shows exactly what Claude changed this turn (with a diff and a way to undo it), keeps your usage limits in view, and can hand work back to Claude with a click. It also does most of your everyday git, plus a read-only look at your GitHub pull requests and issues, so you rarely need to leave Claude Code.
What's in the pane:
<table> <tr> <td align="center"><b>Files</b><br><img src="screenshots/file-list.png" width="225" alt="The Files tab listing a project's folders and files with their sizes"></td> <td align="center"><b>Viewing a file</b><br><img src="screenshots/file-view.png" width="225" alt="A Rust file open in the viewer with syntax highlighting and line numbers"></td> <td align="center"><b>This Turn</b><br><img src="screenshots/this-turn.png" width="225" alt="The This Turn tab listing five files Claude edited with their added and removed line counts, Review and revert buttons, under the stats strip"></td> <td align="center"><b>Source Control</b><br><img src="screenshots/source-control.png" width="225" alt="The Source Control tab with the branch picker, Fetch, Pull and Push, and the Changes, History, Branches and Stash views"></td> </tr> </table> <table> <tr> <td align="center"><b>History</b><br><img src="screenshots/history.png" width="225" alt="The History view listing recent commits with their age, short hash and a Revert button"></td> <td align="center"><b>Branches</b><br><img src="screenshots/branches.png" width="225" alt="The Branches view with Merge and Rebase, local branches with Switch, Rename and Delete, and remote branches with Check out"></td> <td align="center"><b>GitHub</b><br><img src="screenshots/github.png" width="225" alt="The GitHub tab listing a repo's open issues with their author, age and @ buttons"></td> </tr> </table>
Files tab
! for a conflict) in the same colors Source Control uses, and a folder gets a ● when something inside it changed◆ when Claude reads it and ✎ when Claude edits it, and the row lights up while the tool is running. A timeline card at the top lists everything Claude touched this turn, even in folders you aren't looking at, and you can click a name to open it. The marks stay until you send your next prompt@ on any file or folder (and in the file viewer's header) drops @path into your prompt box at the cursor, so you can point Claude at it without typing the pathExplain @path in your prompt box, so asking Claude about a file is one clickThis Turn tab
✓ when they finish, and the one in progress shows what Claude is doing right now, like "Writing tests". It only shows up when Claude actually makes a list, which depends on your setup having the TodoWrite or Task tools turned onsed -i or a script that writes files, not just its Edit and Write tools↺ (or ↺ Revert in the diff) puts the file back the way it was before this turn. For a file Claude created, it deletes the file. Both ask firstReview the changes you made to @path in your prompt boxStats bar
Source Control tab
▾ next to a section to fold it shut when the list gets long+ and − to stage or unstage it. The file lights up for a moment in its new section. Inside the diff, each hunk has a header with its line range and counts, plus its own + Stage or − Unstage button. Review in the diff header asks Claude to look over that file's changes↺ on a file throws away its changes, and Discard all does the whole list. Both ask firstmain or mastergit init -b main)GitHub tab
✓, ✗, ● pending) and review status (✓ approved, ± changes requested, ○ review required), and a card above the list adds those up across all open PRs@ on any row (and in the detail header) drops a short reference like PR #42 "Fix the thing" (https://github.com/...) into your prompt box at the cursor. Claude can then pull the full thread or diff with gh itself, so your prompt doesn't fill up with a whole conversation↻ reloads the listsThe pane draws its own dark theme, so it looks the same no matter which Claude Code theme you use.
In kitty and Ghostty the graph is drawn as a real picture, with smooth curves where branches split and merge. Everywhere else you get the same graph in Unicode box characters. The pane guesses which one to use from your terminal's environment (TERM, TERM_PROGRAM and KITTY_WINDOW_ID). If it guesses wrong, set CLAUDE_WORKSPACE_GRAPH before starting Claude Code:
CLAUDE_WORKSPACE_GRAPH=text claude # always use the Unicode graph
CLAUDE_WORKSPACE_GRAPH=image claude # always draw the picture
You need Claude Code in a terminal. Installing goes through this repo, which doubles as a plugin marketplace.
At the Claude Code prompt:
/plugin install workspace --marketplace pinkpixel-dev/claude-workspace
Answer y when it asks to add the github:pinkpixel-dev/claude-workspace marketplace, then pick a scope (the user scope is first, press Enter for it). The mod is active right away.
From a regular shell instead:
claude plugin install workspace --marketplace pinkpixel-dev/claude-workspace
If you'd rather add the marketplace first and install the plugin as a separate step:
/plugin marketplace add pinkpixel-dev/claude-workspace
/plugin install workspace@claude-workspace
In the Claude Code desktop app, where /plugin doesn't run: click + next to the prompt box, then Plugins, Add marketplace with pinkpixel-dev/claude-workspace, then Add plugin.
To update to the latest version later, run this from a shell and then restart Claude Code:
claude plugin update workspace@claude-workspace
Open the pane with:
/workspace
Click the tabs at the top, or focus the pane and use Tab, the arrow keys and Enter. Esc takes you back to the prompt.
A few things worth knowing:
git push -u <remote> <branch> for you.git pull --no-edit, so a merge never stops to open an editor. If you picked a remote other than the one your branch tracks, Push and Pull name that remote and branch directly.--rebase. If it stops on conflicts, they show up in Merge Changes like any other rebase. Sync pulls and then pushes, and asks first on main or master.--force-with-lease, so git refuses if someone else pushed since your last fetch. It always asks first.git reset --soft HEAD~1, so its changes land back in Staged Changes and its message goes back in the commit box (unless you'd already typed something there). If the commit is pushed, it asks first.git push <remote> --delete <name>, which deletes it for everyone, so it always asks first.git tag -a) once you type one. Check out on a tag leaves you on that commit with no branch (a detached HEAD). Delete only removes your local copy. Delete remote runs git push <remote> --delete refs/tags/<name> and asks first, and it leaves your local copy alone.git switch --track <remote>/<name>, which makes a local copy that tracks it.main or master asks first. Rebase always asks, since it rewrites your branch's commits (and if the branch is already pushed, you'll need a force push after).Co-Authored-By: Claude <model> <noreply@anthropic.com> line naming the session's model. Delete that line if you don't want it.git clean -f -x -q -- <path>, since Claude Code's file API can't delete files. That means it only works inside a git repository.@ the item and ask Claude.git branch -d first. If the branch has commits that aren't merged anywhere, git refuses, and the pane offers a Force delete (-D) behind a confirm. You can't delete the branch you're on.gh in your repo, so you need gh installed and logged in (gh auth login), and the repo's remote has to be on GitHub. If any of that is missing, the tab shows gh's error instead of a list.✎ marks, while Edit and Write keep working./workspace, but it's way nicer with a wide terminal.git log as the list, so it shows merges into your branch but not other branches' tips.CLAUDE_WORKSPACE_GRAPH=text if you see a blank column.The Claude Code mod API is still early access. I built this on Claude Code 2.1.303, so a future release could change something the mod relies on.
It's a single hooks module. /workspace opens a pane, and the pane is drawn from state the mod keeps for the session. Buttons call git through Claude Code's process API (no shell involved) and read or write files through its file API. After each Claude turn, the mod refreshes the file list and git status so the pane stays in sync with whatever Claude just did. It also watches Claude's file tool calls as they happen, which is where the read and edit marks come from. Claude's Edit and Write results include the file as it was before the call, and Bash results include a diff of every file the command changed, which the mod undoes on the current file to get the old text back. Either way, it keeps the first copy per file each turn. That copy is what This Turn diffs against and reverts to.
hooks/
register.tsx /workspace command, refresh after each turn, the pane frame and tabs
files-view.tsx file browser, viewer and editor
git-view.tsx branch and remote pickers, sub-tabs, the Changes view
diff-view.tsx a file's diff, split into hunks
turn-view.tsx the This Turn tab
stats-view.tsx the stats strip: its usage figures, toggle and row
confirm-view.tsx the confirm card, busy label and notice every tab shares
merge-view.tsx the operation banner, Merge Changes and the Merge/Rebase row
branches-view.tsx the Branches view
history-view.tsx the History view, its graph and the activity chart
stash-view.tsx the Stash view
tags-view.tsx the Tags view
remotes-view.tsx the Remotes section at the bottom of Branches
actions.ts what the Files tab's buttons do: file reads and writes, and @ mentions
activity.ts watches Claude's file tools for the read and edit marks
tasks.ts watches Claude's task list tools for the Tasks section
turn-actions.ts before-edit copies, This Turn diffs and reverts, Review/Explain, and ✦ Message
tree.ts maps git and tool paths onto the file list (no Claude Code calls)
git-actions.ts what the Source Control buttons do, plus the background fetch
branch-actions.ts new, rename, delete, and check out or delete remote branches
tag-actions.ts create, check out, push and delete tags
remote-actions.ts add and remove remotes
sync-actions.ts Pull (rebase), Sync, Fetch all and Force push
history-actions.ts history, revert, undo the last commit, and stash
merge-actions.ts merge, rebase, Continue/Abort and resolving conflicts
repo.ts shared git plumbing: running git, errors, reading repo status
git.ts parses git output (no Claude Code calls, so it's easy to test)
graph.ts lays out the History graph and its Unicode cells (no Claude Code calls)
graph-image.ts draws the graph as RGBA pixels for kitty and Ghostty
chart.ts the commit-activity chart's data and Raster cells
diff.ts a line diff for This Turn (no Claude Code calls)
stats.ts the stats strip's text and what fits a width (no Claude Code calls)
icons.ts file-kind glyphs
base64.ts base64 for Raster cells and Image bytes
controls.tsx shared chips, tabs, section headers, frames, cards, badges, bars and divider
theme.ts color tokens
*.test.ts(x) tests
types/index.d.ts the shape of the session state
Clone the repo and start Claude Code with the folder loaded:
git clone https://github.com/pinkpixel-dev/claude-workspace.git
claude --plugin-dir ./claude-workspace
Edits to the folder reload while that session runs. To check your changes:
claude plugin validate ./claude-workspace
claude plugin test ./claude-workspace
The tests run against Claude Code's own test kit. The git tests feed real git status output through the parser, and the pane tests draw the pane and press its buttons against a fake repo, so they don't touch your actual git setup.
Apache 2.0. See LICENSE.
Made with 💖 by Pink Pixel
hooks/register.tsx 145 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { openPath, refreshAll } from './actions'
5import { clearActivity, trackTool } from './activity'
6import { autoFetch, FETCH_EVERY_MS } from './git-actions'
7import { refreshRepo } from './repo'
8import { drawFiles } from './files-view'
9import { drawGit } from './git-view'
10import { refreshGithub } from './github-actions'
11import { drawGithub } from './github-view'
12import { chip, tab as navTab } from './controls'
13import { detectImages } from './history-actions'
14import { isStaged, isUnstaged } from './git'
15import { C } from './theme'
16import { PANE, EMPTY_REPO } from './state'
17import { drawStats, loadStats, measure, setModel, toggleStats } from './stats-view'
18import { clearTasks, TASK_TOOLS, trackTasks } from './tasks'
19import { clearTurn } from './turn-actions'
20import { drawTurn } from './turn-view'
21
22const activityState = atom({ plugin: 'workspace', key: 'activity' } as const, [])
23const isEditingState = atom({ plugin: 'workspace', key: 'isEditing' } as const, false)
24const openFileState = atom({ plugin: 'workspace', key: 'openFile' } as const, null)
25const repoState = atom({ plugin: 'workspace', key: 'repo' } as const, EMPTY_REPO)
26const showStatsState = atom({ plugin: 'workspace', key: 'showStats' } as const, true)
27const statsState = atom({ plugin: 'workspace', key: 'stats' } as const, {})
28const tabState = atom({ plugin: 'workspace', key: 'tab' } as const, 'files')
29
30export const register: Register = on => {
31 on('session.start', async ($, e, next) => {
32 await $.command.register({
33 name: 'workspace',
34 description: 'Open the file browser and source control pane',
35 })
36 void refreshAll($)
37 void detectImages($)
38 void loadStats($)
39 $.clock.every(FETCH_EVERY_MS, () => void autoFetch($))
40
41 return next(e)
42 })
43
44 on('command.run', { command: 'workspace' }, async $ => {
45 const opened = await $.ui.open({ id: PANE, title: 'Workspace' })
46 void refreshAll($)
47
48 return { text: opened.isPlaced ? 'Workspace opened.' : 'Workspace will show once there is room.' }
49 })
50
51 // Marks the files Claude reads and edits in the Files tab.
52 on('tool.call', ($, e, next) => (TASK_TOOLS.has(e.tool) ? trackTasks($, e, next) : trackTool($, e, next)))
53
54 // A new prompt starts a fresh set of marks; slash commands keep them.
55 on('prompt.submit', async ($, e, next) => {
56 if (e.command === undefined && e.isMeta !== true) {
57 await clearActivity($).catch(() => undefined)
58 await clearTurn($).catch(() => undefined)
59 await clearTasks($).catch(() => undefined)
60 }
61
62 return next(e)
63 })
64
65 // The stats bar's usage, context and cost.
66 on('session.measure', async ($, e, next) => {
67 await measure($, e).catch(() => undefined)
68 return next(e)
69 })
70
71 // The model and effort the main thread asks for; subagents' steps don't count.
72 on('turn.step', async function* ($, e, next) {
73 if (e.agentId === undefined) await setModel($, e.model, e.effort).catch(() => undefined)
74 return yield* next(e)
75 })
76
77 // Claude may have edited files or made commits during the turn.
78 on('turn.complete', async ($, e, next) => {
79 const done = await next(e)
80 void (async () => {
81 await refreshAll($)
82 // Claude may have opened a pull request or issue too.
83 if ((await read($, tabState)) === 'github') await refreshGithub($)
84 const file = await read($, openFileState)
85 if (file !== null && !(await read($, isEditingState)) && !file.readOnlyReason) {
86 await openPath($, file.path)
87 }
88 })()
89
90 return done
91 })
92
93 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
94 if (e.surface === 'mobile') {
95 const { Text } = $.ui.resolve(e)
96 return <Text color={C.muted}>The workspace needs a keyboard surface.</Text>
97 }
98
99 const ui = $.ui.resolve(e)
100 const { Box, Text } = ui
101 const tab = await read($, tabState)
102 const repo = await read($, repoState)
103 const count = new Set(repo.changes.filter(one => isStaged(one) || isUnstaged(one)).map(one => one.path)).size
104 const width = e.props.bodyColumns
105 const edits = (await read($, activityState)).filter(one => one.kind === 'edit').length
106 const showStats = await read($, showStatsState)
107 const name = repo.root.slice(repo.root.lastIndexOf('/') + 1)
108
109 const body = tab === 'files'
110 ? await drawFiles($, ui, e.props.scroll.bodyRows)
111 : tab === 'git'
112 ? await drawGit($, ui, width - 2)
113 : tab === 'turn'
114 ? await drawTurn($, ui)
115 : await drawGithub($, ui, width - 2)
116
117 return (
118 <Box flexDirection="column" width={width} minHeight={e.props.scroll.bodyRows} backgroundColor={C.bg} paddingX={1}>
119 {showStats && drawStats(ui, await read($, statsState), width - 6)}
120 <Box justifyContent="space-between" gap={1} paddingTop={1} paddingX={1}>
121 <Text color={C.text} bold>Workspace</Text>
122 <Box gap={1} flexShrink={1}>
123 {name !== '' && <Text color={C.faint} wrap="truncate-end">{name}</Text>}
124 {chip(ui, { key: 'stats-toggle', label: 'Stats', kind: showStats ? 'secondary' : 'quiet', onPress: () => toggleStats($) })}
125 </Box>
126 </Box>
127 <Box justifyContent="space-between" gap={2} backgroundColor={C.card} paddingX={2} marginY={1}>
128 {navTab(ui, 'tab-files', 'Files', tab === 'files', () => update($, tabState, () => 'files' as const))}
129 {navTab(ui, 'tab-git', count > 0 ? `Source Control ${count}` : 'Source Control', tab === 'git', async () => {
130 await update($, tabState, () => 'git' as const)
131 await refreshRepo($)
132 })}
133 {navTab(ui, 'tab-turn', edits > 0 ? `This Turn ${edits}` : 'This Turn', tab === 'turn', () =>
134 update($, tabState, () => 'turn' as const))}
135 {navTab(ui, 'tab-github', 'GitHub', tab === 'github', async () => {
136 await update($, tabState, () => 'github' as const)
137 await refreshGithub($)
138 })}
139 </Box>
140 {body}
141 </Box>
142 )
143 })
144}
145hooks/actions.ts 160 lines1// What the Files tab's buttons do. Drawing never runs these: handlers and
2// events do, and the state they write redraws the pane. Source Control's
3// actions live in git-actions.ts.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface } from 'claude-code'
6
7import type { Confirm, Entry } from '../types'
8import { CODE_LIMIT } from './git'
9import { confirmed, fail, refreshRepo } from './repo'
10import { mentionText } from './tree'
11
12const cwdState = atom({ plugin: 'workspace', key: 'cwd' } as const, '')
13const dirState = atom({ plugin: 'workspace', key: 'dir' } as const, '')
14const draftState = atom({ plugin: 'workspace', key: 'draft' } as const, '')
15const entriesState = atom({ plugin: 'workspace', key: 'entries' } as const, [])
16const isEditingState = atom({ plugin: 'workspace', key: 'isEditing' } as const, false)
17const openFileState = atom({ plugin: 'workspace', key: 'openFile' } as const, null)
18
19type $ = EngineInterface
20
21const MAX_OPEN_BYTES = 1024 * 1024
22
23const join = (parent: string, name: string) => (parent ? `${parent}/${name}` : name)
24const parentOf = (path: string) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '')
25
26// Files
27
28export async function refreshDir($: $) {
29 const current = await read($, dirState)
30 const listed = await $.fs.list(current || undefined).catch(() => null)
31
32 if (listed === null && current !== '') {
33 await update($, dirState, () => '')
34 return refreshDir($)
35 }
36
37 const sorted: Entry[] = (listed ?? [])
38 .filter(entry => entry.name !== '.git')
39 .map(entry => ({ name: entry.name, kind: entry.kind, size: entry.size }))
40 .sort((a, b) =>
41 a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : b.kind === 'dir' ? 1 : 0,
42 )
43 await update($, entriesState, () => sorted)
44}
45
46export async function openEntry($: $, entry: Entry) {
47 const path = join(await read($, dirState), entry.name)
48
49 if (entry.kind === 'dir') {
50 await update($, dirState, () => path)
51 return refreshDir($)
52 }
53
54 await openPath($, path)
55}
56
57export async function openPath($: $, path: string) {
58 try {
59 const { size } = await $.fs.stat(path)
60 if (size > MAX_OPEN_BYTES) {
61 await update($, openFileState, () => ({ path, text: '', readOnlyReason: 'Too large to open here.' }))
62 } else {
63 const text = await $.fs.read(path)
64 const isBinary = text.includes('\u0000')
65 const isLong = text.length > CODE_LIMIT
66 await update($, openFileState, () => ({
67 path,
68 text: isBinary ? '' : text.slice(0, CODE_LIMIT),
69 readOnlyReason: isBinary ? 'Binary file.' : isLong ? 'Too large to edit here, showing the start.' : undefined,
70 }))
71 }
72 await update($, isEditingState, () => false)
73 } catch (error) {
74 await fail($, error)
75 }
76}
77
78// Jumps straight to a folder, as the Files tab's path crumbs do; '' is the top.
79export async function openDir($: $, path: string) {
80 await update($, dirState, () => path)
81 await refreshDir($)
82}
83
84export async function goUp($: $) {
85 await update($, dirState, current => parentOf(current))
86 await refreshDir($)
87}
88
89export async function closeFile($: $) {
90 await update($, openFileState, () => null)
91 await update($, isEditingState, () => false)
92}
93
94export async function startEdit($: $) {
95 const file = await read($, openFileState)
96 if (file === null || file.readOnlyReason) return
97
98 await update($, draftState, () => file.text)
99 await update($, isEditingState, () => true)
100}
101
102export function cancelEdit($: $) {
103 return update($, isEditingState, () => false)
104}
105
106// Asks before writing over a file that changed on disk since it was opened,
107// so a save never quietly wipes out an edit Claude made meanwhile.
108export async function save($: $, text: string, isForced = false) {
109 const file = await read($, openFileState)
110 if (file === null) return
111
112 try {
113 if (!isForced) {
114 const disk = await $.fs.read(file.path).catch(() => null)
115 if (typeof disk === 'string' && disk.slice(0, CODE_LIMIT) !== file.text) {
116 const name = file.path.slice(file.path.lastIndexOf('/') + 1)
117 const ask: Confirm = {
118 action: 'overwrite',
119 path: file.path,
120 question: `${name} changed on disk since you opened it. Save over it?`,
121 yes: 'Yes, overwrite',
122 }
123 // The confirm's yes saves the draft, so it has to hold what was submitted.
124 await update($, draftState, () => text)
125 if (!(await confirmed($, ask))) return
126 }
127 }
128 await $.fs.write(file.path, text)
129 await update($, openFileState, () => ({ ...file, text }))
130 await update($, isEditingState, () => false)
131 $.ui.toast(`Saved ${file.path}`)
132 await refreshRepo($)
133 } catch (error) {
134 await fail($, error)
135 }
136}
137
138// Picks up what a git step did to the open file: reloads it, or closes it once it's gone.
139export async function reloadFile($: $) {
140 const file = await read($, openFileState)
141 if (file === null || (await read($, isEditingState))) return
142
143 const exists = await $.fs.stat(file.path).then(() => true, () => false)
144 if (exists) await openPath($, file.path)
145 else await closeFile($)
146}
147
148// Puts `@path` in the prompt box at the cursor, so the next prompt names the file.
149export async function mention($: $, path: string) {
150 const box = await $.prompt.read()
151 const filled = await $.prompt.fill({ text: mentionText(path, box.text.slice(0, box.cursor)), mode: 'insert' })
152 if (!filled.isFilled) $.ui.toast(`Couldn't add @${path} to the prompt right now.`)
153}
154
155export async function refreshAll($: $) {
156 const cwd = await $.session.cwd().catch(() => '')
157 await update($, cwdState, () => cwd)
158 await Promise.all([refreshDir($), refreshRepo($)])
159}
160hooks/activity.ts 89 lines1// Watches Claude's file tools, and the files its Bash commands change, so the
2// Files tab can mark what it reads and edits. The marks stay until the next prompt you send.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, ToolCallInput, ToolCallResult } from 'claude-code'
5
6import { refreshAll } from './actions'
7import { FILE_TOOLS, treePathOf, withTouch } from './tree'
8import { keepBashSnapshot, keepSnapshot } from './turn-actions'
9import type { BashFileChange } from './turn-actions'
10
11const activityState = atom({ plugin: 'workspace', key: 'activity' } as const, [])
12const cwdState = atom({ plugin: 'workspace', key: 'cwd' } as const, '')
13
14type $ = EngineInterface
15
16// The tools whose results carry the file as it was before the call.
17const SNAPSHOT_TOOLS = new Set(['Edit', 'Write'])
18
19const pathArg = (e: ToolCallInput) => {
20 if ('file_path' in e && typeof e.file_path === 'string') return e.file_path
21 if ('notebook_path' in e && typeof e.notebook_path === 'string') return e.notebook_path
22
23 return null
24}
25
26// What a Bash result says it changed. `bashEditDiff` is marked internal in
27// Claude Code's types, so if it goes away Bash edits just stop being tracked.
28type BashEditDiff = { files: BashFileChange[]; changedFiles?: string[] }
29
30// Marks every file a Bash command changed and keeps a copy from before it.
31async function trackBash($: $, e: ToolCallInput, next: (e: ToolCallInput) => Promise<ToolCallResult>) {
32 const done = await next(e)
33 const result = ('result' in done ? done.result : undefined) as { bashEditDiff?: BashEditDiff } | undefined
34 const changes = result?.bashEditDiff
35 if (changes === undefined || !Array.isArray(changes.files)) return done
36
37 try {
38 const cwd = await read($, cwdState)
39 const shown = new Map(changes.files.map(file => [file.filePath, file]))
40 const changed = new Set([...shown.keys(), ...(changes.changedFiles ?? [])])
41 for (const raw of changed) {
42 const path = treePathOf(cwd, raw)
43 if (path === null) continue
44 await update($, activityState, list => withTouch(list, path, 'edit', false))
45 await keepBashSnapshot($, path, shown.get(raw) ?? null)
46 }
47 if (changed.size > 0) void refreshAll($).catch(() => undefined)
48 } catch {
49 // A mark that can't be written is skipped; the command's result still goes back.
50 }
51
52 return done
53}
54
55// Never in the way of the tool: a mark that can't be written is skipped.
56export async function trackTool($: $, e: ToolCallInput, next: (e: ToolCallInput) => Promise<ToolCallResult>) {
57 if (e.tool === 'Bash') return trackBash($, e, next)
58
59 const kind = FILE_TOOLS[e.tool]
60 const raw = kind === undefined ? null : pathArg(e)
61 if (kind === undefined || raw === null) return next(e)
62
63 const path = treePathOf(await read($, cwdState).catch(() => ''), raw)
64 if (path === null) return next(e)
65
66 const isNew = await read($, activityState).then(list => !list.some(one => one.path === path), () => false)
67 await update($, activityState, list => withTouch(list, path, kind, true)).catch(() => undefined)
68
69 let isDone = false
70 try {
71 const done = await next(e)
72 isDone = !('deny' in done && done.deny !== undefined) && done.isError !== true
73 if (isDone && SNAPSHOT_TOOLS.has(e.tool)) {
74 await keepSnapshot($, path, 'result' in done ? done.result : undefined).catch(() => undefined)
75 }
76 return done
77 } finally {
78 // A refused or failed call never touched the file, so a first mark goes away again.
79 await update($, activityState, list =>
80 !isDone && isNew ? list.filter(one => one.path !== path) : withTouch(list, path, kind, false),
81 ).catch(() => undefined)
82 if (isDone && kind === 'edit') void refreshAll($).catch(() => undefined)
83 }
84}
85
86export function clearActivity($: $) {
87 return update($, activityState, () => [])
88}
89hooks/git-actions.ts 333 lines1// What the Source Control tab's buttons do. Each git step runs through `run`,
2// which shows a busy label and refreshes the repo once it's done.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface } from 'claude-code'
5
6import type { Change, Confirm } from '../types'
7import { closeFile, refreshDir, reloadFile, save } from './actions'
8import {
9 discardArgs,
10 isProtected,
11 isPushed,
12 isStaged,
13 isUnstaged,
14 isUntracked,
15 pullArgs,
16 pushArgs,
17 shortError,
18} from './git'
19import { deleteRemoteBranch, forceDeleteBranch } from './branch-actions'
20import { dropAllStashes, dropStash, revert, undoCommit } from './history-actions'
21import { abortOperation, markResolved, merge, rebase } from './merge-actions'
22import { removeRemote } from './remote-actions'
23import { deleteRemoteTag } from './tag-actions'
24import { confirmed, fail, git, gitOk, refreshRepo, run } from './repo'
25import { forcePush, syncBranch } from './sync-actions'
26import { revertTurn } from './turn-actions'
27import { EMPTY_REPO, PANE } from './state'
28
29const busyState = atom({ plugin: 'workspace', key: 'busy' } as const, null)
30const confirmState = atom({ plugin: 'workspace', key: 'confirm' } as const, null)
31const draftState = atom({ plugin: 'workspace', key: 'draft' } as const, '')
32const flashState = atom({ plugin: 'workspace', key: 'flash' } as const, null)
33const isEditingState = atom({ plugin: 'workspace', key: 'isEditing' } as const, false)
34const messageState = atom({ plugin: 'workspace', key: 'message' } as const, '')
35const repoState = atom({ plugin: 'workspace', key: 'repo' } as const, EMPTY_REPO)
36
37type $ = EngineInterface
38
39export const FETCH_EVERY_MS = 3 * 60 * 1000
40
41const nameOf = (path: string) => path.slice(path.lastIndexOf('/') + 1)
42
43async function commitWith($: $, label: string, args: string[], fallback: string) {
44 await update($, confirmState, () => null)
45 await run($, label, async () => {
46 const ran = await gitOk($, args)
47 await update($, messageState, () => '')
48 $.ui.toast(ran.stdout.split('\n')[0] || fallback)
49 })
50}
51
52export function stage($: $, path: string) {
53 return run($, 'Staging', async () => {
54 await gitOk($, ['add', '-A', '--', path])
55 })
56}
57
58// Stages or unstages one hunk of a file's diff; `header` is the diff's file header.
59export function applyHunk($: $, header: string, hunk: string, isStaged: boolean) {
60 return run($, isStaged ? 'Unstaging' : 'Staging', async () => {
61 const args = ['apply', '--cached', '--recount', ...(isStaged ? ['--reverse'] : []), '-']
62 await gitOk($, args, { stdin: `${header}${hunk}` })
63 })
64}
65
66export function stageAll($: $) {
67 return run($, 'Staging', async () => {
68 await gitOk($, ['add', '-A'])
69 })
70}
71
72export function unstage($: $, path: string) {
73 return run($, 'Unstaging', async () => {
74 // `restore --staged` needs a first commit; `rm --cached` covers a fresh repo.
75 await gitOk($, ['restore', '--staged', '--', path])
76 .catch(() => gitOk($, ['rm', '--cached', '-r', '-q', '--', path]))
77 })
78}
79
80// Same fallback as unstage, for every staged file at once.
81export function unstageAll($: $) {
82 return run($, 'Unstaging', async () => {
83 await gitOk($, ['restore', '--staged', '--', '.'])
84 .catch(() => gitOk($, ['rm', '--cached', '-r', '-q', '--', '.']))
85 })
86}
87
88export async function discard($: $, change: Change) {
89 const name = nameOf(change.path)
90 const ask: Confirm = isUntracked(change)
91 ? { action: 'discard', path: change.path, question: `Delete untracked ${name}?`, yes: 'Yes, delete' }
92 : { action: 'discard', path: change.path, question: `Discard changes to ${name}?`, yes: 'Yes, discard' }
93 if (!(await confirmed($, ask))) return
94
95 await run($, 'Discarding', async () => {
96 await gitOk($, discardArgs(change))
97 await reloadFile($)
98 await refreshDir($)
99 })
100}
101
102export async function discardAll($: $) {
103 const changes = (await read($, repoState)).changes.filter(isUnstaged)
104 if (changes.length === 0) return
105
106 const tracked = changes.filter(one => !isUntracked(one)).map(one => one.path)
107 const fresh = changes.filter(isUntracked).map(one => one.path)
108 const count = changes.length === 1 ? '1 change' : `${changes.length} changes`
109 const ask: Confirm = {
110 action: 'discard-all',
111 question: fresh.length > 0 ? `Discard all ${count}? Untracked files get deleted.` : `Discard all ${count}?`,
112 yes: 'Yes, discard',
113 }
114 if (!(await confirmed($, ask))) return
115
116 await run($, 'Discarding', async () => {
117 if (tracked.length > 0) await gitOk($, ['restore', '--worktree', '--', ...tracked])
118 if (fresh.length > 0) await gitOk($, ['clean', '-f', '-d', '-q', '--', ...fresh])
119 await reloadFile($)
120 await refreshDir($)
121 })
122}
123
124export function setMessage($: $, text: string) {
125 return update($, messageState, () => text)
126}
127
128export function cancelConfirm($: $) {
129 return update($, confirmState, () => null)
130}
131
132// The confirm row's yes button: runs again the step that asked.
133export async function confirmYes($: $) {
134 const ask = await read($, confirmState)
135 if (ask === null) return
136
137 switch (ask.action) {
138 case 'commit':
139 return commit($)
140 case 'commit-all':
141 return commitAll($)
142 case 'amend':
143 return amend($)
144 case 'push':
145 return push($)
146 case 'discard-all':
147 return discardAll($)
148 case 'force-delete':
149 return forceDeleteBranch($, ask.path ?? '')
150 case 'revert': {
151 const commit = (await read($, repoState)).commits.find(one => one.sha === ask.path)
152 if (commit !== undefined) return revert($, commit)
153 return cancelConfirm($)
154 }
155 case 'merge':
156 return merge($, ask.path ?? '')
157 case 'rebase':
158 return rebase($, ask.path ?? '')
159 case 'abort':
160 return abortOperation($)
161 case 'resolve': {
162 const change = (await read($, repoState)).changes.find(one => one.path === ask.path)
163 if (change !== undefined) return markResolved($, change)
164 return cancelConfirm($)
165 }
166 case 'drop-stash': {
167 const stash = (await read($, repoState)).stashes.find(one => one.ref === ask.path)
168 if (stash !== undefined) return dropStash($, stash)
169 return cancelConfirm($)
170 }
171 case 'revert-turn':
172 return revertTurn($, ask.path ?? '')
173 case 'overwrite':
174 await cancelConfirm($)
175 return save($, await read($, draftState), true)
176 case 'delete-remote-branch':
177 return deleteRemoteBranch($, ask.path ?? '')
178 case 'force-push':
179 return forcePush($)
180 case 'sync':
181 return syncBranch($)
182 case 'undo-commit':
183 return undoCommit($)
184 case 'drop-all-stashes':
185 return dropAllStashes($)
186 case 'remove-remote':
187 return removeRemote($, ask.path ?? '')
188 case 'delete-remote-tag':
189 return deleteRemoteTag($, ask.path ?? '')
190 case 'discard': {
191 const change = (await read($, repoState)).changes.find(one => one.path === ask.path)
192 if (change !== undefined) return discard($, change)
193 return cancelConfirm($)
194 }
195 }
196}
197
198export async function commit($: $) {
199 const text = (await read($, messageState)).trim()
200 const current = await read($, repoState)
201
202 if (text === '') return fail($, new Error('Type a commit message first.'))
203 if (!current.changes.some(isStaged)) return fail($, new Error('Nothing staged. Stage some changes first.'))
204 if (isProtected(current.branch)) {
205 const ask: Confirm = { action: 'commit', question: `Commit straight to ${current.branch}?`, yes: 'Yes, commit' }
206 if (!(await confirmed($, ask))) return
207 }
208
209 await commitWith($, 'Committing', ['commit', '-m', text], 'Committed')
210}
211
212// Stages everything, untracked files too, and commits it in one go.
213export async function commitAll($: $) {
214 const text = (await read($, messageState)).trim()
215 const current = await read($, repoState)
216
217 if (text === '') return fail($, new Error('Type a commit message first.'))
218 if (current.changes.length === 0) return fail($, new Error('Nothing to commit.'))
219 if (isProtected(current.branch)) {
220 const ask: Confirm = {
221 action: 'commit-all',
222 question: `Commit all changes straight to ${current.branch}?`,
223 yes: 'Yes, commit',
224 }
225 if (!(await confirmed($, ask))) return
226 }
227
228 await update($, confirmState, () => null)
229 await run($, 'Committing', async () => {
230 await gitOk($, ['add', '-A'])
231 const ran = await gitOk($, ['commit', '-m', text])
232 await update($, messageState, () => '')
233 $.ui.toast(ran.stdout.split('\n')[0] || 'Committed')
234 })
235}
236
237// Folds staged changes into the last commit. A typed message replaces its
238// message; an empty box keeps the old one.
239export async function amend($: $) {
240 const text = (await read($, messageState)).trim()
241 const current = await read($, repoState)
242
243 if (isPushed(current) || isProtected(current.branch)) {
244 const ask: Confirm = isPushed(current)
245 ? { action: 'amend', question: 'Amend a pushed commit? Pushing it again needs a force push.', yes: 'Yes, amend' }
246 : { action: 'amend', question: `Amend the last commit on ${current.branch}?`, yes: 'Yes, amend' }
247 if (!(await confirmed($, ask))) return
248 }
249
250 const args = text === '' ? ['commit', '--amend', '--no-edit'] : ['commit', '--amend', '-m', text]
251 await commitWith($, 'Amending', args, 'Amended')
252}
253
254export async function push($: $) {
255 const current = await read($, repoState)
256
257 if (current.remote === '') return fail($, new Error('No remote to push to.'))
258 if (isProtected(current.branch)) {
259 const ask: Confirm = { action: 'push', question: `Push straight to ${current.branch}?`, yes: 'Yes, push' }
260 if (!(await confirmed($, ask))) return
261 }
262
263 await update($, confirmState, () => null)
264 await run($, 'Pushing', async () => {
265 await gitOk($, pushArgs(current))
266 $.ui.toast(`Pushed ${current.branch} to ${current.remote}`)
267 })
268}
269
270export async function pull($: $) {
271 const current = await read($, repoState)
272 if (current.remote === '') return fail($, new Error('No remote to pull from.'))
273
274 await run($, 'Pulling', async () => {
275 const ran = await gitOk($, pullArgs(current))
276 $.ui.toast(ran.stdout.split('\n')[0] || 'Pulled')
277 await refreshDir($)
278 })
279}
280
281export async function fetchRemote($: $) {
282 const { remote } = await read($, repoState)
283 if (remote === '') return fail($, new Error('No remote to fetch from.'))
284
285 await run($, 'Fetching', async () => {
286 await gitOk($, ['fetch', remote])
287 $.ui.toast(`Fetched ${remote}`)
288 })
289}
290
291// The fetch timer's tick: a quiet fetch while the pane is on screen, so ↑↓
292// stays current. It never shows errors, since nobody asked for it.
293export async function autoFetch($: $) {
294 const panes = await $.ui.panes().catch(() => [])
295 const current = await read($, repoState)
296 const isShown = panes.some(pane => pane.id === PANE && pane.isShown)
297
298 if (!isShown || !current.isRepo || current.remote === '') return
299 if ((await read($, busyState)) !== null) return
300
301 const ran = await git($, ['fetch', '--quiet', current.remote], { cwd: current.root }).catch(() => null)
302 if (ran !== null && ran.exitCode === 0) await refreshRepo($)
303}
304
305// Runs in the session's folder, since there is no repo root to run from yet.
306export function initRepo($: $) {
307 return run($, 'Initializing', async () => {
308 const ran = await git($, ['init', '-b', 'main'])
309 if (ran.exitCode !== 0) throw new Error(shortError(ran.stderr, ran.stdout))
310 $.ui.toast('Initialized an empty git repository')
311 })
312}
313
314export async function switchBranch($: $, name: string) {
315 const current = await read($, repoState)
316 if (name === current.branch) return
317
318 await run($, 'Switching', async () => {
319 await gitOk($, ['switch', name])
320 $.ui.toast(`Switched to ${name}`)
321 if (!(await read($, isEditingState))) await closeFile($)
322 await refreshDir($)
323 })
324}
325
326// Lights the row a stage or unstage just moved, then lets it settle.
327const FLASH_MS = 1200
328
329export async function flash($: $, key: string) {
330 await update($, flashState, () => key)
331 $.clock.after(FLASH_MS, () => void update($, flashState, current => (current === key ? null : current)))
332}
333hooks/repo.ts 187 lines1// The git plumbing every Source Control action shares: running git, showing
2// its errors, and reading the repo back into state.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, ProcessRunInit, ProcessRunResult } from 'claude-code'
5
6import type { Change, Confirm } from '../types'
7import { ACTIVITY_DAYS, parseCommitDays } from './chart'
8import {
9 BRANCH_FORMAT,
10 isStaged,
11 limitDiff,
12 LOG_FORMAT,
13 OPERATION_MARKERS,
14 parseBranches,
15 parseLog,
16 parseRemoteBranches,
17 parseRemotes,
18 parseStashes,
19 parseStatus,
20 parseTags,
21 pickRemote,
22 REMOTE_BRANCH_FORMAT,
23 shortError,
24 STASH_FORMAT,
25 TAG_FORMAT,
26} from './git'
27import { EMPTY_REPO, HISTORY_PAGE } from './state'
28
29const busyState = atom({ plugin: 'workspace', key: 'busy' } as const, null)
30const confirmState = atom({ plugin: 'workspace', key: 'confirm' } as const, null)
31const diffState = atom({ plugin: 'workspace', key: 'diff' } as const, null)
32const gitViewState = atom({ plugin: 'workspace', key: 'gitView' } as const, 'changes')
33const historyLimitState = atom({ plugin: 'workspace', key: 'historyLimit' } as const, HISTORY_PAGE)
34const noticeState = atom({ plugin: 'workspace', key: 'notice' } as const, null)
35const repoState = atom({ plugin: 'workspace', key: 'repo' } as const, EMPTY_REPO)
36
37type $ = EngineInterface
38
39// No shell, no prompts: a push that needs a password fails fast instead of hanging.
40export async function git($: $, args: string[], init: ProcessRunInit = {}) {
41 return $.process.run(['git', ...args], {
42 timeoutMs: 120000,
43 ...init,
44 env: { GIT_TERMINAL_PROMPT: '0', ...init.env },
45 })
46}
47
48export async function gitOk($: $, args: string[], init: ProcessRunInit = {}) {
49 const { root } = await read($, repoState)
50 const ran = await git($, args, { ...init, cwd: root })
51 if (ran.exitCode !== 0) throw new Error(shortError(ran.stderr, ran.stdout))
52
53 return ran
54}
55
56// Where the remote picked for a repo is kept, so the choice outlives the session.
57const remoteKey = (root: string) => `remote:${root}`
58
59// Asks the question the first time; true once the person said yes to this same step.
60export async function confirmed($: $, ask: Confirm) {
61 const current = await read($, confirmState)
62 if (current !== null && current.action === ask.action && current.path === ask.path) {
63 await update($, confirmState, () => null)
64 return true
65 }
66
67 await update($, confirmState, () => ask)
68 return false
69}
70
71export function fail($: $, error: unknown) {
72 return update($, noticeState, () => ({
73 kind: 'error' as const,
74 text: error instanceof Error ? error.message : String(error),
75 }))
76}
77
78// Runs one git action with a busy label, then refreshes what it may have changed.
79export async function run($: $, label: string, work: () => Promise<void>) {
80 if ((await read($, busyState)) !== null) return
81
82 await update($, busyState, () => label)
83 await update($, noticeState, () => null)
84 try {
85 await work()
86 } catch (error) {
87 await fail($, error)
88 } finally {
89 await update($, busyState, () => null)
90 await refreshRepo($)
91 }
92}
93
94// Which operation is waiting on you, from what it leaves in the git folder.
95export async function readOperation($: $, gitDir: string) {
96 const found = await Promise.all(
97 OPERATION_MARKERS.map(([, marker]) => $.fs.stat(`${gitDir}/${marker}`).then(() => true, () => false)),
98 )
99 const index = found.indexOf(true)
100
101 return index < 0 ? null : OPERATION_MARKERS[index]?.[0] ?? null
102}
103
104// For a step that failed: when git stopped partway with conflicts, point at
105// them in Changes; otherwise show git's own complaint. Either way it throws.
106export async function failedStep($: $, label: string, ran: ProcessRunResult): Promise<never> {
107 const { root } = await read($, repoState)
108 const dir = await git($, ['rev-parse', '--absolute-git-dir'], { cwd: root })
109 const operation = dir.exitCode === 0 ? await readOperation($, dir.stdout.trim()) : null
110 if (operation === null) throw new Error(shortError(ran.stderr, ran.stdout))
111
112 await update($, gitViewState, () => 'changes' as const)
113 throw new Error(`${label} stopped on conflicts. Resolve them in Merge Changes.`)
114}
115
116export async function refreshRepo($: $) {
117 const top = await git($, ['rev-parse', '--show-toplevel', '--absolute-git-dir']).catch(() => null)
118
119 if (top === null || top.exitCode !== 0) {
120 await update($, repoState, current => ({ ...current, isRepo: false, changes: [] }))
121 return
122 }
123
124 const [root = '', gitDir = `${root}/.git`] = top.stdout.trim().split('\n')
125 const limit = await read($, historyLimitState)
126 // A repo with no commits yet fails `log`, which reads as an empty history.
127 const [status, branches, remotes, remoteBranches, log, stashes, tags, recent, now] = await Promise.all([
128 git($, ['status', '--porcelain=v1', '-z', '--branch'], { cwd: root }),
129 git($, ['branch', BRANCH_FORMAT], { cwd: root }),
130 git($, ['remote'], { cwd: root }),
131 git($, ['branch', '-r', REMOTE_BRANCH_FORMAT], { cwd: root }),
132 git($, ['log', '-n', String(limit), LOG_FORMAT], { cwd: root }),
133 git($, ['stash', 'list', STASH_FORMAT], { cwd: root }),
134 git($, ['tag', '--sort=-creatordate', TAG_FORMAT], { cwd: root }),
135 // Commit times for the activity chart, read apart from the paged log so it covers the whole window.
136 git($, ['log', `--since=${ACTIVITY_DAYS}.days.ago`, '--format=%ct'], { cwd: root }),
137 $.clock.now().catch(() => null),
138 ])
139 const summary = parseStatus(status.stdout)
140 const remoteNames = parseRemotes(remotes.stdout)
141 const saved = await $.store.get(remoteKey(root)).catch(() => undefined)
142 const operation = await readOperation($, gitDir)
143 await update($, repoState, () => ({
144 isRepo: true,
145 root,
146 ...summary,
147 branches: parseBranches(branches.stdout),
148 remotes: remoteNames,
149 remote: pickRemote(remoteNames, saved, summary.upstream),
150 remoteBranches: parseRemoteBranches(remoteBranches.stdout),
151 commits: log.exitCode === 0 ? parseLog(log.stdout) : [],
152 commitDays: recent.exitCode === 0 && now !== null ? parseCommitDays(recent.stdout, now) : [],
153 stashes: parseStashes(stashes.stdout),
154 tags: tags.exitCode === 0 ? parseTags(tags.stdout) : [],
155 operation,
156 }))
157
158 // Keep an open diff in step, or close it once the file has no changes left.
159 const shown = await read($, diffState)
160 if (shown !== null) {
161 const change = summary.changes.find(one => one.path === shown.path)
162 const isStill = change !== undefined && (shown.isStaged ? isStaged(change) : change.work !== ' ')
163 if (isStill) await showDiff($, change, shown.isStaged)
164 else await update($, diffState, () => null)
165 }
166}
167
168export async function selectRemote($: $, name: string) {
169 const { root } = await read($, repoState)
170 await update($, repoState, current => ({ ...current, remote: name }))
171 await $.store.set(remoteKey(root), name).catch(() => undefined)
172}
173
174export async function showDiff($: $, change: Change, staged: boolean) {
175 const { root } = await read($, repoState)
176 const args = change.index === '?'
177 ? ['diff', '--no-index', '--', '/dev/null', change.path]
178 : ['diff', ...(staged ? ['--cached'] : []), '--', change.path]
179 // `--no-index` exits 1 when the files differ, so the exit code is not checked.
180 const ran = await git($, args, { cwd: root })
181 await update($, diffState, () => ({ path: change.path, isStaged: staged, text: limitDiff(ran.stdout) }))
182}
183
184export function closeDiff($: $) {
185 return update($, diffState, () => null)
186}
187hooks/files-view.tsx 175 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import { cancelEdit, closeFile, goUp, mention, openDir, openEntry, openPath, save, startEdit } from './actions'
5import { confirmCard } from './confirm-view'
6import { card, chip } from './controls'
7import { DIR_ICON, fileIcon } from './icons'
8import { EMPTY_REPO } from './state'
9import { C, formatSize } from './theme'
10import { letterAt, markOf, toneOf, treeStatus } from './tree'
11import { askClaude } from './turn-actions'
12import type { UI } from './ui'
13
14// How many touched files the strip names before it sums up the rest.
15const STRIP_MAX = 6
16
17const activityState = atom({ plugin: 'workspace', key: 'activity' } as const, [])
18const confirmState = atom({ plugin: 'workspace', key: 'confirm' } as const, null)
19const cwdState = atom({ plugin: 'workspace', key: 'cwd' } as const, '')
20const repoState = atom({ plugin: 'workspace', key: 'repo' } as const, EMPTY_REPO)
21const dirState = atom({ plugin: 'workspace', key: 'dir' } as const, '')
22const draftState = atom({ plugin: 'workspace', key: 'draft' } as const, '')
23const entriesState = atom({ plugin: 'workspace', key: 'entries' } as const, [])
24const isEditingState = atom({ plugin: 'workspace', key: 'isEditing' } as const, false)
25const openFileState = atom({ plugin: 'workspace', key: 'openFile' } as const, null)
26
27export async function drawFiles($: EngineInterface, ui: UI, bodyRows: number) {
28 const { Box, Button, Code, Input, Text } = ui
29 const file = await read($, openFileState)
30
31 if (file !== null) {
32 const isEditing = await read($, isEditingState)
33 const draft = await read($, draftState)
34 const lines = file.text === '' ? 0 : file.text.split('\n').length
35 const confirm = await read($, confirmState)
36
37 return (
38 <Box flexDirection="column" gap={1}>
39 <Box justifyContent="space-between" gap={1}>
40 <Box gap={1} flexShrink={1}>
41 {chip(ui, { key: 'back', label: '‹ Back', onPress: () => closeFile($), kind: 'quiet' })}
42 <Text color={C.text} bold wrap="truncate-start">{file.path}</Text>
43 </Box>
44 <Box gap={1} flexShrink={0}>
45 {isEditing && chip(ui, { key: 'cancel', label: 'Cancel', onPress: () => cancelEdit($) })}
46 {isEditing && chip(ui, {
47 key: 'save',
48 label: 'Save',
49 kind: 'primary',
50 onPress: async () => save($, await read($, draftState)),
51 })}
52 {!isEditing && chip(ui, { key: 'explain', label: 'Explain', kind: 'quiet', onPress: () => askClaude($, 'Explain', file.path) })}
53 {!isEditing && chip(ui, { key: 'mention-open', label: '@', onPress: () => mention($, file.path) })}
54 {!isEditing && !file.readOnlyReason && chip(ui, { key: 'edit', label: 'Edit', onPress: () => startEdit($) })}
55 </Box>
56 </Box>
57 {confirm?.action === 'overwrite' && confirm.path === file.path && confirmCard($, ui, confirm)}
58 {file.readOnlyReason
59 ? <Text color={C.modified}>{file.readOnlyReason}</Text>
60 : <Text color={C.faint}>{`${lines} lines${isEditing ? ' · Enter saves' : ''}`}</Text>}
61 {isEditing ? (
62 <Input
63 key="editor"
64 value={draft}
65 rows={Math.max(5, bodyRows - 6)}
66 submitLabel="save"
67 autoFocus
68 onInput={text => update($, draftState, () => text)}
69 onSubmit={text => save($, text)}
70 />
71 ) : (
72 file.text !== '' && <Code source={file.text} path={file.path} startLine={1} />
73 )}
74 </Box>
75 )
76 }
77
78 const where = await read($, dirState)
79 const list = await read($, entriesState)
80 const activity = await read($, activityState)
81 const repo = await read($, repoState)
82 const status = treeStatus(repo.changes, repo.root, await read($, cwdState))
83 const recent = [...activity].reverse()
84 const join = (name: string) => (where ? `${where}/${name}` : name)
85
86 const crumbs = where === '' ? [] : where.split('/')
87
88 return (
89 <Box flexDirection="column">
90 {recent.length > 0 && (
91 <Box marginBottom={1}>
92 {card(ui, 'strip', (
93 <Box gap={1} flexWrap="wrap">
94 <Text color={C.muted} bold>Claude</Text>
95 {recent.slice(0, STRIP_MAX).flatMap((one, i) => [
96 i > 0 && <Text key={`strip-sep:${one.path}`} color={C.line}>─</Text>,
97 <Box key={`strip:${one.path}`}>
98 <Button key={`touch:${one.path}`} plain onPress={() => openPath($, one.path)}>
99 <Text color={one.isRunning ? C.busy : one.kind === 'edit' ? C.modified : C.muted} hover={{ color: C.text }}>
100 {`${markOf(one.kind)} ${one.path.slice(one.path.lastIndexOf('/') + 1)}`}
101 </Text>
102 </Button>
103 </Box>,
104 ])}
105 {recent.length > STRIP_MAX && <Text color={C.faint}>{`+${recent.length - STRIP_MAX}`}</Text>}
106 </Box>
107 ))}
108 </Box>
109 )}
110 <Box justifyContent="space-between" gap={1} marginBottom={1}>
111 <Box gap={1} flexShrink={1} flexWrap="wrap">
112 {where !== '' && chip(ui, { key: 'up', label: '‹ Up', onPress: () => goUp($), kind: 'quiet' })}
113 <Box key="crumb-root">
114 <Button key="crumb:" plain onPress={() => openDir($, '')}>
115 <Text color={where === '' ? C.text : C.muted} bold={where === ''} hover={{ color: C.text }}>./</Text>
116 </Button>
117 </Box>
118 {crumbs.flatMap((part, i) => {
119 const path = crumbs.slice(0, i + 1).join('/')
120 const isLast = i === crumbs.length - 1
121
122 return [
123 i > 0 && <Text key={`crumb-sep:${path}`} color={C.line}>/</Text>,
124 <Box key={`crumb-box:${path}`}>
125 <Button key={`crumb:${path}`} plain onPress={() => openDir($, path)}>
126 <Text color={isLast ? C.text : C.muted} bold={isLast} hover={{ color: C.text }}>{part}</Text>
127 </Button>
128 </Box>,
129 ]
130 })}
131 </Box>
132 <Text color={C.faint}>{`${list.length} items`}</Text>
133 </Box>
134 {list.length === 0 && <Text color={C.faint}>Empty folder.</Text>}
135 {list.map(entry => {
136 const isDir = entry.kind === 'dir'
137 const path = join(entry.name)
138 const letter = letterAt(status, path)
139 const touch = isDir
140 ? activity.find(one => one.path.startsWith(`${path}/`))
141 : activity.find(one => one.path === path)
142 const isRunning = activity.some(one => one.isRunning && (one.path === path || one.path.startsWith(`${path}/`)))
143 const tone = letter === undefined ? undefined : toneOf(letter)
144
145 return (
146 <Box
147 key={`row:${entry.name}`}
148 justifyContent="space-between"
149 gap={1}
150 backgroundColor={isRunning ? C.raised : undefined}
151 hover={{ backgroundColor: C.hover }}
152 >
153 <Box gap={1} flexShrink={1}>
154 <Box flexShrink={1} height={1} overflow="hidden">
155 <Button key={`entry:${entry.name}`} plain onPress={() => openEntry($, entry)}>
156 <Text color={tone ?? (isDir ? C.muted : C.faint)}>{`${isDir ? DIR_ICON : fileIcon(entry.name)} `}</Text>
157 <Text color={tone ?? C.text} bold={isDir}>{entry.name}</Text>
158 </Button>
159 </Box>
160 {touch !== undefined && (
161 <Text color={isRunning ? C.busy : C.muted}>{markOf(touch.kind)}</Text>
162 )}
163 </Box>
164 <Box gap={1} flexShrink={0}>
165 {!isDir && <Text color={C.faint}>{formatSize(entry.size)}</Text>}
166 {letter !== undefined && <Text color={toneOf(letter)} bold>{isDir ? '●' : letter}</Text>}
167 {chip(ui, { key: `mention:${entry.name}`, label: '@', onPress: () => mention($, path), kind: 'row' })}
168 </Box>
169 </Box>
170 )
171 })}
172 </Box>
173 )
174}
175hooks/git-view.tsx 341 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { Change, Repo } from '../types'
5import {
6 amend,
7 commit,
8 commitAll,
9 discard,
10 discardAll,
11 fetchRemote,
12 flash,
13 initRepo,
14 pull,
15 push,
16 setMessage,
17 stage,
18 stageAll,
19 switchBranch,
20 unstage,
21 unstageAll,
22} from './git-actions'
23import { showView } from './branch-actions'
24import { drawBranches } from './branches-view'
25import { drawDiff } from './diff-view'
26import { closeCommit, stashChanges } from './history-actions'
27import { drawHistory } from './history-view'
28import { drawStash } from './stash-view'
29import { drawTags } from './tags-view'
30import { fetchAll, forcePush, pullRebase, syncBranch, toggleSyncMore } from './sync-actions'
31import { badge, bar, card, chip, divider, panel, sectionHeader, segments } from './controls'
32import { colorOf, isConflicted, isStaged, isUnstaged, isUntracked, letterOf } from './git'
33import { drawConflicts, drawOperationBanner } from './merge-view'
34import { drawStatus } from './confirm-view'
35import { draftMessage } from './turn-actions'
36import { refreshRepo, selectRemote, showDiff } from './repo'
37import { EMPTY_REPO } from './state'
38import { fileIcon } from './icons'
39import { C, tint } from './theme'
40import type { UI } from './ui'
41
42const busyState = atom({ plugin: 'workspace', key: 'busy' } as const, null)
43const commitDiffState = atom({ plugin: 'workspace', key: 'commitDiff' } as const, null)
44const flashState = atom({ plugin: 'workspace', key: 'flash' } as const, null)
45const foldedState = atom({ plugin: 'workspace', key: 'folded' } as const, [])
46const diffState = atom({ plugin: 'workspace', key: 'diff' } as const, null)
47const gitViewState = atom({ plugin: 'workspace', key: 'gitView' } as const, 'changes')
48const messageState = atom({ plugin: 'workspace', key: 'message' } as const, '')
49const noticeState = atom({ plugin: 'workspace', key: 'notice' } as const, null)
50const repoState = atom({ plugin: 'workspace', key: 'repo' } as const, EMPTY_REPO)
51const syncMoreState = atom({ plugin: 'workspace', key: 'syncMore' } as const, false)
52
53type $ = EngineInterface
54
55function drawRow($: $, ui: UI, change: Change, staged: boolean, flashed: string | null) {
56 const { Box, Button, Text } = ui
57 const letter = letterOf(staged ? change.index : change.work)
58 const group = staged ? 'staged' : 'work'
59 const cut = change.path.lastIndexOf('/')
60 const name = change.path.slice(cut + 1)
61 const folder = cut < 0 ? '' : change.path.slice(0, cut)
62 const tone = colorOf(letter)
63 const isLit = flashed === `${group}:${change.path}`
64
65 return (
66 <Box
67 key={`${group}:${change.path}`}
68 justifyContent="space-between"
69 gap={1}
70 backgroundColor={isLit ? tint(tone, 0.2) : undefined}
71 hover={{ backgroundColor: C.hover }}
72 >
73 <Box flexShrink={1} height={1} overflow="hidden">
74 <Button key={`diff:${group}:${change.path}`} plain onPress={() => showDiff($, change, staged)}>
75 <Text color={tone}>{`${fileIcon(name)} `}</Text>
76 <Text color={C.text}>{name}</Text>
77 <Text color={C.faint}>{folder ? ` ${folder}` : ''}</Text>
78 </Button>
79 </Box>
80 <Box gap={1} flexShrink={0}>
81 <Text color={tone} bold>{letter}</Text>
82 {!staged && chip(ui, {
83 key: `discard:${change.path}`,
84 label: '↺',
85 kind: 'row',
86 onPress: () => discard($, change),
87 })}
88 {chip(ui, {
89 key: `${staged ? 'unstage' : 'stage'}:${change.path}`,
90 label: staged ? '−' : '+',
91 kind: 'row',
92 onPress: async () => {
93 await (staged ? unstage($, change.path) : stage($, change.path))
94 await flash($, `${staged ? 'work' : 'staged'}:${change.path}`)
95 },
96 })}
97 </Box>
98 </Box>
99 )
100}
101
102function drawSection($: $, ui: UI, title: string, changes: Change[], staged: boolean, isOpen: boolean, flashed: string | null) {
103 const { Box, Text } = ui
104 const key = staged ? 'staged' : 'work'
105
106 return (
107 <Box key={`section:${title}`} flexDirection="column">
108 {sectionHeader(ui, {
109 key,
110 title,
111 count: changes.length,
112 isOpen,
113 onToggle: () => update($, foldedState, list => (list.includes(key) ? list.filter(one => one !== key) : [...list, key])),
114 action: changes.length > 0 && (staged
115 ? chip(ui, { key: 'unstage-all', label: 'Unstage all', kind: 'quiet', onPress: () => unstageAll($) })
116 : chip(ui, { key: 'discard-all', label: 'Discard all', kind: 'quiet', onPress: () => discardAll($) })),
117 })}
118 {isOpen && changes.length === 0 && <Text color={C.faint}>{' Working tree clean.'}</Text>}
119 {isOpen && changes.map(change => drawRow($, ui, change, staged, flashed))}
120 </Box>
121 )
122}
123
124export async function drawGit($: $, ui: UI, width: number) {
125 const { Box, Code, Select, Text } = ui
126 const repo = await read($, repoState)
127
128 if (!repo.isRepo) {
129 const busy = await read($, busyState)
130 const notice = await read($, noticeState)
131
132 return (
133 <Box flexDirection="column" gap={1}>
134 <Text color={C.muted}>This folder isn't a git repository yet.</Text>
135 <Box>
136 {chip(ui, { key: 'init', label: 'Initialize Repository', kind: 'primary', onPress: () => initRepo($) })}
137 </Box>
138 {busy !== null && <Text color={C.busy}>{`${busy}...`}</Text>}
139 {notice !== null && <Text color={notice.kind === 'error' ? C.deleted : C.added}>{notice.text}</Text>}
140 </Box>
141 )
142 }
143
144 const busy = await read($, busyState)
145 const branchNames = repo.branches.some(one => one.name === repo.branch)
146 ? repo.branches.map(one => one.name)
147 : [repo.branch || 'HEAD', ...repo.branches.map(one => one.name)]
148
149 // The confirm card, busy label and notice, drawn where each view wants them.
150 const status = await drawStatus($, ui)
151
152 const diff = await read($, diffState)
153 if (diff !== null) {
154 const change = repo.changes.find(one => one.path === diff.path)
155 return drawDiff($, ui, diff, change !== undefined && isUntracked(change), status)
156 }
157
158 const shownCommit = await read($, commitDiffState)
159 if (shownCommit !== null) {
160 const { commit, text } = shownCommit
161
162 return (
163 <Box flexDirection="column" gap={1}>
164 <Box gap={1}>
165 {chip(ui, { key: 'commit-back', label: '‹ Back', onPress: () => closeCommit($), kind: 'quiet' })}
166 <Text color={C.text} bold wrap="truncate-end">{commit.subject}</Text>
167 <Text color={C.faint}>{commit.short}</Text>
168 </Box>
169 {text === ''
170 ? <Text color={C.faint}>No text changes to show.</Text>
171 : <Code source={text} format="diff" />}
172 </Box>
173 )
174 }
175
176 const view = await read($, gitViewState)
177 const isSyncOpen = await read($, syncMoreState)
178 const isPublished = repo.upstream !== undefined
179 return (
180 <Box flexDirection="column" gap={1}>
181 {card(ui, 'sync', [
182 <Box key="sync-branch" justifyContent="space-between" gap={1} flexWrap="wrap">
183 <Box gap={1}>
184 <Text color={C.accent}>●</Text>
185 <Select
186 key="branch"
187 value={repo.branch || 'HEAD'}
188 options={branchNames.map(name => ({ value: name }))}
189 onSelect={name => switchBranch($, name)}
190 />
191 {repo.remotes.length > 1 && (
192 <Select
193 key="remote"
194 value={repo.remote}
195 options={repo.remotes.map(name => ({ value: name }))}
196 onSelect={name => selectRemote($, name)}
197 />
198 )}
199 </Box>
200 <Box gap={1}>
201 <Text color={repo.ahead > 0 ? C.added : C.faint} bold={repo.ahead > 0}>{`↑ ${repo.ahead}`}</Text>
202 <Text color={repo.behind > 0 ? C.modified : C.faint} bold={repo.behind > 0}>{`↓ ${repo.behind}`}</Text>
203 </Box>
204 </Box>,
205 <Box key="sync-actions" gap={1} flexWrap="wrap">
206 {syncChip(ui, 'fetch', 'Fetch', 'Fetching', busy, false, () => fetchRemote($))}
207 {syncChip(ui, 'pull', 'Pull', 'Pulling', busy, repo.behind > 0, () => pull($))}
208 {syncChip(ui, 'push', isPublished ? 'Push ↑' : 'Publish ↑', 'Pushing', busy, isPublished ? repo.ahead > 0 && repo.behind === 0 : repo.remote !== '', () => push($))}
209 {chip(ui, { key: 'refresh', label: '↻', kind: 'quiet', onPress: () => refreshRepo($) })}
210 {chip(ui, { key: 'sync-more', label: isSyncOpen ? '⋯ Less' : '⋯ More', kind: 'quiet', onPress: () => toggleSyncMore($) })}
211 </Box>,
212 isSyncOpen && (
213 <Box key="sync-more-actions" gap={1} flexWrap="wrap">
214 {syncChip(ui, 'pull-rebase', 'Pull (rebase)', 'Pulling', busy, false, () => pullRebase($))}
215 {syncChip(ui, 'sync', 'Sync ⇅', 'Syncing', busy, false, () => syncBranch($))}
216 {syncChip(ui, 'fetch-all', 'Fetch all', 'Fetching', busy, false, () => fetchAll($))}
217 {chip(ui, { key: 'force-push', label: 'Force push', kind: 'quiet', state: busy === null ? undefined : 'disabled', onPress: () => forcePush($) })}
218 </Box>
219 ),
220 ], 1)}
221
222 {drawOperationBanner($, ui, repo)}
223
224 {segments(ui, [
225 { key: 'view-changes', label: 'Changes', isActive: view === 'changes', onPress: () => showView($, 'changes') },
226 { key: 'view-history', label: 'History', isActive: view === 'history', onPress: () => showView($, 'history') },
227 { key: 'view-branches', label: `Branches ${repo.branches.length}`, isActive: view === 'branches', onPress: () => showView($, 'branches') },
228 {
229 key: 'view-tags',
230 label: repo.tags.length > 0 ? `Tags ${repo.tags.length}` : 'Tags',
231 isActive: view === 'tags',
232 onPress: () => showView($, 'tags'),
233 },
234 {
235 key: 'view-stash',
236 label: repo.stashes.length > 0 ? `Stash ${repo.stashes.length}` : 'Stash',
237 isActive: view === 'stash',
238 onPress: () => showView($, 'stash'),
239 },
240 ])}
241
242 {view === 'history' && await drawHistory($, ui, repo, status)}
243 {view === 'branches' && await drawBranches($, ui, repo, status)}
244 {view === 'tags' && await drawTags($, ui, repo, status)}
245 {view === 'stash' && drawStash($, ui, repo, status)}
246 {view === 'changes' && await drawChanges($, ui, repo, status, width)}
247 </Box>
248 )
249}
250
251// Fetch, Pull and Push: lit as the main action when it's the one to press,
252// shown as running while its step runs, and inert while another one does.
253function syncChip(ui: UI, key: string, label: string, running: string, busy: string | null, isMain: boolean, onPress: () => unknown) {
254 const state = busy === null ? undefined : busy === running ? 'busy' : 'disabled'
255
256 return chip(ui, { key, label: state === 'busy' ? running : label, kind: isMain ? 'primary' : 'secondary', state, onPress })
257}
258
259const SUMMARY = [
260 { key: 'added', letters: ['A', 'U'], sign: '+', color: C.added },
261 { key: 'modified', letters: ['M', 'T'], sign: '~', color: C.modified },
262 { key: 'renamed', letters: ['R', 'C'], sign: '→', color: C.renamed },
263 { key: 'deleted', letters: ['D'], sign: '−', color: C.deleted },
264]
265
266// Changed files by kind: a count per letter and a bar of how they split.
267function drawSummary(ui: UI, changes: Change[], width: number) {
268 const { Box, Text } = ui
269 const letters = changes.map(one => letterOf(one.work !== ' ' ? one.work : one.index))
270 const parts = SUMMARY.map(part => ({ ...part, count: letters.filter(one => part.letters.includes(one)).length }))
271 const files = new Set(changes.map(one => one.path)).size
272
273 return (
274 <Box key="summary" justifyContent="space-between" gap={2}>
275 <Box gap={1} flexShrink={0}>
276 {badge(ui, 'files', files === 1 ? '1 file' : `${files} files`, C.muted)}
277 {parts.filter(part => part.count > 0).map(part => (
278 <Text key={`sum:${part.key}`} color={part.color}>{`${part.sign}${part.count}`}</Text>
279 ))}
280 </Box>
281 {bar(ui, 'changes', parts, Math.min(24, Math.max(0, width - 24)))}
282 </Box>
283 )
284}
285
286async function drawChanges($: $, ui: UI, repo: Repo, status: JSX.Element | null, width: number) {
287 const { Box, Input, Text } = ui
288 const busy = await read($, busyState)
289 const message = await read($, messageState)
290 const folded = await read($, foldedState)
291 const flashed = await read($, flashState)
292 const conflicts = repo.changes.filter(isConflicted)
293 const staged = repo.changes.filter(one => !isConflicted(one) && isStaged(one))
294 const unstaged = repo.changes.filter(one => !isConflicted(one) && isUnstaged(one))
295
296 return (
297 <Box key="changes" flexDirection="column" gap={1}>
298 {panel(ui, 'message-frame', [
299 <Input
300 key="message"
301 value={message}
302 rows={3}
303 placeholder={`Message (Enter to commit on ${repo.branch})`}
304 submitLabel="commit"
305 onInput={text => setMessage($, text)}
306 onSubmit={() => commit($)}
307 />,
308 <Box key="message-rule">{divider(ui, width - 4)}</Box>,
309 <Box key="message-footer" justifyContent="space-between" gap={1} flexWrap="wrap">
310 <Text color={C.faint}>{staged.length === 1 ? '1 staged file' : `${staged.length} staged files`}</Text>
311 <Box gap={1} flexWrap="wrap">
312 {staged.length > 0 && chip(ui, {
313 key: 'draft-message',
314 label: '✦ Message',
315 kind: 'quiet',
316 state: busy === 'Writing message' ? 'busy' : undefined,
317 onPress: () => draftMessage($),
318 })}
319 {repo.changes.length > 0 && chip(ui, { key: 'stash', label: 'Stash', kind: 'quiet', onPress: () => stashChanges($) })}
320 {unstaged.length > 0 && chip(ui, { key: 'stage-all', label: 'Stage all', kind: 'quiet', onPress: () => stageAll($) })}
321 {unstaged.length > 0 && chip(ui, { key: 'commit-all', label: 'Commit all', onPress: () => commitAll($) })}
322 {chip(ui, { key: 'amend', label: 'Amend', onPress: () => amend($) })}
323 {chip(ui, { key: 'commit', label: 'Commit →', kind: 'primary', onPress: () => commit($) })}
324 </Box>
325 </Box>,
326 ], message.trim() === '' ? C.line : C.accent)}
327
328 {status}
329
330 {repo.changes.length > 0 && drawSummary(ui, repo.changes, width)}
331
332 {drawConflicts($, ui, conflicts)}
333 {card(ui, 'change-lists', [
334 staged.length > 0 && drawSection($, ui, 'Staged Changes', staged, true, !folded.includes('staged'), flashed),
335 staged.length > 0 && <Box key="lists-rule">{divider(ui, width - 2)}</Box>,
336 drawSection($, ui, 'Changes', unstaged, false, !folded.includes('work'), flashed),
337 ])}
338 </Box>
339 )
340}
341hooks/github-actions.ts 99 lines1// What the GitHub tab's buttons do: reading pull requests and issues through
2// the GitHub CLI, and handing a reference to the prompt box.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface } from 'claude-code'
5
6import type { GhItem, GithubView } from '../types'
7import {
8 ghError,
9 ISSUE_LIST_FIELDS,
10 ISSUE_VIEW_FIELDS,
11 LIST_LIMIT,
12 parseDetail,
13 parseItems,
14 PR_LIST_FIELDS,
15 PR_VIEW_FIELDS,
16 referenceText,
17} from './github'
18import { EMPTY_GITHUB, EMPTY_REPO } from './state'
19
20const ghDetailState = atom({ plugin: 'workspace', key: 'ghDetail' } as const, null)
21const githubState = atom({ plugin: 'workspace', key: 'github' } as const, EMPTY_GITHUB)
22const githubViewState = atom({ plugin: 'workspace', key: 'githubView' } as const, 'pulls')
23const repoState = atom({ plugin: 'workspace', key: 'repo' } as const, EMPTY_REPO)
24
25type $ = EngineInterface
26
27// Runs gh in the repo with its prompts off, so a missing login fails instead of waiting.
28async function gh($: $, args: string[]) {
29 const { root } = await read($, repoState)
30 const ran = await $.process
31 .run(['gh', ...args], {
32 ...(root ? { cwd: root } : {}),
33 timeoutMs: 30000,
34 env: { GH_PROMPT_DISABLED: '1', NO_COLOR: '1' },
35 })
36 .catch((error: unknown) => {
37 const reason = error instanceof Error ? error.message : String(error)
38 throw new Error(`Couldn't run gh (${reason}). Is the GitHub CLI installed?`)
39 })
40 if (ran.exitCode !== 0) throw new Error(ghError(ran.stderr, ran.stdout))
41
42 return ran.stdout
43}
44
45const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
46
47export async function refreshGithub($: $) {
48 if ((await read($, githubState)).isLoading) return
49
50 await update($, githubState, current => ({ ...current, isLoading: true, error: '' }))
51 try {
52 const repo = (await gh($, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'])).trim()
53 const limit = String(LIST_LIMIT)
54 const [pulls, issues] = await Promise.all([
55 gh($, ['pr', 'list', '--state', 'open', '--limit', limit, '--json', PR_LIST_FIELDS]),
56 gh($, ['issue', 'list', '--state', 'open', '--limit', limit, '--json', ISSUE_LIST_FIELDS]),
57 ])
58 const now = await $.clock.now()
59 await update($, githubState, () => ({
60 isLoading: false,
61 error: '',
62 repo,
63 pulls: parseItems(pulls, 'pr', now),
64 issues: parseItems(issues, 'issue', now),
65 }))
66 } catch (error) {
67 await update($, githubState, current => ({ ...current, isLoading: false, error: message(error) }))
68 }
69}
70
71export function showGithubView($: $, view: GithubView) {
72 return update($, githubViewState, () => view)
73}
74
75export async function showItem($: $, item: GhItem) {
76 await update($, githubState, current => ({ ...current, isLoading: true, error: '' }))
77 try {
78 const out = item.kind === 'pr'
79 ? await gh($, ['pr', 'view', String(item.number), '--json', PR_VIEW_FIELDS])
80 : await gh($, ['issue', 'view', String(item.number), '--json', ISSUE_VIEW_FIELDS])
81 const now = await $.clock.now()
82 await update($, ghDetailState, () => parseDetail(out, item, now))
83 await update($, githubState, current => ({ ...current, isLoading: false }))
84 } catch (error) {
85 await update($, githubState, current => ({ ...current, isLoading: false, error: message(error) }))
86 }
87}
88
89export function closeItem($: $) {
90 return update($, ghDetailState, () => null)
91}
92
93// Puts a short reference to the item in the prompt box at the cursor.
94export async function mentionItem($: $, item: GhItem) {
95 const box = await $.prompt.read()
96 const filled = await $.prompt.fill({ text: referenceText(item, box.text.slice(0, box.cursor)), mode: 'insert' })
97 if (!filled.isFilled) $.ui.toast(`Couldn't add #${item.number} to the prompt right now.`)
98}
99hooks/github-view.tsx 164 lines1import { atom, read } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { GhDetail, GhItem, Review } from '../types'
5import { badge, bar, card, chip, segments } from './controls'
6import { checksLabel, checksState, detailMarkdown, REVIEW_LABEL } from './github'
7import { closeItem, mentionItem, refreshGithub, showGithubView, showItem } from './github-actions'
8import { EMPTY_GITHUB } from './state'
9import { C } from './theme'
10import type { UI } from './ui'
11
12const ghDetailState = atom({ plugin: 'workspace', key: 'ghDetail' } as const, null)
13const githubState = atom({ plugin: 'workspace', key: 'github' } as const, EMPTY_GITHUB)
14const githubViewState = atom({ plugin: 'workspace', key: 'githubView' } as const, 'pulls')
15
16type $ = EngineInterface
17
18const keyOf = (item: GhItem) => `${item.kind}:${item.number}`
19
20// A glyph and a color per CI outcome and review state, so neither leans on color alone.
21const CHECK_MARK = {
22 passing: { glyph: '✓', color: C.added },
23 failing: { glyph: '✗', color: C.deleted },
24 pending: { glyph: '●', color: C.modified },
25 none: { glyph: ' ', color: C.faint },
26} as const
27
28const REVIEW_MARK: Record<Review, { glyph: string; color: string }> = {
29 approved: { glyph: '✓', color: C.added },
30 changes: { glyph: '±', color: C.deleted },
31 required: { glyph: '○', color: C.muted },
32 '': { glyph: ' ', color: C.faint },
33}
34
35function drawItemRow($: $, ui: UI, item: GhItem) {
36 const { Box, Button, Text } = ui
37 const ci = CHECK_MARK[checksState(item.checks)]
38 const review = REVIEW_MARK[item.review]
39
40 return (
41 <Box key={`gh-row:${keyOf(item)}`} justifyContent="space-between" gap={1} hover={{ backgroundColor: C.hover }}>
42 <Box flexShrink={1} height={1} overflow="hidden">
43 <Button key={`gh-show:${keyOf(item)}`} plain onPress={() => showItem($, item)}>
44 <Text color={item.isDraft ? C.faint : item.kind === 'pr' ? C.added : C.renamed}>{'● '}</Text>
45 <Text color={C.faint}>{`#${item.number} `}</Text>
46 <Text color={C.text} wrap="truncate-end">{item.title}</Text>
47 </Button>
48 </Box>
49 <Box gap={1} flexShrink={0}>
50 <Text color={C.faint}>{`${item.isDraft ? 'draft · ' : ''}${item.author} · ${item.age}`}</Text>
51 {item.kind === 'pr' && <Text color={review.color}>{review.glyph}</Text>}
52 {item.kind === 'pr' && <Text color={ci.color} bold>{ci.glyph}</Text>}
53 {chip(ui, { key: `gh-mention:${keyOf(item)}`, label: '@', kind: 'row', onPress: () => mentionItem($, item) })}
54 </Box>
55 </Box>
56 )
57}
58
59// Open pull requests at a glance: how their CI came out and where review stands.
60function drawPullSummary(ui: UI, pulls: GhItem[], width: number) {
61 const { Box, Text } = ui
62 if (pulls.length === 0) return null
63
64 const ci = (['passing', 'failing', 'pending'] as const).map(state => ({
65 key: state,
66 label: state,
67 count: pulls.filter(pull => checksState(pull.checks) === state).length,
68 color: CHECK_MARK[state].color,
69 }))
70 const reviews = (['approved', 'changes', 'required'] as const).map(state => ({
71 key: state,
72 label: REVIEW_LABEL[state],
73 count: pulls.filter(pull => pull.review === state).length,
74 color: REVIEW_MARK[state].color,
75 }))
76 const line = (key: string, title: string, parts: { key: string; label: string; count: number; color: string }[]) => (
77 <Box key={key} justifyContent="space-between" gap={2}>
78 <Box gap={1} flexShrink={1} flexWrap="wrap">
79 <Text color={C.muted} bold>{title}</Text>
80 {parts.every(part => part.count === 0) && <Text color={C.faint}>none reported</Text>}
81 {parts.filter(part => part.count > 0).map(part => (
82 <Text key={`${key}:${part.key}`} color={part.color}>{`${part.count} ${part.label}`}</Text>
83 ))}
84 </Box>
85 {bar(ui, key, parts, Math.min(20, Math.max(0, width - 44)))}
86 </Box>
87 )
88
89 return card(ui, 'gh-summary', [line('gh-sum-ci', 'Checks', ci), line('gh-sum-review', 'Review', reviews)])
90}
91
92const STATE_COLOR: Record<string, string> = { open: C.added, draft: C.faint, merged: C.renamed, closed: C.deleted }
93
94function drawDetail($: $, ui: UI, detail: GhDetail, status: JSX.Element | null) {
95 const { Box, Link, Markdown, Text } = ui
96 const { item } = detail
97 const ci = checksState(detail.checks)
98
99 return (
100 <Box flexDirection="column" gap={1}>
101 <Box justifyContent="space-between" gap={1}>
102 <Box gap={1} flexShrink={1}>
103 {chip(ui, { key: 'gh-back', label: '‹ Back', onPress: () => closeItem($), kind: 'quiet' })}
104 <Text color={C.faint}>{`#${item.number}`}</Text>
105 <Text color={C.text} bold wrap="truncate-end">{item.title}</Text>
106 </Box>
107 <Box gap={1} flexShrink={0}>
108 <Link href={item.url} label="Open" />
109 {chip(ui, { key: 'gh-mention-open', label: '@', onPress: () => mentionItem($, item) })}
110 </Box>
111 </Box>
112 <Box gap={1} flexWrap="wrap">
113 {detail.state !== '' && badge(ui, 'gh-state', detail.state, STATE_COLOR[detail.state] ?? C.muted)}
114 {detail.review !== '' && badge(ui, 'gh-review', `${REVIEW_MARK[detail.review].glyph} ${REVIEW_LABEL[detail.review]}`, REVIEW_MARK[detail.review].color)}
115 {detail.checks !== null && badge(ui, 'gh-checks', `${CHECK_MARK[ci].glyph} ${checksLabel(detail.checks)}`, CHECK_MARK[ci].color)}
116 <Text color={C.muted}>{detail.meta.join(' · ')}</Text>
117 </Box>
118 {item.labels.length > 0 && <Text color={C.faint}>{item.labels.join(' · ')}</Text>}
119 {status}
120 <Markdown text={detailMarkdown(detail)} />
121 </Box>
122 )
123}
124
125export async function drawGithub($: $, ui: UI, width: number) {
126 const { Box, Text } = ui
127 const github = await read($, githubState)
128
129 const status = !github.isLoading && github.error === '' ? null : (
130 <Box key="gh-status" flexDirection="column">
131 {github.isLoading && <Text color={C.busy}>Loading...</Text>}
132 {github.error !== '' && <Text color={C.deleted}>{`✗ ${github.error}`}</Text>}
133 </Box>
134 )
135
136 const detail = await read($, ghDetailState)
137 if (detail !== null) return drawDetail($, ui, detail, status)
138
139 const view = await read($, githubViewState)
140 const items = view === 'pulls' ? github.pulls : github.issues
141 const isEmpty = !github.isLoading && github.error === '' && github.repo !== '' && items.length === 0
142
143 return (
144 <Box flexDirection="column" gap={1}>
145 <Box justifyContent="space-between" gap={1}>
146 {segments(ui, [
147 { key: 'gh-view-pulls', label: `Pull requests ${github.pulls.length}`, isActive: view === 'pulls', onPress: () => showGithubView($, 'pulls') },
148 { key: 'gh-view-issues', label: `Issues ${github.issues.length}`, isActive: view === 'issues', onPress: () => showGithubView($, 'issues') },
149 ])}
150 <Box gap={1}>
151 {github.repo !== '' && <Text color={C.faint}>{github.repo}</Text>}
152 {chip(ui, { key: 'gh-refresh', label: '↻', kind: 'quiet', onPress: () => refreshGithub($) })}
153 </Box>
154 </Box>
155 {view === 'pulls' && drawPullSummary(ui, github.pulls, width)}
156 {status}
157 <Box flexDirection="column">
158 {isEmpty && <Text color={C.faint}>{view === 'pulls' ? 'No open pull requests.' : 'No open issues.'}</Text>}
159 {items.map(item => drawItemRow($, ui, item))}
160 </Box>
161 </Box>
162 )
163}
164hooks/controls.tsx 180 lines1// Small drawing pieces both tabs share, all painted from the theme tokens.
2import { C, tint } from './theme'
3import type { UI } from './ui'
4
5type ChipArgs = {
6 key: string
7 label: string
8 onPress: () => unknown
9 // `row` is a per-row action: faint at rest so a long list stays calm.
10 // `danger` is the yes of a step that throws work away.
11 kind?: 'primary' | 'secondary' | 'quiet' | 'row' | 'danger'
12 // Drawn as text with no button: `busy` while its step runs, `disabled` when it can't run.
13 state?: 'busy' | 'disabled'
14}
15
16// A button drawn as a filled chip; the focus ring still inverts it.
17export function chip(ui: UI, { key, label, onPress, kind = 'secondary', state }: ChipArgs) {
18 const { Box, Button, Text } = ui
19
20 if (state !== undefined) {
21 const isBusy = state === 'busy'
22
23 return (
24 <Box key={`chip:${key}`}>
25 <Text backgroundColor={isBusy ? tint(C.busy, 0.18, C.bg) : undefined} color={isBusy ? C.busy : C.faint}>
26 {` ${isBusy ? `${label}…` : label} `}
27 </Text>
28 </Box>
29 )
30 }
31
32 const look = {
33 primary: { backgroundColor: C.accent, color: C.bg, bold: true, hover: { backgroundColor: C.accentHover } },
34 secondary: { backgroundColor: C.raised, color: C.text, bold: false, hover: { backgroundColor: C.hover, color: C.text } },
35 quiet: { backgroundColor: undefined, color: C.muted, bold: false, hover: { backgroundColor: C.hover, color: C.text } },
36 row: { backgroundColor: undefined, color: C.faint, bold: false, hover: { backgroundColor: C.hover, color: C.text } },
37 danger: { backgroundColor: C.deleted, color: C.bg, bold: true, hover: { backgroundColor: tint(C.deleted, 0.75, C.text) } },
38 }[kind]
39
40 return (
41 <Box key={`chip:${key}`}>
42 <Button key={key} plain onPress={onPress}>
43 <Text backgroundColor={look.backgroundColor} color={look.color} bold={look.bold} hover={look.hover}>
44 {` ${label} `}
45 </Text>
46 </Button>
47 </Box>
48 )
49}
50
51// A top-level tab: the active one is underlined in the accent.
52export function tab(ui: UI, key: string, label: string, isActive: boolean, onPress: () => unknown) {
53 const { Box, Button, Text } = ui
54
55 return (
56 <Box key={`tab:${key}`}>
57 <Button key={key} plain onPress={onPress}>
58 <Text color={isActive ? C.accent : C.muted} bold={isActive} underline={isActive} hover={{ color: C.text }}>
59 {label}
60 </Text>
61 </Button>
62 </Box>
63 )
64}
65
66type Segment = { key: string; label: string; isActive: boolean; onPress: () => unknown }
67
68// A row of sub-views split by dots; the active one sits on a raised pill.
69export function segments(ui: UI, items: Segment[]) {
70 const { Box, Button, Text } = ui
71
72 return (
73 <Box gap={1} flexWrap="wrap">
74 {items.flatMap((item, i) => [
75 i > 0 && <Text key={`sep:${item.key}`} color={C.line}>·</Text>,
76 <Box key={`tab:${item.key}`}>
77 <Button key={item.key} plain onPress={item.onPress}>
78 <Text
79 color={item.isActive ? C.text : C.muted}
80 backgroundColor={item.isActive ? C.raised : undefined}
81 bold={item.isActive}
82 hover={{ color: C.text }}
83 >
84 {item.isActive ? ` ${item.label} ` : item.label}
85 </Text>
86 </Button>
87 </Box>,
88 ])}
89 </Box>
90 )
91}
92
93type SectionArgs = {
94 key: string
95 title: string
96 count: number
97 isOpen: boolean
98 onToggle: () => unknown
99 action?: JSX.Element | false | null
100}
101
102// A list's heading: a chevron that folds it, the title, the count, an optional action.
103export function sectionHeader(ui: UI, { key, title, count, isOpen, onToggle, action }: SectionArgs) {
104 const { Box, Button, Text } = ui
105
106 return (
107 <Box key={`head:${key}`} justifyContent="space-between" gap={1}>
108 <Box gap={1}>
109 <Button key={`fold:${key}`} plain onPress={onToggle}>
110 <Text color={C.faint} hover={{ color: C.text }}>{isOpen ? '▾' : '▸'}</Text>
111 </Button>
112 <Text color={C.text} bold>{title}</Text>
113 <Text color={C.faint}>{String(count)}</Text>
114 </Box>
115 {action}
116 </Box>
117 )
118}
119
120// A rounded frame around a group that belongs together.
121export function panel(ui: UI, key: string, children: JSX.Element | JSX.Element[], color: string = C.line) {
122 const { Box } = ui
123
124 return (
125 <Box key={key} flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
126 {children}
127 </Box>
128 )
129}
130
131// A raised block with no frame, one step lighter than the canvas.
132export function card(ui: UI, key: string, children: JSX.Element | (JSX.Element | false | null)[], gap = 0) {
133 const { Box } = ui
134
135 return (
136 <Box key={key} flexDirection="column" backgroundColor={C.card} paddingX={1} gap={gap}>
137 {children}
138 </Box>
139 )
140}
141
142// A short label on a tinted fill of its own color: HEAD, approved, 2 failing.
143export function badge(ui: UI, key: string, label: string, color: string) {
144 const { Text } = ui
145
146 return <Text key={`badge:${key}`} color={color} backgroundColor={tint(color)} bold>{` ${label} `}</Text>
147}
148
149type BarPart = { key: string; count: number; color: string }
150
151// One row split into colored runs by share: how the changes or checks add up.
152export function bar(ui: UI, key: string, parts: BarPart[], width: number) {
153 const { Box, Text } = ui
154 const total = parts.reduce((sum, part) => sum + part.count, 0)
155 if (total === 0 || width < 1) return null
156
157 // Every non-empty part keeps at least one cell; the last one takes what rounding left.
158 const shown = parts.filter(part => part.count > 0)
159 let left = width
160 const runs = shown.map((part, i) => {
161 const room = left - (shown.length - 1 - i)
162 const size = i === shown.length - 1 ? left : Math.max(1, Math.min(room, Math.round((part.count / total) * width)))
163 left -= size
164
165 return { ...part, size }
166 })
167
168 return (
169 <Box key={`bar:${key}`}>
170 {runs.map(run => <Text key={`bar:${key}:${run.key}`} color={run.color}>{'━'.repeat(run.size)}</Text>)}
171 </Box>
172 )
173}
174
175export function divider(ui: UI, width: number) {
176 const { Text } = ui
177
178 return <Text color={C.line}>{'─'.repeat(Math.max(1, width))}</Text>
179}
180hooks/history-actions.ts 180 lines1// What the History and Stash views' buttons do, plus the Stash button in Changes.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface } from 'claude-code'
4
5import type { Commit, Confirm, Stash } from '../types'
6import { refreshDir, reloadFile } from './actions'
7import { isPushed, isStaged, limitDiff, shortError } from './git'
8import { confirmed, fail, failedStep, git, gitOk, refreshRepo, run } from './repo'
9import { EMPTY_REPO, HISTORY_PAGE } from './state'
10
11const commitDiffState = atom({ plugin: 'workspace', key: 'commitDiff' } as const, null)
12const hasImagesState = atom({ plugin: 'workspace', key: 'hasImages' } as const, false)
13const historyLimitState = atom({ plugin: 'workspace', key: 'historyLimit' } as const, HISTORY_PAGE)
14const messageState = atom({ plugin: 'workspace', key: 'message' } as const, '')
15const repoState = atom({ plugin: 'workspace', key: 'repo' } as const, EMPTY_REPO)
16
17type $ = EngineInterface
18
19// History
20
21export async function showCommit($: $, commit: Commit) {
22 const { root } = await read($, repoState)
23 // An empty format leaves just the patch, which the Code element draws as a diff.
24 const ran = await git($, ['show', '--format=', '--patch', commit.sha], { cwd: root })
25 if (ran.exitCode !== 0) return fail($, new Error(shortError(ran.stderr, ran.stdout)))
26
27 await update($, commitDiffState, () => ({ commit, text: limitDiff(ran.stdout) }))
28}
29
30export function closeCommit($: $) {
31 return update($, commitDiffState, () => null)
32}
33
34export async function loadMore($: $) {
35 await update($, historyLimitState, limit => limit + HISTORY_PAGE)
36 await refreshRepo($)
37}
38
39// Makes a new commit that undoes `commit`. A conflict leaves the revert
40// waiting in Merge Changes, like a merge does.
41export async function revert($: $, commit: Commit) {
42 const ask: Confirm = {
43 action: 'revert',
44 path: commit.sha,
45 question: `Revert "${commit.subject}"? This makes a new commit that undoes it.`,
46 yes: 'Yes, revert',
47 }
48 if (!(await confirmed($, ask))) return
49
50 await run($, 'Reverting', async () => {
51 const { root } = await read($, repoState)
52 const ran = await git($, ['revert', '--no-edit', commit.sha], { cwd: root })
53
54 if (ran.exitCode !== 0) {
55 // A stop on conflicts rewrote files, so pick that up before saying so.
56 await reloadFile($)
57 await refreshDir($)
58 await failedStep($, 'Revert', ran)
59 }
60
61 $.ui.toast(`Reverted ${commit.short}`)
62 await reloadFile($)
63 await refreshDir($)
64 })
65}
66
67// Takes HEAD back off the branch and leaves its changes staged, with its
68// message back in the box if that's empty. Asks once the commit is pushed.
69export async function undoCommit($: $) {
70 const current = await read($, repoState)
71 const head = current.commits[0]
72 if (head === undefined) return fail($, new Error('No commit to undo.'))
73 if (current.operation !== null) return fail($, new Error('Finish or abort the operation in progress first.'))
74 if (isPushed(current)) {
75 const ask: Confirm = {
76 action: 'undo-commit',
77 path: head.sha,
78 question: `Undo "${head.subject}"? It's pushed already, so pushing after needs a force push.`,
79 yes: 'Yes, undo',
80 }
81 if (!(await confirmed($, ask))) return
82 }
83
84 await run($, 'Undoing', async () => {
85 const message = await gitOk($, ['log', '-1', '--format=%B', head.sha])
86 // A first commit has no parent to reset to, so the branch ref goes instead.
87 await gitOk($, head.parents.length === 0 ? ['update-ref', '-d', 'HEAD'] : ['reset', '--soft', 'HEAD~1'])
88 await update($, messageState, text => (text.trim() === '' ? message.stdout.trim() : text))
89 $.ui.toast(`Undid ${head.short}. Its changes are staged.`)
90 })
91}
92
93// Stash
94
95// Untracked files go in too. A typed commit message names the stash.
96export async function stashChanges($: $) {
97 const text = (await read($, messageState)).trim()
98 if ((await read($, repoState)).changes.length === 0) return fail($, new Error('Nothing to stash.'))
99
100 await run($, 'Stashing', async () => {
101 await gitOk($, ['stash', 'push', '-u', ...(text === '' ? [] : ['-m', text])])
102 await update($, messageState, () => '')
103 $.ui.toast('Stashed your changes')
104 await reloadFile($)
105 await refreshDir($)
106 })
107}
108
109// Only what's staged goes in; the rest stays in the working tree.
110export async function stashStaged($: $) {
111 const text = (await read($, messageState)).trim()
112 if (!(await read($, repoState)).changes.some(isStaged)) return fail($, new Error('Nothing staged to stash.'))
113
114 await run($, 'Stashing', async () => {
115 await gitOk($, ['stash', 'push', '--staged', ...(text === '' ? [] : ['-m', text])])
116 await update($, messageState, () => '')
117 $.ui.toast('Stashed your staged changes')
118 await reloadFile($)
119 await refreshDir($)
120 })
121}
122
123// Pop drops the stash once it applies; on a conflict git keeps it either way.
124export function applyStash($: $, stash: Stash, isPop: boolean) {
125 return run($, isPop ? 'Popping' : 'Applying', async () => {
126 await gitOk($, ['stash', isPop ? 'pop' : 'apply', stash.ref])
127 $.ui.toast(`${isPop ? 'Popped' : 'Applied'} ${stash.ref}`)
128 await reloadFile($)
129 await refreshDir($)
130 })
131}
132
133export async function dropStash($: $, stash: Stash) {
134 const ask: Confirm = {
135 action: 'drop-stash',
136 path: stash.ref,
137 question: `Drop ${stash.ref}? Its changes are gone for good.`,
138 yes: 'Yes, drop',
139 }
140 if (!(await confirmed($, ask))) return
141
142 await run($, 'Dropping', async () => {
143 await gitOk($, ['stash', 'drop', stash.ref])
144 $.ui.toast(`Dropped ${stash.ref}`)
145 })
146}
147
148export async function dropAllStashes($: $) {
149 const { stashes } = await read($, repoState)
150 if (stashes.length === 0) return
151
152 const count = stashes.length === 1 ? 'the 1 stash' : `all ${stashes.length} stashes`
153 const ask: Confirm = { action: 'drop-all-stashes', question: `Drop ${count}? They're gone for good.`, yes: 'Yes, drop all' }
154 if (!(await confirmed($, ask))) return
155
156 await run($, 'Dropping', async () => {
157 await gitOk($, ['stash', 'clear'])
158 $.ui.toast('Dropped every stash')
159 })
160}
161
162// Graph
163
164// Terminals that draw kitty-protocol pictures. CLAUDE_WORKSPACE_GRAPH set to
165// `image` or `text` overrides the guess, for a terminal it gets wrong.
166export async function detectImages($: $) {
167 const lower = (value: string | undefined) => (value ?? '').toLowerCase()
168 const quiet = () => undefined
169 const [forced, term, program, kitty] = (await Promise.all([
170 $.env.get('CLAUDE_WORKSPACE_GRAPH').catch(quiet),
171 $.env.get('TERM').catch(quiet),
172 $.env.get('TERM_PROGRAM').catch(quiet),
173 $.env.get('KITTY_WINDOW_ID').catch(quiet),
174 ])).map(lower)
175 const guess = term === 'xterm-kitty' || term === 'xterm-ghostty' || program === 'ghostty' || kitty !== ''
176 const hasImages = forced === 'image' ? true : forced === 'text' ? false : guess
177
178 await update($, hasImagesState, () => hasImages)
179}
180hooks/git.ts 293 lines1// Pure git helpers: no `$`, so the tests can run them directly.
2import type { Branch, Change, Commit, Operation, Repo, Stash, Tag } from '../types'
3import { C } from './theme'
4
5export type StatusSummary = {
6 branch: string
7 upstream?: string
8 ahead: number
9 behind: number
10 changes: Change[]
11}
12
13// The most characters the `Code` element draws in one go.
14export const CODE_LIMIT = 100000
15
16// Reads `git status --porcelain=v1 -z --branch`.
17export const parseStatus = (out: string): StatusSummary => {
18 const records = out.split('\0').filter(record => record !== '')
19 const summary: StatusSummary = { branch: '', ahead: 0, behind: 0, changes: [] }
20
21 for (let i = 0; i < records.length; i += 1) {
22 const record = records[i] ?? ''
23
24 if (record.startsWith('## ')) {
25 Object.assign(summary, parseBranchLine(record.slice(3)))
26 continue
27 }
28
29 // A space is git's "unchanged" letter, so a short record reads as no change.
30 const index = record[0] ?? ' '
31 const work = record[1] ?? ' '
32 summary.changes.push({ path: record.slice(3), index, work })
33
34 // Renames and copies carry the original path as the next record.
35 if (index === 'R' || index === 'C') {
36 i += 1
37 }
38 }
39
40 return summary
41}
42
43const parseBranchLine = (line: string) => {
44 const fresh = line.match(/^(?:No commits yet on|Initial commit on) (.+)$/)
45 if (fresh) {
46 return { branch: fresh[1], upstream: undefined, ahead: 0, behind: 0 }
47 }
48
49 const match = line.match(/^(.+?)(?:\.\.\.(\S+))?(?: \[(.+)\])?$/)
50 const track = match?.[3] ?? ''
51
52 return {
53 branch: match?.[1] ?? line,
54 upstream: match?.[2],
55 ahead: Number(track.match(/ahead (\d+)/)?.[1] ?? 0),
56 behind: Number(track.match(/behind (\d+)/)?.[1] ?? 0),
57 }
58}
59
60// The `git branch` format parseBranches reads: name, upstream, tracking, tab separated.
61export const BRANCH_FORMAT = '--format=%(refname:short)%09%(upstream:short)%09%(upstream:track)'
62
63export const parseBranches = (out: string): Branch[] =>
64 out
65 .split('\n')
66 .filter(line => line.trim() !== '' && !line.startsWith('('))
67 .map(line => {
68 const [name = '', upstream = '', track = ''] = line.split('\t')
69
70 return {
71 name: name.trim(),
72 upstream: upstream.trim() || undefined,
73 ahead: Number(track.match(/ahead (\d+)/)?.[1] ?? 0),
74 behind: Number(track.match(/behind (\d+)/)?.[1] ?? 0),
75 isGone: track.trim() === '[gone]',
76 }
77 })
78
79export const parseRemotes = (out: string): string[] =>
80 out
81 .split('\n')
82 .map(line => line.trim())
83 .filter(line => line !== '')
84
85// Remote branches with the symref beside each, so the remotes' HEAD pointers drop out.
86export const REMOTE_BRANCH_FORMAT = '--format=%(refname:lstrip=2)%09%(symref)'
87
88export const parseRemoteBranches = (out: string): string[] =>
89 out
90 .split('\n')
91 .map(line => line.split('\t'))
92 .filter(([name = '', symref = '']) => name.trim() !== '' && symref.trim() === '')
93 .map(([name = '']) => name.trim())
94
95// Remote branches you don't have locally yet, by name and by tracking.
96export const remoteOnly = (repo: Pick<Repo, 'branches' | 'remoteBranches' | 'remotes'>) =>
97 repo.remoteBranches.filter(ref => {
98 const remote = repo.remotes.find(one => ref.startsWith(`${one}/`))
99 const name = remote === undefined ? ref : ref.slice(remote.length + 1)
100
101 return !repo.branches.some(branch => branch.name === name || branch.upstream === ref)
102 })
103
104// `git log` fields, tab separated with the subject last so a tab inside it survives.
105export const LOG_FORMAT = '--format=%H%x09%h%x09%P%x09%ar%x09%s'
106
107// "2 hours ago" reads as 2h; "1 year, 2 months ago" keeps its first part.
108export const shortAge = (relative: string) => {
109 const match = relative.match(/^(\d+) (second|minute|hour|day|week|month|year)/)
110 if (!match) return relative
111
112 const unit = { second: 's', minute: 'm', hour: 'h', day: 'd', week: 'w', month: 'mo', year: 'y' }[match[2] ?? 'second']
113
114 return `${match[1]}${unit}`
115}
116
117export const parseLog = (out: string): Commit[] =>
118 out
119 .split('\n')
120 .filter(line => line.trim() !== '')
121 .map(line => {
122 const [sha = '', short = '', parents = '', age = '', ...subject] = line.split('\t')
123 const shas = parents.trim().split(' ').filter(one => one !== '')
124
125 return {
126 sha,
127 short,
128 subject: subject.join('\t'),
129 age: shortAge(age),
130 isMerge: shas.length > 1,
131 parents: shas,
132 }
133 })
134
135export const STASH_FORMAT = '--format=%gd%x09%cr%x09%gs'
136
137export const parseStashes = (out: string): Stash[] =>
138 out
139 .split('\n')
140 .filter(line => line.trim() !== '')
141 .map(line => {
142 const [ref = '', age = '', ...message] = line.split('\t')
143
144 return { ref, age: shortAge(age), message: message.join('\t') }
145 })
146
147// `git tag` fields, newest first; `creatordate` covers lightweight and annotated tags alike.
148export const TAG_FORMAT = '--format=%(refname:short)%09%(creatordate:relative)%09%(contents:subject)'
149
150export const parseTags = (out: string): Tag[] =>
151 out
152 .split('\n')
153 .filter(line => line.trim() !== '')
154 .map(line => {
155 const [name = '', age = '', ...message] = line.split('\t')
156
157 return { name, age: shortAge(age), message: message.join('\t') }
158 })
159
160// Splits `origin/feature/x` into its remote and the branch name on it.
161export const splitRemoteRef = (ref: string, remotes: string[]) => {
162 const remote = [...remotes].sort((a, b) => b.length - a.length).find(one => ref.startsWith(`${one}/`))
163 if (remote === undefined) return null
164
165 return { remote, name: ref.slice(remote.length + 1) }
166}
167
168// Which remote the branch's upstream lives on, if it has one.
169export const upstreamRemote = (repo: Pick<Repo, 'upstream' | 'remotes'>) =>
170 repo.remotes.find(remote => repo.upstream?.startsWith(`${remote}/`))
171
172// The remote to use: the one picked last for this repo, else the upstream's, else origin.
173export const pickRemote = (remotes: string[], saved: unknown, upstream?: string) => {
174 if (typeof saved === 'string' && remotes.includes(saved)) return saved
175
176 return upstreamRemote({ upstream, remotes }) ?? (remotes.includes('origin') ? 'origin' : remotes[0] ?? '')
177}
178
179// A branch tracking the picked remote pushes plainly; any other push names it.
180export const pushArgs = (repo: Pick<Repo, 'upstream' | 'remotes' | 'remote' | 'branch'>) => {
181 if (repo.upstream === undefined) return ['push', '-u', repo.remote, repo.branch]
182 if (upstreamRemote(repo) === repo.remote) return ['push']
183
184 return ['push', repo.remote, repo.branch]
185}
186
187export const pullArgs = (repo: Pick<Repo, 'upstream' | 'remotes' | 'remote' | 'branch'>, isRebase = false) => {
188 const flags = ['--no-edit', ...(isRebase ? ['--rebase'] : [])]
189
190 return upstreamRemote(repo) === repo.remote
191 ? ['pull', ...flags]
192 : ['pull', ...flags, repo.remote, repo.branch]
193}
194
195// Overwrites the remote branch, but only if nobody pushed to it since the last fetch.
196export const forcePushArgs = (repo: Pick<Repo, 'upstream' | 'remote' | 'branch'>) =>
197 ['push', '--force-with-lease', ...(repo.upstream === undefined ? ['-u'] : []), repo.remote, repo.branch]
198
199export const isStaged = (change: Change) =>
200 change.index !== ' ' && change.index !== '?'
201
202export const isUnstaged = (change: Change) => change.work !== ' '
203
204// The letter VS Code would show, untracked files reading as `U`.
205export const letterOf = (status: string) => (status === '?' ? 'U' : status)
206
207export const colorOf = (letter: string) => {
208 if (letter === 'A' || letter === 'U') return C.added
209 if (letter === 'D') return C.deleted
210 if (letter === 'R' || letter === 'C') return C.renamed
211
212 return C.modified
213}
214
215// Cuts a diff at a hunk boundary so the part shown still parses as a diff.
216export const limitDiff = (text: string, max = CODE_LIMIT) => {
217 if (text.length <= max) return text
218
219 const cut = text.lastIndexOf('\n@@', max)
220
221 return cut > 0 ? text.slice(0, cut + 1) : text.slice(0, text.lastIndexOf('\n', max) + 1)
222}
223
224export const isProtected = (branch: string) => branch === 'main' || branch === 'master'
225
226// The last commit is on the remote already, so amending it rewrites shared history.
227export const isPushed = (repo: Pick<Repo, 'upstream' | 'ahead'>) => repo.upstream !== undefined && repo.ahead === 0
228
229export const isUntracked = (change: Change) => change.work === '?'
230
231// The status pairs git uses for a file both sides changed (or one side deleted).
232const CONFLICT_PAIRS = ['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']
233
234export const isConflicted = (change: Change) => CONFLICT_PAIRS.includes(`${change.index}${change.work}`)
235
236// What's in `.git` while each operation waits on you, checked in this order.
237export const OPERATION_MARKERS: [Operation, string][] = [
238 ['rebase', 'rebase-merge'],
239 ['rebase', 'rebase-apply'],
240 ['merge', 'MERGE_HEAD'],
241 ['revert', 'REVERT_HEAD'],
242 ['cherry-pick', 'CHERRY_PICK_HEAD'],
243]
244
245export const operationArgs = (operation: Operation, step: 'continue' | 'abort') => {
246 // A merge finishes by committing with the message git already wrote.
247 if (operation === 'merge') return step === 'continue' ? ['commit', '--no-edit'] : ['merge', '--abort']
248
249 return [operation, `--${step}`]
250}
251
252// A line git writes at the start or end of a conflict block.
253export const hasConflictMarkers = (text: string) => /^(<{7}|>{7})( |$)/m.test(text)
254
255// Splits one file's diff into its header and hunks, so each hunk can be
256// applied by itself as header + hunk.
257export const splitHunks = (diff: string) => {
258 const first = diff.indexOf('\n@@')
259 if (!diff.startsWith('@@') && first < 0) return { header: diff, hunks: [] }
260
261 const header = diff.startsWith('@@') ? '' : diff.slice(0, first + 1)
262 const body = diff.slice(header.length)
263 const hunks = body.split(/\n(?=@@ )/).map((hunk, i, all) => (i < all.length - 1 ? `${hunk}\n` : hunk))
264
265 return { header, hunks: hunks.filter(hunk => hunk.startsWith('@@')) }
266}
267
268// Throws away a file's working tree changes; an untracked file is deleted outright.
269export const discardArgs = (change: Change) =>
270 isUntracked(change)
271 ? ['clean', '-f', '-d', '-q', '--', change.path]
272 : ['restore', '--worktree', '--', change.path]
273
274// First few lines of git's complaint, enough to act on in a small pane.
275export const shortError = (stderr: string, stdout: string) =>
276 (stderr.trim() || stdout.trim() || 'git failed').split('\n').slice(0, 4).join('\n')
277
278// A hunk's new-side lines and its counts, for the header the diff view draws over it.
279export const hunkStats = (hunk: string) => {
280 const head = hunk.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@ ?(.*)$/m)
281 const start = Number(head?.[1] ?? 0)
282 const length = Number(head?.[2] ?? 1)
283 const lines = hunk.split('\n').slice(1)
284
285 return {
286 from: start,
287 to: Math.max(start, start + length - 1),
288 context: (head?.[3] ?? '').trim(),
289 added: lines.filter(line => line.startsWith('+')).length,
290 removed: lines.filter(line => line.startsWith('-')).length,
291 }
292}
293