feat: Go to scopes are grouped like the Sources pane, an account at a time
CI / Checks (pull_request) Successful in 24s
PR title / Conventional title (pull_request) Successful in 1s

The scope list follows the Sources pane: All, then each account (or
Databricks, or Repos) with its sources under it, in place of All Azure,
All Databricks and All repos. An account scope searches and loads only
that account. ctrl+s on a group header narrows to its group.

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:51:29 +02:00
1 parent 57aec8a381
commit 331e53fb49
7 files changed
+121 -71

No files matched your search

+11 -10
View File
@@ -84,15 +84,16 @@ under every account. Both are kept in the [state file](config.md#state).
starred) and `R` on Recent (what you used last). `enter` goes to the starred) and `R` on Recent (what you used last). `enter` goes to the
selected item. selected item.
Search has the scope on the left (All; all of Azure, Databricks or repos; Search has the scope on the left and what's in it on the right. The
or one source; with `○ ◐ ●` for how much of it is loaded) and what's in scopes are All, then grouped like the Sources pane: each account (or
it on the right. All repos searches every repo without asking Azure or Databricks, or Repos) with its sources under it, with `○ ◐ ●` for how
Databricks for anything. It opens on All; `ctrl+s` again narrows it to much of each is loaded. Repos searches every repo without asking Azure
the source you're in, and once more goes back to All. Typing a search or Databricks for anything. Go to opens on All; `ctrl+s` again narrows
loads what it covers: everything on All, else the source picked; it to the source or group selected in the Sources pane, and once more
results appear as lists arrive. That's once each time Go to opens, and goes back to All. Typing a search loads what it covers: everything on
only what isn't loaded yet; `ctrl+a` loads again, lists that failed All, else the account or source picked; results appear as lists
included. 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: Like a pane, Go to is either browsing or typing a filter:
@@ -100,7 +101,7 @@ Like a pane, Go to is either browsing or typing a filter:
|---|---|---| |---|---|---|
| `j`/`k` | `ctrl+j`/`ctrl+k` | move | | `j`/`k` | `ctrl+j`/`ctrl+k` | move |
| `g`/`G` | | top / bottom | | `g`/`G` | | top / bottom |
| `ctrl+s` | `ctrl+s` | only the source you're in / All | | `ctrl+s` | `ctrl+s` | only the source or group 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 |
+1 -1
View File
@@ -60,7 +60,7 @@ func (m Model) expiringFields() []panes.Field {
} }
out = append(out, f) out = append(out, f)
} }
cov := m.coverageOf(provider.KindAzure) cov := m.coverageOf(ofKind(provider.KindAzure))
switch { switch {
case cov.vaults == 0 && len(items) == 0: case cov.vaults == 0 && len(items) == 0:
return nil return nil
+3 -3
View File
@@ -206,9 +206,9 @@ func TestSearchAllRepos(t *testing.T) {
for d.m.search.src > 0 { for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp}) d.send(tea.KeyPressMsg{Code: tea.KeyUp})
} }
for d.m.search.scope().id != kindScope+string(provider.KindRepo) { for d.m.search.scope().id != accountScope+"Repos" {
if d.m.search.src == len(d.m.search.scopes)-1 { if d.m.search.src == len(d.m.search.scopes)-1 {
t.Fatalf("no All repos scope: %v", d.m.search.scopes) t.Fatalf("no Repos scope: %v", d.m.search.scopes)
} }
d.send(tea.KeyPressMsg{Code: tea.KeyDown}) d.send(tea.KeyPressMsg{Code: tea.KeyDown})
} }
@@ -235,7 +235,7 @@ func TestSearchAllRepos(t *testing.T) {
if azure() != azureBefore || d.m.crawl.running { if azure() != azureBefore || d.m.crawl.running {
t.Errorf("searching all repos loaded Azure: %d → %d lists", azureBefore, azure()) t.Errorf("searching all repos loaded Azure: %d → %d lists", azureBefore, azure())
} }
if !strings.Contains(d.screen(), "Results in all repos") { if !strings.Contains(d.screen(), "Results in Repos") {
t.Errorf("title:\n%s", d.screen()) t.Errorf("title:\n%s", d.screen())
} }
} }
+20 -8
View File
@@ -131,13 +131,25 @@ func (c coverage) complete() bool {
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
} }
func (m *Model) searchCoverage() coverage { return m.coverageOf("") } func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
// coverageOf is searchCoverage for one kind of provider ("" for all). // providerFilter picks the providers a search or a load covers; nil is
func (m *Model) coverageOf(kind provider.Kind) coverage { // all of them.
type providerFilter func(provider.Provider) bool
func ofKind(k provider.Kind) providerFilter {
return func(p provider.Provider) bool { return p.Kind() == k }
}
func ofProvider(id string) providerFilter {
return func(p provider.Provider) bool { return p.ID() == id }
}
// coverageOf is searchCoverage for the providers in.
func (m *Model) coverageOf(in providerFilter) coverage {
var c coverage var c coverage
for _, p := range m.providers { for _, p := range m.providers {
if kind != "" && p.Kind() != kind { if in != nil && !in(p) {
continue continue
} }
for _, src := range m.sourceResults[p.ID()].sources { for _, src := range m.sourceResults[p.ID()].sources {
@@ -197,12 +209,12 @@ func plural(n int, word string) string {
// startCrawl loads every unlisted subscription's vaults and every vault's // startCrawl loads every unlisted subscription's vaults and every vault's
// secrets not loaded yet. // secrets not loaded yet.
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf("") } func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
// startCrawlOf is startCrawl for one kind of provider ("" for all). // startCrawlOf is startCrawl for the providers in.
func (m *Model) startCrawlOf(kind provider.Kind) tea.Cmd { func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
for _, p := range m.providers { for _, p := range m.providers {
if kind != "" && p.Kind() != kind { if in != nil && !in(p) {
continue continue
} }
for _, src := range m.sourceResults[p.ID()].sources { for _, src := range m.sourceResults[p.ID()].sources {
+25
View File
@@ -218,6 +218,31 @@ func TestSearchStartsWhereYouAre(t *testing.T) {
} }
} }
func TestSearchNarrowsToAccount(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("1", "g") // the first group header
header, _ := d.m.sources.Selected()
d.openSearch()
d.send(ctrl('s'))
v := d.m.search
if v.scope().id != accountScope+header.provider {
t.Fatalf("ctrl+s on %q narrowed to %q", header.label, v.scope().id)
}
if len(v.shown) == 0 || !strings.Contains(d.screen(), "Results in "+header.label) {
t.Errorf("no results in the account:\n%s", d.screen())
}
for _, o := range v.shown {
if providerOf(o.group) != header.provider {
t.Errorf("result from another account: %q", ansiStrip(o.label))
}
}
// The account's sources are under it, indented.
if next := v.scopes[v.src+1]; next.group == "" || providerOf(next.group) != header.provider || !strings.HasPrefix(ansiStrip(next.label), " ") {
t.Errorf("after the account: %q", ansiStrip(next.label))
}
}
func TestSearchHint(t *testing.T) { func TestSearchHint(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
+60 -48
View File
@@ -71,7 +71,7 @@ const (
scopeFavs = "#favs" scopeFavs = "#favs"
scopeRecent = "#recent" scopeRecent = "#recent"
allSources = "#all" allSources = "#all"
kindScope = "#kind:" // + a provider kind: all of Azure, Databricks or repos accountScope = "#account:" // + a provider ID: an account's sources, or Databricks', or the repos
) )
const goToTitle = "Go to" const goToTitle = "Go to"
@@ -138,7 +138,11 @@ func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().gr
func (m *Model) preselect() { func (m *Model) preselect() {
v := m.search v := m.search
row := m.sources.SelectedID() row := m.sources.SelectedID()
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.group != "" && o.group == row }) account := ""
if id, ok := strings.CutPrefix(row, "group:"); ok { // a group header
account = accountScope + id
}
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.id == account || o.group != "" && o.group == row })
if i < 0 { if i < 0 {
return return
} }
@@ -172,25 +176,36 @@ func (m *Model) fillSearch(keep bool) {
} }
sources, items := m.searchEntries() sources, items := m.searchEntries()
all := option{label: "All", id: allSources, mark: m.coverageMark("")} all := option{label: "All", id: allSources, mark: m.coverageMark(nil)}
scopes := []option{all} // Grouped like the Sources pane: each account (or Databricks, or the
// All of one kind, so a search can stay on repos (local, quick) // repos) is a scope too, its sources under it, so a search can stay on
// without asking Azure for every vault; only with several kinds. // one, like the repos (local, quick), without asking Azure for every
var kinds []provider.Kind // vault. With only one, it's the same as All.
var groups []provider.Provider
for _, p := range m.providers { for _, p := range m.providers {
if len(m.sourceResults[p.ID()].sources) > 0 && !slices.Contains(kinds, p.Kind()) { if len(m.sourceResults[p.ID()].sources) > 0 {
kinds = append(kinds, p.Kind()) groups = append(groups, p)
} }
} }
if len(kinds) > 1 { scopes := []option{all}
for _, k := range kinds { if len(groups) < 2 {
scopes = append(scopes, sources...)
} else {
for _, p := range groups {
k := p.Kind()
scopes = append(scopes, option{ scopes = append(scopes, option{
label: m.st.Kind(k).Render(m.icons.Prefix(k) + kindScopeLabel(k)), label: m.st.Kind(k).Render(m.icons.Prefix(k) + p.Label()),
id: kindScope + string(k), kind: k, mark: m.coverageMark(k), id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
}) })
for _, o := range sources {
if providerOf(o.group) == p.ID() {
o.label = " " + o.label
scopes = append(scopes, o)
} }
} }
v.scopes, v.items = append(scopes, sources...), items }
}
v.scopes, v.items = scopes, items
v.src = max(0, slices.IndexFunc(v.scopes, func(o option) bool { return o.id == atSrc })) v.src = max(0, slices.IndexFunc(v.scopes, func(o option) bool { return o.id == atSrc }))
v.shown = v.match() v.shown = v.match()
@@ -203,17 +218,9 @@ func (m *Model) fillSearch(keep bool) {
v.res = max(0, min(v.res, len(v.shown)-1)) v.res = max(0, min(v.res, len(v.shown)-1))
} }
// kindScopeLabel names the scope of all of a kind's sources. // coverageMark is ○ ◐ ● for how much of the providers in is loaded.
func kindScopeLabel(k provider.Kind) string { func (m *Model) coverageMark(in providerFilter) string {
if k == provider.KindRepo { switch c := m.coverageOf(in); {
return "All repos"
}
return "All " + string(k)
}
// coverageMark is ○ ◐ ● for how much of a kind ("" for all) is loaded.
func (m *Model) coverageMark(kind provider.Kind) string {
switch c := m.coverageOf(kind); {
case c.complete(): case c.complete():
return m.st.Success.Render("●") return m.st.Success.Render("●")
case c.vaultsLoaded > 0: case c.vaultsLoaded > 0:
@@ -222,15 +229,28 @@ func (m *Model) coverageMark(kind provider.Kind) string {
return m.st.Muted.Render("○") return m.st.Muted.Render("○")
} }
// scopeKind is the kind a scope covers all of, if it's one of those. // scopeAccount is the provider whose sources the scope covers, if it's
func (v *searchView) scopeKind() (provider.Kind, bool) { // an account's.
k, ok := strings.CutPrefix(v.scope().id, kindScope) func (v *searchView) scopeAccount() (string, bool) {
id, ok := strings.CutPrefix(v.scope().id, accountScope)
if !ok || v.tab != tabSearch { if !ok || v.tab != tabSearch {
return "", false return "", false
} }
return provider.Kind(k), true return id, true
} }
// scopeFilter is what loading everything in the scope covers: an
// account's, else all of them.
func (v *searchView) scopeFilter() providerFilter {
if id, ok := v.scopeAccount(); ok {
return ofProvider(id)
}
return nil
}
// scopeName is the scope's label without colours or indent.
func scopeName(o option) string { return strings.TrimSpace(ansi.Strip(o.label)) }
// match lists the scope's entries matching the input, best first. // match lists the scope's entries matching the input, best first.
func (v *searchView) match() []option { func (v *searchView) match() []option {
var cand []option var cand []option
@@ -240,9 +260,9 @@ func (v *searchView) match() []option {
case scopeRecent: case scopeRecent:
cand = v.recent cand = v.recent
default: default:
kind, ofKind := v.scopeKind() account, ofAccount := v.scopeAccount()
for _, o := range v.items { for _, o := range v.items {
if sc.id == allSources || ofKind && o.kind == kind || sc.group != "" && o.group == sc.group { if sc.id == allSources || ofAccount && providerOf(o.group) == account || sc.group != "" && o.group == sc.group {
cand = append(cand, o) cand = append(cand, o)
} }
} }
@@ -421,11 +441,9 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
if v.onSource() { if v.onSource() {
return v.scope().reload(m) return v.scope().reload(m)
} }
kind, _ := v.scopeKind() return m.startCrawlOf(v.scopeFilter())
return m.startCrawlOf(kind)
case inSearch && key.Matches(msg, k.loadAll): case inSearch && key.Matches(msg, k.loadAll):
kind, _ := v.scopeKind() return m.startCrawlOf(v.scopeFilter())
return m.startCrawlOf(kind)
case key.Matches(msg, k.star): case key.Matches(msg, k.star):
if o, ok := v.selected(); ok && o.star != "" { if o, ok := v.selected(); ok && o.star != "" {
m.toggleStarKey(o.star, o.starAccount) m.toggleStarKey(o.star, o.starAccount)
@@ -488,8 +506,8 @@ func (m *Model) searchLoads() tea.Cmd {
v.loaded = map[string]bool{} v.loaded = map[string]bool{}
} }
v.loaded[sc.id] = true v.loaded[sc.id] = true
if kind, ok := v.scopeKind(); ok { if _, ok := v.scopeAccount(); ok {
return m.startCrawlOf(kind) return m.startCrawlOf(v.scopeFilter())
} }
if !v.onSource() { if !v.onSource() {
return m.startCrawl() return m.startCrawl()
@@ -557,7 +575,7 @@ func (m Model) narrowHint() string {
if i < 0 || v.scope().id != allSources { if i < 0 || v.scope().id != allSources {
return "" return ""
} }
return withKey(m.keys.Search, "%s only "+ansi.Strip(v.scopes[i].label)) return withKey(m.keys.Search, "%s only "+scopeName(v.scopes[i]))
} }
// loadHint says how much of the scope is loaded, and the key to load it. // loadHint says how much of the scope is loaded, and the key to load it.
@@ -569,8 +587,8 @@ func (m Model) loadHint() string {
if v.tab != tabSearch { if v.tab != tabSearch {
return "" // from the store: nothing to load return "" // from the store: nothing to load
} }
if kind, ok := v.scopeKind(); ok || v.scope().id == allSources { if _, ok := v.scopeAccount(); ok || v.scope().id == allSources {
c := m.coverageOf(kind) c := m.coverageOf(v.scopeFilter())
if c.complete() { if c.complete() {
return "" return ""
} }
@@ -702,14 +720,8 @@ func (m Model) searchViewBox() string {
} }
title := func(s string, active bool) string { return panes.TitleStyle(m.st, active && focused).Render(s) } title := func(s string, active bool) string { return panes.TitleStyle(m.st, active && focused).Render(s) }
resultsTitle := map[string]string{scopeFavs: "Favourites", scopeRecent: "Recent", allSources: "Results in all sources"}[v.scope().id] resultsTitle := map[string]string{scopeFavs: "Favourites", scopeRecent: "Recent", allSources: "Results in all sources"}[v.scope().id]
if v.onSource() { if _, ok := v.scopeAccount(); ok || v.onSource() {
resultsTitle = "Results in " + ansi.Strip(v.scope().label) resultsTitle = "Results in " + scopeName(v.scope())
}
if kind, ok := v.scopeKind(); ok {
resultsTitle = "Results in " + string(kind)
if kind == provider.KindRepo {
resultsTitle = "Results in all repos"
}
} }
var lists string var lists string
if v.tab == tabSearch { if v.tab == tabSearch {
+1 -1
View File
@@ -103,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. 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, "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 or group you're in, and back to all. On the left, the scope: All, an account (or 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."),