feat: ctrl+s searches everything; again narrows to the source you're in
CI / Checks (pull_request) Successful in 23s
PR title / Conventional title (pull_request) Successful in 1s

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) <noreply@anthropic.com>
This commit is contained in:
kebandClaude Opus 5.5 committed 2026-10-12 11:45:53 +02:00
1 parent 45fd80b418
commit 57aec8a381
4 files changed
+146 -38

No files matched your search

+8 -5
View File
@@ -87,17 +87,20 @@ selected item.
Search has the scope on the left (All; all of Azure, Databricks or repos; 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 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 it on the right. All repos searches every repo without asking Azure or
Databricks for anything. It opens on the Databricks for anything. It opens on All; `ctrl+s` again narrows it to
source you're in. Typing a search loads what it covers: everything on the source you're in, and once more goes back to All. Typing a search
All, else the source picked; results appear as lists arrive. That's once loads what it covers: everything on All, else the source picked;
each time Go to opens, and only what isn't loaded yet; `ctrl+a` loads results appear as lists arrive. That's once each time Go to opens, and
again, lists that failed included. 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: Like a pane, Go to is either browsing or typing a filter:
| Browsing | Typing | Action | | Browsing | Typing | Action |
|---|---|---| |---|---|---|
| `j`/`k` | `ctrl+j`/`ctrl+k` | move | | `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 | | `h`/`l`, `tab` | `ctrl+h`/`ctrl+l`, `tab` | scope list / results |
| `[` / `]` | | previous / next tab | | `[` / `]` | | previous / next tab |
| `f` | `ctrl+f` | star | | `f` | `ctrl+f` | star |
+51 -14
View File
@@ -42,11 +42,15 @@ func TestSearchLoaded(t *testing.T) {
t.Fatalf("missing %q:\n%s", want, d.screen()) t.Fatalf("missing %q:\n%s", want, d.screen())
} }
} }
d.typeText("api-key") d.typeText("api-key") // on All: found at once
if !strings.Contains(d.screen(), "no matches here · All searches everything") { 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()) 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") { if !strings.Contains(d.screen(), "platform-prod / kv-app-prod / api-key") {
t.Fatalf("loaded secret not found:\n%s", d.screen()) t.Fatalf("loaded secret not found:\n%s", d.screen())
} }
@@ -62,7 +66,8 @@ func TestSearchLoaded(t *testing.T) {
func TestSearchLoadsAsYouType(t *testing.T) { func TestSearchLoadsAsYouType(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) 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. // Typing loads the scope: that source, without ctrl+a.
d.typeText("smtp") d.typeText("smtp")
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") { if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") {
@@ -76,10 +81,7 @@ func TestSearchLoadsAsYouType(t *testing.T) {
} }
// On All: everything. // On All: everything.
d.keys("tab") // to the scopes d.send(ctrl('s'))
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
d.typeText("-") d.typeText("-")
if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; !ok { 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()) 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.keys("2", "j") // platform-prod / kv-shared-prod
d.openSearch() d.openSearch()
v := d.m.search v := d.m.search
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-prod") { scope := func() string { return ansiStrip(v.scopes[v.src].label) }
t.Errorf("source not preselected: %q", got) 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") { 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()) 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. // From Favourites: to Search, on All.
d.keys("esc", "esc", "1", "j") // stop typing, close; platform-dev 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() d.openSearch()
v = d.m.search v = d.m.search
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-dev") { d.send(ctrl('s'))
t.Errorf("source not preselected: %q", got) 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()) 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.keys("/")
d.typeText("shared") d.typeText("shared")
if !v.typing || len(v.shown) == 0 { if !v.typing || len(v.shown) == 0 {
+80 -15
View File
@@ -20,16 +20,17 @@ import (
) )
// Go to is one popup for finding a place and going there. Tabs at the // 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 // top: Search has two lists side by side, the scope on the left (All, or
// anything loads) are one list each; Search has two lists side by side, // one source with how much of it is loaded: ○ ◐ ● ◌ ✗) and what's in it
// the scope on the left (All, or one source with how much of it is // on the right; Favourites and Recent (from the store, so they work
// loaded: ○ ◐ ● ◌ ✗) and what's in it on the right. // before anything loads) are one list each.
// //
// Like a pane, it's either browsing (letters: j/k, [ and ] for tabs, f // 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 // star, / to type) or typing a filter (ctrl+ letters). esc while typing
// clears the text and goes back to browsing; esc while browsing closes. // 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, // 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 // 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 // keep their order and new ones come after them, with the cursor on the
@@ -39,7 +40,7 @@ import (
type searchView struct { type searchView struct {
input textinput.Model input textinput.Model
typing bool // the text field has the keyboard 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) results bool // in Search, the results have the cursor (else the scopes)
scopes []option // All, then the sources (Search) 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 shown []option // the tab's (or scope's) entries matching the input
src int // cursor in scopes src int // cursor in scopes
res int // cursor in shown 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: // 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 // typing loads the scope once, so lists that fail aren't asked for
@@ -57,13 +59,13 @@ type searchView struct {
} }
const ( const (
tabFavs = iota tabSearch = iota
tabFavs
tabRecent tabRecent
tabSearch
tabCount tabCount
) )
var tabNames = [tabCount]string{"Favourites", "Recent", "Search"} var tabNames = [tabCount]string{"Search", "Favourites", "Recent"}
const ( const (
scopeFavs = "#favs" scopeFavs = "#favs"
@@ -74,8 +76,8 @@ const (
const goToTitle = "Go to" const goToTitle = "Go to"
// showSearch opens Go to on Search, typing, in the source you're in // showSearch opens Go to on Search, typing, on All, with the selected
// (else All), with the selected vault under the cursor. // vault under the cursor.
func (m *Model) showSearch() tea.Cmd { func (m *Model) showSearch() tea.Cmd {
cmd := m.openGoTo(tabSearch, true) cmd := m.openGoTo(tabSearch, true)
m.preselect() m.preselect()
@@ -131,8 +133,8 @@ func (v *searchView) scope() option {
// onSource reports whether the results come from one source. // onSource reports whether the results come from one source.
func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().group != "" } func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().group != "" }
// preselect starts where the user is: the selected source picked on the // preselect starts where the user is: the selected source is the one
// left, and the selected vault or scope under the cursor on the right. // search narrows to, and the selected vault or scope is under the cursor.
func (m *Model) preselect() { func (m *Model) preselect() {
v := m.search v := m.search
row := m.sources.SelectedID() row := m.sources.SelectedID()
@@ -140,8 +142,7 @@ func (m *Model) preselect() {
if i < 0 { if i < 0 {
return return
} }
v.src = i v.here = v.scopes[i].id
m.fillSearch(false)
if m.focus == paneSources { if m.focus == paneSources {
return 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. // 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 } 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} 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 { if v.typing {
k = modeKeys{km.PopupDown, km.PopupUp, km.SearchSources, km.SearchResults, km.SearchSwitch, km.PopupStar, km.SearchLoad, km.SearchLoadAll, km.PopupSelect} 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) { if key.Matches(msg, km.PopupClose) {
@@ -338,6 +342,22 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
return nil return nil
case key.Matches(msg, km.GoFilter): case key.Matches(msg, km.GoFilter):
return m.setTyping(true) 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): case key.Matches(msg, km.Help):
return m.showKeybindingsFor(keys.GoTo) return m.showKeybindingsFor(keys.GoTo)
case key.Matches(msg, km.GoNextTab), key.Matches(msg, km.GoPrevTab): 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 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 // searchLoads loads what a search in the Search tab covers, like the
// load-all key: everything on All, else the source picked. Only what // load-all key: everything on All, else the source picked. Only what
// isn't loaded yet, once a popup; repos load by themselves anyway. // 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 // searchHint tops the results when what they come from isn't all
// loaded, saying how to load it. // loaded, saying how to load it.
func (m Model) searchHint() string { 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 v := m.search
if m.crawl.running { if m.crawl.running {
return m.st.Muted.Render(fmt.Sprintf("◌ loading… %d lists left", len(m.crawl.queue)+m.crawl.active)) return m.st.Muted.Render(fmt.Sprintf("◌ loading… %d lists left", len(m.crawl.queue)+m.crawl.active))
+7 -4
View File
@@ -61,7 +61,8 @@ type Map struct {
SearchSources, SearchResults, SearchSwitch key.Binding SearchSources, SearchResults, SearchSwitch key.Binding
SearchLoad, SearchLoadAll key.Binding SearchLoad, SearchLoadAll key.Binding
// goto // goto
GoDown, GoUp, GoPrevTab, GoNextTab key.Binding GoDown, GoUp, GoTop, GoBottom key.Binding
GoPrevTab, GoNextTab key.Binding
GoSources, GoResults, GoSwitch key.Binding GoSources, GoResults, GoSwitch key.Binding
GoStar, GoLoad, GoLoadAll, GoFilter key.Binding GoStar, GoLoad, GoLoadAll, GoFilter key.Binding
GoSelect, GoClose 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, "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, "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, "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, "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, "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."), 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, "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, "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, "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, "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)."), 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.sources": {"ctrl+h"}, "search.results": {"ctrl+l"}, "search.switchList": {"tab", "shift+tab"},
"search.load": {"ctrl+r"}, "search.loadAll": {"ctrl+a"}, "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.sources": {"h", "left"}, "goto.results": {"l", "right"}, "goto.switchList": {"tab", "shift+tab"},
"goto.star": {"f"}, "goto.load": {"r"}, "goto.loadAll": {"a"}, "goto.filter": {"/"}, "goto.star": {"f"}, "goto.load": {"r"}, "goto.loadAll": {"a"}, "goto.filter": {"/"},
"goto.select": {"enter"}, "goto.close": {"esc", "q"}, "goto.select": {"enter"}, "goto.close": {"esc", "q"},