Skip to content

Source Control Panel

dazeb edited this page Sep 17, 2026 · 2 revisions

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.

Files

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.

Two rendering modes

embedded changes chrome only, never behavior:

  • Overlay (embedded = false, opened from the cog menu): floating .source-control panel with .source-control-head, the source control title, a HelpBadge, and a × button wired to onClose.
  • Sidebar section (embedded = true): class becomes source-control source-control-embedded, the head and close button are skipped, and content spans the sidebar width — the VS Code-style SOURCE CONTROL section.

Target resolution: local vs SSH

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 } (a ProjectRemote), and cwd is the remote tree root the user sees. Main runs git on the remote host. A ssh <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"]
Loading

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 model

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 → renders pushing 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 a run button calling git.publish. This is a deliberate "teach the command" affordance.
  • The branch bar shows ↑ahead / ↓behind only when snap.sync.upstream exists.

The op loop

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:

  1. busy = true, error = null.
  2. await op().
  3. busy = false.
  4. res.code !== 0 → error = res.stderr.trim() || 'git command failed', and no refresh — the stale snapshot stays on screen next to the error.
  5. Otherwise status = okMsg and refresh().

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
Loading

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.

Affordances → IPC → core

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

Boundaries and sharp edges

  • "Not a repo" is inferred, not flagged. snap.branch === '' renders not a git repository. Core returns '' from currentBranch whenever git branch --show-current fails and [] from gitStatus, so the branch check doubles as the repo gate.
  • Failures don't refresh. A non-zero result shows stderr and leaves the previous snapshot visible — the panel never clears snap on error.
  • Inputs clear optimistically. commit() clears msg immediately; the branch and worktree forms clear their inputs immediately. A failed op surfaces the error but does not restore the typed text.
  • busy is informational. It only renders working…; no mutation button is disabled by it (only ai uses aiBusy). Rapid clicks can therefore issue concurrent git commands.
  • No try/finally around op() or refresh(). A rejected IPC promise leaves busy stuck true (or snap null, i.e. loading…), because only the resolved-GitResult path resets state.
  • Destructive actions use inline confirmation state, not modals: confirmDiscard per path and confirmRemoveWt per worktree path. Worktree removal is force-removal and says so in the confirm text.
  • Ref names are validated before use. isValidGitRefName rejects leading - (classic git 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 show buffers 16 MB, runGit 32 MB; over-buffer output fails the command rather than silently truncating.
  • Missing refs are data, not errors. showFromRef resolves null when git show <ref>:<path> exits non-zero (e.g. a newly staged file), and diffInfo never throws — errors are returned in a DiffInfo.error payload with NO_REPO | MISSING | IO.
  • Sharp edge in dependency wiring. target is a fresh object literal on every render, refresh is a useCallback keyed on [target], and the mount effect is keyed on [refresh]. The fetch therefore re-fires on every render, and each fire sets fresh snap/worktrees objects (a re-render) — memoizing target, or keying the effect on cwd/remote, removes the churn.
  • List identity is by change.path, b.name, w.path, and c.hash; duplicate paths would collide.

Extension points

  • Add a git action. Add the method to the preload window.termsprawl.git surface (Preload Bridge & IPC Contract), implement the argv-array command in core, then call it through run() so busy / 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 GitPanelSnapshot in shared types and the main-side composer; the panel renders purely from snap, so there is no local cache to invalidate.
  • Remote support. Any new op that accepts GitTarget and 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. embedded currently only strips the head; branch on it (as the head does) rather than forking the component.
  • Alternative message generators. commitMessage only 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

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally