Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -150,6 +151,7 @@ raw body and sent as `X-Git-Green-Signature: sha256=<hex>`.
| `↑` / `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 |
Expand Down
4 changes: 3 additions & 1 deletion internal/ui/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
}

Expand Down
2 changes: 2 additions & 0 deletions internal/ui/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
39 changes: 38 additions & 1 deletion internal/ui/tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions internal/ui/tree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Loading