diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 29c9f50..7dcabf5 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "image/color" - "math" "os" "os/exec" "strings" @@ -724,7 +723,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.versionsOf != nil { m.setVersions(msg.items) } else { - m.secrets.SetItems(m.starredFirst(msg.items)) + m.secrets.SetItems(m.starredObjectsFirst(msg.items)) cmds = append(cmds, m.refreshShown(before)) } } @@ -1259,7 +1258,7 @@ func (m *Model) showObjects(c provider.Container, key string) tea.Cmd { m.secrets.SetLoading() return m.loadObjects(c, m.objType, key) } - m.secrets.SetItems(m.starredFirst(e.value)) + m.secrets.SetItems(m.starredObjectsFirst(e.value)) m.secrets.Select(m.cache.cursor[key]) if e.fresh(m.now()) { return nil @@ -1682,12 +1681,8 @@ func (m *Model) copyText(label, text string, sensitive bool) tea.Cmd { // clipTick fires when the countdown's displayed second next changes. func (m *Model) clipTick() tea.Cmd { - wait := m.clipDeadline.Sub(m.now()) % time.Second - if wait <= 0 { - wait = time.Second - } token := m.clipToken - return tea.Tick(wait, func(time.Time) tea.Msg { return clipTickMsg{token: token} }) + return tea.Tick(untilNextSecond(m.clipDeadline, m.now()), func(time.Time) tea.Msg { return clipTickMsg{token: token} }) } func (m *Model) cancelClipClear() { @@ -1794,13 +1789,15 @@ func (m Model) footerView() string { } else if l != nil && l.Filtering() { help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2) } else { - revealed := m.detail.State() == panes.ValueShown - help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), revealed: &revealed}.ShortHelp(), 2) + reveal := "show" + if m.detail.State() == panes.ValueShown { + reveal = "hide" + } + help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), reveal: reveal}.ShortHelp(), 2) } var lines []string if !m.clipDeadline.IsZero() { - left := max(0, int(math.Ceil(m.clipDeadline.Sub(m.now()).Seconds()))) - lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, left)))) + lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, secondsLeft(m.clipDeadline, m.now()))))) } if m.flash.text != "" { lines = append(lines, m.flashLine(m.flash)) diff --git a/internal/ui/app/commands.go b/internal/ui/app/commands.go index 08b0999..d34ae04 100644 --- a/internal/ui/app/commands.go +++ b/internal/ui/app/commands.go @@ -313,6 +313,9 @@ func newTextInput(prompt string, width int) textinput.Model { return in } +// pathInputWidth fits a link or a folder path in a prompt, in the window. +func (m Model) pathInputWidth() int { return max(20, min(m.w-12, 64)) } + func cmpOr(a, b string) string { if a != "" { return a diff --git a/internal/ui/app/favourites.go b/internal/ui/app/favourites.go index 4932634..d104f20 100644 --- a/internal/ui/app/favourites.go +++ b/internal/ui/app/favourites.go @@ -115,7 +115,7 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line if withSource { src := c.Source.Name - if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok { + if account := accountOf(c.Source); account != "" { src = account + " / " + src } line += sty.Muted.Render(" · " + src) @@ -143,24 +143,36 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string { } } -// starredContainersFirst moves starred vaults and scopes to the front, -// keeping the order otherwise. It doesn't modify items, which may be cached. -func (m Model) starredContainersFirst(items []provider.Container) []provider.Container { +// starredFirst moves starred items to the front, keeping the order +// otherwise. It doesn't modify items, which may be cached. +func starredFirst[T any](items []T, starred func(T) bool) []T { + rank := func(x T) int { + if starred(x) { + return 0 + } + return 1 + } out := slices.Clone(items) - slices.SortStableFunc(out, func(a, b provider.Container) int { - return boolRank(m.store.Node(storeKey(a)).StarredFor(a.Source.Provider)) - boolRank(m.store.Node(storeKey(b)).StarredFor(b.Source.Provider)) - }) + slices.SortStableFunc(out, func(a, b T) int { return rank(a) - rank(b) }) return out } -// starredFirst moves starred objects to the front, keeping the order -// otherwise. It doesn't modify items, which may be cached. -func (m Model) starredFirst(items []provider.Object) []provider.Object { - out := slices.Clone(items) - slices.SortStableFunc(out, func(a, b provider.Object) int { - return boolRank(m.store.Node(objectKey(a)).StarredFor(a.Container.Source.Provider)) - boolRank(m.store.Node(objectKey(b)).StarredFor(b.Container.Source.Provider)) +func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source { + return starredFirst(sources, func(s provider.Source) bool { + return m.store.Node(sourceKey(s)).StarredFor(s.Provider) + }) +} + +func (m Model) starredContainersFirst(items []provider.Container) []provider.Container { + return starredFirst(items, func(c provider.Container) bool { + return m.store.Node(storeKey(c)).StarredFor(c.Source.Provider) + }) +} + +func (m Model) starredObjectsFirst(items []provider.Object) []provider.Object { + return starredFirst(items, func(o provider.Object) bool { + return m.store.Node(objectKey(o)).StarredFor(o.Container.Source.Provider) }) - return out } // showLinkedContainers lists the vaults opened from links. @@ -198,7 +210,7 @@ func (m *Model) resortStarred() { m.containers.SetItems(m.starredContainersFirst(m.containers.Items())) } if m.versionsOf == nil { - m.secrets.SetItems(m.starredFirst(m.secrets.Items())) + m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items())) } } @@ -244,7 +256,7 @@ func (m *Model) toggleFavourite() tea.Cmd { c := o.Container toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) } if err = m.remember(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source, toggle); err == nil { - m.secrets.SetItems(m.starredFirst(m.secrets.Items())) + m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items())) } } switch { diff --git a/internal/ui/app/help.go b/internal/ui/app/help.go index 0dfa1ed..b93a34e 100644 --- a/internal/ui/app/help.go +++ b/internal/ui/app/help.go @@ -38,10 +38,9 @@ type contextHelp struct { commands bool // custom commands apply here account bool // the fourth pane shows Fields (fields.go) of pane // what the fourth pane describes - // revealed, when set, says whether the selected value is shown, so - // the reveal key says just what it'll do: show or hide, the same - // width so the bar doesn't shift. - revealed *bool + // reveal, when set, is what the reveal key will do, "show" or "hide": + // the same width so the bar doesn't shift. + reveal string } func (c contextHelp) ShortHelp() []key.Binding { @@ -69,8 +68,8 @@ func (c contextHelp) ShortHelp() []key.Binding { b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter) default: reveal := k.Reveal - if c.revealed != nil { - reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, map[bool]string{false: "show", true: "hide"}[*c.revealed])) + if c.reveal != "" { + reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, c.reveal)) } b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter) } @@ -263,11 +262,7 @@ func hint(b key.Binding, desc string) keyHint { return keyHint{keys.Name(b), des // move is "↑/↓ move" for an up and a down binding. func move(up, down key.Binding) keyHint { - u, d := keys.Name(up), keys.Name(down) - if u == "" || d == "" { - return keyHint{u + d, "move"} - } - return keyHint{u + "/" + d, "move"} + return keyHint{joinKeys(up, down), "move"} } // withKey fills a hint's format with the binding's key ("· %s loads diff --git a/internal/ui/app/jump.go b/internal/ui/app/jump.go index 066c841..12cec16 100644 --- a/internal/ui/app/jump.go +++ b/internal/ui/app/jump.go @@ -199,18 +199,7 @@ func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd { m.startNav(r, actGo, sourceRowKey(n.Account, n.Source)) return nil } - var choices []choice - for _, p := range m.providers { - if p.Kind() != r.Kind { - continue - } - for _, src := range m.sourceResults[p.ID()].sources { - if src.ID == n.Source && n.Source != "" { - row := sourceRowKey(p.ID(), src.ID) - choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row}) - } - } - } + choices := m.openWith(r.Kind, func(src provider.Source) bool { return src.ID == n.Source && n.Source != "" }) switch len(choices) { case 0: return m.openRef(r, actGo) diff --git a/internal/ui/app/links.go b/internal/ui/app/links.go index 465e936..e63cb02 100644 --- a/internal/ui/app/links.go +++ b/internal/ui/app/links.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "git.kebcloud.no/keb/lazysecrets/internal/provider" @@ -209,18 +208,7 @@ const defaultResolveTimeout = 30 * time.Second // openSource goes to a subscription or workspace, asking which account // when several list it. Sources are loaded already, so it needs no lookup. func (m *Model) openSource(r ref.Ref) { - var choices []choice - for _, p := range m.providers { - if p.Kind() != r.Kind { - continue - } - for _, src := range m.sourceResults[p.ID()].sources { - if sourceMatches(r, src) { - row := sourceRowKey(p.ID(), src.ID) - choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row}) - } - } - } + choices := m.openWith(r.Kind, func(src provider.Source) bool { return sourceMatches(r, src) }) if row, ok := preferredRow(r, choices); ok { choices = []choice{{sourceRow: row}} } @@ -242,6 +230,24 @@ func (m *Model) openSource(r ref.Ref) { } } +// openWith offers each loaded source of kind that matches, one per +// account that lists it. +func (m Model) openWith(kind provider.Kind, matches func(provider.Source) bool) []choice { + var choices []choice + for _, p := range m.providers { + if p.Kind() != kind { + continue + } + for _, src := range m.sourceResults[p.ID()].sources { + if matches(src) { + row := sourceRowKey(p.ID(), src.ID) + choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row}) + } + } + } + return choices +} + // refLabel draws what a link points at like the favourites list: the // provider's icon and the path in its colour, "󰠅 platform-prod / // kv-app-prod / api-key", with the type and version after it. The @@ -830,10 +836,8 @@ func (m *Model) shareObject(o provider.Object, k string) tea.Cmd { } func (m *Model) startOpenPrompt() tea.Cmd { - in := textinput.New() - in.Prompt = "open: " + in := newTextInput("open: ", m.pathInputWidth()) in.Placeholder = "lazysecrets:// link, Key Vault URL or reference" - in.SetWidth(max(20, min(m.w-12, 64))) m.prompt = &modal{ title: "Open a link", input: &in, lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")}, diff --git a/internal/ui/app/modal.go b/internal/ui/app/modal.go index 777ebdc..4c420d2 100644 --- a/internal/ui/app/modal.go +++ b/internal/ui/app/modal.go @@ -75,10 +75,8 @@ type modal struct { // showFilterMenu opens a menu with a filter input above the options. func (m *Model) showFilterMenu(title string, options []option) tea.Cmd { - in := textinput.New() - in.Prompt = "/" + in := newTextInput("/", 40) in.Placeholder = "type to filter" - in.SetWidth(40) m.prompt = &modal{title: title, options: options, all: options, filter: &in} m.prompt.move(0) m.flash = flash{} diff --git a/internal/ui/app/notes.go b/internal/ui/app/notes.go index 6b2d074..af08c74 100644 --- a/internal/ui/app/notes.go +++ b/internal/ui/app/notes.go @@ -3,7 +3,6 @@ package app import ( "strings" - "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" @@ -58,11 +57,9 @@ func (m *Model) editNote() tea.Cmd { c := o.Container key, name, path, src = objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source } - in := textinput.New() - in.Prompt = "note: " + in := newTextInput("note: ", 48) in.Placeholder = "e.g. prod SQL admin" in.CharLimit = 80 - in.SetWidth(48) in.SetValue(m.store.Node(key).Note) in.CursorEnd() m.prompt = &modal{ diff --git a/internal/ui/app/persist.go b/internal/ui/app/persist.go index 1fab15f..85d9926 100644 --- a/internal/ui/app/persist.go +++ b/internal/ui/app/persist.go @@ -29,41 +29,43 @@ type persistTickMsg struct{} type persistedMsg struct{ err error } -// keepCached reports whether a cache key's account still exists and -// isn't a repo: every key starts with the provider ID. -func (m *Model) keepCached(key string) bool { return m.keepProvider(cachefile.ProviderOf(key)) } - -func (m *Model) keepProvider(id string) bool { return cachefile.Keep(m.providers)(id) } +// keepers report whether a provider ID, or a cache key's, is an account +// that still exists and isn't a repo. +func (m *Model) keepers() (keep, keepKey func(string) bool) { + keep = cachefile.Keep(m.providers) + return keep, func(key string) bool { return keep(cachefile.ProviderOf(key)) } +} // loadCacheFile fills the cache from the file, leaving out what's too // old or belongs to accounts that aren't signed in any more. func (m *Model) loadCacheFile() { f := cachefile.Read(m.cachePath) + keep, keepKey := m.keepers() now := m.now() recent := func(at time.Time) bool { return now.Sub(at) < cachefile.MaxAge } for id, e := range f.Sources { - if m.keepProvider(id) && recent(e.At) { + if keep(id) && recent(e.At) { m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value} m.sourcesAt[id] = e.At } } for k, e := range f.Containers { - if m.keepCached(k) && recent(e.At) { + if keepKey(k) && recent(e.At) { m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At} } } for k, e := range f.Objects { - if m.keepCached(k) && recent(e.At) { + if keepKey(k) && recent(e.At) { m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At} } } for k, e := range f.Details { - if m.keepCached(k) && recent(e.At) { + if keepKey(k) && recent(e.At) { m.cache.details[k] = entry[provider.Object]{e.Value, e.At} } } for k, v := range f.Cursor { - if m.keepCached(k) { + if keepKey(k) { m.cache.cursor[k] = v } } @@ -72,29 +74,30 @@ func (m *Model) loadCacheFile() { // cacheSnapshot is the cache as the file holds it. func (m *Model) cacheSnapshot() cachefile.File { f := cachefile.New() + keep, keepKey := m.keepers() f.Dropped, m.cacheDropped = m.cacheDropped, nil for id, r := range m.sourceResults { - if r.err == nil && m.keepProvider(id) { + if r.err == nil && keep(id) { f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources} } } for k, e := range m.cache.containers { - if m.keepCached(k) { + if keepKey(k) { f.Containers[k] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value} } } for k, e := range m.cache.objects { - if m.keepCached(k) { + if keepKey(k) { f.Objects[k] = cachefile.Stamped[[]provider.Object]{At: e.at, Value: e.value} } } for k, e := range m.cache.details { - if m.keepCached(k) { + if keepKey(k) { f.Details[k] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value} } } for k, v := range m.cache.cursor { - if m.keepCached(k) { + if keepKey(k) { f.Cursor[k] = v } } diff --git a/internal/ui/app/reveal.go b/internal/ui/app/reveal.go index 1c314bf..1937e20 100644 --- a/internal/ui/app/reveal.go +++ b/internal/ui/app/reveal.go @@ -28,12 +28,22 @@ func (m *Model) startRevealTimer() tea.Cmd { // revealTickCmd fires when the displayed second next changes. func (m *Model) revealTickCmd() tea.Cmd { - wait := m.revealDeadline.Sub(m.now()) % time.Second - if wait <= 0 { - wait = time.Second - } token := m.revealToken - return tea.Tick(wait, func(time.Time) tea.Msg { return revealTickMsg{token} }) + return tea.Tick(untilNextSecond(m.revealDeadline, m.now()), func(time.Time) tea.Msg { return revealTickMsg{token} }) +} + +// untilNextSecond is how long until a countdown to deadline shows its +// next whole second. +func untilNextSecond(deadline, now time.Time) time.Duration { + if wait := deadline.Sub(now) % time.Second; wait > 0 { + return wait + } + return time.Second +} + +// secondsLeft is a countdown to deadline, in whole seconds rounded up. +func secondsLeft(deadline, now time.Time) int { + return max(0, int(math.Ceil(deadline.Sub(now).Seconds()))) } func (m *Model) revealTick(msg revealTickMsg) tea.Cmd { @@ -49,8 +59,7 @@ func (m *Model) revealTick(msg revealTickMsg) tea.Cmd { } func (m *Model) showHidesIn() { - left := max(0, int(math.Ceil(m.revealDeadline.Sub(m.now()).Seconds()))) - m.detail.SetHidesIn(fmt.Sprintf("%ds", left)) + m.detail.SetHidesIn(fmt.Sprintf("%ds", secondsLeft(m.revealDeadline, m.now()))) } // hideValue hides the revealed value and stops its countdown. diff --git a/internal/ui/app/search.go b/internal/ui/app/search.go index 7648853..437ada1 100644 --- a/internal/ui/app/search.go +++ b/internal/ui/app/search.go @@ -351,6 +351,19 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd { return cmd } +// vaultsLoaded counts the vaults whose secrets are cached or can't be +// read, and says whether any are loading. +func (m *Model) vaultsLoaded(vaults []provider.Container) (loaded int, loading bool) { + for _, c := range vaults { + key := cachefile.ObjectsKey(c, provider.TypeSecret) + loading = loading || m.crawl.pending[key] + if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] { + loaded++ + } + } + return loaded, loading +} + // sourceMark shows how much of a subscription or workspace is loaded: // ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable. func (m *Model) sourceMark(row string) string { @@ -364,14 +377,7 @@ func (m *Model) sourceMark(row string) string { if !ok { return m.st.Muted.Render("○") } - loaded, loading := 0, false - for _, c := range e.value { - key := cachefile.ObjectsKey(c, provider.TypeSecret) - loading = loading || m.crawl.pending[key] - if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] { - loaded++ - } - } + loaded, loading := m.vaultsLoaded(e.value) switch { case loading: return m.st.Muted.Render("◌") diff --git a/internal/ui/app/searchview.go b/internal/ui/app/searchview.go index 8f7b469..b16e335 100644 --- a/internal/ui/app/searchview.go +++ b/internal/ui/app/searchview.go @@ -12,7 +12,6 @@ import ( "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" - "git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/match" "git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/ref" @@ -95,9 +94,7 @@ 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 + in := newTextInput("/", 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 @@ -616,13 +613,7 @@ func (m Model) loadHint() string { } return m.st.Warning.Render("⚠ not loaded" + withKey(m.loadKey(), " · %s loads "+name)) } - loaded := 0 - for _, c := range e.value { - key := cachefile.ObjectsKey(c, provider.TypeSecret) - if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] { - loaded++ - } - } + loaded, _ := m.vaultsLoaded(e.value) if loaded == len(e.value) { return "" } @@ -770,7 +761,7 @@ func (m Model) searchViewBox() string { body = append(body, " "+m.st.Error.Render(l)) } } - body = append(body, "", " "+m.goToTabs(ts)) + body = append(body, "", " "+panes.Tabs(m.st, ts, tabNames[:], m.search.tab)) } body = append(body, " "+v.input.View(), "") for _, l := range strings.Split(lists, "\n") { @@ -803,7 +794,7 @@ func (m Model) searchViewBox() string { // Tabs in the title, the current one underlined, like the panes; a // picker has its own title, and the tabs over the input. if v.picker == nil { - head := ts.Render(goToTitle+" ") + m.goToTabs(ts) + head := ts.Render(goToTitle+" ") + panes.Tabs(m.st, ts, tabNames[:], m.search.tab) return panes.Box(m.st, head, foot, body, w, len(body)+2, focused) } // A picker: in its own border, under what it's for. @@ -856,19 +847,3 @@ func pickable(o option) (ref.Ref, bool) { } return r, true } - -// goToTabs draws Go to's tabs, the current one underlined. -func (m Model) goToTabs(ts lipgloss.Style) string { - var out string - for i, name := range tabNames { - if i > 0 { - out += ts.Render(" - ") - } - if i == m.search.tab { - out += ts.Underline(true).Render(name) - } else { - out += m.st.Muted.Render(name) - } - } - return out -} diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index d693b27..7464d27 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -10,7 +10,6 @@ import ( "slices" "strings" - "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" @@ -338,9 +337,7 @@ func (m *Model) openFailed(s share.Sealed, err error, whole bool) { // askShareDir asks where to save files from a repo that isn't here. It // stays until they're saved, saying why when they can't be. func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd { - in := textinput.New() - in.Prompt = "folder: " - in.SetWidth(max(20, min(m.w-12, 64))) + in := newTextInput("folder: ", m.pathInputWidth()) dir := m.placeDir if dir == "" { dir, _ = os.Getwd() diff --git a/internal/ui/app/sources.go b/internal/ui/app/sources.go index e1b3bd4..ccc8269 100644 --- a/internal/ui/app/sources.go +++ b/internal/ui/app/sources.go @@ -134,10 +134,7 @@ func (m *Model) rebuildSources() { } for _, p := range m.providers { kind, id := p.Kind(), p.ID() - account, _ := strings.CutPrefix(p.Label(), string(kind)+" · ") - if account == p.Label() { - account = "" - } + account := accountOf(provider.Source{Kind: kind, ProviderLabel: p.Label()}) r, done := m.sourceResults[id] sources := r.sources if !flat { @@ -215,17 +212,6 @@ func (m Model) currentSource() (provider.Source, bool) { return provider.Source{}, false } -// starredSourcesFirst moves starred sources to the front, keeping the -// order otherwise. It doesn't modify sources. -func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source { - out := slices.Clone(sources) - rank := func(s provider.Source) int { - return boolRank(m.store.Node(sourceKey(s)).StarredFor(s.Provider)) - } - slices.SortStableFunc(out, func(a, b provider.Source) int { return rank(a) - rank(b) }) - return out -} - // starRank orders the Favourites row, then starred sources, then the rest. func starRank(mem *store.Store, r sourceRow) int { switch { @@ -239,13 +225,6 @@ func starRank(mem *store.Store, r sourceRow) int { return 3 } -func boolRank(starred bool) int { - if starred { - return 0 - } - return 1 -} - // selectedSource is the source under the cursor; false on a header. func (m Model) selectedSource() (provider.Source, bool) { r, ok := m.sources.Selected() diff --git a/internal/ui/app/update.go b/internal/ui/app/update.go index 03eee22..3726463 100644 --- a/internal/ui/app/update.go +++ b/internal/ui/app/update.go @@ -2,13 +2,11 @@ package app import ( "context" - "errors" "strings" "time" tea "charm.land/bubbletea/v2" - "git.kebcloud.no/keb/lazysecrets/internal/browser" "git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/update" ) @@ -130,15 +128,7 @@ func (m *Model) showUpdate() { run: func(m *Model) tea.Cmd { return m.install(r) }}} if r.URL != "" { opts = append(opts, option{key: "o", label: "Open the release page", run: func(m *Model) tea.Cmd { - switch err := m.openURL(r.URL); { - case errors.Is(err, browser.ErrRemote): - cmd := m.copyText("link to the release", r.URL, false) - m.flash.text += " (can't open a browser over SSH)" - return cmd - case err != nil: - m.flash = flash{text: "open: " + err.Error(), isErr: true} - } - return nil + return m.openPage("the release page", r.URL, func() {}) }}) } m.showMenu("lazysecrets "+r.Tag, lines, opts) diff --git a/internal/ui/panes/box.go b/internal/ui/panes/box.go index c0b8226..6f59891 100644 --- a/internal/ui/panes/box.go +++ b/internal/ui/panes/box.go @@ -69,3 +69,20 @@ func TitleStyle(st styles.Styles, focused bool) lipgloss.Style { } return st.TitleBlurred } + +// Tabs draws tab names for a title in style ts, the current one +// underlined and the rest muted. +func Tabs(st styles.Styles, ts lipgloss.Style, names []string, current int) string { + var out string + for i, name := range names { + if i > 0 { + out += ts.Render(" - ") + } + if i == current { + out += ts.Underline(true).Render(name) + } else { + out += st.Muted.Render(name) + } + } + return out +} diff --git a/internal/ui/panes/detail.go b/internal/ui/panes/detail.go index 8d363c9..66e52b3 100644 --- a/internal/ui/panes/detail.go +++ b/internal/ui/panes/detail.go @@ -198,20 +198,31 @@ func carryStyle(lines []string) []string { return lines } +var valueIndent = strings.Repeat(" ", 12) + +// fieldLabel starts a detail line: the label, padded so values line up +// at valueIndent. +func fieldLabel(st styles.Styles, label string) string { + return " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " " +} + // lines renders the body, wrapped to the pane width. func (d Detail) lines(st styles.Styles) []string { inner := max(1, d.w-3) var out []string field := func(label, value string) { - prefix := " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " " - indent := strings.Repeat(" ", 12) wrapped := carryStyle(strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n")) - out = append(out, prefix+wrapped[0]) + out = append(out, fieldLabel(st, label)+wrapped[0]) for _, w := range wrapped[1:] { - out = append(out, indent+w) + out = append(out, valueIndent+w) } } muted := st.Muted.Render + note := func() { + if d.note != "" { + field("note", st.Favourite.Render(d.note)) + } + } if d.container == nil { return []string{muted(" Select a vault or scope to browse its secrets.")} @@ -221,30 +232,27 @@ func (d Detail) lines(st styles.Styles) []string { if containerLabel == "" { containerLabel = "vault" } + source := d.Icons.Prefix(c.Source.Kind) + cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind)) + " / " + c.Source.Name if d.secret == nil { field(containerLabel, c.Name) - if d.note != "" { - field("note", st.Favourite.Render(d.note)) - } + note() if c.Detail != "" { field("", muted(c.Detail)) } - field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name) + field("source", source) out = append(out, "", muted(" Select a secret to see its details.")) return out } s := *d.secret field("name", s.Name) - if d.note != "" { - field("note", st.Favourite.Render(d.note)) - } + note() field(containerLabel, c.Name) if c.Source.Kind != provider.KindAzure && c.Detail != "" { field("", muted(c.Detail)) } - field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name) + field("source", source) if s.Version != "" { field("version", s.Version) } @@ -308,14 +316,12 @@ func (d Detail) lines(st styles.Styles) []string { case provider.TypeCertificate: label, hidden, reveal = "cert", "", "show the certificate" } - indent := strings.Repeat(" ", 12) - out = append(out, "") switch d.state { case ValueHidden: if hidden != "" { field(label, hidden) - out = append(out, indent+hints(reveal)) + out = append(out, valueIndent+hints(reveal)) } else { field(label, hints(reveal)) } @@ -332,9 +338,9 @@ func (d Detail) lines(st styles.Styles) []string { for j, w := range strings.Split(ansi.Wrap(text, max(1, inner-11-len(lead)), ""), "\n") { w = lead + w if i == 0 && j == 0 { - out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Error.Render(w)) + out = append(out, fieldLabel(st, label)+st.Error.Render(w)) } else { - out = append(out, strings.Repeat(" ", 12)+st.Error.Render(w)) + out = append(out, valueIndent+st.Error.Render(w)) } } } @@ -345,10 +351,10 @@ func (d Detail) lines(st styles.Styles) []string { for _, line := range lines { for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") { if first { - out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w)) + out = append(out, fieldLabel(st, label)+st.Value.Render(w)) first = false } else { - out = append(out, indent+st.Value.Render(w)) + out = append(out, valueIndent+st.Value.Render(w)) } } } @@ -362,7 +368,7 @@ func (d Detail) lines(st styles.Styles) []string { if d.hidesIn != "" { more = append(more, "hides in "+d.hidesIn) } - out = append(out, indent+hints("hide", more...)) + out = append(out, valueIndent+hints("hide", more...)) } return append(out, d.versionLines(st)...) } @@ -383,7 +389,7 @@ func (d Detail) versionLines(st styles.Styles) []string { head += muted(" · " + d.VersionsKey + " to browse") } } - out := []string{"", " " + st.Label.Render(fmt.Sprintf("%-10s", "versions")) + " " + head} + out := []string{"", fieldLabel(st, "versions") + head} current := d.versions[0].Version for i, v := range d.versions { if i == maxVersionLines { diff --git a/internal/ui/panes/list.go b/internal/ui/panes/list.go index c1feb06..66eb5e3 100644 --- a/internal/ui/panes/list.go +++ b/internal/ui/panes/list.go @@ -338,17 +338,7 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string { ts := TitleStyle(st, focused) title := ts.Render(l.Title) if len(l.Tabs) > 1 { - for i, tab := range l.Tabs { - sep := " - " - if i == 0 { - sep = " " - } - if i == l.Tab { - title += ts.Render(sep) + ts.Underline(true).Render(tab) - } else { - title += ts.Render(sep) + st.Muted.Render(tab) - } - } + title += ts.Render(" ") + Tabs(st, ts, l.Tabs, l.Tab) } if q := l.input.Value(); q != "" && !l.filtering { title += ts.Render(" /" + q) @@ -379,26 +369,13 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string { default: for i := l.offset; i < len(l.visible) && i < l.offset+rows; i++ { line := l.item.Render(st, l.items[l.visible[i]], inner-1) - plain := ansi.Strip(line) - hits := l.highlight(i, plain) + line = " " + MarkMatches(line, l.highlight(i, ansi.Strip(line)), st.Match) if i == l.cursor { base := st.SelectedBlurred if focused { base = st.Selected } - text := " " + line - if len(hits) > 0 { - for j := range hits { - hits[j]++ // leading space - } - text = lipgloss.StyleRanges(text, matchRanges(" "+plain, hits, st.Match)...) - } - line = Highlight(ansi.Truncate(text, inner, "…"), base, inner) - } else if len(hits) > 0 { - // Style just the matches, keeping the row's own colours. - line = " " + lipgloss.StyleRanges(line, matchRanges(plain, hits, st.Match)...) - } else { - line = " " + line + line = Highlight(ansi.Truncate(line, inner, "…"), base, inner) } body = append(body, line) }