From 8c6a8b7d3a532051473e6c3f6a641ed2d8192dd9 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Wed, 21 Oct 2026 13:36:55 -0400 Subject: [PATCH] feat(ui): s cycles repo sort between active-first, name and open PRs Assisted-by: Claude Code:claude-opus-5-5 --- CONTEXT.md | 2 ++ README.md | 2 ++ internal/ui/dashboard.go | 4 +++- internal/ui/help.go | 2 ++ internal/ui/tree.go | 39 ++++++++++++++++++++++++++++++++++++++- internal/ui/tree_test.go | 30 ++++++++++++++++++++++++++++++ 6 files changed, 77 insertions(+), 2 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index f2868d3..ec7f600 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -89,6 +89,8 @@ _Avoid_: approval status, review badge Repos, Stacks, and PRs are sorted by Stoplight priority so the most actionable items appear at the top: ๐ŸŸก in-progress โ†’ ๐Ÿ”ด failing โ†’ ๐ŸŸข passing โ†’ โšช no signal. Order is stable within each tier. +Active-first is the default Repo order. Pressing `s` cycles the Repo order through active-first โ†’ name (Aโ†’Z, case-insensitive) โ†’ open PRs (most first, ties Aโ†’Z) and back. The choice lasts for the session and only reorders Repos; PRs and Stacks inside a Repo stay active-first. + **Stacks are the exception.** Members of a Stack always render bottom-to-top, in the order they have to merge, because reordering them by Stoplight would hide that order. The Stack as a whole still sorts active-first against other Stacks and standalone PRs. _Avoid_: bubbling, floating diff --git a/README.md b/README.md index 0ffcd12..6bc1d9d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A terminal dashboard for live GitHub CI health across multiple repos โ€” no brow - **PR-level CI tree** โ€” expand any repo to see branch CI and each open PR with its own stoplight - **Stacked PRs grouped** โ€” PRs in a `gh stack` stack collapse into one row that shows the stack number, its health, and each member in merge order - **Active-first sorting** โ€” in-progress and failing repos/PRs bubble to the top automatically +- **Sort toggle** โ€” press `s` to cycle repos between active-first, name (Aโ†’Z) and open PRs (most first) - **Inline expand/collapse** โ€” navigate with `โ†‘`/`โ†“`, toggle any row with `enter`/`space` - **Auto-expand** โ€” a repo opens on its own when its CI starts running or failing and closes when it goes green again; a row you toggle by hand stays put until its status changes - **Auto-polling** โ€” refreshes every 15 seconds (configurable); retains last-known status on API errors @@ -150,6 +151,7 @@ raw body and sent as `X-Git-Green-Signature: sha256=`. | `โ†‘` / `k` | Navigate up | | `โ†“` / `j` | Navigate down | | `enter` / `space` | Expand / collapse repo, stack, or PR row | +| `s` | Cycle repo sort: active-first โ†’ name (Aโ†’Z) โ†’ open PRs (most first) | | `f` | Re-run the failed run on the selected row (`enter` confirms, `esc` cancels) | | `r` | Force refresh | | `o` | Open run in browser | diff --git a/internal/ui/dashboard.go b/internal/ui/dashboard.go index f283c09..57779a3 100644 --- a/internal/ui/dashboard.go +++ b/internal/ui/dashboard.go @@ -151,6 +151,8 @@ func (d Dashboard) Update(msg tea.Msg) (Dashboard, tea.Cmd) { if d.tree.toggle() { return d, tea.Batch(selectionTimeoutCmd(), expandedChangedCmd(d.ExpandedRepos())) } + case "s": + d.tree.cycleSort() case "f": // Only a row whose run actually failed can be re-run. if target := d.selectedRerunTarget(); target != nil && d.rerunner != nil { @@ -345,7 +347,7 @@ func (d Dashboard) hintLine() string { } return successStyle.Render(d.rerunMsg) default: - return hintStyle.Render("โ†‘/โ†“ navigate enter/space expand f re-run o open r refresh m manage q quit ? help") + return hintStyle.Render("โ†‘/โ†“ navigate enter/space expand s sort: " + d.tree.sortBy.String() + " f re-run o open r refresh m manage q quit ? help") } } diff --git a/internal/ui/help.go b/internal/ui/help.go index 541d3f1..387191e 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -20,6 +20,7 @@ const helpMarkdown = `# git-green ยท keybindings | **โ†‘** / **k** | Move up | | **โ†“** / **j** | Move down | | **enter** / **space** | Expand or collapse repo, stack, or PR | +| **s** | Sort repos: active-first โ†’ name (Aโ†’Z) โ†’ open PRs (most first) | | **f** | Re-run the failed run on the selected row (confirm with **enter**) | | **o** | Open run in browser | | **r** | Force refresh | @@ -58,6 +59,7 @@ func fallbackHelpText() string { โ†‘ / k move up โ†“ / j move down enter / space expand/collapse repo, stack, or PR + s sort repos: active-first / name A-Z / open PRs f re-run the failed run on the selected row (enter to confirm) o open run in browser r force refresh diff --git a/internal/ui/tree.go b/internal/ui/tree.go index e6b5c77..188359e 100644 --- a/internal/ui/tree.go +++ b/internal/ui/tree.go @@ -2,6 +2,7 @@ package ui import ( "sort" + "strings" "github.com/ericdahl-dev/git-green/internal/aggregator" githubclient "github.com/ericdahl-dev/git-green/internal/github" @@ -63,6 +64,31 @@ type tree struct { attention map[string]bool // last seen needsAttention per Repo, for auto-expand rows []node cursor int + sortBy sortMode +} + +// sortMode orders the Repo rows. PRs and Stacks inside a Repo always sort +// active-first. +type sortMode int + +const ( + sortActiveFirst sortMode = iota + sortName // Aโ†’Z + sortOpenPRs // most open PRs first, ties Aโ†’Z +) + +func (m sortMode) String() string { + return [...]string{"active", "name", "open PRs"}[m] +} + +// cycleSort moves to the next sort mode, keeping the cursor on its row. +func (t *tree) cycleSort() { + sel, ok := t.selectedKey() + t.sortBy = (t.sortBy + 1) % 3 + t.build() + if ok { + t.restore(sel) + } } func newTree(snap state.Snapshot) tree { @@ -138,7 +164,18 @@ func (t *tree) build() { repos := make([]state.RepoState, len(t.snapshot.Repos)) copy(repos, t.snapshot.Repos) sort.SliceStable(repos, func(a, b int) bool { - return repos[a].Stoplight.ActiveFirst() < repos[b].Stoplight.ActiveFirst() + ra, rb := repos[a], repos[b] + switch t.sortBy { + case sortOpenPRs: + if len(ra.PRs) != len(rb.PRs) { + return len(ra.PRs) > len(rb.PRs) + } + fallthrough + case sortName: + return strings.ToLower(ra.FullName()) < strings.ToLower(rb.FullName()) + default: + return ra.Stoplight.ActiveFirst() < rb.Stoplight.ActiveFirst() + } }) // A fresh slice, not t.rows[:0]: Bubble Tea copies the model by value, and diff --git a/internal/ui/tree_test.go b/internal/ui/tree_test.go index 72dd784..9f26c94 100644 --- a/internal/ui/tree_test.go +++ b/internal/ui/tree_test.go @@ -112,3 +112,33 @@ func TestTreeEmpty(t *testing.T) { tr.move(1) tr.move(-1) } + +// s cycles the Repo order: active-first, then name Aโ†’Z, then most open PRs +// (ties Aโ†’Z), then back. The cursor stays on its Repo throughout. +func TestTreeCycleSort(t *testing.T) { + pr := lonePR(1, aggregator.StoplightGreen) + tr := newTree(state.New([]state.RepoState{ + treeRepo("c", aggregator.StoplightGreen, pr), + treeRepo("B", aggregator.StoplightGreen, pr, pr), + treeRepo("a", aggregator.StoplightRed), + treeRepo("d", aggregator.StoplightGreen, pr), + })) + order := func() string { + out := "" + for _, n := range tr.rows { + out += n.repo.Name + } + return out + } + selectKind(t, &tr, kindRepo, 0) // cursor on the first row, "a" + + for _, want := range []string{"acBd", "aBcd", "Bcda", "acBd"} { + if got := order(); got != want { + t.Fatalf("mode %s: order %q, want %q", tr.sortBy, got, want) + } + if n, _ := tr.selected(); n.repo.Name != "a" { + t.Fatalf("mode %s: cursor on %q, want a", tr.sortBy, n.repo.Name) + } + tr.cycleSort() + } +}