Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
5987787f38
130 files changed
+26897
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,50 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// cacheTTL is how long cached metadata is shown without reloading. Older
|
||||
// entries are still shown immediately, then reloaded in the background.
|
||||
const cacheTTL = 2 * time.Minute
|
||||
|
||||
// cache holds metadata: never secret values or errors. It's kept between
|
||||
// runs in the cache file (persist.go).
|
||||
type cache struct {
|
||||
containers map[string]entry[[]provider.Container] // by source key
|
||||
objects map[string]entry[[]provider.Object] // by container key (ID|type)
|
||||
details map[string]entry[provider.Object] // Describe results by ID@version
|
||||
// cursor remembers the selected child per parent key, so returning to
|
||||
// a subscription or vault puts the cursor back where it was.
|
||||
cursor map[string]string
|
||||
}
|
||||
|
||||
type entry[T any] struct {
|
||||
value T
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func newCache() cache {
|
||||
return cache{
|
||||
containers: map[string]entry[[]provider.Container]{},
|
||||
objects: map[string]entry[[]provider.Object]{},
|
||||
details: map[string]entry[provider.Object]{},
|
||||
cursor: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (e entry[T]) fresh(now time.Time) bool { return now.Sub(e.at) < cacheTTL }
|
||||
|
||||
// detailKey includes the account: the result carries its container, and
|
||||
// later requests for the object are routed by it.
|
||||
func detailKey(o provider.Object) string {
|
||||
return containerKey(o.Container) + "|" + o.ID + "@" + o.Version
|
||||
}
|
||||
|
||||
func (c cache) rememberCursor(parent, child string) {
|
||||
if parent != "" && child != "" {
|
||||
c.cursor[parent] = child
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
)
|
||||
|
||||
// counting records how often lists and details are fetched.
|
||||
type counting struct {
|
||||
provider.Provider
|
||||
containers, objects, describes, values, versions int
|
||||
}
|
||||
|
||||
func (c *counting) Versions(ctx context.Context, o provider.Object) ([]provider.Object, error) {
|
||||
c.versions++
|
||||
return c.Provider.Versions(ctx, o)
|
||||
}
|
||||
|
||||
func (c *counting) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
c.containers++
|
||||
return c.Provider.Containers(ctx, src)
|
||||
}
|
||||
|
||||
func (c *counting) Objects(ctx context.Context, ct provider.Container, t provider.ObjectType) ([]provider.Object, error) {
|
||||
c.objects++
|
||||
return c.Provider.Objects(ctx, ct, t)
|
||||
}
|
||||
|
||||
func (c *counting) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
|
||||
c.describes++
|
||||
return c.Provider.Describe(ctx, o)
|
||||
}
|
||||
|
||||
func (c *counting) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
c.values++
|
||||
return c.Provider.Value(ctx, o)
|
||||
}
|
||||
|
||||
func newCachingDriver(t *testing.T) (*driver, *counting, *time.Time) {
|
||||
t.Helper()
|
||||
p := &counting{Provider: fake.Azure(0)}
|
||||
d := newDriver(t, p)
|
||||
// Entries from the initial load were stamped with the real clock.
|
||||
clock := time.Now()
|
||||
d.m.now = func() time.Time { return clock }
|
||||
return d, p, &clock
|
||||
}
|
||||
|
||||
func TestCacheAvoidsRefetchAndRestoresCursor(t *testing.T) {
|
||||
t.Parallel()
|
||||
d, p, _ := newCachingDriver(t)
|
||||
d.keys("2", "j", "3", "j") // kv-shared-prod... then back
|
||||
d.keys("2", "k", "3", "j") // kv-app-prod, cursor to api-key
|
||||
d.keys("1", "j", "k") // away to platform-dev and back
|
||||
before := *p
|
||||
|
||||
d.keys("j", "k")
|
||||
if p.containers != before.containers || p.objects != before.objects {
|
||||
t.Errorf("fresh cache refetched: containers %d→%d, objects %d→%d",
|
||||
before.containers, p.containers, before.objects, p.objects)
|
||||
}
|
||||
if _, c, s := d.selected(); c != "kv-app-prod" || s != "api-key" {
|
||||
t.Errorf("cursor not restored: %s/%s", c, s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleCacheShownThenReloaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
d, p, clock := newCachingDriver(t)
|
||||
d.keys("j") // platform-dev
|
||||
*clock = clock.Add(cacheTTL + time.Second)
|
||||
before := p.containers
|
||||
d.keys("k") // back to platform-prod: stale
|
||||
if p.containers != before+1 {
|
||||
t.Errorf("stale entry not reloaded: %d calls, want %d", p.containers, before+1)
|
||||
}
|
||||
if _, c, _ := d.selected(); c != "kv-app-prod" {
|
||||
t.Errorf("selection lost across background reload: %q", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshBypassesCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
d, p, _ := newCachingDriver(t)
|
||||
before := p.objects
|
||||
d.keys("3", "r")
|
||||
if p.objects != before+1 {
|
||||
t.Errorf("r did not reload: %d calls, want %d", p.objects, before+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValuesAndErrorsNotCached(t *testing.T) {
|
||||
t.Parallel()
|
||||
d, p, _ := newCachingDriver(t)
|
||||
d.keys("space", "space", "space")
|
||||
if p.values != 2 {
|
||||
t.Errorf("value fetched %d times for two reveals, want 2", p.values)
|
||||
}
|
||||
|
||||
d.keys("2", "G") // kv-restricted: 403
|
||||
if _, ok := d.m.cache.objects[d.m.selContainer]; ok {
|
||||
t.Error("error result cached")
|
||||
}
|
||||
before := p.objects
|
||||
d.keys("k", "j")
|
||||
if p.objects != before+2 {
|
||||
t.Errorf("failed list not retried: %d calls, want %d", p.objects, before+2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignInDropsCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
d, _, _ := newCachingDriver(t)
|
||||
d.send(loginDoneMsg{})
|
||||
// Everything is reloaded fresh, so only the current selection is cached.
|
||||
if n := len(d.m.cache.containers); n != 1 {
|
||||
t.Errorf("containers cached after sign-in = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// runAll runs cmd and the commands it batches, ignoring their messages.
|
||||
func runAll(cmd tea.Cmd) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if b, ok := cmd().(tea.BatchMsg); ok {
|
||||
for _, c := range b {
|
||||
runAll(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingOnDropsUnsettledLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &counting{Provider: fake.Azure(0)}
|
||||
d := newDriverOpts(t, Options{}, c)
|
||||
d.m.settle = 20 * time.Millisecond
|
||||
before := c.containers
|
||||
|
||||
// j to platform-dev and straight back: its load never goes out.
|
||||
m1, passed := d.m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"})
|
||||
_, back := m1.Update(tea.KeyPressMsg{Code: 'k', Text: "k"})
|
||||
runAll(back)
|
||||
runAll(passed)
|
||||
if c.containers != before {
|
||||
t.Errorf("passing over a subscription loaded it: %d calls", c.containers-before)
|
||||
}
|
||||
|
||||
// Stopping on it loads it once the cursor settles.
|
||||
_, stop := d.m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"})
|
||||
runAll(stop)
|
||||
if c.containers != before+1 {
|
||||
t.Errorf("stopping on a subscription: %d calls, want 1", c.containers-before)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
)
|
||||
|
||||
// Custom commands (customCommands in the config) run a shell command on
|
||||
// the selected item: by their key in the pane they're for, or from the
|
||||
// commands menu (x). Prompts ask for text first; commands that get the
|
||||
// secret value ask before running. The value reaches the command on its
|
||||
// input or in an environment variable, never on its command line.
|
||||
|
||||
// commandTimeout bounds commands whose output is captured; terminal ones
|
||||
// run as long as the user likes.
|
||||
const commandTimeout = 5 * time.Minute
|
||||
|
||||
type commandValueMsg struct {
|
||||
cmd commands.Command
|
||||
rendered string
|
||||
value provider.SecretValue
|
||||
err error
|
||||
}
|
||||
|
||||
type commandDoneMsg struct {
|
||||
cmd commands.Command
|
||||
out string
|
||||
err error
|
||||
}
|
||||
|
||||
// paneContext is the custom command context of a pane.
|
||||
func paneContext(p pane) string {
|
||||
switch p {
|
||||
case paneSources:
|
||||
return commands.Sources
|
||||
case paneContainers:
|
||||
return commands.Vaults
|
||||
}
|
||||
return commands.Secrets
|
||||
}
|
||||
|
||||
// commandsHere are the custom commands for the focused pane.
|
||||
func (m *Model) commandsHere() []commands.Command {
|
||||
if m.focus == paneStatus {
|
||||
return nil
|
||||
}
|
||||
var out []commands.Command
|
||||
for _, c := range m.commands {
|
||||
if c.Context == commands.Global || c.Context == paneContext(m.focus) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// commandForKey is the custom command a key runs here, if any.
|
||||
func (m *Model) commandForKey(msg tea.KeyPressMsg) (commands.Command, bool) {
|
||||
for _, c := range m.commandsHere() {
|
||||
if c.Key != "" && c.Key == msg.String() {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return commands.Command{}, false
|
||||
}
|
||||
|
||||
// showCommands lists the custom commands for the focused pane.
|
||||
func (m *Model) showCommands() tea.Cmd {
|
||||
cmds := m.commandsHere()
|
||||
if len(cmds) == 0 {
|
||||
m.flash = warning("no custom commands for the " + strings.ToLower(paneNames[m.focus]) + " pane: see customCommands in the config")
|
||||
return nil
|
||||
}
|
||||
var opts []option
|
||||
for _, c := range cmds {
|
||||
label := c.Description
|
||||
if label == "" {
|
||||
label = c.Command
|
||||
}
|
||||
if c.Key != "" {
|
||||
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
|
||||
}
|
||||
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
|
||||
}
|
||||
return m.showFilterMenu("Commands", opts)
|
||||
}
|
||||
|
||||
// startCommand runs c on the selection: prompts, then confirmation, then
|
||||
// the command.
|
||||
func (m *Model) startCommand(c commands.Command) tea.Cmd {
|
||||
it, obj, ok := m.commandItem(c.Context)
|
||||
if !ok {
|
||||
m.flash = warning(map[string]string{
|
||||
commands.Sources: "select a subscription or workspace to run " + c.Description + " on",
|
||||
commands.Vaults: "select a vault or scope to run " + c.Description + " on",
|
||||
commands.Secrets: "select a secret to run " + c.Description + " on",
|
||||
}[c.Context])
|
||||
return nil
|
||||
}
|
||||
it.Prompt = map[string]commands.Q{}
|
||||
return m.askPrompt(c, it, obj, 0)
|
||||
}
|
||||
|
||||
// askPrompt asks c's prompts in turn, then confirms or runs it.
|
||||
func (m *Model) askPrompt(c commands.Command, it commands.Item, obj *provider.Object, i int) tea.Cmd {
|
||||
if i == len(c.Prompts) {
|
||||
return m.confirmCommand(c, it, obj)
|
||||
}
|
||||
p := c.Prompts[i]
|
||||
in := newTextInput(p.Title+": ", 48)
|
||||
in.SetValue(p.Default)
|
||||
in.CursorEnd()
|
||||
m.prompt = &modal{
|
||||
title: cmpOr(c.Description, "Command"), input: &in,
|
||||
submit: func(m *Model, text string) tea.Cmd {
|
||||
it.Prompt[p.Name] = commands.Q(text)
|
||||
return m.askPrompt(c, it, obj, i+1)
|
||||
},
|
||||
footer: footer(hint(m.keys.PopupSelect, "next"), hint(m.keys.PopupClose, "cancel")),
|
||||
}
|
||||
return m.prompt.input.Focus()
|
||||
}
|
||||
|
||||
func (m *Model) confirmCommand(c commands.Command, it commands.Item, obj *provider.Object) tea.Cmd {
|
||||
rendered, err := c.Render(it)
|
||||
if err != nil {
|
||||
m.flash = flash{text: c.Description + ": " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
if !c.Confirms() {
|
||||
return m.runCommand(c, rendered, obj)
|
||||
}
|
||||
var lines []string
|
||||
for _, l := range strings.Split(ansi.Wrap(rendered, 70, " "), "\n") {
|
||||
lines = append(lines, m.st.Muted.Render(l))
|
||||
}
|
||||
switch {
|
||||
case c.Value == "stdin":
|
||||
lines = append(lines, "", "It gets the value of "+string(it.Name)+" on its input.")
|
||||
case strings.HasPrefix(c.Value, "env:"):
|
||||
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
|
||||
}
|
||||
m.showMenu("Run "+cmpOr(c.Description, "command")+"?", lines, []option{
|
||||
{key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCommand fetches the value if the command takes it, then runs it.
|
||||
func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Object) tea.Cmd {
|
||||
if c.Value == "" || obj == nil {
|
||||
return m.execCommand(c, rendered, provider.SecretValue{})
|
||||
}
|
||||
o := *obj
|
||||
p := m.providerFor(o.Container.Source)
|
||||
m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"}
|
||||
timeout := m.timeout
|
||||
m.usedObject(o)
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
v, err := p.Value(ctx, o)
|
||||
return commandValueMsg{cmd: c, rendered: rendered, value: v, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// execCommand runs the command as its output mode says.
|
||||
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
|
||||
shell := cmpOr(os.Getenv("SHELL"), "/bin/sh")
|
||||
setValue := func(cmd *exec.Cmd) {
|
||||
switch {
|
||||
case c.Value == "stdin":
|
||||
cmd.Stdin = strings.NewReader(value.Reveal())
|
||||
case strings.HasPrefix(c.Value, "env:"):
|
||||
cmd.Env = append(os.Environ(), strings.TrimPrefix(c.Value, "env:")+"="+value.Reveal())
|
||||
}
|
||||
}
|
||||
if c.Out() == commands.Terminal {
|
||||
// Pause before coming back, reading the terminal itself: the
|
||||
// command's input may be taken.
|
||||
script := rendered + "\nprintf '\\n%s' 'Press enter to return to lazysecrets'; read -r _ </dev/tty"
|
||||
cmd := exec.Command(shell, "-c", script)
|
||||
setValue(cmd)
|
||||
m.flash = flash{}
|
||||
return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} })
|
||||
}
|
||||
m.flash = flash{text: "running " + cmpOr(c.Description, "command") + "…"}
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, shell, "-c", rendered)
|
||||
setValue(cmd)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return commandDoneMsg{cmd: c, out: string(out), err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleCommandValue(msg commandValueMsg) tea.Cmd {
|
||||
if msg.err != nil {
|
||||
m.noteErr(msg.err)
|
||||
m.flash = flash{text: msg.cmd.Description + ": " + msg.err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
return m.execCommand(msg.cmd, msg.rendered, msg.value)
|
||||
}
|
||||
|
||||
// maxOutputLines caps what a popup shows of a command's output.
|
||||
const maxOutputLines = 30
|
||||
|
||||
func (m *Model) handleCommandDone(msg commandDoneMsg) {
|
||||
name := cmpOr(msg.cmd.Description, "command")
|
||||
if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil {
|
||||
switch {
|
||||
case msg.err != nil:
|
||||
out := strings.TrimSpace(msg.out)
|
||||
if i := strings.LastIndexByte(out, '\n'); i >= 0 {
|
||||
out = out[i+1:] // the last line, usually the error
|
||||
}
|
||||
m.flash = flash{text: strings.TrimSpace(name + " failed: " + msg.err.Error() + " " + out), isErr: true}
|
||||
default:
|
||||
m.flash = done(name)
|
||||
}
|
||||
return
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(ansi.Strip(msg.out), "\n"), "\n")
|
||||
if len(lines) > maxOutputLines {
|
||||
lines = append(lines[:maxOutputLines], m.st.Muted.Render(fmt.Sprintf("… %d more lines", len(lines)-maxOutputLines)))
|
||||
}
|
||||
title := "✓ " + name
|
||||
if msg.err != nil {
|
||||
title = "✗ " + name + ": " + msg.err.Error()
|
||||
}
|
||||
m.showMenu(title, lines, nil)
|
||||
}
|
||||
|
||||
// commandItem fills a command's fields from the selection: the source,
|
||||
// and the vault and secret as deep as the context goes (global: as deep
|
||||
// as the focus). ok is false if the context's item isn't selected.
|
||||
func (m *Model) commandItem(ctx string) (commands.Item, *provider.Object, bool) {
|
||||
depth := map[string]int{commands.Sources: 0, commands.Vaults: 1, commands.Secrets: 2}[ctx]
|
||||
if ctx == commands.Global {
|
||||
depth = min(int(m.focus), 2)
|
||||
}
|
||||
var it commands.Item
|
||||
src, ok := m.selectedSource()
|
||||
if !ok && depth == 0 {
|
||||
return it, nil, false
|
||||
}
|
||||
if ok {
|
||||
it.Kind, it.Account = commands.Q(strings.ToLower(string(src.Kind))), commands.Q(accountOf(src))
|
||||
it.Source, it.SourceID, it.URL = commands.Q(src.Name), commands.Q(src.ID), commands.Q(src.URL)
|
||||
it.Note = commands.Q(m.store.Node(sourceKey(src)).Note)
|
||||
if r, err := ref.ForSource(src); err == nil {
|
||||
it.Link = commands.Q(r.String())
|
||||
}
|
||||
}
|
||||
if depth == 0 {
|
||||
return it, nil, true
|
||||
}
|
||||
c, ok := m.containers.Selected()
|
||||
if !ok {
|
||||
return it, nil, ctx == commands.Global
|
||||
}
|
||||
it.Vault, it.URL, it.Note = commands.Q(c.Name), commands.Q(c.URL), commands.Q(m.store.Node(storeKey(c)).Note)
|
||||
if c.Source.Kind == provider.KindAzure {
|
||||
it.VaultURL = commands.Q(c.Detail)
|
||||
}
|
||||
if r, err := ref.ForContainer(c, m.objType); err == nil {
|
||||
it.Link = commands.Q(r.String())
|
||||
}
|
||||
if depth == 1 {
|
||||
return it, nil, true
|
||||
}
|
||||
o, ok := m.detail.Secret()
|
||||
if !ok {
|
||||
return it, nil, ctx == commands.Global
|
||||
}
|
||||
version := ""
|
||||
if m.versionsOf != nil {
|
||||
version = o.Version // run from the versions view: that version
|
||||
}
|
||||
it.Name, it.Type, it.Version = commands.Q(o.Name), commands.Q(strings.ToLower(string(o.Type))), commands.Q(version)
|
||||
it.Reference, it.URL, it.Note = commands.Q(o.Reference), commands.Q(o.URL), commands.Q(m.store.Node(objectKey(o)).Note)
|
||||
if !o.Expires.IsZero() {
|
||||
it.Expires = commands.Q(o.Expires.Format(time.RFC3339))
|
||||
}
|
||||
if r, err := ref.ForObject(o, version); err == nil {
|
||||
it.Link = commands.Q(r.String())
|
||||
}
|
||||
if version != "" {
|
||||
o.Version = version
|
||||
} else {
|
||||
o.Version = "" // the current value, like copying it
|
||||
}
|
||||
return it, &o, true
|
||||
}
|
||||
|
||||
func newTextInput(prompt string, width int) textinput.Model {
|
||||
in := textinput.New()
|
||||
in.Prompt = prompt
|
||||
in.SetWidth(width)
|
||||
return in
|
||||
}
|
||||
|
||||
func cmpOr(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||
)
|
||||
|
||||
func commandDriver(t *testing.T, cmds ...commands.Command) *driver {
|
||||
t.Setenv("SHELL", "/bin/sh")
|
||||
d := newDriverOpts(t, Options{Commands: cmds})
|
||||
d.wait = 5 * time.Second
|
||||
return d
|
||||
}
|
||||
|
||||
func TestCommandGetsValueOnInput(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), "out")
|
||||
d := commandDriver(t, commands.Command{
|
||||
Key: "K", Context: commands.Secrets, Description: "Save it",
|
||||
Command: "cat > " + out + " && echo {{.Name}} {{.Prompt.ns}} >> " + out,
|
||||
Value: "stdin", Prompts: []commands.Prompt{{Name: "ns", Title: "Namespace", Default: "team a"}},
|
||||
})
|
||||
d.keys("3", "K") // db-password
|
||||
if !strings.Contains(d.screen(), "Namespace: team a") {
|
||||
t.Fatalf("no prompt:\n%s", d.screen())
|
||||
}
|
||||
d.keys("enter")
|
||||
scr := d.screen()
|
||||
// The command as shown, unwrapped: where it wraps depends on the
|
||||
// temporary path's length.
|
||||
shown := ""
|
||||
if d.m.prompt != nil {
|
||||
shown = ansiStrip(strings.Join(d.m.prompt.lines, ""))
|
||||
}
|
||||
if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") {
|
||||
t.Fatalf("no confirmation with the command %q:\n%s", shown, scr)
|
||||
}
|
||||
if strings.Contains(scr, "correct-horse") {
|
||||
t.Fatal("value shown in the confirmation")
|
||||
}
|
||||
d.keys("y")
|
||||
b, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatalf("command didn't run: %v\n%s", err, d.screen())
|
||||
}
|
||||
if got := string(b); got != "correct-horse-battery-staple"+"db-password team a\n" {
|
||||
t.Errorf("command got %q", got)
|
||||
}
|
||||
if !strings.Contains(d.screen(), "✓ Save it") {
|
||||
t.Errorf("no confirmation message:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandPopupAndMenu(t *testing.T) {
|
||||
d := commandDriver(t,
|
||||
commands.Command{Context: commands.Vaults, Description: "Show vault", Command: "echo vault={{.Vault}} url={{.VaultURL}}", Output: commands.Popup},
|
||||
commands.Command{Context: commands.Secrets, Description: "Secrets only", Command: "true"},
|
||||
)
|
||||
d.keys("2", "x")
|
||||
if !strings.Contains(d.screen(), "Show vault") || strings.Contains(d.screen(), "Secrets only") {
|
||||
t.Fatalf("commands menu for the Vaults pane:\n%s", d.screen())
|
||||
}
|
||||
d.keys("enter")
|
||||
if !strings.Contains(d.screen(), "vault=kv-app-prod url=https://kv-app-prod.vault.azure.net") {
|
||||
t.Errorf("popup output:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandNeedsItsSelection(t *testing.T) {
|
||||
d := commandDriver(t, commands.Command{Key: "K", Context: commands.Secrets, Description: "Secret thing", Command: "true"})
|
||||
d.keys("1", "K") // Sources pane: not a secrets command's context
|
||||
if d.m.prompt != nil {
|
||||
t.Error("ran from the wrong pane")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/browser"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// Opening a repo's file (e): SOPS files with sops edit, which decrypts
|
||||
// them for the editor and encrypts them again after. Other files, inside
|
||||
// nvim: in that nvim, once lazysecrets has quit; else in $VISUAL or
|
||||
// $EDITOR, coming back after; else in the system's default app. On a
|
||||
// secret, editors that can open at a line start on its key.
|
||||
|
||||
type editDoneMsg struct {
|
||||
c provider.Container
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
// sopsUnchanged is sops edit's exit code when the file wasn't changed.
|
||||
const sopsUnchanged = 200
|
||||
|
||||
// edit opens the selected file, or the file of the selected secret.
|
||||
func (m *Model) edit() tea.Cmd {
|
||||
if m.focus == paneDetail && m.fields.Shown() {
|
||||
f, ok := m.fields.Selected()
|
||||
if !ok || f.Path == "" {
|
||||
m.flash = warning("nothing to open in an editor here")
|
||||
return nil
|
||||
}
|
||||
name := filepath.Base(f.Path)
|
||||
return m.openInEditor(provider.File{Path: f.Path}, func(err error) tea.Msg { return editDoneMsg{name: name, err: err} })
|
||||
}
|
||||
var c provider.Container
|
||||
var o *provider.Object
|
||||
switch m.focus {
|
||||
case paneSources:
|
||||
m.flash = warning("select a file to open it")
|
||||
return nil
|
||||
case paneContainers:
|
||||
sel, ok := m.containers.Selected()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
c = sel
|
||||
default:
|
||||
sel, ok := m.detail.Secret()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
c, o = sel.Container, &sel
|
||||
}
|
||||
files, ok := m.providerFor(c.Source).(provider.Files)
|
||||
if !ok {
|
||||
m.flash = warning("only SOPS and .env files in repos can be opened")
|
||||
return nil
|
||||
}
|
||||
f, err := files.File(c, o)
|
||||
if err != nil {
|
||||
m.flash = flash{text: "open: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
if o != nil {
|
||||
m.usedObject(*o)
|
||||
} else {
|
||||
m.usedContainer(c)
|
||||
}
|
||||
name := filepath.Base(f.Path)
|
||||
finished := func(err error) tea.Msg { return editDoneMsg{c: c, name: name, err: err} }
|
||||
|
||||
if f.Sops != "" {
|
||||
cmd := exec.Command(f.Sops, "edit", f.Path)
|
||||
cmd.Dir = filepath.Dir(f.Path) // sops looks for .sops.yaml from here
|
||||
// sops starts SOPS_EDITOR (else EDITOR, else vim) on the decrypted
|
||||
// copy, which keeps the file's lines: start it on the key.
|
||||
editor := cmp.Or(m.getenv("SOPS_EDITOR"), m.getenv("EDITOR"), "vim")
|
||||
if at, ok := editorAtLine(editor, f.Line); ok {
|
||||
cmd.Env = append(os.Environ(), "SOPS_EDITOR="+at)
|
||||
}
|
||||
m.flash = flash{}
|
||||
return tea.ExecProcess(cmd, finished)
|
||||
}
|
||||
return m.openInEditor(f, finished)
|
||||
}
|
||||
|
||||
// plusLine are editors that open at a line with +N before the file.
|
||||
var plusLine = map[string]bool{
|
||||
"vi": true, "vim": true, "nvim": true, "nano": true, "emacs": true, "emacsclient": true,
|
||||
"micro": true, "kak": true, "mg": true, "ne": true, "joe": true, "vis": true,
|
||||
}
|
||||
|
||||
// editorAtLine is editor starting at line, for editors that take +N:
|
||||
// sops adds the file after it.
|
||||
func editorAtLine(editor string, line int) (string, bool) {
|
||||
fields := strings.Fields(editor)
|
||||
if line == 0 || len(fields) == 0 || !plusLine[filepath.Base(fields[0])] {
|
||||
return "", false
|
||||
}
|
||||
return editor + " +" + strconv.Itoa(line), true
|
||||
}
|
||||
|
||||
// editorArgs opens f at its line, for editors known to take one.
|
||||
func editorArgs(editor string, f provider.File) []string {
|
||||
fields := strings.Fields(editor)
|
||||
if f.Line == 0 || len(fields) == 0 {
|
||||
return []string{f.Path}
|
||||
}
|
||||
at := f.Path + ":" + strconv.Itoa(f.Line)
|
||||
switch base := filepath.Base(fields[0]); {
|
||||
case plusLine[base]:
|
||||
return []string{"+" + strconv.Itoa(f.Line), f.Path}
|
||||
case base == "hx" || base == "helix" || base == "subl" || base == "zed":
|
||||
return []string{at}
|
||||
case base == "code" || base == "codium" || base == "cursor" || base == "windsurf":
|
||||
return []string{"-g", at}
|
||||
}
|
||||
return []string{f.Path}
|
||||
}
|
||||
|
||||
// handleEditDone reports how editing went and reloads the file's secrets:
|
||||
// their names, lines or values may have changed.
|
||||
func (m *Model) handleEditDone(msg editDoneMsg) tea.Cmd {
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case errors.As(msg.err, &exit) && exit.ExitCode() == sopsUnchanged:
|
||||
m.flash = flash{text: msg.name + " unchanged"}
|
||||
return nil
|
||||
case errors.Is(msg.err, exec.ErrNotFound):
|
||||
m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error() + " (is sops installed?)", isErr: true}
|
||||
return nil
|
||||
case msg.err != nil:
|
||||
m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
m.flash = done("closed " + msg.name)
|
||||
if c, ok := m.containers.Selected(); ok && containerKey(c) == containerKey(msg.c) {
|
||||
m.hideValue()
|
||||
m.secrets.Refresh()
|
||||
return m.loadObjects(c, m.objType, m.selContainer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nvimLua opens a file in the nvim lazysecrets runs in. If lazysecrets is
|
||||
// that terminal's own process (the nvim plugin, or :terminal lazysecrets),
|
||||
// it waits for the terminal to close, so the file opens in the window
|
||||
// below rather than in one about to close. Otherwise (a shell in a
|
||||
// terminal) it opens it in the previous window now. The autocmd isn't
|
||||
// the buffer's own: snacks wipes the buffer when the terminal closes,
|
||||
// which would delete it before it runs.
|
||||
const nvimLua = `local path, line, pid = ...
|
||||
local function open()
|
||||
local ok, err = pcall(vim.cmd, "edit " .. vim.fn.fnameescape(path))
|
||||
if not ok then vim.notify("lazysecrets: " .. err, vim.log.levels.ERROR) return end
|
||||
if line > 0 then pcall(vim.api.nvim_win_set_cursor, 0, { line, 0 }) vim.cmd("normal! zz") end
|
||||
end
|
||||
for _, b in ipairs(vim.api.nvim_list_bufs()) do
|
||||
if vim.bo[b].buftype == "terminal" and vim.b[b].terminal_job_pid == pid then
|
||||
vim.api.nvim_create_autocmd("TermClose", { callback = function(ev)
|
||||
if ev.buf ~= b then return end
|
||||
vim.defer_fn(open, 50)
|
||||
return true
|
||||
end })
|
||||
return
|
||||
end
|
||||
end
|
||||
pcall(vim.cmd, "wincmd p")
|
||||
open()`
|
||||
|
||||
// nvimOpen asks the nvim at server to open path at line.
|
||||
func nvimOpen(server, path string, line int) error {
|
||||
q := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
|
||||
lua := strings.ReplaceAll(nvimLua, "\n", " ")
|
||||
expr := "luaeval('load(_A[1])(_A[2], _A[3], _A[4])', [" + q(lua) + ", " + q(path) + ", " +
|
||||
strconv.Itoa(line) + ", " + strconv.Itoa(os.Getpid()) + "])"
|
||||
out, err := exec.Command("nvim", "--server", server, "--remote-expr", expr).CombinedOutput()
|
||||
if err != nil {
|
||||
if msg := strings.TrimSpace(string(out)); msg != "" {
|
||||
return errors.New(msg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openInEditor opens f: in the nvim lazysecrets runs in, quitting; else
|
||||
// in $VISUAL or $EDITOR, coming back after; else in the default app.
|
||||
func (m *Model) openInEditor(f provider.File, finished func(error) tea.Msg) tea.Cmd {
|
||||
name := filepath.Base(f.Path)
|
||||
if server := m.getenv("NVIM"); server != "" {
|
||||
open := m.nvimOpen
|
||||
return func() tea.Msg {
|
||||
if err := open(server, f.Path, f.Line); err != nil {
|
||||
return finished(err)
|
||||
}
|
||||
return tea.QuitMsg{}
|
||||
}
|
||||
}
|
||||
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
|
||||
// Through the shell, so EDITOR can carry arguments ("code -w").
|
||||
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
|
||||
m.flash = flash{}
|
||||
return tea.ExecProcess(exec.Command("/bin/sh", args...), finished)
|
||||
}
|
||||
switch err := m.openURL(f.Path); {
|
||||
case errors.Is(err, browser.ErrRemote):
|
||||
m.flash = warning("set $EDITOR to open files over SSH")
|
||||
case err != nil:
|
||||
m.flash = flash{text: "open: " + err.Error(), isErr: true}
|
||||
default:
|
||||
m.flash = done("opened " + name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
// The status pane lists this session's errors, newest first: what a pane
|
||||
// showed (a list that couldn't load) and what the footer flashed (a copy
|
||||
// that failed), which go when you move on. Only the first line of each,
|
||||
// and never kept after quitting.
|
||||
|
||||
// maxErrors is how many errors are kept.
|
||||
const maxErrors = 20
|
||||
|
||||
type loggedErr struct {
|
||||
at time.Time
|
||||
text string
|
||||
}
|
||||
|
||||
// logErr records text; an error already there moves to the top, with
|
||||
// the time it happened again.
|
||||
func (m *Model) logErr(text string) {
|
||||
text, _, _ = strings.Cut(strings.TrimSpace(text), "\n")
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
m.errLog = slices.DeleteFunc(m.errLog, func(e loggedErr) bool { return e.text == text })
|
||||
m.errLog = append([]loggedErr{{m.now(), text}}, m.errLog[:min(len(m.errLog), maxErrors-1)]...)
|
||||
}
|
||||
|
||||
// logErrOf records err, about what, except a sign-in, which the footer
|
||||
// and the account's details say.
|
||||
func (m *Model) logErrOf(what string, err error) {
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(err, &auth) {
|
||||
m.logErr(what + ": " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// logFlash records an error the footer is showing, once.
|
||||
func (m *Model) logFlash() {
|
||||
if m.flash.isErr && m.flash.text != m.loggedFlash {
|
||||
m.logErr(m.flash.text)
|
||||
}
|
||||
m.loggedFlash = m.flash.text
|
||||
}
|
||||
|
||||
// errorFields are the status pane's rows for them.
|
||||
func (m Model) errorFields() []panes.Field {
|
||||
out := make([]panes.Field, len(m.errLog))
|
||||
for i, e := range m.errLog {
|
||||
out[i] = panes.Field{Value: e.at.Format("15:04") + " " + e.text, Err: true}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
out[0].Label = "errors"
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
// The status pane lists what has expired or will soon, across every vault
|
||||
// loaded (opened, searched, or from the cache): expiry dates come with
|
||||
// the lists, so this makes no requests. enter on one goes to it.
|
||||
|
||||
type expiringItem struct {
|
||||
o provider.Object
|
||||
row string // its source's row key, to go to it
|
||||
}
|
||||
|
||||
// expiringItems are the loaded objects that have expired or will within
|
||||
// the warning period, soonest first.
|
||||
func (m Model) expiringItems() []expiringItem {
|
||||
var out []expiringItem
|
||||
for _, p := range m.providers {
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
for _, c := range m.cache.containers[row].value {
|
||||
for _, t := range c.ObjectTypes() {
|
||||
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
|
||||
if state, _ := m.expiry(o); state != notExpiring {
|
||||
out = append(out, expiringItem{o, row})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b expiringItem) int {
|
||||
return cmp.Or(a.o.Expires.Compare(b.o.Expires), cmp.Compare(a.o.Name, b.o.Name))
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// expiringFields are the status pane's rows for them, and how much of
|
||||
// everything they cover.
|
||||
func (m Model) expiringFields() []panes.Field {
|
||||
items := m.expiringItems()
|
||||
var out []panes.Field
|
||||
for _, it := range items {
|
||||
state, left := m.expiry(it.o)
|
||||
mark := "⚠ " + daysLeft(left)
|
||||
if state == expired {
|
||||
mark = "✗ expired"
|
||||
}
|
||||
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row}
|
||||
if r, err := ref.ForObject(it.o, ""); err == nil {
|
||||
f.Link = r.String()
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
cov := m.coverageOf(ofKind(provider.KindAzure))
|
||||
switch {
|
||||
case cov.vaults == 0 && len(items) == 0:
|
||||
return nil
|
||||
case !cov.complete():
|
||||
out = append(out, panes.Field{Value: fmt.Sprintf("in %d of %d vaults", cov.vaultsLoaded, cov.vaults) +
|
||||
withKey(m.keys.SearchLoadAll, " (%s in search loads all)"), Info: true})
|
||||
case len(items) == 0:
|
||||
out = append(out, panes.Field{Value: fmt.Sprintf("nothing within %d days", int(m.expiryWarning.Hours()/24)), Info: true})
|
||||
}
|
||||
out[0].Label = "expiring"
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// Secrets, keys and certificates with an expiry date are marked in the
|
||||
// list once they have expired (✗ expired) or will within the configured
|
||||
// warning period (⚠ 12d), and typing "expir" in the filter finds them.
|
||||
|
||||
type expiryState int
|
||||
|
||||
const (
|
||||
notExpiring expiryState = iota
|
||||
expiresSoon
|
||||
expired
|
||||
)
|
||||
|
||||
// expiry tells whether o has expired or will within the warning period,
|
||||
// and how long it has left.
|
||||
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
|
||||
if o.Expires.IsZero() {
|
||||
return notExpiring, 0
|
||||
}
|
||||
left := o.Expires.Sub(m.now())
|
||||
switch {
|
||||
case left <= 0:
|
||||
return expired, 0
|
||||
case left <= m.expiryWarning:
|
||||
return expiresSoon, left
|
||||
}
|
||||
return notExpiring, left
|
||||
}
|
||||
|
||||
// expiryWord is matched by the filter: "expired" or "expiring".
|
||||
func (m Model) expiryWord(o provider.Object) string {
|
||||
switch state, _ := m.expiry(o); state {
|
||||
case expired:
|
||||
return "expired"
|
||||
case expiresSoon:
|
||||
return "expiring"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// daysLeft is a short "12d", or "5h" on the last day.
|
||||
func daysLeft(d time.Duration) string {
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
|
||||
}
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"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/styles"
|
||||
)
|
||||
|
||||
// What lazysecrets remembers about subscriptions, vaults and secrets —
|
||||
// stars, notes — lives in the store, one record per item keyed by its
|
||||
// lazysecrets:// link. Keys leave out the account, so a star or note on a
|
||||
// subscription shows under every account that sees it.
|
||||
|
||||
const star = "★ "
|
||||
|
||||
func sourceKey(s provider.Source) string {
|
||||
if r, err := ref.ForSource(s); err == nil {
|
||||
return r.String()
|
||||
}
|
||||
return "source:" + string(s.Kind) + "/" + s.ID
|
||||
}
|
||||
|
||||
func storeKey(c provider.Container) string {
|
||||
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
|
||||
return r.String()
|
||||
}
|
||||
return "container:" + c.ID
|
||||
}
|
||||
|
||||
func objectKey(o provider.Object) string {
|
||||
if r, err := ref.ForObject(o, ""); err == nil {
|
||||
return r.String()
|
||||
}
|
||||
return "object:" + o.ID
|
||||
}
|
||||
|
||||
// remember records context with a change, so lists of remembered items
|
||||
// can show and jump to them later.
|
||||
func (m *Model) remember(key, name string, path []string, src provider.Source, change func(*store.Node)) error {
|
||||
ctx := withContext(name, path, src)
|
||||
return m.store.Update(key, func(n *store.Node) {
|
||||
ctx(n)
|
||||
change(n)
|
||||
})
|
||||
}
|
||||
|
||||
// withContext sets a record's context: where the item is, and the account
|
||||
// that read it. A vault opened from a link has no known subscription;
|
||||
// that keeps the one already recorded.
|
||||
func withContext(name string, path []string, src provider.Source) func(*store.Node) {
|
||||
return func(n *store.Node) {
|
||||
n.Name = name
|
||||
if src.ID != "" || n.Source == "" {
|
||||
n.Path, n.Source = path, src.ID
|
||||
}
|
||||
if src.Provider != "" {
|
||||
n.Account = src.Provider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refreshContext updates a remembered item's context after reaching it,
|
||||
// e.g. the account that could read it. Items without a record are left
|
||||
// alone, so browsing doesn't write the file.
|
||||
func (m *Model) refreshContext(key, name string, path []string, src provider.Source) {
|
||||
if m.store.Has(key) && src.Provider != "" {
|
||||
_ = m.remember(key, name, path, src, func(*store.Node) {})
|
||||
}
|
||||
}
|
||||
|
||||
// containerRender draws a vault or scope, starred or not. In the
|
||||
// Opened links list it also names the source, since they come from many.
|
||||
func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Container, int) string {
|
||||
st, iconSet := m.store, m.icons
|
||||
return func(sty styles.Styles, c provider.Container, width int) string {
|
||||
n := st.Node(storeKey(c))
|
||||
line := c.Name
|
||||
if c.Source.Kind == provider.KindRepo {
|
||||
// A secret file: a lock for an encrypted one, a file in the
|
||||
// warning colour for plain text, and its type after it
|
||||
// ("sops · age", "env · plain text").
|
||||
icon, style := iconSet.Encrypted, sty.KindRepo
|
||||
if !strings.HasPrefix(c.Detail, "sops") {
|
||||
icon, style = iconSet.PlainFile, sty.Warning
|
||||
}
|
||||
if icon != "" {
|
||||
line = style.Render(icon) + " " + line
|
||||
}
|
||||
line += sty.Muted.Render(" " + c.Detail)
|
||||
}
|
||||
if n.StarredFor(c.Source.Provider) {
|
||||
line = sty.Favourite.Render(star) + line
|
||||
}
|
||||
if withSource {
|
||||
src := c.Source.Name
|
||||
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok {
|
||||
src = account + " / " + src
|
||||
}
|
||||
line += sty.Muted.Render(" · " + src)
|
||||
}
|
||||
return withNote(sty, line, n.Note, width)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
|
||||
st := m.store
|
||||
return func(sty styles.Styles, o provider.Object, width int) string {
|
||||
n := st.Node(objectKey(o))
|
||||
line := o.Name
|
||||
if o.Enabled != nil && !*o.Enabled {
|
||||
line = sty.Muted.Render(o.Name + " (disabled)")
|
||||
}
|
||||
if n.StarredFor(o.Container.Source.Provider) {
|
||||
line = sty.Favourite.Render(star) + line
|
||||
}
|
||||
switch state, left := m.expiry(o); state {
|
||||
case expired:
|
||||
line += " " + sty.Error.Render("✗ expired")
|
||||
case expiresSoon:
|
||||
line += " " + sty.Warning.Render("⚠ "+daysLeft(left))
|
||||
}
|
||||
return withNote(sty, line, n.Note, width)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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))
|
||||
})
|
||||
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))
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// showLinkedContainers lists the vaults opened from links.
|
||||
func (m *Model) showLinkedContainers() {
|
||||
m.containers.Title, m.containers.Empty = "[2] Opened links", "no links opened"
|
||||
m.containers.SetRender(m.containerRender(true))
|
||||
m.containers.SetItems(m.linked)
|
||||
m.containers.Select(m.cache.cursor[linksRowID])
|
||||
}
|
||||
|
||||
// rebind records the account that served a vault opened from a link. The
|
||||
// container's key includes the account, so the selection moves to the
|
||||
// new key without reloading. It returns the new key for the object list.
|
||||
func (m *Model) rebind(c provider.Container) string {
|
||||
m.refreshContext(storeKey(c), c.Name, []string{c.Source.Name}, c.Source)
|
||||
for i := range m.linked {
|
||||
if m.linked[i].ID == c.ID {
|
||||
m.linked[i].Source = c.Source
|
||||
}
|
||||
}
|
||||
key := containerKey(c) + "|" + string(m.objType)
|
||||
if m.onLinks() {
|
||||
m.containers.SetItems(m.linked)
|
||||
m.containers.Select(containerKey(c))
|
||||
m.selContainer = key
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// resortStarred puts starred items first again in every pane, after
|
||||
// stars changed elsewhere (the favourites list).
|
||||
func (m *Model) resortStarred() {
|
||||
m.rebuildSources()
|
||||
if !m.onLinks() {
|
||||
m.containers.SetItems(m.starredContainersFirst(m.containers.Items()))
|
||||
}
|
||||
if m.versionsOf == nil {
|
||||
m.secrets.SetItems(m.starredFirst(m.secrets.Items()))
|
||||
}
|
||||
}
|
||||
|
||||
// toggleFavourite stars or unstars the selected subscription (Sources),
|
||||
// vault or scope (Vaults) or object (Secrets and detail panes).
|
||||
func (m *Model) toggleFavourite() tea.Cmd {
|
||||
var name string
|
||||
var on bool
|
||||
// A star is the item through the account showing it.
|
||||
var err error
|
||||
switch m.focus {
|
||||
case paneSources:
|
||||
src, ok := m.selectedSource()
|
||||
if !ok {
|
||||
m.flash = warning("select a subscription or workspace to star")
|
||||
return nil
|
||||
}
|
||||
name = src.Name
|
||||
toggle := func(n *store.Node) { on = n.ToggleStar(src.Provider) }
|
||||
if err = m.remember(sourceKey(src), src.Name, nil, src, toggle); err == nil {
|
||||
m.rebuildSources() // re-sorts; the cursor follows the source
|
||||
}
|
||||
case paneContainers:
|
||||
c, ok := m.containers.Selected()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
name = c.Name
|
||||
toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) }
|
||||
if err = m.remember(storeKey(c), c.Name, []string{c.Source.Name}, c.Source, toggle); err == nil {
|
||||
m.containers.SetItems(m.starredContainersFirst(m.containers.Items()))
|
||||
}
|
||||
default:
|
||||
if m.versionsOf != nil {
|
||||
m.flash = warning("star the item from the list rather than a version")
|
||||
return nil
|
||||
}
|
||||
o, ok := m.detail.Secret()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
name = o.Name
|
||||
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()))
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case err != nil:
|
||||
m.flash = flash{text: "saving: " + err.Error(), isErr: true}
|
||||
case on:
|
||||
m.flash = done("starred " + name)
|
||||
default:
|
||||
m.flash = done("unstarred " + name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// jump opens the favourites list, filters it and jumps to the first match.
|
||||
func (d *driver) jump(filter string) {
|
||||
d.t.Helper()
|
||||
d.keys("F")
|
||||
if d.m.search == nil || d.m.search.scope().id != scopeFavs {
|
||||
d.t.Fatalf("F didn't open Go to on Favourites:\n%s", d.screen())
|
||||
}
|
||||
for _, r := range filter {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
d.keys("enter")
|
||||
}
|
||||
|
||||
func TestStarVaultSortsFirstAndJumps(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("2", "j", "f") // star kv-shared-prod
|
||||
if items := d.m.containers.Items(); items[0].Name != "kv-shared-prod" {
|
||||
t.Errorf("starred vault not first: %s", items[0].Name)
|
||||
}
|
||||
if !strings.Contains(d.screen(), "★ kv-shared-prod") {
|
||||
t.Errorf("no star:\n%s", d.screen())
|
||||
}
|
||||
if strings.Contains(d.screen(), "Favourites (") {
|
||||
t.Error("the old Favourites row is back")
|
||||
}
|
||||
|
||||
d.keys("1", "j") // elsewhere
|
||||
d.keys("F")
|
||||
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod") {
|
||||
t.Fatalf("favourites list:\n%s", d.screen())
|
||||
}
|
||||
d.keys("esc")
|
||||
d.jump("shared")
|
||||
if src, c, _ := d.selected(); src != "platform-prod" || c != "kv-shared-prod" {
|
||||
t.Errorf("jumped to %s/%s", src, c)
|
||||
}
|
||||
if d.m.focus != paneContainers {
|
||||
t.Errorf("focus %d after jumping to a vault", d.m.focus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJumpToStarredSourceAndSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "j", "f") // star api-key in platform-prod / kv-app-prod
|
||||
d.keys("1", "j", "f") // star platform-dev (it moves to the top of its group)
|
||||
d.keys("j", "j", "j") // down to Databricks prod
|
||||
|
||||
d.jump("platform-dev")
|
||||
if src, _, _ := d.selected(); src != "platform-dev" || d.m.focus != paneContainers {
|
||||
t.Errorf("source jump: %s, focus %d", src, d.m.focus)
|
||||
}
|
||||
d.jump("api")
|
||||
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
|
||||
t.Errorf("secret jump: %s/%s/%s", src, c, s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavouritesListEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("F")
|
||||
if d.m.search == nil || !strings.Contains(d.screen(), "nothing starred yet") {
|
||||
t.Errorf("empty favourites:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarSecretSortsFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "j", "f") // star api-key
|
||||
if _, _, s := d.selected(); s != "api-key" {
|
||||
t.Errorf("cursor left the starred item: %q", s)
|
||||
}
|
||||
rows := d.m.secrets.Items()
|
||||
if rows[0].Name != "api-key" || !strings.Contains(d.screen(), "★ api-key") {
|
||||
t.Errorf("starred secret not first: %v\n%s", rows[0].Name, d.screen())
|
||||
}
|
||||
// Still first after leaving and coming back (cached list).
|
||||
d.keys("2", "j", "k")
|
||||
if d.m.secrets.Items()[0].Name != "api-key" {
|
||||
t.Error("starred secret not first after returning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoStarringVersions(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("v", "f")
|
||||
if d.m.store.Node(objectKey(*d.m.versionsOf)).StarredFor("Azure") || !strings.Contains(d.screen(), "rather than a version") {
|
||||
t.Errorf("starring a version:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavouritesPersist(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
load := func() *store.Store {
|
||||
s, err := store.Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
d := newDriverOpts(t, Options{Store: load()})
|
||||
d.keys("2", "f", "3", "j", "f")
|
||||
|
||||
d2 := newDriverOpts(t, Options{Store: load()})
|
||||
d2.keys("F")
|
||||
if !strings.Contains(d2.screen(), "platform-prod / kv-app-prod") ||
|
||||
!strings.Contains(d2.screen(), "platform-prod / kv-app-prod / api-key") {
|
||||
t.Errorf("favourites not restored:\n%s", d2.screen())
|
||||
}
|
||||
d2.keys("esc")
|
||||
if d2.m.secrets.Items()[0].Name != "api-key" {
|
||||
t.Error("secret favourite not restored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarSourceMovesItToTheTop(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("j", "f") // star platform-dev
|
||||
if src, _, _ := d.selected(); src != "platform-dev" {
|
||||
t.Errorf("cursor left the starred source: %q", src)
|
||||
}
|
||||
rows := d.m.sources.Items()
|
||||
if rows[1].source.Name != "platform-dev" || !strings.Contains(d.screen(), "★ platform-dev") {
|
||||
t.Errorf("not first in its group: %q\n%s", rows[1].source.Name, d.screen())
|
||||
}
|
||||
|
||||
// Flat: starred sources come before every other provider's.
|
||||
// platform-dev now leads its group: platform-prod, header, prod, dev.
|
||||
d.keys("j", "j", "j", "j", "f", "`") // also star Databricks dev
|
||||
var flat []string
|
||||
for _, r := range d.m.sources.Items() {
|
||||
flat = append(flat, r.source.Name)
|
||||
}
|
||||
if strings.Join(flat[:2], ",") != "platform-dev,dev" {
|
||||
t.Errorf("flat order = %v", flat)
|
||||
}
|
||||
|
||||
d.keys("`", "k") // grouped again; dev leads its group, so k is the header
|
||||
d.keys("f")
|
||||
if !strings.Contains(d.screen(), "select a subscription or workspace to star") {
|
||||
t.Errorf("starring a header:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
// denying refuses to list objects, like an account without access.
|
||||
type denying struct{ *counting }
|
||||
|
||||
func (d denying) Objects(context.Context, provider.Container, provider.ObjectType) ([]provider.Object, error) {
|
||||
d.objects++
|
||||
return nil, fake.ErrForbidden
|
||||
}
|
||||
|
||||
// kvKey is kv-app-prod's store key, kvSharedKey kv-shared-prod's.
|
||||
const (
|
||||
kvKey = "lazysecrets://azure/kv-app-prod.vault.azure.net"
|
||||
kvSharedKey = "lazysecrets://azure/kv-shared-prod.vault.azure.net"
|
||||
)
|
||||
|
||||
// starredWithoutAccount is kv-app-prod starred with no account recorded,
|
||||
// like one starred by an account that's no longer signed in.
|
||||
func starredWithoutAccount(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
s, _ := store.Load("")
|
||||
if err := s.Update(kvKey, func(n *store.Node) {
|
||||
n.Name, n.Path, n.Source, n.StarredBy = "kv-app-prod", []string{"platform-prod"}, "sub-prod", []string{"Azure:carol"}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestOldFavouriteFindsTheAccountWithAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")}
|
||||
bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")}
|
||||
mem := starredWithoutAccount(t)
|
||||
d := newDriverOpts(t, Options{Store: mem}, denying{alice}, bob)
|
||||
|
||||
d.jump("kv-app") // both accounts see platform-prod: which one?
|
||||
if !strings.Contains(d.screen(), "Several accounts can see kv-app-prod") {
|
||||
t.Fatalf("no account choice:\n%s", d.screen())
|
||||
}
|
||||
d.keys("2") // bob
|
||||
if _, c, s := d.selected(); c != "kv-app-prod" || s != "db-password" {
|
||||
t.Fatalf("favourite not browsable: %q/%q\n%s", c, s, d.screen())
|
||||
}
|
||||
if got := mem.Node(kvKey).Account; got != "Azure:bob" {
|
||||
t.Errorf("favourite bound to %q, want Azure:bob", got)
|
||||
}
|
||||
d.keys("R") // used through bob now
|
||||
if !strings.Contains(d.screen(), "platform-prod / kv-app-prod · bob") {
|
||||
t.Errorf("recent should show the account:\n%s", d.screen())
|
||||
}
|
||||
d.keys("esc")
|
||||
|
||||
// Reads go through bob, and alice isn't asked again.
|
||||
before := alice.objects
|
||||
d.keys("space")
|
||||
if bob.values != 1 || alice.values != 0 {
|
||||
t.Errorf("reveal: alice %d, bob %d", alice.values, bob.values)
|
||||
}
|
||||
d.keys("2", "r")
|
||||
if alice.objects != before {
|
||||
t.Errorf("alice asked again after binding: %d → %d", before, alice.objects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldFavouriteNoAccountHasAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")}
|
||||
bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")}
|
||||
mem := starredWithoutAccount(t)
|
||||
d := newDriverOpts(t, Options{Store: mem}, denying{alice}, denying{bob})
|
||||
|
||||
d.jump("kv-app")
|
||||
d.keys("1") // alice: no access
|
||||
if !strings.Contains(d.screen(), "403 Forbidden") {
|
||||
t.Errorf("no access error:\n%s", d.screen())
|
||||
}
|
||||
if mem.Node(kvKey).Account != "" {
|
||||
t.Error("favourite bound although no account worked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavouritesPopupKeepsItsSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("j", "f", "2", "j", "f", "3", "j", "f") // a subscription, a vault, a secret
|
||||
d.keys("F")
|
||||
size := func() (int, int) {
|
||||
box := d.m.searchViewBox()
|
||||
return lipgloss.Width(box), lipgloss.Height(box)
|
||||
}
|
||||
w0, h0 := size()
|
||||
for _, k := range []string{"down", "down", "up"} {
|
||||
d.send(tea.KeyPressMsg{Code: map[string]rune{"down": tea.KeyDown, "up": tea.KeyUp}[k]})
|
||||
if w, h := size(); w != w0 || h != h0 {
|
||||
t.Fatalf("popup changed from %dx%d to %dx%d moving %s", w0, h0, w, h, k)
|
||||
}
|
||||
}
|
||||
d.send(tea.KeyPressMsg{Code: 'z', Text: "z"}) // filter down to nothing
|
||||
if w, h := size(); w != w0 || h != h0 {
|
||||
t.Errorf("popup changed from %dx%d to %dx%d while filtering", w0, h0, w, h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreKeysAreLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("f", "2", "f", "3", "j", "f")
|
||||
var keys []string
|
||||
for k := range d.m.store.Nodes() {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
want := []string{
|
||||
"lazysecrets://azure/kv-app-prod.vault.azure.net",
|
||||
"lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key",
|
||||
"lazysecrets://azure/subscriptions/sub-prod",
|
||||
}
|
||||
if !slices.Equal(keys, want) {
|
||||
t.Errorf("keys = %v", keys)
|
||||
}
|
||||
n := d.m.store.Node(want[1])
|
||||
if n.Name != "api-key" || n.Source != "sub-prod" || n.Account != "Azure" || !slices.Equal(n.Path, []string{"platform-prod", "kv-app-prod"}) {
|
||||
t.Errorf("context = %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavouritesShowNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("2", "j", "f", "n") // star kv-shared-prod and note it
|
||||
for _, r := range "shared certs" {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
d.keys("enter", "F")
|
||||
line := ""
|
||||
for l := range strings.SplitSeq(d.screen(), "\n") {
|
||||
if strings.Contains(l, "kv-shared-prod") && strings.Contains(l, "│") && strings.Contains(l, "/") {
|
||||
line = l
|
||||
}
|
||||
}
|
||||
if !strings.Contains(line, "shared certs") {
|
||||
t.Fatalf("no note in the favourites row %q:\n%s", line, d.screen())
|
||||
}
|
||||
for _, r := range "certs" { // the filter matches notes
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
if len(d.m.search.shown) != 1 {
|
||||
t.Errorf("filtering by note: %d entries", len(d.m.search.shown))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretWithoutContextJumpsThroughItsVault(t *testing.T) {
|
||||
t.Parallel()
|
||||
mem, _ := store.Load("")
|
||||
_ = mem.Update(kvKey, func(n *store.Node) {
|
||||
n.Name, n.Path, n.Source, n.Account, n.StarredBy = "kv-app-prod", []string{"platform-prod"}, "sub-prod", "Azure", []string{"Azure"}
|
||||
})
|
||||
_ = mem.Update(kvKey+"/secrets/api-key", func(n *store.Node) { n.StarredBy = []string{"Azure"} })
|
||||
// counting hides Locate, like an account that can't find the vault
|
||||
// in Resource Graph: opening it as a link would land in Opened links.
|
||||
d := newDriverOpts(t, Options{Store: mem}, &counting{Provider: fake.Azure(0)})
|
||||
d.keys("j") // elsewhere
|
||||
d.jump("api-key")
|
||||
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
|
||||
t.Errorf("jumped to %s/%s/%s", src, c, s)
|
||||
}
|
||||
if strings.Contains(d.screen(), "Opened links") {
|
||||
t.Errorf("went through Opened links:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkedVaultKeepsKnownContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
n := store.Node{Source: "sub-prod", Path: []string{"platform-prod", "kv-app-prod"}}
|
||||
// A vault opened from a link: account known, subscription not.
|
||||
withContext("api-key", []string{"kv-app-prod.vault.azure.net", "kv-app-prod"},
|
||||
provider.Source{Kind: provider.KindAzure, Provider: "Azure:bob"})(&n)
|
||||
if n.Source != "sub-prod" || n.Path[0] != "platform-prod" || n.Account != "Azure:bob" {
|
||||
t.Errorf("context = %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleStarInGoTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("2", "j", "f") // star kv-shared-prod
|
||||
d.keys("3", "j", "space", "2") // use smtp-password, unstarred
|
||||
smtp := kvSharedKey + "/secrets/smtp-password"
|
||||
ctrlF := tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl}
|
||||
|
||||
d.keys("R", "/")
|
||||
d.typeText("smtp")
|
||||
d.send(ctrlF) // star it from Recent, typing
|
||||
if !d.m.store.Node(smtp).StarredFor("Azure") || !strings.Contains(d.screen(), "★ ") {
|
||||
t.Fatalf("ctrl+f didn't star a recent item:\n%s", d.screen())
|
||||
}
|
||||
d.send(ctrlF) // and back
|
||||
if d.m.store.Node(smtp).StarredFor("Azure") {
|
||||
t.Error("second ctrl+f didn't unstar")
|
||||
}
|
||||
if d.m.search == nil || d.m.search.input.Value() != "smtp" || len(d.m.search.shown) == 0 {
|
||||
t.Fatalf("entry or query gone after toggling:\n%s", d.screen())
|
||||
}
|
||||
|
||||
// Unstarring a favourite keeps it listed until Go to opens again.
|
||||
d.keys("esc", "esc", "F") // stop typing, close, open on Favourites
|
||||
v := d.m.search
|
||||
for v.res < len(v.shown)-1 && v.shown[v.res].star != kvSharedKey {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
|
||||
}
|
||||
if v.shown[v.res].star != kvSharedKey {
|
||||
t.Fatalf("selected %q", v.shown[v.res].star)
|
||||
}
|
||||
d.keys("f") // browsing: f
|
||||
if d.m.store.Node(kvSharedKey).StarredFor("Azure") {
|
||||
t.Error("favourite still starred")
|
||||
}
|
||||
if v.shown[v.res].star != kvSharedKey || v.shown[v.res].starred {
|
||||
t.Error("unstarred favourite didn't stay in place, unstarred")
|
||||
}
|
||||
d.keys("esc")
|
||||
if strings.Contains(d.screen(), "★ kv-shared-prod") {
|
||||
t.Errorf("Vaults pane still shows the star:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarWhileFiltering(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "/")
|
||||
for _, r := range "api" {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
d.send(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
|
||||
if !d.m.store.Node(kvKey + "/secrets/api-key").StarredFor("Azure") {
|
||||
t.Errorf("ctrl+f while filtering didn't star:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarIsPerAccount(t *testing.T) {
|
||||
t.Parallel()
|
||||
alice := fake.Azure(0).As("Azure:alice", "Azure · alice")
|
||||
bob := fake.Azure(0).As("Azure:bob", "Azure · bob")
|
||||
d := newDriverOpts(t, Options{}, alice, bob)
|
||||
// Rows: alice header, platform-prod, platform-dev, bob header, ….
|
||||
d.keys("f") // alice's platform-prod
|
||||
n := d.m.store.Node("lazysecrets://azure/subscriptions/sub-prod")
|
||||
if !n.StarredFor("Azure:alice") || n.StarredFor("Azure:bob") {
|
||||
t.Errorf("starred by %v, want alice only", n.StarredBy)
|
||||
}
|
||||
if c := strings.Count(d.screen(), "★ platform-prod"); c != 1 {
|
||||
t.Errorf("star shown %d times, want once:\n%s", c, d.screen())
|
||||
}
|
||||
// A note is about the thing: it shows under both.
|
||||
d.keys("n")
|
||||
d.typeText("shared note")
|
||||
d.keys("enter")
|
||||
// On both accounts' rows, and in the selected one's details.
|
||||
if n := strings.Count(d.screen(), "shared note"); n != 3 {
|
||||
t.Errorf("note shown %d times, want 3:\n%s", n, d.screen())
|
||||
}
|
||||
d.keys("F")
|
||||
var labels []string
|
||||
for _, o := range d.m.search.shown {
|
||||
labels = append(labels, ansiStrip(o.label))
|
||||
}
|
||||
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
|
||||
t.Errorf("favourites should list alice's star only: %q", labels)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
// The fourth pane shows Fields for anything but a secret. On a provider's
|
||||
// row: its account, from what the provider knows locally.
|
||||
|
||||
// A file's details beyond its listing load once a
|
||||
// session, when the cursor rests on it, and again with refresh. They're
|
||||
// extra: moving on cancels the load, and search never waits for them.
|
||||
type detailsState struct {
|
||||
attrs []provider.Attr
|
||||
err error
|
||||
loading bool
|
||||
}
|
||||
|
||||
type detailsMsg struct {
|
||||
req int
|
||||
key string
|
||||
attrs []provider.Attr
|
||||
err error
|
||||
}
|
||||
|
||||
// detailOf is the pane the fourth pane describes: the one in focus, or,
|
||||
// with the fourth in focus, the one it was entered from.
|
||||
func (m Model) detailOf() pane {
|
||||
if m.focus != paneDetail {
|
||||
return m.focus
|
||||
}
|
||||
return m.lastLeft
|
||||
}
|
||||
|
||||
// syncFields shows the Fields pane for what the focused pane has selected,
|
||||
// unless that's a secret (or a vault with the Secrets pane in focus), which
|
||||
// the secret pane shows, as it does a revealed value whatever the focus.
|
||||
func (m *Model) syncFields() tea.Cmd {
|
||||
if m.detail.State() != panes.ValueHidden {
|
||||
// A value revealed (or being fetched) from any pane stays in
|
||||
// view until it's hidden.
|
||||
m.fields.Hide()
|
||||
return nil
|
||||
}
|
||||
m.fields.Header, m.fields.HeaderShort = nil, ""
|
||||
switch m.detailOf() {
|
||||
case paneSources:
|
||||
r, ok := m.sources.Selected()
|
||||
switch {
|
||||
case m.nothingFound():
|
||||
m.fields.Show("get started", m.getStartedFields())
|
||||
case !ok:
|
||||
m.fields.Show("", nil)
|
||||
case m.selectedProblem() != "":
|
||||
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
|
||||
case r.kind == rowGroup:
|
||||
m.showAccount(r)
|
||||
case r.kind == rowSource:
|
||||
m.fields.Show(r.source.Name, m.sourceFields(r.source))
|
||||
default:
|
||||
m.fields.Show("", nil)
|
||||
}
|
||||
case paneStatus:
|
||||
m.fields.Header, m.fields.HeaderShort = logo, strings.TrimSpace("lazysecrets "+m.versionLabel())
|
||||
m.fields.Show("lazysecrets", m.statusFields())
|
||||
case paneContainers:
|
||||
if c, ok := m.containers.Selected(); ok {
|
||||
cmd := m.loadDetails(c)
|
||||
m.fields.Show(c.Name, m.containerFields(c))
|
||||
return cmd
|
||||
} else {
|
||||
m.fields.Show("", nil)
|
||||
}
|
||||
default:
|
||||
m.fields.Hide()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// showAccount shows a provider's account.
|
||||
func (m *Model) showAccount(r sourceRow) {
|
||||
p := m.byID[r.provider]
|
||||
var fields []panes.Field
|
||||
if a, ok := p.(provider.Accounts); ok {
|
||||
if acct, ok := a.Account(); ok {
|
||||
for _, f := range acct.Fields {
|
||||
fields = append(fields, panes.Field{Label: f.Label, Value: f.Value})
|
||||
}
|
||||
}
|
||||
}
|
||||
if res, done := m.sourceResults[r.provider]; done && res.err == nil && len(res.sources) == 0 {
|
||||
hint := emptyHint(p.Kind())
|
||||
fields = append(fields, panes.Field{Label: "nothing", Value: "found: try", Info: true}, panes.Field{Value: hint.Value, URL: hint.URL})
|
||||
}
|
||||
m.fields.Show(p.Label(), fields)
|
||||
}
|
||||
|
||||
// sourceFields describe a subscription, workspace or repo.
|
||||
func (m Model) sourceFields(src provider.Source) []panes.Field {
|
||||
var out []panes.Field
|
||||
add := func(label, value string) {
|
||||
if value != "" {
|
||||
out = append(out, panes.Field{Label: label, Value: value})
|
||||
}
|
||||
}
|
||||
// Azure's detail is "subscription <ID>": the ID row says it.
|
||||
id, detail := "ID", ""
|
||||
switch src.Kind {
|
||||
case provider.KindDatabricks:
|
||||
id, detail = "profile", "host"
|
||||
case provider.KindRepo:
|
||||
id, detail = "path", "remote"
|
||||
}
|
||||
add("name", src.Name)
|
||||
add("note", m.store.Node(sourceKey(src)).Note)
|
||||
if src.ID != src.Name {
|
||||
add(id, src.ID)
|
||||
}
|
||||
if detail != "" {
|
||||
add(detail, src.Detail)
|
||||
}
|
||||
if src.ProviderLabel != string(src.Kind) {
|
||||
add("account", src.ProviderLabel)
|
||||
}
|
||||
add("web", src.URL)
|
||||
if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok {
|
||||
label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind]
|
||||
add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// containerFields describe a vault, scope or file.
|
||||
func (m Model) containerFields(c provider.Container) []panes.Field {
|
||||
var out []panes.Field
|
||||
add := func(label, value string) {
|
||||
if value != "" {
|
||||
out = append(out, panes.Field{Label: label, Value: value})
|
||||
}
|
||||
}
|
||||
add("name", c.Name)
|
||||
add("note", m.store.Node(storeKey(c)).Note)
|
||||
// The vault's URI, the scope's backend, the file's format.
|
||||
switch c.Source.Kind {
|
||||
case provider.KindAzure:
|
||||
add("URI", c.Detail)
|
||||
case provider.KindDatabricks:
|
||||
add("backend", c.Detail)
|
||||
case provider.KindRepo:
|
||||
add("format", c.Detail)
|
||||
}
|
||||
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
|
||||
add("web", c.URL)
|
||||
for _, t := range c.ObjectTypes() {
|
||||
if e, ok := m.cache.objects[containerKey(c)+"|"+string(t)]; ok {
|
||||
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
|
||||
}
|
||||
}
|
||||
for _, a := range c.Attrs {
|
||||
out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn})
|
||||
}
|
||||
switch st := m.details[containerKey(c)]; {
|
||||
case st.loading:
|
||||
out = append(out, panes.Field{Label: "details", Value: "loading…", Info: true})
|
||||
case errors.Is(st.err, provider.ErrUnsupported):
|
||||
case st.err != nil:
|
||||
out = append(out, panes.Field{Label: "details", Value: "couldn't load them: " + st.err.Error(), Info: true, Err: true})
|
||||
}
|
||||
for _, a := range m.details[containerKey(c)].attrs {
|
||||
out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// loadDetails loads c's details, if its provider has them and they
|
||||
// aren't loaded yet.
|
||||
func (m *Model) loadDetails(c provider.Container) tea.Cmd {
|
||||
d, ok := m.providerFor(c.Source).(provider.ContainerDetails)
|
||||
key := containerKey(c)
|
||||
if _, seen := m.details[key]; !ok || seen {
|
||||
return nil
|
||||
}
|
||||
// One at a time: the previous one, if it's still loading, is
|
||||
// cancelled and forgotten, to load when it's shown again.
|
||||
for k, st := range m.details {
|
||||
if st.loading {
|
||||
delete(m.details, k)
|
||||
}
|
||||
}
|
||||
m.details[key] = detailsState{loading: true}
|
||||
ctx, req := m.begin(slotDetails)
|
||||
return m.settled(ctx, func() tea.Msg {
|
||||
attrs, err := d.Details(ctx, c)
|
||||
if ctx.Err() != nil {
|
||||
err = ctx.Err()
|
||||
}
|
||||
return detailsMsg{req: req, key: key, attrs: attrs, err: err}
|
||||
})
|
||||
}
|
||||
|
||||
// problemFields show why an account has nothing to show, wrapped to the
|
||||
// pane, in red.
|
||||
func (m Model) problemFields(problem string) []panes.Field {
|
||||
var out []panes.Field
|
||||
for _, l := range strings.Split(ansi.Wrap(problem, max(20, m.fields.Width()-16), " "), "\n") {
|
||||
out = append(out, panes.Field{Value: l, Info: true, Err: true})
|
||||
}
|
||||
out[0].Label = "problem"
|
||||
return out
|
||||
}
|
||||
|
||||
// fieldName names a copied field: its label, or a row of several under
|
||||
// one label (a tag), its value.
|
||||
func fieldName(f panes.Field) string {
|
||||
return cmpOr(f.Label, f.Value)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||
)
|
||||
|
||||
// When no account has anything to show, as on a first run, the right
|
||||
// pane says how to get started: the sign-in for each kind of account
|
||||
// (y copies it, L runs az login), and the demo.
|
||||
|
||||
// nothingFound reports whether every account has loaded, with nothing.
|
||||
func (m Model) nothingFound() bool {
|
||||
if m.sourcesPending > 0 {
|
||||
return false
|
||||
}
|
||||
for _, p := range m.providers {
|
||||
if len(m.sourceResults[p.ID()].sources) > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// getStartedFields are the rows saying how to get started.
|
||||
func (m Model) getStartedFields() []panes.Field {
|
||||
out := []panes.Field{{Value: "Nothing to show yet: sign in where your secrets are.", Info: true}}
|
||||
seen := map[provider.Kind]bool{}
|
||||
for _, p := range m.providers {
|
||||
if seen[p.Kind()] {
|
||||
continue
|
||||
}
|
||||
seen[p.Kind()] = true
|
||||
f := emptyHint(p.Kind())
|
||||
var auth *provider.AuthError
|
||||
switch err := m.sourceResults[p.ID()].err; {
|
||||
case errors.As(err, &auth) && len(auth.Login) > 0:
|
||||
f.Provider = p.ID() // L signs in
|
||||
out = append(out, f, panes.Field{Value: withKey(m.keys.Login, "%s runs it here"), Info: true})
|
||||
case err != nil:
|
||||
out = append(out, f, panes.Field{Value: err.Error(), Info: true, Err: true})
|
||||
default:
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return append(out,
|
||||
panes.Field{Label: "try", Value: "lazysecrets --demo"},
|
||||
panes.Field{Value: "built-in sample data, to look around", Info: true},
|
||||
panes.Field{Label: "docs", Value: "getting started", URL: update.DefaultRepo + "#get-started"},
|
||||
)
|
||||
}
|
||||
|
||||
// emptyHint is how an account of kind gets something to show.
|
||||
func emptyHint(kind provider.Kind) panes.Field {
|
||||
switch kind {
|
||||
case provider.KindDatabricks:
|
||||
return panes.Field{Label: "Databricks", Value: "databricks auth login --host https://<workspace>"}
|
||||
case provider.KindRepo:
|
||||
return panes.Field{Label: "repos", Value: "SOPS and .env files in your git repos",
|
||||
URL: update.DefaultRepo + "/src/branch/main/docs/repos.md"}
|
||||
}
|
||||
return panes.Field{Label: string(kind), Value: "az login"}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package app
|
||||
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
|
||||
// At startup, if lazysecrets:// links don't open lazysecrets, a popup
|
||||
// offers to set that up, once per start until the user says not to ask
|
||||
// again; that choice is kept in the store.
|
||||
|
||||
// HandlerSetup offers to set up the link handler.
|
||||
type HandlerSetup struct {
|
||||
Reason string // why links don't open lazysecrets
|
||||
// Install sets it up, returning what links open in ("kitty").
|
||||
Install func() (string, error)
|
||||
}
|
||||
|
||||
const (
|
||||
flagNoHandlerPrompt = "noLinkHandlerPrompt"
|
||||
setupTitle = "Open links in lazysecrets?"
|
||||
)
|
||||
|
||||
// deferHandlerSetup puts the question back to show later, if a link
|
||||
// popup is about to replace it.
|
||||
func (m *Model) deferHandlerSetup() {
|
||||
if m.prompt != nil && m.prompt.title == setupTitle {
|
||||
m.askHandler = true
|
||||
}
|
||||
}
|
||||
|
||||
type handlerInstalledMsg struct {
|
||||
opens string
|
||||
err error
|
||||
}
|
||||
|
||||
// askHandlerSetup shows the popup, once no other popup is in the way.
|
||||
func (m *Model) askHandlerSetup() {
|
||||
if !m.askHandler || m.prompt != nil || m.search != nil {
|
||||
return
|
||||
}
|
||||
m.askHandler = false
|
||||
setup := m.handlerSetup
|
||||
m.prompt = &modal{
|
||||
title: setupTitle,
|
||||
lines: []string{
|
||||
setup.Reason + ".",
|
||||
m.st.Muted.Render("Set up, clicking a lazysecrets:// link (in Slack, a browser…) opens it here."),
|
||||
},
|
||||
options: []option{
|
||||
{key: "i", label: "Set it up", run: func(*Model) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
opens, err := setup.Install()
|
||||
return handlerInstalledMsg{opens, err}
|
||||
}
|
||||
}},
|
||||
{key: "s", label: "Not now", tip: "Asks again next time lazysecrets starts.", run: func(*Model) tea.Cmd { return nil }},
|
||||
{key: "n", label: "Don't ask again", tip: "lazysecrets handler install sets it up later.", run: func(m *Model) tea.Cmd {
|
||||
if err := m.store.SetFlag(flagNoHandlerPrompt, true); err != nil {
|
||||
m.flash = flash{text: "saving: " + err.Error(), isErr: true}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handlerInstalled(msg handlerInstalledMsg) {
|
||||
switch {
|
||||
case msg.err != nil:
|
||||
m.flash = flash{text: "setting up links: " + msg.err.Error(), isErr: true}
|
||||
case msg.opens != "":
|
||||
m.flash = done("lazysecrets:// links now open here, or in a new " + msg.opens + " window")
|
||||
default:
|
||||
m.flash = done("lazysecrets:// links now open in lazysecrets")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
func setupOpts(mem *store.Store, installs *int) Options {
|
||||
return Options{Store: mem, HandlerSetup: &HandlerSetup{
|
||||
Reason: "lazysecrets:// links don't open lazysecrets yet",
|
||||
Install: func() (string, error) { *installs++; return "kitty", nil },
|
||||
}}
|
||||
}
|
||||
|
||||
func TestHandlerSetupPopup(t *testing.T) {
|
||||
t.Parallel()
|
||||
mem, _ := store.Load("")
|
||||
installs := 0
|
||||
d := newDriverOpts(t, setupOpts(mem, &installs))
|
||||
for _, want := range []string{"Open links in lazysecrets?", "links don't open lazysecrets yet", "i Set it up", "s Not now", "n Don't ask again"} {
|
||||
if !strings.Contains(d.screen(), want) {
|
||||
t.Fatalf("missing %q:\n%s", want, d.screen())
|
||||
}
|
||||
}
|
||||
d.keys("i")
|
||||
if installs != 1 || !strings.Contains(d.screen(), "links now open here, or in a new kitty window") {
|
||||
t.Errorf("set up: %d installs\n%s", installs, d.screen())
|
||||
}
|
||||
|
||||
// Not now: asked again next start.
|
||||
d = newDriverOpts(t, setupOpts(mem, &installs))
|
||||
d.keys("s")
|
||||
if d.m.prompt != nil || mem.Flag(flagNoHandlerPrompt) {
|
||||
t.Error("not now")
|
||||
}
|
||||
d = newDriverOpts(t, setupOpts(mem, &installs))
|
||||
if d.m.prompt == nil {
|
||||
t.Fatal("not asked again after not now")
|
||||
}
|
||||
|
||||
// Don't ask again: remembered.
|
||||
d.keys("n")
|
||||
if !mem.Flag(flagNoHandlerPrompt) {
|
||||
t.Error("don't ask again not stored")
|
||||
}
|
||||
d = newDriverOpts(t, setupOpts(mem, &installs))
|
||||
if d.m.prompt != nil {
|
||||
t.Errorf("asked after don't ask again:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
// A link given at startup is offered first; the setup question waits.
|
||||
func TestHandlerSetupWaitsForLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
mem, _ := store.Load("")
|
||||
installs := 0
|
||||
opts := setupOpts(mem, &installs)
|
||||
r, _ := ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key")
|
||||
opts.Open = &r
|
||||
d := newDriverOpts(t, opts)
|
||||
if !strings.Contains(d.screen(), "Open link") {
|
||||
t.Fatalf("link not first:\n%s", d.screen())
|
||||
}
|
||||
d.keys("x")
|
||||
if !strings.Contains(d.screen(), "Open links in lazysecrets?") {
|
||||
t.Errorf("setup question not shown after the link:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
// The help bar shows the keys that matter in the focused pane; ? lists all
|
||||
// of them in a menu (showKeybindings).
|
||||
|
||||
// newHelp styles the help bar to read clearly: keys like labels elsewhere,
|
||||
// descriptions in the normal text colour, only the separators dimmed. The
|
||||
// bubbles defaults are grey on grey.
|
||||
func newHelp(st styles.Styles) help.Model {
|
||||
h := help.New()
|
||||
h.Styles.ShortKey = st.Label
|
||||
h.Styles.ShortDesc = lipgloss.NewStyle()
|
||||
h.Styles.ShortSeparator = st.Muted
|
||||
h.Styles.Ellipsis = st.Muted
|
||||
h.Styles.FullKey, h.Styles.FullDesc, h.Styles.FullSeparator = h.Styles.ShortKey, h.Styles.ShortDesc, h.Styles.ShortSeparator
|
||||
return h
|
||||
}
|
||||
|
||||
// contextHelp is the help bar for the focused pane.
|
||||
type contextHelp struct {
|
||||
k keys.Map
|
||||
focus pane
|
||||
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
|
||||
}
|
||||
|
||||
func (c contextHelp) ShortHelp() []key.Binding {
|
||||
k := c.k
|
||||
var b []key.Binding
|
||||
if k.Login.Enabled() {
|
||||
b = append(b, k.Login)
|
||||
}
|
||||
copyField := key.NewBinding(key.WithKeys(k.Copy.Keys()...), key.WithHelp(k.Copy.Help().Key, "copy"))
|
||||
switch {
|
||||
case c.focus == paneStatus:
|
||||
details := key.NewBinding(key.WithKeys(k.Enter.Keys()...), key.WithHelp(k.Enter.Help().Key, "details"))
|
||||
return append(b, details, k.Update, k.Help, k.Quit)
|
||||
case c.focus == paneDetail && c.account && c.of == paneStatus:
|
||||
open := key.NewBinding(key.WithKeys(k.Open.Keys()...), key.WithHelp(k.Open.Help().Key, "open link"))
|
||||
return append(b, k.Up, k.Down, copyField, open, k.Edit, k.Filter, k.Help, k.Quit)
|
||||
case c.focus == paneDetail && c.account:
|
||||
return append(b, k.Up, k.Down, copyField, k.Filter, k.Help, k.Quit)
|
||||
}
|
||||
switch c.focus {
|
||||
case paneSources:
|
||||
b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Filter)
|
||||
case paneContainers:
|
||||
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]))
|
||||
}
|
||||
b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter)
|
||||
}
|
||||
if c.commands {
|
||||
b = append(b, k.Commands)
|
||||
}
|
||||
return append(b, k.OpenRef, k.Help, k.Quit)
|
||||
}
|
||||
|
||||
func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.ShortHelp()} }
|
||||
|
||||
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
|
||||
|
||||
// showKeybindings opens the ? menu, like lazygit's: every binding with
|
||||
// what it does, the focused pane's first, filterable by key, description
|
||||
// and group. Enter runs the selected one.
|
||||
func (m *Model) showKeybindings() tea.Cmd { return m.showKeybindingsFor("") }
|
||||
|
||||
// showKeybindingsFor opens the ? menu, like lazygit's: every binding with
|
||||
// what it does, filterable by key, description and group; enter runs the
|
||||
// selected one. Opened from the panes, it lists the focused pane's keys
|
||||
// ("") first, then the rest; from a popup (keys.GoTo, keys.Menu), only
|
||||
// the keys that work there, and it comes back to the popup when closed.
|
||||
func (m *Model) showKeybindingsFor(ctx string) tea.Cmd {
|
||||
// Where it was opened from, to come back to.
|
||||
search, prompt := m.search, m.prompt
|
||||
m.search = nil
|
||||
|
||||
actions := m.keys.Actions()
|
||||
id := func(a keys.Action) string { return a.Context + "." + a.Name }
|
||||
here := map[string]bool{}
|
||||
var local []keys.Action
|
||||
switch ctx {
|
||||
case "":
|
||||
for _, b := range (contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0}).ShortHelp() {
|
||||
for _, a := range actions {
|
||||
if a.Context == keys.Lists && !here[id(a)] && slices.Equal(a.Binding.Keys(), b.Keys()) && a.Desc == b.Help().Desc {
|
||||
here[id(a)] = true
|
||||
local = append(local, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
for _, a := range actions {
|
||||
if a.Context == ctx {
|
||||
here[id(a)] = true
|
||||
local = append(local, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
var rest []keys.Action
|
||||
for _, a := range actions {
|
||||
if !here[id(a)] && a.Binding.Enabled() && !(a.Context == keys.Lists && a.Name == "help") {
|
||||
rest = append(rest, a)
|
||||
}
|
||||
}
|
||||
var opts []option
|
||||
section := func(title string, list []keys.Action) {
|
||||
opts = append(opts, option{label: title, header: true})
|
||||
for _, a := range list {
|
||||
h := a.Binding.Help()
|
||||
if len(a.Binding.Keys()) == 0 {
|
||||
continue // unbound
|
||||
}
|
||||
msg := keyPress(a.Binding.Keys()[0])
|
||||
// Go to's typing keys need Go to typing: start it first.
|
||||
typing := ctx == keys.GoTo && (a.Context == keys.Popup || a.Context == keys.Search)
|
||||
label := m.st.Label.Render(fmt.Sprintf("%-14s", h.Key)) + fmt.Sprintf("%-26s", h.Desc) + m.st.Muted.Render(a.Group)
|
||||
opts = append(opts, option{label: label, tip: a.Tip, run: func(m *Model) tea.Cmd {
|
||||
send := func() tea.Msg { return msg }
|
||||
if typing && m.search != nil && !m.search.typing {
|
||||
// Typing starts now; its command only blinks the cursor.
|
||||
return tea.Batch(m.setTyping(true), send)
|
||||
}
|
||||
return send
|
||||
}})
|
||||
}
|
||||
}
|
||||
title := map[string]string{"": paneNames[m.focus] + " pane", keys.GoTo: "Go to", keys.Menu: "This menu"}[ctx]
|
||||
section(title, local)
|
||||
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
|
||||
opts = append(opts, option{label: "Custom commands", header: true})
|
||||
for _, c := range cmds {
|
||||
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmpOr(c.Description, "command")) + m.st.Muted.Render("custom")
|
||||
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
|
||||
}
|
||||
}
|
||||
switch ctx {
|
||||
case "":
|
||||
section("Global", rest)
|
||||
case keys.GoTo:
|
||||
// Only what works here: Go to's keys, browsing and typing.
|
||||
var typing []keys.Action
|
||||
for _, a := range actions {
|
||||
if a.Context == keys.Popup || a.Context == keys.Search {
|
||||
typing = append(typing, a)
|
||||
}
|
||||
}
|
||||
section("While typing, after "+keys.Name(m.keys.GoFilter), typing)
|
||||
}
|
||||
cmd := m.showFilterMenu("Keybindings", opts)
|
||||
m.prompt.footer = footer(move(m.keys.PopupUp, m.keys.PopupDown), hint(m.keys.PopupSelect, "run"), hint(m.keys.PopupClose, "close"))
|
||||
if ctx != "" {
|
||||
m.prompt.onClose = func(m *Model) { m.search, m.prompt = search, prompt }
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// versionLabel is the version as shown: "v0.1.0", or the build for
|
||||
// development builds ("dev-c1a4f90").
|
||||
func (m Model) versionLabel() string {
|
||||
if m.version == "" || strings.HasPrefix(m.version, "dev") || strings.HasPrefix(m.version, "v") {
|
||||
return m.version
|
||||
}
|
||||
return "v" + m.version
|
||||
}
|
||||
|
||||
// helpBar draws bindings as the help bar. When they don't fit, entries
|
||||
// before the last keep (like "? help • q quit") give way, marked "…",
|
||||
// so those always show.
|
||||
func (m Model) helpBar(bindings []key.Binding, keep int) string {
|
||||
var bs []key.Binding
|
||||
for _, b := range bindings {
|
||||
if b.Enabled() {
|
||||
bs = append(bs, b)
|
||||
}
|
||||
}
|
||||
h := m.help
|
||||
h.SetWidth(0) // measure untruncated
|
||||
limit := max(0, m.w-1)
|
||||
if v := m.versionLabel(); v != "" {
|
||||
limit -= ansi.StringWidth(v) + 2 // a gap, the version, the margin
|
||||
}
|
||||
if full := h.ShortHelpView(bs); ansi.StringWidth(full) <= limit {
|
||||
return full
|
||||
}
|
||||
keep = min(keep, len(bs))
|
||||
head, tail := bs[:len(bs)-keep], bs[len(bs)-keep:]
|
||||
sep := h.Styles.ShortSeparator.Render(h.ShortSeparator)
|
||||
more := h.Styles.Ellipsis.Render("…")
|
||||
for n := len(head); n >= 0; n-- {
|
||||
line := more + sep + h.ShortHelpView(tail)
|
||||
if n > 0 {
|
||||
line = h.ShortHelpView(head[:n]) + sep + line
|
||||
}
|
||||
if ansi.StringWidth(line) <= limit || n == 0 {
|
||||
return ansi.Truncate(line, limit, "…")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// popupKeys are the open popup's keys for the help bar: Go to's for its
|
||||
// mode and tab, a menu's, or a text field's.
|
||||
func (m Model) popupKeys() []key.Binding {
|
||||
km := m.keys
|
||||
describe := func(b key.Binding, desc string) key.Binding {
|
||||
b.SetHelp(b.Help().Key, desc)
|
||||
return b
|
||||
}
|
||||
if v := m.search; v != nil {
|
||||
inSearch := v.tab == tabSearch
|
||||
if v.typing {
|
||||
b := []key.Binding{km.PopupDown, km.PopupUp}
|
||||
if inSearch {
|
||||
b = append(b, km.SearchSources, km.SearchResults, km.SearchLoad, km.SearchLoadAll)
|
||||
}
|
||||
return append(b, km.PopupStar, km.PopupSelect, describe(km.PopupClose, "stop typing"))
|
||||
}
|
||||
b := []key.Binding{km.GoDown, km.GoUp, km.GoPrevTab, km.GoNextTab}
|
||||
if inSearch {
|
||||
b = append(b, km.GoSources, km.GoResults, km.GoLoad, km.GoLoadAll)
|
||||
}
|
||||
return append(b, km.GoFilter, km.GoStar, km.GoSelect, km.GoClose, km.Help)
|
||||
}
|
||||
switch p := m.prompt; {
|
||||
case p.input != nil:
|
||||
return []key.Binding{describe(km.PopupSelect, "confirm"), describe(km.PopupClose, "cancel")}
|
||||
case p.filter != nil:
|
||||
return []key.Binding{km.PopupDown, km.PopupUp, km.PopupSelect, km.PopupClose}
|
||||
}
|
||||
return []key.Binding{km.MenuDown, km.MenuUp, km.MenuSelect, km.MenuClose, km.Help}
|
||||
}
|
||||
|
||||
// A keyHint is a key and what it does, for footers and hints, read from
|
||||
// the bindings so they show what's bound.
|
||||
type keyHint struct{ key, desc string }
|
||||
|
||||
func hint(b key.Binding, desc string) keyHint { return keyHint{keys.Name(b), desc} }
|
||||
|
||||
// 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"}
|
||||
}
|
||||
|
||||
// withKey fills a hint's format with the binding's key ("· %s loads
|
||||
// everything"), or is empty when the action is unbound.
|
||||
func withKey(b key.Binding, format string) string {
|
||||
if k := keys.Name(b); k != "" {
|
||||
return fmt.Sprintf(format, k)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// joinKeys is "ctrl+h/ctrl+l" for two bindings, or whichever is bound.
|
||||
func joinKeys(a, b key.Binding) string {
|
||||
x, y := keys.Name(a), keys.Name(b)
|
||||
if x != "" && y != "" {
|
||||
return x + "/" + y
|
||||
}
|
||||
return x + y
|
||||
}
|
||||
|
||||
// footer joins hints as "↑/↓ move · enter go", leaving out unbound keys.
|
||||
func footer(hints ...keyHint) string {
|
||||
var parts []string
|
||||
for _, h := range hints {
|
||||
if h.key != "" {
|
||||
parts = append(parts, h.key+" "+h.desc)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// keyPress builds the key press a binding's key name stands for, so the ?
|
||||
// menu can run it.
|
||||
func keyPress(name string) tea.KeyPressMsg {
|
||||
var mod tea.KeyMod
|
||||
for {
|
||||
switch {
|
||||
case strings.HasPrefix(name, "ctrl+") && len(name) > 5:
|
||||
mod |= tea.ModCtrl
|
||||
name = name[5:]
|
||||
continue
|
||||
case strings.HasPrefix(name, "shift+") && len(name) > 6:
|
||||
mod |= tea.ModShift
|
||||
name = name[6:]
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
named := map[string]rune{
|
||||
"space": tea.KeySpace, "enter": tea.KeyEnter, "tab": tea.KeyTab, "esc": tea.KeyEscape,
|
||||
"up": tea.KeyUp, "down": tea.KeyDown, "left": tea.KeyLeft, "right": tea.KeyRight,
|
||||
"home": tea.KeyHome, "end": tea.KeyEnd, "backspace": tea.KeyBackspace,
|
||||
}
|
||||
if code, ok := named[name]; ok {
|
||||
k := tea.KeyPressMsg{Code: code, Mod: mod}
|
||||
if code == tea.KeySpace && mod == 0 {
|
||||
k.Text = " "
|
||||
}
|
||||
return k
|
||||
}
|
||||
r := []rune(name)[0]
|
||||
if mod != 0 {
|
||||
return tea.KeyPressMsg{Code: r, Mod: mod}
|
||||
}
|
||||
return tea.KeyPressMsg{Code: r, Text: name}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// The Favourites and Recent scopes of the Go to popup list what's
|
||||
// remembered in the store, so they work before anything has loaded.
|
||||
|
||||
// storeEntries lists starred items (sources first, then vaults, then
|
||||
// secrets) and recently used ones (newest first), as Go to entries.
|
||||
// Entries read as paths: " platform-prod / kv-app-prod / api-key · dev",
|
||||
// the account last, and only when there are several.
|
||||
func (m *Model) storeEntries() (favs, recent []option) {
|
||||
type entry struct {
|
||||
depth int // 0 source, 1 vault, 2 secret: sources list first
|
||||
sort string
|
||||
opt option
|
||||
}
|
||||
nodes := m.store.Nodes()
|
||||
keys := slices.Sorted(maps.Keys(nodes))
|
||||
|
||||
// entries lists jumps to the item through each of accounts; nil
|
||||
// means through whichever accounts list a source, else the account
|
||||
// that last read the item.
|
||||
entries := func(prefix, key string, n store.Node, accounts []string) []entry {
|
||||
r, err := ref.Parse(key)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []entry
|
||||
add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) {
|
||||
account := ""
|
||||
if p, ok := m.byID[acct]; ok {
|
||||
account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()})
|
||||
}
|
||||
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
||||
out = append(out, entry{depth, sort, option{
|
||||
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
|
||||
id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
|
||||
run: func(m *Model) tea.Cmd {
|
||||
m.used(key)
|
||||
return run(m)
|
||||
}}})
|
||||
}
|
||||
if r.IsSource() {
|
||||
for _, p := range m.providers {
|
||||
if p.Kind() != r.Kind || (accounts != nil && !slices.Contains(accounts, p.ID())) {
|
||||
continue
|
||||
}
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
if !sourceMatches(r, src) {
|
||||
continue
|
||||
}
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
add([]string{src.Name}, p.ID(), 0, func(m *Model) tea.Cmd {
|
||||
m.nav = &nav{sourceRow: row, sourceOnly: true}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
if accounts == nil {
|
||||
accounts = []string{n.Account}
|
||||
}
|
||||
depth := 1
|
||||
if !r.IsContainer() {
|
||||
depth = 2
|
||||
}
|
||||
path := slices.Clone(n.Path)
|
||||
if len(path) == 0 && !r.IsContainer() {
|
||||
path = []string{r.ContainerName()} // no context recorded
|
||||
}
|
||||
for _, acct := range accounts {
|
||||
via := n
|
||||
via.Account = acct
|
||||
add(append(slices.Clone(path), cmp.Or(n.Name, r.Name, r.ContainerName())), acct, depth,
|
||||
func(m *Model) tea.Cmd { return m.jumpTo(r, via) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var starred []entry
|
||||
for _, k := range keys {
|
||||
if n := nodes[k]; len(n.StarredBy) > 0 {
|
||||
starred = append(starred, entries("fav|", k, n, n.StarredBy)...)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(starred, func(a, b entry) int {
|
||||
return cmp.Or(a.depth-b.depth, strings.Compare(a.sort, b.sort))
|
||||
})
|
||||
for _, e := range starred {
|
||||
favs = append(favs, e.opt)
|
||||
}
|
||||
|
||||
if m.history {
|
||||
var used []string
|
||||
for _, k := range keys {
|
||||
if !nodes[k].LastUsed.IsZero() {
|
||||
used = append(used, k)
|
||||
}
|
||||
}
|
||||
slices.SortStableFunc(used, func(a, b string) int { return nodes[b].LastUsed.Compare(nodes[a].LastUsed) })
|
||||
for _, k := range used[:min(len(used), recentMax)] {
|
||||
for _, e := range entries("recent|", k, nodes[k], nil) {
|
||||
recent = append(recent, e.opt)
|
||||
}
|
||||
}
|
||||
}
|
||||
return favs, recent
|
||||
}
|
||||
|
||||
// recentMax is how many recently used items Recent lists.
|
||||
const recentMax = 20
|
||||
|
||||
// pathLabel draws a jump list entry: the provider's icon and the path in
|
||||
// its colour, then the account.
|
||||
func (m Model) pathLabel(kind provider.Kind, path []string, account string) string {
|
||||
style := m.st.Kind(kind)
|
||||
parts := make([]string, len(path))
|
||||
for i, p := range path {
|
||||
parts[i] = style.Render(p)
|
||||
}
|
||||
label := style.Render(m.icons.Prefix(kind)) + strings.Join(parts, m.st.Muted.Render(" / "))
|
||||
if account != "" {
|
||||
label += m.st.Muted.Render(" · " + account)
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
// sourceMatches reports whether src is the subscription or workspace r
|
||||
// links to.
|
||||
func sourceMatches(r ref.Ref, src provider.Source) bool {
|
||||
if r.Kind != provider.KindAzure { // by host: workspaces, repos
|
||||
return strings.EqualFold(strings.TrimSuffix(src.Detail, "/"), r.Host)
|
||||
}
|
||||
return src.ID == r.Subscription
|
||||
}
|
||||
|
||||
// accountOf is the account part of a source's provider label ("alice" in
|
||||
// "Azure · alice"), or "" when there's a single account.
|
||||
func accountOf(src provider.Source) string {
|
||||
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
// jumpTo goes to a remembered vault or secret. With the account that last
|
||||
// read it signed in, it goes straight there; otherwise it uses the
|
||||
// accounts that list its subscription, asking if there are several, or
|
||||
// finds it like a link. Reaching it updates the remembered account.
|
||||
func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
|
||||
if n.Source == "" && !r.IsContainer() {
|
||||
// A secret without context: its vault's record may have it.
|
||||
parent := r
|
||||
parent.Type, parent.Name, parent.Version = "", "", ""
|
||||
if p := m.store.Node(parent.String()); p.Source != "" {
|
||||
n.Source, n.Account = p.Source, cmp.Or(n.Account, p.Account)
|
||||
}
|
||||
}
|
||||
if _, ok := m.byID[n.Account]; ok && n.Source != "" {
|
||||
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})
|
||||
}
|
||||
}
|
||||
}
|
||||
switch len(choices) {
|
||||
case 0:
|
||||
return m.openRef(r, actGo)
|
||||
case 1:
|
||||
m.startNav(r, actGo, choices[0].sourceRow)
|
||||
default:
|
||||
m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// Layout: the left column stacks Sources, Vaults and Secrets; the detail
|
||||
// pane fills the rest. The user can move the divider between them, change
|
||||
// the left panes' heights, and zoom the focused pane.
|
||||
|
||||
type zoom int
|
||||
|
||||
const (
|
||||
zoomNormal zoom = iota
|
||||
zoomHalf // the focused pane gets most of the screen
|
||||
zoomFull // only the focused pane
|
||||
zoomCount
|
||||
)
|
||||
|
||||
const (
|
||||
// Divider between the left column and the detail pane.
|
||||
splitStep = 4
|
||||
minLeftW = 20
|
||||
minDetailW = 30
|
||||
defaultMinW = 28
|
||||
defaultMaxW = 48
|
||||
|
||||
// Heights of the left panes.
|
||||
heightStep = 2
|
||||
minPaneH = 4 // border, title and two rows
|
||||
tinyPaneH = 3 // a squeezed pane in half zoom: border and one row
|
||||
)
|
||||
|
||||
func (m Model) defaultLeftWidth() int { return max(defaultMinW, min(defaultMaxW, m.w/3)) }
|
||||
|
||||
// leftWidth is the default width moved by the user's divider offset,
|
||||
// kept within bounds that leave both sides usable.
|
||||
func (m Model) leftWidth() int {
|
||||
return max(minLeftW, min(m.w-minDetailW, m.defaultLeftWidth()+m.split))
|
||||
}
|
||||
|
||||
// moveSplit moves the divider by delta columns. The offset is stored as
|
||||
// what actually applied, so after hitting a limit the other direction
|
||||
// responds immediately.
|
||||
func (m *Model) moveSplit(delta int) {
|
||||
m.split += delta
|
||||
m.split = m.leftWidth() - m.defaultLeftWidth()
|
||||
}
|
||||
|
||||
func defaultHeights(avail int) [3]int {
|
||||
src, cont := max(minPaneH, avail/4), max(minPaneH, avail/3)
|
||||
return [3]int{src, cont, avail - src - cont}
|
||||
}
|
||||
|
||||
// leftHeights applies the user's height changes to the default split.
|
||||
func (m Model) leftHeights(avail int) [3]int {
|
||||
h := defaultHeights(avail)
|
||||
for i := range h {
|
||||
h[i] += m.heights[i]
|
||||
}
|
||||
return fitHeights(h, avail, minPaneH)
|
||||
}
|
||||
|
||||
// fitHeights makes the heights sum to avail with none below lo, taking
|
||||
// from or giving to the tallest panes.
|
||||
func fitHeights(h [3]int, avail, lo int) [3]int {
|
||||
for i := range h {
|
||||
h[i] = max(lo, h[i])
|
||||
}
|
||||
for sum(h) > avail {
|
||||
i := tallest(h)
|
||||
if h[i] <= lo {
|
||||
break // the terminal is too short; the layout check catches it
|
||||
}
|
||||
h[i]--
|
||||
}
|
||||
for sum(h) < avail {
|
||||
h[tallest(h)]++
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func sum(h [3]int) int { return h[0] + h[1] + h[2] }
|
||||
|
||||
func tallest(h [3]int) int {
|
||||
i := 0
|
||||
for j := range h {
|
||||
if h[j] > h[i] {
|
||||
i = j
|
||||
}
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// resizeFocused makes the focused left pane taller (delta > 0) or shorter,
|
||||
// trading rows with the pane below it, or above it for the bottom pane.
|
||||
// It reports false if the detail pane is focused.
|
||||
func (m *Model) resizeFocused(delta int) bool {
|
||||
if m.focus > paneSecrets {
|
||||
return false
|
||||
}
|
||||
avail := m.listAvail()
|
||||
h := m.leftHeights(avail)
|
||||
i := int(m.focus)
|
||||
j := i + 1
|
||||
if i == int(paneSecrets) {
|
||||
j = i - 1
|
||||
}
|
||||
delta = max(delta, minPaneH-h[i]) // don't shrink below the minimum
|
||||
delta = min(delta, h[j]-minPaneH) // or squeeze the neighbour below it
|
||||
h[i] += delta
|
||||
h[j] -= delta
|
||||
base := defaultHeights(avail)
|
||||
for k := range h {
|
||||
m.heights[k] = h[k] - base[k]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// saveLayout remembers the divider and heights for the next run.
|
||||
func (m *Model) saveLayout() {
|
||||
if err := m.store.SetLayout(store.Layout{Split: m.split, Heights: m.heights}); err != nil {
|
||||
m.flash = flash{text: "saving layout: " + err.Error(), isErr: true}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) leftAvail() int { return m.h - lipgloss.Height(m.footerView()) }
|
||||
|
||||
// listAvail is the rows the three list panes share, under the status pane.
|
||||
func (m Model) listAvail() int { return m.leftAvail() - statusH }
|
||||
|
||||
func (m *Model) cycleZoom(delta int) {
|
||||
m.zoom = (m.zoom + zoom(delta) + zoomCount) % zoomCount
|
||||
}
|
||||
|
||||
func (m *Model) layout() {
|
||||
if m.w < minWidth || m.h < minHeight {
|
||||
return
|
||||
}
|
||||
m.help.SetWidth(max(0, m.w-1)) // one column in, like messages
|
||||
avail := m.leftAvail()
|
||||
leftW, h := m.leftWidth(), m.leftHeights(avail-statusH)
|
||||
|
||||
switch m.zoom {
|
||||
case zoomHalf:
|
||||
if m.focus == paneDetail || m.focus == paneStatus {
|
||||
leftW = minLeftW
|
||||
} else {
|
||||
leftW = max(m.leftWidth(), m.w/2)
|
||||
h = [3]int{tinyPaneH, tinyPaneH, tinyPaneH}
|
||||
h[m.focus] = avail - statusH
|
||||
h = fitHeights(h, avail-statusH, tinyPaneH)
|
||||
}
|
||||
case zoomFull:
|
||||
// Only the focused pane is drawn; it gets the whole screen.
|
||||
switch m.focus {
|
||||
case paneDetail, paneStatus:
|
||||
leftW = 0
|
||||
default:
|
||||
leftW = m.w
|
||||
h = [3]int{}
|
||||
h[m.focus] = avail
|
||||
}
|
||||
}
|
||||
|
||||
m.statusW = leftW
|
||||
m.sources.SetSize(leftW, h[0])
|
||||
m.containers.SetSize(leftW, h[1])
|
||||
m.secrets.SetSize(leftW, h[2])
|
||||
m.detail.SetSize(m.w-leftW, avail)
|
||||
m.fields.SetSize(m.w-leftW, avail)
|
||||
}
|
||||
|
||||
// active reports whether p is drawn focused: it has focus and the terminal
|
||||
// does too. Like lazygit, nothing is highlighted while you're in another
|
||||
// window, so the focus is easy to spot when you come back.
|
||||
func (m Model) active(p pane) bool { return m.focus == p && !m.termBlurred }
|
||||
|
||||
func (m Model) panesView() string {
|
||||
spin := m.spin.View()
|
||||
views := [paneStatus]func() string{
|
||||
func() string { return m.sources.View(m.st, m.active(paneSources), spin) },
|
||||
func() string { return m.containers.View(m.st, m.active(paneContainers), spin) },
|
||||
func() string { return m.secrets.View(m.st, m.active(paneSecrets), spin) },
|
||||
func() string {
|
||||
if m.fields.Shown() {
|
||||
return m.fields.View(m.st, m.active(paneDetail), spin)
|
||||
}
|
||||
return m.detail.View(m.st, m.active(paneDetail), spin)
|
||||
},
|
||||
}
|
||||
if m.zoom == zoomFull {
|
||||
if m.focus == paneStatus {
|
||||
return views[paneDetail]() // its rows
|
||||
}
|
||||
return views[m.focus]()
|
||||
}
|
||||
left := lipgloss.JoinVertical(lipgloss.Left, m.statusView(), views[0](), views[1](), views[2]())
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, views[3]())
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// paneHeights reads the left panes' heights back from the screen: each
|
||||
// starts with a "╭─ [n]" line.
|
||||
func (d *driver) paneHeights() [3]int {
|
||||
lines := strings.Split(d.screen(), "\n")
|
||||
var starts []int
|
||||
for i, l := range lines {
|
||||
for n := 1; n <= 3; n++ {
|
||||
if strings.HasPrefix(l, "╭─ ["+string(rune('0'+n))+"]") {
|
||||
starts = append(starts, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(starts) != 3 {
|
||||
d.t.Fatalf("found %d left panes:\n%s", len(starts), d.screen())
|
||||
}
|
||||
bottom := len(lines) - 1 - strings.Count(d.m.footerView(), "\n") // first footer line
|
||||
return [3]int{starts[1] - starts[0], starts[2] - starts[1], bottom - starts[2]}
|
||||
}
|
||||
|
||||
func TestPaneHeights(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t) // 30 rows, one footer line: 29 for panes
|
||||
start := d.paneHeights()
|
||||
if sum(start) != 29 {
|
||||
t.Fatalf("heights %v don't fill the column", start)
|
||||
}
|
||||
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyDown, Mod: tea.ModCtrl}) // Sources taller, from Vaults
|
||||
got := d.paneHeights()
|
||||
if got[0] != start[0]+heightStep || got[1] != start[1]-heightStep || got[2] != start[2] {
|
||||
t.Errorf("ctrl+↓ on Sources: %v → %v", start, got)
|
||||
}
|
||||
|
||||
d.keys("3", "}") // Secrets is the bottom pane: it takes from Vaults above
|
||||
got2 := d.paneHeights()
|
||||
if got2[2] != got[2]+heightStep || got2[1] != got[1]-heightStep {
|
||||
t.Errorf("} on Secrets: %v → %v", got, got2)
|
||||
}
|
||||
|
||||
for range 20 {
|
||||
d.keys("{") // shrink Secrets as far as it goes
|
||||
}
|
||||
if h := d.paneHeights(); h[2] != minPaneH || sum(h) != 29 {
|
||||
t.Errorf("minimum height: %v", h)
|
||||
}
|
||||
|
||||
d.keys("4", "}")
|
||||
if !strings.Contains(d.screen(), "focus a left pane") {
|
||||
t.Error("no hint when resizing from the detail pane")
|
||||
}
|
||||
|
||||
d.keys("=")
|
||||
if h := d.paneHeights(); h != start {
|
||||
t.Errorf("= didn't reset heights: %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoom(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "+") // half: Secrets gets most of the column and half the width
|
||||
h := d.paneHeights()
|
||||
if h[0] != tinyPaneH || h[1] != tinyPaneH || h[2] != 29-2*tinyPaneH {
|
||||
t.Errorf("half zoom heights = %v", h)
|
||||
}
|
||||
if got := d.leftEdge(); got != 50 {
|
||||
t.Errorf("half zoom width = %d, want half of 100", got)
|
||||
}
|
||||
|
||||
d.keys("+") // full: only Secrets
|
||||
screen := d.screen()
|
||||
if !strings.Contains(screen, "[3] Secrets") || strings.Contains(screen, "[1] Sources") || strings.Contains(screen, "Secret ─") {
|
||||
t.Errorf("full zoom should show only the Secrets pane:\n%s", screen)
|
||||
}
|
||||
if w := len([]rune(strings.Split(screen, "\n")[0])); w != 100 {
|
||||
t.Errorf("full zoom width = %d", w)
|
||||
}
|
||||
|
||||
d.keys("4") // the zoom follows focus
|
||||
if !strings.Contains(d.screen(), "╭─ Secret ") || strings.Contains(d.screen(), "[3] Secrets") {
|
||||
t.Errorf("full zoom on the detail pane:\n%s", d.screen())
|
||||
}
|
||||
|
||||
d.keys("+") // wraps to normal
|
||||
if d.m.zoom != zoomNormal {
|
||||
t.Errorf("zoom = %d after three +", d.m.zoom)
|
||||
}
|
||||
d.keys("_")
|
||||
if d.m.zoom != zoomFull {
|
||||
t.Errorf("_ from normal should go to full, got %d", d.m.zoom)
|
||||
}
|
||||
d.keys("=")
|
||||
if d.m.zoom != zoomNormal {
|
||||
t.Error("= didn't reset zoom")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutIsSaved(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
load := func() *store.Store {
|
||||
s, err := store.Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
d := newDriverOpts(t, Options{Store: load()})
|
||||
d.keys(">", ">", "}")
|
||||
edge, heights := d.leftEdge(), d.paneHeights()
|
||||
|
||||
d2 := newDriverOpts(t, Options{Store: load()})
|
||||
if d2.leftEdge() != edge || d2.paneHeights() != heights {
|
||||
t.Errorf("restored %d %v, want %d %v", d2.leftEdge(), d2.paneHeights(), edge, heights)
|
||||
}
|
||||
d2.keys("=")
|
||||
if l := load().Layout(); l.Split != 0 || l.Heights != [3]int{} {
|
||||
t.Errorf("reset not saved: %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextHelpBar(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
|
||||
t.Errorf("Sources help bar: %q", bar)
|
||||
}
|
||||
d.keys("3")
|
||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
|
||||
t.Errorf("Secrets help bar: %q", bar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeybindingsMenu(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "?")
|
||||
if d.m.prompt == nil || d.m.prompt.title != "Keybindings" {
|
||||
t.Fatalf("? didn't open the menu:\n%s", d.screen())
|
||||
}
|
||||
// Sections: the focused pane's keys, then global ones; the cursor
|
||||
// starts on the first key, and a counter shows where it is.
|
||||
p := d.m.prompt
|
||||
if !p.options[0].header || p.options[0].label != "Secrets pane" {
|
||||
t.Errorf("first section: %+v", p.options[0])
|
||||
}
|
||||
if sel := ansi.Strip(p.options[p.cursor].label); !strings.HasPrefix(sel, "space") {
|
||||
t.Errorf("cursor starts on %q", sel)
|
||||
}
|
||||
screen := d.screen()
|
||||
if !strings.Contains(screen, "Global") || !strings.Contains(screen, "1 of ") {
|
||||
t.Errorf("no Global section or counter:\n%s", screen)
|
||||
}
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp}) // can't go up onto the header
|
||||
if p.options[p.cursor].header {
|
||||
t.Error("cursor landed on a header")
|
||||
}
|
||||
if !strings.Contains(d.screen(), "Show or hide the selected value") {
|
||||
t.Errorf("no tip for the selected row:\n%s", d.screen())
|
||||
}
|
||||
|
||||
// The filter matches descriptions and groups too; matches stay under
|
||||
// their section headers, and empty sections drop out.
|
||||
for _, r := range "copy" {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
var headers []string
|
||||
for _, o := range d.m.prompt.options {
|
||||
if o.header {
|
||||
headers = append(headers, o.label)
|
||||
}
|
||||
}
|
||||
if strings.Join(headers, ",") != "Secrets pane,Global" {
|
||||
t.Errorf("sections while filtering: %v", headers)
|
||||
}
|
||||
if d.m.prompt.options[d.m.prompt.cursor].header {
|
||||
t.Error("cursor on a header after filtering")
|
||||
}
|
||||
var keys []string
|
||||
for _, o := range d.m.prompt.options {
|
||||
keys = append(keys, strings.Fields(ansi.Strip(o.label))[0])
|
||||
}
|
||||
for _, want := range []string{"y", "Y", "s", "ctrl+y"} {
|
||||
if !slices.Contains(keys, want) {
|
||||
t.Errorf("filter copy: %v lacks %s", keys, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Enter runs the selected binding.
|
||||
d.keys("esc", "?")
|
||||
for _, r := range "space" {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
if sel := ansi.Strip(d.m.prompt.options[d.m.prompt.cursor].label); !strings.HasPrefix(sel, "space") {
|
||||
t.Fatalf("filter space: selected %q", sel)
|
||||
}
|
||||
d.keys("enter")
|
||||
if !strings.Contains(d.screen(), "correct-horse-battery-staple") {
|
||||
t.Errorf("running reveal from the menu didn't reveal:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyPress(t *testing.T) {
|
||||
t.Parallel()
|
||||
for name, want := range map[string]string{
|
||||
"space": "space", "ctrl+y": "ctrl+y", "shift+tab": "shift+tab", "S": "S", "`": "`", "ctrl+left": "ctrl+left",
|
||||
} {
|
||||
if got := keyPress(name).String(); got != want {
|
||||
t.Errorf("keyPress(%q).String() = %q", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lastLine(s string) string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
return lines[len(lines)-1]
|
||||
}
|
||||
|
||||
func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
green := "\x1b[32m╭" // a focused border; "current" versions are green too
|
||||
if !strings.Contains(d.m.View().Content, green) {
|
||||
t.Fatal("focused pane not highlighted")
|
||||
}
|
||||
d.send(tea.BlurMsg{})
|
||||
if strings.Contains(d.m.View().Content, green) {
|
||||
t.Error("still highlighted while the terminal is blurred")
|
||||
}
|
||||
d.send(tea.FocusMsg{})
|
||||
if !strings.Contains(d.m.View().Content, green) {
|
||||
t.Error("highlight not back after focus")
|
||||
}
|
||||
|
||||
// Popups too.
|
||||
d.keys("?")
|
||||
d.send(tea.BlurMsg{})
|
||||
if strings.Contains(d.m.modalView(), "\x1b[32m") || strings.Contains(d.m.modalView(), "\x1b[7m") {
|
||||
t.Error("popup still highlighted while the terminal is blurred")
|
||||
}
|
||||
d.send(tea.FocusMsg{})
|
||||
if !strings.Contains(d.m.modalView(), "\x1b[32m╭") {
|
||||
t.Error("popup highlight not back after focus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionInStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriverOpts(t, Options{Version: "0.1.0"})
|
||||
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
|
||||
t.Errorf("no version in the status pane:\n%s", d.screen())
|
||||
}
|
||||
if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
|
||||
t.Errorf("help line %q", bar)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,892 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
// Opening references: a reference (lazysecrets:// link, Key Vault URL, …)
|
||||
// is first resolved to a source, then the panes are stepped through to it
|
||||
// as each list loads. Copy and reveal actions ask before reading the value.
|
||||
|
||||
// linksRowID is the Sources row listing vaults opened from links that no
|
||||
// account could locate (e.g. no read access to the vault resource).
|
||||
const linksRowID = "links"
|
||||
|
||||
type navStage int
|
||||
|
||||
const (
|
||||
navSource navStage = iota
|
||||
navContainer
|
||||
navObject
|
||||
navVersion
|
||||
navAction
|
||||
)
|
||||
|
||||
// nav is an open in progress.
|
||||
type nav struct {
|
||||
ref ref.Ref
|
||||
act linkAction
|
||||
// sourceOnly stops at the source (jumping to a starred subscription).
|
||||
sourceOnly bool
|
||||
|
||||
sourceRow string // Sources row to select
|
||||
stage navStage
|
||||
// quiet gives up without a word: going back to where lazysecrets
|
||||
// was left, which may be gone.
|
||||
quiet bool
|
||||
}
|
||||
|
||||
type resolvedMsg struct {
|
||||
ref ref.Ref
|
||||
act linkAction
|
||||
sourceRow string
|
||||
others []string // other accounts' rows that also found the vault
|
||||
choices []choice // several candidates: ask
|
||||
linked *provider.Container
|
||||
err error
|
||||
}
|
||||
|
||||
type choice struct {
|
||||
label string
|
||||
sourceRow string
|
||||
}
|
||||
|
||||
// linkAction is what to do with an object once it's opened. Links don't
|
||||
// carry it: whoever opens one chooses.
|
||||
type linkAction int
|
||||
|
||||
const (
|
||||
actGo linkAction = iota // just go to it
|
||||
actCopy // and copy its value
|
||||
actShow // and show its value
|
||||
)
|
||||
|
||||
// offerRef asks what to do with a reference: for an object, go to it, or
|
||||
// go to it and copy or show its value; for a subscription, vault or scope,
|
||||
// just open it.
|
||||
// With ask false, vault and scope links open straight away.
|
||||
func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd {
|
||||
if r.IsContainer() || r.IsSource() {
|
||||
if !ask {
|
||||
return m.openRef(r, actGo)
|
||||
}
|
||||
m.showMenu(title, nil, []option{
|
||||
{key: "o", label: "Open it", run: func(m *Model) tea.Cmd { return m.openRef(r, actGo) }},
|
||||
})
|
||||
m.prompt.about = &r
|
||||
return nil
|
||||
}
|
||||
do := func(a linkAction) func(*Model) tea.Cmd {
|
||||
return func(m *Model) tea.Cmd { return m.openRef(r, a) }
|
||||
}
|
||||
opts := []option{
|
||||
{key: "o", label: "Go to it", run: do(actGo)},
|
||||
{key: "c", label: "Go to it and copy its value", run: do(actCopy)},
|
||||
{key: "r", label: "Go to it and show its value", run: do(actShow)},
|
||||
}
|
||||
if r.Kind == provider.KindAzure || r.Host != "" { // {{secrets/s/k}} names no workspace
|
||||
opts = append(opts, option{key: "y", label: "Copy its value, staying here", run: func(m *Model) tea.Cmd { return m.copyFromRef(r) }})
|
||||
}
|
||||
m.showMenu(title, nil, opts)
|
||||
m.prompt.about = &r
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyFromRef copies the value a link points at without going there. The
|
||||
// link says enough to read it: the vault and name (Azure, tried with each
|
||||
// account, the one that last read it first) or the workspace, scope and
|
||||
// key (Databricks).
|
||||
func (m *Model) copyFromRef(r ref.Ref) tea.Cmd {
|
||||
type try struct {
|
||||
p provider.Provider
|
||||
o provider.Object
|
||||
}
|
||||
var tries []try
|
||||
key := r
|
||||
key.Version = ""
|
||||
node := m.store.Node(key.String())
|
||||
for _, p := range m.providers {
|
||||
if p.Kind() != r.Kind {
|
||||
continue
|
||||
}
|
||||
o := provider.Object{Name: r.Name, Type: cmp.Or(r.Type, provider.TypeSecret), Version: r.Version}
|
||||
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: node.Source}
|
||||
if len(node.Path) > 0 {
|
||||
src.Name = node.Path[0]
|
||||
}
|
||||
if r.Kind != provider.KindAzure { // by host: Databricks workspaces, repos
|
||||
o.Type = provider.TypeSecret
|
||||
src.ID = ""
|
||||
for _, s := range m.sourceResults[p.ID()].sources {
|
||||
if sourceMatches(r, s) {
|
||||
src = s
|
||||
}
|
||||
}
|
||||
if src.ID == "" {
|
||||
continue
|
||||
}
|
||||
o.Container = provider.Container{Source: src, Name: r.Scope}
|
||||
} else {
|
||||
o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()}
|
||||
}
|
||||
t := try{p, o}
|
||||
if p.ID() == node.Account {
|
||||
tries = append([]try{t}, tries...)
|
||||
} else {
|
||||
tries = append(tries, t)
|
||||
}
|
||||
}
|
||||
if len(tries) == 0 {
|
||||
m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true}
|
||||
return nil
|
||||
}
|
||||
ctx, req := m.begin(slotCopy)
|
||||
m.flash = flash{text: "copying " + r.Name + "…"}
|
||||
return func() tea.Msg {
|
||||
var errs []error
|
||||
for _, t := range tries {
|
||||
v, err := t.p.Value(ctx, t.o)
|
||||
if err == nil {
|
||||
msg := copyMsg{req: req, name: r.Name, value: v}
|
||||
if t.o.Container.Source.ID != "" {
|
||||
msg.obj = t.o // where it is is known: count the use
|
||||
}
|
||||
return msg
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return copyMsg{req: req, name: r.Name, err: err}
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err))
|
||||
}
|
||||
if len(errs) == 1 {
|
||||
return copyMsg{req: req, name: r.Name, err: errors.Unwrap(errs[0])} // one account: no need to name it
|
||||
}
|
||||
return copyMsg{req: req, name: r.Name, err: errors.Join(errs...)}
|
||||
}
|
||||
}
|
||||
|
||||
// openRef starts opening a reference, doing act once there.
|
||||
func (m *Model) openRef(r ref.Ref, act linkAction) tea.Cmd {
|
||||
m.nav = nil
|
||||
if r.IsSource() {
|
||||
m.openSource(r)
|
||||
return nil
|
||||
}
|
||||
m.flash = flash{text: "opening " + describeRef(r) + "…"}
|
||||
cands := m.providers
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultResolveTimeout)
|
||||
defer cancel()
|
||||
msg := resolve(ctx, cands, r)
|
||||
msg.act = act
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
}
|
||||
}
|
||||
switch len(choices) {
|
||||
case 0:
|
||||
m.flash = flash{text: "open: " + describeRef(r) + " isn't in your sources", isErr: true}
|
||||
case 1:
|
||||
m.nav = &nav{ref: r, sourceRow: choices[0].sourceRow, sourceOnly: true}
|
||||
default:
|
||||
var opts []option
|
||||
for i, c := range choices {
|
||||
row := c.sourceRow
|
||||
opts = append(opts, option{key: fmt.Sprint(i + 1), label: c.label, run: func(m *Model) tea.Cmd {
|
||||
m.nav = &nav{ref: r, sourceRow: row, sourceOnly: true}
|
||||
return nil
|
||||
}})
|
||||
}
|
||||
m.showMenu("Several accounts can see "+describeRef(r), nil, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// subscription or workspace isn't in the link: it comes from what's
|
||||
// remembered about the item, or from lists already loaded.
|
||||
func (m Model) refLabel(r ref.Ref) string {
|
||||
var path []string
|
||||
if src := m.refSource(r); src != "" {
|
||||
path = append(path, src)
|
||||
}
|
||||
switch {
|
||||
case r.IsSource():
|
||||
if len(path) == 0 {
|
||||
path = append(path, cmp.Or(r.Subscription, r.Host))
|
||||
}
|
||||
case r.IsContainer():
|
||||
path = append(path, r.ContainerName())
|
||||
default:
|
||||
path = append(path, r.ContainerName(), r.Name)
|
||||
}
|
||||
label := m.pathLabel(r.Kind, path, "")
|
||||
var extra []string
|
||||
if t := strings.ToLower(pluralLabels[r.Type]); t != "" && r.Type != provider.TypeSecret {
|
||||
if r.IsContainer() {
|
||||
extra = append(extra, t+" tab")
|
||||
} else {
|
||||
extra = append(extra, strings.TrimSuffix(t, "s")) // "key", "certificate"
|
||||
}
|
||||
}
|
||||
if r.Version != "" {
|
||||
extra = append(extra, "version "+r.Version)
|
||||
}
|
||||
if len(extra) > 0 {
|
||||
label += m.st.Muted.Render(" · " + strings.Join(extra, " · "))
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
// refSource names the subscription or workspace r is in, if known.
|
||||
func (m Model) refSource(r ref.Ref) string {
|
||||
key := r
|
||||
key.Version = ""
|
||||
if n := m.store.Node(key.String()); len(n.Path) > 0 {
|
||||
return n.Path[0]
|
||||
}
|
||||
for _, res := range m.sourceResults {
|
||||
for _, src := range res.sources {
|
||||
if (r.Kind != provider.KindAzure || r.IsSource()) && src.Kind == r.Kind && sourceMatches(r, src) {
|
||||
return src.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.Kind == provider.KindAzure && !r.IsSource() {
|
||||
for _, e := range m.cache.containers {
|
||||
for _, c := range e.value {
|
||||
if matchesContainer(c, r) {
|
||||
return c.Source.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func describeRef(r ref.Ref) string {
|
||||
if r.IsSource() {
|
||||
switch r.Kind {
|
||||
case provider.KindDatabricks:
|
||||
return "workspace " + r.Host
|
||||
case provider.KindRepo:
|
||||
return "repo " + r.Host
|
||||
}
|
||||
return "subscription " + r.Subscription
|
||||
}
|
||||
if r.IsContainer() {
|
||||
return r.ContainerName()
|
||||
}
|
||||
return r.Name + " in " + r.ContainerName()
|
||||
}
|
||||
|
||||
// resolve finds the source holding the reference's container.
|
||||
func resolve(ctx context.Context, providers []provider.Provider, r ref.Ref) resolvedMsg {
|
||||
msg := resolvedMsg{ref: r}
|
||||
var errs []error
|
||||
switch {
|
||||
case r.Kind == provider.KindDatabricks && r.Host == "":
|
||||
// {{secrets/scope/key}}: search every workspace for the scope.
|
||||
for _, p := range providers {
|
||||
if p.Kind() != provider.KindDatabricks {
|
||||
continue
|
||||
}
|
||||
srcs, err := p.Sources(ctx)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
for _, src := range srcs {
|
||||
cs, err := p.Containers(ctx, src)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", src.Name, err))
|
||||
continue
|
||||
}
|
||||
for _, c := range cs {
|
||||
if c.Name == r.Scope {
|
||||
msg.choices = append(msg.choices, choice{label: src.Name, sourceRow: sourceRowKey(p.ID(), src.ID)})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
switch len(msg.choices) {
|
||||
case 0:
|
||||
msg.err = errors.Join(append([]error{fmt.Errorf("scope %q isn't in any workspace", r.Scope)}, errs...)...)
|
||||
case 1:
|
||||
msg.sourceRow, msg.choices = msg.choices[0].sourceRow, nil
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
for _, p := range providers {
|
||||
loc, ok := p.(provider.Locator)
|
||||
if !ok || p.Kind() != r.Kind {
|
||||
continue
|
||||
}
|
||||
id, found, err := loc.Locate(ctx, r.Host)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", p.Label(), err))
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
// Several accounts may see it, with different access: the
|
||||
// user picks.
|
||||
row := sourceRowKey(p.ID(), id)
|
||||
if msg.sourceRow == "" {
|
||||
msg.sourceRow = row
|
||||
} else {
|
||||
msg.others = append(msg.others, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
if msg.sourceRow != "" {
|
||||
return msg
|
||||
}
|
||||
if r.Kind == provider.KindAzure {
|
||||
// No account can find the vault resource, but one may still read
|
||||
// it: open it under "Opened links", where each account is tried.
|
||||
msg.linked = &provider.Container{
|
||||
Source: provider.Source{Kind: provider.KindAzure, Name: r.Host},
|
||||
ID: r.VaultURL(), Name: r.ContainerName(), Detail: r.VaultURL(),
|
||||
Types: []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate},
|
||||
}
|
||||
return msg
|
||||
}
|
||||
missing := fmt.Errorf("no Databricks profile for workspace %s", r.Host)
|
||||
if r.Kind == provider.KindRepo {
|
||||
missing = fmt.Errorf("repo %s isn't here: start lazysecrets in it, or list it under providers.repos.paths", r.Host)
|
||||
}
|
||||
msg.err = errors.Join(append([]error{missing}, errs...)...)
|
||||
return msg
|
||||
}
|
||||
|
||||
func (m *Model) handleResolved(msg resolvedMsg) {
|
||||
switch {
|
||||
case msg.err != nil:
|
||||
m.flash = flash{text: "open: " + msg.err.Error(), isErr: true}
|
||||
case len(msg.choices) > 1:
|
||||
for i := range msg.choices {
|
||||
msg.choices[i].label = "Open in " + msg.choices[i].label
|
||||
}
|
||||
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices)
|
||||
case len(msg.others) > 0:
|
||||
var choices []choice
|
||||
for _, row := range append([]string{msg.sourceRow}, msg.others...) {
|
||||
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
||||
}
|
||||
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices)
|
||||
case msg.linked != nil:
|
||||
m.addLinked(*msg.linked)
|
||||
m.startNav(msg.ref, msg.act, linksRowID)
|
||||
default:
|
||||
m.startNav(msg.ref, msg.act, msg.sourceRow)
|
||||
}
|
||||
}
|
||||
|
||||
// chooseSource asks where to open r.
|
||||
func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice) {
|
||||
var opts []option
|
||||
for i, c := range choices {
|
||||
key := ""
|
||||
if i < 9 {
|
||||
key = fmt.Sprint(i + 1)
|
||||
}
|
||||
row := c.sourceRow
|
||||
opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd {
|
||||
m.startNav(r, act, row)
|
||||
return nil
|
||||
}})
|
||||
}
|
||||
m.showMenu(title, nil, opts)
|
||||
}
|
||||
|
||||
// sourceLabel names a Sources row as "Azure · admin / app-dev".
|
||||
func (m Model) sourceLabel(row string) string {
|
||||
providerID, sourceID := splitSourceRow(row)
|
||||
label := providerID
|
||||
if p, ok := m.byID[providerID]; ok {
|
||||
label = p.Label()
|
||||
}
|
||||
for _, s := range m.sourceResults[providerID].sources {
|
||||
if s.ID == sourceID {
|
||||
return label + " / " + s.Name
|
||||
}
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
func (m *Model) startNav(r ref.Ref, act linkAction, sourceRow string) {
|
||||
m.nav = &nav{ref: r, act: act, sourceRow: sourceRow}
|
||||
m.versionsOf = nil
|
||||
m.flash = flash{}
|
||||
}
|
||||
|
||||
// addLinked lists a vault under "Opened links" for this session.
|
||||
func (m *Model) addLinked(c provider.Container) {
|
||||
for _, l := range m.linked {
|
||||
if l.ID == c.ID {
|
||||
return
|
||||
}
|
||||
}
|
||||
m.linked = append(m.linked, c)
|
||||
m.rebuildSources()
|
||||
}
|
||||
|
||||
func (m *Model) navFail(format string, a ...any) {
|
||||
if !m.nav.quiet {
|
||||
m.flash = flash{text: "open: " + fmt.Sprintf(format, a...), isErr: true}
|
||||
}
|
||||
m.nav = nil
|
||||
}
|
||||
|
||||
// advanceNav moves an open along as far as the loaded lists allow. It
|
||||
// reports whether it got further, so the caller can sync and try again.
|
||||
func (m *Model) advanceNav() bool {
|
||||
n := m.nav
|
||||
if n == nil {
|
||||
return false
|
||||
}
|
||||
r := n.ref
|
||||
switch n.stage {
|
||||
case navSource:
|
||||
if !m.sources.Select(n.sourceRow) {
|
||||
group, _ := splitSourceRow(n.sourceRow)
|
||||
if m.collapsed[group] {
|
||||
m.collapsed[group] = false
|
||||
m.rebuildSources()
|
||||
return true
|
||||
}
|
||||
if m.sourcesPending > 0 {
|
||||
return false // still loading
|
||||
}
|
||||
m.navFail("%s isn't in the Sources list (hidden in the config?)", describeRef(r))
|
||||
return false
|
||||
}
|
||||
m.sourcesTouched = true
|
||||
m.focus = paneContainers
|
||||
if n.sourceOnly {
|
||||
m.nav = nil
|
||||
return true
|
||||
}
|
||||
n.stage = navContainer
|
||||
return true
|
||||
|
||||
case navContainer:
|
||||
if m.selSource != n.sourceRow || m.containers.Loading {
|
||||
return false
|
||||
}
|
||||
if !m.containers.SelectWhere(func(c provider.Container) bool { return matchesContainer(c, r) }) {
|
||||
m.navFail("%s not found", r.ContainerName())
|
||||
return false
|
||||
}
|
||||
if r.Type != "" {
|
||||
m.wantType = r.Type
|
||||
}
|
||||
if !r.IsContainer() {
|
||||
m.focus = paneSecrets
|
||||
}
|
||||
n.stage = navObject
|
||||
return true
|
||||
|
||||
case navObject:
|
||||
c, _ := m.containers.Selected()
|
||||
if (r.Type != "" && m.objType != r.Type) || m.secrets.Loading ||
|
||||
!strings.HasPrefix(m.selContainer, containerKey(c)+"|") {
|
||||
return false
|
||||
}
|
||||
if m.secrets.Err != nil {
|
||||
m.nav = nil // the pane shows the error
|
||||
return false
|
||||
}
|
||||
// Reached it, and its account can read it: remember that.
|
||||
m.refreshContext(storeKey(c), c.Name, []string{c.Source.Name}, c.Source)
|
||||
if r.IsContainer() {
|
||||
m.nav = nil
|
||||
return true
|
||||
}
|
||||
if !m.secrets.SelectWhere(func(o provider.Object) bool { return o.Name == r.Name }) {
|
||||
m.navFail("%s not found in %s", r.Name, r.ContainerName())
|
||||
return false
|
||||
}
|
||||
if o, ok := m.secrets.Selected(); ok {
|
||||
m.refreshContext(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source)
|
||||
}
|
||||
if r.Version != "" {
|
||||
o, _ := m.secrets.Selected()
|
||||
m.versionsOf = &o
|
||||
n.stage = navVersion
|
||||
return true
|
||||
}
|
||||
n.stage = navAction
|
||||
return true
|
||||
|
||||
case navVersion:
|
||||
if m.versionsOf == nil || m.secrets.Loading || m.secrets.Err != nil {
|
||||
if m.secrets.Err != nil {
|
||||
m.nav = nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !m.secrets.SelectWhere(func(o provider.Object) bool { return o.Version == r.Version }) {
|
||||
m.flash = flash{text: "version " + r.Version + " not found; showing all versions", isErr: true}
|
||||
}
|
||||
n.stage = navAction
|
||||
return true
|
||||
|
||||
case navAction:
|
||||
// The user chose this when opening the link; no need to ask again.
|
||||
m.nav = nil
|
||||
switch n.act {
|
||||
case actCopy:
|
||||
m.pendingCmd = m.copyValue()
|
||||
case actShow:
|
||||
if m.detail.State() != panes.ValueShown {
|
||||
m.pendingCmd = m.toggleReveal()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesContainer compares by vault URL (Azure), or scope or file name.
|
||||
func matchesContainer(c provider.Container, r ref.Ref) bool {
|
||||
if r.Kind != provider.KindAzure {
|
||||
return c.Name == r.Scope
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSuffix(c.Detail, "/")+"/", r.VaultURL())
|
||||
}
|
||||
|
||||
// share copies a link to the focused item. With menu, it offers the
|
||||
// other things worth sharing about it.
|
||||
func (m *Model) share(menu bool) tea.Cmd {
|
||||
switch m.focus {
|
||||
case paneSources:
|
||||
s, ok := m.selectedSource()
|
||||
if !ok {
|
||||
m.flash = warning("select a subscription, workspace, vault or secret to share")
|
||||
return nil
|
||||
}
|
||||
if !menu {
|
||||
return m.shareSource(s, "o")
|
||||
}
|
||||
share := func(k string) func(*Model) tea.Cmd {
|
||||
return func(m *Model) tea.Cmd { return m.shareSource(s, k) }
|
||||
}
|
||||
opts := []option{{key: "o", label: "Lazysecrets link", run: share("o")}}
|
||||
if s.URL != "" {
|
||||
opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")})
|
||||
}
|
||||
m.showMenu("Share "+s.Name, nil, opts)
|
||||
case paneContainers:
|
||||
c, ok := m.containers.Selected()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !menu {
|
||||
return m.shareContainer(c, "o")
|
||||
}
|
||||
share := func(k string) func(*Model) tea.Cmd {
|
||||
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
|
||||
}
|
||||
opts := []option{option{key: "o", label: "Lazysecrets link", run: share("o")}}
|
||||
if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret {
|
||||
opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")})
|
||||
}
|
||||
if c.Source.Kind == provider.KindAzure {
|
||||
opts = append(opts, option{key: "u", label: "Vault URL", run: share("u")})
|
||||
}
|
||||
if c.URL != "" {
|
||||
opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")})
|
||||
}
|
||||
m.showMenu("Share "+c.Name, nil, opts)
|
||||
default:
|
||||
o, ok := m.detail.Secret()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !menu {
|
||||
return m.shareObject(o, "o")
|
||||
}
|
||||
share := func(k string) func(*Model) tea.Cmd {
|
||||
return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
|
||||
}
|
||||
opts := []option{
|
||||
option{key: "o", label: "Lazysecrets link", run: share("o")},
|
||||
}
|
||||
if o.Container.Source.Kind == provider.KindAzure {
|
||||
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
|
||||
}
|
||||
if o.Reference != "" {
|
||||
opts = append(opts, option{key: "y", label: "Config reference", run: share("y")})
|
||||
}
|
||||
if o.URL != "" {
|
||||
opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")})
|
||||
}
|
||||
m.showMenu("Share "+o.Name, nil, opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// webLabel names the web page o (open in browser) opens.
|
||||
func webLabel(kind provider.Kind) string {
|
||||
switch kind {
|
||||
case provider.KindDatabricks:
|
||||
return "Web page (Databricks workspace)"
|
||||
case provider.KindRepo:
|
||||
return "Web page (repository)"
|
||||
}
|
||||
return "Web page (Azure portal)"
|
||||
}
|
||||
|
||||
// shareSource copies what the share menu's key k stands for, for a
|
||||
// subscription or workspace.
|
||||
func (m *Model) shareSource(s provider.Source, k string) tea.Cmd {
|
||||
m.usedSource(s)
|
||||
if k == "b" {
|
||||
if s.URL == "" {
|
||||
return nil
|
||||
}
|
||||
return m.copyText(s.Name+" web page", s.URL, false)
|
||||
}
|
||||
r, err := ref.ForSource(s)
|
||||
if err != nil {
|
||||
m.flash = flash{text: "share: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
return m.copyText("link to "+s.Name, r.String(), false)
|
||||
}
|
||||
|
||||
// shareContainer copies what the share menu's key k stands for, for a
|
||||
// vault or scope.
|
||||
func (m *Model) shareContainer(c provider.Container, k string) tea.Cmd {
|
||||
m.usedContainer(c)
|
||||
tab := provider.TypeSecret
|
||||
switch k {
|
||||
case "b":
|
||||
if c.URL == "" {
|
||||
return nil
|
||||
}
|
||||
return m.copyText(c.Name+" web page", c.URL, false)
|
||||
case "t":
|
||||
tab = m.objType
|
||||
case "o", "u":
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
r, err := ref.ForContainer(c, tab)
|
||||
if err != nil {
|
||||
m.flash = flash{text: "share: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
if k == "u" {
|
||||
if u := r.IdentifierURL(); u != "" {
|
||||
return m.copyText(c.Name+" URL", u, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return m.copyText("link to "+c.Name, r.String(), false)
|
||||
}
|
||||
|
||||
// shareObject copies what the share menu's key k stands for.
|
||||
func (m *Model) shareObject(o provider.Object, k string) tea.Cmd {
|
||||
m.usedObject(o)
|
||||
if k == "b" {
|
||||
if o.URL == "" {
|
||||
return nil
|
||||
}
|
||||
return m.copyText(o.Name+" web page", o.URL, false)
|
||||
}
|
||||
if k == "y" {
|
||||
if o.Reference == "" {
|
||||
return nil
|
||||
}
|
||||
return m.copyText(o.Name+" reference", o.Reference, false)
|
||||
}
|
||||
version := ""
|
||||
if m.versionsOf != nil {
|
||||
version = o.Version // shared from the versions view: that version
|
||||
}
|
||||
r, err := ref.ForObject(o, version)
|
||||
if err != nil {
|
||||
m.flash = flash{text: "share: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
switch k {
|
||||
case "u":
|
||||
if u := r.IdentifierURL(); u != "" {
|
||||
return m.copyText(o.Name+" URL", u, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return m.copyText("link to "+o.Name, r.String(), false)
|
||||
}
|
||||
|
||||
func (m *Model) startOpenPrompt() tea.Cmd {
|
||||
in := textinput.New()
|
||||
in.Prompt = "open: "
|
||||
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")},
|
||||
submit: func(m *Model, text string) tea.Cmd { return m.openText(text) },
|
||||
}
|
||||
// Pre-fill from the clipboard, read now: it may hold any reference,
|
||||
// including ones not offered in the footer (our own, dismissed ones).
|
||||
clip := m.clip
|
||||
readClip := func() tea.Msg {
|
||||
text, err := clip.Read()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if _, err := ref.Parse(text); err != nil || strings.ContainsAny(text, "\n") {
|
||||
return nil // not a reference: don't keep it
|
||||
}
|
||||
return openPromptFillMsg(text)
|
||||
}
|
||||
return tea.Batch(m.prompt.input.Focus(), readClip)
|
||||
}
|
||||
|
||||
type openPromptFillMsg string
|
||||
|
||||
// fillOpenPrompt puts a reference from the clipboard in the open prompt,
|
||||
// unless the user has started typing.
|
||||
func (m *Model) fillOpenPrompt(text string) {
|
||||
if p := m.prompt; p != nil && p.input != nil && p.input.Value() == "" {
|
||||
p.input.SetValue(text)
|
||||
p.input.CursorEnd()
|
||||
}
|
||||
}
|
||||
|
||||
// openText parses a pasted or typed reference and asks what to do with it.
|
||||
func (m *Model) openText(s string) tea.Cmd {
|
||||
r, err := ref.Parse(s)
|
||||
if err != nil {
|
||||
m.flash = flash{text: "open: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
return m.offerRef("Open link", r, false)
|
||||
}
|
||||
|
||||
// handlePaste opens a pasted reference, unless a text input has focus.
|
||||
func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd {
|
||||
if p := m.prompt; p != nil && p.input != nil {
|
||||
in, cmd := p.input.Update(msg)
|
||||
p.input = &in
|
||||
return cmd
|
||||
}
|
||||
if l := m.focusedFilter(); l != nil && l.Filtering() {
|
||||
return l.UpdateFilter(msg)
|
||||
}
|
||||
if m.prompt != nil {
|
||||
return nil
|
||||
}
|
||||
return m.openText(msg.Content)
|
||||
}
|
||||
|
||||
type clipboardTextMsg string
|
||||
|
||||
// clipCheckEvery throttles the clipboard check on key presses, which
|
||||
// covers terminals that don't report focus.
|
||||
const clipCheckEvery = 3 * time.Second
|
||||
|
||||
// maybeCheckClipboard checks the clipboard if it hasn't been for a while.
|
||||
func (m *Model) maybeCheckClipboard() tea.Cmd {
|
||||
if now := m.now(); now.Sub(m.clipChecked) >= clipCheckEvery {
|
||||
m.clipChecked = now
|
||||
return m.checkClipboard()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkClipboard reads the clipboard in the background to look for a
|
||||
// lazysecrets:// link. Only such links are kept; anything else (it may well
|
||||
// be a password) is dropped as soon as it has been looked at.
|
||||
func (m Model) checkClipboard() tea.Cmd {
|
||||
clip := m.clip
|
||||
return func() tea.Msg {
|
||||
text, err := clip.Read()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if !isLink(text) {
|
||||
return clipboardTextMsg("")
|
||||
}
|
||||
return clipboardTextMsg(text)
|
||||
}
|
||||
}
|
||||
|
||||
// isLink reports whether s looks like a lazysecrets:// link, valid or not.
|
||||
func isLink(s string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(s), ref.Scheme+"://")
|
||||
}
|
||||
|
||||
// offerClipboardLink pops up a link found on the clipboard, once: whatever
|
||||
// the user picks, that link isn't offered again. It isn't offered while
|
||||
// another popup is open, or if it points at what's selected already (e.g.
|
||||
// just shared with s). A lazysecrets:// link that doesn't parse is
|
||||
// reported, so a typo doesn't fail silently.
|
||||
func (m *Model) offerClipboardLink(text string) {
|
||||
if text == "" || text == m.dismissedLink || m.prompt != nil || m.search != nil {
|
||||
return
|
||||
}
|
||||
r, err := ref.Parse(text)
|
||||
if err != nil {
|
||||
m.dismissedLink = text
|
||||
m.showMenu("Link on clipboard can't be read", []string{m.st.Error.Render(err.Error())}, nil)
|
||||
return
|
||||
}
|
||||
if m.isSelected(r) {
|
||||
return
|
||||
}
|
||||
m.dismissedLink = text
|
||||
m.offerRef("Link on clipboard", r, true)
|
||||
}
|
||||
|
||||
// isSelected reports whether r points at the selected container, or the
|
||||
// selected object in it.
|
||||
func (m Model) isSelected(r ref.Ref) bool {
|
||||
c, ok := m.containers.Selected()
|
||||
if !ok || c.Source.Kind != r.Kind || !matchesContainer(c, r) {
|
||||
return false
|
||||
}
|
||||
if r.IsContainer() {
|
||||
return true
|
||||
}
|
||||
o, ok := m.detail.Secret()
|
||||
return ok && o.Name == r.Name && (r.Type == "" || o.Type == r.Type) &&
|
||||
(r.Version == "" || o.Version == r.Version)
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
const kv = "kv-app-prod.vault.azure.net"
|
||||
|
||||
// open pastes a reference and, for an object, chooses to go to it.
|
||||
func (d *driver) open(link string) {
|
||||
d.t.Helper()
|
||||
d.openWith(link, "o")
|
||||
}
|
||||
|
||||
// openWith pastes a reference and answers the "Open link" menu with k.
|
||||
func (d *driver) openWith(link, k string) {
|
||||
d.t.Helper()
|
||||
d.send(tea.PasteMsg{Content: link})
|
||||
if d.m.prompt != nil && d.m.prompt.title == "Open link" {
|
||||
d.keys(k)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSecretLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("1", "j") // start elsewhere: platform-dev
|
||||
d.open("lazysecrets://azure/" + kv + "/secrets/api-key")
|
||||
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
|
||||
t.Fatalf("opened %s/%s/%s", src, c, s)
|
||||
}
|
||||
if d.m.focus != paneSecrets || d.m.detail.State() != panes.ValueHidden {
|
||||
t.Errorf("focus %d, value state %d", d.m.focus, d.m.detail.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAcceptsOtherFormats(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, in := range []string{
|
||||
"https://" + kv + "/secrets/api-key",
|
||||
"@Microsoft.KeyVault(SecretUri=https://" + kv + "/secrets/api-key)",
|
||||
"@Microsoft.KeyVault(VaultName=kv-app-prod;SecretName=api-key)",
|
||||
} {
|
||||
d := newDriver(t)
|
||||
d.open(in)
|
||||
if _, _, s := d.selected(); s != "api-key" {
|
||||
t.Errorf("%s: selected %q", in, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenVaultLinkOnTab(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("1", "j")
|
||||
d.open("lazysecrets://azure/" + kv + "/certificates")
|
||||
if _, c, s := d.selected(); c != "kv-app-prod" || d.m.objType != provider.TypeCertificate || s != "app-tls" {
|
||||
t.Errorf("vault link: %q tab %s selected %q", c, d.m.objType, s)
|
||||
}
|
||||
if d.m.focus != paneContainers {
|
||||
t.Errorf("focus = %d, want the vaults pane", d.m.focus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenVersionLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.open("lazysecrets://azure/" + kv + "/secrets/db-password/91d3e0")
|
||||
if d.m.versionsOf == nil {
|
||||
t.Fatal("versions view not opened")
|
||||
}
|
||||
if o, _ := d.m.secrets.Selected(); o.Version != "91d3e0" {
|
||||
t.Errorf("selected version %q", o.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpeningAsksWhatToDo(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.send(tea.PasteMsg{Content: "lazysecrets://azure/" + kv + "/secrets/db-password"})
|
||||
screen := d.screen()
|
||||
for _, want := range []string{"Open link", "platform-prod / kv-app-prod / db-password", "o Go to it",
|
||||
"c Go to it and copy its value", "r Go to it and show its value", "x Dismiss"} {
|
||||
if !strings.Contains(screen, want) {
|
||||
t.Fatalf("missing %q:\n%s", want, screen)
|
||||
}
|
||||
}
|
||||
if d.clip().content != "" {
|
||||
t.Fatal("did something before being asked")
|
||||
}
|
||||
d.keys("c")
|
||||
if d.clip().content != "correct-horse-battery-staple" || strings.Contains(d.screen(), "correct-horse") {
|
||||
t.Errorf("copy: clipboard %q, revealed %v", d.clip().content, strings.Contains(d.screen(), "correct-horse"))
|
||||
}
|
||||
|
||||
d2 := newDriver(t)
|
||||
d2.keys("1", "j")
|
||||
d2.openWith("lazysecrets://azure/"+kv+"/secrets/api-key", "r")
|
||||
if _, _, s := d2.selected(); s != "api-key" || !strings.Contains(d2.screen(), "sk_live_51HxDEMO") {
|
||||
t.Errorf("show: selected %q\n%s", s, d2.screen())
|
||||
}
|
||||
|
||||
// Old links carrying ?action= still open, and still ask.
|
||||
d3 := newDriver(t)
|
||||
d3.send(tea.PasteMsg{Content: "lazysecrets://azure/" + kv + "/secrets/db-password?action=copy"})
|
||||
if d3.m.prompt == nil || d3.clip().content != "" {
|
||||
t.Error("an old ?action=copy link acted without asking")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenDatabricksLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.open("lazysecrets://databricks/adb-2222222222222222.2.azuredatabricks.net/app/storage-key")
|
||||
if src, c, s := d.selected(); src != "dev" || c != "app" || s != "storage-key" {
|
||||
t.Errorf("databricks link: %s/%s/%s", src, c, s)
|
||||
}
|
||||
|
||||
// A bare {{secrets/…}} reference: the app scope is in both workspaces.
|
||||
d.open("{{secrets/app/storage-key}}")
|
||||
screen := d.screen()
|
||||
if d.m.prompt == nil || !strings.Contains(screen, "1 Open in prod") || !strings.Contains(screen, "2 Open in dev") ||
|
||||
!strings.Contains(screen, "x Dismiss") {
|
||||
t.Fatalf("no choice:\n%s", screen)
|
||||
}
|
||||
d.keys("1")
|
||||
if src, _, s := d.selected(); src != "prod" || s != "storage-key" {
|
||||
t.Errorf("after choosing prod: %s/%s", src, s)
|
||||
}
|
||||
}
|
||||
|
||||
// unlocatable hides Locate, like an account that can read a vault's
|
||||
// secrets but not see the vault resource.
|
||||
type unlocatable struct{ provider.Provider }
|
||||
|
||||
func TestOpenUnlocatableVaultUsesOpenedLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t, unlocatable{fake.Azure(0)}, fake.Databricks(0))
|
||||
d.open("lazysecrets://azure/" + kv + "/secrets/api-key")
|
||||
if !strings.Contains(d.screen(), "↗ Opened links (1)") {
|
||||
t.Fatalf("no Opened links row:\n%s", d.screen())
|
||||
}
|
||||
if _, c, s := d.selected(); c != "kv-app-prod" || s != "api-key" {
|
||||
t.Errorf("opened %s/%s", c, s)
|
||||
}
|
||||
if c, _ := d.m.containers.Selected(); c.Source.Provider == "" {
|
||||
t.Error("the account that could read it wasn't recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.open("hello")
|
||||
if !strings.Contains(d.screen(), "open: not a lazysecrets link") {
|
||||
t.Errorf("bad paste:\n%s", d.screen())
|
||||
}
|
||||
d.open("lazysecrets://azure/" + kv + "/secrets/nope")
|
||||
if !strings.Contains(d.screen(), "open: nope not found in kv-app-prod") {
|
||||
t.Errorf("missing secret:\n%s", d.screen())
|
||||
}
|
||||
d.open("lazysecrets://databricks/adb-9.9.azuredatabricks.net/app")
|
||||
if !strings.Contains(d.screen(), "no Databricks profile for workspace adb-9.9") {
|
||||
t.Errorf("unknown workspace:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasteWhileFilteringFilters(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "/")
|
||||
d.send(tea.PasteMsg{Content: "api"})
|
||||
if d.m.secrets.Len() != 1 || d.m.nav != nil {
|
||||
t.Errorf("paste in filter: visible %d", d.m.secrets.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenPromptAndStartupOpen(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.send(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
|
||||
d.send(tea.PasteMsg{Content: "lazysecrets://azure/" + kv + "/secrets/api-key"})
|
||||
d.keys("enter", "o")
|
||||
if _, _, s := d.selected(); s != "api-key" {
|
||||
t.Errorf("ctrl+o: selected %q", s)
|
||||
}
|
||||
|
||||
r, _ := ref.Parse("lazysecrets://azure/" + kv + "/secrets/api-key")
|
||||
d2 := newDriverOpts(t, Options{Open: &r})
|
||||
if d2.m.prompt == nil || d2.m.prompt.title != "Open link" {
|
||||
t.Fatal("open at startup didn't ask what to do")
|
||||
}
|
||||
d2.keys("o")
|
||||
if _, _, s := d2.selected(); s != "api-key" {
|
||||
t.Errorf("open at startup: selected %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "s") // s: the plain link, straight away
|
||||
if want := "lazysecrets://azure/" + kv + "/secrets/db-password"; d.clip().content != want {
|
||||
t.Errorf("s = %q", d.clip().content)
|
||||
}
|
||||
if strings.Contains(d.screen(), "copies its value") {
|
||||
t.Error("share menu still offers action links")
|
||||
}
|
||||
for k, want := range map[string]string{
|
||||
"o": "lazysecrets://azure/" + kv + "/secrets/db-password",
|
||||
"u": "https://" + kv + "/secrets/db-password",
|
||||
"y": "@Microsoft.KeyVault(SecretUri=https://" + kv + "/secrets/db-password)",
|
||||
} {
|
||||
d.keys("S", k)
|
||||
if d.clip().content != want {
|
||||
t.Errorf("S %s = %q, want %q", k, d.clip().content, want)
|
||||
}
|
||||
}
|
||||
d.keys("2", "S", "u") // vault menu: plain vault URL
|
||||
if want := "https://" + kv + "/"; d.clip().content != want {
|
||||
t.Errorf("vault URL = %q", d.clip().content)
|
||||
}
|
||||
d.keys("]", "S", "t") // link that opens on the Keys tab
|
||||
if want := "lazysecrets://azure/" + kv + "/keys"; d.clip().content != want {
|
||||
t.Errorf("tab link = %q", d.clip().content)
|
||||
}
|
||||
d.keys("[", "S", "b")
|
||||
if !strings.HasPrefix(d.clip().content, "https://portal.azure.com/") {
|
||||
t.Errorf("portal link = %q", d.clip().content)
|
||||
}
|
||||
d.keys("2", "s")
|
||||
if want := "lazysecrets://azure/" + kv; d.clip().content != want {
|
||||
t.Errorf("vault link = %q", d.clip().content)
|
||||
}
|
||||
d.keys("3", "v", "j", "s") // from the versions view: that version
|
||||
if want := "lazysecrets://azure/" + kv + "/secrets/db-password/91d3e0"; d.clip().content != want {
|
||||
t.Errorf("version link = %q", d.clip().content)
|
||||
}
|
||||
d.keys("esc", "1", "j", "j", "j", "3", "S")
|
||||
if strings.Contains(d.screen(), "Key Vault URL") {
|
||||
t.Error("Databricks offered a Key Vault URL")
|
||||
}
|
||||
d.keys("o")
|
||||
if want := "lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key"; d.clip().content != want {
|
||||
t.Errorf("databricks link = %q", d.clip().content)
|
||||
}
|
||||
|
||||
// Every shared link opens what was shared.
|
||||
d2 := newDriver(t)
|
||||
d2.open(d.clip().content)
|
||||
if src, c, s := d2.selected(); src != "prod" || c != "app" || s != "storage-key" {
|
||||
t.Errorf("round trip: %s/%s/%s", src, c, s)
|
||||
}
|
||||
}
|
||||
|
||||
// blind sees vaults (Locate) but may not read them, like an account with
|
||||
// Reader on the subscription but no Key Vault data-plane role.
|
||||
type blind struct{ *fake.Provider }
|
||||
|
||||
func (blind) Objects(context.Context, provider.Container, provider.ObjectType) ([]provider.Object, error) {
|
||||
return nil, fake.ErrForbidden
|
||||
}
|
||||
|
||||
func TestOpenAsksWhichAccount(t *testing.T) {
|
||||
t.Parallel()
|
||||
// IDs like the real ones: they contain a "/".
|
||||
alice := blind{fake.Azure(0).As("Azure:alice@example.com/t1", "Azure · alice")}
|
||||
bob := fake.Azure(0).As("Azure:bob@example.com/t1", "Azure · bob")
|
||||
d := newDriver(t, alice, bob)
|
||||
d.open("lazysecrets://azure/" + kv + "/secrets/api-key")
|
||||
screen := d.screen()
|
||||
for _, want := range []string{"Several accounts can see kv-app-prod",
|
||||
"1 Open with Azure · alice / platform-prod", "2 Open with Azure · bob / platform-prod", "x Dismiss"} {
|
||||
if !strings.Contains(screen, want) {
|
||||
t.Fatalf("missing %q:\n%s", want, screen)
|
||||
}
|
||||
}
|
||||
d.keys("2")
|
||||
c, _ := d.m.containers.Selected()
|
||||
if _, _, s := d.selected(); s != "api-key" || c.Source.Provider != "Azure:bob@example.com/t1" {
|
||||
t.Errorf("opened %q via %q, want api-key via bob", s, c.Source.Provider)
|
||||
}
|
||||
|
||||
// j/k and enter work too; x dismisses.
|
||||
d.open("lazysecrets://azure/" + kv + "/secrets/api-key")
|
||||
d.keys("x")
|
||||
if d.m.prompt != nil || d.m.nav != nil {
|
||||
t.Error("x didn't dismiss")
|
||||
}
|
||||
d.open("lazysecrets://azure/" + kv + "/secrets/legacy-token")
|
||||
d.keys("j", "enter")
|
||||
if c, _ := d.m.containers.Selected(); c.Source.Provider != "Azure:bob@example.com/t1" {
|
||||
t.Errorf("j enter picked %q", c.Source.Provider)
|
||||
}
|
||||
|
||||
// Opening into a folded account unfolds it.
|
||||
d.keys("1", "g") // alice's header
|
||||
d.keys("enter") // fold it
|
||||
d.open("lazysecrets://azure/" + kv + "/secrets/api-key")
|
||||
d.keys("1")
|
||||
if c, _ := d.m.containers.Selected(); c.Source.Provider != "Azure:alice@example.com/t1" {
|
||||
t.Errorf("folded account not opened: %q", c.Source.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
// offered reports whether a "Link on clipboard" popup is showing.
|
||||
func (d *driver) offered() bool {
|
||||
return d.m.prompt != nil && strings.HasPrefix(d.m.prompt.title, "Link on clipboard")
|
||||
}
|
||||
|
||||
func TestLinkOnClipboardPopsUp(t *testing.T) {
|
||||
t.Parallel()
|
||||
link := "lazysecrets://azure/" + kv + "/secrets/api-key"
|
||||
d := newDriverOpts(t, Options{Clipboard: &fakeClipboard{content: link}})
|
||||
screen := d.screen()
|
||||
for _, want := range []string{"Link on clipboard", "platform-prod / kv-app-prod / api-key", "o Go to it", "c Go to it and copy its value", "x Dismiss"} {
|
||||
if !strings.Contains(screen, want) {
|
||||
t.Fatalf("popup missing %q:\n%s", want, screen)
|
||||
}
|
||||
}
|
||||
d.keys("o")
|
||||
if _, _, s := d.selected(); s != "api-key" || d.offered() {
|
||||
t.Errorf("o opened %q, popup still shown: %v", s, d.offered())
|
||||
}
|
||||
d.send(tea.FocusMsg{}) // same link: offered once only
|
||||
if d.offered() {
|
||||
t.Error("offered again after opening")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipboardLinkOfferedOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
if d.offered() {
|
||||
t.Fatal("empty clipboard offered a link")
|
||||
}
|
||||
d.clip().content = "lazysecrets://azure/" + kv + "/secrets/api-key" // copied in another app
|
||||
d.send(tea.FocusMsg{})
|
||||
if !d.offered() {
|
||||
t.Fatal("not offered on focus")
|
||||
}
|
||||
d.keys("esc")
|
||||
d.clip().content = "something else"
|
||||
d.send(tea.FocusMsg{})
|
||||
d.clip().content = "lazysecrets://azure/" + kv + "/secrets/api-key" // copied again later
|
||||
d.send(tea.FocusMsg{})
|
||||
if d.offered() {
|
||||
t.Error("link offered a second time")
|
||||
}
|
||||
d.clip().content = "lazysecrets://azure/" + kv + "/secrets/legacy-token" // a different one
|
||||
d.send(tea.FocusMsg{})
|
||||
if !d.offered() {
|
||||
t.Error("new link not offered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipboardIgnoresOtherText(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriverOpts(t, Options{Clipboard: &fakeClipboard{content: "hunter2"}})
|
||||
if d.offered() || d.m.dismissedLink != "" {
|
||||
t.Error("non-link clipboard text used")
|
||||
}
|
||||
// A Key Vault URL isn't offered: only lazysecrets:// links are.
|
||||
d.clip().content = "https://" + kv + "/secrets/api-key"
|
||||
d.send(tea.FocusMsg{})
|
||||
if d.offered() {
|
||||
t.Error("Key Vault URL offered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkToSelectionNotOffered(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "s") // share db-password: it's what's selected
|
||||
d.send(tea.FocusMsg{})
|
||||
if d.offered() {
|
||||
t.Fatal("offered a link to the selected secret")
|
||||
}
|
||||
d.keys("1", "j") // elsewhere now: the same link is worth offering
|
||||
d.send(tea.FocusMsg{})
|
||||
if !d.offered() || !strings.Contains(d.screen(), "platform-prod / kv-app-prod / db-password") {
|
||||
t.Errorf("own link not offered after moving away:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoClipboardPopupOverAnotherPopup(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "S") // share menu open
|
||||
d.clip().content = "lazysecrets://azure/" + kv + "/secrets/api-key"
|
||||
d.send(tea.FocusMsg{})
|
||||
if d.offered() || !strings.HasPrefix(d.m.prompt.title, "Share") {
|
||||
t.Error("clipboard popup replaced the share menu")
|
||||
}
|
||||
d.keys("esc")
|
||||
d.send(tea.FocusMsg{}) // offered once the other popup is gone
|
||||
if !d.offered() {
|
||||
t.Error("link not offered after the share menu closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidClipboardLinkIsReported(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriverOpts(t, Options{Clipboard: &fakeClipboard{content: "lazysecrets://azure/" + kv + "/secret/api-key"}})
|
||||
if !strings.Contains(d.screen(), "Link on clipboard can't be read") || !strings.Contains(d.screen(), `unknown object type "secret"`) {
|
||||
t.Fatalf("typo not reported:\n%s", d.screen())
|
||||
}
|
||||
d.keys("x")
|
||||
if d.m.prompt != nil {
|
||||
t.Error("x didn't dismiss the report")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenPromptPrefilledFromClipboard(t *testing.T) {
|
||||
t.Parallel()
|
||||
link := "lazysecrets://azure/" + kv + "/secrets/api-key"
|
||||
d := newDriverOpts(t, Options{Clipboard: &fakeClipboard{content: link}})
|
||||
d.keys("x") // the clipboard popup comes first
|
||||
d.send(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
|
||||
d.keys("enter", "o")
|
||||
if _, _, s := d.selected(); s != "api-key" {
|
||||
t.Errorf("prefilled ctrl+o opened %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenPromptReadsClipboardFresh(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
// Copied in another app, no focus event: not offered, but ctrl+o
|
||||
// still picks it up, and so it does for a plain Key Vault URL.
|
||||
d.clip().content = "https://" + kv + "/secrets/api-key"
|
||||
d.send(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
|
||||
d.keys("enter", "o")
|
||||
if _, _, s := d.selected(); s != "api-key" {
|
||||
t.Errorf("ctrl+o didn't use the clipboard: %q", s)
|
||||
}
|
||||
|
||||
// Not prefilled with text that isn't a reference.
|
||||
d.clip().content = "hunter2"
|
||||
d.send(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
|
||||
if v := d.m.prompt.input.Value(); v != "" {
|
||||
t.Errorf("prefilled with %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipboardCheckedOnKeysWithoutFocusEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
clock := time.Now()
|
||||
d.m.now = func() time.Time { return clock }
|
||||
d.clip().content = "lazysecrets://azure/" + kv + "/secrets/api-key"
|
||||
|
||||
clock = clock.Add(clipCheckEvery + time.Second)
|
||||
d.keys("j") // any key, once the throttle allows
|
||||
if !d.offered() {
|
||||
t.Fatal("not offered after a key press")
|
||||
}
|
||||
d.keys("x")
|
||||
d.clip().content = "lazysecrets://azure/" + kv + "/secrets/legacy-token"
|
||||
d.keys("k") // within the throttle window: not checked yet
|
||||
if d.offered() {
|
||||
t.Error("checked again within the throttle window")
|
||||
}
|
||||
clock = clock.Add(clipCheckEvery)
|
||||
d.keys("j")
|
||||
if !d.offered() {
|
||||
t.Error("new link not offered after the window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSourceLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.send(tea.PasteMsg{Content: "lazysecrets://azure/subscriptions/sub-dev"})
|
||||
if src, _, _ := d.selected(); src != "platform-dev" || d.m.focus != paneContainers {
|
||||
t.Errorf("subscription link: %q, focus %d", src, d.m.focus)
|
||||
}
|
||||
d.send(tea.PasteMsg{Content: "lazysecrets://databricks/adb-2222222222222222.2.azuredatabricks.net"})
|
||||
if src, _, _ := d.selected(); src != "dev" {
|
||||
t.Errorf("workspace link: %q", src)
|
||||
}
|
||||
|
||||
two := newDriver(t, fake.Azure(0).As("Azure:alice", "Azure · alice"), fake.Azure(0).As("Azure:bob", "Azure · bob"))
|
||||
two.send(tea.PasteMsg{Content: "lazysecrets://azure/subscriptions/sub-dev"})
|
||||
if !strings.Contains(two.screen(), "2 Open with Azure · bob / platform-dev") {
|
||||
t.Fatalf("no account choice:\n%s", two.screen())
|
||||
}
|
||||
two.keys("2")
|
||||
if r, _ := two.m.sources.Selected(); r.provider != "Azure:bob" || r.source.Name != "platform-dev" {
|
||||
t.Errorf("chose %s/%s", r.provider, r.source.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandedOverLinkAndActivity(t *testing.T) {
|
||||
t.Parallel()
|
||||
touched := 0
|
||||
d := newDriverOpts(t, Options{OnActive: func() { touched++ }})
|
||||
d.keys("j")
|
||||
d.send(tea.FocusMsg{})
|
||||
if touched != 2 {
|
||||
t.Errorf("OnActive called %d times, want 2", touched)
|
||||
}
|
||||
r, _ := ref.Parse("lazysecrets://azure/kv-shared-prod.vault.azure.net/secrets/smtp-password")
|
||||
d.send(OpenLink(r))
|
||||
if d.m.prompt == nil || !strings.Contains(d.screen(), "Open link") {
|
||||
t.Fatalf("handed-over link not offered:\n%s", d.screen())
|
||||
}
|
||||
d.keys("o")
|
||||
if _, c, s := d.selected(); c != "kv-shared-prod" || s != "smtp-password" {
|
||||
t.Errorf("went to %s/%s", c, s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefLabel(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
for link, want := range map[string]string{
|
||||
// The subscription from lists already loaded.
|
||||
"lazysecrets://azure/kv-app-prod.vault.azure.net/keys/signing/abc123": "platform-prod / kv-app-prod / signing · key · version abc123",
|
||||
"lazysecrets://azure/kv-app-prod.vault.azure.net/certificates": "platform-prod / kv-app-prod · certificates tab",
|
||||
// Not loaded: the vault alone.
|
||||
"lazysecrets://azure/kv-elsewhere.vault.azure.net/secrets/x": "kv-elsewhere / x",
|
||||
} {
|
||||
r, err := ref.Parse(link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ansi.Strip(d.m.refLabel(r)); !strings.HasSuffix(got, want) {
|
||||
t.Errorf("%s: %q, want …%q", link, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFromLinkStaysPut(t *testing.T) {
|
||||
t.Parallel()
|
||||
alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")}
|
||||
bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")}
|
||||
d := newDriverOpts(t, Options{}, denyingValues{alice}, bob, fake.Databricks(0))
|
||||
d.keys("j") // platform-dev
|
||||
before := d.m.sources.SelectedID()
|
||||
|
||||
r, _ := ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password")
|
||||
d.send(OpenLink(r))
|
||||
d.keys("y")
|
||||
if got := d.clip().content; got != "correct-horse-battery-staple" {
|
||||
t.Fatalf("clipboard = %q (alice denies, bob reads)\n%s", got, d.screen())
|
||||
}
|
||||
if d.m.sources.SelectedID() != before || d.m.nav != nil || strings.Contains(d.screen(), "correct-horse") {
|
||||
t.Errorf("moved or revealed:\n%s", d.screen())
|
||||
}
|
||||
|
||||
// A version, and a Databricks secret.
|
||||
r, _ = ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/91d3e0")
|
||||
d.send(OpenLink(r))
|
||||
d.keys("y")
|
||||
if got := d.clip().content; got != "previous-password" {
|
||||
t.Errorf("version: clipboard = %q", got)
|
||||
}
|
||||
r, _ = ref.Parse("lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key")
|
||||
d.send(OpenLink(r))
|
||||
d.keys("y")
|
||||
if got := d.clip().content; got == "" || got == "previous-password" {
|
||||
t.Errorf("databricks: clipboard = %q\n%s", got, d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
// denyingValues refuses to read values, like an account without access.
|
||||
type denyingValues struct{ *counting }
|
||||
|
||||
func (d denyingValues) Value(context.Context, provider.Object) (provider.SecretValue, error) {
|
||||
return provider.SecretValue{}, fake.ErrForbidden
|
||||
}
|
||||
|
||||
func TestShareWebPages(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("S") // a subscription
|
||||
if !strings.Contains(d.screen(), "o Lazysecrets link") || !strings.Contains(d.screen(), "b Web page (Azure portal)") {
|
||||
t.Fatalf("subscription share menu:\n%s", d.screen())
|
||||
}
|
||||
d.keys("o")
|
||||
if got := d.clip().content; got != "lazysecrets://azure/subscriptions/sub-prod" {
|
||||
t.Errorf("subscription link %q", got)
|
||||
}
|
||||
d.keys("3", "S", "b") // a secret's web page
|
||||
if got := d.clip().content; !strings.HasPrefix(got, "https://portal.azure.com/") {
|
||||
t.Errorf("secret web page %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
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/ui/keys"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
// A modal is a centred popup that takes the keyboard, like lazygit's menus:
|
||||
// a list of options, each with a shortcut key, or a text input.
|
||||
|
||||
type option struct {
|
||||
key string
|
||||
label string
|
||||
tip string // shown under the list while the option is selected
|
||||
note string // dimmed on the right of the row; the filter matches it
|
||||
// star is the store key ctrl+f stars or unstars (favourites, search);
|
||||
// starred shows a ★ before the label.
|
||||
star string
|
||||
starAccount string // the account the star is for
|
||||
starred bool
|
||||
// mark, if set, goes first: how much is loaded (search). reload,
|
||||
// if set, runs on ctrl+r.
|
||||
mark string
|
||||
reload func(m *Model) tea.Cmd
|
||||
id string // identifies the entry across rebuilds (search)
|
||||
group string // the source row it's in (search)
|
||||
kind provider.Kind // the kind of provider it's from (search)
|
||||
hits []int // characters of the label the filter matched (Go to)
|
||||
run func(m *Model) tea.Cmd
|
||||
// header makes this a section title: not selectable, and hidden
|
||||
// while a filter narrows the list.
|
||||
header bool
|
||||
}
|
||||
|
||||
type modal struct {
|
||||
title string
|
||||
lines []string // explanation above the options
|
||||
about *ref.Ref // drawn above lines, redrawn as lists load (refLabel)
|
||||
options []option
|
||||
cursor int
|
||||
|
||||
// A text input instead of options; submit runs on enter.
|
||||
input *textinput.Model
|
||||
submit func(m *Model, text string) tea.Cmd
|
||||
|
||||
// A filter over the options: typing narrows them (all holds them
|
||||
// all), so moving uses arrows or ctrl+j/k.
|
||||
filter *textinput.Model
|
||||
all []option
|
||||
|
||||
footer string // overrides the default footer
|
||||
onClose func(m *Model) // runs when it closes, picked or not
|
||||
}
|
||||
|
||||
// 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.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{}
|
||||
return m.prompt.filter.Focus()
|
||||
}
|
||||
|
||||
// move moves the cursor by delta, skipping section headers; with nothing
|
||||
// selectable that way it stays put. move(0) settles on a selectable option.
|
||||
func (p *modal) move(delta int) {
|
||||
ok := func(i int) bool { return i >= 0 && i < len(p.options) && !p.options[i].header }
|
||||
step := 1
|
||||
if delta < 0 {
|
||||
step = -1
|
||||
}
|
||||
i := max(0, min(p.cursor+delta, len(p.options)-1))
|
||||
for i >= 0 && i < len(p.options) && !ok(i) {
|
||||
i += step
|
||||
}
|
||||
switch {
|
||||
case ok(i):
|
||||
p.cursor = i
|
||||
case !ok(p.cursor): // e.g. after filtering: first selectable
|
||||
for p.cursor = 0; p.cursor < len(p.options) && !ok(p.cursor); p.cursor++ {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// counter is "3 of 12" over the selectable options.
|
||||
func (p *modal) counter() string {
|
||||
n, at := 0, 0
|
||||
for i, o := range p.options {
|
||||
if o.header {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
if i == p.cursor {
|
||||
at = n
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d of %d", at, n)
|
||||
}
|
||||
|
||||
// refilter narrows the options to fuzzy matches of the filter. Matches
|
||||
// stay under their section headers, best first within each section, and
|
||||
// sections without matches drop out.
|
||||
func (p *modal) refilter() {
|
||||
q := p.filter.Value()
|
||||
if q == "" {
|
||||
p.options = p.all
|
||||
p.cursor = max(0, min(p.cursor, len(p.options)-1))
|
||||
p.move(0)
|
||||
return
|
||||
}
|
||||
items := make([]match.Item, len(p.all))
|
||||
for i, o := range p.all {
|
||||
label := ansi.Strip(o.label)
|
||||
items[i] = match.Item{Text: strings.TrimSpace(label + " " + o.note), NameEnd: utf8.RuneCountInString(label)}
|
||||
}
|
||||
rank := map[int]int{} // matching options: their place in the ranking
|
||||
for n, h := range match.Rank(match.Parse(q), items) {
|
||||
if !p.all[h.Index].header {
|
||||
rank[h.Index] = n
|
||||
}
|
||||
}
|
||||
|
||||
p.options = nil
|
||||
var section []int
|
||||
header := -1
|
||||
flush := func() {
|
||||
if len(section) == 0 {
|
||||
return
|
||||
}
|
||||
slices.SortStableFunc(section, func(a, b int) int { return rank[a] - rank[b] })
|
||||
if header >= 0 {
|
||||
p.options = append(p.options, p.all[header])
|
||||
}
|
||||
for _, i := range section {
|
||||
p.options = append(p.options, p.all[i])
|
||||
}
|
||||
}
|
||||
for i, o := range p.all {
|
||||
if o.header {
|
||||
flush()
|
||||
section, header = nil, i
|
||||
continue
|
||||
}
|
||||
if _, ok := rank[i]; ok {
|
||||
section = append(section, i)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
p.cursor = max(0, min(p.cursor, len(p.options)-1))
|
||||
p.move(0)
|
||||
}
|
||||
|
||||
// showMenu opens a menu. Every menu ends with an x Dismiss row.
|
||||
func (m *Model) showMenu(title string, lines []string, options []option) {
|
||||
options = append(options, option{key: "x", label: "Dismiss", run: func(*Model) tea.Cmd { return nil }})
|
||||
m.prompt = &modal{title: title, lines: lines, options: options}
|
||||
m.flash = flash{}
|
||||
}
|
||||
|
||||
// handleModal routes keys while a modal is shown.
|
||||
func (m *Model) handleModal(msg tea.KeyPressMsg) tea.Cmd {
|
||||
p := m.prompt
|
||||
km := m.keys
|
||||
textField := p.input != nil || p.filter != nil
|
||||
closeIt := func() {
|
||||
m.prompt = nil
|
||||
if p.onClose != nil {
|
||||
p.onClose(m)
|
||||
}
|
||||
}
|
||||
if (textField && key.Matches(msg, km.PopupClose)) || (!textField && key.Matches(msg, km.MenuClose)) {
|
||||
closeIt()
|
||||
return nil
|
||||
}
|
||||
if p.input != nil {
|
||||
if key.Matches(msg, km.PopupSelect) {
|
||||
m.prompt = nil
|
||||
return p.submit(m, p.input.Value())
|
||||
}
|
||||
in, cmd := p.input.Update(msg)
|
||||
p.input = &in
|
||||
return cmd
|
||||
}
|
||||
if p.filter != nil {
|
||||
switch {
|
||||
case key.Matches(msg, km.PopupDown):
|
||||
p.move(1)
|
||||
case key.Matches(msg, km.PopupUp):
|
||||
p.move(-1)
|
||||
case key.Matches(msg, km.PopupSelect):
|
||||
if len(p.options) > 0 && !p.options[p.cursor].header {
|
||||
closeIt()
|
||||
return p.options[p.cursor].run(m)
|
||||
}
|
||||
default:
|
||||
in, cmd := p.filter.Update(msg)
|
||||
p.filter = &in
|
||||
p.refilter()
|
||||
return cmd
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// A menu: each entry's letter picks it (fixed, part of its label),
|
||||
// before the moving keys.
|
||||
for _, o := range p.options {
|
||||
if o.key != "" && o.key == msg.String() {
|
||||
m.prompt = nil
|
||||
return o.run(m)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case key.Matches(msg, km.Help):
|
||||
return m.showKeybindingsFor(keys.Menu)
|
||||
case key.Matches(msg, km.MenuDown):
|
||||
p.cursor = min(p.cursor+1, len(p.options)-1)
|
||||
case key.Matches(msg, km.MenuUp):
|
||||
p.cursor = max(p.cursor-1, 0)
|
||||
case key.Matches(msg, km.MenuSelect):
|
||||
m.prompt = nil
|
||||
return p.options[p.cursor].run(m)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// modalView draws the modal as a box sized to its content.
|
||||
func (m Model) modalView() string {
|
||||
p := m.prompt
|
||||
var body []string
|
||||
if p.about != nil {
|
||||
body = append(body, " "+m.refLabel(*p.about))
|
||||
}
|
||||
for _, l := range p.lines {
|
||||
body = append(body, " "+l)
|
||||
}
|
||||
if (len(p.lines) > 0 || p.about != nil) && (len(p.options) > 0 || p.input != nil) {
|
||||
body = append(body, "")
|
||||
}
|
||||
km := m.keys
|
||||
foot := footer(move(km.MenuUp, km.MenuDown), hint(km.MenuSelect, "select"), hint(km.MenuClose, "dismiss"))
|
||||
if p.input != nil {
|
||||
body = append(body, " "+p.input.View())
|
||||
foot = footer(hint(km.PopupSelect, "open"), hint(km.PopupClose, "cancel"))
|
||||
}
|
||||
|
||||
// Size from all options, not the ones shown or the cursor's, so the
|
||||
// popup doesn't change shape while moving or filtering.
|
||||
all := p.all
|
||||
if all == nil {
|
||||
all = p.options
|
||||
}
|
||||
optText := func(o option) string {
|
||||
pre := ""
|
||||
if o.mark != "" {
|
||||
pre = o.mark + " "
|
||||
}
|
||||
if o.star != "" {
|
||||
mark := " "
|
||||
if o.starred {
|
||||
mark = m.st.Favourite.Render(star)
|
||||
}
|
||||
return pre + mark + o.label
|
||||
}
|
||||
if o.key == "" {
|
||||
return o.label
|
||||
}
|
||||
return m.st.Label.Render(o.key) + " " + o.label
|
||||
}
|
||||
optW := 0
|
||||
for _, o := range all {
|
||||
if !o.header {
|
||||
w := ansi.StringWidth(optText(o)) + 2 // "▸ " or " "
|
||||
if o.note != "" {
|
||||
w += 2 + ansi.StringWidth(o.note)
|
||||
}
|
||||
optW = max(optW, w)
|
||||
}
|
||||
}
|
||||
optW = min(optW, max(0, m.w-8)) // notes give way to the screen
|
||||
row := func(o option) string {
|
||||
return withNote(m.st, optText(o), o.note, optW-2)
|
||||
}
|
||||
|
||||
const window = 15 // rows of a filtered list
|
||||
options, first, rows := p.options, 0, len(p.options)
|
||||
if p.filter != nil {
|
||||
body = append(body, " "+p.filter.View(), "")
|
||||
foot = footer(move(km.PopupUp, km.PopupDown), hint(km.PopupSelect, "jump"), hint(km.PopupClose, "close"))
|
||||
rows = min(len(all), window)
|
||||
first = max(0, min(p.cursor-window/2, len(options)-window))
|
||||
options = options[first:min(len(options), first+window)]
|
||||
if len(options) == 0 {
|
||||
body = append(body, " "+m.st.Muted.Render("no matches"))
|
||||
rows--
|
||||
}
|
||||
}
|
||||
var headers []int // body lines that are section headers, drawn once w is known
|
||||
for j, o := range options {
|
||||
if o.header {
|
||||
if j > 0 {
|
||||
body = append(body, "")
|
||||
}
|
||||
headers = append(headers, len(body))
|
||||
body = append(body, o.label)
|
||||
continue
|
||||
}
|
||||
if i := first + j; i == p.cursor {
|
||||
sel := m.st.Selected
|
||||
if m.termBlurred {
|
||||
sel = m.st.SelectedBlurred
|
||||
}
|
||||
body = append(body, " "+panes.Highlight("▸ "+row(o), sel, optW))
|
||||
} else {
|
||||
body = append(body, " "+" "+row(o))
|
||||
}
|
||||
}
|
||||
for range rows - len(options) {
|
||||
body = append(body, "") // keep the height while filtering
|
||||
}
|
||||
|
||||
if p.footer != "" {
|
||||
foot = p.footer
|
||||
}
|
||||
w := max(ansi.StringWidth(p.title)+6, optW+4, ansi.StringWidth(foot)+6)
|
||||
for _, l := range body {
|
||||
w = max(w, ansi.StringWidth(l)+3)
|
||||
}
|
||||
if p.input != nil || p.filter != nil {
|
||||
w = max(w, min(m.w-4, 72))
|
||||
}
|
||||
w = min(w, m.w-2)
|
||||
|
||||
// Section headers as rules: "─── Secrets pane ─────────".
|
||||
for _, i := range headers {
|
||||
title := body[i]
|
||||
fill := max(1, w-2-2-3-2-ansi.StringWidth(title)) // borders, margins, "───", spaces
|
||||
body[i] = " " + m.st.Muted.Render("─── ") + panes.TitleStyle(m.st, !m.termBlurred).Render(title) +
|
||||
m.st.Muted.Render(" "+strings.Repeat("─", fill))
|
||||
}
|
||||
|
||||
// The selected option's tip, wrapped; room for the longest one is kept
|
||||
// so the popup doesn't change height while moving.
|
||||
wrapTip := func(tip string) []string {
|
||||
if tip == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(ansi.Wordwrap(tip, w-4, " "), "\n")
|
||||
}
|
||||
tipRows := 0
|
||||
for _, o := range all {
|
||||
tipRows = max(tipRows, len(wrapTip(o.tip)))
|
||||
}
|
||||
if tipRows > 0 {
|
||||
var tip []string
|
||||
if p.cursor < len(p.options) {
|
||||
tip = wrapTip(p.options[p.cursor].tip)
|
||||
}
|
||||
body = append(body, "")
|
||||
for i := range tipRows {
|
||||
line := ""
|
||||
if i < len(tip) {
|
||||
line = " " + m.st.Muted.Render(tip[i])
|
||||
}
|
||||
body = append(body, line)
|
||||
}
|
||||
}
|
||||
counter := ""
|
||||
if p.filter != nil {
|
||||
counter = p.counter()
|
||||
}
|
||||
focused := !m.termBlurred // like the panes, no highlight in an unfocused window
|
||||
return panes.BoxCounted(m.st, panes.TitleStyle(m.st, focused).Render(p.title), foot, counter, body, w, len(body)+2, focused)
|
||||
}
|
||||
|
||||
// overlay draws the modal centred over the screen.
|
||||
func (m Model) overlay(base string) string {
|
||||
var box string
|
||||
if m.search != nil {
|
||||
box = m.searchViewBox()
|
||||
} else {
|
||||
box = m.modalView()
|
||||
}
|
||||
x := max(0, (m.w-lipgloss.Width(box))/2)
|
||||
y := max(0, (strings.Count(base, "\n")+1-lipgloss.Height(box))/3)
|
||||
return lipgloss.NewCompositor(
|
||||
lipgloss.NewLayer(base),
|
||||
lipgloss.NewLayer(box).X(x).Y(y).Z(1),
|
||||
).Render()
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
// Notes: n writes a short note on the selected subscription, vault or
|
||||
// secret, kept in its store record. Notes show dimmed on the right of rows
|
||||
// and in the detail pane, and the filter matches them.
|
||||
|
||||
// withNote right-aligns a dimmed note in a row of the given width. The
|
||||
// note gives way first when space is short.
|
||||
func withNote(st styles.Styles, left, note string, width int) string {
|
||||
if note == "" {
|
||||
return left
|
||||
}
|
||||
room := width - ansi.StringWidth(left) - 2
|
||||
if room < 4 {
|
||||
return left
|
||||
}
|
||||
note = ansi.Truncate(note, room, "…")
|
||||
pad := width - ansi.StringWidth(left) - ansi.StringWidth(note)
|
||||
return left + strings.Repeat(" ", pad) + st.Muted.Render(note)
|
||||
}
|
||||
|
||||
// editNote opens a popup to write the focused item's note.
|
||||
func (m *Model) editNote() tea.Cmd {
|
||||
var key, name string
|
||||
var path []string
|
||||
var src provider.Source
|
||||
switch m.focus {
|
||||
case paneSources:
|
||||
s, ok := m.selectedSource()
|
||||
if !ok {
|
||||
m.flash = warning("select a subscription or workspace to add a note")
|
||||
return nil
|
||||
}
|
||||
key, name, src = sourceKey(s), s.Name, s
|
||||
case paneContainers:
|
||||
c, ok := m.containers.Selected()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
key, name, path, src = storeKey(c), c.Name, []string{c.Source.Name}, c.Source
|
||||
default:
|
||||
o, ok := m.detail.Secret()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
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.Placeholder = "e.g. prod SQL admin"
|
||||
in.CharLimit = 80
|
||||
in.SetWidth(48)
|
||||
in.SetValue(m.store.Node(key).Note)
|
||||
in.CursorEnd()
|
||||
m.prompt = &modal{
|
||||
title: "Note on " + name, input: &in,
|
||||
lines: []string{m.st.Muted.Render("Shown on the right of the row. Empty removes it.")},
|
||||
submit: func(m *Model, text string) tea.Cmd {
|
||||
note := strings.Join(strings.Fields(text), " ") // one line
|
||||
if err := m.remember(key, name, path, src, func(n *store.Node) { n.Note = note }); err != nil {
|
||||
m.flash = flash{text: "saving: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
m.refreshDetailNote()
|
||||
return nil
|
||||
},
|
||||
footer: footer(hint(m.keys.PopupSelect, "save"), hint(m.keys.PopupClose, "cancel")),
|
||||
}
|
||||
m.flash = flash{}
|
||||
return m.prompt.input.Focus()
|
||||
}
|
||||
|
||||
// refreshDetailNote shows the note of whatever the detail pane shows.
|
||||
func (m *Model) refreshDetailNote() {
|
||||
if o, ok := m.detail.Secret(); ok {
|
||||
m.detail.SetNote(m.store.Node(objectKey(o)).Note)
|
||||
} else if c, ok := m.detail.Container(); ok {
|
||||
m.detail.SetNote(m.store.Node(storeKey(c)).Note)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
func (d *driver) note(text string) {
|
||||
d.t.Helper()
|
||||
d.keys("n")
|
||||
if d.m.prompt == nil || !strings.HasPrefix(d.m.prompt.title, "Note on ") {
|
||||
d.t.Fatalf("n didn't open the note popup:\n%s", d.screen())
|
||||
}
|
||||
for range 40 { // clear what's there
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyBackspace})
|
||||
}
|
||||
for _, r := range text {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
d.keys("enter")
|
||||
}
|
||||
|
||||
func TestNotesOnRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
mem, _ := store.Load(path)
|
||||
d := newDriverOpts(t, Options{Store: mem})
|
||||
|
||||
d.keys("3", "j")
|
||||
d.note("stripe live")
|
||||
d.keys("2")
|
||||
d.note("app secrets")
|
||||
d.keys("1")
|
||||
d.note("main prod")
|
||||
|
||||
screen := d.screen()
|
||||
for _, want := range []string{"stripe live", "app secrets", "main prod", "note main prod"} {
|
||||
if !strings.Contains(screen, want) {
|
||||
t.Errorf("missing %q:\n%s", want, screen)
|
||||
}
|
||||
}
|
||||
|
||||
// The filter matches notes.
|
||||
d.keys("3", "/")
|
||||
d.typeText("stripe")
|
||||
if _, _, s := d.selected(); s != "api-key" || d.m.secrets.Len() != 1 {
|
||||
t.Errorf("filter by note: %q (%d)", s, d.m.secrets.Len())
|
||||
}
|
||||
d.keys("esc")
|
||||
|
||||
// Saved, and an empty note removes it.
|
||||
again, _ := store.Load(path)
|
||||
notes := 0
|
||||
for _, n := range again.Nodes() {
|
||||
if n.Note != "" {
|
||||
notes++
|
||||
}
|
||||
}
|
||||
if notes != 3 {
|
||||
t.Errorf("saved notes = %v", again.Nodes())
|
||||
}
|
||||
d.keys("3")
|
||||
d.note("")
|
||||
if strings.Contains(d.screen(), "stripe live") {
|
||||
t.Error("empty note didn't remove it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithNoteGivesWay(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := styles.Default()
|
||||
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(stripANSI(got))) != 30 ||
|
||||
!strings.HasSuffix(stripANSI(got), "prod SQL admin") {
|
||||
t.Errorf("fits: %q", stripANSI(got))
|
||||
}
|
||||
if got := stripANSI(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
|
||||
!strings.HasSuffix(got, "…") {
|
||||
t.Errorf("tight: %q", got)
|
||||
}
|
||||
if got := stripANSI(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
|
||||
t.Errorf("no room: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func stripANSI(s string) string { return ansi.Strip(s) }
|
||||
@@ -0,0 +1,248 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// What was loaded is kept between runs in a cache file (Options.CacheFile):
|
||||
// the Sources lists, vaults, secrets' metadata and where the cursor was in
|
||||
// each. Starting again shows it at once, and lists older than cacheTTL
|
||||
// reload in the background as usual. Values are never in it; neither are
|
||||
// repos, whose files are read from disk anyway.
|
||||
//
|
||||
// And where lazysecrets was left, per folder it ran in, goes in the store,
|
||||
// to go back there on the next start.
|
||||
|
||||
// cacheFileVersion is the file's format; a file of another version is
|
||||
// ignored, and replaced on the next save.
|
||||
const cacheFileVersion = 1
|
||||
|
||||
// cacheMaxAge drops what hasn't been loaded again for this long.
|
||||
const cacheMaxAge = 30 * 24 * time.Hour
|
||||
|
||||
// persistEvery is how often a changed cache is saved while running, so
|
||||
// little is lost if lazysecrets is killed rather than quit.
|
||||
const persistEvery = 30 * time.Second
|
||||
|
||||
type cacheFile struct {
|
||||
Version int `json:"version"`
|
||||
Sources map[string]stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID
|
||||
Containers map[string]stamped[[]provider.Container] `json:"containers,omitempty"`
|
||||
Objects map[string]stamped[[]provider.Object] `json:"objects,omitempty"`
|
||||
Details map[string]stamped[provider.Object] `json:"details,omitempty"`
|
||||
Cursor map[string]string `json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
type stamped[T any] struct {
|
||||
At time.Time `json:"at"`
|
||||
Value T `json:"value"`
|
||||
}
|
||||
|
||||
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 {
|
||||
id, _ := splitSourceRow(key)
|
||||
p, ok := m.byID[id]
|
||||
return ok && p.Kind() != provider.KindRepo
|
||||
}
|
||||
|
||||
// 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() {
|
||||
b, err := os.ReadFile(m.cachePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var f cacheFile
|
||||
if json.Unmarshal(b, &f) != nil || f.Version != cacheFileVersion {
|
||||
return
|
||||
}
|
||||
now := m.now()
|
||||
recent := func(at time.Time) bool { return now.Sub(at) < cacheMaxAge }
|
||||
for id, e := range f.Sources {
|
||||
if m.keepCached(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) {
|
||||
m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At}
|
||||
}
|
||||
}
|
||||
for k, e := range f.Objects {
|
||||
if m.keepCached(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) {
|
||||
m.cache.details[k] = entry[provider.Object]{e.Value, e.At}
|
||||
}
|
||||
}
|
||||
for k, v := range f.Cursor {
|
||||
if m.keepCached(k) {
|
||||
m.cache.cursor[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cacheSnapshot is the cache as the file holds it.
|
||||
func (m *Model) cacheSnapshot() cacheFile {
|
||||
f := cacheFile{
|
||||
Version: cacheFileVersion,
|
||||
Sources: map[string]stamped[[]provider.Source]{},
|
||||
Containers: map[string]stamped[[]provider.Container]{},
|
||||
Objects: map[string]stamped[[]provider.Object]{},
|
||||
Details: map[string]stamped[provider.Object]{},
|
||||
Cursor: map[string]string{},
|
||||
}
|
||||
for id, r := range m.sourceResults {
|
||||
if r.err == nil && m.keepCached(id+"|") {
|
||||
f.Sources[id] = stamped[[]provider.Source]{m.sourcesAt[id], r.sources}
|
||||
}
|
||||
}
|
||||
for k, e := range m.cache.containers {
|
||||
if m.keepCached(k) {
|
||||
f.Containers[k] = stamped[[]provider.Container]{e.at, e.value}
|
||||
}
|
||||
}
|
||||
for k, e := range m.cache.objects {
|
||||
if m.keepCached(k) {
|
||||
f.Objects[k] = stamped[[]provider.Object]{e.at, e.value}
|
||||
}
|
||||
}
|
||||
for k, e := range m.cache.details {
|
||||
if m.keepCached(k) {
|
||||
f.Details[k] = stamped[provider.Object]{e.at, e.value}
|
||||
}
|
||||
}
|
||||
for k, v := range m.cache.cursor {
|
||||
if m.keepCached(k) {
|
||||
f.Cursor[k] = v
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// writeCacheFile writes f atomically, readable by the user only.
|
||||
func writeCacheFile(path string, f cacheFile) error {
|
||||
b, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // after a successful rename, nothing there
|
||||
if _, err := tmp.Write(b); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
|
||||
// persistTick saves the cache, and where lazysecrets is, in the
|
||||
// background when they changed.
|
||||
func (m *Model) persistTick() tea.Cmd {
|
||||
cmds := []tea.Cmd{tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })}
|
||||
if m.cacheDirty && m.cachePath != "" {
|
||||
m.cacheDirty = false
|
||||
f, path := m.cacheSnapshot(), m.cachePath
|
||||
cmds = append(cmds, func() tea.Msg { return persistedMsg{writeCacheFile(path, f)} })
|
||||
}
|
||||
if p, ok := m.place(); ok && m.placeDir != "" && p.Link != m.savedPlace {
|
||||
m.savedPlace = p.Link
|
||||
st, dir := m.store, m.placeDir
|
||||
cmds = append(cmds, func() tea.Msg { return persistedMsg{st.SetPlace(dir, p)} })
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// Persist saves the cache and where lazysecrets was left; run it after
|
||||
// the program ends.
|
||||
func (m Model) Persist() error {
|
||||
var errs []error
|
||||
if m.cachePath != "" {
|
||||
errs = append(errs, writeCacheFile(m.cachePath, m.cacheSnapshot()))
|
||||
}
|
||||
if m.placeDir != "" {
|
||||
if p, ok := m.place(); ok {
|
||||
errs = append(errs, m.store.SetPlace(m.placeDir, p))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// place is the selected item, as deep as the focus.
|
||||
func (m *Model) place() (store.Place, bool) {
|
||||
src, ok := m.selectedSource()
|
||||
if !ok || m.nav != nil {
|
||||
return store.Place{}, false
|
||||
}
|
||||
var r ref.Ref
|
||||
var err error
|
||||
switch c, cok := m.containers.Selected(); {
|
||||
case m.focus == paneSources || !cok:
|
||||
r, err = ref.ForSource(src)
|
||||
case m.focus == paneContainers:
|
||||
r, err = ref.ForContainer(c, m.objType)
|
||||
default:
|
||||
o, ook := m.detail.Secret()
|
||||
if !ook {
|
||||
r, err = ref.ForContainer(c, m.objType)
|
||||
} else {
|
||||
r, err = ref.ForObject(o, "")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return store.Place{}, false
|
||||
}
|
||||
return store.Place{Link: r.String(), Account: src.Provider, Source: src.ID, At: m.now()}, true
|
||||
}
|
||||
|
||||
// restorePlace goes back to where lazysecrets was left in dir, if that
|
||||
// account is still signed in.
|
||||
func (m *Model) restorePlace() {
|
||||
p, ok := m.store.Place(m.placeDir)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := m.byID[p.Account]; !ok {
|
||||
return
|
||||
}
|
||||
r, err := ref.Parse(p.Link)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.startNav(r, actGo, sourceRowKey(p.Account, p.Source))
|
||||
m.nav.sourceOnly = r.IsSource()
|
||||
m.nav.quiet = true
|
||||
}
|
||||
|
||||
func (m *Model) handlePersisted(msg persistedMsg) {
|
||||
if msg.err != nil {
|
||||
m.logErrOf("saving the cache or place", msg.err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
func TestCacheFileShowsLastRunAtOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
file := filepath.Join(t.TempDir(), "lazysecrets", "cache.json")
|
||||
d := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(0))
|
||||
d.keys("3", "space")
|
||||
if !strings.Contains(d.screen(), "correct-horse-battery-staple") {
|
||||
t.Fatalf("value not revealed:\n%s", d.screen())
|
||||
}
|
||||
src, vault, secret := d.selected()
|
||||
if err := d.m.Persist(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(b), "correct-horse") {
|
||||
t.Error("a value is in the cache file")
|
||||
}
|
||||
if fi, _ := os.Stat(file); fi.Mode().Perm() != 0o600 {
|
||||
t.Errorf("cache file mode %v", fi.Mode().Perm())
|
||||
}
|
||||
|
||||
// Next run, with a backend too slow to answer during the test: the
|
||||
// lists show from the file, and the cursor is where it was.
|
||||
d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute))
|
||||
if strings.Contains(d2.screen(), "cached") {
|
||||
t.Errorf("a fresh cache said its age:\n%s", d2.screen())
|
||||
}
|
||||
d2.keys("2")
|
||||
if _, v, _ := d2.selected(); v != vault {
|
||||
t.Errorf("vaults from the cache: selected %q, want %q\n%s", v, vault, d2.screen())
|
||||
}
|
||||
d2.keys("3")
|
||||
if s, v, o := d2.selected(); s != src || v != vault || o != secret {
|
||||
t.Errorf("selected %s/%s/%s, want %s/%s/%s\n%s", s, v, o, src, vault, secret, d2.screen())
|
||||
}
|
||||
}
|
||||
|
||||
// Lists from an older cache say how old they are while they reload.
|
||||
func TestCacheFileSaysItsAge(t *testing.T) {
|
||||
t.Parallel()
|
||||
file := filepath.Join(t.TempDir(), "cache.json")
|
||||
d := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(0))
|
||||
d.keys("3")
|
||||
at := time.Now().Add(-3 * time.Hour)
|
||||
for k, e := range d.m.cache.containers {
|
||||
d.m.cache.containers[k] = entry[[]provider.Container]{e.value, at}
|
||||
}
|
||||
for id := range d.m.sourcesAt {
|
||||
d.m.sourcesAt[id] = at
|
||||
}
|
||||
if err := d.m.Persist(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute))
|
||||
d2.keys("2")
|
||||
if n := strings.Count(d2.screen(), "cached 3h ago"); n != 2 {
|
||||
t.Errorf("Sources and Vaults should say they're from 3h ago (%d do):\n%s", n, d2.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
file := filepath.Join(t.TempDir(), "cache.json")
|
||||
az := fake.Azure(0)
|
||||
now := time.Now()
|
||||
f := cacheFile{
|
||||
Version: cacheFileVersion,
|
||||
Containers: map[string]stamped[[]provider.Container]{
|
||||
az.ID() + "|recent": {now.Add(-time.Hour), []provider.Container{{Name: "kv-recent"}}},
|
||||
az.ID() + "|old": {now.Add(-cacheMaxAge - time.Hour), []provider.Container{{Name: "kv-old"}}},
|
||||
"Azure:gone@x/t|recent": {now, []provider.Container{{Name: "kv-gone"}}},
|
||||
},
|
||||
}
|
||||
if err := writeCacheFile(file, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := newDriverOpts(t, Options{CacheFile: file}, az)
|
||||
c := d.m.cache.containers
|
||||
if _, ok := c[az.ID()+"|recent"]; !ok {
|
||||
t.Error("dropped a recent entry")
|
||||
}
|
||||
if _, ok := c[az.ID()+"|old"]; ok {
|
||||
t.Error("kept an entry older than cacheMaxAge")
|
||||
}
|
||||
if _, ok := c["Azure:gone@x/t|recent"]; ok {
|
||||
t.Error("kept an entry of an account that isn't signed in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackWhereItWasLeft(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, _ := store.Load("")
|
||||
dir := "/work/project"
|
||||
d := newDriverOpts(t, Options{Store: st, Dir: dir}, fake.Azure(0))
|
||||
d.keys("3", "j", "j")
|
||||
src, vault, secret := d.selected()
|
||||
if err := d.m.Persist(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p, ok := st.Place(dir); !ok || !strings.Contains(p.Link, secret) {
|
||||
t.Fatalf("place %+v", p)
|
||||
}
|
||||
|
||||
d2 := newDriverOpts(t, Options{Store: st, Dir: dir}, fake.Azure(0))
|
||||
if s, v, o := d2.selected(); s != src || v != vault || o != secret {
|
||||
t.Errorf("back at %s/%s/%s, want %s/%s/%s\n%s", s, v, o, src, vault, secret, d2.screen())
|
||||
}
|
||||
|
||||
// Another folder starts as usual; so does opening a link.
|
||||
d3 := newDriverOpts(t, Options{Store: st, Dir: "/elsewhere"}, fake.Azure(0))
|
||||
if _, _, o := d3.selected(); o == secret {
|
||||
t.Errorf("another folder went to %s too", o)
|
||||
}
|
||||
r, _ := ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password")
|
||||
d4 := newDriverOpts(t, Options{Store: st, Dir: dir, Open: &r}, fake.Azure(0))
|
||||
if d4.m.nav != nil && d4.m.nav.quiet {
|
||||
t.Error("a link was overridden by where it was left")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
const sopsFile = `db:
|
||||
password: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
|
||||
sops:
|
||||
age:
|
||||
- recipient: age1xxxx
|
||||
lastmodified: "2026-10-09T10:05:29Z"
|
||||
mac: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
|
||||
`
|
||||
|
||||
// gitRepoWith makes a git repository holding a SOPS file, with a remote.
|
||||
func gitRepoWith(t *testing.T, remote string) string {
|
||||
t.Helper()
|
||||
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
os.MkdirAll(filepath.Join(root, "secrets"), 0o755)
|
||||
os.WriteFile(filepath.Join(root, "secrets", "app.yaml"), []byte(sopsFile), 0o644)
|
||||
for _, args := range [][]string{{"init", "-q"}, {"add", "."}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x"},
|
||||
{"remote", "add", "origin", remote}} {
|
||||
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v %s", err, out)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestCurrentRepoFirst(t *testing.T) {
|
||||
// Not parallel: git, run by the repo provider, is slow under load.
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
here := gitRepoWith(t, "https://git.example.com/team/here.git")
|
||||
other := gitRepoWith(t, "https://git.example.com/team/aaa-other.git")
|
||||
mem, _ := store.Load("")
|
||||
mem.Update("lazysecrets://repo/git.example.com%2Fteam%2Faaa-other", func(n *store.Node) {
|
||||
n.Source, n.StarredBy = other, []string{"Repos"}
|
||||
})
|
||||
d := newDriverOpts(t, Options{Store: mem}, repo.New(repo.Settings{Dir: here, Starred: []string{other}}))
|
||||
// Grouped: the repo it started in on a row of its own on top,
|
||||
// selected, then the other repos under Repos, without it.
|
||||
var rows []string
|
||||
for _, r := range d.m.sources.Items() {
|
||||
switch r.kind {
|
||||
case rowGroup:
|
||||
rows = append(rows, "group "+r.label)
|
||||
case rowSource:
|
||||
rows = append(rows, r.source.ID)
|
||||
}
|
||||
}
|
||||
if want := []string{here, "group Repos", other}; !slices.Equal(rows, want) {
|
||||
t.Errorf("rows %v, want %v", rows, want)
|
||||
}
|
||||
if !strings.Contains(d.screen(), "Repo · "+filepath.Base(here)) {
|
||||
t.Errorf("no Repo · name row:\n%s", d.screen())
|
||||
}
|
||||
if src, ok := d.m.selectedSource(); !ok || src.ID != here {
|
||||
t.Errorf("selected %v, want the repo it started in", src.ID)
|
||||
}
|
||||
|
||||
// Flat: it sorts first, labelled.
|
||||
d.keys("`")
|
||||
if !strings.Contains(d.screen(), filepath.Base(here)+" · here") {
|
||||
t.Errorf("no here label in the flat list:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoSource(t *testing.T) {
|
||||
// Not parallel: git, run by the repo provider, is slow under load.
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
os.MkdirAll(filepath.Join(root, "secrets"), 0o755)
|
||||
os.WriteFile(filepath.Join(root, "secrets", "app.yaml"), []byte(sopsFile), 0o644)
|
||||
for _, args := range [][]string{{"init", "-q"}, {"add", "."}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x"},
|
||||
{"remote", "add", "origin", "https://git.example.com/team/infra.git"}} {
|
||||
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v %s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
d := newDriverOpts(t, Options{}, []provider.Provider{repo.New(repo.Settings{Dir: root})}...)
|
||||
scr := d.screen()
|
||||
for _, want := range []string{"Repos", filepath.Base(root), "[2] Files", "secrets/app.yaml sops · age", "db.password"} {
|
||||
if !strings.Contains(scr, want) {
|
||||
t.Fatalf("missing %q:\n%s", want, scr)
|
||||
}
|
||||
}
|
||||
d.keys("3", "f")
|
||||
key := "lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password"
|
||||
if !d.m.store.Node(key).StarredFor("Repos") {
|
||||
t.Errorf("star not stored under the repo link; nodes: %v", d.m.store.Nodes())
|
||||
}
|
||||
d.keys("s")
|
||||
if got := d.clip().content; got != key {
|
||||
t.Errorf("shared link %q", got)
|
||||
}
|
||||
|
||||
// Opening the link finds the repo by its remote and goes there.
|
||||
d.keys("1")
|
||||
r, _ := ref.Parse(key)
|
||||
d.send(OpenLink(r))
|
||||
d.keys("o")
|
||||
if src, c, s := d.selected(); src != filepath.Base(root) || c != "secrets/app.yaml" || s != "db.password" {
|
||||
t.Errorf("opened %s/%s/%s\n%s", src, c, s, d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReposLoadThemselves(t *testing.T) {
|
||||
// Not parallel: git, run by the repo provider, is slow under load.
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
root := gitRepoWith(t, "https://git.example.com/team/x.git")
|
||||
// Started elsewhere, so the repo isn't the selected source.
|
||||
d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Dir: t.TempDir(), Paths: []string{root}}))
|
||||
d.openSearch()
|
||||
d.searchAll()
|
||||
d.typeText("db.password")
|
||||
if !strings.Contains(d.screen(), "secrets/app.yaml / db.password") {
|
||||
t.Errorf("repo keys not searchable without loading them:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupsStartCollapsed(t *testing.T) {
|
||||
// Not parallel: git, run by the repo provider, is slow under load.
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
here := gitRepoWith(t, "https://git.example.com/team/here.git")
|
||||
d := newDriverOpts(t, Options{CollapseGroups: true},
|
||||
fake.Azure(0), fake.Databricks(0), repo.New(repo.Settings{Dir: here}))
|
||||
var rows []string
|
||||
for _, r := range d.m.sources.Items() {
|
||||
switch r.kind {
|
||||
case rowGroup:
|
||||
rows = append(rows, "group "+r.label)
|
||||
case rowSource:
|
||||
rows = append(rows, r.source.Name)
|
||||
}
|
||||
}
|
||||
// Folded groups, and the repo it started in on top, selected, so its
|
||||
// files show.
|
||||
if want := []string{filepath.Base(here), "group Azure", "group Databricks"}; !slices.Equal(rows, want) {
|
||||
t.Errorf("rows %v, want %v", rows, want)
|
||||
}
|
||||
if src, ok := d.m.selectedSource(); !ok || src.ID != here {
|
||||
t.Errorf("selected %v, want the repo it started in", src.ID)
|
||||
}
|
||||
d.keys("j", "enter") // unfold Azure
|
||||
if d.sourceCount() < 2 {
|
||||
t.Errorf("enter on a folded group didn't unfold it:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentRepoBeforeTheRest(t *testing.T) {
|
||||
// Not parallel: git, run by the repo provider, is slow under load.
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
here := gitRepoWith(t, "https://git.example.com/team/here.git")
|
||||
p := repo.New(repo.Settings{Dir: here})
|
||||
d := newDriverOpts(t, Options{}, fake.Azure(0), p)
|
||||
// Sources asked again, nothing answered yet but the started-in repo
|
||||
// on its own (provider.Current): it shows on top already.
|
||||
d.m.loadSources()
|
||||
src, ok := p.Current(context.Background())
|
||||
if !ok {
|
||||
t.Fatal("no current repo")
|
||||
}
|
||||
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
||||
d.send(currentMsg{gen: d.m.sourcesGen, source: src})
|
||||
if !strings.Contains(d.screen(), "Repo · "+filepath.Base(here)) {
|
||||
t.Errorf("the started-in repo waits for the others:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchAllRepos(t *testing.T) {
|
||||
// Not parallel: git, run by the repo provider, is slow under load.
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
a := gitRepoWith(t, "https://git.example.com/team/alpha.git")
|
||||
b := gitRepoWith(t, "https://git.example.com/team/beta.git")
|
||||
d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Paths: []string{a, b}}))
|
||||
d.openSearch()
|
||||
d.send(ctrl('h'))
|
||||
for d.m.search.src > 0 {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
||||
}
|
||||
for d.m.search.scope().id != accountScope+"Repos" {
|
||||
if d.m.search.src == len(d.m.search.scopes)-1 {
|
||||
t.Fatalf("no Repos scope: %v", d.m.search.scopes)
|
||||
}
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
|
||||
}
|
||||
azure := func() (n int) {
|
||||
for row := range d.m.cache.containers {
|
||||
if p, _ := splitSourceRow(row); p != "Repos" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
azureBefore := azure()
|
||||
d.typeText("password")
|
||||
var got []string
|
||||
for _, o := range d.m.search.shown {
|
||||
if o.kind != provider.KindRepo {
|
||||
t.Errorf("not a repo: %s", ansiStrip(o.label))
|
||||
}
|
||||
got = append(got, ansiStrip(o.label))
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("want db.password in both repos: %v", got)
|
||||
}
|
||||
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 Repos") {
|
||||
t.Errorf("title:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFile(t *testing.T) {
|
||||
// Not parallel: git, run by the repo provider, is slow under load.
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
root := gitRepoWith(t, "https://git.example.com/team/x.git")
|
||||
envPath := filepath.Join(root, "secrets", ".env")
|
||||
os.WriteFile(envPath, []byte("# local\nTOKEN=x\n"), 0o600)
|
||||
var opened []string
|
||||
d := newDriverOpts(t, Options{OpenURL: func(u string) error { opened = append(opened, u); return nil }},
|
||||
fake.Azure(0), repo.New(repo.Settings{Dir: root}))
|
||||
env := map[string]string{"NVIM": "/tmp/nvim.sock"}
|
||||
d.m.getenv = func(k string) string { return env[k] }
|
||||
var nvim []string
|
||||
d.m.nvimOpen = func(server, path string, line int) error {
|
||||
nvim = append(nvim, fmt.Sprintf("%s %s:%d", server, path, line))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Inside nvim: the file, then a secret at its line, and lazysecrets quits.
|
||||
d.keys("2")
|
||||
if _, c, _ := d.selected(); c != "secrets/.env" {
|
||||
t.Fatalf("selected %q\n%s", c, d.screen())
|
||||
}
|
||||
d.keys("e")
|
||||
d.keys("3", "e")
|
||||
want := []string{"/tmp/nvim.sock " + envPath + ":0", "/tmp/nvim.sock " + envPath + ":2"}
|
||||
if !slices.Equal(nvim, want) {
|
||||
t.Errorf("nvim got %q\nwant %q", nvim, want)
|
||||
}
|
||||
if msg := d.m.edit()(); msg != (tea.QuitMsg{}) {
|
||||
t.Errorf("after opening in nvim: %#v, want quit", msg)
|
||||
}
|
||||
|
||||
// SOPS files go to sops edit, in the terminal, even inside nvim.
|
||||
nvim = nil
|
||||
d.keys("2", "j")
|
||||
if _, c, _ := d.selected(); c != "secrets/app.yaml" {
|
||||
t.Fatalf("selected %q", c)
|
||||
}
|
||||
if cmd := d.m.edit(); cmd == nil || len(nvim) > 0 {
|
||||
t.Errorf("sops file: cmd %v, nvim %q", cmd, nvim)
|
||||
}
|
||||
|
||||
// Neither nvim nor an editor: the system's default app.
|
||||
delete(env, "NVIM")
|
||||
d.keys("k", "e")
|
||||
if !slices.Equal(opened, []string{envPath}) || !strings.Contains(d.screen(), "opened .env") {
|
||||
t.Errorf("opened %q\n%s", opened, d.screen())
|
||||
}
|
||||
|
||||
// Done editing: the file's secrets reload.
|
||||
d.send(editDoneMsg{c: provider.Container{Source: provider.Source{Provider: "Repos"}, ID: envPath}, name: ".env"})
|
||||
if !strings.Contains(d.screen(), "closed .env") {
|
||||
t.Errorf("after editing:\n%s", d.screen())
|
||||
}
|
||||
|
||||
// Other providers have no files.
|
||||
az := newDriver(t, fake.Azure(0))
|
||||
az.keys("2", "e")
|
||||
if !strings.Contains(az.screen(), "only SOPS and .env files") {
|
||||
t.Errorf("azure:\n%s", az.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := provider.File{Path: "/r/.env", Line: 7}
|
||||
for editor, want := range map[string][]string{
|
||||
"nvim": {"+7", "/r/.env"},
|
||||
"/usr/bin/vim -p": {"+7", "/r/.env"},
|
||||
"hx": {"/r/.env:7"},
|
||||
"code --wait": {"-g", "/r/.env:7"},
|
||||
"open -a TextEdit": {"/r/.env"},
|
||||
} {
|
||||
if got := editorArgs(editor, f); !slices.Equal(got, want) {
|
||||
t.Errorf("%s: %q, want %q", editor, got, want)
|
||||
}
|
||||
}
|
||||
if got := editorArgs("nvim", provider.File{Path: "/r/.env"}); !slices.Equal(got, []string{"/r/.env"}) {
|
||||
t.Errorf("no line: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorAtLine(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, c := range []struct {
|
||||
editor string
|
||||
line int
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"nvim", 4, "nvim +4", true},
|
||||
{"/usr/bin/vim -p", 4, "/usr/bin/vim -p +4", true},
|
||||
{"code --wait", 4, "", false}, // sops adds the file last: no -g file:N
|
||||
{"nvim", 0, "", false},
|
||||
} {
|
||||
if got, ok := editorAtLine(c.editor, c.line); got != c.want || ok != c.ok {
|
||||
t.Errorf("%q at %d: %q %v", c.editor, c.line, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
// A revealed value doesn't stay on screen: it hides after the configured
|
||||
// timeout, counting down in the detail pane, and when the terminal loses
|
||||
// focus (switching windows, screen sharing another app).
|
||||
|
||||
// startRevealTimer starts the countdown for a value just revealed.
|
||||
func (m *Model) startRevealTimer() tea.Cmd {
|
||||
m.revealToken++
|
||||
if m.revealTimeout <= 0 {
|
||||
m.revealDeadline = time.Time{}
|
||||
return nil
|
||||
}
|
||||
m.revealDeadline = m.now().Add(m.revealTimeout)
|
||||
m.showHidesIn()
|
||||
return m.revealTickCmd()
|
||||
}
|
||||
|
||||
// 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} })
|
||||
}
|
||||
|
||||
func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
|
||||
if msg.token != m.revealToken || m.revealDeadline.IsZero() || m.detail.State() != panes.ValueShown {
|
||||
return nil // hidden meanwhile, or another value shown
|
||||
}
|
||||
if !m.now().Before(m.revealDeadline) {
|
||||
m.hideValue()
|
||||
return nil
|
||||
}
|
||||
m.showHidesIn()
|
||||
return m.revealTickCmd()
|
||||
}
|
||||
|
||||
func (m *Model) showHidesIn() {
|
||||
left := max(0, int(math.Ceil(m.revealDeadline.Sub(m.now()).Seconds())))
|
||||
m.detail.SetHidesIn(fmt.Sprintf("%ds", left))
|
||||
}
|
||||
|
||||
// hideValue hides the revealed value and stops its countdown.
|
||||
func (m *Model) hideValue() {
|
||||
m.detail.Hide()
|
||||
m.revealToken++
|
||||
m.revealDeadline = time.Time{}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// containerKey identifies a container in the UI and caches. The same vault
|
||||
// seen through two accounts must not share cached lists, since each
|
||||
// account may see different things.
|
||||
func containerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID }
|
||||
|
||||
// providerFor finds the provider instance a source came from. Sources
|
||||
// remembered from an earlier run (favourites) may name an account that
|
||||
// isn't signed in any more, or predate accounts; they fall back to the
|
||||
// first provider of the same kind.
|
||||
func (m Model) providerFor(src provider.Source) provider.Provider {
|
||||
if p, ok := m.byID[src.Provider]; ok {
|
||||
return p
|
||||
}
|
||||
for _, p := range m.providers {
|
||||
if p.Kind() == src.Kind {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return missingProvider{src}
|
||||
}
|
||||
|
||||
// candidates lists the providers that may serve src, in configured order:
|
||||
// its own provider when that is known, otherwise every provider of its kind
|
||||
// that lists the source (all of that kind if their sources haven't loaded).
|
||||
// Favourites saved before accounts existed, or by an account that is no
|
||||
// longer signed in, take the second path.
|
||||
func (m Model) candidates(src provider.Source) []provider.Provider {
|
||||
if p, ok := m.byID[src.Provider]; ok {
|
||||
return []provider.Provider{p}
|
||||
}
|
||||
var seeing, ofKind []provider.Provider
|
||||
for _, p := range m.providers {
|
||||
if p.Kind() != src.Kind {
|
||||
continue
|
||||
}
|
||||
ofKind = append(ofKind, p)
|
||||
r, loaded := m.sourceResults[p.ID()]
|
||||
if !loaded || slices.ContainsFunc(r.sources, func(s provider.Source) bool { return s.ID == src.ID }) {
|
||||
seeing = append(seeing, p)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(seeing) > 0:
|
||||
return seeing
|
||||
case len(ofKind) > 0:
|
||||
return ofKind
|
||||
}
|
||||
return []provider.Provider{missingProvider{src}}
|
||||
}
|
||||
|
||||
// boundTo returns src as served by p.
|
||||
func boundTo(src provider.Source, p provider.Provider) provider.Source {
|
||||
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
||||
return src
|
||||
}
|
||||
|
||||
// missingProvider answers for a provider that isn't configured, e.g. a
|
||||
// favourite from a provider disabled in the config.
|
||||
type missingProvider struct{ src provider.Source }
|
||||
|
||||
func (p missingProvider) err() error {
|
||||
return fmt.Errorf("%s isn't available (disabled in the config or not signed in)", p.src.Kind)
|
||||
}
|
||||
|
||||
func (p missingProvider) Kind() provider.Kind { return p.src.Kind }
|
||||
func (p missingProvider) ID() string { return p.src.Provider }
|
||||
func (p missingProvider) Label() string { return string(p.src.Kind) }
|
||||
func (p missingProvider) Sources(context.Context) ([]provider.Source, error) {
|
||||
return nil, p.err()
|
||||
}
|
||||
func (p missingProvider) Containers(context.Context, provider.Source) ([]provider.Container, error) {
|
||||
return nil, p.err()
|
||||
}
|
||||
func (p missingProvider) Objects(context.Context, provider.Container, provider.ObjectType) ([]provider.Object, error) {
|
||||
return nil, p.err()
|
||||
}
|
||||
func (p missingProvider) Describe(_ context.Context, o provider.Object) (provider.Object, error) {
|
||||
return o, p.err()
|
||||
}
|
||||
func (p missingProvider) Versions(context.Context, provider.Object) ([]provider.Object, error) {
|
||||
return nil, p.err()
|
||||
}
|
||||
func (p missingProvider) Value(context.Context, provider.Object) (provider.SecretValue, error) {
|
||||
return provider.SecretValue{}, p.err()
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
)
|
||||
|
||||
// ctrl+s searches everything loaded: subscriptions and workspaces, vaults
|
||||
// and scopes, and the secrets, keys and certificates of every vault opened
|
||||
// this session. Azure has no search across vaults, so ctrl+a in the popup
|
||||
// loads the rest in the background, a few requests at a time; results
|
||||
// appear as lists arrive, and the panes use them too.
|
||||
|
||||
const crawlWorkers = 4
|
||||
|
||||
// crawl loads every vault's secrets for search.
|
||||
type crawl struct {
|
||||
running bool
|
||||
queue []crawlJob
|
||||
active int
|
||||
pending map[string]bool // cache keys of lists queued or loading
|
||||
failed map[string]bool // cache keys of lists that couldn't be read
|
||||
}
|
||||
|
||||
type crawlJob struct {
|
||||
src *provider.Source // list its vaults or scopes
|
||||
container *provider.Container // list its secrets
|
||||
row string // the source's row key (containers cache key)
|
||||
force bool // reload what's loaded already
|
||||
}
|
||||
|
||||
type crawlMsg struct {
|
||||
job crawlJob
|
||||
containers []provider.Container
|
||||
objects []provider.Object
|
||||
err error
|
||||
}
|
||||
|
||||
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), d)
|
||||
}
|
||||
|
||||
// searchEntries lists everything loaded: the sources (with how much of
|
||||
// each is loaded) and the vaults, scopes and secrets in them, each with
|
||||
// its source's row key as group.
|
||||
func (m *Model) searchEntries() (sources, items []option) {
|
||||
type entry struct {
|
||||
depth int
|
||||
sort string
|
||||
opt option
|
||||
}
|
||||
var es []entry
|
||||
// key is the store key, so ctrl+f stars the entry; mark shows how
|
||||
// much is loaded, reload (ctrl+r) loads it again.
|
||||
add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) {
|
||||
n := m.store.Node(key)
|
||||
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
||||
es = append(es, entry{depth, sort,
|
||||
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
|
||||
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
|
||||
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
|
||||
}
|
||||
for _, p := range m.providers {
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
add(0, row, src.Kind, []string{src.Name}, accountOf(src), sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd {
|
||||
m.nav = &nav{sourceRow: row, sourceOnly: true}
|
||||
return nil
|
||||
}, func(m *Model) tea.Cmd { return m.loadSource(src) })
|
||||
for _, c := range m.cache.containers[row].value {
|
||||
r, err := ref.ForContainer(c, provider.TypeSecret)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
reload := func(m *Model) tea.Cmd { return m.loadContainer(c) }
|
||||
add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd {
|
||||
m.startNav(r, actGo, row)
|
||||
return nil
|
||||
}, reload)
|
||||
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
|
||||
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
|
||||
r, err := ref.ForObject(o, "")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
suffix := accountOf(src)
|
||||
if t != provider.TypeSecret {
|
||||
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
|
||||
suffix = strings.Trim(typ+" · "+suffix, " ·")
|
||||
}
|
||||
add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd {
|
||||
m.startNav(r, actGo, row)
|
||||
return nil
|
||||
}, reload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortStableFunc(es, func(a, b entry) int { return cmp.Or(a.depth-b.depth, strings.Compare(a.sort, b.sort)) })
|
||||
for _, e := range es {
|
||||
if e.depth == 0 {
|
||||
sources = append(sources, e.opt)
|
||||
} else {
|
||||
items = append(items, e.opt)
|
||||
}
|
||||
}
|
||||
return sources, items
|
||||
}
|
||||
|
||||
// coverage is how much of everything search can see: sources whose
|
||||
// vaults are listed, vaults whose secrets are loaded, and secrets. Lists
|
||||
// that couldn't be read count as done.
|
||||
type coverage struct {
|
||||
sources, sourcesListed int
|
||||
vaults, vaultsLoaded int
|
||||
secrets, unreadable int
|
||||
}
|
||||
|
||||
func (c coverage) complete() bool {
|
||||
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
|
||||
}
|
||||
|
||||
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
|
||||
|
||||
// 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 in != nil && !in(p) {
|
||||
continue
|
||||
}
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
c.sources++
|
||||
e, ok := m.cache.containers[row]
|
||||
if ok || m.crawl.failed[row] {
|
||||
c.sourcesListed++
|
||||
}
|
||||
if m.crawl.failed[row] {
|
||||
c.unreadable++
|
||||
}
|
||||
for _, v := range e.value {
|
||||
c.vaults++
|
||||
key := containerKey(v) + "|" + string(provider.TypeSecret)
|
||||
if objs, ok := m.cache.objects[key]; ok {
|
||||
c.vaultsLoaded++
|
||||
c.secrets += len(objs.value)
|
||||
} else if m.crawl.failed[key] {
|
||||
c.vaultsLoaded++
|
||||
c.unreadable++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// searchLines says what the search covers.
|
||||
func (m *Model) searchLines() []string {
|
||||
c := m.searchCoverage()
|
||||
switch {
|
||||
case m.crawl.running:
|
||||
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
|
||||
len(m.crawl.queue)+m.crawl.active))}
|
||||
case c.complete():
|
||||
s := fmt.Sprintf("Searching everything: %s, %s, %s",
|
||||
plural(c.sources, "source"), plural(c.vaults, "vault"), plural(c.secrets, "secret"))
|
||||
if c.unreadable > 0 {
|
||||
s += fmt.Sprintf(" (%s unreadable)", plural(c.unreadable, "list"))
|
||||
}
|
||||
return []string{m.st.Success.Render("✓") + " " + m.st.Notice.Render(s)}
|
||||
}
|
||||
s := fmt.Sprintf("Only what's loaded: %d/%d vaults", c.vaultsLoaded, c.vaults)
|
||||
if c.sourcesListed < c.sources {
|
||||
s += fmt.Sprintf(", %d/%d sources listed", c.sourcesListed, c.sources)
|
||||
}
|
||||
return []string{m.st.Warning.Render("⚠ " + s + "." + withKey(m.loadAllKey(), " %s loads the rest."))}
|
||||
}
|
||||
|
||||
func plural(n int, word string) string {
|
||||
if n == 1 {
|
||||
return "1 " + word
|
||||
}
|
||||
return fmt.Sprintf("%d %ss", n, word)
|
||||
}
|
||||
|
||||
// startCrawl loads every unlisted subscription's vaults and every vault's
|
||||
// secrets not loaded yet.
|
||||
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
|
||||
|
||||
// startCrawlOf is startCrawl for the providers in.
|
||||
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
|
||||
for _, p := range m.providers {
|
||||
if in != nil && !in(p) {
|
||||
continue
|
||||
}
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
||||
m.queueSource(src, false)
|
||||
}
|
||||
}
|
||||
return m.crawlStart()
|
||||
}
|
||||
|
||||
// loadSource loads (again) a subscription's vaults and their secrets.
|
||||
func (m *Model) loadSource(src provider.Source) tea.Cmd {
|
||||
m.queueSource(src, true)
|
||||
return m.crawlStart()
|
||||
}
|
||||
|
||||
// loadContainer loads (again) a vault's secrets.
|
||||
func (m *Model) loadContainer(c provider.Container) tea.Cmd {
|
||||
m.queueContainer(c, sourceRowKey(c.Source.Provider, c.Source.ID), true)
|
||||
return m.crawlStart()
|
||||
}
|
||||
|
||||
// crawlStart runs what's queued, and shows it in the popup.
|
||||
func (m *Model) crawlStart() tea.Cmd {
|
||||
cmd := m.crawlNext()
|
||||
m.refreshSearch()
|
||||
return cmd
|
||||
}
|
||||
|
||||
// queueSource queues listing src's vaults or, with them listed lately
|
||||
// and not force, loading their secrets. Lists from an earlier run (the
|
||||
// cache file) are listed again, so searching brings them up to date.
|
||||
func (m *Model) queueSource(src provider.Source, force bool) {
|
||||
row := sourceRowKey(src.Provider, src.ID)
|
||||
if e, ok := m.cache.containers[row]; ok && e.fresh(m.now()) && !force {
|
||||
for _, c := range e.value {
|
||||
m.queueContainer(c, row, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
m.enqueue(crawlJob{src: &src, row: row, force: force}, row)
|
||||
}
|
||||
|
||||
// queueContainer queues loading c's secrets, unless loaded lately and not
|
||||
// force.
|
||||
func (m *Model) queueContainer(c provider.Container, row string, force bool) {
|
||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
||||
if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force {
|
||||
return
|
||||
}
|
||||
m.enqueue(crawlJob{container: &c, row: row, force: force}, key)
|
||||
}
|
||||
|
||||
func (m *Model) enqueue(job crawlJob, key string) {
|
||||
if m.crawl.pending == nil {
|
||||
m.crawl.pending, m.crawl.failed = map[string]bool{}, map[string]bool{}
|
||||
}
|
||||
if m.crawl.pending[key] {
|
||||
return // queued or loading already
|
||||
}
|
||||
m.crawl.pending[key] = true
|
||||
m.crawl.queue = append(m.crawl.queue, job)
|
||||
}
|
||||
|
||||
// crawlNext starts jobs up to crawlWorkers at a time.
|
||||
func (m *Model) crawlNext() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
for m.crawl.active < crawlWorkers && len(m.crawl.queue) > 0 {
|
||||
job := m.crawl.queue[0]
|
||||
m.crawl.queue = m.crawl.queue[1:]
|
||||
m.crawl.active++
|
||||
cmds = append(cmds, m.crawlCmd(job))
|
||||
}
|
||||
m.crawl.running = m.crawl.active > 0 || len(m.crawl.queue) > 0
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
|
||||
timeout := m.timeout
|
||||
if job.src != nil {
|
||||
p := m.providerFor(*job.src)
|
||||
src := *job.src
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := contextWithTimeout(timeout)
|
||||
defer cancel()
|
||||
items, err := p.Containers(ctx, src)
|
||||
for i := range items {
|
||||
items[i].Source = src
|
||||
}
|
||||
return crawlMsg{job: job, containers: items, err: err}
|
||||
}
|
||||
}
|
||||
c := *job.container
|
||||
p := m.providerFor(c.Source)
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := contextWithTimeout(timeout)
|
||||
defer cancel()
|
||||
items, err := p.Objects(ctx, c, provider.TypeSecret)
|
||||
for i := range items {
|
||||
items[i].Container = c
|
||||
}
|
||||
return crawlMsg{job: job, objects: items, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
|
||||
m.crawl.active--
|
||||
key := msg.job.row
|
||||
if msg.job.container != nil {
|
||||
key = containerKey(*msg.job.container) + "|" + string(provider.TypeSecret)
|
||||
}
|
||||
delete(m.crawl.pending, key)
|
||||
switch {
|
||||
case msg.err != nil:
|
||||
m.crawl.failed[key] = true
|
||||
case msg.job.src != nil:
|
||||
delete(m.crawl.failed, key)
|
||||
m.cache.containers[key] = entry[[]provider.Container]{msg.containers, m.now()}
|
||||
m.cacheDirty = true
|
||||
for _, c := range msg.containers {
|
||||
m.queueContainer(c, key, msg.job.force)
|
||||
}
|
||||
default:
|
||||
delete(m.crawl.failed, key)
|
||||
m.cache.objects[key] = entry[[]provider.Object]{msg.objects, m.now()}
|
||||
m.cacheDirty = true
|
||||
}
|
||||
cmd := m.crawlNext()
|
||||
m.refreshSearch()
|
||||
return cmd
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if m.crawl.pending[row] {
|
||||
return m.st.Muted.Render("◌")
|
||||
}
|
||||
if m.crawl.failed[row] {
|
||||
return m.st.Error.Render("✗")
|
||||
}
|
||||
e, ok := m.cache.containers[row]
|
||||
if !ok {
|
||||
return m.st.Muted.Render("○")
|
||||
}
|
||||
loaded, loading := 0, false
|
||||
for _, c := range e.value {
|
||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
||||
loading = loading || m.crawl.pending[key]
|
||||
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
|
||||
loaded++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case loading:
|
||||
return m.st.Muted.Render("◌")
|
||||
case loaded == len(e.value):
|
||||
return m.st.Success.Render("●")
|
||||
case loaded > 0:
|
||||
return m.st.Warning.Render("◐")
|
||||
}
|
||||
return m.st.Muted.Render("○")
|
||||
}
|
||||
|
||||
// vaultMark marks a vault in search only when its secrets can't be read
|
||||
// (✗) or are loading (◌): whether a subscription is loaded says whether
|
||||
// its vaults are searchable.
|
||||
func (m *Model) vaultMark(c provider.Container) string {
|
||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
||||
switch {
|
||||
case m.crawl.pending[key]:
|
||||
return m.st.Muted.Render("◌")
|
||||
case m.crawl.failed[key]:
|
||||
return m.st.Error.Render("✗")
|
||||
}
|
||||
return " "
|
||||
}
|
||||
|
||||
// providerOf is the provider ID in a source row key.
|
||||
func providerOf(row string) string {
|
||||
p, _ := splitSourceRow(row)
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func (d *driver) openSearch() {
|
||||
d.t.Helper()
|
||||
d.send(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl})
|
||||
if d.m.search == nil {
|
||||
d.t.Fatalf("no search popup:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func ctrl(r rune) tea.KeyPressMsg { return tea.KeyPressMsg{Code: r, Mod: tea.ModCtrl} }
|
||||
|
||||
// searchAll picks All on the left and goes back to the results.
|
||||
func (d *driver) searchAll() {
|
||||
d.send(ctrl('h'))
|
||||
for d.m.search.src > 0 {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
||||
}
|
||||
for d.m.search.scope().id != allSources {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
|
||||
}
|
||||
d.send(ctrl('l'))
|
||||
}
|
||||
|
||||
func TestSearchLoaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("j") // platform-dev: away from what's loaded
|
||||
d.openSearch()
|
||||
for _, want := range []string{"Sources", "Results", "All", "platform-dev"} {
|
||||
if !strings.Contains(d.screen(), want) {
|
||||
t.Fatalf("missing %q:\n%s", want, d.screen())
|
||||
}
|
||||
}
|
||||
d.typeText("api-key") // on All: found at once
|
||||
if !strings.Contains(d.screen(), "ctrl+s only platform-dev") {
|
||||
t.Errorf("no hint to narrow to the source:\n%s", d.screen())
|
||||
}
|
||||
d.send(ctrl('s'))
|
||||
if !strings.Contains(d.screen(), "no matches here · All searches everything") || !strings.Contains(d.screen(), "ctrl+s searches all") {
|
||||
t.Errorf("no hint to search everything:\n%s", d.screen())
|
||||
}
|
||||
d.send(ctrl('s'))
|
||||
if !strings.Contains(d.screen(), "platform-prod / kv-app-prod / api-key") {
|
||||
t.Fatalf("loaded secret not found:\n%s", d.screen())
|
||||
}
|
||||
d.keys("enter")
|
||||
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
|
||||
t.Errorf("jumped to %s/%s/%s", src, c, s)
|
||||
}
|
||||
if d.m.search != nil {
|
||||
t.Error("popup still open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchLoadsAsYouType(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.openSearch()
|
||||
d.send(ctrl('s')) // only the source the cursor is on: platform-prod
|
||||
// Typing loads the scope: that source, without ctrl+a.
|
||||
d.typeText("smtp")
|
||||
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") {
|
||||
t.Fatalf("not found in a vault that wasn't loaded:\n%s", d.screen())
|
||||
}
|
||||
if _, ok := d.m.cache.objects["Azure|sub-prod/kv-shared-prod|Secret"]; !ok {
|
||||
t.Error("loaded list not in the panes' cache")
|
||||
}
|
||||
if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; ok {
|
||||
t.Error("another source loaded too")
|
||||
}
|
||||
|
||||
// On All: everything.
|
||||
d.send(ctrl('s'))
|
||||
d.typeText("-")
|
||||
if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; !ok {
|
||||
t.Errorf("typing on All didn't load the other sources:\n%s", d.screen())
|
||||
}
|
||||
if d.m.search.input.Value() != "smtp-" {
|
||||
t.Error("query lost while loading")
|
||||
}
|
||||
if !strings.Contains(d.screen(), "unreadable") { // kv-restricted refuses
|
||||
t.Errorf("unreadable vaults not counted:\n%s", d.screen())
|
||||
}
|
||||
// Once: the next key doesn't ask for the list that failed again;
|
||||
// ctrl+a still does, on purpose.
|
||||
d.typeText("p")
|
||||
if d.m.crawl.running {
|
||||
t.Error("typing on reloads what failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchSourceNarrowsAndLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.openSearch()
|
||||
d.keys("tab") // to Sources
|
||||
sel := func() string { return d.m.search.scopes[d.m.search.src].id }
|
||||
for d.m.search.src > 0 {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
||||
}
|
||||
for !strings.HasPrefix(sel(), "platform-dev") {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
|
||||
if d.m.search.src == len(d.m.search.scopes)-1 {
|
||||
t.Fatal("platform-dev not in sources")
|
||||
}
|
||||
}
|
||||
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "○") {
|
||||
t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
|
||||
}
|
||||
if len(d.m.search.shown) != 0 {
|
||||
t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown))
|
||||
}
|
||||
d.send(ctrl('r'))
|
||||
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "●") {
|
||||
t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
|
||||
}
|
||||
for _, o := range d.m.search.shown {
|
||||
if !strings.Contains(ansiStrip(o.label), "platform-dev") {
|
||||
t.Errorf("result from elsewhere: %q", ansiStrip(o.label))
|
||||
}
|
||||
}
|
||||
if len(d.m.search.shown) == 0 {
|
||||
t.Error("nothing shown after loading platform-dev")
|
||||
}
|
||||
if _, ok := d.m.cache.objects["Azure|sub-prod/kv-shared-prod|Secret"]; ok {
|
||||
t.Error("loaded another subscription too")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchResultsStayPut(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.openSearch()
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
|
||||
ids := func() []string {
|
||||
var out []string
|
||||
for _, o := range d.m.search.shown {
|
||||
out = append(out, o.id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
before, at := ids(), d.m.search.shown[d.m.search.res].id
|
||||
d.send(ctrl('a'))
|
||||
after := ids()
|
||||
if len(after) <= len(before) {
|
||||
t.Fatalf("nothing loaded: %d → %d", len(before), len(after))
|
||||
}
|
||||
for i, id := range before {
|
||||
if after[i] != id {
|
||||
t.Errorf("row %d changed from %q to %q", i, id, after[i])
|
||||
}
|
||||
}
|
||||
if got := d.m.search.shown[d.m.search.res].id; got != at {
|
||||
t.Errorf("cursor moved from %q to %q", at, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarFromSearch(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.openSearch()
|
||||
d.typeText("api-key")
|
||||
d.send(ctrl('f'))
|
||||
if !d.m.store.Node(kvKey + "/secrets/api-key").StarredFor("Azure") {
|
||||
t.Errorf("ctrl+f in search didn't star:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func ansiStrip(s string) string { return ansi.Strip(s) }
|
||||
|
||||
func TestSearchStartsWhereYouAre(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("2", "j") // platform-prod / kv-shared-prod
|
||||
d.openSearch()
|
||||
v := d.m.search
|
||||
scope := func() string { return ansiStrip(v.scopes[v.src].label) }
|
||||
if scope() != "All" {
|
||||
t.Errorf("opened on %q, want All", scope())
|
||||
}
|
||||
if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") {
|
||||
t.Errorf("vault not under the cursor:\n%s", d.screen())
|
||||
}
|
||||
d.send(ctrl('s')) // narrows to the source, and back
|
||||
if !strings.Contains(scope(), "platform-prod") {
|
||||
t.Errorf("ctrl+s narrowed to %q", scope())
|
||||
}
|
||||
d.send(ctrl('s'))
|
||||
if scope() != "All" {
|
||||
t.Errorf("second ctrl+s on %q, want All", scope())
|
||||
}
|
||||
|
||||
// From Favourites: to Search, on All.
|
||||
d.keys("esc", "]", "]") // stop typing; Favourites
|
||||
d.send(ctrl('s'))
|
||||
if v.tab != tabSearch || scope() != "All" {
|
||||
t.Errorf("ctrl+s from another tab: tab %d, %q", v.tab, scope())
|
||||
}
|
||||
|
||||
// From the Sources pane: the source.
|
||||
d.keys("esc", "1", "j") // close; platform-dev
|
||||
d.openSearch()
|
||||
v = d.m.search
|
||||
d.send(ctrl('s'))
|
||||
if !strings.Contains(scope(), "platform-dev") {
|
||||
t.Errorf("ctrl+s narrowed to %q", scope())
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
d.openSearch()
|
||||
d.searchAll()
|
||||
if !strings.Contains(d.screen(), "vaults loaded · ctrl+a loads everything") {
|
||||
t.Errorf("no hint on All:\n%s", d.screen())
|
||||
}
|
||||
d.send(ctrl('a'))
|
||||
if strings.Contains(d.screen(), "ctrl+a loads everything") {
|
||||
t.Errorf("hint after loading everything:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoToTabsAndModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("2", "f", "j", "f") // two favourites
|
||||
d.keys("F")
|
||||
v := d.m.search
|
||||
if v.tab != tabFavs || v.typing {
|
||||
t.Fatalf("F: tab %d, typing %v", v.tab, v.typing)
|
||||
}
|
||||
d.keys("j") // browsing: j moves, doesn't type
|
||||
if v.res != 1 || v.input.Value() != "" {
|
||||
t.Errorf("j: cursor %d, input %q", v.res, v.input.Value())
|
||||
}
|
||||
d.keys("]")
|
||||
if v.tab != tabRecent {
|
||||
t.Errorf("] → tab %d, want Recent", v.tab)
|
||||
}
|
||||
d.keys("]", "]")
|
||||
if v.tab != tabFavs {
|
||||
t.Errorf("] wraps to tab %d, want Favourites", v.tab)
|
||||
}
|
||||
d.keys("[")
|
||||
if v.tab != tabSearch || !strings.Contains(d.screen(), "Scope") {
|
||||
t.Errorf("[ → tab %d, want Search:\n%s", v.tab, d.screen())
|
||||
}
|
||||
|
||||
// g and G: top and bottom of the list with the cursor.
|
||||
d.keys("G")
|
||||
if v.res != len(v.shown)-1 || v.res == 0 {
|
||||
t.Errorf("G on results: %d of %d", v.res, len(v.shown))
|
||||
}
|
||||
d.keys("g")
|
||||
if v.res != 0 {
|
||||
t.Errorf("g on results: %d", v.res)
|
||||
}
|
||||
d.keys("h", "G")
|
||||
if v.src != len(v.scopes)-1 {
|
||||
t.Errorf("G on scopes: %d of %d", v.src, len(v.scopes))
|
||||
}
|
||||
d.keys("g", "l")
|
||||
if v.src != 0 {
|
||||
t.Errorf("g on scopes: %d", v.src)
|
||||
}
|
||||
|
||||
d.keys("/")
|
||||
d.typeText("shared")
|
||||
if !v.typing || len(v.shown) == 0 {
|
||||
t.Fatalf("typing: %v, %d results", v.typing, len(v.shown))
|
||||
}
|
||||
d.keys("esc") // clears, back to browsing
|
||||
if v.typing || v.input.Value() != "" || d.m.search == nil {
|
||||
t.Errorf("esc while typing: typing %v, input %q, open %v", v.typing, v.input.Value(), d.m.search != nil)
|
||||
}
|
||||
d.keys("esc") // closes
|
||||
if d.m.search != nil {
|
||||
t.Error("esc while browsing didn't close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchHighlightsMatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Icons: labels start with a multi-byte glyph, where byte offsets and
|
||||
// characters differ.
|
||||
d := newDriverOpts(t, Options{Icons: true})
|
||||
d.openSearch()
|
||||
d.typeText("dbpw")
|
||||
if len(d.m.search.shown) == 0 {
|
||||
t.Fatal("no results")
|
||||
}
|
||||
o := d.m.search.shown[0]
|
||||
plain := []rune(ansiStrip(o.label))
|
||||
var got string
|
||||
for _, h := range o.hits {
|
||||
got += string(plain[h])
|
||||
}
|
||||
if got != "dbpw" {
|
||||
t.Errorf("hits %v spell %q in %q", o.hits, got, string(plain))
|
||||
}
|
||||
with := d.m.searchViewBox()
|
||||
for i := range d.m.search.shown {
|
||||
d.m.search.shown[i].hits = nil
|
||||
}
|
||||
if with == d.m.searchViewBox() {
|
||||
t.Error("matched characters not styled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpBarFollowsPopup(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("F")
|
||||
bar := lastLine(d.screen())
|
||||
for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} {
|
||||
if !strings.Contains(bar, want) {
|
||||
t.Errorf("browsing: help bar %q lacks %q", bar, want)
|
||||
}
|
||||
}
|
||||
d.keys("/")
|
||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") {
|
||||
t.Errorf("typing: help bar %q", bar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpFromPopups(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("F", "?")
|
||||
if d.m.prompt == nil || d.m.prompt.title != "Keybindings" || d.m.prompt.options[0].label != "Go to" {
|
||||
t.Fatalf("? in Go to:\n%s", d.screen())
|
||||
}
|
||||
if !strings.Contains(d.screen(), "switch list") { // tab: not on the help bar
|
||||
t.Errorf("Go to's keys missing:\n%s", d.screen())
|
||||
}
|
||||
var sections []string
|
||||
for _, o := range d.m.prompt.options {
|
||||
if o.header {
|
||||
sections = append(sections, o.label)
|
||||
}
|
||||
if strings.Contains(ansiStrip(o.label), "copy value") {
|
||||
t.Errorf("pane key listed in Go to's help: %q", ansiStrip(o.label))
|
||||
}
|
||||
}
|
||||
if strings.Join(sections, ",") != "Go to,While typing, after /" {
|
||||
t.Errorf("sections %v", sections)
|
||||
}
|
||||
d.keys("esc") // back to Go to, where it was
|
||||
if d.m.search == nil || d.m.search.tab != tabFavs || d.m.prompt != nil {
|
||||
t.Errorf("not back in Go to: search %v, prompt %v", d.m.search != nil, d.m.prompt != nil)
|
||||
}
|
||||
|
||||
// Picking a key in the list runs it in Go to.
|
||||
d.keys("?")
|
||||
d.typeText("next tab")
|
||||
d.keys("enter")
|
||||
if d.m.search == nil || d.m.search.tab != tabRecent {
|
||||
t.Errorf("running ] from ? didn't switch Go to's tab")
|
||||
}
|
||||
|
||||
// Menus too.
|
||||
d.keys("esc", "3", "S", "?")
|
||||
if d.m.prompt == nil || d.m.prompt.options[0].label != "This menu" {
|
||||
t.Fatalf("? in a menu:\n%s", d.screen())
|
||||
}
|
||||
for _, o := range d.m.prompt.options {
|
||||
if o.header && o.label != "This menu" {
|
||||
t.Errorf("menu help has section %q", o.label)
|
||||
}
|
||||
}
|
||||
d.keys("esc")
|
||||
if d.m.prompt == nil || !strings.HasPrefix(d.m.prompt.title, "Share") {
|
||||
t.Errorf("not back in the share menu:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpRunsTypingKeysInGoTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("ctrl+s", "esc") // Go to on Search, browsing
|
||||
before := d.m.search.results
|
||||
d.keys("?")
|
||||
d.typeText("ctrl+h") // "sources", a typing key
|
||||
d.keys("enter")
|
||||
if d.m.search == nil || !d.m.search.typing || d.m.search.results || !before {
|
||||
t.Errorf("typing key from ? didn't run: typing %v, results %v", d.m.search != nil && d.m.search.typing, d.m.search.results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoToHelpBarEndsWithHelp(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t) // 100 columns: Go to's keys don't all fit
|
||||
d.keys("ctrl+s", "esc")
|
||||
bar := strings.TrimSpace(lastLine(d.screen()))
|
||||
if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") {
|
||||
t.Errorf("help bar %q", bar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithEmptyResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &counting{Provider: fake.Azure(0)}
|
||||
d := newDriverOpts(t, Options{}, c)
|
||||
d.openSearch()
|
||||
d.send(ctrl('h')) // pick platform-dev, never opened, in the scopes
|
||||
for d.m.search.src > 0 {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
||||
}
|
||||
for i := 0; !strings.Contains(ansiStrip(d.m.search.scope().label), "platform-dev"); i++ {
|
||||
if i > len(d.m.search.scopes) {
|
||||
var labels []string
|
||||
for _, o := range d.m.search.scopes {
|
||||
labels = append(labels, ansiStrip(o.label))
|
||||
}
|
||||
t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results)
|
||||
}
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
|
||||
}
|
||||
d.send(ctrl('l'))
|
||||
if len(d.m.search.shown) != 0 || !d.m.search.results {
|
||||
t.Fatalf("want empty results with the cursor there: %d shown", len(d.m.search.shown))
|
||||
}
|
||||
before := c.containers
|
||||
d.send(ctrl('r')) // in the results, nothing selected: the source
|
||||
if c.containers == before {
|
||||
t.Error("ctrl+r with empty results didn't load the source")
|
||||
}
|
||||
if len(d.m.search.shown) == 0 {
|
||||
t.Errorf("nothing shown after loading:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,778 @@
|
||||
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/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
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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):
|
||||
m.search = nil
|
||||
return v.shown[v.res].run(m)
|
||||
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)
|
||||
}
|
||||
|
||||
body := []string{" " + 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, "go"), 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, "go"), 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 + " ")
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
// The Sources pane either groups sources under a foldable header per
|
||||
// provider, or lists them flat with the provider on each row.
|
||||
|
||||
type rowKind int
|
||||
|
||||
const (
|
||||
rowGroup rowKind = iota
|
||||
rowSource
|
||||
rowLinks // vaults opened from links that no account could locate
|
||||
)
|
||||
|
||||
type sourceRow struct {
|
||||
kind rowKind
|
||||
group provider.Kind // colour and icon
|
||||
provider string // provider ID: one group per instance (account)
|
||||
label string // group: provider label, e.g. "Azure · keb"
|
||||
account string // flat source: account part of the label, if any
|
||||
status string // group: source count, "…" while loading, "!" on error
|
||||
collapsed bool // group only
|
||||
flat bool // source only: shown without a group header
|
||||
top bool // source only: the repo lazysecrets started in, on top
|
||||
source provider.Source
|
||||
}
|
||||
|
||||
// sourceRowKey joins a provider ID and a source ID. "|" doesn't occur in
|
||||
// either; provider IDs contain "/" (Azure:user/tenant).
|
||||
func sourceRowKey(providerID, sourceID string) string { return providerID + "|" + sourceID }
|
||||
|
||||
func splitSourceRow(row string) (providerID, sourceID string) {
|
||||
providerID, sourceID, _ = strings.Cut(row, "|")
|
||||
return providerID, sourceID
|
||||
}
|
||||
|
||||
func sourceRowID(r sourceRow) string {
|
||||
switch r.kind {
|
||||
case rowGroup:
|
||||
return "group:" + r.provider
|
||||
case rowLinks:
|
||||
return linksRowID
|
||||
}
|
||||
return sourceRowKey(r.provider, r.source.ID)
|
||||
}
|
||||
|
||||
func newSourcesList(iconSet icons.Set, mem *store.Store) panes.List[sourceRow] {
|
||||
return panes.NewList("[1] Sources", panes.Item[sourceRow]{
|
||||
ID: sourceRowID,
|
||||
// Headers have no filter text, so filtering shows just sources.
|
||||
Filter: func(r sourceRow) string {
|
||||
if r.kind != rowSource {
|
||||
return ""
|
||||
}
|
||||
return r.source.Name
|
||||
},
|
||||
// So "data" finds Databricks workspaces and an account name finds
|
||||
// its subscriptions.
|
||||
FilterExtra: func(r sourceRow) string {
|
||||
if r.kind != rowSource {
|
||||
return ""
|
||||
}
|
||||
return cmp.Or(r.source.ProviderLabel, string(r.group)) + " " + mem.Node(sourceKey(r.source)).Note
|
||||
},
|
||||
Render: func(st styles.Styles, r sourceRow, width int) string {
|
||||
node := mem.Node(sourceKey(r.source))
|
||||
kind := st.Kind(r.group)
|
||||
starred := ""
|
||||
if r.kind == rowSource && node.StarredFor(r.source.Provider) {
|
||||
starred = st.Favourite.Render(star)
|
||||
}
|
||||
switch {
|
||||
case r.kind == rowLinks:
|
||||
return st.Notice.Render("↗ Opened links") + " " + st.Muted.Render(fmt.Sprintf("(%s)", r.status))
|
||||
case r.kind == rowSource && r.top:
|
||||
return withNote(st, kind.Render(iconSet.Prefix(r.group)+"Repo · ")+starred+kind.Render(r.source.Name), node.Note, width)
|
||||
case r.kind == rowSource && r.flat:
|
||||
line := kind.Render(iconSet.Prefix(r.group)+fmt.Sprintf("%-10s", r.group)) + " " + starred + r.source.Name
|
||||
if r.account != "" {
|
||||
line += st.Muted.Render(" · " + r.account)
|
||||
}
|
||||
return withNote(st, line+here(st, r.source), node.Note, width)
|
||||
case r.kind == rowSource:
|
||||
// The provider's colour, so long groups stay recognisable.
|
||||
return withNote(st, " "+starred+kind.Render(r.source.Name), node.Note, width)
|
||||
}
|
||||
arrow := "▾"
|
||||
if r.collapsed {
|
||||
arrow = "▸"
|
||||
}
|
||||
status := st.Muted.Render(fmt.Sprintf("(%s)", r.status))
|
||||
if r.status == "!" {
|
||||
status = st.Error.Render("(!)")
|
||||
}
|
||||
return kind.Render(arrow+" "+iconSet.Prefix(r.group)+r.label) + " " + status
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// rebuildSources lays out the Sources pane from the per-provider results.
|
||||
func (m *Model) rebuildSources() {
|
||||
// While filtering, headers drop out, so rows show their provider and
|
||||
// account like the flat view (and folded groups are searched too).
|
||||
flat := m.flatSources || m.sources.HasFilter() || m.sourcesFavs
|
||||
m.sourcesFiltered = m.sources.HasFilter()
|
||||
// Tabs: everything, or just the starred ones ([ and ]).
|
||||
m.sources.Title, m.sources.Tabs = "[1]", []string{"Sources", "Favourites"}
|
||||
m.sources.Tab, m.sources.Empty = 0, "no sources found"
|
||||
if m.sourcesFavs {
|
||||
m.sources.Tab, m.sources.Empty = 1, "nothing starred"+withKey(m.keys.Favourite, ": %s on a subscription or workspace stars it")
|
||||
}
|
||||
var rows []sourceRow
|
||||
if n := len(m.linked); n > 0 && !m.sourcesFavs {
|
||||
rows = append(rows, sourceRow{kind: rowLinks, status: fmt.Sprint(n)})
|
||||
}
|
||||
// The repo lazysecrets started in gets a row of its own on top, so it
|
||||
// opens on it; flat, it sorts first instead.
|
||||
if src, ok := m.currentSource(); ok && !flat {
|
||||
rows = append(rows, sourceRow{kind: rowSource, group: src.Kind, provider: src.Provider, top: true, source: src})
|
||||
}
|
||||
for _, p := range m.providers {
|
||||
kind, id := p.Kind(), p.ID()
|
||||
account, _ := strings.CutPrefix(p.Label(), string(kind)+" · ")
|
||||
if account == p.Label() {
|
||||
account = ""
|
||||
}
|
||||
r, done := m.sourceResults[id]
|
||||
sources := r.sources
|
||||
if !flat {
|
||||
sources = slices.DeleteFunc(slices.Clone(sources), func(s provider.Source) bool { return s.Here })
|
||||
}
|
||||
if kind == provider.KindRepo && !(done && (r.err != nil || len(sources) > 0)) {
|
||||
continue // repos show once some are found to have secret files
|
||||
}
|
||||
status := "…"
|
||||
switch {
|
||||
case done && r.err != nil:
|
||||
status = "!"
|
||||
case done:
|
||||
status = fmt.Sprint(len(sources))
|
||||
}
|
||||
if !flat {
|
||||
rows = append(rows, sourceRow{
|
||||
kind: rowGroup, group: kind, provider: id, label: p.Label(),
|
||||
status: status, collapsed: m.collapsed[id],
|
||||
})
|
||||
if m.collapsed[id] {
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, s := range m.starredSourcesFirst(sources) {
|
||||
if m.sourcesFavs && !m.store.Node(sourceKey(s)).StarredFor(s.Provider) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, sourceRow{
|
||||
kind: rowSource, group: kind, provider: id, account: account,
|
||||
flat: flat, source: s,
|
||||
})
|
||||
}
|
||||
}
|
||||
if flat {
|
||||
// Flat: starred sources go to the very top, across providers.
|
||||
slices.SortStableFunc(rows, func(a, b sourceRow) int {
|
||||
return starRank(m.store, a) - starRank(m.store, b)
|
||||
})
|
||||
}
|
||||
m.sources.SetItems(rows)
|
||||
|
||||
// Until the user moves, keep the cursor on the first source in
|
||||
// provider order rather than a header, so the other panes fill in on
|
||||
// their own. Providers answer in any order, so this can change as
|
||||
// results arrive.
|
||||
if !m.sourcesTouched {
|
||||
m.sources.Top() // scroll back up so the first header shows
|
||||
for _, r := range rows {
|
||||
if r.kind == rowSource {
|
||||
m.sources.Select(sourceRowID(r))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// currentSource is the source lazysecrets started in: from its
|
||||
// provider's list once that's in, else as found on its own beforehand.
|
||||
func (m Model) currentSource() (provider.Source, bool) {
|
||||
for _, p := range m.providers {
|
||||
r, done := m.sourceResults[p.ID()]
|
||||
if !done {
|
||||
if m.current != nil && m.current.Provider == p.ID() {
|
||||
return *m.current, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, src := range r.sources {
|
||||
if src.Here {
|
||||
return src, true
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
case r.kind == rowLinks:
|
||||
return 0
|
||||
case r.kind == rowSource && r.source.Here:
|
||||
return 1
|
||||
case r.kind == rowSource && mem.Node(sourceKey(r.source)).StarredFor(r.source.Provider):
|
||||
return 2
|
||||
}
|
||||
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()
|
||||
if !ok || r.kind != rowSource {
|
||||
return provider.Source{}, false
|
||||
}
|
||||
return r.source, true
|
||||
}
|
||||
|
||||
// onLinks reports whether the cursor is on the Opened links row.
|
||||
func (m Model) onLinks() bool {
|
||||
return m.sources.SelectedID() == linksRowID
|
||||
}
|
||||
|
||||
// toggleSourcesView switches between grouped and flat, keeping the cursor
|
||||
// on the same source.
|
||||
func (m *Model) toggleSourcesView() {
|
||||
m.flatSources = !m.flatSources
|
||||
m.rebuildSources()
|
||||
}
|
||||
|
||||
// toggleGroup folds or unfolds the group under the cursor. It reports
|
||||
// whether the cursor was on a group header.
|
||||
func (m *Model) toggleGroup() bool {
|
||||
r, ok := m.sources.Selected()
|
||||
if !ok || r.kind != rowGroup {
|
||||
return false
|
||||
}
|
||||
m.collapsed[r.provider] = !m.collapsed[r.provider]
|
||||
m.rebuildSources()
|
||||
m.sources.Select(sourceRowID(r))
|
||||
return true
|
||||
}
|
||||
|
||||
// here labels the source lazysecrets was started in, in the flat list
|
||||
// (grouped, it has the "Repo · name" row on top).
|
||||
func here(st styles.Styles, s provider.Source) string {
|
||||
if s.Here {
|
||||
return st.Muted.Render(" · here")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
)
|
||||
|
||||
func TestGroupedSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
screen := d.screen()
|
||||
for _, want := range []string{"▾ Azure (2)", " platform-prod", "▾ Databricks (2)", " prod"} {
|
||||
if !strings.Contains(screen, want) {
|
||||
t.Errorf("missing %q:\n%s", want, screen)
|
||||
}
|
||||
}
|
||||
if src, _, _ := d.selected(); src != "platform-prod" {
|
||||
t.Errorf("startup cursor on %q, want the first source", src)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldGroup(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("k", "enter") // Azure header: fold
|
||||
if !strings.Contains(d.screen(), "▸ Azure (2)") || strings.Contains(d.screen(), "platform-prod") {
|
||||
t.Fatalf("Azure not folded:\n%s", d.screen())
|
||||
}
|
||||
if d.m.focus != paneSources {
|
||||
t.Error("enter on a header moved focus")
|
||||
}
|
||||
d.keys("space") // unfold
|
||||
if !strings.Contains(d.screen(), "platform-prod") {
|
||||
t.Fatalf("Azure not unfolded:\n%s", d.screen())
|
||||
}
|
||||
// enter on a source still drills in.
|
||||
d.keys("j", "enter")
|
||||
if d.m.focus != paneContainers {
|
||||
t.Error("enter on a source didn't move to vaults")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleFlatKeepsSelection(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("j", "j", "j") // Databricks prod
|
||||
d.keys("`")
|
||||
if !d.m.flatSources || strings.Contains(d.screen(), "▾") {
|
||||
t.Fatalf("not flat:\n%s", d.screen())
|
||||
}
|
||||
if !strings.Contains(d.screen(), "Databricks prod") {
|
||||
t.Errorf("flat rows should show the provider:\n%s", d.screen())
|
||||
}
|
||||
if src, _, _ := d.selected(); src != "prod" {
|
||||
t.Errorf("selection lost: %q", src)
|
||||
}
|
||||
d.keys("`")
|
||||
if src, _, _ := d.selected(); d.m.flatSources || src != "prod" {
|
||||
t.Errorf("back to grouped: flat=%v selected=%q", d.m.flatSources, src)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlatSourcesOption(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriverOpts(t, Options{FlatSources: true})
|
||||
if strings.Contains(d.screen(), "▾") || d.m.sources.Len() != 4 {
|
||||
t.Errorf("FlatSources ignored:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupHeaderShowsProviderError(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t, failingSources{fake.Azure(0)}, fake.Databricks(0))
|
||||
if !strings.Contains(d.screen(), "▾ Azure (!)") {
|
||||
t.Errorf("no error marker:\n%s", d.screen())
|
||||
}
|
||||
if src, _, _ := d.selected(); src != "prod" {
|
||||
t.Errorf("cursor should skip to the first working source, got %q", src)
|
||||
}
|
||||
}
|
||||
|
||||
// slowSources delivers Azure's sources after Databricks', like the real
|
||||
// Azure provider (network) versus Databricks (a local file).
|
||||
func TestStartupPrefersFirstProviderWhenItAnswersLast(t *testing.T) {
|
||||
t.Parallel()
|
||||
m := New([]provider.Provider{fake.Azure(0), fake.Databricks(0)}, Options{Clipboard: &fakeClipboard{}})
|
||||
d := &driver{t: t, m: m}
|
||||
d.send(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
d.send(sourcesMsg{gen: m.sourcesGen, provider: "Databricks", sources: mustSources(t, fake.Databricks(0))})
|
||||
if src, _, _ := d.selected(); src != "prod" {
|
||||
t.Fatalf("before Azure answers: %q", src)
|
||||
}
|
||||
d.send(sourcesMsg{gen: m.sourcesGen, provider: "Azure", sources: mustSources(t, fake.Azure(0))})
|
||||
if src, _, _ := d.selected(); src != "platform-prod" {
|
||||
t.Errorf("cursor not moved to the first Azure source: %q", src)
|
||||
}
|
||||
|
||||
// Once the user has moved, late results don't move the cursor.
|
||||
d2 := &driver{t: t, m: New([]provider.Provider{fake.Azure(0), fake.Databricks(0)}, Options{Clipboard: &fakeClipboard{}})}
|
||||
d2.send(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
d2.send(sourcesMsg{gen: d2.m.sourcesGen, provider: "Databricks", sources: mustSources(t, fake.Databricks(0))})
|
||||
d2.keys("j") // dev
|
||||
d2.send(sourcesMsg{gen: d2.m.sourcesGen, provider: "Azure", sources: mustSources(t, fake.Azure(0))})
|
||||
if src, _, _ := d2.selected(); src != "dev" {
|
||||
t.Errorf("cursor moved after the user navigated: %q", src)
|
||||
}
|
||||
}
|
||||
|
||||
func mustSources(t *testing.T, p provider.Provider) []provider.Source {
|
||||
t.Helper()
|
||||
s, err := p.Sources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestTwoAccounts(t *testing.T) {
|
||||
t.Parallel()
|
||||
alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")}
|
||||
bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")}
|
||||
d := newDriver(t, alice, bob)
|
||||
|
||||
screen := d.screen()
|
||||
if !strings.Contains(screen, "▾ Azure · alice (2)") || !strings.Contains(screen, "▾ Azure · bob (2)") {
|
||||
t.Fatalf("one group per account expected:\n%s", screen)
|
||||
}
|
||||
|
||||
// Both accounts see platform-prod; bob's is a different row, and
|
||||
// browsing it goes through bob's provider.
|
||||
d.keys("j", "j", "j") // alice dev, bob header, bob prod
|
||||
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "db-password" {
|
||||
t.Fatalf("bob's platform-prod: %s/%s/%s", src, c, s)
|
||||
}
|
||||
c, _ := d.m.containers.Selected()
|
||||
if c.Source.Provider != "Azure:bob" || d.m.containers.SelectedID() != "Azure:bob|"+c.ID {
|
||||
t.Errorf("vault not tied to bob: provider %q, key %q", c.Source.Provider, d.m.containers.SelectedID())
|
||||
}
|
||||
if bob.objects != 1 {
|
||||
t.Errorf("bob listed objects %d times, want 1", bob.objects)
|
||||
}
|
||||
before := alice.objects
|
||||
d.keys("space") // reveal goes through bob too
|
||||
if alice.values != 0 || bob.values != 1 || alice.objects != before {
|
||||
t.Errorf("reveal routed wrong: alice values %d, bob values %d", alice.values, bob.values)
|
||||
}
|
||||
|
||||
if !strings.Contains(d.screen(), "source Azure · bob / platform-prod") {
|
||||
t.Errorf("detail should name the account:\n%s", d.screen())
|
||||
}
|
||||
|
||||
// Flat: the account is shown on each row.
|
||||
d.keys("`")
|
||||
if !strings.Contains(d.screen(), "platform-prod · bob") {
|
||||
t.Errorf("flat rows should name the account:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSourcesByProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("/")
|
||||
d.typeText("databr")
|
||||
d.m.sources.Top()
|
||||
var names []string
|
||||
for range d.m.sources.Len() {
|
||||
r, _ := d.m.sources.Selected()
|
||||
names = append(names, r.source.Name)
|
||||
d.m.sources.Move(1)
|
||||
}
|
||||
slices.Sort(names)
|
||||
if strings.Join(names, ",") != "dev,prod" {
|
||||
t.Errorf("\"databr\" matched %v, want the Databricks workspaces", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteringShowsAccounts(t *testing.T) {
|
||||
t.Parallel()
|
||||
alice := fake.Azure(0).As("Azure:alice", "Azure · alice")
|
||||
bob := fake.Azure(0).As("Azure:bob", "Azure · bob")
|
||||
d := newDriver(t, alice, bob)
|
||||
d.keys("k", "enter") // fold alice: filtering still finds her sources
|
||||
d.keys("/")
|
||||
d.typeText("dev")
|
||||
screen := d.screen()
|
||||
if !strings.Contains(screen, "platform-dev · ali") || !strings.Contains(screen, "platform-dev · bob") {
|
||||
t.Errorf("filtered rows should name the account:\n%s", screen)
|
||||
}
|
||||
d.keys("esc") // clear: back to groups
|
||||
if !strings.Contains(d.screen(), "▸ Azure · alice") || strings.Contains(d.screen(), "platform-dev · bob") {
|
||||
t.Errorf("not grouped again after clearing:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||
)
|
||||
|
||||
// The status pane, like lazygit's: a line on top of the left panes with
|
||||
// the version, a newer release and how the accounts are doing. Focused
|
||||
// (0), the right pane has the rest: updates, each account, the files
|
||||
// lazysecrets uses, links, and the changelog.
|
||||
|
||||
// logo heads the status pane's rows.
|
||||
var logo = []string{
|
||||
` _ _`,
|
||||
`| | __ _ _____ _ ___ ___ ___ _ __ ___| |_ ___`,
|
||||
`| |/ _` + "`" + ` |_ / | | / __|/ _ \/ __| '__/ _ \ __/ __|`,
|
||||
`| | (_| |/ /| |_| \__ \ __/ (__| | | __/ |_\__ \`,
|
||||
`|_|\__,_/___|\__, |___/\___|\___|_| \___|\__|___/`,
|
||||
` |___/`,
|
||||
}
|
||||
|
||||
// statusH is the status pane's height: one line in a box.
|
||||
const statusH = 3
|
||||
|
||||
// statusLine is what the status pane says while it isn't focused.
|
||||
func (m Model) statusLine() string {
|
||||
var parts []string
|
||||
switch {
|
||||
case m.updated != "":
|
||||
parts = append(parts, m.st.Notice.Render("→ "+m.updated+" (restart)"))
|
||||
case m.newer != nil:
|
||||
parts = append(parts, m.st.Notice.Render("↑ "+m.newer.Tag+withKey(m.keys.Update, " %s")))
|
||||
}
|
||||
accounts, failing, repos := 0, 0, 0
|
||||
for _, p := range m.providers {
|
||||
if p.Kind() == provider.KindRepo {
|
||||
repos += len(m.sourceResults[p.ID()].sources) // those with secret files
|
||||
continue
|
||||
}
|
||||
accounts++
|
||||
if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil {
|
||||
failing++
|
||||
}
|
||||
}
|
||||
if n := len(m.expiringItems()); n > 0 {
|
||||
parts = append(parts, m.st.Warning.Render(fmt.Sprintf("⚠ %d expiring", n)))
|
||||
}
|
||||
if accounts > 0 {
|
||||
s := plural(accounts, "account")
|
||||
if failing > 0 {
|
||||
s += " · " + m.st.Error.Render(fmt.Sprintf("%d need sign-in", failing))
|
||||
}
|
||||
parts = append(parts, s)
|
||||
}
|
||||
if repos > 0 {
|
||||
parts = append(parts, plural(repos, "repo"))
|
||||
}
|
||||
return " " + strings.Join(parts, m.st.Muted.Render(" · "))
|
||||
}
|
||||
|
||||
func (m Model) statusView() string {
|
||||
title := "[0] lazysecrets"
|
||||
if v := m.versionLabel(); v != "" {
|
||||
title += " " + v
|
||||
}
|
||||
return panes.Box(m.st, panes.TitleStyle(m.st, m.active(paneStatus)).Render(title), "",
|
||||
[]string{m.statusLine()}, m.statusW, statusH, m.active(paneStatus))
|
||||
}
|
||||
|
||||
// statusFields are the status pane's rows on the right.
|
||||
func (m Model) statusFields() []panes.Field {
|
||||
var out []panes.Field
|
||||
add := func(f panes.Field) {
|
||||
if f.Value != "" {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
// A release's rows open its release page.
|
||||
version := panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")}
|
||||
if strings.HasPrefix(version.Value, "v") {
|
||||
version.URL = update.DefaultRepo + "/releases/tag/" + version.Value
|
||||
}
|
||||
add(version)
|
||||
upd := panes.Field{Label: "update", Value: m.updateStatus(), Info: m.newer == nil}
|
||||
if m.newer != nil {
|
||||
upd.URL = update.DefaultRepo + "/releases/tag/" + m.newer.Tag
|
||||
}
|
||||
add(upd)
|
||||
add(panes.Field{Label: "license", Value: "GPL-3.0-or-later · © 2026 Karl-Einar Bergstrøm", Info: true})
|
||||
|
||||
for i, p := range m.providers {
|
||||
f := panes.Field{Value: p.Label() + " · " + m.accountStatus(p), Provider: p.ID()}
|
||||
if i == 0 {
|
||||
f.Label = "accounts"
|
||||
}
|
||||
var auth *provider.AuthError
|
||||
f.Err = errors.As(m.sourceResults[p.ID()].err, &auth)
|
||||
add(f)
|
||||
}
|
||||
|
||||
out = append(out, m.expiringFields()...)
|
||||
out = append(out, m.errorFields()...)
|
||||
|
||||
for i, path := range m.configFiles {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
label = "config"
|
||||
}
|
||||
add(panes.Field{Label: label, Value: path, Path: path})
|
||||
}
|
||||
add(panes.Field{Label: "state", Value: m.statePath, Path: m.statePath})
|
||||
add(panes.Field{Label: "cache", Value: m.cachePath, Path: m.cachePath})
|
||||
|
||||
// Short, without https://; y copies and o opens the whole URL.
|
||||
repo := strings.TrimPrefix(update.DefaultRepo, "https://")
|
||||
add(panes.Field{Label: "docs", Value: repo + "/…/docs", URL: update.DefaultRepo + "/src/branch/main/docs"})
|
||||
add(panes.Field{Label: "source", Value: repo, URL: update.DefaultRepo})
|
||||
add(panes.Field{Label: "issues", Value: repo + "/issues", URL: update.DefaultRepo + "/issues"})
|
||||
add(panes.Field{Label: "releases", Value: repo + "/releases", URL: update.DefaultRepo + "/releases"})
|
||||
add(panes.Field{Label: "changelog", Value: repo + "/…/CHANGELOG.md", URL: update.DefaultRepo + "/src/branch/main/CHANGELOG.md"})
|
||||
return append(out, m.changelog...)
|
||||
}
|
||||
|
||||
// updateStatus says whether a newer release is out, and when it was
|
||||
// last checked.
|
||||
func (m Model) updateStatus() string {
|
||||
switch {
|
||||
case m.updated != "":
|
||||
return "updated to " + m.updated + ": restart to use it"
|
||||
case m.newer != nil:
|
||||
return m.newer.Tag + " is out" + withKey(m.keys.Update, " · %s updates")
|
||||
}
|
||||
at := m.store.UpdateCheck().At
|
||||
if at.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return "up to date · checked " + ago(m.now().Sub(at))
|
||||
}
|
||||
|
||||
// accountStatus says whether an account's sources loaded.
|
||||
func (m Model) accountStatus(p provider.Provider) string {
|
||||
r, done := m.sourceResults[p.ID()]
|
||||
var auth *provider.AuthError
|
||||
switch {
|
||||
case !done:
|
||||
return "loading…"
|
||||
case errors.As(r.err, &auth) && len(auth.Login) > 0:
|
||||
return "sign-in required" + withKey(m.keys.Login, " · %s signs in")
|
||||
case r.err != nil:
|
||||
return r.err.Error()
|
||||
}
|
||||
noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()]
|
||||
return plural(len(r.sources), cmpOr(noun, "source"))
|
||||
}
|
||||
|
||||
// ago is a duration as people say it: "just now", "5m ago", "2h ago".
|
||||
func ago(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
case d < 48*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
}
|
||||
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||
}
|
||||
|
||||
// changelogFields are CHANGELOG.md as rows: each release's version and
|
||||
// date, then its entries, marked by what kind they are.
|
||||
func changelogFields(md string) []panes.Field {
|
||||
var out []panes.Field
|
||||
kind := ""
|
||||
for _, line := range strings.Split(md, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "## "):
|
||||
version, date, _ := strings.Cut(strings.TrimPrefix(line, "## "), " – ")
|
||||
out = append(out, panes.Field{Label: version, Value: date, Info: true})
|
||||
kind = ""
|
||||
case strings.HasPrefix(line, "### "):
|
||||
kind = strings.ToLower(strings.TrimPrefix(line, "### "))
|
||||
case strings.HasPrefix(line, "- ") && len(out) > 0:
|
||||
text := update.PlainLinks(strings.TrimPrefix(line, "- "))
|
||||
if kind != "" {
|
||||
text = kind + ": " + text
|
||||
}
|
||||
out = append(out, panes.Field{Value: text})
|
||||
case line != "" && len(out) > 0 && !strings.HasPrefix(line, "<!--"):
|
||||
// A release's own words ("Hello world 👋").
|
||||
out = append(out, panes.Field{Value: update.PlainLinks(line)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
func TestChangelogFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
md := "# Changelog\n\nEvery release of lazysecrets, newest first.\n\n## v0.2.0 – 2026-10-12\n\n### Added\n\n" +
|
||||
"- Search ([#11](https://x/pulls/11)) ([65dc2d7](https://x/commit/65dc2d7))\n\n### Fixed\n\n- A fix (#3)\n\n" +
|
||||
"## v0.1.0 – 2026-10-10\n\nHello world 👋\n\n<!-- generated by git-cliff (cliff.toml) -->\n"
|
||||
got := changelogFields(md)
|
||||
want := []panes.Field{
|
||||
{Label: "v0.2.0", Value: "2026-10-12", Info: true},
|
||||
{Value: "added: Search (#11) (65dc2d7)"},
|
||||
{Value: "fixed: A fix (#3)"},
|
||||
{Label: "v0.1.0", Value: "2026-10-10", Info: true},
|
||||
{Value: "Hello world 👋"},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("row %d: %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
╭─ [0] lazysecrets ─────────────╮╭─ platform-prod ─────────────────────────────────────────────────╮
|
||||
│ 2 accounts ││ name platform-prod │
|
||||
╰───────────────────────────────╯│ ID sub-prod │
|
||||
╭─ [1] Sources - Favourites ────╮│ web https://portal.azure.com/#resource/subscriptions/sub…│
|
||||
│ ▾ Azure (2) ││ vaults 3 │
|
||||
│ platform-prod ││ │
|
||||
│ platform-dev ││ │
|
||||
│ ▾ Databricks (2) ││ │
|
||||
│ prod ││ │
|
||||
╰────────────────────── 2 of 6 ─╯│ │
|
||||
╭─ [2] Vaults ──────────────────╮│ │
|
||||
│ kv-app-prod ││ │
|
||||
│ kv-shared-prod ││ │
|
||||
│ kv-restricted ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰────────────────────── 1 of 3 ─╯│ │
|
||||
╭─ [3] Secrets - Keys - Certs ──╮│ │
|
||||
│ db-password ││ │
|
||||
│ api-key ││ │
|
||||
│ legacy-token (disabled) ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
|
||||
enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit
|
||||
@@ -0,0 +1,157 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// Updater finds and installs newer releases (internal/update).
|
||||
type Updater struct {
|
||||
Latest func(context.Context) (update.Release, error)
|
||||
Install func(context.Context, update.Release) error
|
||||
}
|
||||
|
||||
// updateEvery is how often lazysecrets looks for a newer release on its
|
||||
// own; within that, the last answer (in the state file) is used.
|
||||
const updateEvery = 24 * time.Hour
|
||||
|
||||
type updateMsg struct {
|
||||
release update.Release
|
||||
err error
|
||||
asked bool // the user asked (U), rather than the daily check
|
||||
}
|
||||
|
||||
type installedMsg struct {
|
||||
release update.Release
|
||||
err error
|
||||
}
|
||||
|
||||
// startUpdateCheck sets up the startup look for a newer release: the
|
||||
// state file's answer when it looked less than a day ago, else a quiet
|
||||
// look at the server (fetchOnStart, for Init).
|
||||
func (m *Model) startUpdateCheck() {
|
||||
if m.updater == nil || !m.updateCheck {
|
||||
return
|
||||
}
|
||||
last := m.store.UpdateCheck()
|
||||
if m.now().Sub(last.At) < updateEvery {
|
||||
m.offer(update.Release{Tag: last.Tag, Body: last.Notes, URL: last.URL})
|
||||
return
|
||||
}
|
||||
m.fetchOnStart = true
|
||||
}
|
||||
|
||||
func (m Model) fetchLatest(asked bool) tea.Cmd {
|
||||
latest := m.updater.Latest
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
r, err := latest(ctx)
|
||||
return updateMsg{release: r, err: err, asked: asked}
|
||||
}
|
||||
}
|
||||
|
||||
// offer remembers r as the update to offer, if it's newer.
|
||||
func (m *Model) offer(r update.Release) {
|
||||
m.newer = nil
|
||||
if update.Newer(m.version, r.Tag) {
|
||||
m.newer = &r
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleUpdate(msg updateMsg) tea.Cmd {
|
||||
if msg.err != nil {
|
||||
if msg.asked {
|
||||
m.flash = flash{text: "couldn't check for a newer lazysecrets: " + msg.err.Error(), isErr: true}
|
||||
}
|
||||
return nil // the daily check stays quiet: offline, or the server's down
|
||||
}
|
||||
m.store.SetUpdateCheck(store.UpdateCheck{
|
||||
At: m.now(), Tag: msg.release.Tag, Notes: msg.release.Body, URL: msg.release.URL,
|
||||
})
|
||||
m.offer(msg.release)
|
||||
if msg.asked {
|
||||
if m.newer == nil {
|
||||
m.flash = done("lazysecrets " + m.versionLabel() + " is the latest (" + msg.release.Tag + ")")
|
||||
return nil
|
||||
}
|
||||
m.showUpdate()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// update is the update key: the newer release, or a look for one.
|
||||
func (m *Model) update() tea.Cmd {
|
||||
switch {
|
||||
case m.updater == nil:
|
||||
m.flash = warning("updates aren't available in this build")
|
||||
return nil
|
||||
case m.newer != nil:
|
||||
m.showUpdate()
|
||||
return nil
|
||||
case !update.Newer(m.version, "v999.0.0"): // dev builds aren't versions
|
||||
m.flash = warning("this is a development build (" + m.versionLabel() + "): update it with go install or install.sh")
|
||||
return nil
|
||||
}
|
||||
m.flash = flash{text: "looking for a newer lazysecrets…"}
|
||||
return m.fetchLatest(true)
|
||||
}
|
||||
|
||||
// showUpdate offers the newer release: its changelog, and updating.
|
||||
func (m *Model) showUpdate() {
|
||||
r := *m.newer
|
||||
lines := []string{m.st.Muted.Render("You have " + m.versionLabel() + ".")}
|
||||
if notes := r.Notes(); notes != "" {
|
||||
lines = append(lines, "")
|
||||
for _, l := range strings.Split(notes, "\n") {
|
||||
if h, ok := strings.CutPrefix(l, "### "); ok {
|
||||
l = m.st.Label.Render(h)
|
||||
}
|
||||
lines = append(lines, l)
|
||||
}
|
||||
}
|
||||
opts := []option{{key: "u", label: "Update now", tip: "Download, check and replace this lazysecrets; restart to use it.",
|
||||
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
|
||||
}})
|
||||
}
|
||||
m.showMenu("lazysecrets "+r.Tag, lines, opts)
|
||||
}
|
||||
|
||||
func (m *Model) install(r update.Release) tea.Cmd {
|
||||
m.flash = flash{text: "downloading lazysecrets " + r.Tag + "…"}
|
||||
install := m.updater.Install
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
return installedMsg{release: r, err: install(ctx, r)}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleInstalled(msg installedMsg) {
|
||||
if msg.err != nil {
|
||||
m.flash = flash{text: "update failed: " + msg.err.Error(), isErr: true}
|
||||
return
|
||||
}
|
||||
m.newer = nil
|
||||
m.updated = msg.release.Tag
|
||||
m.flash = done("updated to lazysecrets " + msg.release.Tag + " · restart lazysecrets to use it")
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||
)
|
||||
|
||||
// fakeUpdater offers latest and records what it installs.
|
||||
type fakeUpdater struct {
|
||||
latest update.Release
|
||||
err error
|
||||
asked int
|
||||
installed string
|
||||
}
|
||||
|
||||
func (f *fakeUpdater) updater() *Updater {
|
||||
return &Updater{
|
||||
Latest: func(context.Context) (update.Release, error) {
|
||||
f.asked++
|
||||
return f.latest, f.err
|
||||
},
|
||||
Install: func(_ context.Context, r update.Release) error {
|
||||
f.installed = r.Tag
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var v020 = update.Release{
|
||||
Tag: "v0.2.0",
|
||||
Body: "### Added\n\n- Install with a one-liner (#7)\n\n## Install\n\ncurl …",
|
||||
URL: "https://git.example.com/keb/lazysecrets/releases/tag/v0.2.0",
|
||||
}
|
||||
|
||||
func TestUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &fakeUpdater{latest: v020}
|
||||
mem, _ := store.Load(filepath.Join(t.TempDir(), "state.json"))
|
||||
d := newDriverOpts(t, Options{Version: "0.1.1", Updater: f.updater(), UpdateCheck: true, Store: mem})
|
||||
if f.asked != 1 {
|
||||
t.Fatalf("asked %d times at startup", f.asked)
|
||||
}
|
||||
if scr := d.screen(); !strings.Contains(scr, "[0] lazysecrets v0.1.1") || !strings.Contains(scr, "↑ v0.2.0 U") {
|
||||
t.Errorf("no update in the status pane:\n%s", scr)
|
||||
}
|
||||
|
||||
// U: the changelog, without the install notes; u installs.
|
||||
d.keys("U")
|
||||
scr := d.screen()
|
||||
for _, want := range []string{"lazysecrets v0.2.0", "You have v0.1.1.", "Install with a one-liner (#7)", "Update now", "Open the release page"} {
|
||||
if !strings.Contains(scr, want) {
|
||||
t.Errorf("missing %q:\n%s", want, scr)
|
||||
}
|
||||
}
|
||||
if strings.Contains(scr, "curl") {
|
||||
t.Errorf("install notes shown:\n%s", scr)
|
||||
}
|
||||
d.keys("u")
|
||||
if f.installed != "v0.2.0" {
|
||||
t.Fatalf("installed %q", f.installed)
|
||||
}
|
||||
if !strings.Contains(d.screen(), "updated to lazysecrets v0.2.0 · restart lazysecrets to use it") ||
|
||||
!strings.Contains(d.screen(), "→ v0.2.0 (restart)") {
|
||||
t.Errorf("after updating:\n%s", d.screen())
|
||||
}
|
||||
|
||||
// Within a day, the next start uses what it found: no request.
|
||||
f2 := &fakeUpdater{latest: v020}
|
||||
d2 := newDriverOpts(t, Options{Version: "0.1.1", Updater: f2.updater(), UpdateCheck: true, Store: mem})
|
||||
if f2.asked != 0 || !strings.Contains(d2.screen(), "↑ v0.2.0") {
|
||||
t.Errorf("asked %d times within a day:\n%s", f2.asked, d2.screen())
|
||||
}
|
||||
// A day later it asks again.
|
||||
mem.SetUpdateCheck(store.UpdateCheck{At: time.Now().Add(-25 * time.Hour), Tag: "v0.2.0"})
|
||||
newDriverOpts(t, Options{Version: "0.1.1", Updater: f2.updater(), UpdateCheck: true, Store: mem})
|
||||
if f2.asked != 1 {
|
||||
t.Errorf("asked %d times a day later", f2.asked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAsked(t *testing.T) {
|
||||
t.Parallel()
|
||||
mem, _ := store.Load(filepath.Join(t.TempDir(), "state.json"))
|
||||
// Not looking on its own: U looks.
|
||||
f := &fakeUpdater{latest: update.Release{Tag: "v0.1.1"}}
|
||||
d := newDriverOpts(t, Options{Version: "0.1.1", Updater: f.updater(), Store: mem})
|
||||
if f.asked != 0 {
|
||||
t.Fatal("looked without updateCheck")
|
||||
}
|
||||
d.keys("U")
|
||||
if f.asked != 1 || !strings.Contains(d.screen(), "lazysecrets v0.1.1 is the latest (v0.1.1)") {
|
||||
t.Errorf("asked %d:\n%s", f.asked, d.screen())
|
||||
}
|
||||
|
||||
// The daily check stays quiet when offline; U says why.
|
||||
f.err = errors.New("no network")
|
||||
d.keys("U")
|
||||
if !strings.Contains(d.screen(), "couldn't check for a newer lazysecrets: no network") {
|
||||
t.Errorf("no error:\n%s", d.screen())
|
||||
}
|
||||
|
||||
// A development build isn't offered releases.
|
||||
dev := newDriverOpts(t, Options{Version: "dev-2ba762c", Updater: (&fakeUpdater{latest: v020}).updater(), UpdateCheck: true, Store: mem})
|
||||
if strings.Contains(dev.screen(), "↑") {
|
||||
t.Errorf("dev build offered an update:\n%s", dev.screen())
|
||||
}
|
||||
dev.keys("U")
|
||||
if !strings.Contains(dev.screen(), "development build") {
|
||||
t.Errorf("U on a dev build:\n%s", dev.screen())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// Usage: revealing, copying, opening in the browser, sharing and jumping
|
||||
// to an item count as using it. The store keeps when each item was last
|
||||
// used and how often, for the Recent section of the jump list. The config's
|
||||
// history: false turns this off.
|
||||
|
||||
// used counts a use of the remembered item key, keeping its context.
|
||||
func (m *Model) used(key string) {
|
||||
if !m.history {
|
||||
return
|
||||
}
|
||||
_ = m.store.Use(key, m.now(), func(*store.Node) {}) // best effort: never in the way
|
||||
}
|
||||
|
||||
// usedItem counts a use of an item, recording its context.
|
||||
func (m *Model) usedItem(key, name string, path []string, src provider.Source) {
|
||||
if !m.history {
|
||||
return
|
||||
}
|
||||
_ = m.store.Use(key, m.now(), withContext(name, path, src))
|
||||
}
|
||||
|
||||
func (m *Model) usedSource(s provider.Source) {
|
||||
m.usedItem(sourceKey(s), s.Name, nil, s)
|
||||
}
|
||||
|
||||
func (m *Model) usedContainer(c provider.Container) {
|
||||
m.usedItem(storeKey(c), c.Name, []string{c.Source.Name}, c.Source)
|
||||
}
|
||||
|
||||
func (m *Model) usedObject(o provider.Object) {
|
||||
c := o.Container
|
||||
m.usedItem(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
func TestRecentScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "j", "space") // reveal api-key
|
||||
d.keys("j", "s") // share legacy-token: a use too
|
||||
d.keys("2", "j", "f") // star kv-shared-prod
|
||||
|
||||
key := "lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key"
|
||||
if n := d.m.store.Node(key); n.Uses != 1 || n.LastUsed.IsZero() || n.Name != "api-key" {
|
||||
t.Fatalf("reveal not counted: %+v", n)
|
||||
}
|
||||
d.keys("R")
|
||||
var recent []string
|
||||
for _, o := range d.m.search.shown {
|
||||
recent = append(recent, ansiStrip(o.label))
|
||||
}
|
||||
if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") {
|
||||
t.Fatalf("want newest first: %q", recent)
|
||||
}
|
||||
d.keys("enter") // jump to legacy-token
|
||||
if _, _, s := d.selected(); s != "legacy-token" {
|
||||
t.Errorf("jumped to %q", s)
|
||||
}
|
||||
d.keys("F")
|
||||
if len(d.m.search.shown) != 1 || !strings.Contains(ansiStrip(d.m.search.shown[0].label), "kv-shared-prod") {
|
||||
t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoHistory(t *testing.T) {
|
||||
t.Parallel()
|
||||
mem, _ := store.Load("")
|
||||
d := newDriverOpts(t, Options{Store: mem, NoHistory: true})
|
||||
d.keys("3", "j", "space", "y")
|
||||
if len(mem.Nodes()) != 0 {
|
||||
t.Errorf("recorded with history off: %v", mem.Nodes())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Package icons maps backends to Nerd Font glyphs. They are on by default
|
||||
// and can be turned off, since they render as boxes without a Nerd Font
|
||||
// (v3+) installed.
|
||||
package icons
|
||||
|
||||
import "git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
|
||||
type Set struct {
|
||||
Azure string
|
||||
Databricks string
|
||||
Repo string
|
||||
Encrypted string // an encrypted file (SOPS)
|
||||
PlainFile string // a plain text secrets file (.env)
|
||||
}
|
||||
|
||||
// None renders no icons.
|
||||
var None = Set{}
|
||||
|
||||
// NerdFont uses Nerd Fonts v3 Material Design glyphs. There is no Databricks
|
||||
// logo glyph, so it uses stacked layers, which resembles it.
|
||||
var NerdFont = Set{
|
||||
Azure: "\U000F0805", // md-microsoft_azure
|
||||
Databricks: "\U000F0F58", // md-layers_triple
|
||||
Repo: "\U000F02A2", // md-git
|
||||
Encrypted: "\U000F033E", // md-lock
|
||||
PlainFile: "\U000F0214", // md-file
|
||||
}
|
||||
|
||||
// Prefix returns the icon for kind followed by a space, or "" if the set has
|
||||
// no icon for it.
|
||||
func (s Set) Prefix(kind provider.Kind) string {
|
||||
var icon string
|
||||
switch kind {
|
||||
case provider.KindAzure:
|
||||
icon = s.Azure
|
||||
case provider.KindDatabricks:
|
||||
icon = s.Databricks
|
||||
case provider.KindRepo:
|
||||
icon = s.Repo
|
||||
}
|
||||
if icon == "" {
|
||||
return ""
|
||||
}
|
||||
return icon + " "
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
// Package keys defines the keymap: named actions in contexts, their
|
||||
// default keys, and overrides from the config:
|
||||
//
|
||||
// keybinding:
|
||||
// lists:
|
||||
// star: [f]
|
||||
// versions: [] # unbound
|
||||
// filter:
|
||||
// star: [ctrl+f]
|
||||
//
|
||||
// An action set in the config gets exactly the keys given, replacing its
|
||||
// defaults; an empty list unbinds it. Everything that shows a key (the
|
||||
// help bar, ?, footers, hints) reads it from here.
|
||||
package keys
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
)
|
||||
|
||||
// Contexts group the actions by where they apply. The search popup takes
|
||||
// search's actions and popup's.
|
||||
const (
|
||||
Lists = "lists" // the panes, when not typing
|
||||
Filter = "filter" // typing a filter in a pane
|
||||
Menu = "menu" // popups without a text field
|
||||
Popup = "popup" // popups with a text field: favourites, help, search, notes, open link
|
||||
Search = "search" // Go to's own actions while typing
|
||||
GoTo = "goto" // Go to while browsing (no text field active)
|
||||
)
|
||||
|
||||
type Map struct {
|
||||
// lists
|
||||
Up, Down, Top, Bottom key.Binding
|
||||
NextPane, PrevPane, Details key.Binding
|
||||
Pane0, Pane1, Pane2, Pane3, Pane4 key.Binding
|
||||
NextTab, PrevTab key.Binding
|
||||
Enter, Back, Filter key.Binding
|
||||
Reveal, Copy, CopyRef, Open, Edit key.Binding
|
||||
Versions, Favourite, Favourites key.Binding
|
||||
Recent key.Binding
|
||||
Note, Share, ShareMenu, OpenRef key.Binding
|
||||
Search, SourcesView, Commands key.Binding
|
||||
SplitLeft, SplitRight, SplitReset key.Binding
|
||||
Taller, Shorter, ZoomIn, ZoomOut key.Binding
|
||||
Login, Refresh, Help, Quit key.Binding
|
||||
Update key.Binding
|
||||
// filter
|
||||
AcceptFilter, ClearFilter key.Binding
|
||||
FilterUp, FilterDown key.Binding
|
||||
FilterCopy, FilterReveal, FilterStar key.Binding
|
||||
// menu
|
||||
MenuUp, MenuDown, MenuSelect, MenuClose key.Binding
|
||||
// popup
|
||||
PopupUp, PopupDown, PopupSelect, PopupClose, PopupStar key.Binding
|
||||
// search
|
||||
SearchSources, SearchResults, SearchSwitch key.Binding
|
||||
SearchLoad, SearchLoadAll key.Binding
|
||||
// goto
|
||||
GoDown, GoUp, GoTop, GoBottom key.Binding
|
||||
GoPrevTab, GoNextTab key.Binding
|
||||
GoSources, GoResults, GoSwitch key.Binding
|
||||
GoStar, GoLoad, GoLoadAll, GoFilter key.Binding
|
||||
GoSelect, GoClose key.Binding
|
||||
}
|
||||
|
||||
// Action is a binding with its name in the config and what it does.
|
||||
type Action struct {
|
||||
Context, Name string
|
||||
Binding *key.Binding
|
||||
Desc string // short, for the help bar
|
||||
Group string // section in the ? menu
|
||||
Tip string // a sentence or two for the ? menu
|
||||
}
|
||||
|
||||
// Actions lists every action, in the ? menu's order.
|
||||
func (m *Map) Actions() []Action {
|
||||
a := func(ctx, name string, b *key.Binding, desc, group, tip string) Action {
|
||||
return Action{ctx, name, b, desc, group, tip}
|
||||
}
|
||||
return []Action{
|
||||
a(Lists, "up", &m.Up, "up", "move", "Move up in the focused list, or scroll the detail pane."),
|
||||
a(Lists, "down", &m.Down, "down", "move", "Move down in the focused list, or scroll the detail pane."),
|
||||
a(Lists, "top", &m.Top, "top", "move", "Jump to the top of the focused list."),
|
||||
a(Lists, "bottom", &m.Bottom, "bottom", "move", "Jump to the bottom of the focused list."),
|
||||
a(Lists, "nextPane", &m.NextPane, "next pane", "move", "Focus the next pane: Sources, Vaults, Secrets, detail."),
|
||||
a(Lists, "prevPane", &m.PrevPane, "prev pane", "move", "Focus the previous pane; from the detail pane, the one it describes."),
|
||||
a(Lists, "details", &m.Details, "details", "move", "Focus the detail pane on the right, on what's selected: a source, vault, file or secret."),
|
||||
a(Lists, "pane1", &m.Pane1, "Sources pane", "move", "Focus the Sources pane."),
|
||||
a(Lists, "pane2", &m.Pane2, "Vaults pane", "move", "Focus the Vaults (or Scopes) pane."),
|
||||
a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."),
|
||||
a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."),
|
||||
a(Lists, "pane0", &m.Pane0, "status", "move", "Focus the status pane: version and updates, accounts, config files, links and the changelog, on the right."),
|
||||
a(Lists, "enter", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."),
|
||||
a(Lists, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."),
|
||||
|
||||
a(Lists, "filter", &m.Filter, "filter", "lists", "Filter the focused list by name, fuzzily: dbpw finds db-password. In Sources the provider and account match too."),
|
||||
a(Lists, "prevTab", &m.PrevTab, "prev tab", "lists", "Previous tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets."),
|
||||
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 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."),
|
||||
a(Lists, "note", &m.Note, "note", "lists", "Write a short note on the selected subscription, vault or secret, shown on the right of its row. Filtering matches notes too."),
|
||||
|
||||
a(Lists, "reveal", &m.Reveal, "reveal/hide", "secrets", "Show or hide the selected value. For keys and certificates: the public key or certificate."),
|
||||
a(Lists, "copyValue", &m.Copy, "copy value", "secrets", "Copy the selected value without showing it. It is cleared from the clipboard after a countdown."),
|
||||
a(Lists, "copyReference", &m.CopyRef, "copy reference", "secrets", "Copy the reference to use in config, e.g. @Microsoft.KeyVault(SecretUri=…) or {{secrets/scope/key}}."),
|
||||
a(Lists, "versions", &m.Versions, "versions", "secrets", "List the versions of the selected item (Azure). Reveal, copy and share then act on the selected version."),
|
||||
a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."),
|
||||
a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line. SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."),
|
||||
|
||||
a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
|
||||
a(Lists, "shareMenu", &m.ShareMenu, "share", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, or the web page."),
|
||||
a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."),
|
||||
|
||||
a(Lists, "dividerLeft", &m.SplitLeft, "divider left", "layout", "Move the divider between the left panes and the detail pane to the left."),
|
||||
a(Lists, "dividerRight", &m.SplitRight, "divider right", "layout", "Move the divider between the left panes and the detail pane to the right."),
|
||||
a(Lists, "taller", &m.Taller, "taller", "layout", "Make the focused left pane taller."),
|
||||
a(Lists, "shorter", &m.Shorter, "shorter", "layout", "Make the focused left pane shorter."),
|
||||
a(Lists, "zoomIn", &m.ZoomIn, "zoom", "layout", "Zoom the focused pane: normal, half screen, full screen."),
|
||||
a(Lists, "zoomOut", &m.ZoomOut, "unzoom", "layout", "Zoom back out."),
|
||||
a(Lists, "resetLayout", &m.SplitReset, "reset layout", "layout", "Reset the divider, pane heights and zoom."),
|
||||
|
||||
a(Lists, "refresh", &m.Refresh, "refresh", "other", "Reload the focused pane, bypassing the cache."),
|
||||
a(Lists, "login", &m.Login, "sign in", "other", "Sign in again (az login or databricks auth login) when a login has expired."),
|
||||
a(Lists, "update", &m.Update, "update", "other", "Check for a newer lazysecrets: its changelog, and update to it."),
|
||||
a(Lists, "help", &m.Help, "help", "other", "Show this list. Also works in Go to while browsing, and in menus, showing their keys first."),
|
||||
a(Lists, "quit", &m.Quit, "quit", "other", "Quit lazysecrets. ctrl+c always quits too."),
|
||||
|
||||
a(Filter, "down", &m.FilterDown, "down", "while filtering", "Move down while typing a filter."),
|
||||
a(Filter, "up", &m.FilterUp, "up", "while filtering", "Move up while typing a filter."),
|
||||
a(Filter, "copyValue", &m.FilterCopy, "copy value", "while filtering", "Copy the selected value while typing a filter."),
|
||||
a(Filter, "reveal", &m.FilterReveal, "reveal/hide", "while filtering", "Reveal or hide the selected value while typing a filter."),
|
||||
a(Filter, "star", &m.FilterStar, "star", "while filtering", "Star or unstar the selected item while typing a filter."),
|
||||
a(Filter, "accept", &m.AcceptFilter, "apply filter", "while filtering", "Stop typing and keep the filter."),
|
||||
a(Filter, "clear", &m.ClearFilter, "clear filter", "while filtering", "Clear the filter."),
|
||||
|
||||
a(Menu, "down", &m.MenuDown, "down", "menus", "Move down in a menu."),
|
||||
a(Menu, "up", &m.MenuUp, "up", "menus", "Move up in a menu."),
|
||||
a(Menu, "select", &m.MenuSelect, "select", "menus", "Pick the selected entry. Each entry's letter picks it directly."),
|
||||
a(Menu, "close", &m.MenuClose, "dismiss", "menus", "Close the menu."),
|
||||
|
||||
a(Popup, "down", &m.PopupDown, "down", "popups", "Move down in a popup with a text field (favourites, help, search)."),
|
||||
a(Popup, "up", &m.PopupUp, "up", "popups", "Move up in a popup with a text field."),
|
||||
a(Popup, "select", &m.PopupSelect, "go", "popups", "Go to the selected entry, or confirm what you typed."),
|
||||
a(Popup, "close", &m.PopupClose, "close", "popups", "Close the popup."),
|
||||
a(Popup, "star", &m.PopupStar, "star", "popups", "Star or unstar the selected entry in Go to. It stays where it is until Go to opens again."),
|
||||
|
||||
a(Search, "sources", &m.SearchSources, "sources", "search", "Move to the sources list."),
|
||||
a(Search, "results", &m.SearchResults, "results", "search", "Move to the results list."),
|
||||
a(Search, "switchList", &m.SearchSwitch, "switch list", "search", "Switch between the sources and the results."),
|
||||
a(Search, "load", &m.SearchLoad, "load selected", "search", "Load (or reload) the selected source's vaults and secrets, or the selected vault's secrets, in the background."),
|
||||
a(Search, "loadAll", &m.SearchLoadAll, "load all", "search", "Load every source's vaults and secrets in the background, to search them all."),
|
||||
|
||||
a(GoTo, "down", &m.GoDown, "down", "go to", "Move down in Go to while browsing."),
|
||||
a(GoTo, "up", &m.GoUp, "up", "go to", "Move up in Go to while browsing."),
|
||||
a(GoTo, "top", &m.GoTop, "top", "go to", "Jump to the top of the list in Go to while browsing."),
|
||||
a(GoTo, "bottom", &m.GoBottom, "bottom", "go to", "Jump to the bottom of the list in Go to while browsing."),
|
||||
a(GoTo, "prevTab", &m.GoPrevTab, "prev tab", "go to", "Previous tab of Go to: Search, Favourites, Recent."),
|
||||
a(GoTo, "nextTab", &m.GoNextTab, "next tab", "go to", "Next tab of Go to."),
|
||||
a(GoTo, "sources", &m.GoSources, "sources", "go to", "Move to the sources list (Search tab)."),
|
||||
a(GoTo, "results", &m.GoResults, "results", "go to", "Move to the results list (Search tab)."),
|
||||
a(GoTo, "switchList", &m.GoSwitch, "switch list", "go to", "Switch between the sources and the results (Search tab)."),
|
||||
a(GoTo, "star", &m.GoStar, "star", "go to", "Star or unstar the selected entry. It stays where it is until Go to opens again."),
|
||||
a(GoTo, "load", &m.GoLoad, "load selected", "go to", "Load (or reload) the selected source or vault in the background (Search tab)."),
|
||||
a(GoTo, "loadAll", &m.GoLoadAll, "load all", "go to", "Load everything in the background, to search it all (Search tab)."),
|
||||
a(GoTo, "filter", &m.GoFilter, "filter", "go to", "Start typing to filter the list."),
|
||||
a(GoTo, "select", &m.GoSelect, "go", "go to", "Go to the selected entry."),
|
||||
a(GoTo, "close", &m.GoClose, "close", "go to", "Close Go to."),
|
||||
}
|
||||
}
|
||||
|
||||
// defaults follow one rule: a letter in the lists, ctrl+ the same letter
|
||||
// where you type.
|
||||
var defaults = map[string][]string{
|
||||
"lists.up": {"k", "up", "ctrl+k", "ctrl+p"}, "lists.down": {"j", "down", "ctrl+j", "ctrl+n"},
|
||||
"lists.top": {"g", "home"}, "lists.bottom": {"G", "end"},
|
||||
"lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"},
|
||||
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"},
|
||||
"lists.enter": {"enter"}, "lists.back": {"esc", "backspace"},
|
||||
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
|
||||
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"},
|
||||
"lists.commands": {"x"},
|
||||
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
|
||||
"lists.versions": {"v"}, "lists.openInBrowser": {"o"}, "lists.edit": {"e"},
|
||||
"lists.share": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"},
|
||||
"lists.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"},
|
||||
"lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"},
|
||||
"lists.zoomIn": {"+"}, "lists.zoomOut": {"_"}, "lists.resetLayout": {"="},
|
||||
"lists.refresh": {"r"}, "lists.login": {"L"}, "lists.update": {"U"}, "lists.help": {"?"}, "lists.quit": {"q"},
|
||||
|
||||
"filter.down": {"ctrl+j", "ctrl+n", "down"}, "filter.up": {"ctrl+k", "ctrl+p", "up"},
|
||||
"filter.copyValue": {"ctrl+y"}, "filter.reveal": {"ctrl+r"}, "filter.star": {"ctrl+f"},
|
||||
"filter.accept": {"enter"}, "filter.clear": {"esc"},
|
||||
|
||||
"menu.down": {"j", "down", "ctrl+j", "ctrl+n"}, "menu.up": {"k", "up", "ctrl+k", "ctrl+p"},
|
||||
"menu.select": {"enter"}, "menu.close": {"esc"},
|
||||
|
||||
"popup.down": {"ctrl+j", "ctrl+n", "down"}, "popup.up": {"ctrl+k", "ctrl+p", "up"},
|
||||
"popup.select": {"enter"}, "popup.close": {"esc"}, "popup.star": {"ctrl+f"},
|
||||
|
||||
"search.sources": {"ctrl+h"}, "search.results": {"ctrl+l"}, "search.switchList": {"tab", "shift+tab"},
|
||||
"search.load": {"ctrl+r"}, "search.loadAll": {"ctrl+a"},
|
||||
|
||||
"goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.top": {"g", "home"}, "goto.bottom": {"G", "end"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"},
|
||||
"goto.sources": {"h", "left"}, "goto.results": {"l", "right"}, "goto.switchList": {"tab", "shift+tab"},
|
||||
"goto.star": {"f"}, "goto.load": {"r"}, "goto.loadAll": {"a"}, "goto.filter": {"/"},
|
||||
"goto.select": {"enter"}, "goto.close": {"esc", "q"},
|
||||
}
|
||||
|
||||
// Default is the keymap without overrides.
|
||||
func Default() Map {
|
||||
var m Map
|
||||
for _, a := range m.Actions() {
|
||||
*a.Binding = bind(defaults[a.Context+"."+a.Name], a.Desc)
|
||||
}
|
||||
m.Login.SetEnabled(false) // shown and active only once a login expires
|
||||
return m
|
||||
}
|
||||
|
||||
func bind(keys []string, desc string) key.Binding {
|
||||
if len(keys) == 0 {
|
||||
return key.NewBinding(key.WithDisabled())
|
||||
}
|
||||
return key.NewBinding(key.WithKeys(keys...), key.WithHelp(Label(keys), desc))
|
||||
}
|
||||
|
||||
// Label shows keys in help: the first two, arrows as arrows, a shared
|
||||
// ctrl+ written once ("k/↑", "ctrl+j/n").
|
||||
func Label(keys []string) string {
|
||||
pretty := strings.NewReplacer("left", "←", "right", "→", "up", "↑", "down", "↓", "backspace", "⌫")
|
||||
var out []string
|
||||
for i, k := range keys[:min(2, len(keys))] {
|
||||
k = pretty.Replace(k)
|
||||
if rest, ok := strings.CutPrefix(k, "ctrl+"); ok && i > 0 && strings.HasPrefix(out[i-1], "ctrl+") {
|
||||
k = rest
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
return strings.Join(out, "/")
|
||||
}
|
||||
|
||||
// Name is the key to mention in a hint ("space to reveal"): the first one
|
||||
// bound, or "" if the action is unbound.
|
||||
func Name(b key.Binding) string {
|
||||
if ks := b.Keys(); len(ks) > 0 {
|
||||
return Label(ks[:1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Apply overrides the defaults from the config: each action given gets
|
||||
// exactly the keys listed; none unbinds it. Unknown contexts or actions,
|
||||
// and a key bound to two actions that apply together, are errors.
|
||||
func (m *Map) Apply(cfg map[string]map[string][]string) error {
|
||||
byName := map[string]Action{}
|
||||
contexts := map[string]bool{}
|
||||
for _, a := range m.Actions() {
|
||||
byName[a.Context+"."+a.Name] = a
|
||||
contexts[a.Context] = true
|
||||
}
|
||||
for _, ctx := range sortedKeys(cfg) {
|
||||
if !contexts[ctx] {
|
||||
return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(sortedKeys(contexts), ", "))
|
||||
}
|
||||
for _, name := range sortedKeys(cfg[ctx]) {
|
||||
a, ok := byName[ctx+"."+name]
|
||||
if !ok {
|
||||
return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", "))
|
||||
}
|
||||
*a.Binding = bind(cfg[ctx][name], a.Desc)
|
||||
}
|
||||
}
|
||||
m.Login.SetEnabled(false)
|
||||
return m.check()
|
||||
}
|
||||
|
||||
func (m *Map) names(ctx string) []string {
|
||||
var out []string
|
||||
for _, a := range m.Actions() {
|
||||
if a.Context == ctx {
|
||||
out = append(out, a.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedKeys[V any](set map[string]V) []string {
|
||||
out := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// check finds a key bound to two actions that apply together: within a
|
||||
// context, and in the search popup (search and popup).
|
||||
func (m *Map) check() error {
|
||||
for _, group := range [][]string{{Lists}, {Filter}, {Menu}, {Popup}, {Search, Popup}, {GoTo}} {
|
||||
seen := map[string]string{}
|
||||
for _, a := range m.Actions() {
|
||||
if !slices.Contains(group, a.Context) {
|
||||
continue
|
||||
}
|
||||
name := a.Context + "." + a.Name
|
||||
for _, k := range a.Binding.Keys() {
|
||||
if other, ok := seen[k]; ok && other != name {
|
||||
return fmt.Errorf("keybinding: %q is bound to both %s and %s", k, other, name)
|
||||
}
|
||||
seen[k] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Section is a titled group of actions for the ? menu.
|
||||
type Section struct {
|
||||
Title string
|
||||
Entries []Action
|
||||
}
|
||||
|
||||
// Sections groups the actions for the ? menu.
|
||||
func (m *Map) Sections() []Section {
|
||||
var out []Section
|
||||
for _, a := range m.Actions() {
|
||||
if n := len(out); n == 0 || out[n-1].Title != a.Group {
|
||||
out = append(out, Section{Title: a.Group})
|
||||
}
|
||||
out[len(out)-1].Entries = append(out[len(out)-1].Entries, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FilterHelp is shown while typing a filter.
|
||||
type FilterHelp struct{ Map }
|
||||
|
||||
func (f FilterHelp) ShortHelp() []key.Binding {
|
||||
return []key.Binding{f.FilterDown, f.FilterUp, f.FilterCopy, f.FilterReveal, f.FilterStar, f.AcceptFilter, f.ClearFilter}
|
||||
}
|
||||
|
||||
func (f FilterHelp) FullHelp() [][]key.Binding { return [][]key.Binding{f.ShortHelp()} }
|
||||
|
||||
// YAML is the keymap as a config section, every action with its keys and
|
||||
// what it does, to copy into the config and edit.
|
||||
func (m *Map) YAML() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("keybinding:\n")
|
||||
ctx := ""
|
||||
for _, a := range m.Actions() {
|
||||
if a.Context != ctx {
|
||||
ctx = a.Context
|
||||
fmt.Fprintf(&b, " %s:\n", ctx)
|
||||
}
|
||||
quoted := make([]string, len(a.Binding.Keys()))
|
||||
for i, k := range a.Binding.Keys() {
|
||||
quoted[i] = fmt.Sprintf("%q", k)
|
||||
}
|
||||
fmt.Fprintf(&b, " %-15s [%s] # %s\n", a.Name+":", strings.Join(quoted, ", "), a.Desc)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// CheckExtra reports a key some other binding in the lists takes, for keys
|
||||
// added outside the keymap (custom commands). name says whose key it is.
|
||||
func (m *Map) CheckExtra(k, name string) error {
|
||||
for _, a := range m.Actions() {
|
||||
if a.Context == Lists && slices.Contains(a.Binding.Keys(), k) {
|
||||
return fmt.Errorf("%s: %q is bound to lists.%s", name, k, a.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package keys
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
func press(s string) tea.KeyPressMsg { return tea.KeyPressMsg{Code: rune(s[0]), Text: s} }
|
||||
|
||||
func TestDefaultsHaveNoConflicts(t *testing.T) {
|
||||
m := Default()
|
||||
if err := m.check(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, a := range m.Actions() {
|
||||
if len(a.Binding.Keys()) == 0 {
|
||||
t.Errorf("%s.%s has no default keys", a.Context, a.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply(t *testing.T) {
|
||||
m := Default()
|
||||
err := m.Apply(map[string]map[string][]string{
|
||||
"lists": {"star": {"z"}, "versions": {}},
|
||||
"filter": {"star": {"ctrl+x"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !key.Matches(press("z"), m.Favourite) || key.Matches(press("f"), m.Favourite) {
|
||||
t.Error("star not rebound from f to z")
|
||||
}
|
||||
if m.Favourite.Help().Key != "z" {
|
||||
t.Errorf("help shows %q", m.Favourite.Help().Key)
|
||||
}
|
||||
if m.Versions.Enabled() || Name(m.Versions) != "" {
|
||||
t.Error("versions not unbound")
|
||||
}
|
||||
if Name(m.FilterStar) != "ctrl+x" || Name(m.PopupStar) != "ctrl+f" {
|
||||
t.Errorf("filter star %q, popup star %q", Name(m.FilterStar), Name(m.PopupStar))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyErrors(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
cfg map[string]map[string][]string
|
||||
want string
|
||||
}{
|
||||
{map[string]map[string][]string{"panes": {"star": {"x"}}}, `unknown context "panes"`},
|
||||
{map[string]map[string][]string{"lists": {"starr": {"x"}}}, `unknown action "starr"`},
|
||||
{map[string]map[string][]string{"lists": {"star": {"s"}}}, `"s" is bound to both`}, // share
|
||||
{map[string]map[string][]string{"search": {"load": {"ctrl+f"}}}, `"ctrl+f" is bound to both`}, // popup star
|
||||
} {
|
||||
m := Default()
|
||||
if err := m.Apply(c.cfg); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%v: %v, want %q", c.cfg, err, c.want)
|
||||
}
|
||||
}
|
||||
// The same key in contexts that never apply together is fine.
|
||||
m := Default()
|
||||
if err := m.Apply(map[string]map[string][]string{"filter": {"star": {"ctrl+r"}, "reveal": {"ctrl+e"}}}); err != nil {
|
||||
t.Errorf("filter ctrl+r and search ctrl+r clash: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabel(t *testing.T) {
|
||||
for keys, want := range map[string]string{
|
||||
"k up": "k/↑",
|
||||
"ctrl+j ctrl+n down": "ctrl+j/n",
|
||||
"space": "space",
|
||||
"ctrl+left <": "ctrl+←/<",
|
||||
} {
|
||||
if got := Label(strings.Fields(keys)); got != want {
|
||||
t.Errorf("Label(%s) = %q, want %q", keys, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Package panes contains the lazygit-style bordered panes.
|
||||
package panes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
// Box draws a rounded border of exactly w×h cells with the title embedded in
|
||||
// the top edge and an optional footer in the bottom edge. The title is
|
||||
// rendered as given, so callers style it (see TitleStyle). Body lines are
|
||||
// truncated or padded to fit; extra lines are dropped.
|
||||
func Box(st styles.Styles, title, footer string, body []string, w, h int, focused bool) string {
|
||||
return BoxCounted(st, title, footer, "", body, w, h, focused)
|
||||
}
|
||||
|
||||
// BoxCounted is Box with a counter like "3 of 12" at the right end of the
|
||||
// bottom edge, where lazygit puts it.
|
||||
func BoxCounted(st styles.Styles, title, footer, counter string, body []string, w, h int, focused bool) string {
|
||||
if w < 4 || h < 2 {
|
||||
return ""
|
||||
}
|
||||
border := st.BorderBlurred
|
||||
if focused {
|
||||
border = st.BorderFocused
|
||||
}
|
||||
plain := func(s ...string) string { return strings.Join(s, "") }
|
||||
inner := w - 2
|
||||
|
||||
edge := func(left, right, label string, styled func(...string) string) string {
|
||||
if label == "" {
|
||||
return border.Render(left + strings.Repeat("─", inner) + right)
|
||||
}
|
||||
label = ansi.Truncate(" "+label+" ", inner-1, "…")
|
||||
fill := inner - 1 - ansi.StringWidth(label)
|
||||
return border.Render(left+"─") + styled(label) + border.Render(strings.Repeat("─", fill)+right)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(edge("╭", "╮", title, plain))
|
||||
side := border.Render("│")
|
||||
for i := range h - 2 {
|
||||
line := ""
|
||||
if i < len(body) {
|
||||
line = ansi.Truncate(body[i], inner, "…")
|
||||
}
|
||||
b.WriteString("\n" + side + line + strings.Repeat(" ", inner-ansi.StringWidth(line)) + side)
|
||||
}
|
||||
bottom := edge("╰", "╯", footer, border.Render)
|
||||
if counter != "" {
|
||||
// Overwrite the end of the bottom edge: "… 3 of 12 ─╯".
|
||||
label := " " + counter + " "
|
||||
if keep := w - 2 - ansi.StringWidth(label); keep > 2 {
|
||||
bottom = ansi.Truncate(bottom, keep, "") + border.Render(label+"─╯")
|
||||
}
|
||||
}
|
||||
b.WriteString("\n" + bottom)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// TitleStyle returns the style for a pane title.
|
||||
func TitleStyle(st styles.Styles, focused bool) lipgloss.Style {
|
||||
if focused {
|
||||
return st.TitleFocused
|
||||
}
|
||||
return st.TitleBlurred
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package panes
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
type ValueState int
|
||||
|
||||
const (
|
||||
ValueHidden ValueState = iota
|
||||
ValueLoading
|
||||
ValueShown
|
||||
ValueFailed
|
||||
)
|
||||
|
||||
// Detail shows metadata for the selected secret (or container) and, on
|
||||
// request, its value.
|
||||
type Detail struct {
|
||||
Now func() time.Time
|
||||
Icons icons.Set
|
||||
// RevealKey and VersionsKey are the keys hints mention ("space to
|
||||
// reveal"); a hint is left out when its key is "" (unbound).
|
||||
RevealKey, VersionsKey, CopyKey string
|
||||
|
||||
secret *provider.Object
|
||||
container *provider.Container
|
||||
|
||||
state ValueState
|
||||
value provider.SecretValue
|
||||
err error
|
||||
hidesIn string // when a shown value hides by itself, if it does
|
||||
|
||||
describing bool
|
||||
describeErr error
|
||||
|
||||
versions []provider.Object // newest first; nil while unknown
|
||||
note string // the user's note on what's shown
|
||||
|
||||
offset int
|
||||
w, h int
|
||||
}
|
||||
|
||||
func NewDetail(iconSet icons.Set) Detail { return Detail{Now: time.Now, Icons: iconSet} }
|
||||
|
||||
// ShowSecret selects a secret and forgets any previously fetched value.
|
||||
func (d *Detail) ShowSecret(s provider.Object) {
|
||||
d.Clear()
|
||||
d.secret = &s
|
||||
d.container = &s.Container
|
||||
}
|
||||
|
||||
// ShowContainer shows a container summary when no secret is selected.
|
||||
func (d *Detail) ShowContainer(c provider.Container) {
|
||||
d.Clear()
|
||||
d.container = &c
|
||||
}
|
||||
|
||||
// Clear drops the selection and any fetched value.
|
||||
func (d *Detail) Clear() {
|
||||
*d = Detail{Now: d.Now, Icons: d.Icons, RevealKey: d.RevealKey, VersionsKey: d.VersionsKey, CopyKey: d.CopyKey, w: d.w, h: d.h} // drops the note too
|
||||
}
|
||||
|
||||
// SetDescribing marks extra metadata as being fetched.
|
||||
func (d *Detail) SetDescribing() { d.describing, d.describeErr = true, nil }
|
||||
|
||||
// SetDescribed replaces the shown object's metadata, keeping the value
|
||||
// state. It ignores objects other than the one shown.
|
||||
func (d *Detail) SetDescribed(o provider.Object) {
|
||||
if d.secret == nil || d.secret.ID != o.ID {
|
||||
return
|
||||
}
|
||||
// Keep the container (and so the account) the object was shown with;
|
||||
// value requests are routed by it.
|
||||
o.Container = d.secret.Container
|
||||
d.secret = &o
|
||||
d.describing = false
|
||||
}
|
||||
|
||||
// SetNote shows the user's note on the shown object or container.
|
||||
func (d *Detail) SetNote(note string) { d.note = note }
|
||||
|
||||
// SetVersions records the shown object's versions, newest first. It
|
||||
// ignores objects other than the one shown.
|
||||
func (d *Detail) SetVersions(id string, versions []provider.Object) {
|
||||
if d.secret != nil && d.secret.ID == id {
|
||||
d.versions = versions
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Detail) SetDescribeError(err error) { d.describing, d.describeErr = false, err }
|
||||
|
||||
func (d Detail) Secret() (provider.Object, bool) {
|
||||
if d.secret == nil {
|
||||
return provider.Object{}, false
|
||||
}
|
||||
return *d.secret, true
|
||||
}
|
||||
|
||||
// Container is the container shown, if any.
|
||||
func (d Detail) Container() (provider.Container, bool) {
|
||||
if d.container == nil {
|
||||
return provider.Container{}, false
|
||||
}
|
||||
return *d.container, true
|
||||
}
|
||||
|
||||
// Value returns the value if it is shown.
|
||||
func (d Detail) Value() (provider.SecretValue, bool) {
|
||||
return d.value, d.state == ValueShown
|
||||
}
|
||||
|
||||
func (d Detail) State() ValueState { return d.state }
|
||||
|
||||
func (d *Detail) SetLoading() { d.state, d.err = ValueLoading, nil }
|
||||
|
||||
func (d *Detail) SetValue(v provider.SecretValue) { d.state, d.value, d.err = ValueShown, v, nil }
|
||||
|
||||
func (d *Detail) SetValueError(err error) { d.state, d.err = ValueFailed, err }
|
||||
|
||||
// Hide masks the value again and drops it from memory.
|
||||
func (d *Detail) Hide() {
|
||||
d.state, d.value, d.err, d.hidesIn = ValueHidden, provider.SecretValue{}, nil, ""
|
||||
}
|
||||
|
||||
// SetHidesIn shows when a revealed value hides by itself, e.g. "42s".
|
||||
func (d *Detail) SetHidesIn(s string) { d.hidesIn = s }
|
||||
|
||||
func (d *Detail) SetSize(w, h int) {
|
||||
d.w, d.h = w, h
|
||||
d.Scroll(0)
|
||||
}
|
||||
|
||||
func (d *Detail) Scroll(delta int) {
|
||||
total := len(d.lines(styles.Styles{}))
|
||||
d.offset = max(0, min(d.offset+delta, total-(d.h-2)))
|
||||
}
|
||||
|
||||
func (d *Detail) ScrollTop() { d.offset = 0 }
|
||||
func (d *Detail) ScrollBottom() { d.Scroll(1 << 30) }
|
||||
|
||||
func (d Detail) View(st styles.Styles, focused bool, spin string) string {
|
||||
lines := d.lines(st)
|
||||
if spin != "" && d.state == ValueLoading {
|
||||
for i, l := range lines {
|
||||
lines[i] = strings.Replace(l, spinnerPlaceholder, spin, 1)
|
||||
}
|
||||
}
|
||||
rows := max(0, d.h-2)
|
||||
footer := ""
|
||||
if len(lines) > rows {
|
||||
footer = fmt.Sprintf("%d-%d of %d", d.offset+1, min(d.offset+rows, len(lines)), len(lines))
|
||||
}
|
||||
title := "Details"
|
||||
if d.secret != nil {
|
||||
title = string(d.secret.Type)
|
||||
}
|
||||
return Box(st, TitleStyle(st, focused).Render(title), footer, lines[min(d.offset, len(lines)):], d.w, d.h, focused)
|
||||
}
|
||||
|
||||
const spinnerPlaceholder = "\x00spin\x00"
|
||||
|
||||
// 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 := strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n")
|
||||
out = append(out, prefix+wrapped[0])
|
||||
for _, w := range wrapped[1:] {
|
||||
out = append(out, indent+w)
|
||||
}
|
||||
}
|
||||
muted := st.Muted.Render
|
||||
|
||||
if d.container == nil {
|
||||
return []string{muted(" Select a vault or scope to browse its secrets.")}
|
||||
}
|
||||
c := *d.container
|
||||
containerLabel := map[provider.Kind]string{provider.KindDatabricks: "scope", provider.KindRepo: "file"}[c.Source.Kind]
|
||||
if containerLabel == "" {
|
||||
containerLabel = "vault"
|
||||
}
|
||||
|
||||
if d.secret == nil {
|
||||
field(containerLabel, c.Name)
|
||||
if d.note != "" {
|
||||
field("note", st.Favourite.Render(d.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)
|
||||
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))
|
||||
}
|
||||
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)
|
||||
if s.Version != "" {
|
||||
field("version", s.Version)
|
||||
}
|
||||
|
||||
if s.Enabled != nil {
|
||||
if *s.Enabled {
|
||||
field("enabled", "yes")
|
||||
} else {
|
||||
field("enabled", st.Error.Render("no"))
|
||||
}
|
||||
}
|
||||
if !s.Updated.IsZero() {
|
||||
field("updated", formatTime(s.Updated))
|
||||
}
|
||||
if !s.Expires.IsZero() {
|
||||
exp := formatTime(s.Expires)
|
||||
if s.Expires.Before(d.Now()) {
|
||||
exp = st.Error.Render(exp + " (expired)")
|
||||
}
|
||||
field("expires", exp)
|
||||
}
|
||||
if s.ContentType != "" {
|
||||
field("content", s.ContentType)
|
||||
}
|
||||
for _, a := range s.Attrs {
|
||||
field(a.Label, a.Value)
|
||||
}
|
||||
if len(s.Tags) > 0 {
|
||||
var tags []string
|
||||
for _, k := range slices.Sorted(maps.Keys(s.Tags)) {
|
||||
tags = append(tags, k+"="+s.Tags[k])
|
||||
}
|
||||
field("tags", strings.Join(tags, ", "))
|
||||
}
|
||||
if s.Reference != "" {
|
||||
field("ref", muted(s.Reference))
|
||||
}
|
||||
switch {
|
||||
case d.describing:
|
||||
out = append(out, muted(" loading details…"))
|
||||
case d.describeErr != nil:
|
||||
field("details", st.Error.Render(d.describeErr.Error()))
|
||||
}
|
||||
|
||||
// Secrets are masked; keys and certificates only expose public material.
|
||||
// What the keys do with the value, on the line under it.
|
||||
hints := func(reveal string, more ...string) string {
|
||||
var h []string
|
||||
if d.RevealKey != "" {
|
||||
h = append(h, d.RevealKey+" to "+reveal)
|
||||
}
|
||||
if d.CopyKey != "" {
|
||||
h = append(h, d.CopyKey+" to copy")
|
||||
}
|
||||
return muted(strings.Join(append(h, more...), " · "))
|
||||
}
|
||||
label, hidden, reveal := "value", st.Masked.Render("••••••••"), "reveal"
|
||||
switch s.Type {
|
||||
case provider.TypeKey:
|
||||
label, hidden, reveal = "public", "", "show the public key"
|
||||
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))
|
||||
} else {
|
||||
field(label, hints(reveal))
|
||||
}
|
||||
case ValueLoading:
|
||||
field(label, spinnerPlaceholder+" fetching…")
|
||||
case ValueFailed:
|
||||
// Errors can run over several lines (sops lists every key it
|
||||
// tried): all of them, each wrapped at spaces and styled alone,
|
||||
// so lines aren't padded to the longest.
|
||||
for i, line := range strings.Split(strings.TrimRight(d.err.Error(), "\n"), "\n") {
|
||||
// Keep a line's own indent, on each part it wraps into.
|
||||
text := strings.TrimLeft(line, " ")
|
||||
lead := strings.Repeat(" ", len(line)-len(text))
|
||||
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))
|
||||
} else {
|
||||
out = append(out, strings.Repeat(" ", 12)+st.Error.Render(w))
|
||||
}
|
||||
}
|
||||
}
|
||||
case ValueShown:
|
||||
// Where the dots were, continuing under itself; the keys below.
|
||||
first := true
|
||||
lines, note := valueView(d.value.Reveal())
|
||||
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))
|
||||
first = false
|
||||
} else {
|
||||
out = append(out, indent+st.Value.Render(w))
|
||||
}
|
||||
}
|
||||
}
|
||||
var more []string
|
||||
if note != "" && d.CopyKey != "" {
|
||||
note += ": " + d.CopyKey + " copies it as stored"
|
||||
}
|
||||
if note != "" {
|
||||
more = append(more, note)
|
||||
}
|
||||
if d.hidesIn != "" {
|
||||
more = append(more, "hides in "+d.hidesIn)
|
||||
}
|
||||
out = append(out, indent+hints("hide", more...))
|
||||
}
|
||||
return append(out, d.versionLines(st)...)
|
||||
}
|
||||
|
||||
// maxVersionLines caps the version history shown in the detail pane; the
|
||||
// versions view (v) shows them all.
|
||||
const maxVersionLines = 10
|
||||
|
||||
func (d Detail) versionLines(st styles.Styles) []string {
|
||||
if len(d.versions) == 0 {
|
||||
return nil
|
||||
}
|
||||
muted := st.Muted.Render
|
||||
head := fmt.Sprintf("%d", len(d.versions))
|
||||
if len(d.versions) > 1 {
|
||||
head = st.Notice.Render(head)
|
||||
if d.VersionsKey != "" {
|
||||
head += muted(" · " + d.VersionsKey + " to browse")
|
||||
}
|
||||
}
|
||||
out := []string{"", " " + st.Label.Render(fmt.Sprintf("%-10s", "versions")) + " " + head}
|
||||
current := d.versions[0].Version
|
||||
for i, v := range d.versions {
|
||||
if i == maxVersionLines {
|
||||
out = append(out, muted(fmt.Sprintf(" … %d more", len(d.versions)-i)))
|
||||
break
|
||||
}
|
||||
// A list to read, not to move in (v browses them): no cursor. The
|
||||
// version shown above, when it isn't the current one, is named.
|
||||
line := " " + VersionLine(st, v, d.Now(), current)
|
||||
if d.secret.Version != "" && v.Version == d.secret.Version && v.Version != current {
|
||||
line += muted(" · shown above")
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// VersionLine draws a version as "id date state".
|
||||
func VersionLine(st styles.Styles, v provider.Object, now time.Time, current string) string {
|
||||
id := v.Version
|
||||
if len(id) > 8 {
|
||||
id = id[:8]
|
||||
}
|
||||
line := fmt.Sprintf("%-8s %s", id, v.Updated.Local().Format("2006-01-02"))
|
||||
switch {
|
||||
case v.Enabled != nil && !*v.Enabled:
|
||||
return st.Muted.Render(line + " disabled")
|
||||
case !v.Expires.IsZero() && v.Expires.Before(now):
|
||||
return st.Muted.Render(line + " expired")
|
||||
case v.Version == current:
|
||||
return line + st.Notice.Render(" current")
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string { return t.Local().Format("2006-01-02 15:04") }
|
||||
@@ -0,0 +1,79 @@
|
||||
package panes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
// Fields is the fourth pane for anything but a secret (an account, and
|
||||
// what describes vaults, scopes and files): labelled values, one per row,
|
||||
// a label on the first of several values, to move through, filter and
|
||||
// copy from. It's a List, so / filters it like the other panes. Secrets
|
||||
// have Detail, for revealing and versions.
|
||||
type Fields struct {
|
||||
List[Field]
|
||||
shown bool
|
||||
}
|
||||
|
||||
// Field is a row: a value under a label. Info rows (loading, errors)
|
||||
// can't be copied; Err ones are errors. A row can also be opened: URL in
|
||||
// the browser (o), Path in the editor (e), Provider signed in to (L), or
|
||||
// Link gone to (enter).
|
||||
type Field struct {
|
||||
Label, Value string
|
||||
Info, Err bool
|
||||
|
||||
URL, Path, Provider string
|
||||
// Link is a lazysecrets link enter goes to, in the source of row Row.
|
||||
Link, Row string
|
||||
}
|
||||
|
||||
func NewFields() Fields {
|
||||
l := NewList("", Item[Field]{
|
||||
ID: func(f Field) string { return f.Label + "\x00" + f.Value },
|
||||
Filter: func(f Field) string { return f.Value },
|
||||
// "/tenant" finds the tenant's rows.
|
||||
FilterExtra: func(f Field) string { return f.Label },
|
||||
Render: func(st styles.Styles, f Field, width int) string {
|
||||
value := f.Value
|
||||
switch {
|
||||
case f.Err:
|
||||
value = st.Error.Render(value)
|
||||
case f.Info:
|
||||
value = st.Muted.Render(value)
|
||||
}
|
||||
return ansi.Truncate(st.Label.Render(fmt.Sprintf("%-10s", f.Label))+" "+value, width, "…")
|
||||
},
|
||||
})
|
||||
l.Empty = "nothing to show"
|
||||
return Fields{List: l}
|
||||
}
|
||||
|
||||
// Show shows fields under title, instead of the secret pane. Another
|
||||
// title (another account or vault) starts over, without a filter.
|
||||
func (s *Fields) Show(title string, fields []Field) {
|
||||
if title != s.Title {
|
||||
s.Reset()
|
||||
s.Title = title
|
||||
}
|
||||
s.shown = true
|
||||
s.SetItems(fields)
|
||||
}
|
||||
|
||||
// Hide gives the fourth pane back to the secret pane.
|
||||
func (s *Fields) Hide() { s.shown = false }
|
||||
|
||||
// Shown reports whether the fourth pane shows Fields.
|
||||
func (s Fields) Shown() bool { return s.shown }
|
||||
|
||||
// Selected is the field under the cursor, unless it's an info row.
|
||||
func (s Fields) Selected() (Field, bool) {
|
||||
f, ok := s.List.Selected()
|
||||
if !ok || f.Info {
|
||||
return Field{}, false
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package panes
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// sgr matches SGR escape sequences.
|
||||
var sgr = regexp.MustCompile("\x1b\\[([0-9;:]*)m")
|
||||
|
||||
// Highlight draws a cursor row: line, padded to width, on style's
|
||||
// background, keeping the colours line already has (a provider's colour,
|
||||
// a dimmed note), like LazyVim's and lazygit's selected lines; bold
|
||||
// carries over too. A reverse style is applied to the plain text instead,
|
||||
// since reversing coloured text would scramble it.
|
||||
func Highlight(line string, style lipgloss.Style, width int) string {
|
||||
pad := strings.Repeat(" ", max(0, width-ansi.StringWidth(line)))
|
||||
if style.GetReverse() {
|
||||
return style.Render(ansi.Strip(line) + pad)
|
||||
}
|
||||
var s ansi.Style
|
||||
if bg := style.GetBackground(); !isNoColor(bg) { // never nil: NoColor when unset
|
||||
s = s.BackgroundColor(bg)
|
||||
}
|
||||
if style.GetBold() {
|
||||
s = s.Bold()
|
||||
}
|
||||
if len(s) == 0 {
|
||||
return line + pad
|
||||
}
|
||||
on := s.String()
|
||||
// Every reset in line (a bare reset, one starting with 0, or a
|
||||
// background reset) would end the highlight: put it back after each.
|
||||
line = sgr.ReplaceAllStringFunc(line, func(seq string) string {
|
||||
params := sgr.FindStringSubmatch(seq)[1]
|
||||
for _, p := range strings.Split(params, ";") {
|
||||
if p == "" || p == "0" || p == "49" {
|
||||
return seq + on
|
||||
}
|
||||
}
|
||||
return seq
|
||||
})
|
||||
return on + line + pad + ansi.ResetStyle
|
||||
}
|
||||
|
||||
func isNoColor(c color.Color) bool {
|
||||
_, ok := c.(lipgloss.NoColor)
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package panes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"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/ui/styles"
|
||||
)
|
||||
|
||||
// Item adapts a value for display in a List.
|
||||
type Item[T any] struct {
|
||||
ID func(T) string // stable identity, used to detect selection changes
|
||||
Filter func(T) string // text matched by the filter, and highlighted
|
||||
// FilterExtra, if set, is matched too but not shown highlighted, for
|
||||
// text the row doesn't show (e.g. the provider of a grouped source).
|
||||
FilterExtra func(T) string
|
||||
Render func(st styles.Styles, v T, width int) string
|
||||
}
|
||||
|
||||
// List is a scrollable, filterable list pane with loading and error states.
|
||||
type List[T any] struct {
|
||||
Title string
|
||||
// Tabs, when there are several, are shown after the title; Tab is the
|
||||
// active one.
|
||||
Tabs []string
|
||||
Tab int
|
||||
Empty string // shown when loaded with no items
|
||||
Loading bool
|
||||
Err error
|
||||
// Age says how old the items are ("2h ago") while they reload, by
|
||||
// the counter.
|
||||
Age string
|
||||
// Header lines (a logo) go above the items while the cursor is in
|
||||
// the rows under it, the pane has room and there's no filter; else
|
||||
// HeaderShort, a line of plain text, does.
|
||||
Header []string
|
||||
HeaderShort string
|
||||
|
||||
item Item[T]
|
||||
items []T
|
||||
visible []int // indices into items that match the filter, best first
|
||||
hits [][]int // per visible item: matched rune indices in its Filter text
|
||||
cursor int // index into visible
|
||||
offset int // first visible row
|
||||
w, h int // outer size including border
|
||||
|
||||
input textinput.Model
|
||||
filtering bool
|
||||
}
|
||||
|
||||
func NewList[T any](title string, item Item[T]) List[T] {
|
||||
in := textinput.New()
|
||||
in.Prompt = "/"
|
||||
return List[T]{Title: title, Empty: "nothing here", item: item, input: in}
|
||||
}
|
||||
|
||||
// SetRender changes how items are drawn, e.g. when the pane switches to
|
||||
// showing versions.
|
||||
func (l *List[T]) SetRender(render func(st styles.Styles, v T, width int) string) {
|
||||
l.item.Render = render
|
||||
}
|
||||
|
||||
// SetItems replaces the contents, keeping the cursor on the same item if it
|
||||
// is still present.
|
||||
func (l *List[T]) SetItems(items []T) {
|
||||
prev := l.SelectedID()
|
||||
l.items = items
|
||||
l.Loading = false
|
||||
l.Err = nil
|
||||
l.applyFilter(prev)
|
||||
}
|
||||
|
||||
// Reset clears items, error, filter and cursor.
|
||||
func (l *List[T]) Reset() {
|
||||
l.items, l.visible = nil, nil
|
||||
l.cursor, l.offset = 0, 0
|
||||
l.Loading, l.Err = false, nil
|
||||
l.filtering = false
|
||||
l.input.Reset()
|
||||
}
|
||||
|
||||
func (l *List[T]) SetError(err error) {
|
||||
l.Reset()
|
||||
l.Err = err
|
||||
}
|
||||
|
||||
// Refresh marks the pane as loading while keeping the current items and
|
||||
// selection on screen until SetItems replaces them.
|
||||
func (l *List[T]) Refresh() {
|
||||
l.Loading = true
|
||||
l.Err = nil
|
||||
l.Age = ""
|
||||
}
|
||||
|
||||
func (l *List[T]) SetLoading() {
|
||||
l.Reset()
|
||||
l.Loading = true
|
||||
}
|
||||
|
||||
func (l List[T]) Selected() (T, bool) {
|
||||
if l.cursor < 0 || l.cursor >= len(l.visible) {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
return l.items[l.visible[l.cursor]], true
|
||||
}
|
||||
|
||||
// SelectedID is "" when nothing is selected.
|
||||
func (l List[T]) SelectedID() string {
|
||||
v, ok := l.Selected()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return l.item.ID(v)
|
||||
}
|
||||
|
||||
func (l List[T]) Len() int { return len(l.visible) }
|
||||
|
||||
// Width is the pane's outer width.
|
||||
func (l List[T]) Width() int { return l.w }
|
||||
|
||||
// Items returns all items, ignoring the filter.
|
||||
func (l List[T]) Items() []T { return l.items }
|
||||
|
||||
// Select moves the cursor to the item with the given ID, if visible, and
|
||||
// reports whether it was.
|
||||
func (l *List[T]) Select(id string) bool {
|
||||
return l.SelectWhere(func(v T) bool { return l.item.ID(v) == id })
|
||||
}
|
||||
|
||||
// SelectWhere moves the cursor to the first visible item matching f.
|
||||
func (l *List[T]) SelectWhere(f func(T) bool) bool {
|
||||
for i, idx := range l.visible {
|
||||
if f(l.items[idx]) {
|
||||
l.cursor = i
|
||||
l.scroll()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (l *List[T]) Move(delta int) {
|
||||
l.cursor = max(0, min(l.cursor+delta, len(l.visible)-1))
|
||||
l.scroll()
|
||||
}
|
||||
|
||||
func (l *List[T]) Top() { l.Move(-len(l.visible)) }
|
||||
func (l *List[T]) Bottom() { l.Move(len(l.visible)) }
|
||||
|
||||
// SetSize sets the outer size of the pane, including its border.
|
||||
func (l *List[T]) SetSize(w, h int) {
|
||||
l.w, l.h = w, h
|
||||
l.input.SetWidth(max(1, w-4))
|
||||
l.scroll()
|
||||
}
|
||||
|
||||
// rows is the number of item rows that fit in the pane.
|
||||
func (l List[T]) rows() int {
|
||||
r := l.h - 2
|
||||
if l.filtering {
|
||||
r--
|
||||
}
|
||||
r -= len(l.header())
|
||||
return max(0, r)
|
||||
}
|
||||
|
||||
// headerMinRows is how many item rows Header leaves at least.
|
||||
const headerMinRows = 5
|
||||
|
||||
// header is the lines above the items: Header and a blank line, if it
|
||||
// fits, leaves headerMinRows and the cursor is in the rows under it;
|
||||
// else HeaderShort.
|
||||
func (l List[T]) header() []string {
|
||||
short := []string{}
|
||||
if l.HeaderShort != "" {
|
||||
short = []string{l.HeaderShort}
|
||||
}
|
||||
if len(l.Header) == 0 || l.filtering || l.input.Value() != "" {
|
||||
return short
|
||||
}
|
||||
for _, line := range l.Header {
|
||||
if ansi.StringWidth(line) > l.w-3 {
|
||||
return short
|
||||
}
|
||||
}
|
||||
below := l.h - 2 - len(l.Header) - 1
|
||||
if below < headerMinRows || l.cursor >= below {
|
||||
return short
|
||||
}
|
||||
return append(l.Header[:len(l.Header):len(l.Header)], "")
|
||||
}
|
||||
|
||||
// Filtering reports whether the filter input has focus.
|
||||
func (l List[T]) Filtering() bool { return l.filtering }
|
||||
|
||||
// HasFilter reports whether a filter query is applied.
|
||||
func (l List[T]) HasFilter() bool { return l.input.Value() != "" }
|
||||
|
||||
func (l *List[T]) StartFilter() tea.Cmd {
|
||||
l.filtering = true
|
||||
l.scroll()
|
||||
return l.input.Focus()
|
||||
}
|
||||
|
||||
// AcceptFilter leaves filter mode, keeping the query applied.
|
||||
func (l *List[T]) AcceptFilter() {
|
||||
l.filtering = false
|
||||
l.input.Blur()
|
||||
}
|
||||
|
||||
// ClearFilter leaves filter mode and removes the query.
|
||||
func (l *List[T]) ClearFilter() {
|
||||
l.filtering = false
|
||||
l.input.Blur()
|
||||
prev := l.SelectedID()
|
||||
l.input.Reset()
|
||||
l.applyFilter(prev)
|
||||
}
|
||||
|
||||
// UpdateFilter forwards a message to the filter input and re-filters.
|
||||
func (l *List[T]) UpdateFilter(msg tea.Msg) tea.Cmd {
|
||||
var cmd tea.Cmd
|
||||
prev := l.SelectedID()
|
||||
l.input, cmd = l.input.Update(msg)
|
||||
l.applyFilter(prev)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// applyFilter recomputes the visible items and puts the cursor back on the
|
||||
// item with ID keep if it is still visible.
|
||||
func (l *List[T]) applyFilter(keep string) {
|
||||
l.visible, l.hits = l.visible[:0], nil
|
||||
if q := l.input.Value(); q == "" {
|
||||
for i := range l.items {
|
||||
l.visible = append(l.visible, i)
|
||||
}
|
||||
} else {
|
||||
// The row's own text is the name; FilterExtra (a note, a provider)
|
||||
// is context, matched too but ranked lower.
|
||||
items := make([]match.Item, len(l.items))
|
||||
for i, v := range l.items {
|
||||
text := l.item.Filter(v)
|
||||
items[i] = match.Item{Text: text, NameEnd: utf8.RuneCountInString(text)}
|
||||
if l.item.FilterExtra != nil {
|
||||
if extra := l.item.FilterExtra(v); extra != "" {
|
||||
items[i].Text += " " + extra
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, h := range match.Rank(match.Parse(q), items) {
|
||||
var hits []int // the ones in the text the row shows
|
||||
for _, p := range h.Pos {
|
||||
if p < items[h.Index].NameEnd {
|
||||
hits = append(hits, p)
|
||||
}
|
||||
}
|
||||
l.visible = append(l.visible, h.Index)
|
||||
l.hits = append(l.hits, hits)
|
||||
}
|
||||
}
|
||||
l.cursor = max(0, min(l.cursor, len(l.visible)-1))
|
||||
if keep != "" {
|
||||
l.Select(keep)
|
||||
}
|
||||
l.scroll()
|
||||
}
|
||||
|
||||
// MarkMatches styles the characters at hits (rune indexes in the text
|
||||
// shown, without styling) in a styled string, keeping its own colours.
|
||||
func MarkMatches(styled string, hits []int, style lipgloss.Style) string {
|
||||
if len(hits) == 0 {
|
||||
return styled
|
||||
}
|
||||
return lipgloss.StyleRanges(styled, matchRanges(ansi.Strip(styled), hits, style)...)
|
||||
}
|
||||
|
||||
// matchRanges turns matched rune indexes in plain into ranges of screen
|
||||
// cells, merging neighbours, for lipgloss.StyleRanges.
|
||||
func matchRanges(plain string, hits []int, style lipgloss.Style) []lipgloss.Range {
|
||||
runes := []rune(plain)
|
||||
cell := func(i int) int { return ansi.StringWidth(string(runes[:min(i, len(runes))])) }
|
||||
var out []lipgloss.Range
|
||||
for i := 0; i < len(hits); {
|
||||
j := i
|
||||
for j+1 < len(hits) && hits[j+1] == hits[j]+1 {
|
||||
j++
|
||||
}
|
||||
if hits[i] < len(runes) {
|
||||
out = append(out, lipgloss.NewRange(cell(hits[i]), cell(hits[j]+1), style))
|
||||
}
|
||||
i = j + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RuneIndexes converts byte offsets in s to rune offsets.
|
||||
func RuneIndexes(s string, bytes []int) []int {
|
||||
out := make([]int, 0, len(bytes))
|
||||
for _, b := range bytes {
|
||||
out = append(out, utf8.RuneCountInString(s[:b]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// highlight returns the rune indexes to highlight in a row's plain text:
|
||||
// the filter hits, shifted to where the Filter text appears in the row.
|
||||
// It returns nil if the row doesn't contain the Filter text verbatim.
|
||||
func (l List[T]) highlight(i int, plain string) []int {
|
||||
if i >= len(l.hits) {
|
||||
return nil
|
||||
}
|
||||
off := strings.Index(plain, l.item.Filter(l.items[l.visible[i]]))
|
||||
if off < 0 {
|
||||
return nil
|
||||
}
|
||||
shift := utf8.RuneCountInString(plain[:off])
|
||||
out := make([]int, len(l.hits[i]))
|
||||
for j, h := range l.hits[i] {
|
||||
out[j] = h + shift
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// View renders the pane at the size given to SetSize. spin is the current
|
||||
// spinner frame.
|
||||
func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
|
||||
rows := l.rows()
|
||||
inner := max(0, l.w-2)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
if q := l.input.Value(); q != "" && !l.filtering {
|
||||
title += ts.Render(" /" + q)
|
||||
}
|
||||
if l.Loading && len(l.items) > 0 {
|
||||
title += ts.Render(" " + spin)
|
||||
}
|
||||
|
||||
var body []string
|
||||
if l.filtering {
|
||||
body = append(body, l.input.View())
|
||||
}
|
||||
for _, line := range l.header() {
|
||||
body = append(body, st.Notice.Render(" "+line))
|
||||
}
|
||||
|
||||
switch {
|
||||
case l.Loading && len(l.items) == 0:
|
||||
body = append(body, " "+spin+" loading…")
|
||||
case l.Err != nil:
|
||||
for _, line := range wrapLines(l.Err.Error(), inner-1) {
|
||||
body = append(body, st.Error.Render(" "+line))
|
||||
}
|
||||
case len(l.visible) == 0 && len(l.items) > 0:
|
||||
body = append(body, st.Muted.Render(" no matches"))
|
||||
case len(l.visible) == 0:
|
||||
body = append(body, st.Muted.Render(" "+l.Empty))
|
||||
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)
|
||||
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
|
||||
}
|
||||
body = append(body, line)
|
||||
}
|
||||
}
|
||||
|
||||
counter := ""
|
||||
if len(l.visible) > 0 {
|
||||
counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible))
|
||||
}
|
||||
if l.Loading && l.Age != "" && len(l.items) > 0 {
|
||||
counter = "cached " + l.Age + " · " + counter
|
||||
}
|
||||
return BoxCounted(st, title, "", counter, body, l.w, l.h, focused)
|
||||
}
|
||||
|
||||
// scroll keeps the cursor within the visible window.
|
||||
func (l *List[T]) scroll() {
|
||||
rows := l.rows()
|
||||
if rows <= 0 {
|
||||
return
|
||||
}
|
||||
if l.cursor < l.offset {
|
||||
l.offset = l.cursor
|
||||
}
|
||||
if l.cursor >= l.offset+rows {
|
||||
l.offset = l.cursor - rows + 1
|
||||
}
|
||||
l.offset = max(0, min(l.offset, len(l.visible)-rows))
|
||||
}
|
||||
|
||||
// wrapLines wraps text to width, line by line; a "• " item's further
|
||||
// lines line up under its text.
|
||||
func wrapLines(text string, width int) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
indent := ""
|
||||
if strings.HasPrefix(line, "• ") {
|
||||
indent = " "
|
||||
}
|
||||
for i, l := range strings.Split(ansi.Wrap(line, max(1, width-len(indent)), " -/"), "\n") {
|
||||
if i > 0 {
|
||||
l = indent + l
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package panes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
func newStringList(n int) List[string] {
|
||||
l := NewList("Items", Item[string]{
|
||||
ID: func(s string) string { return s },
|
||||
Filter: func(s string) string { return s },
|
||||
Render: func(_ styles.Styles, s string, _ int) string { return s },
|
||||
})
|
||||
var items []string
|
||||
for i := range n {
|
||||
items = append(items, fmt.Sprintf("item-%02d", i))
|
||||
}
|
||||
l.SetItems(items)
|
||||
l.SetSize(20, 7) // 5 rows
|
||||
return l
|
||||
}
|
||||
|
||||
func TestListScrollKeepsCursorVisible(t *testing.T) {
|
||||
l := newStringList(20)
|
||||
l.Move(9)
|
||||
view := ansi.Strip(l.View(styles.Default(), true, ""))
|
||||
if !strings.Contains(view, "item-09") || strings.Contains(view, "item-04") {
|
||||
t.Fatalf("cursor row not scrolled into view:\n%s", view)
|
||||
}
|
||||
// Moving up within the window must not scroll.
|
||||
l.Move(-2)
|
||||
view = ansi.Strip(l.View(styles.Default(), true, ""))
|
||||
if !strings.Contains(view, "item-05") {
|
||||
t.Fatalf("window jumped when moving up inside it:\n%s", view)
|
||||
}
|
||||
l.Top()
|
||||
if l.SelectedID() != "item-00" || l.offset != 0 {
|
||||
t.Fatalf("Top: selected %q offset %d", l.SelectedID(), l.offset)
|
||||
}
|
||||
l.Bottom()
|
||||
if l.SelectedID() != "item-19" || l.offset != 15 {
|
||||
t.Fatalf("Bottom: selected %q offset %d", l.SelectedID(), l.offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSetItemsKeepsSelection(t *testing.T) {
|
||||
l := newStringList(5)
|
||||
l.Move(3)
|
||||
l.SetItems([]string{"new", "item-03", "item-04"})
|
||||
if l.SelectedID() != "item-03" {
|
||||
t.Fatalf("selected = %q", l.SelectedID())
|
||||
}
|
||||
l.SetItems([]string{"other"})
|
||||
if l.SelectedID() != "other" {
|
||||
t.Fatalf("selected = %q after item vanished", l.SelectedID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoxExactSize(t *testing.T) {
|
||||
out := Box(styles.Default(), "a very long title that will not fit", "footer", []string{"x", strings.Repeat("y", 50)}, 12, 5, true)
|
||||
lines := strings.Split(out, "\n")
|
||||
if len(lines) != 5 {
|
||||
t.Fatalf("height = %d", len(lines))
|
||||
}
|
||||
for _, line := range lines {
|
||||
if w := ansi.StringWidth(line); w != 12 {
|
||||
t.Errorf("width %d: %q", w, ansi.Strip(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func filterList(t *testing.T, items []string, query string) List[string] {
|
||||
t.Helper()
|
||||
l := NewList("Items", Item[string]{
|
||||
ID: func(s string) string { return s },
|
||||
Filter: func(s string) string { return s },
|
||||
Render: func(_ styles.Styles, s string, _ int) string { return s },
|
||||
})
|
||||
l.SetItems(items)
|
||||
l.SetSize(40, 10)
|
||||
l.StartFilter()
|
||||
for _, r := range query {
|
||||
l.UpdateFilter(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func visibleNames(l List[string]) []string {
|
||||
var out []string
|
||||
for _, i := range l.visible {
|
||||
out = append(out, l.items[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestFuzzyFilterRanksAndSkips(t *testing.T) {
|
||||
l := filterList(t, []string{"api-key", "db-password", "storage-key", "dbx-pat"}, "dbpw")
|
||||
if got := visibleNames(l); !reflect.DeepEqual(got, []string{"db-password"}) {
|
||||
t.Errorf("dbpw matched %v", got)
|
||||
}
|
||||
l = filterList(t, []string{"monkey", "key-vault", "api-key"}, "key")
|
||||
if got := visibleNames(l); got[0] != "key-vault" || len(got) != 3 {
|
||||
t.Errorf("prefix match not ranked first: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterHighlightsMatches(t *testing.T) {
|
||||
l := filterList(t, []string{"db-password", "api-key"}, "dpw")
|
||||
l.AcceptFilter()
|
||||
view := l.View(styles.Default(), false, "")
|
||||
// d, p and w are styled; with ANSI stripped the text is unchanged.
|
||||
if !strings.Contains(ansi.Strip(view), "db-password") {
|
||||
t.Fatalf("row missing:\n%s", ansi.Strip(view))
|
||||
}
|
||||
if hits := l.highlight(0, "db-password"); !reflect.DeepEqual(hits, []int{0, 3, 7}) {
|
||||
t.Errorf("hits = %v", hits)
|
||||
}
|
||||
// Rows that show the Filter text at an offset get shifted hits.
|
||||
if hits := l.highlight(0, " db-password (disabled)"); !reflect.DeepEqual(hits, []int{2, 5, 9}) {
|
||||
t.Errorf("shifted hits = %v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterKeepsRowColours(t *testing.T) {
|
||||
blue := lipgloss.NewStyle().Foreground(lipgloss.Blue)
|
||||
match := styles.Default().Match
|
||||
|
||||
if got := matchRanges("platform-dev", []int{9, 10, 11}, match); len(got) != 1 || got[0].Start != 9 || got[0].End != 12 {
|
||||
t.Errorf("ranges = %+v, want one range 9-12", got)
|
||||
}
|
||||
|
||||
// Only the matches change style; the rest keeps the row's colour.
|
||||
styled := " " + blue.Render("platform-dev")
|
||||
out := lipgloss.StyleRanges(styled, matchRanges(" platform-dev", []int{11, 12, 13}, match)...)
|
||||
if ansi.Strip(out) != " platform-dev" {
|
||||
t.Fatalf("text changed: %q", ansi.Strip(out))
|
||||
}
|
||||
if !strings.Contains(out, "\x1b[34m") {
|
||||
t.Errorf("unmatched text lost its colour: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCounterInBottomRight(t *testing.T) {
|
||||
out := BoxCounted(styles.Default(), "T", "footer", "3 of 12", []string{"x"}, 30, 3, true)
|
||||
bottom := ansi.Strip(strings.Split(out, "\n")[2])
|
||||
if !strings.HasSuffix(bottom, " 3 of 12 ─╯") || !strings.HasPrefix(bottom, "╰─ footer ") || ansi.StringWidth(bottom) != 30 {
|
||||
t.Errorf("bottom edge = %q", bottom)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightKeepsColours(t *testing.T) {
|
||||
blue := lipgloss.NewStyle().Foreground(lipgloss.Blue)
|
||||
sel := lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true)
|
||||
row := " " + blue.Render("platform-prod") + lipgloss.NewStyle().Faint(true).Render(" note")
|
||||
out := Highlight(row, sel, 30)
|
||||
if ansi.StringWidth(out) != 30 || ansi.Strip(out) != ansi.Strip(row)+strings.Repeat(" ", 30-ansi.StringWidth(row)) {
|
||||
t.Fatalf("text or width changed: %q", ansi.Strip(out))
|
||||
}
|
||||
if !strings.Contains(out, "\x1b[34m") {
|
||||
t.Error("row lost its blue")
|
||||
}
|
||||
// The background is re-applied after every reset, so it spans the row.
|
||||
resets := strings.Count(out, "\x1b[m") + strings.Count(out, "\x1b[0m")
|
||||
if bgs := strings.Count(out, "\x1b[100;1m"); bgs < resets {
|
||||
t.Errorf("background not re-applied after each reset: %d resets, %d backgrounds\n%q", resets, bgs, out)
|
||||
}
|
||||
// Reverse styles fall back to plain text.
|
||||
if got := Highlight(row, lipgloss.NewStyle().Reverse(true), 30); strings.Contains(got, "34m") {
|
||||
t.Error("reverse kept the colours")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListHeader(t *testing.T) {
|
||||
l := NewList("t", Item[string]{
|
||||
ID: func(s string) string { return s },
|
||||
Filter: func(s string) string { return s },
|
||||
Render: func(_ styles.Styles, s string, _ int) string { return s },
|
||||
})
|
||||
l.Header, l.HeaderShort = []string{"ART1", "ART2"}, "short"
|
||||
l.SetItems([]string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"})
|
||||
l.SetSize(30, 2+2+1+6) // the art, a blank line and 6 rows
|
||||
st := styles.Default()
|
||||
if v := l.View(st, true, ""); !strings.Contains(v, "ART1") || strings.Contains(v, "short") {
|
||||
t.Errorf("at the top, the art:\n%s", v)
|
||||
}
|
||||
l.Move(6) // below the rows under the art
|
||||
if v := l.View(st, true, ""); strings.Contains(v, "ART1") || !strings.Contains(v, "short") || !strings.Contains(v, " g") {
|
||||
t.Errorf("further down, the text:\n%s", v)
|
||||
}
|
||||
l.Top()
|
||||
l.SetSize(30, 8) // too short for the art and 5 rows
|
||||
if v := l.View(st, true, ""); strings.Contains(v, "ART1") || !strings.Contains(v, "short") {
|
||||
t.Errorf("no room, the text:\n%s", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package panes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A revealed value is shown as it reads best: JSON indented, and PEM
|
||||
// certificates as what they say (subject, issuer, dates, names) rather
|
||||
// than base64, with any other PEM block, a private key say, named but not
|
||||
// shown. Anything else is shown as it is. y copies it as stored.
|
||||
|
||||
// valueView is how to show v, and what's been done to it ("" when it's
|
||||
// shown as it is).
|
||||
func valueView(v string) (lines []string, note string) {
|
||||
if lines, ok := pemView(v); ok {
|
||||
return lines, "certificate"
|
||||
}
|
||||
if t := strings.TrimSpace(v); (strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[")) && json.Valid([]byte(t)) {
|
||||
var b bytes.Buffer
|
||||
if json.Indent(&b, []byte(t), "", " ") == nil {
|
||||
return strings.Split(b.String(), "\n"), "JSON, formatted"
|
||||
}
|
||||
}
|
||||
return strings.Split(v, "\n"), ""
|
||||
}
|
||||
|
||||
// pemView describes the PEM blocks in v, if it's PEM with a certificate.
|
||||
func pemView(v string) ([]string, bool) {
|
||||
rest := []byte(strings.TrimSpace(v))
|
||||
var out []string
|
||||
certs := 0
|
||||
for len(rest) > 0 {
|
||||
block, after := pem.Decode(rest)
|
||||
if block == nil {
|
||||
return nil, false // text that isn't PEM: show it all
|
||||
}
|
||||
rest = bytes.TrimSpace(after)
|
||||
if len(out) > 0 {
|
||||
out = append(out, "")
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
out = append(out, block.Type+" (not shown)")
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
certs++
|
||||
out = append(out, certLines(cert)...)
|
||||
}
|
||||
return out, certs > 0
|
||||
}
|
||||
|
||||
func certLines(c *x509.Certificate) []string {
|
||||
day := func(t time.Time) string { return t.UTC().Format("2006-01-02") }
|
||||
out := []string{
|
||||
"subject " + c.Subject.String(),
|
||||
"issuer " + c.Issuer.String(),
|
||||
"valid " + day(c.NotBefore) + " to " + day(c.NotAfter),
|
||||
}
|
||||
names := append([]string{}, c.DNSNames...)
|
||||
for _, ip := range c.IPAddresses {
|
||||
names = append(names, ip.String())
|
||||
}
|
||||
names = append(names, c.EmailAddresses...)
|
||||
if len(names) > 0 {
|
||||
out = append(out, "names "+strings.Join(names, ", "))
|
||||
}
|
||||
sum := sha1.Sum(c.Raw) // what Azure and Windows call the thumbprint
|
||||
return append(out, "SHA-1 "+fmt.Sprintf("%X", sum[:]))
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package panes
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValueViewJSON(t *testing.T) {
|
||||
lines, note := valueView(`{"user":"app","port":5432,"tags":["a"]}`)
|
||||
want := []string{"{", ` "user": "app",`, ` "port": 5432,`, ` "tags": [`, ` "a"`, " ]", "}"}
|
||||
if !slices.Equal(lines, want) || note != "JSON, formatted" {
|
||||
t.Errorf("lines %q, note %q", lines, note)
|
||||
}
|
||||
for _, v := range []string{"plain value", "{not json", "42", "line one\nline two"} {
|
||||
if lines, note := valueView(v); note != "" || strings.Join(lines, "\n") != v {
|
||||
t.Errorf("%q shown as %q (%q)", v, lines, note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValueViewCertificate(t *testing.T) {
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "app.example.com"},
|
||||
DNSNames: []string{"app.example.com", "www.app.example.com"},
|
||||
NotBefore: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), NotAfter: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyDER, _ := x509.MarshalECPrivateKey(key)
|
||||
bundle := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +
|
||||
string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
|
||||
|
||||
lines, note := valueView(bundle)
|
||||
text := strings.Join(lines, "\n")
|
||||
for _, want := range []string{"subject CN=app.example.com", "valid 2026-01-01 to 2027-01-01",
|
||||
"names app.example.com, www.app.example.com", "SHA-1 ", "EC PRIVATE KEY (not shown)"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if note != "certificate" || strings.Contains(text, "BEGIN") {
|
||||
t.Errorf("note %q, or PEM shown:\n%s", note, text)
|
||||
}
|
||||
// A private key alone isn't a certificate: shown as it is.
|
||||
if _, note := valueView(string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))); note != "" {
|
||||
t.Errorf("a key alone: note %q", note)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Package styles holds the lipgloss theme. It sticks to the 16 ANSI colours
|
||||
// so it follows the user's terminal palette in both light and dark themes.
|
||||
package styles
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
type Styles struct {
|
||||
BorderFocused lipgloss.Style
|
||||
BorderBlurred lipgloss.Style
|
||||
TitleFocused lipgloss.Style
|
||||
TitleBlurred lipgloss.Style
|
||||
Footer lipgloss.Style
|
||||
|
||||
Selected lipgloss.Style // cursor row in the focused pane
|
||||
SelectedBlurred lipgloss.Style // cursor row in other panes
|
||||
Muted lipgloss.Style
|
||||
Error lipgloss.Style
|
||||
Notice lipgloss.Style
|
||||
Success lipgloss.Style // the ✓ before confirmations
|
||||
Warning lipgloss.Style // "can't do that here" messages
|
||||
Match lipgloss.Style // filter matches
|
||||
Favourite lipgloss.Style
|
||||
Prompt lipgloss.Style // questions in the footer
|
||||
Label lipgloss.Style
|
||||
Masked lipgloss.Style
|
||||
Value lipgloss.Style
|
||||
|
||||
KindAzure lipgloss.Style
|
||||
KindDatabricks lipgloss.Style
|
||||
KindRepo lipgloss.Style
|
||||
|
||||
// themed marks the selection colours as set by the theme, so
|
||||
// OnBackground leaves them.
|
||||
themed, themedBlurred bool
|
||||
}
|
||||
|
||||
func Default() Styles {
|
||||
green := lipgloss.Green
|
||||
return Styles{
|
||||
BorderFocused: lipgloss.NewStyle().Foreground(green),
|
||||
BorderBlurred: lipgloss.NewStyle().Faint(true),
|
||||
TitleFocused: lipgloss.NewStyle().Foreground(green).Bold(true),
|
||||
TitleBlurred: lipgloss.NewStyle(),
|
||||
Footer: lipgloss.NewStyle().Faint(true),
|
||||
|
||||
// A background rather than reverse, so the row keeps its colours;
|
||||
// in every pane, so each shows what's selected in it. The focused
|
||||
// pane's is bold too. Palette 8 until the terminal says what its
|
||||
// background is (OnBackground).
|
||||
Selected: lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true),
|
||||
SelectedBlurred: lipgloss.NewStyle().Background(lipgloss.BrightBlack),
|
||||
Muted: lipgloss.NewStyle().Faint(true),
|
||||
Error: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
||||
Notice: lipgloss.NewStyle().Foreground(lipgloss.Green),
|
||||
Success: lipgloss.NewStyle().Foreground(lipgloss.Green).Bold(true),
|
||||
Warning: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
|
||||
Match: lipgloss.NewStyle().Foreground(lipgloss.Yellow).Bold(true),
|
||||
Favourite: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
|
||||
Prompt: lipgloss.NewStyle().Foreground(lipgloss.Yellow).Bold(true),
|
||||
Label: lipgloss.NewStyle().Foreground(lipgloss.Blue).Bold(true),
|
||||
Masked: lipgloss.NewStyle().Faint(true),
|
||||
Value: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
|
||||
|
||||
KindAzure: lipgloss.NewStyle().Foreground(lipgloss.Blue),
|
||||
KindDatabricks: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
||||
KindRepo: lipgloss.NewStyle().Foreground(lipgloss.Magenta),
|
||||
}
|
||||
}
|
||||
|
||||
// Kind is the colour of a provider's sources, vaults and paths.
|
||||
func (s Styles) Kind(k provider.Kind) lipgloss.Style {
|
||||
switch k {
|
||||
case provider.KindDatabricks:
|
||||
return s.KindDatabricks
|
||||
case provider.KindRepo:
|
||||
return s.KindRepo
|
||||
}
|
||||
return s.KindAzure
|
||||
}
|
||||
|
||||
// OnBackground shades the selection colours from the terminal's
|
||||
// background, a little lighter on a dark one and darker on a light one:
|
||||
// a quiet band that keeps the row's colours readable, where palette 8 is
|
||||
// often too bright. Colours the theme sets are kept.
|
||||
func (s Styles) OnBackground(bg color.Color, dark bool) Styles {
|
||||
shade := func(p float64) color.Color {
|
||||
if dark {
|
||||
return lipgloss.Lighten(bg, p)
|
||||
}
|
||||
return lipgloss.Darken(bg, p)
|
||||
}
|
||||
if !s.themed {
|
||||
s.Selected = s.Selected.Background(shade(0.09))
|
||||
}
|
||||
if !s.themedBlurred {
|
||||
s.SelectedBlurred = s.SelectedBlurred.Background(shade(0.05))
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package styles
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// A theme maps roles to a colour and attributes, like lazygit's gui.theme:
|
||||
//
|
||||
// activeBorderColor: ["#e0af68", bold]
|
||||
// selectedLineBgColor: ["#2d3f76"]
|
||||
//
|
||||
// Role names follow lazygit where the meaning is the same, so an editor
|
||||
// plugin can map them from highlight groups the way snacks.nvim does for
|
||||
// lazygit. Colours are hex (#rrggbb), a palette number (0-255) or a name
|
||||
// (black, red, green, yellow, blue, magenta, cyan, white, their bright
|
||||
// variants like brightred, or default). Attributes: bold, italic,
|
||||
// underline, reverse, faint.
|
||||
|
||||
// Roles lists the theme roles with what they colour.
|
||||
var Roles = map[string]string{
|
||||
"activeBorderColor": "border and title of the focused pane",
|
||||
"inactiveBorderColor": "borders of other panes",
|
||||
"selectedLineBgColor": "background of the cursor row in the focused pane",
|
||||
"inactiveViewSelectedLineBgColor": "background of the cursor row in other panes",
|
||||
"defaultFgColor": "default text colour",
|
||||
"optionsTextColor": "keys in the help bar and menus, and labels in the detail pane",
|
||||
"searchMatchColor": "matched characters when filtering",
|
||||
"errorColor": "✗ errors",
|
||||
"noticeColor": "notices, the clipboard countdown and current versions",
|
||||
"successColor": "the ✓ before confirmations (copied, opened…)",
|
||||
"warningColor": "⚠ messages: what can't be done here, and why",
|
||||
"mutedColor": "secondary text",
|
||||
"favouriteColor": "stars and questions",
|
||||
"valueColor": "revealed values",
|
||||
"azureColor": "Azure sources and vaults",
|
||||
"databricksColor": "Databricks workspaces and scopes",
|
||||
"repoColor": "repos and their secret files (SOPS)",
|
||||
}
|
||||
|
||||
// FromTheme returns the default styles with the theme's roles applied, and
|
||||
// the default text colour if the theme sets one.
|
||||
func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
||||
st := Default()
|
||||
var fg color.Color
|
||||
roles := make([]string, 0, len(theme))
|
||||
for r := range theme {
|
||||
roles = append(roles, r)
|
||||
}
|
||||
sort.Strings(roles)
|
||||
for _, role := range roles {
|
||||
if _, ok := Roles[role]; !ok {
|
||||
return st, nil, fmt.Errorf("theme: unknown role %q (known: %s)", role, strings.Join(sortedRoles(), ", "))
|
||||
}
|
||||
v, err := parse(theme[role])
|
||||
if err != nil {
|
||||
return st, nil, fmt.Errorf("theme: %s: %w", role, err)
|
||||
}
|
||||
switch role {
|
||||
case "activeBorderColor":
|
||||
st.BorderFocused = v.fg(lipgloss.NewStyle())
|
||||
st.TitleFocused = v.fg(lipgloss.NewStyle()).Bold(true)
|
||||
case "inactiveBorderColor":
|
||||
st.BorderBlurred = v.fg(lipgloss.NewStyle())
|
||||
case "selectedLineBgColor":
|
||||
st.Selected = v.bg(lipgloss.NewStyle()).Bold(true)
|
||||
st.themed = true
|
||||
case "inactiveViewSelectedLineBgColor":
|
||||
st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true)
|
||||
st.themedBlurred = true
|
||||
case "defaultFgColor":
|
||||
fg = v.color
|
||||
case "optionsTextColor":
|
||||
st.Label = v.fg(lipgloss.NewStyle())
|
||||
case "searchMatchColor":
|
||||
st.Match = v.fg(lipgloss.NewStyle())
|
||||
case "errorColor":
|
||||
st.Error = v.fg(lipgloss.NewStyle())
|
||||
case "noticeColor":
|
||||
st.Notice = v.fg(lipgloss.NewStyle())
|
||||
case "warningColor":
|
||||
st.Warning = v.fg(lipgloss.NewStyle())
|
||||
case "successColor":
|
||||
st.Success = v.fg(lipgloss.NewStyle()).Bold(true)
|
||||
case "mutedColor":
|
||||
st.Muted = v.fg(lipgloss.NewStyle())
|
||||
st.Masked, st.Footer = st.Muted, st.Muted
|
||||
case "favouriteColor":
|
||||
st.Favourite = v.fg(lipgloss.NewStyle())
|
||||
st.Prompt = v.fg(lipgloss.NewStyle()).Bold(true)
|
||||
case "valueColor":
|
||||
st.Value = v.fg(lipgloss.NewStyle())
|
||||
case "azureColor":
|
||||
st.KindAzure = v.fg(lipgloss.NewStyle())
|
||||
case "databricksColor":
|
||||
st.KindDatabricks = v.fg(lipgloss.NewStyle())
|
||||
case "repoColor":
|
||||
st.KindRepo = v.fg(lipgloss.NewStyle())
|
||||
}
|
||||
}
|
||||
return st, fg, nil
|
||||
}
|
||||
|
||||
func sortedRoles() []string {
|
||||
var out []string
|
||||
for r := range Roles {
|
||||
out = append(out, r)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// value is a parsed role: a colour (nil for default) and attributes.
|
||||
type value struct {
|
||||
color color.Color
|
||||
bold, italic, underline, reverse, faint bool
|
||||
}
|
||||
|
||||
// fg styles s with the value as foreground, bg as background.
|
||||
func (v value) fg(s lipgloss.Style) lipgloss.Style {
|
||||
if v.color != nil {
|
||||
s = s.Foreground(v.color)
|
||||
}
|
||||
return v.attrs(s)
|
||||
}
|
||||
|
||||
func (v value) bg(s lipgloss.Style) lipgloss.Style {
|
||||
if v.color != nil {
|
||||
s = s.Background(v.color)
|
||||
}
|
||||
return v.attrs(s)
|
||||
}
|
||||
|
||||
func (v value) attrs(s lipgloss.Style) lipgloss.Style {
|
||||
return s.Bold(v.bold).Italic(v.italic).Underline(v.underline).Reverse(v.reverse).Faint(v.faint)
|
||||
}
|
||||
|
||||
var names = map[string]color.Color{
|
||||
"black": lipgloss.Black, "red": lipgloss.Red, "green": lipgloss.Green, "yellow": lipgloss.Yellow,
|
||||
"blue": lipgloss.Blue, "magenta": lipgloss.Magenta, "cyan": lipgloss.Cyan, "white": lipgloss.White,
|
||||
"brightblack": lipgloss.BrightBlack, "brightred": lipgloss.BrightRed, "brightgreen": lipgloss.BrightGreen,
|
||||
"brightyellow": lipgloss.BrightYellow, "brightblue": lipgloss.BrightBlue,
|
||||
"brightmagenta": lipgloss.BrightMagenta, "brightcyan": lipgloss.BrightCyan, "brightwhite": lipgloss.BrightWhite,
|
||||
}
|
||||
|
||||
func parse(items []string) (value, error) {
|
||||
var v value
|
||||
for _, it := range items {
|
||||
s := strings.ToLower(strings.TrimSpace(it))
|
||||
switch s {
|
||||
case "bold":
|
||||
v.bold = true
|
||||
case "italic":
|
||||
v.italic = true
|
||||
case "underline":
|
||||
v.underline = true
|
||||
case "reverse":
|
||||
v.reverse = true
|
||||
case "faint", "dim":
|
||||
v.faint = true
|
||||
case "default", "":
|
||||
// the terminal's own colour
|
||||
default:
|
||||
c, err := parseColor(s)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.color = c
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func parseColor(s string) (color.Color, error) {
|
||||
if c, ok := names[s]; ok {
|
||||
return c, nil
|
||||
}
|
||||
if strings.HasPrefix(s, "#") {
|
||||
if len(s) != 7 {
|
||||
return nil, fmt.Errorf("%q: want #rrggbb", s)
|
||||
}
|
||||
if _, err := strconv.ParseUint(s[1:], 16, 32); err != nil {
|
||||
return nil, fmt.Errorf("%q: want #rrggbb", s)
|
||||
}
|
||||
return lipgloss.Color(s), nil
|
||||
}
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
if n < 0 || n > 255 {
|
||||
return nil, fmt.Errorf("%q: palette numbers are 0-255", s)
|
||||
}
|
||||
return lipgloss.Color(s), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%q isn't a colour or attribute", s)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package styles
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func TestFromTheme(t *testing.T) {
|
||||
st, fg, err := FromTheme(map[string][]string{
|
||||
"activeBorderColor": {"#e0af68", "bold"},
|
||||
"selectedLineBgColor": {"#2d3f76"},
|
||||
"defaultFgColor": {"#c8d3f5"},
|
||||
"azureColor": {"75"},
|
||||
"databricksColor": {"brightred"},
|
||||
"searchMatchColor": {"default", "underline"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fg == nil {
|
||||
t.Error("defaultFgColor not returned")
|
||||
}
|
||||
if got := st.BorderFocused.GetForeground(); got != lipgloss.Color("#e0af68") || !st.BorderFocused.GetBold() {
|
||||
t.Errorf("active border = %v bold=%v", got, st.BorderFocused.GetBold())
|
||||
}
|
||||
// The theme's selected-line background replaces the default one.
|
||||
if st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.Color("#2d3f76") {
|
||||
t.Errorf("selected = reverse %v bg %v", st.Selected.GetReverse(), st.Selected.GetBackground())
|
||||
}
|
||||
if st.KindAzure.GetForeground() != lipgloss.Color("75") || st.KindDatabricks.GetForeground() != lipgloss.BrightRed {
|
||||
t.Error("provider colours not applied")
|
||||
}
|
||||
if !st.Match.GetUnderline() {
|
||||
t.Error("attribute without colour not applied")
|
||||
}
|
||||
// Roles the theme leaves out keep their defaults.
|
||||
if st.Error.GetForeground() != lipgloss.Red {
|
||||
t.Error("default error colour changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromThemeRejectsMistakes(t *testing.T) {
|
||||
for name, theme := range map[string]map[string][]string{
|
||||
"role": {"activeBorder": {"red"}},
|
||||
"colour": {"errorColor": {"redish"}},
|
||||
"hex": {"errorColor": {"#fff"}},
|
||||
"palette": {"errorColor": {"300"}},
|
||||
} {
|
||||
if _, _, err := FromTheme(theme); err == nil {
|
||||
t.Errorf("%s: no error", name)
|
||||
} else if name == "role" && !strings.Contains(err.Error(), "activeBorderColor") {
|
||||
t.Errorf("unknown role error should list the known ones: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyThemeIsDefault(t *testing.T) {
|
||||
st, fg, err := FromTheme(nil)
|
||||
if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack {
|
||||
t.Errorf("empty theme: %v %v reverse=%v bg=%v", err, fg, st.Selected.GetReverse(), st.Selected.GetBackground())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnBackground(t *testing.T) {
|
||||
hex := func(c any) string {
|
||||
r, g, b, _ := c.(interface{ RGBA() (r, g, b, a uint32) }).RGBA()
|
||||
return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
|
||||
}
|
||||
st := Default().OnBackground(lipgloss.Color("#282c34"), true)
|
||||
if got := hex(st.Selected.GetBackground()); got != "#3e424a" {
|
||||
t.Errorf("dark: selected %s", got)
|
||||
}
|
||||
if got := hex(st.SelectedBlurred.GetBackground()); got != "#343840" {
|
||||
t.Errorf("dark: blurred %s", got)
|
||||
}
|
||||
if got := hex(Default().OnBackground(lipgloss.Color("#ffffff"), false).Selected.GetBackground()); got != "#e8e8e8" {
|
||||
t.Errorf("light: selected %s", got)
|
||||
}
|
||||
|
||||
themed, _, err := FromTheme(map[string][]string{"selectedLineBgColor": {"#2d3f76"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st = themed.OnBackground(lipgloss.Color("#282c34"), true)
|
||||
if got := hex(st.Selected.GetBackground()); got != "#2d3f76" {
|
||||
t.Errorf("themed: selected %s (want the theme's)", got)
|
||||
}
|
||||
if got := hex(st.SelectedBlurred.GetBackground()); got != "#343840" {
|
||||
t.Errorf("themed: blurred %s (not themed: shaded)", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user