From e0f4ac823b387a4016aea8a9ff3e295059d372f8 Mon Sep 17 00:00:00 2001 From: Simon Franken Date: Thu, 22 Oct 2026 10:14:35 +0200 Subject: [PATCH] Show staged pins in the tree A staged pin was only visible in the detail pane, so the header's "n pin staged" could not be traced to a row. The status column now shows a blue "staged" instead of "in sync", a blue marker after an issue word, and the marker on collapsed rows with a staged pin below. It is still not an issue and not counted in +n. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 5 +++-- docs/decisions.md | 12 ++++++++++++ docs/requirements.md | 5 +++-- docs/state-model.md | 2 ++ docs/ux-guide.md | 8 ++++---- internal/submodule/status.go | 12 ++++++++---- internal/submodule/status_test.go | 6 +++--- internal/tui/detail/detail.go | 7 +++++-- internal/tui/theme/issues.go | 4 ++++ internal/tui/tree/tree.go | 14 ++++++++++++-- 10 files changed, 56 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index c0825bb..809aafe 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,9 @@ the questions you actually have: - **Branch plus distance, not hashes.** `origin/main ↓2` says more than `b7c8d9e`. A pin on a feature branch shows that branch. - **Problems are loud, healthy is quiet.** Submodules without issues are - muted `in sync`. A collapsed parent counts the problems hidden below it - (`·1`). + muted `in sync`; a staged pin shows as blue `staged` (or a `●` after an + issue word). A collapsed parent counts the problems hidden below it + (`·1`) and shows `●` if a staged pin is hidden. - **Words, not symbols.** Every state is a short word (`unpushed`, `conflict`, `missing`, `off pin`, `changed`), so nothing depends on color alone. - **Live.** Fetches in the background on startup and follows changes you make diff --git a/docs/decisions.md b/docs/decisions.md index 0dc18d0..82e0a58 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -246,3 +246,15 @@ usual keys. A filtered-out selection moves to the next visible submodule (previous if none follows) instead of the first, so the cursor stays near where it was. This adds the `bubbles/textinput` dependency (and its `atotto/clipboard` import). + +## Staged pin is shown in the tree, but is not an issue + +A staged pin was only visible in the detail pane, so the header's `1 pin +staged` could not be traced to a row, and `in sync nothing to do` was +wrong for a submodule whose pin change still has to be committed. The tree +now shows it in the status column: blue `staged` replaces `in sync`; next to +an issue word it is a blue `●` (an issue wins the word, since the cell holds +one word, D3). It is not counted in `+n`, because `+n` and the header count +issues and a staged pin stays quiet (D4). A collapsed row shows `●` if a +staged pin is hidden below it (`Hidden.Staged`), placed before the `·k` +counts so truncation of the 16-column cell cuts the counts first. diff --git a/docs/requirements.md b/docs/requirements.md index cbfe76d..d4e3bb1 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -35,7 +35,7 @@ commit hashes. It doesn't answer the questions users actually have: e.g. `[2]`. - **F6** A collapsed submodule shows the number of issues **hidden** in all its descendants (`·1 ·2`), one count per level: errors in red, then - warnings in yellow. + warnings in yellow. A staged pin below shows as a blue `●` before them. - **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). @@ -44,7 +44,8 @@ commit hashes. It doesn't answer the questions users actually have: - **F10** Each submodule shows at most **one** state word: the most severe issue, plus `+n` if there are more. Submodules without issues show a - muted `in sync`. + muted `in sync`, or a blue `staged` if their pin change is staged. With + issues, a staged pin adds a blue `●` after the word. See [state-model.md](state-model.md) for the full rules. - **F11** Each submodule shows its pin as a ref (`origin/main`, a tag, or a short SHA as a fallback) and the distance to that ref (`↑n`, `↓n`, `↑n↓m`). diff --git a/docs/state-model.md b/docs/state-model.md index d11244b..9362670 100644 --- a/docs/state-model.md +++ b/docs/state-model.md @@ -82,6 +82,8 @@ Rules: changed. - **Not issues** (quiet): in sync, pin behind/ahead/diverged but pushed, staged pin, detached HEAD on the pin. They are shown as information only. + A staged pin is also visible in the tree (`staged` or a `●` marker), see + [ux-guide.md](ux-guide.md#tree-row), but is never counted as an issue. - A loading error is shown as `error` (Danger) in the UI; it is not an issue kind. diff --git a/docs/ux-guide.md b/docs/ux-guide.md index 19a868f..5950261 100644 --- a/docs/ux-guide.md +++ b/docs/ux-guide.md @@ -63,7 +63,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 | 16 | issue word, `+n`, `·k` hidden issues per level (red, then yellow); muted `in sync` without issues; spinner while loading | +| status | 16 | issue word, `+n`, `●` staged pin, `·k` hidden issues per level (red, then yellow); blue `staged` or 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` | @@ -77,8 +77,8 @@ indent ▸ name [n] status ref delta 1. Directory (muted) and name (bold) 2. One line per issue: word (colored, 10 columns) + explanation; - `in sync nothing to do` if there are none; `staged` and - `detached HEAD on pin` as information + `in sync nothing to do` if there are none and no pin is staged; + `staged` and `detached HEAD on pin` as information 3. `Changes` (only with uncommitted changes), see [Change preview](#change-preview) 4. `Ref` with distance and `via `; `Fetched ` of the submodule; @@ -165,7 +165,7 @@ colors in components. |---|---|---| | Danger | ANSI `1` red, bold | `unpushed`, `conflict`, `error`, `↑n` of an unpushed pin | | Warn | ANSI `3` yellow | `missing`, `off pin`, `changed`, differing checkout SHA | -| Info | ANSI `4` blue | `staged` | +| Info | ANSI `4` blue | `staged`, the `●` staged marker | | Primary | ANSI `6` cyan | branch names, commit hashes, spinner | | Muted | ANSI `8` grey | quiet rows, directory headers, arrows, labels | | Text | terminal default | normal text | diff --git a/internal/submodule/status.go b/internal/submodule/status.go index c6ad2eb..c10bafd 100644 --- a/internal/submodule/status.go +++ b/internal/submodule/status.go @@ -60,13 +60,14 @@ func (s *Status) Issues() []Issue { return out } -// Hidden counts issues below a submodule per level. -type Hidden struct{ Warn, Danger int } +// Hidden counts issues below a submodule per level, plus staged pins (which +// are not issues). +type Hidden struct{ Warn, Danger, Staged int } -// Total is the number of issues of all levels. +// Total is the number of issues of all levels; staged pins are not counted. func (h Hidden) Total() int { return h.Warn + h.Danger } -// HiddenIssues counts the issues of all loaded descendants per level. A +// HiddenIssues counts the issues and staged pins of all loaded descendants. A // descendant whose load failed counts as one danger, since its state is // unknown. func (m *Submodule) HiddenIssues() Hidden { @@ -75,6 +76,9 @@ func (m *Submodule) HiddenIssues() Hidden { if c.Status == nil && c.Err != nil { h.Danger++ } + if c.Status != nil && c.Status.PinStaged { + h.Staged++ + } for _, is := range c.Status.Issues() { switch is.Level { case LevelDanger: diff --git a/internal/submodule/status_test.go b/internal/submodule/status_test.go index 3037eb2..6a4ad3b 100644 --- a/internal/submodule/status_test.go +++ b/internal/submodule/status_test.go @@ -39,15 +39,15 @@ func TestIssues(t *testing.T) { func TestHiddenIssues(t *testing.T) { ok := &Status{Pin: PinInfo{Pushed: true}} - dirty := &Status{Worktree: WorktreeModified, Pin: PinInfo{Pushed: true}} + dirty := &Status{Worktree: WorktreeModified, Pin: PinInfo{Pushed: true}, PinStaged: true} unpushed := &Status{} grandchild := &Submodule{Status: unpushed} child := &Submodule{Status: dirty, Children: []*Submodule{grandchild}} loading := &Submodule{} root := &Submodule{Status: ok, Children: []*Submodule{child, {Status: ok}, loading}} - if got := root.HiddenIssues(); got != (Hidden{Warn: 1, Danger: 1}) { - t.Errorf("HiddenIssues() = %+v; want 1 warn, 1 danger", got) + if got := root.HiddenIssues(); got != (Hidden{Warn: 1, Danger: 1, Staged: 1}) { + t.Errorf("HiddenIssues() = %+v; want 1 warn, 1 danger, 1 staged", got) } } diff --git a/internal/tui/detail/detail.go b/internal/tui/detail/detail.go index 41e5a3b..f3b3aab 100644 --- a/internal/tui/detail/detail.go +++ b/internal/tui/detail/detail.go @@ -97,11 +97,11 @@ func (m Model) lines(s *submodule.Submodule) []string { for _, is := range issues { out = append(out, t.LevelStyle(is.Level, lipgloss.NewStyle()).Width(labelWidth).Render(theme.IssueWord(is.Kind))+text.Render(is.Detail)) } - if len(issues) == 0 { + if len(issues) == 0 && !st.PinStaged { out = append(out, label(theme.InSync)+muted.Render("nothing to do")) } if st.PinStaged { - out = append(out, theme.Fg(t.Info).Width(labelWidth).Render("staged")+text.Render("pin change staged, not committed")) + out = append(out, theme.Fg(t.Info).Width(labelWidth).Render(theme.Staged)+text.Render("pin change staged, not committed")) } if st.Checkout == submodule.CheckoutDetached { out = append(out, label("")+muted.Render("detached HEAD on pin")) @@ -170,6 +170,9 @@ func (m Model) lines(s *submodule.Submodule) []string { if h.Warn > 0 { parts = append(parts, t.LevelStyle(submodule.LevelWarn, lipgloss.NewStyle()).Render(fmt.Sprintf("%d warning(s)", h.Warn))) } + if h.Staged > 0 { + parts = append(parts, theme.Fg(t.Info).Render(fmt.Sprintf("%d %s", h.Staged, theme.Staged))) + } if len(parts) > 0 { children += muted.Render(" · ") + strings.Join(parts, muted.Render(", ")) + muted.Render(" below") } diff --git a/internal/tui/theme/issues.go b/internal/tui/theme/issues.go index 7287435..e057ddd 100644 --- a/internal/tui/theme/issues.go +++ b/internal/tui/theme/issues.go @@ -12,6 +12,10 @@ import ( // InSync is the word for a submodule without issues. const InSync = "in sync" +// Staged is the word for a pin change staged in the superproject. It is not +// an issue: it replaces InSync, or follows an issue word as a marker. +const Staged = "staged" + // IssueWord is the short label for an issue kind shown in the tree. func IssueWord(k submodule.IssueKind) string { switch k { diff --git a/internal/tui/tree/tree.go b/internal/tui/tree/tree.go index 18d4a60..6038c98 100644 --- a/internal/tui/tree/tree.go +++ b/internal/tui/tree/tree.go @@ -410,7 +410,12 @@ func (m Model) nodeLine(r row, nameColW, refW int) string { nameCell := seg(textColor, name) + seg(t.Muted, count) + pad(nameW-lipgloss.Width(name+count)) // status: the most severe issue as a word, "+n" for further issues; - // a muted "in sync" without issues, so an empty cell never looks broken + // a muted "in sync" without issues, so an empty cell never looks broken. + // A staged pin is no issue: it replaces "in sync", or follows the issue + // as a blue marker (also for staged pins below a collapsed row). + staged := s.Status != nil && s.Status.PinStaged + h := s.HiddenIssues() + stagedMark := staged && len(issues) > 0 var status string switch { case s.Err != nil: @@ -422,10 +427,15 @@ func (m Model) nodeLine(r row, nameColW, refW int) string { if len(issues) > 1 { status += seg(t.Muted, fmt.Sprintf(" +%d", len(issues)-1)) } + case staged: + status = seg(t.Info, theme.Staged) default: status = seg(t.Muted, theme.InSync) } - if h := s.HiddenIssues(); collapsed && h.Total() > 0 { + if stagedMark || collapsed && h.Staged > 0 { + status += pad(1) + seg(t.Info, t.Glyphs.Staged) + } + if collapsed && h.Total() > 0 { for _, c := range []struct { level submodule.Level n int