From 57aec8a38101c72b3039a8e8e3ee25aa096afbf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Mon, 12 Oct 2026 11:45:53 +0200 Subject: [PATCH] feat: `ctrl+s` searches everything; again narrows to the source you're in Go to opens on All instead of the source you're in, so a search finds anything at once. ctrl+s again in Go to narrows to that source, and once more goes back to All; the hint names it. g/G go to the top and bottom of a list while browsing, and the tabs are Search, Favourites, Recent. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/keys.md | 13 +++-- internal/ui/app/search_test.go | 65 ++++++++++++++++++----- internal/ui/app/searchview.go | 95 ++++++++++++++++++++++++++++------ internal/ui/keys/keys.go | 11 ++-- 4 files changed, 146 insertions(+), 38 deletions(-) diff --git a/docs/keys.md b/docs/keys.md index 8f9d538..8b0b205 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -87,17 +87,20 @@ selected item. Search has the scope on the left (All; all of Azure, Databricks or repos; or one source; with `○ ◐ ●` for how much of it is loaded) and what's in it on the right. All repos searches every repo without asking Azure or -Databricks for anything. It opens on the -source you're in. Typing a search loads what it covers: everything on -All, else the source picked; results appear as lists arrive. That's once -each time Go to opens, and only what isn't loaded yet; `ctrl+a` loads -again, lists that failed included. +Databricks for anything. It opens on All; `ctrl+s` again narrows it to +the source you're in, and once more goes back to All. Typing a search +loads what it covers: everything on All, else the source picked; +results appear as lists arrive. That's once each time Go to opens, and +only what isn't loaded yet; `ctrl+a` loads again, lists that failed +included. Like a pane, Go to is either browsing or typing a filter: | Browsing | Typing | Action | |---|---|---| | `j`/`k` | `ctrl+j`/`ctrl+k` | move | +| `g`/`G` | | top / bottom | +| `ctrl+s` | `ctrl+s` | only the source you're in / All | | `h`/`l`, `tab` | `ctrl+h`/`ctrl+l`, `tab` | scope list / results | | `[` / `]` | | previous / next tab | | `f` | `ctrl+f` | star | diff --git a/internal/ui/app/search_test.go b/internal/ui/app/search_test.go index 26d02cf..e63322c 100644 --- a/internal/ui/app/search_test.go +++ b/internal/ui/app/search_test.go @@ -42,11 +42,15 @@ func TestSearchLoaded(t *testing.T) { t.Fatalf("missing %q:\n%s", want, d.screen()) } } - d.typeText("api-key") - if !strings.Contains(d.screen(), "no matches here · All searches everything") { + d.typeText("api-key") // on All: found at once + if !strings.Contains(d.screen(), "ctrl+s only platform-dev") { + t.Errorf("no hint to narrow to the source:\n%s", d.screen()) + } + d.send(ctrl('s')) + if !strings.Contains(d.screen(), "no matches here · All searches everything") || !strings.Contains(d.screen(), "ctrl+s searches all") { t.Errorf("no hint to search everything:\n%s", d.screen()) } - d.searchAll() + d.send(ctrl('s')) if !strings.Contains(d.screen(), "platform-prod / kv-app-prod / api-key") { t.Fatalf("loaded secret not found:\n%s", d.screen()) } @@ -62,7 +66,8 @@ func TestSearchLoaded(t *testing.T) { func TestSearchLoadsAsYouType(t *testing.T) { t.Parallel() d := newDriver(t) - d.openSearch() // on the source the cursor is on: platform-prod + d.openSearch() + d.send(ctrl('s')) // only the source the cursor is on: platform-prod // Typing loads the scope: that source, without ctrl+a. d.typeText("smtp") if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") { @@ -76,10 +81,7 @@ func TestSearchLoadsAsYouType(t *testing.T) { } // On All: everything. - d.keys("tab") // to the scopes - for d.m.search.src > 0 { - d.send(tea.KeyPressMsg{Code: tea.KeyUp}) - } + d.send(ctrl('s')) d.typeText("-") if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; !ok { t.Errorf("typing on All didn't load the other sources:\n%s", d.screen()) @@ -183,19 +185,36 @@ func TestSearchStartsWhereYouAre(t *testing.T) { d.keys("2", "j") // platform-prod / kv-shared-prod d.openSearch() v := d.m.search - if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-prod") { - t.Errorf("source not preselected: %q", got) + scope := func() string { return ansiStrip(v.scopes[v.src].label) } + if scope() != "All" { + t.Errorf("opened on %q, want All", scope()) } if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") { t.Errorf("vault not under the cursor:\n%s", d.screen()) } + d.send(ctrl('s')) // narrows to the source, and back + if !strings.Contains(scope(), "platform-prod") { + t.Errorf("ctrl+s narrowed to %q", scope()) + } + d.send(ctrl('s')) + if scope() != "All" { + t.Errorf("second ctrl+s on %q, want All", scope()) + } - // From the Sources pane: just the source. - d.keys("esc", "esc", "1", "j") // stop typing, close; platform-dev + // From Favourites: to Search, on All. + d.keys("esc", "]", "]") // stop typing; Favourites + d.send(ctrl('s')) + if v.tab != tabSearch || scope() != "All" { + t.Errorf("ctrl+s from another tab: tab %d, %q", v.tab, scope()) + } + + // From the Sources pane: the source. + d.keys("esc", "1", "j") // close; platform-dev d.openSearch() v = d.m.search - if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-dev") { - t.Errorf("source not preselected: %q", got) + d.send(ctrl('s')) + if !strings.Contains(scope(), "platform-dev") { + t.Errorf("ctrl+s narrowed to %q", scope()) } } @@ -239,6 +258,24 @@ func TestGoToTabsAndModes(t *testing.T) { t.Errorf("[ → tab %d, want Search:\n%s", v.tab, d.screen()) } + // g and G: top and bottom of the list with the cursor. + d.keys("G") + if v.res != len(v.shown)-1 || v.res == 0 { + t.Errorf("G on results: %d of %d", v.res, len(v.shown)) + } + d.keys("g") + if v.res != 0 { + t.Errorf("g on results: %d", v.res) + } + d.keys("h", "G") + if v.src != len(v.scopes)-1 { + t.Errorf("G on scopes: %d of %d", v.src, len(v.scopes)) + } + d.keys("g", "l") + if v.src != 0 { + t.Errorf("g on scopes: %d", v.src) + } + d.keys("/") d.typeText("shared") if !v.typing || len(v.shown) == 0 { diff --git a/internal/ui/app/searchview.go b/internal/ui/app/searchview.go index 5342713..2284dd3 100644 --- a/internal/ui/app/searchview.go +++ b/internal/ui/app/searchview.go @@ -20,16 +20,17 @@ import ( ) // Go to is one popup for finding a place and going there. Tabs at the -// top: Favourites and Recent (from the store, so they work before -// anything loads) are one list each; Search has two lists side by side, -// the scope on the left (All, or one source with how much of it is -// loaded: ○ ◐ ● ◌ ✗) and what's in it on the right. +// top: Search has two lists side by side, the scope on the left (All, or +// one source with how much of it is loaded: ○ ◐ ● ◌ ✗) and what's in it +// on the right; Favourites and Recent (from the store, so they work +// before anything loads) are one list each. // // Like a pane, it's either browsing (letters: j/k, [ and ] for tabs, f // star, / to type) or typing a filter (ctrl+ letters). esc while typing // clears the text and goes back to browsing; esc while browsing closes. // F opens it on Favourites and R on Recent, browsing; search on Search, -// typing. +// typing, on All. search again narrows to the source you were in, and +// back to All. // // The results don't move by themselves: as lists load, rows already shown // keep their order and new ones come after them, with the cursor on the @@ -39,7 +40,7 @@ import ( type searchView struct { input textinput.Model typing bool // the text field has the keyboard - tab int // tabFavs, tabRecent or tabSearch + tab int // tabSearch, tabFavs or tabRecent results bool // in Search, the results have the cursor (else the scopes) scopes []option // All, then the sources (Search) @@ -49,6 +50,7 @@ type searchView struct { shown []option // the tab's (or scope's) entries matching the input src int // cursor in scopes res int // cursor in shown + here string // the scope of the source Go to was opened from, if any // loaded are the scopes a search has loaded since the popup opened: // typing loads the scope once, so lists that fail aren't asked for @@ -57,13 +59,13 @@ type searchView struct { } const ( - tabFavs = iota + tabSearch = iota + tabFavs tabRecent - tabSearch tabCount ) -var tabNames = [tabCount]string{"Favourites", "Recent", "Search"} +var tabNames = [tabCount]string{"Search", "Favourites", "Recent"} const ( scopeFavs = "#favs" @@ -74,8 +76,8 @@ const ( const goToTitle = "Go to" -// showSearch opens Go to on Search, typing, in the source you're in -// (else All), with the selected vault under the cursor. +// showSearch opens Go to on Search, typing, on All, with the selected +// vault under the cursor. func (m *Model) showSearch() tea.Cmd { cmd := m.openGoTo(tabSearch, true) m.preselect() @@ -131,8 +133,8 @@ func (v *searchView) scope() option { // onSource reports whether the results come from one source. func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().group != "" } -// preselect starts where the user is: the selected source picked on the -// left, and the selected vault or scope under the cursor on the right. +// preselect starts where the user is: the selected source is the one +// search narrows to, and the selected vault or scope is under the cursor. func (m *Model) preselect() { v := m.search row := m.sources.SelectedID() @@ -140,8 +142,7 @@ func (m *Model) preselect() { if i < 0 { return } - v.src = i - m.fillSearch(false) + v.here = v.scopes[i].id if m.focus == paneSources { return } @@ -320,6 +321,9 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd { // The actions shared by both modes, by their keys in the mode. type modeKeys struct{ down, up, sources, results, switchList, star, load, loadAll, sel key.Binding } k := modeKeys{km.GoDown, km.GoUp, km.GoSources, km.GoResults, km.GoSwitch, km.GoStar, km.GoLoad, km.GoLoadAll, km.GoSelect} + if key.Matches(msg, km.Search) { + return m.narrow() + } if v.typing { k = modeKeys{km.PopupDown, km.PopupUp, km.SearchSources, km.SearchResults, km.SearchSwitch, km.PopupStar, km.SearchLoad, km.SearchLoadAll, km.PopupSelect} if key.Matches(msg, km.PopupClose) { @@ -338,6 +342,22 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd { return nil case key.Matches(msg, km.GoFilter): return m.setTyping(true) + case key.Matches(msg, km.GoTop), key.Matches(msg, km.GoBottom): + n := len(v.shown) + if !v.results { + n = len(v.scopes) + } + i := 0 + if key.Matches(msg, km.GoBottom) { + i = max(0, n-1) + } + if v.results { + v.res = i + } else if v.src != i { + v.src, v.res = i, 0 + m.fillSearch(false) + } + return nil case key.Matches(msg, km.Help): return m.showKeybindingsFor(keys.GoTo) case key.Matches(msg, km.GoNextTab), key.Matches(msg, km.GoPrevTab): @@ -436,6 +456,22 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd { return cmd } +// narrow switches Search between All and the source Go to was opened +// from; from another tab, or another scope, it goes to Search on All. +func (m *Model) narrow() tea.Cmd { + v := m.search + src := 0 + if v.tab == tabSearch && v.scope().id == allSources { + src = slices.IndexFunc(v.scopes, func(o option) bool { return o.id == v.here }) + if src < 0 { + return nil + } + } + v.tab, v.src, v.res, v.results = tabSearch, src, 0, true + m.fillSearch(false) + return m.searchLoads() +} + // searchLoads loads what a search in the Search tab covers, like the // load-all key: everything on All, else the source picked. Only what // isn't loaded yet, once a popup; repos load by themselves anyway. @@ -497,6 +533,35 @@ func (m *Model) toggleStarKey(key, account string) { // searchHint tops the results when what they come from isn't all // loaded, saying how to load it. func (m Model) searchHint() string { + load, narrow := m.loadHint(), m.narrowHint() + switch { + case narrow == "": + return load + case load != "": + narrow = " · " + narrow + } + return load + m.st.Muted.Render(narrow) +} + +// narrowHint names the key for the other of All and the source Go to was +// opened from. +func (m Model) narrowHint() string { + v := m.search + if v.tab != tabSearch || m.crawl.running { + return "" + } + if v.scope().id == v.here && v.here != "" { + return withKey(m.keys.Search, "%s searches all") + } + i := slices.IndexFunc(v.scopes, func(o option) bool { return o.id == v.here }) + if i < 0 || v.scope().id != allSources { + return "" + } + return withKey(m.keys.Search, "%s only "+ansi.Strip(v.scopes[i].label)) +} + +// loadHint says how much of the scope is loaded, and the key to load it. +func (m Model) loadHint() string { v := m.search if m.crawl.running { return m.st.Muted.Render(fmt.Sprintf("◌ loading… %d lists left", len(m.crawl.queue)+m.crawl.active)) diff --git a/internal/ui/keys/keys.go b/internal/ui/keys/keys.go index b983324..2a53da9 100644 --- a/internal/ui/keys/keys.go +++ b/internal/ui/keys/keys.go @@ -61,7 +61,8 @@ type Map struct { SearchSources, SearchResults, SearchSwitch key.Binding SearchLoad, SearchLoadAll key.Binding // goto - GoDown, GoUp, GoPrevTab, GoNextTab key.Binding + GoDown, GoUp, GoTop, GoBottom key.Binding + GoPrevTab, GoNextTab key.Binding GoSources, GoResults, GoSwitch key.Binding GoStar, GoLoad, GoLoadAll, GoFilter key.Binding GoSelect, GoClose key.Binding @@ -102,7 +103,7 @@ func (m *Map) Actions() []Action { a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."), a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."), a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."), - a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one, starting in the source you're in. On the left, the scope: Favourites, Recent, All, or one source (○ ◐ ● for how much of it is loaded)."), + a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source you're in, and back to all. On the left, the scope: All, all of Azure, Databricks or repos, or one source (○ ◐ ● for how much of it is loaded)."), a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."), a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."), a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."), @@ -160,7 +161,9 @@ func (m *Map) Actions() []Action { a(GoTo, "down", &m.GoDown, "down", "go to", "Move down in Go to while browsing."), a(GoTo, "up", &m.GoUp, "up", "go to", "Move up in Go to while browsing."), - a(GoTo, "prevTab", &m.GoPrevTab, "prev tab", "go to", "Previous tab of Go to: Favourites, Recent, Search."), + a(GoTo, "top", &m.GoTop, "top", "go to", "Jump to the top of the list in Go to while browsing."), + a(GoTo, "bottom", &m.GoBottom, "bottom", "go to", "Jump to the bottom of the list in Go to while browsing."), + a(GoTo, "prevTab", &m.GoPrevTab, "prev tab", "go to", "Previous tab of Go to: Search, Favourites, Recent."), a(GoTo, "nextTab", &m.GoNextTab, "next tab", "go to", "Next tab of Go to."), a(GoTo, "sources", &m.GoSources, "sources", "go to", "Move to the sources list (Search tab)."), a(GoTo, "results", &m.GoResults, "results", "go to", "Move to the results list (Search tab)."), @@ -206,7 +209,7 @@ var defaults = map[string][]string{ "search.sources": {"ctrl+h"}, "search.results": {"ctrl+l"}, "search.switchList": {"tab", "shift+tab"}, "search.load": {"ctrl+r"}, "search.loadAll": {"ctrl+a"}, - "goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"}, + "goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.top": {"g", "home"}, "goto.bottom": {"G", "end"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"}, "goto.sources": {"h", "left"}, "goto.results": {"l", "right"}, "goto.switchList": {"tab", "shift+tab"}, "goto.star": {"f"}, "goto.load": {"r"}, "goto.loadAll": {"a"}, "goto.filter": {"/"}, "goto.select": {"enter"}, "goto.close": {"esc", "q"},