From 17151a578d39d427f600f242e7129cfc6a9ff4d9 Mon Sep 17 00:00:00 2001 From: Simon Franken Date: Wed, 21 Oct 2026 20:21:00 +0200 Subject: [PATCH] Split hidden issue counts by level Co-Authored-By: Claude Sonnet 5.5 --- docs/decisions.md | 8 +++++--- docs/requirements.md | 3 ++- docs/state-model.md | 5 +++-- docs/ux-guide.md | 2 +- internal/submodule/status.go | 28 +++++++++++++++++++--------- internal/submodule/status_test.go | 9 ++++----- internal/tui/detail/detail.go | 12 ++++++++++-- internal/tui/tree/tree.go | 18 +++++++++++++----- 8 files changed, 57 insertions(+), 28 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index b02967d..acee918 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -53,9 +53,11 @@ everyone else's checkout. **Context.** Only the first level is shown by default, so nested problems would be invisible. -**Decision.** Collapsed parents show `·k` (issues in all descendants), colored -by the worst one. Problems in a child do **not** mark the parent itself as -`changed`. +**Decision.** Collapsed parents show `·k` (issues in all descendants), one +count per level: red for danger, then yellow for warn (amended: a single +count in the worst color made 1 error + 1 warning look like 2 errors; the +status column grew from 14 to 16 for the second count). Problems in a child +do **not** mark the parent itself as `changed`. ## D8 — Collapsed by default, branch-first navigation diff --git a/docs/requirements.md b/docs/requirements.md index 59c588f..f72d784 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -34,7 +34,8 @@ commit hashes. It doesn't answer the questions users actually have: - **F5** A collapsed submodule shows the number of its **direct** children, e.g. `[2]`. - **F6** A collapsed submodule shows the number of issues **hidden** in all - its descendants (`·2`), colored by the most severe hidden issue. + its descendants (`·1 ·2`), one count per level: errors in red, then + warnings in yellow. - **F7** Expansion state of descendants is remembered when a parent is collapsed and restored when it is expanded again (except after "collapse all", which resets it). diff --git a/docs/state-model.md b/docs/state-model.md index ec8d712..0d7ee37 100644 --- a/docs/state-model.md +++ b/docs/state-model.md @@ -96,8 +96,9 @@ Rules: ### Hidden issues `Submodule.HiddenIssues()` counts the issues of **all** loaded descendants -and returns the most severe level. A collapsed submodule shows this as `·k` -in that level's color, so a danger can never hide inside a collapsed subtree. +per level (`Hidden{Warn, Danger}`). A collapsed submodule shows them as `·k` +per level, red first, then yellow, so a danger can never hide inside a +collapsed subtree and a warning is never mistaken for one. A descendant whose load failed (`error`) counts as one danger. ## Resolving a pin to a ref diff --git a/docs/ux-guide.md b/docs/ux-guide.md index 2689fe6..2d4238f 100644 --- a/docs/ux-guide.md +++ b/docs/ux-guide.md @@ -47,7 +47,7 @@ indent ▸ name [n] status ref delta | indent | 2 per depth | | | toggle | 3 | `▸` collapsed, `▾` expanded, blank for leaves | | name | fits the widest visible name | name, `[n]` = direct children (collapsed only) | -| status | 14 | issue word, `+n`, `·k` hidden issues; muted `in sync` without issues; spinner while loading | +| status | 16 | issue word, `+n`, `·k` hidden issues per level (red, then yellow); muted `in sync` without issues; spinner while loading | | ref | widest ref, max 22 | resolved ref; muted short SHA if unresolved | | delta | 6 | `↑n`, `↓n`, `↑n↓m` | diff --git a/internal/submodule/status.go b/internal/submodule/status.go index 19f6040..c6ad2eb 100644 --- a/internal/submodule/status.go +++ b/internal/submodule/status.go @@ -60,21 +60,31 @@ func (s *Status) Issues() []Issue { return out } -// HiddenIssues counts the issues of all loaded descendants and returns the -// most severe level among them. A descendant whose load failed counts as one -// danger, since its state is unknown. -func (m *Submodule) HiddenIssues() (count int, worst Level) { +// Hidden counts issues below a submodule per level. +type Hidden struct{ Warn, Danger int } + +// Total is the number of issues of all levels. +func (h Hidden) Total() int { return h.Warn + h.Danger } + +// HiddenIssues counts the issues of all loaded descendants per level. A +// descendant whose load failed counts as one danger, since its state is +// unknown. +func (m *Submodule) HiddenIssues() Hidden { + var h Hidden Walk(m.Children, func(c *Submodule) { if c.Status == nil && c.Err != nil { - count++ - worst = max(worst, LevelDanger) + h.Danger++ } for _, is := range c.Status.Issues() { - count++ - worst = max(worst, is.Level) + switch is.Level { + case LevelDanger: + h.Danger++ + case LevelWarn: + h.Warn++ + } } }) - return count, worst + return h } // Walk calls fn for every submodule in depth-first order. diff --git a/internal/submodule/status_test.go b/internal/submodule/status_test.go index d4af037..3037eb2 100644 --- a/internal/submodule/status_test.go +++ b/internal/submodule/status_test.go @@ -46,9 +46,8 @@ func TestHiddenIssues(t *testing.T) { loading := &Submodule{} root := &Submodule{Status: ok, Children: []*Submodule{child, {Status: ok}, loading}} - count, worst := root.HiddenIssues() - if count != 2 || worst != LevelDanger { - t.Errorf("HiddenIssues() = %d, %v; want 2, LevelDanger", count, worst) + if got := root.HiddenIssues(); got != (Hidden{Warn: 1, Danger: 1}) { + t.Errorf("HiddenIssues() = %+v; want 1 warn, 1 danger", got) } } @@ -75,7 +74,7 @@ func TestHiddenIssuesCountLoadErrors(t *testing.T) { bad := &Submodule{Err: errors.New("boom")} loading := &Submodule{} parent := &Submodule{Children: []*Submodule{bad, loading}} - if n, w := parent.HiddenIssues(); n != 1 || w != LevelDanger { - t.Errorf("got %d, %v; want 1, danger", n, w) + if got := parent.HiddenIssues(); got != (Hidden{Danger: 1}) { + t.Errorf("got %+v; want 1 danger", got) } } diff --git a/internal/tui/detail/detail.go b/internal/tui/detail/detail.go index 49fd382..016534c 100644 --- a/internal/tui/detail/detail.go +++ b/internal/tui/detail/detail.go @@ -138,8 +138,16 @@ func (m Model) lines(s *submodule.Submodule) []string { out = append(out, "") if n := len(s.Children); n > 0 { children := fmt.Sprintf("%d direct", n) - if k, worst := s.HiddenIssues(); k > 0 { - children += t.LevelStyle(worst, lipgloss.NewStyle()).Render(fmt.Sprintf(" · %d issue(s) below", k)) + h := s.HiddenIssues() + var parts []string + if h.Danger > 0 { + parts = append(parts, t.LevelStyle(submodule.LevelDanger, lipgloss.NewStyle()).Render(fmt.Sprintf("%d error(s)", h.Danger))) + } + if h.Warn > 0 { + parts = append(parts, t.LevelStyle(submodule.LevelWarn, lipgloss.NewStyle()).Render(fmt.Sprintf("%d warning(s)", h.Warn))) + } + if len(parts) > 0 { + children += muted.Render(" · ") + strings.Join(parts, muted.Render(", ")) + muted.Render(" below") } out = append(out, label("Children")+children) } diff --git a/internal/tui/tree/tree.go b/internal/tui/tree/tree.go index 7e81868..9385f44 100644 --- a/internal/tui/tree/tree.go +++ b/internal/tui/tree/tree.go @@ -15,7 +15,7 @@ import ( ) const ( - statusWidth = 14 // "unpushed +1 ·2" + statusWidth = 16 // "unpushed +1 ·2 ·3" deltaWidth = 6 maxRefWidth = 22 ) @@ -414,11 +414,19 @@ func (m Model) nodeLine(r row, nameColW, refW int) string { default: status = seg(t.Muted, theme.InSync) } - if k, worst := s.HiddenIssues(); collapsed && k > 0 { - if status != "" { - status += pad(1) + if h := s.HiddenIssues(); collapsed && h.Total() > 0 { + for _, c := range []struct { + level submodule.Level + n int + }{{submodule.LevelDanger, h.Danger}, {submodule.LevelWarn, h.Warn}} { + if c.n == 0 { + continue + } + if status != "" { + status += pad(1) + } + status += t.LevelStyle(c.level, base).Render(fmt.Sprintf("%s%d", t.Glyphs.Problems, c.n)) } - status += t.LevelStyle(worst, base).Render(fmt.Sprintf("%s%d", t.Glyphs.Problems, k)) } status = ansi.Truncate(status, statusWidth, "") status += pad(statusWidth - lipgloss.Width(status))