-
Notifications
You must be signed in to change notification settings - Fork 0
Source Control Panel
SourceControlPanel is the git UI bound to one project folder. It is deliberately thin: it owns presentation state, resolves a single git target, and sends every action through window.termsprawl.git. It never spawns git, never reads the filesystem, and never builds a shell string — the actual commands live in the electron-free core (src/core/git-service.ts), which the Server Edition can boot unchanged.
The component is annotated by build phase: source-control ops are Phase 8 / Task 8.2, the AI commit-message button is Phase 8.4, and the SSH target is Phase 9.
| File | Role |
|---|---|
src/renderer/src/components/SourceControlPanel.tsx |
The entire panel: props, state, IPC calls, and the render tree for changes / commit / history / branches / worktrees. |
src/core/git-service.ts |
System-git primitives that back the IPC surface: runGit, parseGitStatus, gitStatus, currentBranch, listBranches, stagedDiff, stageChanges, unstageChanges, discardChanges, createBranch, checkoutBranch, isValidGitRefName, plus the diff helpers findRepoRoot, showFromRef, readWorkingTree, diffInfo. |
@shared/types |
GitPanelSnapshot, GitFileChange, GitFileStatus, GitResult, GitTarget, GitWorktree, ProjectRemote. |
@shared/remote-project |
remoteLabel(remote) for the ssh user@host line. |
./HelpBadge |
The "about source control" affordance in the non-embedded head. |
embedded changes chrome only, never behavior:
-
Overlay (
embedded = false, opened from the cog menu): floating.source-controlpanel with.source-control-head, thesource controltitle, aHelpBadge, and a×button wired toonClose. -
Sidebar section (
embedded = true): class becomessource-control source-control-embedded, the head and close button are skipped, and content spans the sidebar width — the VS Code-styleSOURCE CONTROLsection.
const target: GitTarget = remote ? { remote } : { cwd }Every IPC call takes target as its first argument, so the panel has exactly one notion of "where git runs":
-
Folder project →
{ cwd }; main resolves the repository locally. -
SSH project →
{ remote }(aProjectRemote), andcwdis the remote tree root the user sees. Main runs git on the remote host. Assh <remoteLabel>line is always rendered for remote targets, so the user can see which host they are about to modify. -
Worktree management is gated
!remote— that whole section is local-only here.
flowchart LR
A["props: cwd, remote?"] --> B{"remote set?"}
B -- no --> C["target = { cwd }"]
B -- yes --> D["target = { remote }<br/>cwd = remote tree root"]
C --> E["window.termsprawl.git.*(target, …)"]
D --> E
E --> F["main / RPC boundary resolves which side runs git"]
F --> G["core/git-service: runGit(cwd, ['-c','color.ui=false', …argv])"]
G --> H["system git via execFile<br/>argv array, never a shell string"]
G --> I["parseGitStatus / listBranches / currentBranch / stagedDiff"]
I --> J["GitPanelSnapshot → panel state"]
Key nodes: the remote prop is the only branch point in the panel — everything downstream is target-agnostic. runGit is the single exec seam in core and always uses execFile with an argv array and -c color.ui=false, with a 32 MB stdout buffer. Assembling the pieces into a GitPanelSnapshot happens above git-service.ts (the main/RPC layer), which is why the panel sees one read call instead of six.
| State | Type | Purpose |
|---|---|---|
snap |
GitPanelSnapshot | null |
Everything read back in one shot: branch, changes, sync, branches, commits, remote, ghAuthed. null means first load. |
msg |
string |
Commit message box; also the destination of AI generation. |
newBranch |
string |
New-branch input. |
status / error
|
string | null |
Success line and failure line. There is exactly one error channel for the whole panel. |
busy |
boolean |
Renders working… while a run() op is in flight. |
confirmDiscard |
string | null |
Path awaiting inline "discard? / keep" confirmation. |
worktrees |
GitWorktree[] |
Result of the second read in refresh(). |
newWtName / newWtBranch
|
string |
Worktree creation form. |
confirmRemoveWt |
string | null |
Worktree path awaiting inline force-remove confirmation. |
aiBusy |
boolean |
Disables the ai button while commitMessage runs. |
Derived values:
-
ghNeedsAuth = !!snap?.remote?.includes('github.com') && snap.ghAuthed === false→ renderspushing to GitHub needs you logged in: run gh auth login. -
publishCommand— when there is a branch but no upstream:git push -u origin <branch>; rendered as a copyable command row plus arunbutton callinggit.publish. This is a deliberate "teach the command" affordance. - The branch bar shows
↑ahead/↓behindonly whensnap.sync.upstreamexists.
Two functions define all behavior.
refresh() fans out two reads with Promise.all — git.snapshot(target) and git.worktrees(target) — and stores both. It runs from the mount effect and again after every successful run().
run(op, okMsg) is the uniform wrapper for every mutation:
-
busy = true,error = null. -
await op(). -
busy = false. -
res.code !== 0→error = res.stderr.trim() || 'git command failed', and no refresh — the stale snapshot stays on screen next to the error. - Otherwise
status = okMsgandrefresh().
GitResult is { code, stdout, stderr }, produced by core runGit, which converts the execFile failure into a numeric code. The panel's single code === 0 check is the only success test.
sequenceDiagram
autonumber
participant U as User
participant P as SourceControlPanel
participant IPC as window.termsprawl.git
participant M as main / RPC boundary
participant G as core/git-service
U->>P: stage / commit / push / branch / worktree action
P->>P: run(): busy=true, error=null
P->>IPC: git.<op>(target, …)
IPC->>M: typed channel
M->>G: runGit(cwd, argv) or read primitive
G-->>M: { code, stdout, stderr }
M-->>IPC: result
IPC-->>P: res
P->>P: busy=false
alt res.code === 0
P->>P: status = okMsg
P->>IPC: snapshot(target) + worktrees(target)
IPC-->>P: GitPanelSnapshot + GitWorktree[]
P->>P: setSnap / setWorktrees (re-render)
else non-zero
P->>P: error = stderr.trim() || 'git command failed'
end
Key nodes: the alt on res.code is the only control-flow fork; success is the only path that reaches refresh(), so failures are non-destructive to the displayed snapshot. busy flips back before the result branch, so the working… indicator disappears even when the command fails.
| UI affordance | IPC (window.termsprawl.git) |
Core primitive in git-service.ts
|
Notes |
|---|---|---|---|
changes row + / −
|
stage(target,[path]) / unstage(target,[path])
|
stageChanges → git add -A -- <path>; unstageChanges → git restore --staged -- <path>
|
Direction is chosen by change.staged. |
changes row ✕
|
discard(target,[path]) |
discardChanges → git checkout -- <path>
|
Destructive; inline "discard? / keep" first. |
| commit box | commit(target, text) |
— (below the read range) | Enter commits; button disabled while blank. |
ai |
commitMessage(target) |
— (owned by AI Commit Message Generation) | Fills the input, does not commit; for remote targets the staged diff is fetched over SSH first. |
push / pull
|
push(target) / pull(target)
|
— | ahead/behind shown only when an upstream exists. |
| publish command row | publish(target) |
— | Rendered only when snap.sync.upstream is absent. |
| branch row | checkout(target, name) |
checkoutBranch → git checkout <name>
|
Current branch is disabled; ref name validated first. |
| new branch | createBranch(target, name) |
createBranch → git checkout -b <name>
|
Validated first; invalid names return code 128 + invalid branch name: <name>, which lands in the panel's error line. |
| worktree create | worktreeAdd(target, name, branch?) |
— | Branch is optional (`newWtBranch.trim() |
| worktree remove | worktreeRemove(target, path, true) |
— |
true = force; discards that worktree's uncommitted changes. |
| load / refresh |
snapshot(target), worktrees(target)
|
Read side corresponds to gitStatus + parseGitStatus, currentBranch, listBranches, stagedDiff (plus a commit-log reader below the read range) |
One call returns the whole GitPanelSnapshot. |
The status letter and per-row CSS class come from statusLetter(change) and git-file-${change.status}; parseGitStatus maps porcelain XY into untracked / added / deleted / renamed / modified with a staged boolean (staged = x !== ' ' && x !== '?', src = staged ? x : y).
-
"Not a repo" is inferred, not flagged.
snap.branch === ''rendersnot a git repository. Core returns''fromcurrentBranchwhenevergit branch --show-currentfails and[]fromgitStatus, so the branch check doubles as the repo gate. -
Failures don't refresh. A non-zero result shows
stderrand leaves the previous snapshot visible — the panel never clearssnapon error. -
Inputs clear optimistically.
commit()clearsmsgimmediately; the branch and worktree forms clear their inputs immediately. A failed op surfaces the error but does not restore the typed text. -
busyis informational. It only rendersworking…; no mutation button is disabled by it (onlyaiusesaiBusy). Rapid clicks can therefore issue concurrent git commands. -
No
try/finallyaroundop()orrefresh(). A rejected IPC promise leavesbusystuck true (orsnapnull, i.e.loading…), because only the resolved-GitResultpath resets state. -
Destructive actions use inline confirmation state, not modals:
confirmDiscardper path andconfirmRemoveWtper worktree path. Worktree removal is force-removal and says so in the confirm text. -
Ref names are validated before use.
isValidGitRefNamerejects leading-(classicgit checkout -b <name>option injection, e.g.--upload-pack=…),..,@{,//, trailing/or., control characters and the git-special set~^:?*[\, and bare@. This guard is inline — no extra spawn. -
Never a shell. Both the panel-facing and diff paths use argv-array
execFile.git showbuffers 16 MB,runGit32 MB; over-buffer output fails the command rather than silently truncating. -
Missing refs are data, not errors.
showFromRefresolvesnullwhengit show <ref>:<path>exits non-zero (e.g. a newly staged file), anddiffInfonever throws — errors are returned in aDiffInfo.errorpayload withNO_REPO | MISSING | IO. -
Sharp edge in dependency wiring.
targetis a fresh object literal on every render,refreshis auseCallbackkeyed on[target], and the mount effect is keyed on[refresh]. The fetch therefore re-fires on every render, and each fire sets freshsnap/worktreesobjects (a re-render) — memoizingtarget, or keying the effect oncwd/remote, removes the churn. -
List identity is by
change.path,b.name,w.path, andc.hash; duplicate paths would collide.
-
Add a git action. Add the method to the preload
window.termsprawl.gitsurface (Preload Bridge & IPC Contract), implement the argv-array command in core, then call it throughrun()sobusy/error/status/ refresh semantics stay uniform. Because nothing in the panel builds shell strings, a new op needs only an argument array. -
Add a snapshot field. Extend
GitPanelSnapshotin shared types and the main-side composer; the panel renders purely fromsnap, so there is no local cache to invalidate. -
Remote support. Any new op that accepts
GitTargetand handles{ remote }works over SSH with no panel change. Ops that are inherently local should follow the worktree pattern and gate on!remote. -
Sidebar-specific affordances.
embeddedcurrently only strips the head; branch on it (as the head does) rather than forking the component. -
Alternative message generators.
commitMessageonly needs to return{ ok, message?, tool?, error? }; the panel's contract does not care which provider or CLI produced the text.
Related reading: Git Service Core (how the IPC surface is composed), AI Commit Message Generation (the ai button), SSH Remote Projects, Terminals & Files (the { remote } target), and Project Scope, Deletion & Worktree Registry (worktrees tracked outside this panel).
Sources: src/renderer/src/components/SourceControlPanel.tsx, src/renderer/src/components/SourceControlPanel.tsx, src/core/git-service.ts, src/core/git-service.ts
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance