SLOPSHOPPER

workspace

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…

paneguardcommandtoastpromptmodel
★ 1v0.10.0Apache-2.0updated 2026-10-19pinkpixel-dev/claude-workspace
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workspace
│ ┃ Workspace ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ 5h ◔ 31% · ctx ▰▰▱▱ 49% · Opus 5.5 · $0.4 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Workspace app st ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ tab-files tab-git tab-turn ta ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Claude touch:src/cache.ts ─ touch:src/audi │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ crumb: │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Empty folder. │ ┃ › /workspace │ ┃ ⎿ workspace: Workspace opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Workspace
5h ◔ 31% · ctx ▰▰▱▱ 49% · Opus 5.5 · $0.42 Workspace app stats-toggle tab-files tab-git tab-turn tab-github Claude touch:src/cache.ts ─ touch:src/audit.ts ─ touch:src crumb: 0 items Empty folder.
README

Workspace

<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:

  • Files: browse, read and edit files, with git status and marks for what Claude touched
  • Source Control: VS Code style git, from staging single hunks to merges, rebases and conflicts
  • This Turn: Claude's task list and every file it edited since your last prompt, with diffs, Review and revert
  • GitHub: open pull requests and issues, with CI and review status
  • Stats strip: 5 hour and 7 day usage, context, model and cost along the top

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

Features

Files tab

  • Browse the session's working directory, with folders listed first and file sizes on the right. Each file gets a small glyph for its kind, and the path at the top is clickable crumbs so you can jump back up
  • Git status right in the list: changed files get their letter (M, A, D, U, R, or ! for a conflict) in the same colors Source Control uses, and a folder gets a ● when something inside it changed
  • See what Claude is looking at. A file gets ◆ 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 path
  • Open a file to read it with syntax highlighting and line numbers
  • Click Edit to change it in place, then Save (or press Enter) to write it back to disk
  • The open file reloads after each Claude turn, so you see edits Claude made
  • If the file changed on disk while you were editing it (say Claude edited it mid-turn), Save asks before writing over it instead of quietly wiping out that change
  • Explain in the viewer's header puts Explain @path in your prompt box, so asking Claude about a file is one click

This Turn tab

  • A Tasks section at the top shows Claude's task list for the turn, with a done count like 2/4. Items get ✓ 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 on
  • Under it, Edited Files lists every file Claude edited since your last prompt, with how many lines it added and removed. Files Claude created get a new badge
  • That includes files changed by Claude's Bash commands, like a sed -i or a script that writes files, not just its Edit and Write tools
  • Click a file to see its diff against how it looked before Claude's first edit this turn. That's a different view from Source Control's diff, which compares against your last commit
  • ↺ (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 first
  • Review puts Review the changes you made to @path in your prompt box
  • The tab label counts the edited files, like This Turn 2

Stats bar

  • A slim strip at the top of the pane with your 5 hour and 7 day usage (with reset times), how full the context window is, the model and effort, and what the session has cost so far. It's the same numbers the status line reads
  • When the pane gets narrow, pieces drop off from the right: cost first, then the model, then context
  • Stats in the title row hides it or brings it back. It starts on, and the pane remembers your choice across sessions

Source Control tab

  • Staged and unstaged changes, colored the way VS Code does it (added, modified, deleted, renamed). Click the ▾ next to a section to fold it shut when the list gets long
  • Click a file to see its diff, and use + 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
  • A summary row counts your changed files by kind, with a bar showing how they split
  • Stage all for everything at once, and Unstage all on the staged section to undo that
  • ↺ on a file throws away its changes, and Discard all does the whole list. Both ask first
  • A commit message box with a Commit button (Enter commits too)
  • ✦ Message writes a commit message for you from the staged diff. It shows up once something is staged, and you can edit the message before committing
  • Amend to fold staged changes into your last commit, and Commit all to stage everything and commit in one go
  • Fetch, Pull, Push and a refresh button, plus ahead/behind counts for your branch. Whichever of Pull or Push you need right now is the highlighted one. A branch with no upstream yet gets Publish instead of Push
  • ⋯ More opens a second row with Pull (rebase), Sync (pull, then push), Fetch all (every remote, pruning deleted branches) and Force push
  • A quiet fetch every 3 minutes while the pane is showing, so the ahead/behind counts stay current
  • A branch picker that switches branches for you
  • A Branches view listing your local branches with what each one tracks, plus + New branch, Rename and Delete. A new branch can start from where you are or from any branch, remote branch or tag. Remote branches show up underneath with Delete, plus Check out on the ones you don't have locally yet
  • A Remotes section at the bottom of Branches to add or remove remotes
  • A Tags view listing your tags, newest first, with + New tag (add a message to make it annotated), Push all, and Check out, Push, Delete and Delete remote on each tag
  • A History view with the last 50 commits on your branch (Load more for older ones) and a branch graph beside them. Click one to see its diff, Revert it, or start a Branch or Tag from it. The top commit also gets Undo. A little bar chart at the top shows commits per day for the last two weeks
  • Stash in the Changes view sets your work aside. The Stash view has Stash all, Stash staged and Drop all at the top, and Apply, Pop and Drop for each entry
  • Merge and Rebase at the top of the Branches view, with any local or remote branch as the source
  • When a merge, rebase, revert or cherry-pick stops on conflicts, a banner shows up with Continue and Abort, and the conflicted files get their own Merge Changes section with Ours, Theirs and Resolved. This works for ones you started from a terminal too
  • A remote picker when the repo has more than one remote. Push, Pull and Fetch use it, and the pane remembers your pick for that repo
  • A confirm step before committing or pushing straight to main or master
  • An Initialize Repository button when the folder isn't a git repo yet (it runs git init -b main)

GitHub tab

  • Open pull requests and issues for the repo, up to 50 of each, with who last touched them and how long ago. Drafts get a dimmer dot
  • Pull requests show their CI result (✓, ✗, ● pending) and review status (✓ approved, ± changes requested, ○ review required), and a card above the list adds those up across all open PRs
  • Click one to read it right in the pane: its state, author, branches, review status, a quick count of passing and failing checks, labels, the description and the comments
  • @ 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
  • Open in the detail view takes you to it on GitHub, and ↻ reloads the lists
  • The lists refresh after each Claude turn while the tab is showing, so a PR Claude just opened shows up on its own

The pane draws its own dark theme, so it looks the same no matter which Claude Code theme you use.

The History graph

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

Installation

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

Usage

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:

  • In the editor, Enter saves. You get a new line the same way you do in the Claude Code prompt box.
  • Commit only commits what's staged. If nothing is staged, the pane tells you instead of guessing. Use Commit all if you want everything in.
  • Discarding an untracked file deletes it. It was never in git, so there's no getting it back.
  • Amend with an empty message box keeps the old commit message. Type one to replace it. If the commit is already pushed, the pane warns you first, since pushing it again needs a force push.
  • If your branch has no upstream yet, Push runs git push -u <remote> <branch> for you.
  • Pull runs 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.
  • Pull (rebase) adds --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 push uses --force-with-lease, so git refuses if someone else pushed since your last fetch. It always asks first.
  • New branches start from wherever you are unless you pick something else in the from picker, and the pane switches you onto them. Starting from a remote branch makes the new branch track it.
  • Undo on the top commit runs 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.
  • Delete on a remote branch runs git push <remote> --delete <name>, which deletes it for everyone, so it always asks first.
  • Tags are created without a message as lightweight tags, or annotated (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.
  • Remove on a remote asks first. It drops that remote's branches from your clone but doesn't touch the server.
  • Check out on a remote branch runs git switch --track <remote>/<name>, which makes a local copy that tracks it.
  • Revert makes a new commit that undoes the old one, so it never rewrites history. If it conflicts with later changes, it stops in Merge Changes like a merge does. Merge commits don't get a Revert button.
  • Stash includes untracked files. Stash staged only takes what's staged and leaves everything else where it is. If you've typed something in the commit message box, that becomes the stash's name. Drop and Drop all ask first, since a dropped stash is basically gone.
  • If applying or popping a stash conflicts, git keeps the stash and the conflicted files show up in Merge Changes.
  • Merging into 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).
  • Click a conflicted file to open it in the Files tab and fix it by hand, then hit Resolved. If the file still has conflict markers in it, the pane asks before staging it.
  • Ours and Theirs keep one side's whole file. Heads up: during a rebase git flips these, so Ours is the branch you're rebasing onto and Theirs is your own commit.
  • Continue finishes the merge (or moves the rebase to its next commit) using git's own commit message, since there's no editor to open. It won't run while conflicts are left.
  • Abort asks first, then puts everything back to how it was before the merge or rebase started.
  • ✦ Message sends the staged diff and your last 10 commit subjects to Haiku, which costs a tiny bit of usage. The message follows Conventional Commits and ends with a Co-Authored-By: Claude <model> <noreply@anthropic.com> line naming the session's model. Delete that line if you don't want it.
  • Review and Explain only fill the prompt box. Nothing gets sent until you press Enter, so you can add to the request first.
  • This Turn starts fresh every time you send a prompt (slash commands don't count). Once you send the next prompt, the old before-edit copies are gone, so revert what you want to revert first.
  • Reverting a file Claude created runs 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 GitHub tab is read-only for now. It doesn't comment, review, merge or close anything. If you want one of those, @ the item and ask Claude.
  • Delete tries the safe 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.

Limitations

  • The GitHub tab needs the GitHub CLI. It runs 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.
  • Git hooks don't run. Claude Code runs git for mods with repo hooks turned off, so things like husky or pre-commit checks get skipped for commits made from the pane.
  • Push and pull won't ask for a password. They need an SSH key or a git credential helper already set up. Without one they fail fast and show git's error instead of hanging.
  • Rebase is plain only. There's no interactive rebase (squash, reorder, edit), since that needs an editor the pane can't drive.
  • Staging goes down to hunks, not lines. You can't stage a single line, and untracked files only stage as a whole.
  • The editor is a plain text box. It's good for quick edits, but there's no highlighting while you type, no multi-cursor and no real undo history.
  • Big and binary files are view only. Files over 1 MB don't open, binary files show a note, and very long files show the start and can't be edited.
  • This Turn only remembers one turn. The before-edit copies are kept in memory, so they're gone once you send your next prompt or restart Claude Code. Notebook edits show up in the list but have no copy to diff or revert, and neither do files too large for the Edit tool to hand back, or Bash changes Claude Code didn't make a diff for (it skips some, like big batches of files).
  • Bash tracking leans on an internal field. Files changed by Bash come from the per-file diff Claude Code attaches to Bash results, which its own types mark as internal. If a future Claude Code release changes it, Bash edits would quietly stop showing up in This Turn and the ✎ marks, while Edit and Write keep working.
  • The background fetch is silent. If it fails (no network, a key that needs a passphrase), it just skips that round. Hit Fetch to see the actual error.
  • Mobile isn't supported. The pane needs text inputs, which the mobile app doesn't have.
  • The pane opens at any width when you run /workspace, but it's way nicer with a wide terminal.
  • The History graph only follows your current branch. It reads the same git log as the list, so it shows merges into your branch but not other branches' tips.
  • The picture graph needs kitty-style image support. I haven't tried it inside tmux, so it might not draw there even in kitty. Use 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.

How it works

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

Development

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.

License

Apache 2.0. See LICENSE.


Made with 💖 by Pink Pixel

Source 42 files
hooks/register.tsx 145 lines
1import { 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}
145
hooks/actions.ts 160 lines
1// 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}
160
hooks/activity.ts 89 lines
1// 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}
89
hooks/git-actions.ts 333 lines
1// 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}
333
hooks/repo.ts 187 lines
1// 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}
187
hooks/files-view.tsx 175 lines
1import { 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}
175
hooks/git-view.tsx 341 lines
1import { 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}
341
hooks/github-actions.ts 99 lines
1// 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}
99
hooks/github-view.tsx 164 lines
1import { 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}
164
hooks/controls.tsx 180 lines
1// 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}
180
hooks/history-actions.ts 180 lines
1// 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}
180
hooks/git.ts 293 lines
1// 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