Compare commits
4
Commits
114478d2f2
..
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
637bf6a253
|
||
|
|
29b0c76f40
|
||
|
|
df429002c9 | ||
|
|
0ff5204d4d |
No files matched your search
@@ -2,6 +2,12 @@
|
||||
|
||||
Every release of lazysecrets, newest first.
|
||||
|
||||
## v0.2.1 – 2026-10-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- A link opens lazysecrets with your cache and next to your running instances ([#71](https://git.kebcloud.no/keb/lazysecrets/pulls/71)) ([bb41767](https://git.kebcloud.no/keb/lazysecrets/commit/bb41767cbad00bab4f1824f4e0754cc7abd98c59))
|
||||
|
||||
## v0.2.0 – 2026-10-14
|
||||
|
||||
### Added
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 113 KiB |
+9
-12
@@ -8,7 +8,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
@@ -725,7 +724,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))
|
||||
}
|
||||
}
|
||||
@@ -1257,7 +1256,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
|
||||
@@ -1676,12 +1675,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() {
|
||||
@@ -1788,13 +1783,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))
|
||||
|
||||
@@ -306,3 +306,6 @@ func newTextInput(prompt string, width int) textinput.Model {
|
||||
in.SetWidth(width)
|
||||
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)) }
|
||||
@@ -116,7 +116,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)
|
||||
@@ -144,24 +144,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.
|
||||
@@ -199,7 +211,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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +257,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 {
|
||||
|
||||
+6
-11
@@ -39,10 +39,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 {
|
||||
@@ -70,8 +69,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)
|
||||
}
|
||||
@@ -259,11 +258,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
|
||||
|
||||
+1
-12
@@ -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)
|
||||
|
||||
+20
-16
@@ -8,7 +8,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
@@ -210,18 +209,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}}
|
||||
}
|
||||
@@ -243,6 +231,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
|
||||
@@ -831,10 +837,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")},
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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{
|
||||
|
||||
+18
-15
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -341,6 +341,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 {
|
||||
@@ -354,14 +367,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("◌")
|
||||
|
||||
@@ -95,9 +95,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 +614,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 +762,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 +795,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 +848,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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+27
-21
@@ -188,20 +188,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.")}
|
||||
@@ -211,30 +222,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)
|
||||
}
|
||||
@@ -298,14 +306,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))
|
||||
}
|
||||
@@ -322,9 +328,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,10 +341,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,7 +358,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)...)
|
||||
}
|
||||
@@ -373,7 +379,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 {
|
||||
|
||||
@@ -329,17 +329,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)
|
||||
@@ -370,26 +360,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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user