Add / filter for the tree

A text input above the footer filters submodules by path (case-insensitive).
Matches keep their ancestors. A filtered-out selection moves to the next
visible submodule; esc closes the input and removes the filter.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 21:15:24 +02:00
co-authored by Claude Sonnet 5.5
parent 8aa12774eb
commit f5fabfd093
11 changed files with 351 additions and 21 deletions
+141
View File
@@ -0,0 +1,141 @@
package tree
import (
"strings"
"submarine/internal/submodule"
)
// SetFilter shows only the submodules whose path contains term (ignoring
// case), plus their ancestors, which are shown expanded. An empty term
// removes the filter.
//
// 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
// matches). Removing the filter keeps the selection visible by expanding its
// ancestors.
func (m *Model) SetFilter(term string) {
term = strings.ToLower(term)
if term == m.filter {
return
}
old := m.cursor
m.filter = term
if term == "" {
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()
}
// Filter returns the active filter term in lower case.
func (m Model) Filter() string { return m.filter }
func parentOf(s *submodule.Submodule) *submodule.Submodule {
if s == nil {
return nil
}
return s.Parent
}
// reselect makes sure the cursor is on a visible submodule. If old is not
// visible any more, the closest visible one after it in display order
// becomes the selection, else the closest one before it.
func (m *Model) reselect(old *submodule.Submodule) {
visible := map[*submodule.Submodule]bool{}
var first *submodule.Submodule
for _, r := range m.rows() {
if r.kind == rowNode {
visible[r.node] = true
if first == nil {
first = r.node
}
}
}
if old != nil && visible[old] {
m.cursor = old
return
}
m.cursor = first
if old == nil || first == nil {
return
}
var all []row
m.appendAll(&all, m.roots, 0)
i := 0
for ; i < len(all); i++ {
if all[i].node == old {
break
}
}
for j := i + 1; j < len(all); j++ {
if visible[all[j].node] {
m.cursor = all[j].node
return
}
}
for j := i - 1; j >= 0; j-- {
if visible[all[j].node] {
m.cursor = all[j].node
return
}
}
}
// appendAll lists every submodule in display order, whatever is expanded or
// filtered.
func (m *Model) appendAll(out *[]row, mods []*submodule.Submodule, depth int) {
for _, g := range groupByDir(mods) {
for _, s := range g.mods {
*out = append(*out, row{kind: rowNode, depth: depth, node: s})
m.appendAll(out, s.Children, depth+1)
}
}
}
// isExpanded reports whether the children of s are shown. While filtering,
// 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 == "" {
return m.expanded[s.Path]
}
for _, c := range s.Children {
if m.shown(c) {
return true
}
}
return false
}
// filtered returns the submodules of mods that pass the filter.
func (m *Model) filtered(mods []*submodule.Submodule) []*submodule.Submodule {
if m.filter == "" {
return mods
}
var out []*submodule.Submodule
for _, s := range mods {
if m.shown(s) {
out = append(out, s)
}
}
return out
}
// 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) {
return true
}
for _, c := range s.Children {
if m.shown(c) {
return true
}
}
return false
}
+2 -2
View File
@@ -30,7 +30,7 @@ func (m *Model) rows() []row {
// appendRows groups mods by the directory of their RelPath. Each group with a
// directory gets a header row; its submodules are indented one level deeper.
func (m *Model) appendRows(out *[]row, mods []*submodule.Submodule, depth int) {
for _, g := range groupByDir(mods) {
for _, g := range groupByDir(m.filtered(mods)) {
d := depth
if g.dir != "." {
*out = append(*out, row{kind: rowDir, depth: depth, label: g.dir + "/"})
@@ -38,7 +38,7 @@ func (m *Model) appendRows(out *[]row, mods []*submodule.Submodule, depth int) {
}
for _, s := range g.mods {
*out = append(*out, row{kind: rowNode, depth: d, node: s})
if m.expanded[s.Path] && len(s.Children) > 0 {
if m.isExpanded(s) && len(s.Children) > 0 {
m.appendRows(out, s.Children, d+1)
}
}
+18 -7
View File
@@ -25,6 +25,7 @@ type Model struct {
theme theme.Theme
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
@@ -42,9 +43,7 @@ func (m *Model) SetRoots(roots []*submodule.Submodule) {
m.expanded = map[string]bool{}
m.cursor = nil
m.offset = 0
if len(roots) > 0 {
m.cursor = m.rows()[m.firstNodeRow()].node
}
m.cursor = m.firstNode()
}
// ReplaceRoots swaps in a reloaded tree. Unlike SetRoots it keeps the
@@ -63,8 +62,8 @@ func (m *Model) ReplaceRoots(roots []*submodule.Submodule) {
break
}
}
if m.cursor == nil && len(roots) > 0 {
m.cursor = m.rows()[m.firstNodeRow()].node
if m.cursor == nil {
m.reselect(nil)
}
m.clampOffset()
}
@@ -210,6 +209,15 @@ func (m Model) cursorRow(rows []row) int {
return -1
}
func (m *Model) firstNode() *submodule.Submodule {
for _, r := range m.rows() {
if r.kind == rowNode {
return r.node
}
}
return nil
}
func (m Model) firstNodeRow() int {
for i, r := range m.rows() {
if r.kind == rowNode {
@@ -304,6 +312,9 @@ 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"))
}
for len(lines) < m.height {
lines = append(lines, strings.Repeat(" ", m.width))
}
@@ -324,7 +335,7 @@ func (m Model) nameColumnWidth(rows []row, refW int) int {
}
func (m Model) childCount(s *submodule.Submodule) string {
if len(s.Children) == 0 || m.expanded[s.Path] {
if len(s.Children) == 0 || m.isExpanded(s) {
return ""
}
return fmt.Sprintf(" [%d]", len(s.Children))
@@ -368,7 +379,7 @@ func (m Model) nodeLine(r row, nameColW, refW int) string {
}
pad := func(n int) string { return base.Render(strings.Repeat(" ", max(n, 0))) }
collapsed := len(s.Children) > 0 && !m.expanded[s.Path]
collapsed := len(s.Children) > 0 && !m.isExpanded(s)
// toggle marker
marker := " "
+56
View File
@@ -249,3 +249,59 @@ func TestExpandAllOnLeafKeepsSelection(t *testing.T) {
t.Fatal("all nodes with children should be expanded")
}
}
func TestFilterShowsMatchesWithAncestors(t *testing.T) {
m := newTree(t)
m.SetFilter("D")
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 gone: next available (an ancestor of the match)
}
func TestFilterSelectionMovesToNextThenPrevious(t *testing.T) {
m := newTree(t)
m.Select("a")
m.SetFilter("f")
assertCursor(t, m, "f")
m.SetFilter("")
m.Select("f")
m.SetFilter("a") // only a matches, it lies before f
assertCursor(t, m, "a")
}
func TestFilterWithoutMatchSelectsNothing(t *testing.T) {
m := newTree(t)
m.SetFilter("zzz")
if m.Selected() != nil || len(m.rows()) != 0 {
t.Fatalf("selected %v, rows %v", m.Selected(), visible(m))
}
m.Down() // must not panic
m.SetFilter("f")
assertCursor(t, m, "f")
}
func TestClearingFilterKeepsSelectionVisible(t *testing.T) {
m := newTree(t)
m.SetFilter("d")
m.Select("d")
m.SetFilter("")
assertCursor(t, m, "d")
want := []string{"a", "src/", "b", "modules/", "c", "d", "e", "f"}
if got := visible(m); !equal(got, want) {
t.Fatalf("rows = %v, want %v", got, want)
}
}
func equal(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}