Add problems-only view (!) and draft the write actions

`!` reduces the tree to submodules with issues (or unknown state) plus
their ancestors, reusing the filter's reduction. Adds docs/actions.md as
a draft spec for restore and stage pin.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 12:12:11 +02:00
co-authored by Claude Sonnet 5.5
parent 72c060882b
commit 928e76154f
10 changed files with 484 additions and 25 deletions
+48 -8
View File
@@ -8,7 +8,7 @@ import (
// SetFilter shows only the submodules whose path contains term (ignoring
// case), plus their ancestors, which are shown expanded. An empty term
// removes the filter.
// removes the filter. It combines with SetProblemsOnly.
//
// If the selected submodule is filtered out, the next visible one is
// selected (the previous one if there is none after it, none if nothing
@@ -21,15 +21,46 @@ func (m *Model) SetFilter(term string) {
}
old := m.cursor
m.filter = term
if term == "" {
m.narrow(old)
}
// SetProblemsOnly shows only the submodules with issues, plus their
// ancestors, which are shown expanded. A submodule whose status is loading or
// failed to load counts as a problem, since its state is unknown. The
// selection behaves as with SetFilter.
func (m *Model) SetProblemsOnly(on bool) {
if on == m.problemsOnly {
return
}
old := m.cursor
m.problemsOnly = on
m.narrow(old)
}
// ProblemsOnly reports whether only submodules with issues are shown.
func (m Model) ProblemsOnly() bool { return m.problemsOnly }
// Refit keeps the selection visible after statuses changed, which can make a
// submodule enter or leave the problems-only view.
func (m *Model) Refit() {
if m.problemsOnly {
m.reselect(m.cursor)
m.clampOffset()
}
}
// narrow fixes the selection after the filter or the problems-only view
// changed.
func (m *Model) narrow(old *submodule.Submodule) {
if m.narrowed() {
m.reselect(old)
} else {
for p := parentOf(old); p != nil; p = p.Parent {
m.expanded[p.Path] = true
}
if m.cursor == nil {
m.cursor = m.firstNode()
}
} else {
m.reselect(old)
}
m.clampOffset()
}
@@ -37,6 +68,9 @@ func (m *Model) SetFilter(term string) {
// Filter returns the active filter term in lower case.
func (m Model) Filter() string { return m.filter }
// narrowed reports whether the tree is reduced to matches.
func (m Model) narrowed() bool { return m.filter != "" || m.problemsOnly }
func parentOf(s *submodule.Submodule) *submodule.Submodule {
if s == nil {
return nil
@@ -106,10 +140,10 @@ func (m *Model) appendAll(out *[]row, mods []*submodule.Submodule, depth int) {
}
}
// isExpanded reports whether the children of s are shown. While filtering,
// isExpanded reports whether the children of s are shown. While narrowed,
// that is the case if a match is below s, whatever the user expanded.
func (m *Model) isExpanded(s *submodule.Submodule) bool {
if m.filter == "" {
if !m.narrowed() {
return m.expanded[s.Path]
}
for _, c := range s.Children {
@@ -122,7 +156,7 @@ func (m *Model) isExpanded(s *submodule.Submodule) bool {
// filtered returns the submodules of mods that pass the filter.
func (m *Model) filtered(mods []*submodule.Submodule) []*submodule.Submodule {
if m.filter == "" {
if !m.narrowed() {
return mods
}
var out []*submodule.Submodule
@@ -136,7 +170,7 @@ func (m *Model) filtered(mods []*submodule.Submodule) []*submodule.Submodule {
// shown reports whether s matches the filter or has a descendant that does.
func (m *Model) shown(s *submodule.Submodule) bool {
if strings.Contains(strings.ToLower(s.Path), m.filter) {
if strings.Contains(strings.ToLower(s.Path), m.filter) && (!m.problemsOnly || hasProblem(s)) {
return true
}
for _, c := range s.Children {
@@ -146,3 +180,9 @@ func (m *Model) shown(s *submodule.Submodule) bool {
}
return false
}
// hasProblem reports whether s has an issue or an unknown state (loading, or
// failed to load, like HiddenIn counts it).
func hasProblem(s *submodule.Submodule) bool {
return s.Status == nil || len(s.Status.Issues()) > 0
}
+13 -7
View File
@@ -27,11 +27,13 @@ type Model struct {
roots []*submodule.Submodule
expanded map[string]bool // keyed by Submodule.Path; kept when ancestors collapse
filter string // lower-case; empty if not filtering
cursor *submodule.Submodule
offset int // index of the first visible row
width int
height int
spinner string
// problemsOnly hides submodules without issues; it combines with filter.
problemsOnly bool
cursor *submodule.Submodule
offset int // index of the first visible row
width int
height int
spinner string
}
func New(t theme.Theme) Model {
@@ -365,8 +367,12 @@ func (m Model) View() string {
lines = append(lines, m.nodeLine(r, nameColW, refW))
}
}
if len(rows) == 0 && m.filter != "" {
lines = append(lines, theme.Fg(m.theme.Muted).Width(m.width).Render(" no submodule matches"))
if len(rows) == 0 && m.narrowed() {
none := " no submodule matches"
if m.filter == "" {
none = " no problems"
}
lines = append(lines, theme.Fg(m.theme.Muted).Width(m.width).Render(none))
}
for len(lines) < m.height {
lines = append(lines, strings.Repeat(" ", m.width))
+80
View File
@@ -411,3 +411,83 @@ func TestFilterKeepsRoot(t *testing.T) {
t.Error("closing the filter should keep the root")
}
}
// healthy gives every submodule of the fixture an issue-free status, then
// marks the given paths as changed.
func healthy(t *testing.T, m *Model, changed ...string) {
t.Helper()
submodule.Walk(m.roots, func(s *submodule.Submodule) {
s.Status = &submodule.Status{Pin: submodule.PinInfo{Pushed: true}}
for _, p := range changed {
if s.Path == p {
s.Status.Worktree = submodule.WorktreeModified
}
}
})
}
func TestProblemsOnlyShowsIssuesWithAncestors(t *testing.T) {
m := newTree(t)
healthy(t, m, "d")
m.SetProblemsOnly(true)
want := []string{"src/", "b", "modules/", "c", "d"}
if got := visible(m); !equal(got, want) {
t.Fatalf("rows = %v, want %v", got, want)
}
assertCursor(t, m, "b") // a was selected and is healthy: next available
m.Select("d")
m.SetProblemsOnly(false)
assertCursor(t, m, "d")
if got := visible(m); len(got) != 8 {
t.Fatalf("rows = %v, want all expanded to d", got)
}
}
func TestProblemsOnlyKeepsUnknownStates(t *testing.T) {
m := newTree(t)
healthy(t, m)
f := m.roots[2]
f.Status, f.Err = nil, os.ErrNotExist // failed to load
m.roots[0].Status = nil // still loading
m.SetProblemsOnly(true)
if got, want := visible(m), []string{"a", "src/", "f"}; !equal(got, want) {
t.Fatalf("rows = %v, want %v", got, want)
}
}
func TestProblemsOnlyWithoutProblemsKeepsRoot(t *testing.T) {
m := New(theme.Default())
m.SetRoot(&submodule.Submodule{Status: &submodule.Status{Root: true}})
m.SetRoots(fixture())
m.SetSize(80, 20)
healthy(t, &m)
m.SetProblemsOnly(true)
if len(m.rows()) != 0 || !m.RootSelected() {
t.Fatalf("rows %v, root selected %v", visible(&m), m.RootSelected())
}
m.Down() // must not panic
}
func TestProblemsOnlyCombinesWithFilter(t *testing.T) {
m := newTree(t)
healthy(t, m, "d", "f")
m.SetProblemsOnly(true)
m.SetFilter("f")
if got, want := visible(m), []string{"src/", "f"}; !equal(got, want) {
t.Fatalf("rows = %v, want %v", got, want)
}
m.SetFilter("")
if !m.ProblemsOnly() || len(visible(m)) != 6 {
t.Fatalf("closing the filter keeps problems only: %v", visible(m))
}
}
func TestRefitMovesSelectionWhenProblemDisappears(t *testing.T) {
m := newTree(t)
healthy(t, m, "a", "f")
m.SetProblemsOnly(true)
assertCursor(t, m, "a")
m.roots[0].Status.Worktree = submodule.WorktreeClean
m.Refit()
assertCursor(t, m, "f")
}