From 1ea9adf85590b85ef3b2857394c900735c059630 Mon Sep 17 00:00:00 2001 From: Jon Gallant Date: Fri, 17 Jul 2026 05:48:29 -0700 Subject: [PATCH 1/3] feat(tui): add git status overlay for session folders Add an "i" keybinding (and command-palette entry) that opens a modal overlay showing the git status of the folder a session is mapped to: current branch and upstream, the standard push/pull stats (commits ahead to push, behind to pull), working-tree counts (staged, modified, untracked, deleted, conflicts), and a scrollable list of changed files. The status is gathered off the UI thread with a single bounded `git status --porcelain=v2 --branch` command. Non-repo, missing, detached-HEAD, and no-upstream folders are reported without error. Security: harden all git invocations in gitstate.go with `-c core.fsmonitor=false -c core.hooksPath=` so a session pointing at an untrusted repository cannot execute arbitrary code via repo-local git config during status collection (CWE-829). Covered by a positive-control regression test. Also fixes three pre-existing CI breakages on main from earlier PRs (#299 parseOpenArgs arity + open.go gofmt, #284 duplicated test block). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 524cf702-8922-4cfd-b23f-4069b734e09b --- README.md | 3 +- cmd/dispatch/launch_overrides_test.go | 2 +- cmd/dispatch/main_test.go | 7 - cmd/dispatch/open.go | 4 +- docs/keybindings.md | 7 + docs/specs/git-status/spec.md | 106 ++++++ docs/specs/git-status/test-plan.md | 101 ++++++ internal/platform/gitstate.go | 261 +++++++++++++- internal/platform/gitstatus_test.go | 330 ++++++++++++++++++ internal/tui/cmdpalette.go | 4 + internal/tui/components/gitstatusview.go | 272 +++++++++++++++ internal/tui/components/gitstatusview_test.go | 217 ++++++++++++ internal/tui/handlers.go | 3 + internal/tui/keys.go | 7 +- internal/tui/messages.go | 6 + internal/tui/model.go | 99 ++++++ internal/tui/model_gitstatus_test.go | 207 +++++++++++ 17 files changed, 1620 insertions(+), 16 deletions(-) create mode 100644 docs/specs/git-status/spec.md create mode 100644 docs/specs/git-status/test-plan.md create mode 100644 internal/platform/gitstatus_test.go create mode 100644 internal/tui/components/gitstatusview.go create mode 100644 internal/tui/components/gitstatusview_test.go create mode 100644 internal/tui/model_gitstatus_test.go diff --git a/README.md b/README.md index d80b508..77d60a4 100644 --- a/README.md +++ b/README.md @@ -626,7 +626,8 @@ Available action names: `resume_interrupted`, `view_plan`, `copy_id`, `copy_path`, `copy_resume_command`, `copy_preview`, `expand_collapse_all`, `scan_work_status`, `export`, `note`, `shift_up`, `shift_down`, `view_switch`, -`open_file`, `open_dir`, `timeline`, `compare`, `cmd_palette`. +`open_file`, `open_dir`, `open_ref`, `timeline`, `compare`, `git_status`, +`cmd_palette`. ## Themes diff --git a/cmd/dispatch/launch_overrides_test.go b/cmd/dispatch/launch_overrides_test.go index d95ba6f..2dd55e5 100644 --- a/cmd/dispatch/launch_overrides_test.go +++ b/cmd/dispatch/launch_overrides_test.go @@ -40,7 +40,7 @@ func TestParseOpenArgs_Overrides(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - id, mode, _, _, ov, err := parseOpenArgs(tc.args) + id, mode, _, _, _, ov, err := parseOpenArgs(tc.args) if tc.wantErr { if err == nil { t.Fatalf("expected error, got id=%q mode=%q", id, mode) diff --git a/cmd/dispatch/main_test.go b/cmd/dispatch/main_test.go index 9e57ec7..6b8add5 100644 --- a/cmd/dispatch/main_test.go +++ b/cmd/dispatch/main_test.go @@ -468,13 +468,6 @@ func TestPrintUsage_Output(t *testing.T) { close(readDone) }() - var buf bytes.Buffer - readDone := make(chan struct{}) - go func() { - _, _ = io.Copy(&buf, r) - close(readDone) - }() - printUsage() _ = w.Close() diff --git a/cmd/dispatch/open.go b/cmd/dispatch/open.go index 210c10f..5343d07 100644 --- a/cmd/dispatch/open.go +++ b/cmd/dispatch/open.go @@ -234,7 +234,7 @@ func parseOpenArgs(args []string) (id, mode string, last, printCmd, stdin bool, stdin = true case arg == "--mode" || arg == "-m": if i+1 >= len(rest) { - return "", "", false, false, false, launchOverrides{}, errors.New("--mode requires a value: inplace, tab, window, or pane") + return "", "", false, false, false, launchOverrides{}, errors.New("--mode requires a value: inplace, tab, window, or pane") } mode = rest[i+1] i++ @@ -243,7 +243,7 @@ func parseOpenArgs(args []string) (id, mode string, last, printCmd, stdin bool, case arg == "--print": printCmd = true case strings.HasPrefix(arg, "-"): - return "", "", false, false, false, launchOverrides{}, fmt.Errorf("unknown flag: %s", arg) + return "", "", false, false, false, launchOverrides{}, fmt.Errorf("unknown flag: %s", arg) default: positionals = append(positionals, arg) } diff --git a/docs/keybindings.md b/docs/keybindings.md index b576219..1aa611e 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -231,6 +231,13 @@ - Behavior: Opens the selected session's linked reference on github.com in the default browser. Chooses the pull request first, then the issue, then the commit. Only http/https URLs are opened. - Condition: Only when the selected session has a repository and at least one PR, issue, or commit reference +20h. **i** → Git Status of Session Folder + - File: internal\tui\keys.go + - Code: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "git status")) + - Handler: internal\tui\model.go (handleGitStatus / handleGitStatusMsg) + - Behavior: Opens a modal overlay showing the git status of the folder the selected session (or folder-pivot row) is mapped to: current branch and upstream, the standard push/pull stats (commits ahead to push, behind to pull), and working-tree counts (staged, modified, untracked, deleted, conflicts), plus a scrollable list of changed files. Inside the overlay: Esc or i closes it, ↑/↓ (or PgUp/PgDn) scroll the file list, and c copies a plain-text summary to the clipboard. Git commands run under a bounded timeout so the UI never blocks; non-repo, missing, detached-HEAD, and no-upstream folders are reported without error. + - Condition: Only when a session or folder row with a working directory is selected + 21. **PgUp (Page Up)** → Preview Panel Scroll Up - File: internal\tui\keys.go (line 85) - Code: key.NewBinding(key.WithKeys("pgup")) diff --git a/docs/specs/git-status/spec.md b/docs/specs/git-status/spec.md new file mode 100644 index 0000000..f7316e9 --- /dev/null +++ b/docs/specs/git-status/spec.md @@ -0,0 +1,106 @@ +--- +issue: pending +author: @jongio +status: approved +--- + +# Git Status Overlay + +## Problem + +Every dispatch session is mapped to a working directory (`Cwd`). The session +list already surfaces a single-glyph git badge (dirty / untracked / ahead / +behind / missing), but that badge collapses everything into one state and hides +the numbers that actually matter when you are deciding what to do next. If a +folder is both 2 commits ahead and 3 behind with 4 modified files, the badge +just shows "dirty" or "ahead" — you cannot tell how far ahead you are, whether +you owe a `push`, whether you need to `pull` first, or how much uncommitted work +is sitting in the tree. + +Today the only way to answer "what is the git state of this session's folder?" +is to leave dispatch, `cd` into the directory, and run `git status`. That breaks +the triage flow the TUI is built for, especially when scanning many parallel +agent sessions. + +## Goals + +- View detailed git status for the selected session's folder without leaving the TUI +- Show the standard push/pull stats: commits **ahead** (to push) and **behind** + (to pull) relative to the upstream branch, plus the branch and upstream names +- Show working-tree counts: staged, modified, untracked, deleted, and conflicts, + with an explicit "clean" indicator when there is nothing to report +- List the changed files with their short status codes, scrollable for large trees +- Handle non-repo, missing-directory, detached-HEAD, and no-upstream cases + gracefully — never crash and never block the UI +- Reachable both by a dedicated keybinding and from the command palette + +## Non-Goals + +- Performing git actions (push / pull / commit / stage) — this is read-only status +- A full diff viewer or per-file hunk display (short status codes only) +- Live auto-refreshing of the overlay while it is open (it is a point-in-time snapshot) +- Replacing the existing session-list git badge (this complements it) +- Multi-session/aggregate git status (one folder at a time, the selected row) + +## Solution + +Add a modal **Git Status overlay**, following the exact pattern already used by +the Compare (`D`) and Command Palette (`:`) overlays: a dedicated `viewState`, +a component that owns rendering + scrolling, an async command that gathers the +data off the UI thread, and a message that flips the model into the overlay +state. + +**Data layer (`internal/platform/gitstate.go`).** The existing file already runs +bounded git commands for the collapsed badge. Add a richer sibling: + +- A `GitStatus` struct carrying `Exists`, `IsRepo`, `Branch`, `Upstream`, + `Ahead`, `Behind`, per-category counts (`Staged`, `Modified`, `Untracked`, + `Deleted`, `Conflicts`), a `Clean` flag, and a capped slice of changed files + (short `XY` code + path). +- `DetectGitStatus(dir)` runs a single `git status --porcelain=v2 --branch` + invocation under the existing 2s context timeout. One command yields the + branch header, upstream, ahead/behind counts, and every changed entry, which + keeps the on-disk work and the parse atomic and consistent. +- A pure `parseGitStatusV2(output)` helper does the parsing so it can be unit + tested without a live repo. `DetectGitStatus` is the thin exec wrapper around it. + +**Component (`internal/tui/components/gitstatusview.go`).** A `GitStatusView` +mirroring `CompareView`: `SetStatus`, `SetSize`, `ScrollUp/Down`, `View()`, and +`PlainText()` for clipboard. It renders labelled rows (path, branch → upstream, +push/pull, the working-tree counts) and a scrollable changed-files list, reusing +the existing git colour styles (`GitAheadStyle`, `GitBehindStyle`, etc.). + +**Model wiring (`internal/tui`).** A new `stateGitStatusView`, a `gitStatusView` +field, a `gitStatusMsg`, a `showGitStatusCmd` that resolves the selected +session's `Cwd` (or the folder-pivot row's cwd) and runs `DetectGitStatus` +asynchronously, and a `GitStatus` keybinding on the free lowercase key **`i`** +("git info"). The overlay's key handling (esc / ↑↓ / copy) and resize follow the +`stateCompareView` cases. A palette entry and the demo-mode synthetic status +(gated by the existing `DISPATCH_DEMO_GIT_STATES` env var) keep parity with the +badge feature. + +## Alternatives Considered + +- **Inline section in the preview pane** instead of an overlay. Rejected: the + preview is already dense with conversation/plan content, the request is to + "easily see" status on demand, and an overlay matches the Compare/Timeline + precedent and is independently scrollable. +- **Reusing/extending the existing `GitState` enum.** Rejected: the enum is a + single collapsed value by design (drives the one-glyph badge). Push/pull stats + need real counts, so a parallel richer type is cleaner than overloading the enum. +- **Multiple git subprocesses** (`rev-parse`, `rev-list`, `status`) like the + badge path. Rejected in favour of one `--porcelain=v2 --branch` call: fewer + process spawns under the timeout and a single consistent snapshot. + +## Risks & Rabbit Holes + +- **Porcelain v2 parsing**: the `2` (rename/copy) and `u` (unmerged) record + shapes differ from ordinary `1` records. Parse defensively by field index and + treat the path as the remainder; do not over-model rename scoring. +- **No upstream / detached HEAD**: `branch.upstream` and `branch.ab` lines are + simply absent — represent "no upstream" explicitly rather than defaulting to + 0/0 as if synced. +- **Unbounded file lists**: cap the stored changed-files slice so a giant tree + cannot blow up memory or the render; the overlay scrolls through what is kept. +- **Blocking the UI**: all git work must stay behind the context timeout and run + inside the async `tea.Cmd`, never on the update path. diff --git a/docs/specs/git-status/test-plan.md b/docs/specs/git-status/test-plan.md new file mode 100644 index 0000000..f0f33bb --- /dev/null +++ b/docs/specs/git-status/test-plan.md @@ -0,0 +1,101 @@ +# Test Plan: Git Status Overlay + +## Status: COVERED +## Spec: docs/specs/git-status/spec.md +## Created: 2026-07-15 +## Updated: 2026-07-15 + +--- + +## Coverage Strategy + +Three test levels apply: + +- **Unit (pure)** — `parseGitStatusV2` is a pure string→struct function and gets + the bulk of the coverage: every branch header, record type, and edge case. +- **Unit (integration-lite)** — `DetectGitStatus` runs against real temp git + repos created in the test (`t.TempDir()` + `git init`), exercising the exec + wrapper, missing-dir and non-repo paths. Guarded by a `git` availability check. +- **Component** — `GitStatusView` render/scroll/plaintext, asserting the overlay + shows push/pull stats and working-tree counts. +- **Model** — the `showGitStatus` handler (empty path hint vs. async open) and + the overlay key handling (esc closes, ↑↓ scroll, copy). + +Runner: `go test ./... -count=1`. Coverage target: >=80% on new/modified lines. + +## Planned Tests + +| ID | Behavior to verify | Source | Level | Test file -> name | Status | +|----|--------------------|--------|-------|-------------------|--------| +| T1 | Clean repo parses: Clean=true, all counts 0, ahead/behind 0 | AC-3 | unit | platform/gitstate_test.go -> TestParseGitStatusV2_Clean | automated | +| T2 | Ahead/behind parsed from `# branch.ab +A -B` | AC-2 | unit | platform/gitstate_test.go -> TestParseGitStatusV2_AheadBehind | automated | +| T3 | Branch + upstream parsed from branch headers | AC-2 | unit | platform/gitstate_test.go -> TestParseGitStatusV2_BranchUpstream | automated | +| T4 | Staged/modified/deleted counts from `1`/`2` records (XY codes) | AC-3 | unit | platform/gitstate_test.go -> TestParseGitStatusV2_TrackedCounts | automated | +| T5 | Untracked `?` records counted | AC-3 | unit | platform/gitstate_test.go -> TestParseGitStatusV2_Untracked | automated | +| T6 | Unmerged `u` records counted as conflicts | AC-3 | unit | platform/gitstate_test.go -> TestParseGitStatusV2_Conflicts | automated | +| T7 | No-upstream: Upstream empty, HasUpstream false, ahead/behind 0 | AC-5 | unit | platform/gitstate_test.go -> TestParseGitStatusV2_NoUpstream | automated | +| T8 | Detached HEAD reported (branch.head "(detached)") | AC-5 | unit | platform/gitstate_test.go -> TestParseGitStatusV2_Detached | automated | +| T9 | Renamed `2` record: path parsed (remainder after tab), staged counted | AC-3 | unit | platform/gitstate_test.go -> TestParseGitStatusV2_Renamed | automated | +| T10 | Changed-files slice capped at max, counts still complete | AC-4 | unit | platform/gitstate_test.go -> TestParseGitStatusV2_FileCap | automated | +| T11 | DetectGitStatus on missing dir → Exists=false | AC-5 | unit | platform/gitstate_test.go -> TestDetectGitStatus_Missing | automated | +| T12 | DetectGitStatus on non-repo dir → IsRepo=false | AC-5 | unit | platform/gitstate_test.go -> TestDetectGitStatus_NonRepo | automated | +| T13 | DetectGitStatus on real temp repo with a staged+untracked file | AC-1,AC-3 | unit | platform/gitstate_test.go -> TestDetectGitStatus_RealRepo | automated | +| T14 | GitStatusView.View shows push/pull line + counts for dirty status | AC-2,AC-3 | component | components/gitstatusview_test.go -> TestGitStatusView_ViewDirty | automated | +| T15 | GitStatusView.View shows "clean" for a clean status | AC-3 | component | components/gitstatusview_test.go -> TestGitStatusView_ViewClean | automated | +| T16 | GitStatusView.View handles non-repo / missing dir messaging | AC-5 | component | components/gitstatusview_test.go -> TestGitStatusView_ViewNonRepo | automated | +| T17 | GitStatusView.PlainText returns copyable summary incl. ahead/behind | AC-6 | component | components/gitstatusview_test.go -> TestGitStatusView_PlainText | automated | +| T18 | GitStatusView scroll clamps within file list bounds | AC-4 | component | components/gitstatusview_test.go -> TestGitStatusView_Scroll | automated | +| T19 | showGitStatus with no selected path → status hint, stays in list state | AC-1 | model | tui/model_gitstatus_test.go -> TestHandleGitStatus_NoPath | automated | +| T20 | gitStatusMsg opens overlay (state=stateGitStatusView) | AC-1 | model | tui/model_gitstatus_test.go -> TestHandleGitStatusMsg_OpensOverlay | automated | +| T21 | esc closes overlay back to session list | AC-6 | model | tui/model_gitstatus_test.go -> TestGitStatusOverlay_EscCloses | automated | +| T22 | copy key writes PlainText to clipboard from overlay | AC-6 | model | tui/model_gitstatus_test.go -> TestGitStatusOverlay_Copy | automated | +| T23 | `i` key binding is registered and remappable (keybindingEntries) | AC-1 | unit | tui/keys_test.go -> TestGitStatusKeybinding | automated | +| T24 | gitSafeArgs prepends core.fsmonitor hardening before subcommand | AC-5 | unit | platform/gitstatus_test.go -> TestGitSafeArgs_Hardens | automated | +| T25 | DetectGitStatus does not execute a malicious core.fsmonitor hook (CWE-829) | AC-5 | unit | platform/gitstatus_test.go -> TestDetectGitStatus_FsmonitorNotExecuted | automated | + +## Functionality Inventory (Phase 3 reconciliation) + +Built after implementation from `git diff origin/main...HEAD`. + +| # | Functionality introduced | Location | Covered by | Status | +|---|--------------------------|----------|------------|--------| +| F1 | `GitStatus` struct + `Clean()` method | platform/gitstate.go | T1,T13,T14 | covered | +| F2 | `GitFileStatus` struct | platform/gitstate.go | T4,T14 | covered | +| F3 | `DetectGitStatus` exec wrapper (missing/non-repo/real) | platform/gitstate.go | T11,T12,T13 | covered | +| F4 | `parseGitStatusV2` branch-header parsing | platform/gitstate.go | T1,T2,T3,T7,T8 | covered | +| F5 | `parseTrackedEntry` `1`/`2` XY classification | platform/gitstate.go | T4,T9 | covered | +| F6 | `pathFromTracked` (spaces, rename tab-split) | platform/gitstate.go | T4,T9 | covered | +| F7 | `unmergedEntry` conflict parsing | platform/gitstate.go | T6 | covered | +| F8 | untracked `?` + `shortCode` + `atoiSign` + `addFile` cap | platform/gitstate.go | T5,T10 | covered | +| F9 | `gitSafeArgs` hardening (core.fsmonitor RCE fix, CWE-829) | platform/gitstate.go | T24,T25 | covered | +| F10 | Hardening applied to pre-existing DetectGitState/AheadBehind | platform/gitstate.go | T25 (build+full suite) | covered | +| F11 | `GitStatusView` render (dirty/clean/no-upstream) | components/gitstatusview.go | T14,T15,T25b | covered | +| F12 | `GitStatusView` non-repo/missing messaging | components/gitstatusview.go | T16 | covered | +| F13 | `GitStatusView.PlainText` clipboard summary | components/gitstatusview.go | T17,T26b | covered | +| F14 | `GitStatusView` scroll clamp | components/gitstatusview.go | T18 | covered | +| F14b | `truncPath` rune-safe left-truncation (multi-byte UTF-8) | components/gitstatusview.go | T29 | covered | +| F15 | `handleGitStatus` path resolution + empty hint | tui/model.go | T19,T19b | covered | +| F16 | `showGitStatusCmd` async + demo-mode branch | tui/model.go | T19b,T26 (demoGitStatus) | covered | +| F17 | `handleGitStatusMsg` opens overlay | tui/model.go | T20 | covered | +| F18 | Overlay key handling (esc/scroll/copy/copy-error) | tui/model.go | T21,T22,T22b,T27 | covered | +| F19 | `git_status` keybinding + remappable entry | tui/keys.go | T23 | covered | +| F20 | Command-palette "git-status" entry + dispatch | tui/cmdpalette.go | T28 | covered | +| F21 | Resize propagation to overlay | tui/handlers.go | (exercised via build; render path covered by T14) | covered | + +Added during reconciliation (beyond the Phase 1 plan): +- T24 → TestGitSafeArgs_Hardens (unit) — hardening flags prepended before subcommand +- T25 → TestDetectGitStatus_FsmonitorNotExecuted (unit, positive-control) — RCE fix proven +- T26 → TestDemoGitStatus (model) — demo synthetic status populated +- T27 → TestGitStatusOverlay_ScrollKeys (model) — scroll keys keep overlay open +- T28 → TestCmdPaletteAction_GitStatus (model) — palette action opens flow +- T22b → TestGitStatusOverlay_CopyError (model) — clipboard failure surfaces error +- T19b → TestHandleGitStatus_ValidPathReturnsCmd (model) — valid path returns async cmd +- T29 → TestTruncPath (component) — rune-safe truncation, no invalid UTF-8 on + multi-byte paths (regression for the MQ-found byte-slicing bug) + +## Gaps & Additions + +- All functionality units mapped to a covering test; zero GAP rows remaining. +- F21 (resize) has no dedicated assertion — behavior is a one-line SetSize call + mirroring the existing stateCompareView resize path; the render it feeds is + covered by T14/T15. Acceptable per low-risk parity with existing code. diff --git a/internal/platform/gitstate.go b/internal/platform/gitstate.go index 5a6235e..cda258b 100644 --- a/internal/platform/gitstate.go +++ b/internal/platform/gitstate.go @@ -66,6 +66,21 @@ func (g GitState) String() string { // gitCommandTimeout is the maximum time allowed for a single git command. const gitCommandTimeout = 2 * time.Second +// gitSafeArgs prepends configuration flags that neutralize executable Git +// config before the given git subcommand and its arguments. These commands run +// with cmd.Dir set to a session's working directory, which is untrusted input +// (it originates from the local session store and may point at an +// attacker-crafted repository). A repository's local .git/config can set +// core.fsmonitor to an arbitrary program, which `git status` executes while +// collecting state — arbitrary code execution in the user's context +// (CWE-829). Forcing core.fsmonitor=false (and clearing core.hooksPath as +// defense in depth) prevents Git from invoking any repository-supplied program. +func gitSafeArgs(args ...string) []string { + out := make([]string, 0, 4+len(args)) + out = append(out, "-c", "core.fsmonitor=false", "-c", "core.hooksPath=") + return append(out, args...) +} + // DetectGitState checks the Git workspace state of a directory. It returns // GitStateMissing if the path does not exist, GitStateUnknown if git is not // available or the directory is not a git repository, and the appropriate @@ -83,7 +98,7 @@ func DetectGitState(dir string) GitState { // Verify this is a git repository by running git rev-parse. ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout) defer cancel() - cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-dir") + cmd := exec.CommandContext(ctx, "git", gitSafeArgs("rev-parse", "--git-dir")...) cmd.Dir = dir if err := cmd.Run(); err != nil { return GitStateUnknown @@ -113,7 +128,7 @@ func gitStatusPorcelain(dir string) GitState { ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout) defer cancel() - cmd := exec.CommandContext(ctx, "git", "status", "--porcelain") + cmd := exec.CommandContext(ctx, "git", gitSafeArgs("status", "--porcelain")...) cmd.Dir = dir out, err := cmd.Output() if err != nil { @@ -156,7 +171,7 @@ func gitAheadBehind(dir string) (ahead, behind int) { ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout) defer cancel() - cmd := exec.CommandContext(ctx, "git", "rev-list", "--left-right", "--count", "HEAD...@{u}") + cmd := exec.CommandContext(ctx, "git", gitSafeArgs("rev-list", "--left-right", "--count", "HEAD...@{u}")...) cmd.Dir = dir out, err := cmd.Output() if err != nil { @@ -186,3 +201,243 @@ func ScanGitStates(sessions map[string]string) map[string]GitState { } return results } + +// --------------------------------------------------------------------------- +// Detailed git status (push/pull stats + working-tree counts) +// --------------------------------------------------------------------------- + +// maxGitStatusFiles caps the number of changed-file entries retained by +// DetectGitStatus so a pathologically large working tree cannot exhaust memory +// or overwhelm the overlay. Counts remain complete; only the file list is +// truncated (GitStatus.Truncated reports when this happens). +const maxGitStatusFiles = 500 + +// detachedHeadLabel is the branch.head value git reports for a detached HEAD. +const detachedHeadLabel = "(detached)" + +// GitFileStatus is a single changed path paired with its short two-character +// status code as shown by `git status --short` (e.g. " M", "??", "A ", "UU"). +type GitFileStatus struct { + Code string + Path string +} + +// GitStatus is a detailed snapshot of a directory's Git workspace. Unlike +// GitState (a single collapsed enum used for the list badge), it carries the +// standard push/pull counts and per-category working-tree counts needed for a +// full status view. +type GitStatus struct { + Dir string + Exists bool // directory exists on disk + IsRepo bool // directory is inside a Git work tree + Branch string + Detached bool + Upstream string // upstream ref, empty when there is none + HasUpstream bool + Ahead int // commits ahead of upstream (to push) + Behind int // commits behind upstream (to pull) + Staged int // entries with an index (staged) change + Modified int // entries modified in the work tree (not staged) + Untracked int // untracked files + Deleted int // entries deleted in the work tree (not staged) + Conflicts int // unmerged (conflicted) entries + + Files []GitFileStatus // changed entries, capped at maxGitStatusFiles + Truncated bool // Files was capped +} + +// Clean reports whether the working tree has no changes of any category. +func (s GitStatus) Clean() bool { + return s.Staged == 0 && s.Modified == 0 && s.Untracked == 0 && + s.Deleted == 0 && s.Conflicts == 0 +} + +// DetectGitStatus gathers a detailed Git status for dir. It returns a GitStatus +// with Exists=false when the path is missing, IsRepo=false when the path is not +// a Git repository (or git is unavailable / times out), and the fully populated +// status otherwise. +// +// It runs a single bounded `git status --porcelain=v2 --branch` command so the +// branch headers, ahead/behind counts, and changed entries come from one +// consistent snapshot and the call never blocks longer than gitCommandTimeout. +func DetectGitStatus(dir string) GitStatus { + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + return GitStatus{Dir: dir, Exists: false} + } + + ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout) + defer cancel() + + // core.quotepath=false keeps non-ASCII paths readable instead of octal-escaped. + // gitSafeArgs neutralizes executable config (core.fsmonitor) since dir is untrusted. + cmd := exec.CommandContext(ctx, "git", + gitSafeArgs("-c", "core.quotepath=false", "status", "--porcelain=v2", "--branch")...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + // Not a repository, git missing, or timeout. + return GitStatus{Dir: dir, Exists: true, IsRepo: false} + } + + s := parseGitStatusV2(string(out)) + s.Dir = dir + s.Exists = true + s.IsRepo = true + return s +} + +// parseGitStatusV2 parses the output of `git status --porcelain=v2 --branch` +// into a GitStatus. It is a pure function (no I/O) so it can be unit tested +// without a live repository. It does not set Dir/Exists/IsRepo — DetectGitStatus +// fills those from the on-disk check. +func parseGitStatusV2(output string) GitStatus { + var s GitStatus + for _, line := range strings.Split(output, "\n") { + if line == "" { + continue + } + switch line[0] { + case '#': + parseBranchHeader(line, &s) + case '1', '2': + parseTrackedEntry(line, &s) + case 'u': + s.Conflicts++ + s.addFile(unmergedEntry(line)) + case '?': + s.Untracked++ + s.addFile("??", strings.TrimPrefix(line, "? ")) + case '!': + // Ignored files — not reported. + } + } + return s +} + +// parseBranchHeader parses a `# branch.*` header line into s. +func parseBranchHeader(line string, s *GitStatus) { + fields := strings.Fields(line) + if len(fields) < 3 { + return + } + switch fields[1] { + case "branch.head": + if fields[2] == detachedHeadLabel { + s.Detached = true + s.Branch = detachedHeadLabel + } else { + s.Branch = fields[2] + } + case "branch.upstream": + s.Upstream = fields[2] + s.HasUpstream = true + case "branch.ab": + // Format: "# branch.ab + -". + if len(fields) >= 4 { + s.Ahead = atoiSign(fields[2]) + s.Behind = atoiSign(fields[3]) + } + } +} + +// parseTrackedEntry parses an ordinary ('1') or rename/copy ('2') changed entry +// and updates the per-category counts plus the file list. +func parseTrackedEntry(line string, s *GitStatus) { + // XY is always the second space-delimited token. + fields := strings.SplitN(line, " ", 3) + if len(fields) < 2 || len(fields[1]) < 2 { + return + } + xy := fields[1] + x, y := xy[0], xy[1] + + if x != '.' { + s.Staged++ + } + switch y { + case 'M', 'T': + s.Modified++ + case 'D': + s.Deleted++ + } + + s.addFile(shortCode(xy), pathFromTracked(line)) +} + +// pathFromTracked extracts the display path from a '1' or '2' porcelain-v2 +// record. The path is always the final space-delimited token, so splitting on +// spaces up to the fixed field count keeps paths that contain spaces intact. +// For rename/copy ('2') records the field is "\t"; only the new path +// is shown. +func pathFromTracked(line string) string { + // Ordinary entries have 8 fixed fields before the path; rename/copy have 9. + fixed := 9 // SplitN count for '1': 8 fields + path + if line[0] == '2' { + fixed = 10 // '2' adds the field before the path + } + parts := strings.SplitN(line, " ", fixed) + if len(parts) < fixed { + return "" + } + path := parts[fixed-1] + if line[0] == '2' { + if tab := strings.IndexByte(path, '\t'); tab >= 0 { + path = path[:tab] + } + } + return path +} + +// unmergedEntry returns the short code and path for an unmerged ('u') entry. +// Unmerged records carry a two-char XY at token index 1 and the path as the +// final token, with 10 fixed fields preceding it. +func unmergedEntry(line string) (code, path string) { + fields := strings.SplitN(line, " ", 3) + code = "UU" + if len(fields) >= 2 && len(fields[1]) >= 2 { + code = shortCode(fields[1]) + } + parts := strings.SplitN(line, " ", 11) + if len(parts) == 11 { + path = parts[10] + } + return code, path +} + +// addFile appends a changed-file entry, capping the slice at maxGitStatusFiles +// and flagging truncation once the cap is reached. +func (s *GitStatus) addFile(code, path string) { + if path == "" { + return + } + if len(s.Files) >= maxGitStatusFiles { + s.Truncated = true + return + } + s.Files = append(s.Files, GitFileStatus{Code: code, Path: path}) +} + +// shortCode converts a porcelain-v2 XY status into the two-character short +// form used by `git status --short`, rendering unchanged positions ('.') as +// spaces (e.g. "M." -> "M ", ".M" -> " M"). +func shortCode(xy string) string { + b := []byte(xy[:2]) + for i := range b { + if b[i] == '.' { + b[i] = ' ' + } + } + return string(b) +} + +// atoiSign parses a signed count token like "+2" or "-3" into its magnitude. +// Invalid input yields 0. +func atoiSign(tok string) int { + tok = strings.TrimLeft(tok, "+-") + n, err := strconv.Atoi(tok) + if err != nil { + return 0 + } + return n +} diff --git a/internal/platform/gitstatus_test.go b/internal/platform/gitstatus_test.go new file mode 100644 index 0000000..575326f --- /dev/null +++ b/internal/platform/gitstatus_test.go @@ -0,0 +1,330 @@ +package platform + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// joinLines builds porcelain-v2 style input from individual lines. +func joinLines(lines ...string) string { + return strings.Join(lines, "\n") + "\n" +} + +// TestParseGitStatusV2_Clean verifies a synced, clean repo parses to all-zero +// counts with Clean() true. +func TestParseGitStatusV2_Clean(t *testing.T) { + in := joinLines( + "# branch.oid abc123", + "# branch.head main", + "# branch.upstream origin/main", + "# branch.ab +0 -0", + ) + s := parseGitStatusV2(in) + if !s.Clean() { + t.Errorf("Clean() = false, want true for %+v", s) + } + if s.Ahead != 0 || s.Behind != 0 { + t.Errorf("ahead/behind = %d/%d, want 0/0", s.Ahead, s.Behind) + } + if !s.HasUpstream { + t.Error("HasUpstream = false, want true") + } +} + +// TestParseGitStatusV2_AheadBehind verifies push/pull counts come from branch.ab. +func TestParseGitStatusV2_AheadBehind(t *testing.T) { + in := joinLines( + "# branch.head main", + "# branch.upstream origin/main", + "# branch.ab +2 -3", + ) + s := parseGitStatusV2(in) + if s.Ahead != 2 { + t.Errorf("Ahead = %d, want 2", s.Ahead) + } + if s.Behind != 3 { + t.Errorf("Behind = %d, want 3", s.Behind) + } +} + +// TestParseGitStatusV2_BranchUpstream verifies branch and upstream names parse. +func TestParseGitStatusV2_BranchUpstream(t *testing.T) { + in := joinLines( + "# branch.head feature/login", + "# branch.upstream origin/feature/login", + "# branch.ab +0 -0", + ) + s := parseGitStatusV2(in) + if s.Branch != "feature/login" { + t.Errorf("Branch = %q, want feature/login", s.Branch) + } + if s.Upstream != "origin/feature/login" { + t.Errorf("Upstream = %q, want origin/feature/login", s.Upstream) + } + if s.Detached { + t.Error("Detached = true, want false") + } +} + +// TestParseGitStatusV2_TrackedCounts verifies staged/modified/deleted counts +// from ordinary '1' records and that a space-containing path is preserved. +func TestParseGitStatusV2_TrackedCounts(t *testing.T) { + in := joinLines( + "# branch.head main", + "1 M. N... 100644 100644 100644 1111 2222 staged.go", + "1 .M N... 100644 100644 100644 1111 2222 my file.go", + "1 .D N... 100644 100644 000000 1111 2222 gone.go", + ) + s := parseGitStatusV2(in) + if s.Staged != 1 { + t.Errorf("Staged = %d, want 1", s.Staged) + } + if s.Modified != 1 { + t.Errorf("Modified = %d, want 1", s.Modified) + } + if s.Deleted != 1 { + t.Errorf("Deleted = %d, want 1", s.Deleted) + } + if len(s.Files) != 3 { + t.Fatalf("Files len = %d, want 3", len(s.Files)) + } + // The second record's path contains a space and must be intact. + if s.Files[1].Path != "my file.go" { + t.Errorf("Files[1].Path = %q, want %q", s.Files[1].Path, "my file.go") + } + if s.Files[1].Code != " M" { + t.Errorf("Files[1].Code = %q, want %q", s.Files[1].Code, " M") + } +} + +// TestParseGitStatusV2_Untracked verifies '?' records are counted and listed. +func TestParseGitStatusV2_Untracked(t *testing.T) { + in := joinLines( + "# branch.head main", + "? new.txt", + "? docs/readme.md", + ) + s := parseGitStatusV2(in) + if s.Untracked != 2 { + t.Errorf("Untracked = %d, want 2", s.Untracked) + } + if len(s.Files) != 2 || s.Files[0].Code != "??" { + t.Errorf("Files = %+v, want two ?? entries", s.Files) + } + if s.Files[1].Path != "docs/readme.md" { + t.Errorf("Files[1].Path = %q, want docs/readme.md", s.Files[1].Path) + } +} + +// TestParseGitStatusV2_Conflicts verifies unmerged 'u' records count as conflicts. +func TestParseGitStatusV2_Conflicts(t *testing.T) { + in := joinLines( + "# branch.head main", + "u UU N... 100644 100644 100644 100644 h1 h2 h3 conflict.go", + ) + s := parseGitStatusV2(in) + if s.Conflicts != 1 { + t.Errorf("Conflicts = %d, want 1", s.Conflicts) + } + if len(s.Files) != 1 || s.Files[0].Path != "conflict.go" { + t.Errorf("Files = %+v, want conflict.go", s.Files) + } +} + +// TestParseGitStatusV2_NoUpstream verifies missing upstream headers leave +// HasUpstream false and counts zero. +func TestParseGitStatusV2_NoUpstream(t *testing.T) { + in := joinLines( + "# branch.oid abc", + "# branch.head main", + ) + s := parseGitStatusV2(in) + if s.HasUpstream { + t.Error("HasUpstream = true, want false") + } + if s.Upstream != "" { + t.Errorf("Upstream = %q, want empty", s.Upstream) + } + if s.Ahead != 0 || s.Behind != 0 { + t.Errorf("ahead/behind = %d/%d, want 0/0", s.Ahead, s.Behind) + } +} + +// TestParseGitStatusV2_Detached verifies a detached HEAD is reported. +func TestParseGitStatusV2_Detached(t *testing.T) { + in := joinLines( + "# branch.oid abc", + "# branch.head (detached)", + ) + s := parseGitStatusV2(in) + if !s.Detached { + t.Error("Detached = false, want true") + } + if s.Branch != "(detached)" { + t.Errorf("Branch = %q, want (detached)", s.Branch) + } +} + +// TestParseGitStatusV2_Renamed verifies a rename '2' record parses the new path +// (before the tab) and counts as staged. +func TestParseGitStatusV2_Renamed(t *testing.T) { + in := joinLines( + "# branch.head main", + "2 R. N... 100644 100644 100644 h1 h2 R100 new.go\told.go", + ) + s := parseGitStatusV2(in) + if s.Staged != 1 { + t.Errorf("Staged = %d, want 1", s.Staged) + } + if len(s.Files) != 1 || s.Files[0].Path != "new.go" { + t.Errorf("Files = %+v, want new.go", s.Files) + } + if s.Files[0].Code != "R " { + t.Errorf("Files[0].Code = %q, want %q", s.Files[0].Code, "R ") + } +} + +// TestParseGitStatusV2_FileCap verifies the file list is capped while counts +// remain complete and Truncated is flagged. +func TestParseGitStatusV2_FileCap(t *testing.T) { + var b strings.Builder + b.WriteString("# branch.head main\n") + total := maxGitStatusFiles + 100 + for i := 0; i < total; i++ { + b.WriteString("? file") + b.WriteByte(byte('0' + i%10)) + b.WriteString(".txt\n") + } + s := parseGitStatusV2(b.String()) + if s.Untracked != total { + t.Errorf("Untracked = %d, want %d", s.Untracked, total) + } + if len(s.Files) != maxGitStatusFiles { + t.Errorf("Files len = %d, want %d", len(s.Files), maxGitStatusFiles) + } + if !s.Truncated { + t.Error("Truncated = false, want true") + } +} + +// TestDetectGitStatus_Missing verifies a nonexistent path reports Exists=false. +func TestDetectGitStatus_Missing(t *testing.T) { + s := DetectGitStatus(filepath.Join(t.TempDir(), "nope")) + if s.Exists { + t.Errorf("Exists = true, want false for missing dir") + } + if s.IsRepo { + t.Errorf("IsRepo = true, want false for missing dir") + } +} + +// TestDetectGitStatus_NonRepo verifies a plain directory reports IsRepo=false. +func TestDetectGitStatus_NonRepo(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + s := DetectGitStatus(t.TempDir()) + if !s.Exists { + t.Error("Exists = false, want true for existing dir") + } + if s.IsRepo { + t.Error("IsRepo = true, want false for non-repo dir") + } +} + +// TestDetectGitStatus_RealRepo verifies detection against a live repo with a +// staged file and an untracked file. +func TestDetectGitStatus_RealRepo(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := initTestRepo(t) + + // Stage a new file. + if err := os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("s"), 0o644); err != nil { + t.Fatal(err) + } + add := exec.Command("git", "add", "staged.txt") + add.Dir = dir + if out, err := add.CombinedOutput(); err != nil { + t.Fatalf("git add: %s\n%s", err, out) + } + // Leave an untracked file. + if err := os.WriteFile(filepath.Join(dir, "untracked.txt"), []byte("u"), 0o644); err != nil { + t.Fatal(err) + } + + s := DetectGitStatus(dir) + if !s.Exists || !s.IsRepo { + t.Fatalf("Exists/IsRepo = %v/%v, want true/true", s.Exists, s.IsRepo) + } + if s.Branch == "" { + t.Error("Branch is empty, want a branch name") + } + if s.Staged < 1 { + t.Errorf("Staged = %d, want >= 1", s.Staged) + } + if s.Untracked < 1 { + t.Errorf("Untracked = %d, want >= 1", s.Untracked) + } + if s.Clean() { + t.Error("Clean() = true, want false for a dirty tree") + } +} + +// TestGitSafeArgs_Hardens verifies the hardening flags are prepended before the +// git subcommand so an untrusted repository's core.fsmonitor cannot execute. +func TestGitSafeArgs_Hardens(t *testing.T) { + got := gitSafeArgs("status", "--porcelain") + joined := strings.Join(got, " ") + if !strings.Contains(joined, "core.fsmonitor=false") { + t.Errorf("gitSafeArgs missing core.fsmonitor hardening: %v", got) + } + // The subcommand and its args must be preserved at the end, after the flags. + if len(got) < 2 || got[len(got)-2] != "status" || got[len(got)-1] != "--porcelain" { + t.Errorf("subcommand args not preserved at end: %v", got) + } +} + +// TestDetectGitStatus_FsmonitorNotExecuted proves DetectGitStatus neutralizes a +// malicious core.fsmonitor hook (CWE-829 arbitrary code execution). A positive +// control confirms the environment actually executes fsmonitor; otherwise the +// test cannot prove the hardening and is skipped. +func TestDetectGitStatus_FsmonitorNotExecuted(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := initTestRepo(t) + + marker := filepath.Join(dir, "PWNED.txt") + hookPath := filepath.Join(dir, "evil.sh") + if err := os.WriteFile(hookPath, []byte("#!/bin/sh\necho pwned > PWNED.txt\nexit 1\n"), 0o755); err != nil { + t.Fatal(err) + } + setCfg := exec.Command("git", "config", "core.fsmonitor", "./evil.sh") + setCfg.Dir = dir + if out, err := setCfg.CombinedOutput(); err != nil { + t.Fatalf("git config: %s\n%s", err, out) + } + + // Positive control: an unhardened status must trigger the hook here, + // otherwise this environment cannot demonstrate the vulnerability — skip. + ctrl := exec.Command("git", "status", "--porcelain=v2", "--branch") + ctrl.Dir = dir + _, _ = ctrl.CombinedOutput() + if _, err := os.Stat(marker); err != nil { + t.Skip("environment does not execute core.fsmonitor; cannot verify hardening") + } + if err := os.Remove(marker); err != nil { + t.Fatal(err) + } + + // DetectGitStatus is hardened, so the malicious hook must NOT run. + _ = DetectGitStatus(dir) + if _, err := os.Stat(marker); err == nil { + t.Fatal("fsmonitor hook executed via DetectGitStatus — core.fsmonitor not neutralized (CWE-829)") + } +} diff --git a/internal/tui/cmdpalette.go b/internal/tui/cmdpalette.go index 6afd028..bce367c 100644 --- a/internal/tui/cmdpalette.go +++ b/internal/tui/cmdpalette.go @@ -52,6 +52,7 @@ func (m *Model) openCmdPalette() { {Name: "Hide Session", Shortcut: "h", Description: "hide from list", Action: "hide", Enabled: hasSelection}, {Name: "Export Markdown", Shortcut: "X", Description: "export session", Action: "export", Enabled: hasSelection}, {Name: "Open Reference", Shortcut: "b", Description: "open PR/issue/commit", Action: "open-ref", Enabled: hasRef}, + {Name: "Git Status", Shortcut: "i", Description: "folder git status", Action: "git-status", Enabled: hasPath}, {Name: "Rebuild Index", Shortcut: "r", Description: "reindex sessions", Action: "reindex", Enabled: func() bool { return !m.reindexing }}, {Name: "Settings", Shortcut: ",", Description: "open config", Action: "settings", Enabled: alwaysEnabled}, {Name: "Help", Shortcut: "?", Description: "keyboard shortcuts", Action: "help", Enabled: alwaysEnabled}, @@ -135,6 +136,9 @@ func (m Model) handleCmdPaletteAction(msg cmdPaletteActionMsg) (tea.Model, tea.C case "open-ref": return m.handleOpenRef() + case "git-status": + return m.handleGitStatus() + case "reindex": if !m.reindexing { m.reindexing = true diff --git a/internal/tui/components/gitstatusview.go b/internal/tui/components/gitstatusview.go new file mode 100644 index 0000000..582c070 --- /dev/null +++ b/internal/tui/components/gitstatusview.go @@ -0,0 +1,272 @@ +package components + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/jongio/dispatch/internal/platform" + "github.com/jongio/dispatch/internal/tui/styles" +) + +// GitStatusView renders a detailed Git status overlay for the working directory +// a session is mapped to: branch, upstream, push/pull (ahead/behind) counts, +// working-tree category counts, and a scrollable list of changed files. +type GitStatusView struct { + status platform.GitStatus + set bool + width int + height int + scroll int // vertical scroll offset into the changed-file list +} + +// NewGitStatusView returns an empty GitStatusView. Call SetStatus to populate. +func NewGitStatusView() GitStatusView { + return GitStatusView{} +} + +// SetStatus configures the git status to display and resets scroll. +func (g *GitStatusView) SetStatus(s platform.GitStatus) { + g.status = s + g.set = true + g.scroll = 0 +} + +// SetSize updates the available rendering dimensions. +func (g *GitStatusView) SetSize(w, h int) { + g.width = w + g.height = h +} + +// ScrollUp moves the changed-file viewport up by one line. +func (g *GitStatusView) ScrollUp() { + if g.scroll > 0 { + g.scroll-- + } +} + +// ScrollDown moves the changed-file viewport down by one line. +func (g *GitStatusView) ScrollDown() { + g.scroll++ +} + +// View renders the git status overlay, centered in the available area. +func (g *GitStatusView) View() string { + if !g.set { + return "" + } + + contentW := min(64, g.width-4) + contentW = max(contentW, 30) + + header, files := g.buildLines(contentW) + + // The header (metadata) is always visible; only the file list scrolls. + visibleH := g.height - len(header) - 8 // title + footer + border + padding + if visibleH < 1 { + visibleH = 1 + } + if g.scroll > len(files)-visibleH { + g.scroll = max(0, len(files)-visibleH) + } + if g.scroll < 0 { + g.scroll = 0 + } + end := g.scroll + visibleH + if end > len(files) { + end = len(files) + } + var visibleFiles []string + if len(files) > 0 { + visibleFiles = files[g.scroll:end] + } + + title := styles.OverlayTitleStyle.Render("Git Status") + body := title + "\n" + strings.Join(header, "\n") + if len(visibleFiles) > 0 { + body += "\n" + strings.Join(visibleFiles, "\n") + } + footer := styles.DimmedStyle.Render("esc close | c copy | ↑↓ scroll") + body += "\n\n" + footer + + overlay := styles.OverlayStyle.Width(contentW).Render(body) + if g.width == 0 || g.height == 0 { + return overlay + } + return lipgloss.Place(g.width, g.height, lipgloss.Center, lipgloss.Center, overlay) +} + +// PlainText returns an unstyled summary suitable for the clipboard. +func (g *GitStatusView) PlainText() string { + if !g.set { + return "" + } + s := g.status + var b strings.Builder + b.WriteString("Git Status\n") + fmt.Fprintf(&b, "Path: %s\n", s.Dir) + + if !s.Exists { + b.WriteString("Directory not found\n") + return b.String() + } + if !s.IsRepo { + b.WriteString("Not a git repository\n") + return b.String() + } + + fmt.Fprintf(&b, "Branch: %s\n", branchLabel(s)) + if s.HasUpstream { + fmt.Fprintf(&b, "Upstream: %s\n", s.Upstream) + fmt.Fprintf(&b, "Push/Pull: %d ahead, %d behind\n", s.Ahead, s.Behind) + } else { + b.WriteString("Upstream: (none)\n") + } + + if s.Clean() { + b.WriteString("Working tree: clean\n") + } else { + fmt.Fprintf(&b, "Staged: %d Modified: %d Untracked: %d Deleted: %d Conflicts: %d\n", + s.Staged, s.Modified, s.Untracked, s.Deleted, s.Conflicts) + } + + if len(s.Files) > 0 { + b.WriteString("\nChanged files:\n") + for _, f := range s.Files { + fmt.Fprintf(&b, " %s %s\n", f.Code, f.Path) + } + if s.Truncated { + b.WriteString(" … (list truncated)\n") + } + } + return b.String() +} + +// buildLines renders the always-visible metadata header and the scrollable +// changed-file lines separately so the header stays pinned while files scroll. +func (g *GitStatusView) buildLines(contentW int) (header, files []string) { + s := g.status + + header = append(header, row("Path", truncPath(s.Dir, contentW-14))) + + if !s.Exists { + header = append(header, styles.GitMissingStyle.Render("Directory not found")) + return header, nil + } + if !s.IsRepo { + header = append(header, styles.DimmedStyle.Render("Not a git repository")) + return header, nil + } + + header = append(header, row("Branch", branchLabel(s))) + header = append(header, row("Upstream", upstreamLabel(s))) + header = append(header, row("Push/Pull", pushPullLabel(s))) + header = append(header, "") + header = append(header, row("Working", workingLabel(s))) + + if !s.Clean() { + header = append(header, countsLine(s)) + } + + if len(s.Files) > 0 { + header = append(header, "") + label := fmt.Sprintf("Changed files (%d)", len(s.Files)) + if s.Truncated { + label += " — truncated" + } + header = append(header, styles.PreviewLabelStyle.Render(label)) + for _, f := range s.Files { + files = append(files, fileLine(f, contentW)) + } + } + return header, files +} + +// --------------------------------------------------------------------------- +// Rendering helpers +// --------------------------------------------------------------------------- + +// row renders a "label value" line with a fixed-width styled label. +func row(label, value string) string { + l := styles.PreviewLabelStyle.Render(fmt.Sprintf("%-10s", label)) + return l + " " + styles.PreviewValueStyle.Render(value) +} + +// branchLabel returns the branch name, marking a detached HEAD. +func branchLabel(s platform.GitStatus) string { + if s.Detached { + return "detached HEAD" + } + if s.Branch == "" { + return "(unknown)" + } + return s.Branch +} + +// upstreamLabel returns the upstream ref or a "none" placeholder. +func upstreamLabel(s platform.GitStatus) string { + if !s.HasUpstream || s.Upstream == "" { + return styles.DimmedStyle.Render("(none)") + } + return s.Upstream +} + +// pushPullLabel renders the standard ahead/behind push/pull stats with icons +// and colors, or a note when there is no upstream to compare against. +func pushPullLabel(s platform.GitStatus) string { + if !s.HasUpstream { + return styles.DimmedStyle.Render("no upstream") + } + ahead := styles.GitAheadStyle.Render(fmt.Sprintf("%s%d", styles.IconGitAhead(), s.Ahead)) + behind := styles.GitBehindStyle.Render(fmt.Sprintf("%s%d", styles.IconGitBehind(), s.Behind)) + push := styles.DimmedStyle.Render(" to push") + pull := styles.DimmedStyle.Render(" to pull") + if s.Ahead == 0 && s.Behind == 0 { + return styles.SuccessStyle.Render("up to date") + } + return ahead + push + " " + behind + pull +} + +// workingLabel returns "clean" (styled) or a short dirty summary. +func workingLabel(s platform.GitStatus) string { + if s.Clean() { + return styles.GitCleanStyle.Render("clean") + } + return styles.GitDirtyStyle.Render("changes") +} + +// countsLine renders the per-category working-tree counts, omitting zeros. +func countsLine(s platform.GitStatus) string { + var parts []string + add := func(label string, n int, st lipgloss.Style) { + if n > 0 { + parts = append(parts, st.Render(fmt.Sprintf("%s %d", label, n))) + } + } + add("staged", s.Staged, styles.SuccessStyle) + add("modified", s.Modified, styles.GitDirtyStyle) + add("untracked", s.Untracked, styles.GitUntrackedStyle) + add("deleted", s.Deleted, styles.ErrorStyle) + add("conflicts", s.Conflicts, styles.ErrorStyle) + return " " + strings.Join(parts, " ") +} + +// fileLine renders a single changed-file entry: " ". +func fileLine(f platform.GitFileStatus, contentW int) string { + code := styles.DimmedStyle.Render(fmt.Sprintf("%-2s", f.Code)) + return " " + code + " " + truncPath(f.Path, contentW-6) +} + +// truncPath left-truncates a path with an ellipsis when it exceeds width. +// It operates on runes (not bytes) so multi-byte UTF-8 paths are sliced +// at character boundaries. +func truncPath(p string, width int) string { + if width < 4 { + width = 4 + } + runes := []rune(p) + if len(runes) <= width { + return p + } + return "…" + string(runes[len(runes)-(width-1):]) +} diff --git a/internal/tui/components/gitstatusview_test.go b/internal/tui/components/gitstatusview_test.go new file mode 100644 index 0000000..584e3a5 --- /dev/null +++ b/internal/tui/components/gitstatusview_test.go @@ -0,0 +1,217 @@ +package components + +import ( + "strings" + "testing" + "unicode/utf8" + + "github.com/jongio/dispatch/internal/platform" +) + +func TestGitStatusView_NotSet(t *testing.T) { + t.Parallel() + g := NewGitStatusView() + if got := g.View(); got != "" { + t.Errorf("View() before SetStatus should be empty, got %q", got) + } + if got := g.PlainText(); got != "" { + t.Errorf("PlainText() before SetStatus should be empty, got %q", got) + } +} + +func TestGitStatusView_ViewDirty(t *testing.T) { + t.Parallel() + g := NewGitStatusView() + g.SetStatus(platform.GitStatus{ + Dir: "/home/me/proj", + Exists: true, + IsRepo: true, + Branch: "feature/login", + Upstream: "origin/feature/login", + HasUpstream: true, + Ahead: 2, + Behind: 1, + Modified: 3, + Untracked: 1, + Files: []platform.GitFileStatus{ + {Code: " M", Path: "internal/auth/session.go"}, + {Code: "??", Path: "notes.txt"}, + }, + }) + g.SetSize(80, 30) + + view := g.View() + if view == "" { + t.Fatal("View() should not be empty for a dirty status") + } + for _, want := range []string{"Git Status", "feature/login", "to push", "to pull", "modified"} { + if !strings.Contains(view, want) { + t.Errorf("View() missing %q\n%s", want, view) + } + } +} + +func TestGitStatusView_ViewClean(t *testing.T) { + t.Parallel() + g := NewGitStatusView() + g.SetStatus(platform.GitStatus{ + Dir: "/home/me/proj", + Exists: true, + IsRepo: true, + Branch: "main", + Upstream: "origin/main", + HasUpstream: true, + }) + g.SetSize(80, 30) + + view := g.View() + if !strings.Contains(view, "clean") { + t.Errorf("View() should mark a clean tree, got:\n%s", view) + } + if !strings.Contains(view, "up to date") { + t.Errorf("View() should show up to date when synced, got:\n%s", view) + } +} + +func TestGitStatusView_ViewNonRepo(t *testing.T) { + t.Parallel() + + missing := NewGitStatusView() + missing.SetStatus(platform.GitStatus{Dir: "/gone", Exists: false}) + missing.SetSize(80, 20) + if v := missing.View(); !strings.Contains(v, "Directory not found") { + t.Errorf("View() should report missing dir, got:\n%s", v) + } + + nonRepo := NewGitStatusView() + nonRepo.SetStatus(platform.GitStatus{Dir: "/tmp/x", Exists: true, IsRepo: false}) + nonRepo.SetSize(80, 20) + if v := nonRepo.View(); !strings.Contains(v, "Not a git repository") { + t.Errorf("View() should report non-repo, got:\n%s", v) + } +} + +func TestGitStatusView_ViewNoUpstream(t *testing.T) { + t.Parallel() + g := NewGitStatusView() + g.SetStatus(platform.GitStatus{ + Dir: "/home/me/proj", + Exists: true, + IsRepo: true, + Branch: "local-only", + Modified: 1, + }) + g.SetSize(80, 30) + + view := g.View() + if !strings.Contains(view, "no upstream") { + t.Errorf("View() should note no upstream, got:\n%s", view) + } +} + +func TestGitStatusView_PlainText(t *testing.T) { + t.Parallel() + g := NewGitStatusView() + g.SetStatus(platform.GitStatus{ + Dir: "/home/me/proj", + Exists: true, + IsRepo: true, + Branch: "main", + Upstream: "origin/main", + HasUpstream: true, + Ahead: 4, + Behind: 0, + Staged: 2, + Files: []platform.GitFileStatus{ + {Code: "M ", Path: "a.go"}, + }, + }) + + txt := g.PlainText() + for _, want := range []string{"Git Status", "/home/me/proj", "Push/Pull", "4 ahead", "a.go"} { + if !strings.Contains(txt, want) { + t.Errorf("PlainText() missing %q\n%s", want, txt) + } + } +} + +func TestGitStatusView_PlainTextNonRepo(t *testing.T) { + t.Parallel() + g := NewGitStatusView() + g.SetStatus(platform.GitStatus{Dir: "/x", Exists: true, IsRepo: false}) + if txt := g.PlainText(); !strings.Contains(txt, "Not a git repository") { + t.Errorf("PlainText() should report non-repo, got %q", txt) + } +} + +func TestGitStatusView_Scroll(t *testing.T) { + t.Parallel() + g := NewGitStatusView() + files := make([]platform.GitFileStatus, 50) + for i := range files { + files[i] = platform.GitFileStatus{Code: " M", Path: strings.Repeat("x", i+1) + ".go"} + } + g.SetStatus(platform.GitStatus{ + Dir: "/p", Exists: true, IsRepo: true, Branch: "main", Modified: 50, Files: files, + }) + g.SetSize(80, 12) // short viewport forces scrolling + + // ScrollUp at top stays at 0. + g.ScrollUp() + if g.scroll != 0 { + t.Errorf("scroll = %d after ScrollUp at top, want 0", g.scroll) + } + + for i := 0; i < 200; i++ { + g.ScrollDown() + } + g.View() // clamps + if g.scroll < 0 { + t.Errorf("scroll = %d, want >= 0", g.scroll) + } + if g.scroll > len(files) { + t.Errorf("scroll = %d, want <= file count %d", g.scroll, len(files)) + } +} + +func TestGitStatusView_Footer(t *testing.T) { + t.Parallel() + g := NewGitStatusView() + g.SetStatus(platform.GitStatus{Dir: "/p", Exists: true, IsRepo: true, Branch: "main"}) + g.SetSize(80, 20) + view := g.View() + if !strings.Contains(view, "esc") || !strings.Contains(view, "copy") { + t.Errorf("View() footer should mention esc and copy, got:\n%s", view) + } +} + +func TestTruncPath(t *testing.T) { + t.Parallel() + + // Short path is returned unchanged. + if got := truncPath("a/b.go", 20); got != "a/b.go" { + t.Errorf("truncPath short = %q, want unchanged", got) + } + + // Long ASCII path is left-truncated with an ellipsis and never longer + // than the requested width. + long := "internal/tui/components/deeply/nested/file.go" + got := truncPath(long, 15) + if !strings.HasPrefix(got, "…") { + t.Errorf("truncPath long = %q, want leading ellipsis", got) + } + if n := len([]rune(got)); n > 15 { + t.Errorf("truncPath rune length = %d, want <= 15", n) + } + + // Multi-byte UTF-8 path must be sliced on rune boundaries, never emitting + // invalid UTF-8 (regression: byte slicing could split a code point). + multi := strings.Repeat("λ", 40) + "/файл.go" + out := truncPath(multi, 12) + if !utf8.ValidString(out) { + t.Errorf("truncPath multibyte produced invalid UTF-8: %q", out) + } + if n := len([]rune(out)); n > 12 { + t.Errorf("truncPath multibyte rune length = %d, want <= 12", n) + } +} diff --git a/internal/tui/handlers.go b/internal/tui/handlers.go index f0b83b2..392f34b 100644 --- a/internal/tui/handlers.go +++ b/internal/tui/handlers.go @@ -46,6 +46,9 @@ func (m Model) handleResize(msg tea.WindowSizeMsg) (Model, tea.Cmd) { //nolint:u if m.state == stateCompareView { m.compareView.SetSize(m.width, m.height) } + if m.state == stateGitStatusView { + m.gitStatusView.SetSize(m.width, m.height) + } return m, nil } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 230936a..8cd9eb8 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -72,12 +72,13 @@ type keyMap struct { OpenRef key.Binding Timeline key.Binding Compare key.Binding + GitStatus key.Binding CmdPalette key.Binding } // ShortHelp returns a compact set of key bindings for the mini help bar. func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Enter, k.LaunchWindow, k.LaunchTab, k.LaunchPane, k.LaunchAll, k.Search, k.Filter, k.Sort, k.Preview, k.PreviewFullscreen, k.ViewPlan, k.Timeline, k.Compare, k.Hide, k.Star, k.Note, k.Tags, k.Alias, k.CopyID, k.CopyPath, k.CopyResumeCommand, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.OpenRef, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted, k.ScanWorkStatus, k.ExpandCollapseAll, k.ViewSwitch, k.CmdPalette, k.Config, k.Help, k.Quit} + return []key.Binding{k.Enter, k.LaunchWindow, k.LaunchTab, k.LaunchPane, k.LaunchAll, k.Search, k.Filter, k.Sort, k.Preview, k.PreviewFullscreen, k.ViewPlan, k.Timeline, k.Compare, k.GitStatus, k.Hide, k.Star, k.Note, k.Tags, k.Alias, k.CopyID, k.CopyPath, k.CopyResumeCommand, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.OpenRef, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted, k.ScanWorkStatus, k.ExpandCollapseAll, k.ViewSwitch, k.CmdPalette, k.Config, k.Help, k.Quit} } // FullHelp returns grouped key bindings for the expanded help view. @@ -87,7 +88,7 @@ func (k keyMap) FullHelp() [][]key.Binding { {k.Space, k.LaunchAll, k.SelectAll, k.DeselectAll, k.ShiftUp, k.ShiftDown}, {k.Search, k.Escape, k.Filter}, {k.Sort, k.SortOrder, k.Pivot, k.PivotOrder, k.ExpandCollapseAll}, - {k.Preview, k.PreviewFullscreen, k.PreviewPosition, k.PreviewScrollUp, k.PreviewScrollDown, k.ConversationSort, k.ViewPlan, k.Timeline, k.Compare, k.CopyID, k.CopyPath, k.CopyResumeCommand, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.OpenRef, k.Reindex, k.ScanWorkStatus, k.ViewSwitch, k.Config}, + {k.Preview, k.PreviewFullscreen, k.PreviewPosition, k.PreviewScrollUp, k.PreviewScrollDown, k.ConversationSort, k.ViewPlan, k.Timeline, k.Compare, k.GitStatus, k.CopyID, k.CopyPath, k.CopyResumeCommand, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.OpenRef, k.Reindex, k.ScanWorkStatus, k.ViewSwitch, k.Config}, {k.Hide, k.ToggleHidden, k.Star, k.Note, k.Tags, k.Alias, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted}, {k.TimeRange1, k.TimeRange2, k.TimeRange3, k.TimeRange4}, {k.Help, k.CmdPalette, k.Quit}, @@ -161,6 +162,7 @@ func defaultKeyMap() keyMap { OpenRef: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "open PR/issue/commit")), Timeline: key.NewBinding(key.WithKeys("T"), key.WithHelp("T", "activity timeline")), Compare: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "compare selected")), + GitStatus: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "git status")), CmdPalette: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "command palette")), } } @@ -238,6 +240,7 @@ func keybindingEntries(km *keyMap) []keybindingEntry { {"open_ref", &km.OpenRef}, {"timeline", &km.Timeline}, {"compare", &km.Compare}, + {"git_status", &km.GitStatus}, {"cmd_palette", &km.CmdPalette}, } } diff --git a/internal/tui/messages.go b/internal/tui/messages.go index e6b49eb..9bf8084 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -181,6 +181,12 @@ type gitStateScannedMsg struct { states map[string]platform.GitState } +// gitStatusMsg delivers the detailed git status for a single session folder, +// gathered off the UI thread for the git status overlay. +type gitStatusMsg struct { + status platform.GitStatus +} + // fileOpenedMsg reports the result of opening a file with the platform opener. type fileOpenedMsg struct { path string diff --git a/internal/tui/model.go b/internal/tui/model.go index 098a9d0..1a8b3e1 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -136,6 +136,7 @@ const ( stateFilePicker // file picker overlay stateCompareView // compare two sessions overlay stateCmdPalette // command palette overlay + stateGitStatusView // git status overlay for a session's folder ) // Pivot mode constants used by Model.pivot to control session grouping. @@ -327,6 +328,7 @@ type Model struct { viewPicker components.ViewPicker filePicker components.FilePicker compareView components.CompareView + gitStatusView components.GitStatusView cmdPalette components.CmdPalette spinner spinner.Model @@ -506,6 +508,7 @@ func NewModel() Model { viewPicker: components.NewViewPicker(), filePicker: components.NewFilePicker(), compareView: components.NewCompareView(), + gitStatusView: components.NewGitStatusView(), cmdPalette: components.NewCmdPalette(), attentionFilter: make(map[data.AttentionStatus]struct{}), waitingNotified: make(map[string]struct{}), @@ -672,6 +675,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case compareDetailMsg: return m.handleCompareDetail(msg) + case gitStatusMsg: + return m.handleGitStatusMsg(msg), nil + // ----- Transient status clear ----------------------------------------- case clearStatusMsg: return m.handleClearStatus() @@ -819,6 +825,9 @@ func (m Model) View() tea.View { case stateCompareView: content = m.compareView.View() + case stateGitStatusView: + content = m.gitStatusView.View() + case stateCmdPalette: content = m.cmdPalette.View() @@ -1015,6 +1024,26 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } return m, nil + case stateGitStatusView: + switch { + case key.Matches(msg, keys.Escape), key.Matches(msg, keys.GitStatus): + m.state = stateSessionList + case key.Matches(msg, keys.Up), key.Matches(msg, keys.PreviewScrollUp): + m.gitStatusView.ScrollUp() + case key.Matches(msg, keys.Down), key.Matches(msg, keys.PreviewScrollDown): + m.gitStatusView.ScrollDown() + case key.Matches(msg, keys.CopyID): + // 'c' copies the git status summary to the clipboard. + txt := m.gitStatusView.PlainText() + if err := clipboardWrite(txt); err != nil { + m.statusErr = "clipboard: " + err.Error() + } else { + m.statusInfo = "Copied git status \u2713" + } + return m, clearStatusAfter(2 * time.Second) + } + return m, nil + case stateCmdPalette: switch { case key.Matches(msg, keys.Escape): @@ -1650,6 +1679,9 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.Compare): return m.handleCompare() + + case key.Matches(msg, keys.GitStatus): + return m.handleGitStatus() } return m, nil @@ -2060,6 +2092,73 @@ func (m Model) handleCompareDetail(msg compareDetailMsg) (Model, tea.Cmd) { return m, nil } +// handleGitStatus opens the git status overlay for the selected session's +// working directory (or the selected folder-pivot row's path). The path is +// resolved synchronously; the status itself is gathered off the UI thread. +func (m Model) handleGitStatus() (tea.Model, tea.Cmd) { + path := m.gitStatusTargetDir() + if path == "" { + m.statusInfo = "No folder to inspect" + return m, clearStatusAfter(2 * time.Second) + } + return m, m.showGitStatusCmd(path) +} + +// gitStatusTargetDir returns the directory whose git status should be shown: +// the selected session's Cwd, or the selected folder-pivot row's path. +func (m Model) gitStatusTargetDir() string { + if sess, ok := m.sessionList.Selected(); ok { + return sess.Cwd + } + return m.sessionList.SelectedFolderCwd() +} + +// showGitStatusCmd gathers the detailed git status for dir in the background +// and delivers it via gitStatusMsg. In demo mode it returns a synthetic status +// so the overlay renders realistic stats without a real repo on disk. +func (m Model) showGitStatusCmd(dir string) tea.Cmd { + if os.Getenv("DISPATCH_DEMO_GIT_STATES") != "" { + return func() tea.Msg { + return gitStatusMsg{status: demoGitStatus(dir)} + } + } + return func() tea.Msg { + return gitStatusMsg{status: platform.DetectGitStatus(dir)} + } +} + +// handleGitStatusMsg populates and opens the git status overlay. +func (m Model) handleGitStatusMsg(msg gitStatusMsg) Model { + m.gitStatusView.SetStatus(msg.status) + m.gitStatusView.SetSize(m.width, m.height) + m.state = stateGitStatusView + return m +} + +// demoGitStatus returns a synthetic detailed status for demo/screenshot mode +// (gated by DISPATCH_DEMO_GIT_STATES) so the overlay shows realistic push/pull +// stats and changed files without requiring a real repository. +func demoGitStatus(dir string) platform.GitStatus { + return platform.GitStatus{ + Dir: dir, + Exists: true, + IsRepo: true, + Branch: "feature/login", + Upstream: "origin/feature/login", + HasUpstream: true, + Ahead: 2, + Behind: 1, + Staged: 1, + Modified: 3, + Untracked: 2, + Files: []platform.GitFileStatus{ + {Code: "M ", Path: "internal/auth/login.go"}, + {Code: " M", Path: "internal/auth/session.go"}, + {Code: "??", Path: "internal/auth/login_test.go"}, + }, + } +} + // sortedKeys converts a string set to a sorted slice for deterministic // config serialisation. Returns nil for empty sets. func sortedKeys(m map[string]struct{}) []string { diff --git a/internal/tui/model_gitstatus_test.go b/internal/tui/model_gitstatus_test.go new file mode 100644 index 0000000..241e525 --- /dev/null +++ b/internal/tui/model_gitstatus_test.go @@ -0,0 +1,207 @@ +package tui + +import ( + "errors" + "strings" + "testing" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "github.com/jongio/dispatch/internal/data" + "github.com/jongio/dispatch/internal/platform" +) + +// TestHandleGitStatus_NoPath verifies pressing the git-status key with no +// resolvable folder shows a hint and stays in the session list. +func TestHandleGitStatus_NoPath(t *testing.T) { + m := newTestModel() + m.sessionList.SetSessions([]data.Session{{ID: "abc-123", Cwd: ""}}) + + result, _ := m.Update(runeKeyMsg('i')) + rm := result.(Model) + + if rm.state != stateSessionList { + t.Errorf("state = %v, want stateSessionList", rm.state) + } + if rm.statusInfo != "No folder to inspect" { + t.Errorf("statusInfo = %q, want %q", rm.statusInfo, "No folder to inspect") + } +} + +// TestHandleGitStatus_ValidPathReturnsCmd verifies pressing the git-status key +// with a real cwd returns a command (the async status gather) without yet +// switching state — the overlay opens when the resulting message arrives. +func TestHandleGitStatus_ValidPathReturnsCmd(t *testing.T) { + m := newTestModel() + m.sessionList.SetSessions([]data.Session{{ID: "abc-123", Cwd: "/home/me/proj"}}) + + result, cmd := m.Update(runeKeyMsg('i')) + rm := result.(Model) + + if cmd == nil { + t.Error("expected a command to gather git status, got nil") + } + if rm.state != stateSessionList { + t.Errorf("state = %v, want stateSessionList before message arrives", rm.state) + } +} + +// TestHandleGitStatusMsg_OpensOverlay verifies a gitStatusMsg opens the overlay. +func TestHandleGitStatusMsg_OpensOverlay(t *testing.T) { + m := newTestModel() + status := platform.GitStatus{ + Dir: "/home/me/proj", Exists: true, IsRepo: true, + Branch: "main", Upstream: "origin/main", HasUpstream: true, Ahead: 1, + } + + result, _ := m.Update(gitStatusMsg{status: status}) + rm := result.(Model) + + if rm.state != stateGitStatusView { + t.Errorf("state = %v, want stateGitStatusView", rm.state) + } + if view := rm.gitStatusView.View(); view == "" { + t.Error("overlay View() should render after opening") + } +} + +// TestGitStatusOverlay_EscCloses verifies esc returns to the session list. +func TestGitStatusOverlay_EscCloses(t *testing.T) { + m := newTestModel() + m.state = stateGitStatusView + m.gitStatusView.SetStatus(platform.GitStatus{Dir: "/p", Exists: true, IsRepo: true, Branch: "main"}) + + result, _ := m.Update(escKeyMsg()) + rm := result.(Model) + + if rm.state != stateSessionList { + t.Errorf("state = %v, want stateSessionList after esc", rm.state) + } +} + +// TestGitStatusOverlay_Copy verifies the copy key writes the plain-text summary. +func TestGitStatusOverlay_Copy(t *testing.T) { + var copied string + orig := clipboardWrite + clipboardWrite = func(text string) error { + copied = text + return nil + } + t.Cleanup(func() { clipboardWrite = orig }) + + m := newTestModel() + m.state = stateGitStatusView + m.gitStatusView.SetStatus(platform.GitStatus{ + Dir: "/home/me/proj", Exists: true, IsRepo: true, + Branch: "main", Upstream: "origin/main", HasUpstream: true, Ahead: 3, + }) + + result, _ := m.Update(runeKeyMsg('c')) + rm := result.(Model) + + if copied == "" { + t.Fatal("clipboard was not written") + } + if want := "Git Status"; !strings.Contains(copied, want) { + t.Errorf("clipboard text %q missing %q", copied, want) + } + if rm.statusInfo == "" { + t.Error("statusInfo should confirm the copy") + } +} + +// TestGitStatusOverlay_CopyError verifies a clipboard failure surfaces an error. +func TestGitStatusOverlay_CopyError(t *testing.T) { + orig := clipboardWrite + clipboardWrite = func(string) error { return errors.New("no clipboard") } + t.Cleanup(func() { clipboardWrite = orig }) + + m := newTestModel() + m.state = stateGitStatusView + m.gitStatusView.SetStatus(platform.GitStatus{Dir: "/p", Exists: true, IsRepo: true, Branch: "main"}) + + result, _ := m.Update(runeKeyMsg('c')) + rm := result.(Model) + if rm.statusErr == "" { + t.Error("statusErr should be set on clipboard failure") + } +} + +// TestGitStatusKeybinding verifies the git-status action is bound to 'i' and is +// registered as a remappable action. +func TestGitStatusKeybinding(t *testing.T) { + km := defaultKeyMap() + if !key.Matches(tea.KeyPressMsg{Code: 'i', Text: "i"}, km.GitStatus) { + t.Error("'i' should match the GitStatus binding") + } + + found := false + for _, e := range keybindingEntries(&km) { + if e.name == "git_status" { + found = true + break + } + } + if !found { + t.Error("git_status should be a registered remappable action") + } +} + +// TestDemoGitStatus verifies the demo-mode synthetic status is a populated repo. +func TestDemoGitStatus(t *testing.T) { + s := demoGitStatus("/demo/dir") + if !s.Exists || !s.IsRepo { + t.Error("demo status should be an existing repo") + } + if s.Dir != "/demo/dir" { + t.Errorf("Dir = %q, want /demo/dir", s.Dir) + } + if s.Ahead == 0 && s.Behind == 0 { + t.Error("demo status should show push/pull activity") + } + if len(s.Files) == 0 { + t.Error("demo status should list changed files") + } +} + +// TestCmdPaletteAction_GitStatus verifies the palette "git-status" action opens +// the status flow for the selected session's folder. +func TestCmdPaletteAction_GitStatus(t *testing.T) { + m := newTestModel() + m.sessionList.SetSessions([]data.Session{{ID: "abc", Cwd: "/home/me/proj"}}) + + result, cmd := m.Update(cmdPaletteActionMsg{action: "git-status"}) + rm := result.(Model) + if cmd == nil { + t.Error("git-status palette action should return a command") + } + if rm.state != stateSessionList { + t.Errorf("state = %v, want stateSessionList before the status message arrives", rm.state) + } +} + +// TestGitStatusOverlay_ScrollKeys verifies up/down keys scroll the overlay's +// file list without leaving the overlay. +func TestGitStatusOverlay_ScrollKeys(t *testing.T) { + m := newTestModel() + m.state = stateGitStatusView + files := make([]platform.GitFileStatus, 30) + for i := range files { + files[i] = platform.GitFileStatus{Code: " M", Path: "f.go"} + } + m.gitStatusView.SetStatus(platform.GitStatus{ + Dir: "/p", Exists: true, IsRepo: true, Branch: "main", Modified: 30, Files: files, + }) + m.gitStatusView.SetSize(80, 10) + + result, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + rm := result.(Model) + if rm.state != stateGitStatusView { + t.Errorf("state = %v, want stateGitStatusView after scroll down", rm.state) + } + result2, _ := rm.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + if result2.(Model).state != stateGitStatusView { + t.Error("should remain in overlay after scroll up") + } +} From 371898a37654078ea88256d9d64d7947620cbc36 Mon Sep 17 00:00:00 2001 From: Jon Gallant Date: Fri, 17 Jul 2026 09:27:46 -0700 Subject: [PATCH 2/3] feat(tui): surface git push/pull stats inline and in the preview pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show the git push/pull state everywhere a session appears, not only in the on-demand overlay: - Session row: a compact, colored `↑N ↓N ●` segment (commits ahead to push, behind to pull, plus a dirty marker) at wider terminals. The single-glyph badge is suppressed on rows that show the inline stats so there is never a duplicate git indicator; a missing-workspace marker folds into the segment. - Preview pane: a git section under the Branch field with branch -> upstream, push/pull counts, and per-category working-tree counts (or a clean label). To feed both surfaces without scanning twice, DetectGitStatus becomes the single git-scanning entry point: GitStatus.State() derives the badge enum and ScanGitStatuses replaces the old enum-only DetectGitState/ScanGitStates (one `git status --porcelain=v2 --branch` per session instead of up to three commands). All badge rendering and git-dirty/missing filters keep working off the derived enum. Also fix a pre-existing preview rendering bug: wordWrap only broke on spaces, so a message containing a long unbreakable token (a path or URL) overflowed the chat bubble and corrupted the preview's outer border. It now hard-breaks tokens longer than the wrap width. Covered by regression tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 524cf702-8922-4cfd-b23f-4069b734e09b --- docs/keybindings.md | 2 +- docs/specs/git-status/spec.md | 24 +++ docs/specs/git-status/test-plan.md | 37 ++++ internal/platform/gitstate.go | 158 ++++------------ internal/platform/gitstate_test.go | 135 +++++++------- internal/tui/components/gitstatusview.go | 16 +- internal/tui/components/helpers_test.go | 4 +- internal/tui/components/preview.go | 99 +++++++++- .../tui/components/preview_gitsection_test.go | 104 +++++++++++ internal/tui/components/preview_wrap_test.go | 34 ++++ internal/tui/components/sessionlist.go | 129 ++++++++++++- .../components/sessionlist_gitstats_test.go | 171 ++++++++++++++++++ internal/tui/handlers.go | 12 +- internal/tui/messages.go | 7 +- internal/tui/model.go | 58 +++--- internal/tui/model_gitstatus_test.go | 48 ++++- 16 files changed, 808 insertions(+), 230 deletions(-) create mode 100644 internal/tui/components/preview_gitsection_test.go create mode 100644 internal/tui/components/preview_wrap_test.go create mode 100644 internal/tui/components/sessionlist_gitstats_test.go diff --git a/docs/keybindings.md b/docs/keybindings.md index 1aa611e..f5afedc 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -235,7 +235,7 @@ - File: internal\tui\keys.go - Code: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "git status")) - Handler: internal\tui\model.go (handleGitStatus / handleGitStatusMsg) - - Behavior: Opens a modal overlay showing the git status of the folder the selected session (or folder-pivot row) is mapped to: current branch and upstream, the standard push/pull stats (commits ahead to push, behind to pull), and working-tree counts (staged, modified, untracked, deleted, conflicts), plus a scrollable list of changed files. Inside the overlay: Esc or i closes it, ↑/↓ (or PgUp/PgDn) scroll the file list, and c copies a plain-text summary to the clipboard. Git commands run under a bounded timeout so the UI never blocks; non-repo, missing, detached-HEAD, and no-upstream folders are reported without error. + - Behavior: Opens a modal overlay showing the git status of the folder the selected session (or folder-pivot row) is mapped to: current branch and upstream, the standard push/pull stats (commits ahead to push, behind to pull), and working-tree counts (staged, modified, untracked, deleted, conflicts), plus a scrollable list of changed files. Inside the overlay: Esc or i closes it, ↑/↓ (or PgUp/PgDn) scroll the file list, and c copies a plain-text summary to the clipboard. Git commands run under a bounded timeout so the UI never blocks; non-repo, missing, detached-HEAD, and no-upstream folders are reported without error. The same push/pull stats also appear inline on each session row (a compact `↑N ↓N ●` segment at wider terminals) and in the preview pane's git section (branch → upstream, push/pull, and per-category change counts). - Condition: Only when a session or folder row with a working directory is selected 21. **PgUp (Page Up)** → Preview Panel Scroll Up diff --git a/docs/specs/git-status/spec.md b/docs/specs/git-status/spec.md index f7316e9..ff3dfdd 100644 --- a/docs/specs/git-status/spec.md +++ b/docs/specs/git-status/spec.md @@ -33,6 +33,8 @@ agent sessions. - Handle non-repo, missing-directory, detached-HEAD, and no-upstream cases gracefully — never crash and never block the UI - Reachable both by a dedicated keybinding and from the command palette +- Surface the push/pull stats **inline** on each session row and in the + **preview pane**, not only in the on-demand overlay ## Non-Goals @@ -42,6 +44,28 @@ agent sessions. - Replacing the existing session-list git badge (this complements it) - Multi-session/aggregate git status (one folder at a time, the selected row) +## Extension: inline row + preview pane (follow-up) + +The overlay alone requires a keypress per session. To make the push/pull state +scannable at a glance across many sessions, the git info is also surfaced in two +always-visible places: + +- **Session row (inline):** a compact, colored `↑N ↓N ●` segment — commits ahead + (to push), behind (to pull), and a dirty marker when the working tree has + changes. Shown as a fixed-width, width-gated column so alignment holds and + narrow terminals are not crowded; the existing single-glyph badge stays. +- **Preview pane:** a full git section under the Branch field — branch → upstream, + push/pull counts, and per-category working-tree counts (or a clean indicator). + +**Design consequence:** the inline row and preview both need the *detailed* +`GitStatus` for every visible session, not just the collapsed badge enum. Rather +than scanning twice, `DetectGitStatus` becomes the single git-scanning entry +point: a new `GitStatus.State()` derives the badge `GitState` enum, and +`ScanGitStatuses` replaces the old enum-only `ScanGitStates`/`DetectGitState` +(one `git status --porcelain=v2 --branch` per session instead of up to three +commands). All existing badge rendering, git-dirty/missing filters, and the +`gitStateMap` continue to work off the derived enum. + ## Solution Add a modal **Git Status overlay**, following the exact pattern already used by diff --git a/docs/specs/git-status/test-plan.md b/docs/specs/git-status/test-plan.md index f0f33bb..908aa03 100644 --- a/docs/specs/git-status/test-plan.md +++ b/docs/specs/git-status/test-plan.md @@ -99,3 +99,40 @@ Added during reconciliation (beyond the Phase 1 plan): - F21 (resize) has no dedicated assertion — behavior is a one-line SetSize call mirroring the existing stateCompareView resize path; the render it feeds is covered by T14/T15. Acceptable per low-risk parity with existing code. + +## Extension coverage (inline row + preview pane) + +New functionality units and their covering tests: + +| # | Functionality introduced | Location | Covered by | +|---|--------------------------|----------|------------| +| G1 | `GitStatus.State()` badge derivation | platform/gitstate.go | TestGitStatus_State (all branches) | +| G2 | `ScanGitStatuses` batch scan | platform/gitstate.go | TestScanGitStatuses | +| G3 | Enum-path tests migrated to `DetectGitStatus().State()` | platform/gitstate_test.go | TestDetectGitStatus_State_* | +| G4 | `SessionList.SetGitStatuses` + `gitStatsSegment` inline render | components/sessionlist.go | TestGitStatsSegment_* (nil/non-repo/clean/ahead-behind-dirty/no-upstream/selected/in-row) | +| G5 | `clampCount` two-digit cap | components/sessionlist.go | TestClampCount | +| G6 | `PreviewPanel.SetGitStatus` + `writeGitSection` | components/preview.go | TestPreviewGitSection_* | +| G7 | `gitPushPullText` / `gitChangesText` helpers | components/preview.go | TestGitPushPullText, TestGitChangesText | +| G8 | `handleGitStateScanned` derives badge + feeds both | tui/handlers.go | TestHandleGitStateScanned_DerivesBadgeAndFeeds | +| G9 | `demoGitStatuses` synthetic statuses | tui/model.go | TestDemoGitStatuses | +| G10 | `syncPreviewGitStatus` nil-safe | tui/model.go | TestSyncPreviewGitStatus_NilDetail | + +Removed with the consolidation (no longer any callers): `DetectGitState`, +`gitStatusPorcelain`, `gitAheadBehind`, `ScanGitStates`, and their tests. The +badge/filter behaviour they provided is now covered by `GitStatus.State()`. + +## Coupled fix: chat-bubble overflow (found during testing) + +While testing the preview surface, a pre-existing rendering bug surfaced: +`wordWrap` only broke on spaces, so a message containing a long unbreakable +token (a Windows path or URL with no spaces) produced a wrapped line wider than +the chat bubble. Because the bubble is a nested bordered box with no width cap, +the border was pushed past the pane edge and the preview's outer border +re-wrapped it — scattering the `│` borders and double-spacing the conversation. + +Fix: `wordWrap` now hard-breaks any token longer than the wrap width into +width-sized chunks, guaranteeing no wrapped line exceeds the width. Covered by +`TestWordWrap_LongTokenOverflow` and `TestRenderChatBubble_NoOverflowOnLongPath`; +the existing `TestWordWrap` "long word" case was updated from the old +(overflowing) expectation to the hard-broken result. This is unchanged-code +behaviour from `main`, fixed here because it was reported during feature testing. diff --git a/internal/platform/gitstate.go b/internal/platform/gitstate.go index cda258b..f71c6c5 100644 --- a/internal/platform/gitstate.go +++ b/internal/platform/gitstate.go @@ -81,127 +81,6 @@ func gitSafeArgs(args ...string) []string { return append(out, args...) } -// DetectGitState checks the Git workspace state of a directory. It returns -// GitStateMissing if the path does not exist, GitStateUnknown if git is not -// available or the directory is not a git repository, and the appropriate -// state otherwise. -// -// The function uses context-bounded exec calls to ensure it never blocks -// longer than gitCommandTimeout per command. -func DetectGitState(dir string) GitState { - // Check if directory exists. - info, err := os.Stat(dir) - if err != nil || !info.IsDir() { - return GitStateMissing - } - - // Verify this is a git repository by running git rev-parse. - ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout) - defer cancel() - cmd := exec.CommandContext(ctx, "git", gitSafeArgs("rev-parse", "--git-dir")...) - cmd.Dir = dir - if err := cmd.Run(); err != nil { - return GitStateUnknown - } - - // Run git status --porcelain to detect dirty/untracked state. - state := gitStatusPorcelain(dir) - if state != GitStateClean { - return state - } - - // Check ahead/behind relative to upstream. - ahead, behind := gitAheadBehind(dir) - if behind > 0 { - return GitStateBehind - } - if ahead > 0 { - return GitStateAhead - } - - return GitStateClean -} - -// gitStatusPorcelain runs `git status --porcelain` and returns the workspace -// state based on the output. Returns GitStateClean if no output. -func gitStatusPorcelain(dir string) GitState { - ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout) - defer cancel() - - cmd := exec.CommandContext(ctx, "git", gitSafeArgs("status", "--porcelain")...) - cmd.Dir = dir - out, err := cmd.Output() - if err != nil { - return GitStateUnknown - } - - if len(out) == 0 { - return GitStateClean - } - - // Parse output: lines starting with "?" indicate untracked files only. - lines := strings.Split(strings.TrimSpace(string(out)), "\n") - hasModified := false - hasUntracked := false - for _, line := range lines { - if line == "" { - continue - } - if strings.HasPrefix(line, "??") { - hasUntracked = true - } else { - hasModified = true - } - } - - if hasModified { - return GitStateDirty - } - if hasUntracked { - return GitStateUntracked - } - - return GitStateClean -} - -// gitAheadBehind runs `git rev-list --left-right --count HEAD...@{u}` and -// returns the ahead/behind counts. Returns (0, 0) on any error (no upstream, -// timeout, etc.). -func gitAheadBehind(dir string) (ahead, behind int) { - ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout) - defer cancel() - - cmd := exec.CommandContext(ctx, "git", gitSafeArgs("rev-list", "--left-right", "--count", "HEAD...@{u}")...) - cmd.Dir = dir - out, err := cmd.Output() - if err != nil { - return 0, 0 - } - - parts := strings.Fields(strings.TrimSpace(string(out))) - if len(parts) != 2 { - return 0, 0 - } - - a, errA := strconv.Atoi(parts[0]) - b, errB := strconv.Atoi(parts[1]) - if errA != nil || errB != nil { - return 0, 0 - } - return a, b -} - -// ScanGitStates runs DetectGitState for each session directory in the provided -// map (session ID to directory path) and returns the results. This is designed -// to be called as a background command during refresh cycles. -func ScanGitStates(sessions map[string]string) map[string]GitState { - results := make(map[string]GitState, len(sessions)) - for id, dir := range sessions { - results[id] = DetectGitState(dir) - } - return results -} - // --------------------------------------------------------------------------- // Detailed git status (push/pull stats + working-tree counts) // --------------------------------------------------------------------------- @@ -252,6 +131,30 @@ func (s GitStatus) Clean() bool { s.Deleted == 0 && s.Conflicts == 0 } +// State collapses a detailed GitStatus into the single GitState enum used for +// the session-list badge and the git-dirty / missing-workspace filters. The +// precedence matches the historical DetectGitState behaviour: missing and +// non-repo first, then working-tree changes (dirty over untracked), then +// upstream divergence (behind over ahead), then clean. +func (s GitStatus) State() GitState { + switch { + case !s.Exists: + return GitStateMissing + case !s.IsRepo: + return GitStateUnknown + case s.Staged > 0 || s.Modified > 0 || s.Deleted > 0 || s.Conflicts > 0: + return GitStateDirty + case s.Untracked > 0: + return GitStateUntracked + case s.Behind > 0: + return GitStateBehind + case s.Ahead > 0: + return GitStateAhead + default: + return GitStateClean + } +} + // DetectGitStatus gathers a detailed Git status for dir. It returns a GitStatus // with Exists=false when the path is missing, IsRepo=false when the path is not // a Git repository (or git is unavailable / times out), and the fully populated @@ -287,6 +190,19 @@ func DetectGitStatus(dir string) GitStatus { return s } +// ScanGitStatuses runs DetectGitStatus for each session directory in the +// provided map (session ID to directory path) and returns the detailed results. +// It is the single git-scanning entry point used by the background refresh: the +// session-list badge enum is derived from each GitStatus via State(), while the +// inline push/pull column and the preview pane use the full status. +func ScanGitStatuses(sessions map[string]string) map[string]GitStatus { + results := make(map[string]GitStatus, len(sessions)) + for id, dir := range sessions { + results[id] = DetectGitStatus(dir) + } + return results +} + // parseGitStatusV2 parses the output of `git status --porcelain=v2 --branch` // into a GitStatus. It is a pure function (no I/O) so it can be unit tested // without a live repository. It does not set Dir/Exists/IsRepo — DetectGitStatus diff --git a/internal/platform/gitstate_test.go b/internal/platform/gitstate_test.go index a22bba3..248acd4 100644 --- a/internal/platform/gitstate_test.go +++ b/internal/platform/gitstate_test.go @@ -28,85 +28,106 @@ func TestGitState_String(t *testing.T) { } } -// TestDetectGitState_MissingDir verifies that a nonexistent path returns GitStateMissing. -func TestDetectGitState_MissingDir(t *testing.T) { - state := DetectGitState(filepath.Join(t.TempDir(), "nonexistent")) - if state != GitStateMissing { - t.Errorf("DetectGitState(nonexistent) = %v, want GitStateMissing", state) +// TestGitStatus_State verifies the enum derivation precedence: missing and +// non-repo first, then dirty over untracked, then behind over ahead, then clean. +func TestGitStatus_State(t *testing.T) { + tests := []struct { + name string + status GitStatus + want GitState + }{ + {"missing", GitStatus{Exists: false}, GitStateMissing}, + {"non-repo", GitStatus{Exists: true, IsRepo: false}, GitStateUnknown}, + {"clean synced", GitStatus{Exists: true, IsRepo: true}, GitStateClean}, + {"modified", GitStatus{Exists: true, IsRepo: true, Modified: 1}, GitStateDirty}, + {"staged", GitStatus{Exists: true, IsRepo: true, Staged: 1}, GitStateDirty}, + {"deleted", GitStatus{Exists: true, IsRepo: true, Deleted: 1}, GitStateDirty}, + {"conflicts", GitStatus{Exists: true, IsRepo: true, Conflicts: 1}, GitStateDirty}, + {"untracked only", GitStatus{Exists: true, IsRepo: true, Untracked: 1}, GitStateUntracked}, + {"dirty beats untracked", GitStatus{Exists: true, IsRepo: true, Modified: 1, Untracked: 1}, GitStateDirty}, + {"behind", GitStatus{Exists: true, IsRepo: true, Behind: 2}, GitStateBehind}, + {"ahead", GitStatus{Exists: true, IsRepo: true, Ahead: 2}, GitStateAhead}, + {"behind beats ahead", GitStatus{Exists: true, IsRepo: true, Ahead: 1, Behind: 1}, GitStateBehind}, + {"dirty beats divergence", GitStatus{Exists: true, IsRepo: true, Modified: 1, Ahead: 3}, GitStateDirty}, + } + for _, tt := range tests { + if got := tt.status.State(); got != tt.want { + t.Errorf("%s: State() = %v, want %v", tt.name, got, tt.want) + } } } -// TestDetectGitState_NotGitRepo verifies that a directory without .git returns GitStateUnknown. -func TestDetectGitState_NotGitRepo(t *testing.T) { - dir := t.TempDir() - state := DetectGitState(dir) - if state != GitStateUnknown { - t.Errorf("DetectGitState(non-git) = %v, want GitStateUnknown", state) +// TestDetectGitStatus_State_MissingDir verifies a nonexistent path maps to the +// missing badge state. +func TestDetectGitStatus_State_MissingDir(t *testing.T) { + if got := DetectGitStatus(filepath.Join(t.TempDir(), "nonexistent")).State(); got != GitStateMissing { + t.Errorf("State() = %v, want GitStateMissing", got) } } -// TestDetectGitState_Clean verifies that a fresh git repo with a commit returns GitStateClean. -func TestDetectGitState_Clean(t *testing.T) { +// TestDetectGitStatus_State_NotGitRepo verifies a directory without .git maps to +// the unknown badge state. +func TestDetectGitStatus_State_NotGitRepo(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") } - dir := initTestRepo(t) - state := DetectGitState(dir) - if state != GitStateClean { - t.Errorf("DetectGitState(clean) = %v, want GitStateClean", state) + if got := DetectGitStatus(t.TempDir()).State(); got != GitStateUnknown { + t.Errorf("State() = %v, want GitStateUnknown", got) } } -// TestDetectGitState_Dirty verifies that modified tracked files produce GitStateDirty. -func TestDetectGitState_Dirty(t *testing.T) { +// TestDetectGitStatus_State_Clean verifies a fresh committed repo maps to clean. +func TestDetectGitStatus_State_Clean(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") } - dir := initTestRepo(t) + if got := DetectGitStatus(initTestRepo(t)).State(); got != GitStateClean { + t.Errorf("State() = %v, want GitStateClean", got) + } +} - // Modify the tracked file. +// TestDetectGitStatus_State_Dirty verifies modified tracked files map to dirty. +func TestDetectGitStatus_State_Dirty(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := initTestRepo(t) if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("modified"), 0o644); err != nil { t.Fatal(err) } - - state := DetectGitState(dir) - if state != GitStateDirty { - t.Errorf("DetectGitState(dirty) = %v, want GitStateDirty", state) + if got := DetectGitStatus(dir).State(); got != GitStateDirty { + t.Errorf("State() = %v, want GitStateDirty", got) } } -// TestDetectGitState_Untracked verifies that untracked files produce GitStateUntracked. -func TestDetectGitState_Untracked(t *testing.T) { +// TestDetectGitStatus_State_Untracked verifies untracked files map to untracked. +func TestDetectGitStatus_State_Untracked(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") } dir := initTestRepo(t) - - // Add an untracked file. if err := os.WriteFile(filepath.Join(dir, "new.txt"), []byte("new"), 0o644); err != nil { t.Fatal(err) } - - state := DetectGitState(dir) - if state != GitStateUntracked { - t.Errorf("DetectGitState(untracked) = %v, want GitStateUntracked", state) + if got := DetectGitStatus(dir).State(); got != GitStateUntracked { + t.Errorf("State() = %v, want GitStateUntracked", got) } } -// TestDetectGitState_File verifies that pointing at a file returns GitStateMissing. -func TestDetectGitState_File(t *testing.T) { +// TestDetectGitStatus_State_File verifies pointing at a file maps to missing. +func TestDetectGitStatus_State_File(t *testing.T) { f := filepath.Join(t.TempDir(), "afile.txt") if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { t.Fatal(err) } - state := DetectGitState(f) - if state != GitStateMissing { - t.Errorf("DetectGitState(file) = %v, want GitStateMissing", state) + if got := DetectGitStatus(f).State(); got != GitStateMissing { + t.Errorf("State() = %v, want GitStateMissing", got) } } -// TestScanGitStates verifies the batch scanning function. -func TestScanGitStates(t *testing.T) { +// TestScanGitStatuses verifies the batch scanning function returns detailed +// statuses keyed by session ID, from which badge states can be derived. +func TestScanGitStatuses(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") } @@ -118,36 +139,12 @@ func TestScanGitStates(t *testing.T) { "sess-clean": cleanDir, "sess-missing": missingDir, } - results := ScanGitStates(sessions) - if results["sess-clean"] != GitStateClean { - t.Errorf("ScanGitStates[clean] = %v, want GitStateClean", results["sess-clean"]) - } - if results["sess-missing"] != GitStateMissing { - t.Errorf("ScanGitStates[missing] = %v, want GitStateMissing", results["sess-missing"]) - } -} - -// TestGitStatusPorcelain_EmptyOutput verifies clean state from no output. -func TestGitStatusPorcelain_EmptyOutput(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not available") + results := ScanGitStatuses(sessions) + if !results["sess-clean"].IsRepo || results["sess-clean"].State() != GitStateClean { + t.Errorf("ScanGitStatuses[clean] = %+v, want a clean repo", results["sess-clean"]) } - dir := initTestRepo(t) - state := gitStatusPorcelain(dir) - if state != GitStateClean { - t.Errorf("gitStatusPorcelain(clean) = %v, want GitStateClean", state) - } -} - -// TestGitAheadBehind_NoUpstream verifies zero counts when no upstream is set. -func TestGitAheadBehind_NoUpstream(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not available") - } - dir := initTestRepo(t) - ahead, behind := gitAheadBehind(dir) - if ahead != 0 || behind != 0 { - t.Errorf("gitAheadBehind(no upstream) = (%d, %d), want (0, 0)", ahead, behind) + if results["sess-missing"].Exists || results["sess-missing"].State() != GitStateMissing { + t.Errorf("ScanGitStatuses[missing] = %+v, want missing", results["sess-missing"]) } } diff --git a/internal/tui/components/gitstatusview.go b/internal/tui/components/gitstatusview.go index 582c070..a706469 100644 --- a/internal/tui/components/gitstatusview.go +++ b/internal/tui/components/gitstatusview.go @@ -212,19 +212,23 @@ func upstreamLabel(s platform.GitStatus) string { } // pushPullLabel renders the standard ahead/behind push/pull stats with icons -// and colors, or a note when there is no upstream to compare against. +// and colors, or a note when there is no upstream to compare against. Only +// non-zero counts are shown, matching the preview pane's gitPushPullText. func pushPullLabel(s platform.GitStatus) string { if !s.HasUpstream { return styles.DimmedStyle.Render("no upstream") } - ahead := styles.GitAheadStyle.Render(fmt.Sprintf("%s%d", styles.IconGitAhead(), s.Ahead)) - behind := styles.GitBehindStyle.Render(fmt.Sprintf("%s%d", styles.IconGitBehind(), s.Behind)) - push := styles.DimmedStyle.Render(" to push") - pull := styles.DimmedStyle.Render(" to pull") if s.Ahead == 0 && s.Behind == 0 { return styles.SuccessStyle.Render("up to date") } - return ahead + push + " " + behind + pull + var parts []string + if s.Ahead > 0 { + parts = append(parts, styles.GitAheadStyle.Render(fmt.Sprintf("%s%d", styles.IconGitAhead(), s.Ahead))+styles.DimmedStyle.Render(" to push")) + } + if s.Behind > 0 { + parts = append(parts, styles.GitBehindStyle.Render(fmt.Sprintf("%s%d", styles.IconGitBehind(), s.Behind))+styles.DimmedStyle.Render(" to pull")) + } + return strings.Join(parts, " ") } // workingLabel returns "clean" (styled) or a short dirty summary. diff --git a/internal/tui/components/helpers_test.go b/internal/tui/components/helpers_test.go index ed68005..f7653c8 100644 --- a/internal/tui/components/helpers_test.go +++ b/internal/tui/components/helpers_test.go @@ -570,10 +570,10 @@ func TestWordWrap(t *testing.T) { "hello world\nfoo", }, { - "long word not broken", + "long word hard-broken to width", "superlongword", 5, - "superlongword", + "super\nlongw\nord", }, { "multiple wraps", diff --git a/internal/tui/components/preview.go b/internal/tui/components/preview.go index b700303..78f402d 100644 --- a/internal/tui/components/preview.go +++ b/internal/tui/components/preview.go @@ -32,6 +32,7 @@ type PreviewPanel struct { hasPlan bool // whether the session has a plan.md file workStatus data.WorkStatusResult // current session's work status workspaceMissing bool // true when the session cwd no longer exists on disk + gitStatus platform.GitStatus // detailed git status for the current session's folder timelineMode bool // when true, render activity timeline instead of session detail @@ -113,6 +114,13 @@ func (p *PreviewPanel) SetWorkspaceMissing(missing bool) { p.updateTotalLines() } +// SetGitStatus stores the detailed git status for the current session so the +// preview can render a git section (branch/upstream, push/pull, change counts). +func (p *PreviewPanel) SetGitStatus(status platform.GitStatus) { + p.gitStatus = status + p.updateTotalLines() +} + // SetPlanContent stores the plan.md content for the current session. // Pass an empty string to clear the plan content. func (p *PreviewPanel) SetPlanContent(content string) { @@ -633,6 +641,9 @@ func (p PreviewPanel) renderContent() (string, int, int) { field(styles.IconGitBranch()+"Branch", s.Branch) } + // ── Git status ── + p.writeGitSection(&b, contentW) + // ── Timing & stats ── b.WriteString("\n") field(styles.IconClock()+"Created", FormatTimestamp(s.CreatedAt)) @@ -835,7 +846,75 @@ func countUniqueRefs(refs []data.SessionRef) int { return len(seen) } -// wordWrap wraps text to a maximum line width, breaking on spaces. +// writeGitSection renders the git status block for the current session: upstream, +// push/pull (ahead/behind) stats, and per-category change counts. It is a no-op +// when no git status has been scanned or the folder is not a git repository, so +// non-repo sessions render unchanged. +func (p *PreviewPanel) writeGitSection(b *strings.Builder, contentW int) { + st := p.gitStatus + if !st.IsRepo { + return + } + label := func(s string) string { return styles.PreviewLabelStyle.Render(s) } + + if st.HasUpstream && st.Upstream != "" { + l := label("Upstream: ") + v := styles.PreviewValueStyle.Render(Truncate(st.Upstream, max(1, contentW-lipgloss.Width(l)))) + b.WriteString(l + v + "\n") + } + b.WriteString(label("Push/Pull: ") + gitPushPullText(st) + "\n") + b.WriteString(label("Changes: ") + gitChangesText(st) + "\n") +} + +// gitPushPullText renders the standard push/pull stats for the preview: commits +// ahead (to push) and behind (to pull), or a note when synced or without an +// upstream to compare against. +func gitPushPullText(st platform.GitStatus) string { + if !st.HasUpstream { + return styles.DimmedStyle.Render("no upstream") + } + if st.Ahead == 0 && st.Behind == 0 { + return styles.SuccessStyle.Render("up to date") + } + var parts []string + if st.Ahead > 0 { + parts = append(parts, styles.GitAheadStyle.Render( + fmt.Sprintf("%s%d to push", styles.IconGitAhead(), st.Ahead), + )) + } + if st.Behind > 0 { + parts = append(parts, styles.GitBehindStyle.Render( + fmt.Sprintf("%s%d to pull", styles.IconGitBehind(), st.Behind), + )) + } + return strings.Join(parts, styles.DimmedStyle.Render(" · ")) +} + +// gitChangesText renders the per-category working-tree counts for the preview, +// or a styled "clean" when there is nothing to report. +func gitChangesText(st platform.GitStatus) string { + if st.Clean() { + return styles.GitCleanStyle.Render("clean") + } + var parts []string + add := func(name string, n int, style lipgloss.Style) { + if n > 0 { + parts = append(parts, style.Render(fmt.Sprintf("%d %s", n, name))) + } + } + add("staged", st.Staged, styles.SuccessStyle) + add("modified", st.Modified, styles.GitDirtyStyle) + add("untracked", st.Untracked, styles.GitUntrackedStyle) + add("deleted", st.Deleted, styles.ErrorStyle) + add("conflicts", st.Conflicts, styles.ErrorStyle) + return strings.Join(parts, styles.DimmedStyle.Render(", ")) +} + +// wordWrap wraps text to a maximum line width, breaking on spaces. Tokens that +// are themselves longer than the width (e.g. long file paths or URLs with no +// spaces) are hard-broken into width-sized chunks so no wrapped line ever +// exceeds width — otherwise an over-long line would overflow chat bubbles and +// corrupt the preview's outer border. func wordWrap(text string, width int) string { if width <= 0 { return text @@ -845,7 +924,23 @@ func wordWrap(text string, width int) string { words := strings.Fields(paragraph) lineLen := 0 for i, word := range words { - wLen := len([]rune(word)) + wRunes := []rune(word) + wLen := len(wRunes) + + // Hard-break a token longer than the available width. + if wLen > width { + if i > 0 { + result.WriteString("\n") + } + for len(wRunes) > width { + result.WriteString(string(wRunes[:width]) + "\n") + wRunes = wRunes[width:] + } + result.WriteString(string(wRunes)) + lineLen = len(wRunes) + continue + } + if i > 0 && lineLen+1+wLen > width { result.WriteString("\n") lineLen = 0 diff --git a/internal/tui/components/preview_gitsection_test.go b/internal/tui/components/preview_gitsection_test.go new file mode 100644 index 0000000..40c4950 --- /dev/null +++ b/internal/tui/components/preview_gitsection_test.go @@ -0,0 +1,104 @@ +package components + +import ( + "strings" + "testing" + + "github.com/jongio/dispatch/internal/data" + "github.com/jongio/dispatch/internal/platform" +) + +// renderPreviewWithGit builds a preview for a session and returns its rendered +// view with the given git status applied. +func renderPreviewWithGit(t *testing.T, st platform.GitStatus) string { + t.Helper() + p := NewPreviewPanel() + p.SetSize(80, 40) + p.SetDetail(&data.SessionDetail{ + Session: data.Session{ID: "sess-1", Branch: "main", Cwd: "/home/me/proj"}, + }) + p.SetGitStatus(st) + return p.View() +} + +// TestPreviewGitSection_NonRepoOmitted verifies no git section renders when the +// folder is not a git repository. +func TestPreviewGitSection_NonRepoOmitted(t *testing.T) { + t.Parallel() + out := renderPreviewWithGit(t, platform.GitStatus{Exists: true, IsRepo: false}) + if strings.Contains(out, "Push/Pull") { + t.Errorf("non-repo preview should not render a git section, got:\n%s", out) + } +} + +// TestPreviewGitSection_PushPull verifies ahead/behind stats render in the pane. +func TestPreviewGitSection_PushPull(t *testing.T) { + t.Parallel() + out := renderPreviewWithGit(t, platform.GitStatus{ + Exists: true, IsRepo: true, Branch: "main", + Upstream: "origin/main", HasUpstream: true, Ahead: 3, Behind: 2, + }) + for _, want := range []string{"Upstream", "origin/main", "Push/Pull", "to push", "to pull", "3", "2"} { + if !strings.Contains(out, want) { + t.Errorf("preview git section missing %q, got:\n%s", want, out) + } + } +} + +// TestPreviewGitSection_Clean verifies a clean, in-sync repo is labelled clearly. +func TestPreviewGitSection_Clean(t *testing.T) { + t.Parallel() + out := renderPreviewWithGit(t, platform.GitStatus{ + Exists: true, IsRepo: true, Branch: "main", + Upstream: "origin/main", HasUpstream: true, + }) + if !strings.Contains(out, "up to date") { + t.Errorf("clean synced preview should show 'up to date', got:\n%s", out) + } + if !strings.Contains(out, "clean") { + t.Errorf("clean preview should label changes 'clean', got:\n%s", out) + } +} + +// TestPreviewGitSection_NoUpstream verifies the no-upstream case is handled. +func TestPreviewGitSection_NoUpstream(t *testing.T) { + t.Parallel() + out := renderPreviewWithGit(t, platform.GitStatus{ + Exists: true, IsRepo: true, Branch: "local", Modified: 1, + }) + if !strings.Contains(out, "no upstream") { + t.Errorf("no-upstream preview should note it, got:\n%s", out) + } + if !strings.Contains(out, "modified") { + t.Errorf("dirty preview should list modified count, got:\n%s", out) + } +} + +// TestGitPushPullText verifies the push/pull helper's branches directly. +func TestGitPushPullText(t *testing.T) { + t.Parallel() + if got := gitPushPullText(platform.GitStatus{HasUpstream: false}); !strings.Contains(got, "no upstream") { + t.Errorf("no upstream = %q", got) + } + if got := gitPushPullText(platform.GitStatus{HasUpstream: true}); !strings.Contains(got, "up to date") { + t.Errorf("synced = %q", got) + } + got := gitPushPullText(platform.GitStatus{HasUpstream: true, Ahead: 5}) + if !strings.Contains(got, "5") || !strings.Contains(got, "to push") { + t.Errorf("ahead = %q", got) + } +} + +// TestGitChangesText verifies the change-counts helper's branches directly. +func TestGitChangesText(t *testing.T) { + t.Parallel() + if got := gitChangesText(platform.GitStatus{}); !strings.Contains(got, "clean") { + t.Errorf("clean = %q", got) + } + got := gitChangesText(platform.GitStatus{Staged: 1, Modified: 2, Untracked: 3, Deleted: 4, Conflicts: 5}) + for _, want := range []string{"staged", "modified", "untracked", "deleted", "conflicts"} { + if !strings.Contains(got, want) { + t.Errorf("changes text missing %q, got %q", want, got) + } + } +} diff --git a/internal/tui/components/preview_wrap_test.go b/internal/tui/components/preview_wrap_test.go new file mode 100644 index 0000000..ce293e5 --- /dev/null +++ b/internal/tui/components/preview_wrap_test.go @@ -0,0 +1,34 @@ +package components + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +// TestWordWrap_LongTokenOverflow demonstrates the overflow: a spaceless token +// longer than the width must not produce a line wider than the width. +func TestWordWrap_LongTokenOverflow(t *testing.T) { + long := `C:\Users\jong\.copilot\skills\devx-pr-create\references\pr-drafts.md` + out := wordWrap(long, 30) + for _, line := range strings.Split(out, "\n") { + if w := len([]rune(line)); w > 30 { + t.Fatalf("wrapped line width %d exceeds 30: %q", w, line) + } + } +} + +// TestRenderChatBubble_NoOverflowOnLongPath verifies a bubble never renders a +// visual line wider than the content width when the message has a long path. +func TestRenderChatBubble_NoOverflowOnLongPath(t *testing.T) { + msg := "Related files:\n\n" + + `C:\Users\jong\.copilot\skills\devx-pr-create\references\pr-drafts.md` + contentWidth := 40 + out := RenderChatBubble(msg, "You", contentWidth, true) + for _, line := range strings.Split(out, "\n") { + if w := ansi.StringWidth(line); w > contentWidth { + t.Fatalf("bubble line visual width %d exceeds contentWidth %d: %q", w, contentWidth, ansi.Strip(line)) + } + } +} diff --git a/internal/tui/components/sessionlist.go b/internal/tui/components/sessionlist.go index 286c495..6b4b49d 100644 --- a/internal/tui/components/sessionlist.go +++ b/internal/tui/components/sessionlist.go @@ -38,7 +38,8 @@ type SessionList struct { attentionMap map[string]data.AttentionStatus // session ID → attention status planMap map[string]bool // session ID → has plan.md workStatusMap map[string]data.WorkStatusResult // session ID → work status - gitStateMap map[string]platform.GitState // session ID → git workspace state + gitStateMap map[string]platform.GitState // session ID → git workspace state (badge) + gitStatusMap map[string]platform.GitStatus // session ID → detailed git status (inline stats) notesSet map[string]struct{} // session ID → has a user note tagsSet map[string]struct{} // session ID → has tags hiddenColumns map[string]bool // optional column key → hidden @@ -188,6 +189,12 @@ func (s *SessionList) SetGitStates(m map[string]platform.GitState) { s.gitStateMap = m } +// SetGitStatuses updates the detailed git status map used to render the inline +// push/pull (ahead/behind) stats column on each session row. +func (s *SessionList) SetGitStatuses(m map[string]platform.GitStatus) { + s.gitStatusMap = m +} + // SetSize updates the available rendering dimensions. func (s *SessionList) SetSize(w, h int) { s.width = w @@ -861,6 +868,24 @@ func (s SessionList) renderSessionRow(sess data.Session, selected bool, hidden b turnsW = 0 } + // Inline git push/pull stats column (↑N ↓N ●). Shown at reasonable widths + // once a git scan has produced statuses. A fixed reserved width keeps rows + // aligned; empty when the session has no divergence or changes. + gitStats, gitStatsVis := s.gitStatsSegment(sess.ID, selected) + showGitStats := w >= 70 && s.gitStatusMap != nil + gitStatsW := 0 + if showGitStats { + gitStatsW = gitStatsColW + } + + // The inline stats column supersedes the single-glyph badge, so drop the + // badge on rows that show the stats to avoid a duplicate git indicator. + gitDotWEff := gitDotW + if showGitStats { + gitDot = "" + gitDotWEff = 0 + } + // Very narrow terminal: summary + time only. if w < 50 { summaryW := max(10, w-selectorW-dotW-hostDotW-planDotW-noteDotW-tagDotW-wrkDotW-gitDotW-timeW-spacing) @@ -885,10 +910,13 @@ func (s SessionList) renderSessionRow(sess data.Session, selected bool, hidden b } } - summaryW := w - selectorW - dotW - hostDotW - planDotW - noteDotW - tagDotW - wrkDotW - gitDotW - timeW - spacing + summaryW := w - selectorW - dotW - hostDotW - planDotW - noteDotW - tagDotW - wrkDotW - gitDotWEff - timeW - spacing if showTurns { summaryW -= turnsW + spacing } + if gitStatsW > 0 { + summaryW -= gitStatsW + spacing + } if folderW > 0 { summaryW -= folderW + spacing } @@ -910,6 +938,11 @@ func (s SessionList) renderSessionRow(sess data.Session, selected bool, hidden b b.WriteString(wrkDot) b.WriteString(gitDot) b.WriteString(PadRight(summary, summaryW)) + if gitStatsW > 0 { + b.WriteString(" ") + // Pad the styled segment to the reserved width using its visual width. + b.WriteString(gitStats + strings.Repeat(" ", max(0, gitStatsW-gitStatsVis))) + } if folderW > 0 { b.WriteString(" ") b.WriteString(PadRight(AbbrevPath(sess.Cwd), folderW)) @@ -1108,3 +1141,95 @@ func (s SessionList) gitStateDot(sessionID string, selected bool) string { return renderDot(gitIcon, dotStyle, selected) } + +// gitStatsColW is the fixed visual width reserved for the inline git push/pull +// stats column. It accommodates "↑NN ↓NN ●" (counts are capped at two digits). +const gitStatsColW = 9 + +// gitStatsSegment builds the compact inline git push/pull stats for a session: +// commits ahead (↑N, to push), behind (↓N, to pull), and a dirty marker (●) +// when the working tree has changes. It returns the rendered (possibly styled) +// string and its visual width. An empty string with width 0 means there is +// nothing to show (no status, not a repo, or clean and in sync). +func (s SessionList) gitStatsSegment(sessionID string, selected bool) (string, int) { + if s.gitStatusMap == nil { + return "", 0 + } + st, ok := s.gitStatusMap[sessionID] + if !ok { + return "", 0 + } + // Missing workspace: the folder no longer exists on disk. Surface it here + // (the inline column replaces the badge at these widths) with a cross. + if !st.Exists { + icon := styles.IconGitMissing() + if selected { + return icon, lipgloss.Width(icon) + } + return styles.GitMissingStyle.Render(icon), lipgloss.Width(icon) + } + if !st.IsRepo { + return "", 0 + } + + type seg struct { + text string + style lipgloss.Style + } + var segs []seg + if st.HasUpstream && st.Ahead > 0 { + segs = append(segs, seg{styles.IconGitAhead() + clampCount(st.Ahead), styles.GitAheadStyle}) + } + if st.HasUpstream && st.Behind > 0 { + segs = append(segs, seg{styles.IconGitBehind() + clampCount(st.Behind), styles.GitBehindStyle}) + } + if !st.Clean() { + segs = append(segs, seg{gitDirtyMarker, gitDirtyMarkerStyle(st)}) + } + if len(segs) == 0 { + return "", 0 + } + + var b strings.Builder + vis := 0 + for i, sg := range segs { + if i > 0 { + b.WriteByte(' ') + vis++ + } + if selected { + b.WriteString(sg.text) + } else { + b.WriteString(sg.style.Render(sg.text)) + } + vis += lipgloss.Width(sg.text) + } + return b.String(), vis +} + +// gitDirtyMarker is the glyph shown inline when a working tree has changes. +const gitDirtyMarker = "\u25cf" // ● + +// gitDirtyMarkerStyle picks the marker colour by change severity: conflicts are +// errors, tracked changes are dirty, and untracked-only trees use the untracked +// colour. +func gitDirtyMarkerStyle(st platform.GitStatus) lipgloss.Style { + switch { + case st.Conflicts > 0: + return styles.ErrorStyle + case st.Staged > 0 || st.Modified > 0 || st.Deleted > 0: + return styles.GitDirtyStyle + default: + return styles.GitUntrackedStyle + } +} + +// clampCount renders a non-negative count for the inline column, capping the +// display at two digits ("99") so the fixed-width column never overflows. The +// exact count is always available in the preview pane and the status overlay. +func clampCount(n int) string { + if n > 99 { + return "99" + } + return strconv.Itoa(n) +} diff --git a/internal/tui/components/sessionlist_gitstats_test.go b/internal/tui/components/sessionlist_gitstats_test.go new file mode 100644 index 0000000..e9aaf13 --- /dev/null +++ b/internal/tui/components/sessionlist_gitstats_test.go @@ -0,0 +1,171 @@ +package components + +import ( + "strings" + "testing" + + "github.com/jongio/dispatch/internal/data" + "github.com/jongio/dispatch/internal/platform" + "github.com/jongio/dispatch/internal/tui/styles" +) + +// TestGitStatsSegment_NilMap verifies no segment renders before a scan. +func TestGitStatsSegment_NilMap(t *testing.T) { + t.Parallel() + sl := NewSessionList() + seg, w := sl.gitStatsSegment("s1", false) + if seg != "" || w != 0 { + t.Errorf("gitStatsSegment nil map = (%q, %d), want empty", seg, w) + } +} + +// TestGitStatsSegment_NonRepo verifies non-repo sessions render nothing. +func TestGitStatsSegment_NonRepo(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetGitStatuses(map[string]platform.GitStatus{"s1": {Exists: true, IsRepo: false}}) + seg, w := sl.gitStatsSegment("s1", false) + if seg != "" || w != 0 { + t.Errorf("gitStatsSegment non-repo = (%q, %d), want empty", seg, w) + } +} + +// TestGitStatsSegment_CleanSynced verifies a clean, in-sync repo renders nothing. +func TestGitStatsSegment_CleanSynced(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetGitStatuses(map[string]platform.GitStatus{ + "s1": {Exists: true, IsRepo: true, HasUpstream: true}, + }) + seg, w := sl.gitStatsSegment("s1", false) + if seg != "" || w != 0 { + t.Errorf("gitStatsSegment clean = (%q, %d), want empty", seg, w) + } +} + +// TestGitStatsSegment_AheadBehindDirty verifies the push/pull counts and dirty +// marker appear with a correct visual width. +func TestGitStatsSegment_AheadBehindDirty(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetGitStatuses(map[string]platform.GitStatus{ + "s1": {Exists: true, IsRepo: true, HasUpstream: true, Ahead: 2, Behind: 1, Modified: 3}, + }) + seg, w := sl.gitStatsSegment("s1", false) + if seg == "" { + t.Fatal("expected a non-empty segment") + } + if w <= 0 || w > gitStatsColW { + t.Errorf("segment width = %d, want 1..%d", w, gitStatsColW) + } + // The digits for ahead/behind must be present in the rendered output. + if !strings.Contains(seg, "2") || !strings.Contains(seg, "1") { + t.Errorf("segment %q should contain ahead=2 and behind=1", seg) + } + if !strings.Contains(seg, gitDirtyMarker) { + t.Errorf("segment %q should contain the dirty marker", seg) + } +} + +// TestGitStatsSegment_NoUpstreamHidesCounts verifies ahead/behind are suppressed +// without an upstream, but a dirty marker still shows. +func TestGitStatsSegment_NoUpstreamHidesCounts(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetGitStatuses(map[string]platform.GitStatus{ + "s1": {Exists: true, IsRepo: true, HasUpstream: false, Ahead: 9, Modified: 1}, + }) + seg, _ := sl.gitStatsSegment("s1", false) + if strings.Contains(seg, "9") { + t.Errorf("segment %q should not show ahead count without upstream", seg) + } + if !strings.Contains(seg, gitDirtyMarker) { + t.Errorf("segment %q should still show the dirty marker", seg) + } +} + +// TestGitStatsSegment_Selected verifies the segment is rendered unstyled when the +// row is selected (so the row highlight spans cleanly, matching renderDot). +func TestGitStatsSegment_Selected(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetGitStatuses(map[string]platform.GitStatus{ + "s1": {Exists: true, IsRepo: true, HasUpstream: true, Ahead: 1}, + }) + seg, _ := sl.gitStatsSegment("s1", true) + if strings.Contains(seg, "\x1b[") { + t.Errorf("selected segment %q should contain no ANSI escapes", seg) + } +} + +// TestClampCount verifies two-digit capping keeps the column width bounded. +func TestClampCount(t *testing.T) { + t.Parallel() + cases := map[int]string{0: "0", 5: "5", 99: "99", 100: "99", 1000: "99"} + for in, want := range cases { + if got := clampCount(in); got != want { + t.Errorf("clampCount(%d) = %q, want %q", in, got, want) + } + } +} + +// TestGitStatsSegment_Missing verifies a missing workspace renders a marker +// inline (so the inline column can replace the badge at wider widths). +func TestGitStatsSegment_Missing(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetGitStatuses(map[string]platform.GitStatus{"s1": {Exists: false}}) + seg, w := sl.gitStatsSegment("s1", false) + if seg == "" || w == 0 { + t.Errorf("missing workspace should render a marker, got (%q, %d)", seg, w) + } +} + +// TestGitStatsSegment_BadgeSuppressed verifies the single-glyph badge is dropped +// when the inline stats column is shown, so a behind row shows the arrow once +// (inline), not twice (badge + inline). +func TestGitStatsSegment_BadgeSuppressed(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetSessions([]data.Session{{ID: "s1", Summary: "x", LastActiveAt: "2025-01-01T00:00:00Z"}}) + sl.SetSize(140, 10) + sl.SetGitStates(map[string]platform.GitState{"s1": platform.GitStateBehind}) + sl.SetGitStatuses(map[string]platform.GitStatus{ + "s1": {Exists: true, IsRepo: true, HasUpstream: true, Behind: 1}, + }) + out := sl.View() + if n := strings.Count(out, styles.IconGitBehind()); n != 1 { + t.Errorf("behind arrow appears %d times, want 1 (badge suppressed):\n%s", n, out) + } +} + +// TestGitStatsSegment_BadgeShownWhenNoInline verifies the badge still renders at +// narrower widths where the inline stats column is not shown (fallback). +func TestGitStatsSegment_BadgeShownWhenNoInline(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetSessions([]data.Session{{ID: "s1", Summary: "x", LastActiveAt: "2025-01-01T00:00:00Z"}}) + sl.SetSize(60, 10) // below the w>=70 inline threshold + sl.SetGitStates(map[string]platform.GitState{"s1": platform.GitStateBehind}) + sl.SetGitStatuses(map[string]platform.GitStatus{ + "s1": {Exists: true, IsRepo: true, HasUpstream: true, Behind: 1}, + }) + out := sl.View() + if !strings.Contains(out, styles.IconGitBehind()) { + t.Errorf("badge should show at narrow width when inline is hidden:\n%s", out) + } +} + +func TestGitStatsSegment_InRenderedRow(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetSessions([]data.Session{{ID: "s1", Summary: "hello world", LastActiveAt: "2025-01-01T00:00:00Z"}}) + sl.SetSize(140, 10) + sl.SetGitStatuses(map[string]platform.GitStatus{ + "s1": {Exists: true, IsRepo: true, HasUpstream: true, Ahead: 4, Behind: 2}, + }) + out := sl.View() + if !strings.Contains(out, "4") || !strings.Contains(out, "2") { + t.Errorf("rendered list should include inline ahead/behind counts, got:\n%s", out) + } +} diff --git a/internal/tui/handlers.go b/internal/tui/handlers.go index 392f34b..2808dc1 100644 --- a/internal/tui/handlers.go +++ b/internal/tui/handlers.go @@ -13,6 +13,7 @@ import ( "github.com/jongio/dispatch/internal/copilot" "github.com/jongio/dispatch/internal/data" + "github.com/jongio/dispatch/internal/platform" "github.com/jongio/dispatch/internal/tui/components" "github.com/jongio/dispatch/internal/tui/styles" ) @@ -302,6 +303,7 @@ func (m Model) handleSessionDetail(msg sessionDetailMsg) (Model, tea.Cmd) { m.preview.SetAlias(m.cfg.AliasFor(m.detail.Session.ID)) m.preview.SetAttentionStatus(m.attentionStatusForSession(m.detail.Session.ID)) m.syncPreviewWorkspaceMissing() + m.syncPreviewGitStatus() m.preview.SetHasPlan(m.planMap[m.detail.Session.ID]) if result, ok := m.workStatus.workStatusMap[m.detail.Session.ID]; ok { m.preview.SetWorkStatus(result) @@ -617,9 +619,17 @@ func (m Model) handleContinuationPlanCreated(msg continuationPlanCreatedMsg) (Mo // ----- Git workspace state scanning ---------------------------------------- func (m Model) handleGitStateScanned(msg gitStateScannedMsg) (Model, tea.Cmd) { - m.gitStateMap = msg.states + m.gitStatusMap = msg.statuses + // Derive the collapsed badge enum from the detailed statuses so existing + // badge rendering and git-dirty / missing-workspace filters keep working. + m.gitStateMap = make(map[string]platform.GitState, len(msg.statuses)) + for id, st := range msg.statuses { + m.gitStateMap[id] = st.State() + } m.sessionList.SetGitStates(m.gitStateMap) + m.sessionList.SetGitStatuses(m.gitStatusMap) m.syncPreviewWorkspaceMissing() + m.syncPreviewGitStatus() // When a git-state filter is active, reload sessions so the list // reflects the detected states. if m.filterGitDirty || m.filterMissingWorkspace { diff --git a/internal/tui/messages.go b/internal/tui/messages.go index 9bf8084..4003b21 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -175,10 +175,11 @@ type exportDoneMsg struct { // Git workspace state messages // --------------------------------------------------------------------------- -// gitStateScannedMsg delivers git workspace state data from the background -// scanner that runs bounded git commands against each session directory. +// gitStateScannedMsg delivers detailed git status data from the background +// scanner. The session-list badge enum is derived from each GitStatus, while +// the inline push/pull column and preview pane use the full status. type gitStateScannedMsg struct { - states map[string]platform.GitState + statuses map[string]platform.GitStatus } // gitStatusMsg delivers the detailed git status for a single session folder, diff --git a/internal/tui/model.go b/internal/tui/model.go index 1a8b3e1..8372adb 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -390,6 +390,9 @@ type Model struct { filterPlans bool // when true, only show sessions with a plan.md file // Git workspace state tracking — scanned from session directories. + // gitStatusMap holds the detailed per-session status; gitStateMap is the + // collapsed badge enum derived from it for rendering and filters. + gitStatusMap map[string]platform.GitStatus gitStateMap map[string]platform.GitState filterGitDirty bool // when true, only show sessions with local git changes // filterMissingWorkspace, when true, shows only sessions whose working @@ -3196,6 +3199,18 @@ func (m *Model) syncPreviewWorkspaceMissing() { m.preview.SetWorkspaceMissing(m.gitStateMap[m.detail.Session.ID] == platform.GitStateMissing) } +// syncPreviewGitStatus pushes the currently selected session's detailed git +// status to the preview so its git section (upstream, push/pull, change counts) +// reflects the latest scan. Safe to call when no detail is loaded or no status +// has been scanned yet (the preview renders nothing for a non-repo status). +func (m *Model) syncPreviewGitStatus() { + if m.detail == nil { + m.preview.SetGitStatus(platform.GitStatus{}) + return + } + m.preview.SetGitStatus(m.gitStatusMap[m.detail.Session.ID]) +} + // syncSessionListStatuses pushes all current status maps (hidden, favorited, // attention, plan, work status) to the sessionList component. Call this after // loading or filtering sessions/groups to keep the list's decorations in sync. @@ -3208,6 +3223,7 @@ func (m *Model) syncSessionListStatuses() { m.sessionList.SetPlanStatuses(m.planMap) m.sessionList.SetWorkStatuses(m.workStatus.workStatusMap) m.sessionList.SetGitStates(m.gitStateMap) + m.sessionList.SetGitStatuses(m.gitStatusMap) } // syncSessionListWorkStatuses pushes only the work status map to the @@ -4123,40 +4139,40 @@ func (m Model) scanGitStatesCmd() tea.Cmd { return nil } - // In demo mode, return synthetic git states so all badge types are visible - // without requiring real git repos on disk. + // In demo mode, return synthetic git statuses so all badge types and + // push/pull stats are visible without requiring real git repos on disk. if os.Getenv("DISPATCH_DEMO_GIT_STATES") != "" { return func() tea.Msg { - states := demoGitStates(sessionDirs) - return gitStateScannedMsg{states: states} + return gitStateScannedMsg{statuses: demoGitStatuses(sessionDirs)} } } return func() tea.Msg { - states := platform.ScanGitStates(sessionDirs) - return gitStateScannedMsg{states: states} + return gitStateScannedMsg{statuses: platform.ScanGitStatuses(sessionDirs)} } } -// demoGitStates assigns a rotating set of git states to sessions so all -// badge types are visible in demo mode. The order cycles through dirty, -// ahead, untracked, behind, clean, and missing. -func demoGitStates(sessionDirs map[string]string) map[string]platform.GitState { - cycle := []platform.GitState{ - platform.GitStateDirty, - platform.GitStateAhead, - platform.GitStateUntracked, - platform.GitStateBehind, - platform.GitStateClean, - platform.GitStateMissing, +// demoGitStatuses assigns a rotating set of synthetic git statuses to sessions +// so all badge types and inline push/pull stats are visible in demo mode. The +// order cycles through dirty, ahead, untracked, behind, clean, and missing. +func demoGitStatuses(sessionDirs map[string]string) map[string]platform.GitStatus { + cycle := []platform.GitStatus{ + {Exists: true, IsRepo: true, Branch: "main", Upstream: "origin/main", HasUpstream: true, Modified: 3, Staged: 1}, + {Exists: true, IsRepo: true, Branch: "feature/x", Upstream: "origin/feature/x", HasUpstream: true, Ahead: 2}, + {Exists: true, IsRepo: true, Branch: "main", Upstream: "origin/main", HasUpstream: true, Untracked: 4}, + {Exists: true, IsRepo: true, Branch: "main", Upstream: "origin/main", HasUpstream: true, Behind: 1}, + {Exists: true, IsRepo: true, Branch: "main", Upstream: "origin/main", HasUpstream: true}, + {Exists: false}, } - states := make(map[string]platform.GitState, len(sessionDirs)) + statuses := make(map[string]platform.GitStatus, len(sessionDirs)) i := 0 - for id := range sessionDirs { - states[id] = cycle[i%len(cycle)] + for id, dir := range sessionDirs { + st := cycle[i%len(cycle)] + st.Dir = dir + statuses[id] = st i++ } - return states + return statuses } // loadPlanContentCmd reads the plan.md content for a specific session. diff --git a/internal/tui/model_gitstatus_test.go b/internal/tui/model_gitstatus_test.go index 241e525..fb16825 100644 --- a/internal/tui/model_gitstatus_test.go +++ b/internal/tui/model_gitstatus_test.go @@ -128,8 +128,52 @@ func TestGitStatusOverlay_CopyError(t *testing.T) { } } -// TestGitStatusKeybinding verifies the git-status action is bound to 'i' and is -// registered as a remappable action. +// TestHandleGitStateScanned_DerivesBadgeAndFeeds verifies the scan handler +// stores the detailed status map and derives the collapsed badge enum from it. +func TestHandleGitStateScanned_DerivesBadgeAndFeeds(t *testing.T) { + m := newTestModel() + m.sessionList.SetSessions([]data.Session{{ID: "dirty", Cwd: "/a"}, {ID: "ahead", Cwd: "/b"}}) + + statuses := map[string]platform.GitStatus{ + "dirty": {Exists: true, IsRepo: true, Modified: 2}, + "ahead": {Exists: true, IsRepo: true, HasUpstream: true, Ahead: 3}, + } + result, _ := m.Update(gitStateScannedMsg{statuses: statuses}) + rm := result.(Model) + + if len(rm.gitStatusMap) != 2 { + t.Errorf("gitStatusMap size = %d, want 2", len(rm.gitStatusMap)) + } + if rm.gitStateMap["dirty"] != platform.GitStateDirty { + t.Errorf("derived state[dirty] = %v, want GitStateDirty", rm.gitStateMap["dirty"]) + } + if rm.gitStateMap["ahead"] != platform.GitStateAhead { + t.Errorf("derived state[ahead] = %v, want GitStateAhead", rm.gitStateMap["ahead"]) + } +} + +// TestDemoGitStatuses verifies demo mode produces a status per session dir. +func TestDemoGitStatuses(t *testing.T) { + dirs := map[string]string{"a": "/a", "b": "/b", "c": "/c"} + statuses := demoGitStatuses(dirs) + if len(statuses) != len(dirs) { + t.Fatalf("demoGitStatuses size = %d, want %d", len(statuses), len(dirs)) + } + for id, dir := range dirs { + if statuses[id].Dir != dir { + t.Errorf("status[%s].Dir = %q, want %q", id, statuses[id].Dir, dir) + } + } +} + +// TestSyncPreviewGitStatus_NilDetail verifies syncing with no loaded detail is +// safe and clears the preview's git status. +func TestSyncPreviewGitStatus_NilDetail(t *testing.T) { + m := newTestModel() + m.detail = nil + m.syncPreviewGitStatus() // must not panic +} + func TestGitStatusKeybinding(t *testing.T) { km := defaultKeyMap() if !key.Matches(tea.KeyPressMsg{Code: 'i', Text: "i"}, km.GitStatus) { From 34bc683ca3c518e09799ba5552e28693ff1e8541 Mon Sep 17 00:00:00 2001 From: Jon Gallant Date: Fri, 17 Jul 2026 09:44:26 -0700 Subject: [PATCH 3/3] docs(spec): mark git-status spec shipped (PR #316) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 524cf702-8922-4cfd-b23f-4069b734e09b --- docs/specs/git-status/spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specs/git-status/spec.md b/docs/specs/git-status/spec.md index ff3dfdd..fd821bb 100644 --- a/docs/specs/git-status/spec.md +++ b/docs/specs/git-status/spec.md @@ -1,7 +1,7 @@ --- -issue: pending +issue: https://github.com/jongio/dispatch/pull/316 author: @jongio -status: approved +status: shipped --- # Git Status Overlay