Archived
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:
+84
-7
@@ -4,10 +4,12 @@ package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
zone "github.com/lrstanley/bubblezone"
|
||||
@@ -55,6 +57,10 @@ type Model struct {
|
||||
detail detail.Model
|
||||
footer footer.Model
|
||||
spinner spinner.Model
|
||||
filter textinput.Model
|
||||
// filtering is true while the filter line is shown; the filter applies
|
||||
// exactly then. The input is focused while typing and blurred after enter.
|
||||
filtering bool
|
||||
|
||||
width, height int
|
||||
showDetail bool
|
||||
@@ -64,6 +70,12 @@ func New(ctx context.Context, p submodule.Provider, opts Options) Model {
|
||||
t := theme.Default()
|
||||
k := defaultKeys()
|
||||
sp := spinner.New(spinner.WithSpinner(spinner.MiniDot))
|
||||
fi := textinput.New()
|
||||
fi.Prompt = "/ "
|
||||
fi.PromptStyle = theme.Fg(t.Primary)
|
||||
fi.Cursor.Style = theme.Fg(t.Primary)
|
||||
fi.Placeholder = "filter by name or path"
|
||||
fi.PlaceholderStyle = theme.Fg(t.Muted)
|
||||
m := Model{
|
||||
ctx: ctx,
|
||||
provider: p,
|
||||
@@ -76,6 +88,7 @@ func New(ctx context.Context, p submodule.Provider, opts Options) Model {
|
||||
detail: detail.New(t, k.Submodule()),
|
||||
footer: footer.New(t, k),
|
||||
spinner: sp,
|
||||
filter: fi,
|
||||
}
|
||||
m.fetcher, _ = p.(submodule.Fetcher)
|
||||
m.watcher, _ = p.(submodule.Watcher)
|
||||
@@ -309,7 +322,12 @@ func (m Model) changed(msg changeMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
k := m.keys
|
||||
if m.filter.Focused() {
|
||||
return m.typeFilter(msg)
|
||||
}
|
||||
switch {
|
||||
case m.filtering && msg.Type == tea.KeyEsc:
|
||||
m.closeFilter()
|
||||
case key.Matches(msg, k.Quit):
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, k.Help):
|
||||
@@ -355,7 +373,11 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, m.reloadTree(true)
|
||||
case key.Matches(msg, k.Copy):
|
||||
return m.copyPath()
|
||||
case key.Matches(msg, k.Update, k.Bump, k.Filter, k.Issues):
|
||||
case key.Matches(msg, k.Filter):
|
||||
m.filtering = true
|
||||
m.layout()
|
||||
return m, m.filter.Focus()
|
||||
case key.Matches(msg, k.Update, k.Bump, k.Issues):
|
||||
cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype")
|
||||
m.layout()
|
||||
return m, cmd
|
||||
@@ -363,6 +385,57 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// typeFilter handles a key while the filter input has the focus: text edits
|
||||
// the filter, enter hands the keys back to the tree (the filter stays), esc
|
||||
// closes the filter. Arrows still move the selection.
|
||||
func (m Model) typeFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC:
|
||||
return m, tea.Quit
|
||||
case tea.KeyEsc:
|
||||
m.closeFilter()
|
||||
case tea.KeyEnter:
|
||||
m.filter.Blur()
|
||||
case tea.KeyUp:
|
||||
m.tree.Up()
|
||||
case tea.KeyDown:
|
||||
m.tree.Down()
|
||||
case tea.KeyPgUp:
|
||||
m.tree.PageUp()
|
||||
case tea.KeyPgDown:
|
||||
m.tree.PageDown()
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
m.filter, cmd = m.filter.Update(msg)
|
||||
m.tree.SetFilter(m.filter.Value())
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// closeFilter hides the filter line and removes the filter; the selection stays.
|
||||
func (m *Model) closeFilter() {
|
||||
m.filtering = false
|
||||
m.filter.Blur()
|
||||
m.filter.Reset()
|
||||
m.tree.SetFilter("")
|
||||
m.layout()
|
||||
}
|
||||
|
||||
// filterView is the line above the footer: the input and, right-aligned, the
|
||||
// keys that apply.
|
||||
func (m Model) filterView() string {
|
||||
hint := "enter navigate • esc close"
|
||||
if !m.filter.Focused() {
|
||||
hint = "/ edit • esc close"
|
||||
}
|
||||
hint = theme.Fg(m.theme.Muted).Render(hint)
|
||||
m.filter.Width = max(m.width-lipgloss.Width(hint)-6, 1)
|
||||
in := m.filter.View()
|
||||
gap := max(m.width-2-lipgloss.Width(in)-lipgloss.Width(hint), 1)
|
||||
return lipgloss.NewStyle().Width(m.width).Padding(0, 1).Render(in + strings.Repeat(" ", gap) + hint)
|
||||
}
|
||||
|
||||
// copyPath copies the absolute directory of the selected submodule to the
|
||||
// clipboard, or its path in the superproject if the provider can't tell.
|
||||
func (m Model) copyPath() (tea.Model, tea.Cmd) {
|
||||
@@ -384,7 +457,11 @@ func (m Model) copyPath() (tea.Model, tea.Cmd) {
|
||||
// layout distributes the terminal size among the components.
|
||||
func (m *Model) layout() {
|
||||
m.footer.SetWidth(m.width)
|
||||
bodyHeight := max(m.height-headerHeight-lipgloss.Height(m.footer.View()), 0)
|
||||
bodyHeight := m.height - headerHeight - lipgloss.Height(m.footer.View())
|
||||
if m.filtering {
|
||||
bodyHeight--
|
||||
}
|
||||
bodyHeight = max(bodyHeight, 0)
|
||||
|
||||
m.showDetail = m.width >= detailHideBelow
|
||||
treeWidth := m.width
|
||||
@@ -415,9 +492,9 @@ func (m Model) View() string {
|
||||
body = lipgloss.JoinHorizontal(lipgloss.Top, body, pane)
|
||||
}
|
||||
|
||||
return zone.Scan(lipgloss.JoinVertical(lipgloss.Left,
|
||||
header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width),
|
||||
body,
|
||||
m.footer.View(),
|
||||
))
|
||||
parts := []string{header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width), body}
|
||||
if m.filtering {
|
||||
parts = append(parts, m.filterView())
|
||||
}
|
||||
return zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 := " "
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user