From c8e3cb59f7b179064f7400a5c31b521683faad8b Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Mon, 28 Sep 2026 22:53:03 -0400 Subject: [PATCH] refactor: deepen the Dashboard tree Rows, expansion, the cursor and auto-expand move into a tree module in ui/tree.go. Each row is a node carrying its Repo, Stack or PR and the Runs it stands for, so the five kind-switches over index fields collapse into one build. Expansion is one map keyed by identity (Repo name, Stack or PR number). PR expansion was keyed by [repoIdx, prIdx] and jumped to a different PR whenever the PR list changed. Closes #50 --- CONTEXT.md | 2 + internal/ui/autoexpand_test.go | 7 +- internal/ui/dashboard.go | 343 ++++----------------------------- internal/ui/dashboard_test.go | 10 +- internal/ui/stacks_test.go | 6 +- internal/ui/tree.go | 235 ++++++++++++++++++++++ internal/ui/tree_test.go | 114 +++++++++++ 7 files changed, 398 insertions(+), 319 deletions(-) create mode 100644 internal/ui/tree.go create mode 100644 internal/ui/tree_test.go diff --git a/CONTEXT.md b/CONTEXT.md index 69f4d75..e8a68dd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -115,6 +115,8 @@ The Dashboard renders a two-level expandable tree: - **Stack row**: navigable; expand/collapse with `enter`/`space` to show its member PR rows. Its Stoplight is the most actionable of its members', and `f` and `o` act on the first failing member so a collapsed Stack still exposes what broke. - **PR row**: navigable; expand/collapse with `enter`/`space` to show that PR's Workflow runs. A PR inside a Stack renders one level deeper and carries its `position/size`. A Review glyph follows the PR number when there is a Review signal. +Expansion and the cursor stay attached to what a row shows — the Repo by name, a Stack or PR by its number — so they survive PRs opening and closing, config reloads and active-first re-sorting. + _Avoid_: detail view, drill-down screen ## Polling diff --git a/internal/ui/autoexpand_test.go b/internal/ui/autoexpand_test.go index 823a222..1c2f413 100644 --- a/internal/ui/autoexpand_test.go +++ b/internal/ui/autoexpand_test.go @@ -15,7 +15,7 @@ func pr(n int, light aggregator.Stoplight) state.PRState { return state.PRState{Number: n, Title: "pr", Stoplight: light} } -func isOpen(d Dashboard, name string) bool { return d.repoExp["o/"+name] } +func isOpen(d Dashboard, name string) bool { return d.tree.expanded[repoKey("o/"+name)] } func TestAutoExpandOpensActiveAndFailingRepos(t *testing.T) { d := NewDashboard(state.New(nil)) @@ -84,8 +84,7 @@ func TestAutoExpandKeepsCursorOnSelectedRepo(t *testing.T) { repoState("a", aggregator.StoplightRed, pr(1, aggregator.StoplightRed), pr(2, aggregator.StoplightRed)), repoState("b", aggregator.StoplightGreen), })) - row := d.rows[d.cursor] - if row.kind != kindRepo || d.snapshot.Repos[row.repoIdx].Name != "b" { - t.Errorf("cursor moved off repo b to %+v", row) + if n, _ := d.tree.selected(); n.key != repoKey("o/b") { + t.Errorf("cursor moved off repo b to %+v", n.key) } } diff --git a/internal/ui/dashboard.go b/internal/ui/dashboard.go index dac3b58..f283c09 100644 --- a/internal/ui/dashboard.go +++ b/internal/ui/dashboard.go @@ -3,7 +3,6 @@ package ui import ( "context" "fmt" - "sort" "time" tea "github.com/charmbracelet/bubbletea" @@ -64,40 +63,8 @@ type rerunTarget struct { func (t rerunTarget) fullName() string { return t.owner + "/" + t.name } -type rowKind int - -const ( - kindRepo rowKind = iota - kindStack - kindPR -) - -// stackKey identifies an expanded stack. It carries the repo name rather than -// its index because a repo's index moves when the config reloads or the -// active-first sort shifts, which would otherwise reassign expansion state to -// whatever repo landed on that index. -type stackKey struct { - repo string - stack int -} - -type flatRow struct { - kind rowKind - repoIdx int - prIdx int // only for kindPR - groupIdx int // index into the repo's prGroups, for kindStack and stacked kindPR - inStack bool // kindPR rendered inside an expanded stack -} - type Dashboard struct { - snapshot state.Snapshot - rows []flatRow - cursor int - repoExp map[string]bool - attention map[string]bool // last seen needsAttention per repo, for auto-expand - stackExp map[stackKey]bool - prExp map[[2]int]bool - groups map[int][]prGroup // repoIdx -> its PR groups, rebuilt with rows + tree tree lastActivity time.Time selectionFade bool @@ -124,55 +91,7 @@ func (d Dashboard) AwaitingConfirm() bool { } func NewDashboard(snap state.Snapshot) Dashboard { - d := Dashboard{ - snapshot: snap, - repoExp: make(map[string]bool), - attention: make(map[string]bool), - stackExp: make(map[stackKey]bool), - prExp: make(map[[2]int]bool), - lastActivity: time.Now(), - } - d.rows = d.buildRows() - return d -} - -func (d *Dashboard) buildRows() []flatRow { - // Build sorted repo index order: yellow first, then red, green, grey. - repoOrder := make([]int, len(d.snapshot.Repos)) - for i := range repoOrder { - repoOrder[i] = i - } - sort.SliceStable(repoOrder, func(a, b int) bool { - pa := d.snapshot.Repos[repoOrder[a]].Stoplight.ActiveFirst() - pb := d.snapshot.Repos[repoOrder[b]].Stoplight.ActiveFirst() - return pa < pb - }) - - d.groups = make(map[int][]prGroup, len(d.snapshot.Repos)) - var rows []flatRow - for _, i := range repoOrder { - r := d.snapshot.Repos[i] - groups := groupPRs(r.PRs) - d.groups[i] = groups - rows = append(rows, flatRow{kind: kindRepo, repoIdx: i}) - if !d.repoExp[r.FullName()] { - continue - } - for gi, g := range groups { - if !g.isStack() { - rows = append(rows, flatRow{kind: kindPR, repoIdx: i, prIdx: g.prIdxs[0], groupIdx: gi}) - continue - } - rows = append(rows, flatRow{kind: kindStack, repoIdx: i, groupIdx: gi}) - if !d.stackExp[stackKey{repo: r.FullName(), stack: g.stackNum}] { - continue - } - for _, j := range g.prIdxs { - rows = append(rows, flatRow{kind: kindPR, repoIdx: i, prIdx: j, groupIdx: gi, inStack: true}) - } - } - } - return rows + return Dashboard{tree: newTree(snap), lastActivity: time.Now()} } // ExpandedReposMsg carries the repos whose rows are currently open, so the @@ -182,16 +101,7 @@ type ExpandedReposMsg struct { } // ExpandedRepos returns the "owner/name" of every currently expanded repo. -func (d Dashboard) ExpandedRepos() []string { - var out []string - for name, open := range d.repoExp { - if open { - out = append(out, name) - } - } - sort.Strings(out) - return out -} +func (d Dashboard) ExpandedRepos() []string { return d.tree.expandedRepos() } func expandedChangedCmd(repos []string) tea.Cmd { return func() tea.Msg { return ExpandedReposMsg{Repos: repos} } @@ -230,46 +140,16 @@ func (d Dashboard) Update(msg tea.Msg) (Dashboard, tea.Cmd) { d.selectionFade = false switch msg.String() { case "up", "k": - if d.cursor > 0 { - d.cursor-- - } + d.tree.move(-1) case "down", "j": - if d.cursor < len(d.rows)-1 { - d.cursor++ - } + d.tree.move(1) case "enter", " ": - if len(d.rows) == 0 { - break - } - row := d.rows[d.cursor] - switch row.kind { - case kindRepo: - key := d.snapshot.Repos[row.repoIdx].FullName() - d.repoExp[key] = !d.repoExp[key] - d.rows = d.buildRows() - if d.cursor >= len(d.rows) { - d.cursor = len(d.rows) - 1 - } - // Expanding a repo asks for detail the poller was not - // fetching, so tell it and refresh rather than leaving the - // row empty until the next tick. + // Expanding a Repo asks for detail the poller was not fetching, + // so tell it and refresh rather than leaving the row empty until + // the next tick. Stacks and PRs live under an already-expanded + // Repo, so their detail is on hand. + if d.tree.toggle() { return d, tea.Batch(selectionTimeoutCmd(), expandedChangedCmd(d.ExpandedRepos())) - case kindStack: - g := d.group(row) - if g == nil { - break - } - // A stack's members live under an already-expanded repo, so - // their detail is on hand — no refetch needed here. - key := stackKey{repo: d.snapshot.Repos[row.repoIdx].FullName(), stack: g.stackNum} - d.stackExp[key] = !d.stackExp[key] - d.rows = d.buildRows() - if d.cursor >= len(d.rows) { - d.cursor = len(d.rows) - 1 - } - case kindPR: - key := [2]int{row.repoIdx, row.prIdx} - d.prExp[key] = !d.prExp[key] } case "f": // Only a row whose run actually failed can be re-run. @@ -296,11 +176,7 @@ func (d Dashboard) Update(msg tea.Msg) (Dashboard, tea.Cmd) { case rerunResultExpiredMsg: d.clearRerunResult() case state.Snapshot: - sel := d.selectedRow() - d.snapshot = msg - opened := d.autoExpand() - d.rows = d.buildRows() - d.restoreCursor(sel) + opened := d.tree.update(msg) var cmd tea.Cmd if opened { // Newly opened repos need the job and PR-run detail the poller @@ -317,111 +193,6 @@ func (d Dashboard) Update(msg tea.Msg) (Dashboard, tea.Cmd) { return d, nil } -// needsAttention reports whether a repo has CI running or failing, on its -// branch or on any PR the dashboard knows about. -func needsAttention(r state.RepoState) bool { - active := func(s aggregator.Stoplight) bool { - return s == aggregator.StoplightYellow || s == aggregator.StoplightRed - } - if active(r.Stoplight) { - return true - } - for _, p := range r.PRs { - if active(p.Stoplight) { - return true - } - } - return false -} - -// autoExpand opens repos that start needing attention and closes repos that -// stop. It acts only when that state changes (or a repo first appears), so a -// row the user opened or closed by hand stays that way until its CI moves. -// It reports whether any repo was opened. -func (d *Dashboard) autoExpand() bool { - opened := false - seen := make(map[string]bool, len(d.snapshot.Repos)) - for _, r := range d.snapshot.Repos { - name := r.FullName() - seen[name] = true - now := needsAttention(r) - if was, known := d.attention[name]; known && was == now { - continue - } - d.attention[name] = now - if now && !d.repoExp[name] { - opened = true - } - d.repoExp[name] = now - } - for name := range d.attention { - if !seen[name] { - delete(d.attention, name) - } - } - return opened -} - -// rowRef identifies a row by what it shows rather than by position, so the -// cursor can follow it when rows are inserted above it. -type rowRef struct { - repo string - kind rowKind - num int // PR or stack number - valid bool -} - -func (d Dashboard) selectedRow() rowRef { - if d.cursor < 0 || d.cursor >= len(d.rows) { - return rowRef{} - } - return d.refFor(d.rows[d.cursor]) -} - -func (d Dashboard) refFor(row flatRow) rowRef { - if row.repoIdx >= len(d.snapshot.Repos) { - return rowRef{} - } - r := d.snapshot.Repos[row.repoIdx] - ref := rowRef{repo: r.FullName(), kind: row.kind, valid: true} - switch row.kind { - case kindPR: - if row.prIdx < len(r.PRs) { - ref.num = r.PRs[row.prIdx].Number - } - case kindStack: - if g := d.group(row); g != nil { - ref.num = g.stackNum - } - } - return ref -} - -// restoreCursor moves the cursor back onto sel after a rebuild, falling back -// to sel's repo row, then to clamping. -func (d *Dashboard) restoreCursor(sel rowRef) { - if sel.valid { - repoRow := -1 - for i, row := range d.rows { - ref := d.refFor(row) - if ref == sel { - d.cursor = i - return - } - if repoRow < 0 && ref.kind == kindRepo && ref.repo == sel.repo { - repoRow = i - } - } - if repoRow >= 0 { - d.cursor = repoRow - return - } - } - if d.cursor >= len(d.rows) && len(d.rows) > 0 { - d.cursor = len(d.rows) - 1 - } -} - // startRerun moves out of the confirmation and fires the re-run in a command. func (d Dashboard) startRerun() (Dashboard, tea.Cmd) { d.rerunStatus = rerunExecuting @@ -446,19 +217,18 @@ func (d *Dashboard) clearRerunResult() { // selectedRerunTarget returns the first failed run on the selected row, or nil // when the row is green, still running, or has no runs at all. func (d Dashboard) selectedRerunTarget() *rerunTarget { - if len(d.rows) == 0 { + n, ok := d.tree.selected() + if !ok { return nil } - row := d.rows[d.cursor] - repo := d.snapshot.Repos[row.repoIdx] - for _, run := range d.rowRuns(row) { + for _, run := range n.runs() { // Only a finished, red Run can be re-run; one still going is yellow. if aggregator.Of(run.Conclusion) != aggregator.StoplightRed || run.RunID == 0 { continue } return &rerunTarget{ - owner: repo.Owner, - name: repo.Name, + owner: n.repo.Owner, + name: n.repo.Name, runID: run.RunID, workflow: run.WorkflowName, } @@ -466,61 +236,25 @@ func (d Dashboard) selectedRerunTarget() *rerunTarget { return nil } -// group returns the prGroup a stack row or stacked PR row belongs to. -func (d Dashboard) group(row flatRow) *prGroup { - groups := d.groups[row.repoIdx] - if row.groupIdx >= len(groups) { - return nil - } - return &groups[row.groupIdx] -} - -// rowRuns returns the workflow runs a row stands for. A stack row stands for -// every run across its members, bottom to top, so re-running and opening from a -// collapsed stack still reach the layer that needs attention. -func (d Dashboard) rowRuns(row flatRow) []githubclient.WorkflowRun { - repo := d.snapshot.Repos[row.repoIdx] - switch row.kind { - case kindStack: - g := d.group(row) - if g == nil { - return nil - } - var runs []githubclient.WorkflowRun - for _, j := range g.prIdxs { - if j < len(repo.PRs) { - runs = append(runs, repo.PRs[j].Runs...) - } - } - return runs - case kindPR: - if row.prIdx >= len(repo.PRs) { - return nil - } - return repo.PRs[row.prIdx].Runs - default: - return repo.Runs - } -} - // Throttles reports the tokens the poller has slowed down, for the title bar. -func (d Dashboard) Throttles() []state.Throttle { return d.snapshot.Throttles } +func (d Dashboard) Throttles() []state.Throttle { return d.tree.snapshot.Throttles } func (d Dashboard) SelectedRepo() *state.RepoState { - if len(d.rows) == 0 { + n, ok := d.tree.selected() + if !ok { return nil } - r := d.snapshot.Repos[d.rows[d.cursor].repoIdx] - return &r + return &n.repo } // SelectedRunURL returns the HTML URL of the primary workflow run for the -// selected repo row or PR row, if any. +// selected row, if any. func (d Dashboard) SelectedRunURL() string { - if len(d.rows) == 0 { + n, ok := d.tree.selected() + if !ok { return "" } - runs := d.rowRuns(d.rows[d.cursor]) + runs := n.runs() if len(runs) == 0 { return "" } @@ -531,17 +265,17 @@ func (d Dashboard) SelectedRunURL() string { func (d Dashboard) BodyView() string { out := "" - if len(d.snapshot.Repos) == 0 { + if len(d.tree.snapshot.Repos) == 0 { out += staleStyle.Render(" No repos configured.") + "\n" } - for rowIdx, row := range d.rows { - selected := rowIdx == d.cursor && !d.selectionFade - r := d.snapshot.Repos[row.repoIdx] + for rowIdx, n := range d.tree.rows { + selected := rowIdx == d.tree.cursor && !d.selectionFade + r := n.repo + expanded := d.tree.isExpanded(n) - switch row.kind { + switch n.key.kind { case kindRepo: - expanded := d.repoExp[r.FullName()] triangle := "▶" if expanded { triangle = "▼" @@ -557,15 +291,11 @@ func (d Dashboard) BodyView() string { } case kindStack: - g := d.group(row) - if g == nil { - break - } tri := "▶" - if d.stackExp[stackKey{repo: r.FullName(), stack: g.stackNum}] { + if expanded { tri = "▼" } - line := prIndent + tri + " " + g.title() + line := prIndent + tri + " " + n.group.title() if selected { out += selectedStyle.Render(line) + "\n" } else { @@ -573,15 +303,14 @@ func (d Dashboard) BodyView() string { } case kindPR: - pr := r.PRs[row.prIdx] - prExpanded := d.prExp[[2]int{row.repoIdx, row.prIdx}] + pr := n.pr tri := "▶" - if prExpanded { + if expanded { tri = "▼" } indent := prIndent position := "" - if row.inStack { + if n.inStack { indent = stackPRIndent position = fmt.Sprintf("%d/%d ", pr.Stack.Position, pr.Stack.Size) } @@ -591,7 +320,7 @@ func (d Dashboard) BodyView() string { } else { out += normalStyle.Render(indent+tri+" "+line) + "\n" } - if prExpanded { + if expanded { out += renderPRRuns(pr, indent+" ") } } diff --git a/internal/ui/dashboard_test.go b/internal/ui/dashboard_test.go index ad89cde..3a658f4 100644 --- a/internal/ui/dashboard_test.go +++ b/internal/ui/dashboard_test.go @@ -264,20 +264,20 @@ func TestExpansionSurvivesReordering(t *testing.T) { red := state.RepoState{Owner: "o", Name: "red", Stoplight: aggregator.StoplightRed} d := NewDashboard(state.New([]state.RepoState{green, red})) - d.repoExp["o/red"] = true + d.tree.expanded[repoKey("o/red")] = true if got := d.ExpandedRepos(); len(got) != 1 || got[0] != "o/red" { t.Fatalf("ExpandedRepos = %v, want [o/red]", got) } // Reverse the order, as a stoplight change would. - d.snapshot = state.New([]state.RepoState{red, green}) - d.rows = d.buildRows() + d.tree.snapshot = state.New([]state.RepoState{red, green}) + d.tree.build() - if !d.repoExp["o/red"] { + if got := d.ExpandedRepos(); len(got) != 1 || got[0] != "o/red" { t.Error("o/red lost its expansion after reordering") } - if d.repoExp["o/green"] { + if d.tree.expanded[repoKey("o/green")] { t.Error("o/green became expanded after reordering") } } diff --git a/internal/ui/stacks_test.go b/internal/ui/stacks_test.go index 943adc8..442e0c3 100644 --- a/internal/ui/stacks_test.go +++ b/internal/ui/stacks_test.go @@ -188,13 +188,13 @@ func TestSelectedRerunTargetReachesIntoACollapsedStack(t *testing.T) { // moveTo walks the cursor down to the first row of the given kind. func moveTo(t *testing.T, d Dashboard, want rowKind) Dashboard { t.Helper() - for range d.rows { - if d.rows[d.cursor].kind == want { + for range d.tree.rows { + if n, _ := d.tree.selected(); n.key.kind == want { return d } d, _ = d.Update(key("down")) } - t.Fatalf("no row of kind %d in %d rows", want, len(d.rows)) + t.Fatalf("no row of kind %d in %d rows", want, len(d.tree.rows)) return d } diff --git a/internal/ui/tree.go b/internal/ui/tree.go new file mode 100644 index 0000000..e6b5c77 --- /dev/null +++ b/internal/ui/tree.go @@ -0,0 +1,235 @@ +package ui + +import ( + "sort" + + "github.com/ericdahl-dev/git-green/internal/aggregator" + githubclient "github.com/ericdahl-dev/git-green/internal/github" + "github.com/ericdahl-dev/git-green/internal/state" +) + +type rowKind int + +const ( + kindRepo rowKind = iota + kindStack + kindPR +) + +// nodeKey identifies a row by what it shows, never by position. Positions +// move whenever a PR opens or closes, the config reloads, or active-first +// sorting reshuffles, and anything keyed by them drifts onto the wrong row. +type nodeKey struct { + repo string // "owner/name" + kind rowKind + num int // Stack or PR number; 0 for a Repo +} + +func repoKey(name string) nodeKey { return nodeKey{repo: name, kind: kindRepo} } + +// node is one navigable row of the Dashboard tree: a Repo, a Stack, or a PR. +type node struct { + key nodeKey + repo state.RepoState + group prGroup // kindStack only + pr state.PRState // kindPR only + inStack bool // a PR rendered inside an expanded Stack +} + +// runs returns the Runs a row stands for. A Stack stands for every Run across +// its members, bottom to top, so re-running and opening from a collapsed Stack +// still reach the layer that needs attention. +func (n node) runs() []githubclient.WorkflowRun { + switch n.key.kind { + case kindStack: + var runs []githubclient.WorkflowRun + for _, j := range n.group.prIdxs { + runs = append(runs, n.repo.PRs[j].Runs...) + } + return runs + case kindPR: + return n.pr.Runs + default: + return n.repo.Runs + } +} + +// tree is the Dashboard tree: the rows under the current snapshot, which of +// them are expanded, and where the cursor is. It keeps expansion and the +// cursor attached to Repos, Stacks and PRs across every snapshot. +type tree struct { + snapshot state.Snapshot + expanded map[nodeKey]bool + attention map[string]bool // last seen needsAttention per Repo, for auto-expand + rows []node + cursor int +} + +func newTree(snap state.Snapshot) tree { + t := tree{ + snapshot: snap, + expanded: make(map[nodeKey]bool), + attention: make(map[string]bool), + } + t.build() + return t +} + +// update moves the tree onto a new snapshot, auto-expanding Repos whose CI +// started or stopped needing attention. It reports whether any Repo opened. +func (t *tree) update(snap state.Snapshot) bool { + sel, ok := t.selectedKey() + t.snapshot = snap + opened := t.autoExpand() + t.build() + if ok { + t.restore(sel) + } else { + t.move(0) + } + return opened +} + +// toggle expands or collapses the selected row. It reports whether that was a +// Repo, since only Repo expansion changes what the poller fetches. +func (t *tree) toggle() bool { + n, ok := t.selected() + if !ok { + return false + } + t.expanded[n.key] = !t.expanded[n.key] + t.build() + t.restore(n.key) + return n.key.kind == kindRepo +} + +// move shifts the cursor by delta rows, staying on the tree. +func (t *tree) move(delta int) { + t.cursor = max(0, min(t.cursor+delta, len(t.rows)-1)) +} + +func (t tree) selected() (node, bool) { + if t.cursor < 0 || t.cursor >= len(t.rows) { + return node{}, false + } + return t.rows[t.cursor], true +} + +func (t tree) isExpanded(n node) bool { return t.expanded[n.key] } + +// expandedRepos returns the "owner/name" of every expanded Repo, sorted. +func (t tree) expandedRepos() []string { + var out []string + for k, open := range t.expanded { + if open && k.kind == kindRepo { + out = append(out, k.repo) + } + } + sort.Strings(out) + return out +} + +func (t tree) selectedKey() (nodeKey, bool) { + n, ok := t.selected() + return n.key, ok +} + +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() + }) + + // A fresh slice, not t.rows[:0]: Bubble Tea copies the model by value, and + // an earlier copy must keep the rows it rendered. + t.rows = nil + for _, r := range repos { + name := r.FullName() + t.rows = append(t.rows, node{key: repoKey(name), repo: r}) + if !t.expanded[repoKey(name)] { + continue + } + for _, g := range groupPRs(r.PRs) { + if !g.isStack() { + pr := r.PRs[g.prIdxs[0]] + t.rows = append(t.rows, node{key: nodeKey{name, kindPR, pr.Number}, repo: r, pr: pr}) + continue + } + stack := node{key: nodeKey{name, kindStack, g.stackNum}, repo: r, group: g} + t.rows = append(t.rows, stack) + if !t.expanded[stack.key] { + continue + } + for _, j := range g.prIdxs { + pr := r.PRs[j] + t.rows = append(t.rows, node{key: nodeKey{name, kindPR, pr.Number}, repo: r, pr: pr, inStack: true}) + } + } + } +} + +// restore puts the cursor back on sel after a rebuild, falling back to sel's +// Repo row, then to the nearest row that still exists. +func (t *tree) restore(sel nodeKey) { + repoRow := -1 + for i, n := range t.rows { + if n.key == sel { + t.cursor = i + return + } + if repoRow < 0 && n.key == repoKey(sel.repo) { + repoRow = i + } + } + if repoRow >= 0 { + t.cursor = repoRow + return + } + t.move(0) +} + +// needsAttention reports whether a Repo has CI running or failing, on its +// branch or on any PR the dashboard knows about. +func needsAttention(r state.RepoState) bool { + active := func(s aggregator.Stoplight) bool { + return s == aggregator.StoplightYellow || s == aggregator.StoplightRed + } + if active(r.Stoplight) { + return true + } + for _, p := range r.PRs { + if active(p.Stoplight) { + return true + } + } + return false +} + +// autoExpand opens Repos that start needing attention and closes Repos that +// stop. It acts only when that state changes (or a Repo first appears), so a +// row the user opened or closed by hand stays that way until its CI moves. +// It reports whether any Repo was opened. +func (t *tree) autoExpand() bool { + opened := false + seen := make(map[string]bool, len(t.snapshot.Repos)) + for _, r := range t.snapshot.Repos { + name := r.FullName() + seen[name] = true + now := needsAttention(r) + if was, known := t.attention[name]; known && was == now { + continue + } + t.attention[name] = now + if now && !t.expanded[repoKey(name)] { + opened = true + } + t.expanded[repoKey(name)] = now + } + for name := range t.attention { + if !seen[name] { + delete(t.attention, name) + } + } + return opened +} diff --git a/internal/ui/tree_test.go b/internal/ui/tree_test.go new file mode 100644 index 0000000..72dd784 --- /dev/null +++ b/internal/ui/tree_test.go @@ -0,0 +1,114 @@ +package ui + +import ( + "testing" + + "github.com/ericdahl-dev/git-green/internal/aggregator" + githubclient "github.com/ericdahl-dev/git-green/internal/github" + "github.com/ericdahl-dev/git-green/internal/state" +) + +func treeRepo(name string, light aggregator.Stoplight, prs ...state.PRState) state.RepoState { + return state.RepoState{Owner: "o", Name: name, Stoplight: light, PRs: prs} +} + +// selectKind walks the cursor to the first row of kind whose number is num. +func selectKind(t *testing.T, tr *tree, kind rowKind, num int) { + t.Helper() + tr.cursor = 0 + for range tr.rows { + n, _ := tr.selected() + if n.key.kind == kind && n.key.num == num { + return + } + tr.move(1) + } + t.Fatalf("no %d row numbered %d", kind, num) +} + +// PR expansion follows the PR, not its position: when a PR above it closes, +// the expanded one stays expanded and its neighbour does not open. +func TestTreePRExpansionFollowsThePR(t *testing.T) { + tr := newTree(state.New([]state.RepoState{ + treeRepo("a", aggregator.StoplightGreen, lonePR(1, aggregator.StoplightGreen), lonePR(2, aggregator.StoplightGreen)), + })) + tr.toggle() // expand the repo + selectKind(t, &tr, kindPR, 2) + tr.toggle() // expand PR #2 + + // PR #1 closes, so PR #2 moves to index 0 and a new PR #3 takes index 1. + tr.update(state.New([]state.RepoState{ + treeRepo("a", aggregator.StoplightGreen, lonePR(2, aggregator.StoplightGreen), lonePR(3, aggregator.StoplightGreen)), + })) + + for _, n := range tr.rows { + if n.key.kind != kindPR { + continue + } + if want := n.pr.Number == 2; tr.isExpanded(n) != want { + t.Errorf("PR #%d expanded = %v, want %v", n.pr.Number, tr.isExpanded(n), want) + } + } +} + +func TestTreeCursorFollowsSelectionAcrossResort(t *testing.T) { + tr := newTree(state.New([]state.RepoState{ + treeRepo("a", aggregator.StoplightGreen), + treeRepo("b", aggregator.StoplightGreen), + })) + selectKind(t, &tr, kindRepo, 0) + tr.move(1) // on b + + // b starts failing and sorts above a. + tr.update(state.New([]state.RepoState{ + treeRepo("a", aggregator.StoplightGreen), + treeRepo("b", aggregator.StoplightRed), + })) + if n, _ := tr.selected(); n.repo.Name != "b" { + t.Errorf("cursor on %q, want b", n.repo.Name) + } +} + +func TestTreeToggleReportsRepoChanges(t *testing.T) { + tr := newTree(state.New([]state.RepoState{ + treeRepo("a", aggregator.StoplightGreen, lonePR(1, aggregator.StoplightGreen)), + })) + if !tr.toggle() { + t.Error("toggling a Repo changes what the poller fetches") + } + if got := tr.expandedRepos(); len(got) != 1 || got[0] != "o/a" { + t.Errorf("expandedRepos = %v", got) + } + selectKind(t, &tr, kindPR, 1) + if tr.toggle() { + t.Error("toggling a PR does not change what the poller fetches") + } +} + +func TestTreeStackRowRunsCoverEveryMember(t *testing.T) { + bottom := stackedPR(10, aggregator.StoplightGreen, 12, 1, 2, "b") + bottom.Runs = []githubclient.WorkflowRun{{WorkflowName: "CI", RunID: 1}} + top := stackedPR(11, aggregator.StoplightRed, 12, 2, 2, "t") + top.Runs = []githubclient.WorkflowRun{{WorkflowName: "CI", RunID: 2}} + + tr := newTree(state.New([]state.RepoState{treeRepo("a", aggregator.StoplightRed, top, bottom)})) + tr.toggle() + selectKind(t, &tr, kindStack, 12) + n, _ := tr.selected() + runs := n.runs() + if len(runs) != 2 || runs[0].RunID != 1 || runs[1].RunID != 2 { + t.Errorf("stack runs = %+v, want bottom then top", runs) + } +} + +func TestTreeEmpty(t *testing.T) { + tr := newTree(state.New(nil)) + if _, ok := tr.selected(); ok { + t.Error("an empty tree has no selection") + } + if tr.toggle() { + t.Error("toggling nothing changes nothing") + } + tr.move(1) + tr.move(-1) +}