diff --git a/docs/README.md b/docs/README.md index 0fc201d..f6a319a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,9 +32,9 @@ through a git provider (`internal/submodule/git`): `submarine [dir]`. It fetches in the background on startup (`--no-fetch` to skip; `f` to fetch again) and follows changes made in other shells. `submarine --mock` shows the built-in mock data (`internal/submodule/mock`). The actions `u`, `b`, -`/`, `!` only show a "not implemented" message. +`!` only show a "not implemented" message. Next steps, roughly in order: 1. Actions: checkout pin (`submodule update`), bump pin to tip, init. -2. Filter (`/`) and "problems only" (`!`) views. +2. "Problems only" (`!`) view; it can reuse the tree's filter. diff --git a/docs/decisions.md b/docs/decisions.md index c669595..0dc18d0 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -231,3 +231,18 @@ those preferred refs, by commits on either side: scanning every branch's merge base would be too slow. Cost: a pin on main far behind its tip shows a younger branch that contains it if that branch's tip is closer, so "n behind main" is no longer visible there; `via ` still tells which ref it is. + +## Filter: matches with ancestors, lives as long as the input + +`/` filters by case-insensitive substring of the submodule path. Matches +keep their ancestors, shown expanded, because a nested submodule alone +would lose its context; the user's fold state is not touched, so closing the +filter restores it (plus the selection's ancestors, so the selection stays +visible). The filter exists exactly while the input line is open, and `esc` +closes both, so there is no hidden filter that makes the list look +incomplete. While typing, `j`/`k`/`q` are text; `enter` returns the keys to +the tree and keeps the filter, so a filtered list can be navigated with the +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). diff --git a/docs/requirements.md b/docs/requirements.md index f72d784..cbfe76d 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -103,13 +103,18 @@ commit hashes. It doesn't answer the questions users actually have: - **F53** `f` fetches again; `r` reloads everything (e.g. after editing files, which is not watched). +- **F54** `/` filters the tree by name/path (case-insensitive substring). + A text input above the footer takes the term; `esc` closes it and removes + the filter. A selection that is filtered out moves to the next visible + submodule (none if nothing matches); closing the filter keeps the + selection. See [Filter](ux-guide.md#filter). + ### Actions (planned, not implemented) | Key | Action | |---|---| | `u` | Check out the pinned commit (`git submodule update`) | | `b` | Bump the pin to the tip of its ref | -| `/` | Filter by name/path | | `!` | Show only submodules with issues | ## Usage diff --git a/docs/ux-guide.md b/docs/ux-guide.md index 687d392..19a868f 100644 --- a/docs/ux-guide.md +++ b/docs/ux-guide.md @@ -34,6 +34,7 @@ | Header | 1 line | | Tree | Remaining width | | Detail pane | 40% of the width, at least 36 columns; hidden below 90 columns | +| Filter line | 1 line above the footer, only while filtering | | Footer | 1 line (more with full help) | ### Key bar and palette @@ -207,7 +208,8 @@ colors in components. | `f` | Fetch all repositories (does nothing while a fetch runs) | | `r` | Reload everything (e.g. after editing files) | | `c` | Copy the absolute directory of the selected submodule to the clipboard (OSC 52, so it also works over SSH; needs terminal support, in tmux `set -g set-clipboard on`). The footer confirms with `copied `. Without a local provider (mock) the path relative to the superproject is copied | -| `u`, `b`, `/`, `!` | Planned actions (see requirements) | +| `/` | Open the filter, see [Filter](#filter) | +| `u`, `b`, `!` | Planned actions (see requirements) | "First child" always means the first child **as displayed** (children are grouped by directory). @@ -216,6 +218,26 @@ Collapsing an ancestor of the selection moves the selection to that ancestor. The view scrolls so the selection is always visible; when the first submodule is selected, headers above it are shown too. +### Filter + +`/` shows a text input above the footer (`/ term`). While it is open the +tree shows only the submodules whose path (which includes the name) +contains the term, ignoring case; their ancestors stay as context and are +shown expanded, whatever the user folded. Directory headers without a +match vanish. If nothing matches the tree says `no submodule matches`. + +- **Selection.** If the selected submodule is filtered out, the next + visible one (in display order) is selected, else the previous one; if none + is left, nothing is selected (the detail pane is empty). +- **Typing.** The input has the focus: letters edit the term, so `j`, `q` + etc. are text. `↑` `↓` `pgup` `pgdn` still move the selection. `enter` + hands the keys back to the tree (the filter stays, all keys work again); + `/` edits the term again. +- **`esc`** closes the input and removes the filter, in both modes. The + selection stays; its ancestors are expanded so it is still visible. +- The filter line shows the keys that apply on its right. +- The header counts are not filtered. + ### Key reference Every key in the UI, where it is shown, and whether it works. Update this @@ -237,7 +259,7 @@ where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette | `b` | Bump pin to tip | Submodule | Help | no (flashes "not implemented") | | `f` | Fetch all repositories | Global | Help | yes | | `r` | Reload everything | Global | Help | yes | -| `/` | Filter | Global | Help | no (flashes "not implemented") | +| `/` | Filter | Global | Help | yes | | `!` | Problems only | Global | Help | no (flashes "not implemented") | | `?` | Toggle full help | Global | Bar, Help | yes | | `q`, `ctrl+c` | Quit | Global | Bar, Help | yes | diff --git a/go.mod b/go.mod index 3c98c13..8a310cd 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect diff --git a/go.sum b/go.sum index 45d4b7d..b10f688 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= diff --git a/internal/tui/app.go b/internal/tui/app.go index a78fff4..bcdcd72 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -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())...)) } diff --git a/internal/tui/tree/filter.go b/internal/tui/tree/filter.go new file mode 100644 index 0000000..9145939 --- /dev/null +++ b/internal/tui/tree/filter.go @@ -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 +} diff --git a/internal/tui/tree/rows.go b/internal/tui/tree/rows.go index 83f7fbf..95389ec 100644 --- a/internal/tui/tree/rows.go +++ b/internal/tui/tree/rows.go @@ -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) } } diff --git a/internal/tui/tree/tree.go b/internal/tui/tree/tree.go index 9385f44..18d4a60 100644 --- a/internal/tui/tree/tree.go +++ b/internal/tui/tree/tree.go @@ -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 := " " diff --git a/internal/tui/tree/tree_test.go b/internal/tui/tree/tree_test.go index 7ce9010..6bb5bfa 100644 --- a/internal/tui/tree/tree_test.go +++ b/internal/tui/tree/tree_test.go @@ -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 +}