Files
lazysecrets/internal/ui/app/searchview.go
T
kebandClaude Opus 5.5 b46991c7c2
CI / Checks (pull_request) Successful in 37s
PR title / Conventional title (pull_request) Successful in 1s
feat: the key picker says what sharing does, and skips plain env values
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:13:16 +02:00

824 lines
24 KiB
Go

package app
import (
"fmt"
"slices"
"strings"
"unicode/utf8"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/match"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// Go to is one popup for finding a place and going there. Tabs at the
// 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, 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
// same entry. Starring keeps an entry where it is, its ★ showing the
// change. Only typing or picking another tab or scope re-sorts them.
type searchView struct {
input textinput.Model
typing bool // the text field has the keyboard
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)
items []option // everything loaded, unfiltered
favs []option // starred, as when the popup opened
recent []option // recently used, as when the popup opened
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
// pick, if set, makes Go to a picker of secrets, for title: it lists
// secrets only, and enter gives the selected one's link to pick
// instead of going there.
pick func(m *Model, r ref.Ref) tea.Cmd
pickTitle string
pickLines []string // what picking does, above the input
// 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
// again on every key.
loaded map[string]bool
}
const (
tabSearch = iota
tabFavs
tabRecent
tabCount
)
var tabNames = [tabCount]string{"Search", "Favourites", "Recent"}
const (
scopeFavs = "#favs"
scopeRecent = "#recent"
allSources = "#all"
accountScope = "#account:" // + a provider ID: an account's sources, or Databricks', or the repos
)
const goToTitle = "Go to"
// 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()
return cmd
}
// showFavourites opens Go to on Favourites; showRecent on Recent. Both
// start browsing: they're short lists.
func (m *Model) showFavourites() tea.Cmd { return m.openGoTo(tabFavs, false) }
func (m *Model) showRecent() tea.Cmd { return m.openGoTo(tabRecent, false) }
func (m *Model) openGoTo(tab int, typing bool) tea.Cmd {
in := textinput.New()
in.Prompt = "/"
in.SetWidth(searchWidth(m.w) - 6) // as wide as the popup
v := &searchView{input: in, tab: tab, results: true}
v.favs, v.recent = m.storeEntries()
m.search = v
m.fillSearch(false)
m.flash = flash{}
return m.setTyping(typing)
}
// setTyping gives the keyboard to the text field, or takes it back.
func (m *Model) setTyping(on bool) tea.Cmd {
v := m.search
v.typing = on
if on {
v.input.Prompt, v.input.Placeholder = "/", "type to filter"
return v.input.Focus()
}
v.input.Blur()
v.input.Prompt = "" // the placeholder names the key
if v.input.Value() != "" {
v.input.Prompt = "/"
}
v.input.Placeholder = withKey(m.keys.GoFilter, "%s to filter")
return nil
}
// scope is what the results come from: the tab, or in Search the scope
// picked on the left.
func (v *searchView) scope() option {
switch v.tab {
case tabFavs:
return option{id: scopeFavs}
case tabRecent:
return option{id: scopeRecent}
}
return v.scopes[v.src]
}
// 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 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()
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
}
v.here = v.scopes[i].id
if m.focus == paneSources {
return
}
if c, ok := m.containers.Selected(); ok {
key := storeKey(c)
if j := slices.IndexFunc(v.shown, func(o option) bool { return o.star == key }); j >= 0 {
v.res = j
}
}
}
// fillSearch (re)builds the lists from what's loaded. keep keeps the
// order of rows already shown (lists arrived); otherwise the results are
// ranked afresh (the query, tab or scope changed).
func (m *Model) fillSearch(keep bool) {
v := m.search
atSrc, atRes := "", ""
if v.src < len(v.scopes) {
atSrc = v.scopes[v.src].id
}
if v.res < len(v.shown) {
atRes = v.shown[v.res].id
}
prev := map[string]int{}
for i, o := range v.shown {
prev[o.id] = i
}
sources, items := m.searchEntries()
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 {
groups = append(groups, p)
}
}
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) + 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 = scopes, items
v.src = max(0, slices.IndexFunc(v.scopes, func(o option) bool { return o.id == atSrc }))
v.shown = v.match()
if keep {
v.shown = stableOrder(v.shown, prev)
if i := slices.IndexFunc(v.shown, func(o option) bool { return o.id == atRes }); i >= 0 {
v.res = i
}
}
v.res = max(0, min(v.res, len(v.shown)-1))
}
// 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:
return m.st.Warning.Render("◐")
}
return m.st.Muted.Render("○")
}
// 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 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
switch sc := v.scope(); sc.id {
case scopeFavs:
cand = v.favs
case scopeRecent:
cand = v.recent
default:
account, ofAccount := v.scopeAccount()
for _, o := range v.items {
if sc.id == allSources || ofAccount && providerOf(o.group) == account || sc.group != "" && o.group == sc.group {
cand = append(cand, o)
}
}
}
if v.pick != nil {
cand = slices.DeleteFunc(slices.Clone(cand), func(o option) bool { _, ok := pickable(o); return !ok })
}
q := v.input.Value()
if q == "" {
return slices.Clone(cand)
}
items := make([]match.Item, len(cand))
for i, o := range cand {
label := ansi.Strip(o.label)
start, end := nameRange(label)
items[i] = match.Item{Text: strings.TrimSpace(label + " " + o.note), NameStart: start, NameEnd: end}
}
var out []option
for _, h := range match.Rank(match.Parse(q), items) {
o := cand[h.Index]
// Hits in the label are highlighted; ones in the note just count.
n := utf8.RuneCountInString(ansi.Strip(o.label))
o.hits = nil
for _, p := range h.Pos {
if p < n {
o.hits = append(o.hits, p)
}
}
out = append(out, o)
}
return out
}
// nameRange is where the item's own name is in a Go to label, in runes:
// the last part of "sub / vault / secret · account", which matches count
// most for.
func nameRange(label string) (int, int) {
end := len(label)
if i := strings.LastIndex(label, " · "); i >= 0 {
end = i
}
start := 0 // a source: all of it (an icon doesn't match letters)
if i := strings.LastIndex(label[:end], " / "); i >= 0 {
start = i + len(" / ")
}
return utf8.RuneCountInString(label[:start]), utf8.RuneCountInString(label[:end])
}
// refreshSearch updates an open search as lists arrive.
func (m *Model) refreshSearch() {
if m.search != nil {
m.fillSearch(true)
}
}
// stableOrder puts entries shown before (prev) in their earlier order,
// new ones after them in the order given.
func stableOrder(opts []option, prev map[string]int) []option {
out := slices.Clone(opts)
slices.SortStableFunc(out, func(a, b option) int {
ia, okA := prev[a.id]
ib, okB := prev[b.id]
switch {
case okA && okB:
return ia - ib
case okA:
return -1
case okB:
return 1
}
return 0
})
return out
}
func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
v := m.search
km := m.keys
// 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) {
// Back to browsing, the filter cleared.
if v.input.Value() != "" {
v.input.SetValue("")
v.res = 0
m.fillSearch(false)
}
return m.setTyping(false)
}
} else {
switch {
case key.Matches(msg, km.GoClose):
m.search = nil
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):
d := 1
if key.Matches(msg, km.GoPrevTab) {
d = tabCount - 1
}
v.tab = (v.tab + d) % tabCount
v.res, v.results = 0, true
m.fillSearch(false)
return nil
}
}
inSearch := v.tab == tabSearch
moveSrc := func(d int) {
v.src = max(0, min(v.src+d, len(v.scopes)-1))
v.res = 0
m.fillSearch(false)
}
switch {
case inSearch && key.Matches(msg, k.switchList):
v.results = !v.results
return nil
case inSearch && key.Matches(msg, k.sources):
v.results = false
return nil
case inSearch && key.Matches(msg, k.results):
v.results = true
return nil
case key.Matches(msg, k.up):
if v.results {
v.res = max(0, v.res-1)
} else {
moveSrc(-1)
}
return nil
case key.Matches(msg, k.down):
if v.results {
v.res = max(0, min(v.res+1, len(v.shown)-1))
} else {
moveSrc(1)
}
return nil
case key.Matches(msg, k.sel):
switch {
case v.results && v.res < len(v.shown) && v.pick != nil:
r, _ := pickable(v.shown[v.res])
m.search = nil
return v.pick(m, r)
case v.results && v.res < len(v.shown):
m.search = nil
return v.shown[v.res].run(m)
case !v.results && v.pick != nil:
v.results = true // a picker only narrows to the scope
case !v.results && v.onSource():
m.search = nil
return v.scope().run(m)
case !v.results:
v.results = true // All: on to the results
}
return nil
case inSearch && key.Matches(msg, k.load):
if o, ok := v.selected(); ok && o.reload != nil {
return o.reload(m) // the selected source, or a result's vault
}
// Nothing selected (no results yet, or All): the scope.
if v.onSource() {
return v.scope().reload(m)
}
return m.startCrawlOf(v.scopeFilter())
case inSearch && key.Matches(msg, k.loadAll):
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)
// The entry stays where it is, its ★ showing the change.
on := m.store.Node(o.star).StarredFor(o.starAccount)
for _, list := range [][]option{v.favs, v.recent} {
for i := range list {
if list[i].star == o.star && list[i].starAccount == o.starAccount {
list[i].starred = on
}
}
}
m.fillSearch(true)
}
return nil
}
if !v.typing {
return nil
}
before := v.input.Value()
in, cmd := v.input.Update(msg)
v.input = in
if v.input.Value() != before {
v.res = 0
m.fillSearch(false)
return tea.Batch(cmd, m.searchLoads())
}
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.
func (m *Model) searchLoads() tea.Cmd {
v := m.search
if v.tab != tabSearch || strings.TrimSpace(v.input.Value()) == "" {
return nil
}
sc := v.scope()
if v.loaded[sc.id] {
return nil
}
if v.loaded == nil {
v.loaded = map[string]bool{}
}
v.loaded[sc.id] = true
if _, ok := v.scopeAccount(); ok {
return m.startCrawlOf(v.scopeFilter())
}
if !v.onSource() {
return m.startCrawl()
}
providerID, sourceID := splitSourceRow(sc.group)
for _, src := range m.sourceResults[providerID].sources {
if src.ID == sourceID {
src.Provider, src.ProviderLabel = providerID, m.byID[providerID].Label()
m.queueSource(src, false)
return m.crawlStart()
}
}
return nil
}
// selected is the entry under the cursor in the focused list (a scope
// that isn't one source is none).
func (v *searchView) selected() (option, bool) {
if v.results {
if v.res < len(v.shown) {
return v.shown[v.res], true
}
return option{}, false
}
if v.onSource() {
return v.scope(), true
}
return option{}, false
}
// toggleStarKey stars or unstars a store record for an account and
// re-sorts the panes.
func (m *Model) toggleStarKey(key, account string) {
if err := m.store.Update(key, func(n *store.Node) { n.ToggleStar(account) }); err != nil {
m.flash = flash{text: "saving: " + err.Error(), isErr: true}
return
}
m.resortStarred()
}
// 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 "+scopeName(v.scopes[i]))
}
// 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))
}
if v.tab != tabSearch {
return "" // from the store: nothing to load
}
if _, ok := v.scopeAccount(); ok || v.scope().id == allSources {
c := m.coverageOf(v.scopeFilter())
if c.complete() {
return ""
}
return m.st.Warning.Render(fmt.Sprintf("⚠ %d/%d vaults loaded", c.vaultsLoaded, c.vaults) + withKey(m.loadAllKey(), " · %s loads everything"))
}
src := v.scopes[v.src]
e, listed := m.cache.containers[src.group]
name := ansi.Strip(src.label)
if !listed {
if m.crawl.failed[src.group] {
return ""
}
return m.st.Warning.Render("⚠ not loaded" + withKey(m.loadKey(), " · %s loads "+name))
}
loaded := 0
for _, c := range e.value {
key := containerKey(c) + "|" + string(provider.TypeSecret)
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
if loaded == len(e.value) {
return ""
}
return m.st.Warning.Render(fmt.Sprintf("⚠ %d/%d vaults loaded", loaded, len(e.value)) + withKey(m.loadKey(), " · %s loads "+name))
}
// loadKey and loadAllKey are the keys for loading in the current mode.
func (m Model) loadKey() key.Binding {
if m.search.typing {
return m.keys.SearchLoad
}
return m.keys.GoLoad
}
func (m Model) loadAllKey() key.Binding {
if m.search.typing {
return m.keys.SearchLoadAll
}
return m.keys.GoLoadAll
}
// searchWidth is most of the screen: paths are long.
func searchWidth(screen int) int {
return max(40, min(screen-4, screen*9/10))
}
// searchViewBox draws the popup.
func (m Model) searchViewBox() string {
v := m.search
focused := !m.termBlurred
w := searchWidth(m.w)
listH := max(5, min(m.h-10, 14))
srcW := 10
for _, o := range v.scopes {
srcW = max(srcW, ansi.StringWidth(ansi.Strip(o.label))+ansi.StringWidth(o.note)+12) // marks, gap, borders
}
srcW = min(srcW, (w-4)*2/5)
resW := w - 4 - srcW
if v.tab != tabSearch {
resW = w - 4 // one list
}
row := func(o option, width int, cursor, active bool) string {
mark := o.mark
if mark == "" {
mark = " "
}
star := " "
if o.starred {
star = m.st.Favourite.Render("★ ")
}
label := panes.MarkMatches(o.label, o.hits, m.st.Match)
text := withNote(m.st, mark+" "+star+label, o.note, width-4) // borders, cursor column
if !cursor {
return " " + text
}
sel := m.st.Selected
if !active || !focused {
sel = m.st.SelectedBlurred
}
return " " + panes.Highlight("▸"+text, sel, width-3) // inside the borders
}
window := func(n, cursor, h int) (int, int) {
first := max(0, min(cursor-h/2, n-h))
return first, min(n, first+h)
}
inner := listH - 2
// The picked source is a choice that stays, like a tab: it keeps the
// full highlight while the results have the cursor.
var left []string
first, last := window(len(v.scopes), v.src, inner)
for i := first; i < last; i++ {
left = append(left, row(v.scopes[i], srcW, i == v.src, true))
}
var right []string
resRows := inner
if hint := m.searchHint(); hint != "" {
right = append(right, " "+hint)
resRows--
}
first, last = window(len(v.shown), v.res, resRows)
for i := first; i < last; i++ {
right = append(right, row(v.shown[i], resW, i == v.res, v.results))
}
if len(v.shown) == 0 {
msg := "no matches"
switch {
case v.input.Value() == "" && v.scope().id == scopeFavs:
msg = "nothing starred yet" + withKey(m.keys.Favourite, ": %s in the panes stars the selected item")
case v.input.Value() == "" && v.scope().id == scopeRecent:
msg = "nothing used yet"
case v.input.Value() == "":
msg = "nothing loaded here yet"
case v.tab == tabSearch && v.scope().id != allSources:
msg += " here · All searches everything"
case v.tab != tabSearch:
msg += withKey(m.keys.GoNextTab, " here · Search (%s) looks in everything loaded")
}
right = append(right, " "+m.st.Muted.Render(msg))
}
counter := func(i, n int) string {
if n == 0 {
return ""
}
return fmt.Sprintf("%d of %d", i+1, n)
}
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 _, ok := v.scopeAccount(); ok || v.onSource() {
resultsTitle = "Results in " + scopeName(v.scope())
}
var lists string
if v.tab == tabSearch {
lists = lipgloss.JoinHorizontal(lipgloss.Top,
panes.BoxCounted(m.st, title("Scope", !v.results), "", counter(v.src, len(v.scopes)), left, srcW, listH, !v.results && focused),
panes.BoxCounted(m.st, title(resultsTitle, v.results), "", counter(v.res, len(v.shown)), right, resW, listH, v.results && focused),
)
} else {
lists = panes.BoxCounted(m.st, title(resultsTitle, true), "", counter(v.res, len(v.shown)), right, resW, listH, focused)
}
var body []string
if v.pick != nil {
for _, l := range v.pickLines {
body = append(body, " "+l)
}
body = append(body, "")
}
body = append(body, " "+v.input.View(), "")
for _, l := range strings.Split(lists, "\n") {
body = append(body, " "+l)
}
body = append(body, "")
if v.tab == tabSearch {
for _, l := range m.searchLines() {
body = append(body, " "+l)
}
} else {
body = append(body, "") // the same height on every tab
}
km := m.keys
var foot string
switch {
case v.typing:
hints := []keyHint{move(km.PopupUp, km.PopupDown)}
if v.tab == tabSearch {
hints = append(hints, keyHint{joinKeys(km.SearchSources, km.SearchResults), "switch list"}, hint(km.SearchLoad, "load"))
}
foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, v.selectLabel()), hint(km.PopupClose, "stop typing"))...)
default:
hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}}
if v.tab == tabSearch {
hints = append(hints, keyHint{joinKeys(km.GoSources, km.GoResults), "list"}, hint(km.GoLoad, "load"), hint(km.GoLoadAll, "load all"))
}
foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, v.selectLabel()), hint(km.GoClose, "close"))...)
}
// Tabs in the title, the current one underlined, like the panes.
ts := panes.TitleStyle(m.st, focused)
head := ts.Render(goToTitle + " ")
if v.pick != nil {
head = ts.Render(v.pickTitle + " ")
}
for i, name := range tabNames {
if i > 0 {
head += ts.Render(" - ")
}
if i == v.tab {
head += ts.Underline(true).Render(name)
} else {
head += m.st.Muted.Render(name)
}
}
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
}
// selectLabel names what enter does with the selected entry.
func (v *searchView) selectLabel() string {
if v.pick != nil {
return "pick"
}
return "go"
}
// pickable is the link of a Go to entry that a picker can pick: a
// secret (not a key or certificate, nor a key in a plain env file).
func pickable(o option) (ref.Ref, bool) {
r, err := ref.Parse(o.star)
if err != nil || o.plain || r.IsShare() || r.IsSource() || r.IsContainer() || (r.Type != "" && r.Type != provider.TypeSecret) {
return ref.Ref{}, false
}
return r, true
}