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."),