From 331e53fb496444fa72f2613f79ef52c2b8c82d69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Mon, 12 Oct 2026 11:51:29 +0200 Subject: [PATCH] feat: Go to scopes are grouped like the Sources pane, an account at a time 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) --- docs/keys.md | 21 +++--- internal/ui/app/expiring.go | 2 +- internal/ui/app/repo_test.go | 6 +- internal/ui/app/search.go | 28 +++++--- internal/ui/app/search_test.go | 25 ++++++++ internal/ui/app/searchview.go | 114 ++++++++++++++++++--------------- internal/ui/keys/keys.go | 2 +- 7 files changed, 124 insertions(+), 74 deletions(-) diff --git a/docs/keys.md b/docs/keys.md index 8b0b205..b4c1527 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -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 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 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. +Search has the scope on the left and what's in it on the right. The +scopes are All, then grouped like the Sources pane: each account (or +Databricks, or Repos) with its sources under it, with `○ ◐ ●` for how +much of each is loaded. Repos searches every repo without asking Azure +or Databricks for anything. Go to opens on All; `ctrl+s` again narrows +it to the source or group selected in the Sources pane, and once more +goes back to All. Typing a search loads what it covers: everything on +All, else the account or 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: @@ -100,7 +101,7 @@ Like a pane, Go to is either browsing or typing a filter: |---|---|---| | `j`/`k` | `ctrl+j`/`ctrl+k` | move | | `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 | | `[` / `]` | | previous / next tab | | `f` | `ctrl+f` | star | diff --git a/internal/ui/app/expiring.go b/internal/ui/app/expiring.go index c2315a7..dea864f 100644 --- a/internal/ui/app/expiring.go +++ b/internal/ui/app/expiring.go @@ -60,7 +60,7 @@ func (m Model) expiringFields() []panes.Field { } out = append(out, f) } - cov := m.coverageOf(provider.KindAzure) + cov := m.coverageOf(ofKind(provider.KindAzure)) switch { case cov.vaults == 0 && len(items) == 0: return nil diff --git a/internal/ui/app/repo_test.go b/internal/ui/app/repo_test.go index 115b81b..8222d9a 100644 --- a/internal/ui/app/repo_test.go +++ b/internal/ui/app/repo_test.go @@ -206,9 +206,9 @@ func TestSearchAllRepos(t *testing.T) { for d.m.search.src > 0 { 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 { - 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}) } @@ -235,7 +235,7 @@ func TestSearchAllRepos(t *testing.T) { if azure() != azureBefore || d.m.crawl.running { 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()) } } diff --git a/internal/ui/app/search.go b/internal/ui/app/search.go index bcf2c87..f151c17 100644 --- a/internal/ui/app/search.go +++ b/internal/ui/app/search.go @@ -131,13 +131,25 @@ func (c coverage) complete() bool { 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). -func (m *Model) coverageOf(kind provider.Kind) coverage { +// providerFilter picks the providers a search or a load covers; nil is +// 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 for _, p := range m.providers { - if kind != "" && p.Kind() != kind { + if in != nil && !in(p) { continue } 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 // 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). -func (m *Model) startCrawlOf(kind provider.Kind) tea.Cmd { +// startCrawlOf is startCrawl for the providers in. +func (m *Model) startCrawlOf(in providerFilter) tea.Cmd { for _, p := range m.providers { - if kind != "" && p.Kind() != kind { + if in != nil && !in(p) { continue } for _, src := range m.sourceResults[p.ID()].sources { diff --git a/internal/ui/app/search_test.go b/internal/ui/app/search_test.go index e63322c..3246df4 100644 --- a/internal/ui/app/search_test.go +++ b/internal/ui/app/search_test.go @@ -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) { t.Parallel() d := newDriver(t) diff --git a/internal/ui/app/searchview.go b/internal/ui/app/searchview.go index 2284dd3..7fda0b2 100644 --- a/internal/ui/app/searchview.go +++ b/internal/ui/app/searchview.go @@ -68,10 +68,10 @@ const ( var tabNames = [tabCount]string{"Search", "Favourites", "Recent"} const ( - scopeFavs = "#favs" - scopeRecent = "#recent" - allSources = "#all" - kindScope = "#kind:" // + a provider kind: all of Azure, Databricks or repos + scopeFavs = "#favs" + scopeRecent = "#recent" + allSources = "#all" + accountScope = "#account:" // + a provider ID: an account's sources, or Databricks', or the repos ) const goToTitle = "Go to" @@ -138,7 +138,11 @@ func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().gr func (m *Model) preselect() { v := m.search 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 { return } @@ -172,25 +176,36 @@ func (m *Model) fillSearch(keep bool) { } sources, items := m.searchEntries() - all := option{label: "All", id: allSources, mark: m.coverageMark("")} - scopes := []option{all} - // All of one kind, so a search can stay on repos (local, quick) - // without asking Azure for every vault; only with several kinds. - var kinds []provider.Kind + all := option{label: "All", id: allSources, mark: m.coverageMark(nil)} + // Grouped like the Sources pane: each account (or Databricks, or the + // repos) is a scope too, its sources under it, so a search can stay on + // one, like the repos (local, quick), without asking Azure for every + // vault. With only one, it's the same as All. + var groups []provider.Provider for _, p := range m.providers { - if len(m.sourceResults[p.ID()].sources) > 0 && !slices.Contains(kinds, p.Kind()) { - kinds = append(kinds, p.Kind()) + if len(m.sourceResults[p.ID()].sources) > 0 { + groups = append(groups, p) } } - if len(kinds) > 1 { - for _, k := range kinds { + scopes := []option{all} + if len(groups) < 2 { + scopes = append(scopes, sources...) + } else { + for _, p := range groups { + k := p.Kind() scopes = append(scopes, option{ - label: m.st.Kind(k).Render(m.icons.Prefix(k) + kindScopeLabel(k)), - id: kindScope + string(k), kind: k, mark: m.coverageMark(k), + label: m.st.Kind(k).Render(m.icons.Prefix(k) + p.Label()), + 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.shown = v.match() @@ -203,17 +218,9 @@ func (m *Model) fillSearch(keep bool) { v.res = max(0, min(v.res, len(v.shown)-1)) } -// kindScopeLabel names the scope of all of a kind's sources. -func kindScopeLabel(k provider.Kind) string { - if k == provider.KindRepo { - 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); { +// coverageMark is ○ ◐ ● for how much of the providers in is loaded. +func (m *Model) coverageMark(in providerFilter) string { + switch c := m.coverageOf(in); { case c.complete(): return m.st.Success.Render("●") case c.vaultsLoaded > 0: @@ -222,15 +229,28 @@ func (m *Model) coverageMark(kind provider.Kind) string { return m.st.Muted.Render("○") } -// scopeKind is the kind a scope covers all of, if it's one of those. -func (v *searchView) scopeKind() (provider.Kind, bool) { - k, ok := strings.CutPrefix(v.scope().id, kindScope) +// scopeAccount is the provider whose sources the scope covers, if it's +// an account's. +func (v *searchView) scopeAccount() (string, bool) { + id, ok := strings.CutPrefix(v.scope().id, accountScope) if !ok || v.tab != tabSearch { 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. func (v *searchView) match() []option { var cand []option @@ -240,9 +260,9 @@ func (v *searchView) match() []option { case scopeRecent: cand = v.recent default: - kind, ofKind := v.scopeKind() + account, ofAccount := v.scopeAccount() 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) } } @@ -421,11 +441,9 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd { if v.onSource() { return v.scope().reload(m) } - kind, _ := v.scopeKind() - return m.startCrawlOf(kind) + return m.startCrawlOf(v.scopeFilter()) case inSearch && key.Matches(msg, k.loadAll): - kind, _ := v.scopeKind() - return m.startCrawlOf(kind) + return m.startCrawlOf(v.scopeFilter()) case key.Matches(msg, k.star): if o, ok := v.selected(); ok && o.star != "" { m.toggleStarKey(o.star, o.starAccount) @@ -488,8 +506,8 @@ func (m *Model) searchLoads() tea.Cmd { v.loaded = map[string]bool{} } v.loaded[sc.id] = true - if kind, ok := v.scopeKind(); ok { - return m.startCrawlOf(kind) + if _, ok := v.scopeAccount(); ok { + return m.startCrawlOf(v.scopeFilter()) } if !v.onSource() { return m.startCrawl() @@ -557,7 +575,7 @@ func (m Model) narrowHint() string { if i < 0 || v.scope().id != allSources { 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. @@ -569,8 +587,8 @@ func (m Model) loadHint() string { if v.tab != tabSearch { return "" // from the store: nothing to load } - if kind, ok := v.scopeKind(); ok || v.scope().id == allSources { - c := m.coverageOf(kind) + if _, ok := v.scopeAccount(); ok || v.scope().id == allSources { + c := m.coverageOf(v.scopeFilter()) if c.complete() { 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) } resultsTitle := map[string]string{scopeFavs: "Favourites", scopeRecent: "Recent", allSources: "Results in all sources"}[v.scope().id] - if v.onSource() { - resultsTitle = "Results in " + ansi.Strip(v.scope().label) - } - if kind, ok := v.scopeKind(); ok { - resultsTitle = "Results in " + string(kind) - if kind == provider.KindRepo { - resultsTitle = "Results in all repos" - } + if _, ok := v.scopeAccount(); ok || v.onSource() { + resultsTitle = "Results in " + scopeName(v.scope()) } var lists string if v.tab == tabSearch { diff --git a/internal/ui/keys/keys.go b/internal/ui/keys/keys.go index 2a53da9..c9c3a0d 100644 --- a/internal/ui/keys/keys.go +++ b/internal/ui/keys/keys.go @@ -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, "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. 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, "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."),