feat: repos with SOPS and .env files in --demo, and a shorter feature list
#21
No files matched your search
@@ -10,7 +10,7 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
|
|||||||
CLI, `sops`, `git`). No config file needed.
|
CLI, `sops`, `git`). No config file needed.
|
||||||
- **Search everything**: `ctrl+s` searches every source at once, with
|
- **Search everything**: `ctrl+s` searches every source at once, with
|
||||||
fzf-style matching.
|
fzf-style matching.
|
||||||
- **Share links**: Share `lazysecrets://` links with colleages. A colleague opens it
|
- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it
|
||||||
with their own access, from the terminal or with a click in the browser
|
with their own access, from the terminal or with a click in the browser
|
||||||
or chat.
|
or chat.
|
||||||
- **Careful with values**: values stay hidden until you reveal them, and
|
- **Careful with values**: values stay hidden until you reveal them, and
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cmp"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"maps"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"text/tabwriter"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
|
||||||
)
|
|
||||||
|
|
||||||
// lazysecrets expiring lists what has expired, or will within --days,
|
|
||||||
// in every vault of every account: for a pipeline or a cron job. Expiry
|
|
||||||
// dates come with the lists, so it's one request per vault and type,
|
|
||||||
// a few at a time. Only Key Vault has expiry dates.
|
|
||||||
|
|
||||||
// The exit status is grep's: 1 when anything was listed (errExpiring),
|
|
||||||
// else 2 when some lists couldn't be read (errIncomplete), else 0.
|
|
||||||
var (
|
|
||||||
errExpiring = errors.New("expiring")
|
|
||||||
errIncomplete = errors.New("incomplete")
|
|
||||||
)
|
|
||||||
|
|
||||||
// listLimit is how many lists load at once.
|
|
||||||
const listLimit = 8
|
|
||||||
|
|
||||||
type expiringOpts struct {
|
|
||||||
days int
|
|
||||||
timeout time.Duration
|
|
||||||
now time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseExpiring(args []string, days int) (int, error) {
|
|
||||||
fs := flag.NewFlagSet("expiring", flag.ContinueOnError)
|
|
||||||
n := fs.Int("days", days, "list what expires within this many days (default: expiryWarningDays, else 30)")
|
|
||||||
if err := fs.Parse(args); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if fs.NArg() > 0 || *n < 0 {
|
|
||||||
return 0, fmt.Errorf("usage: lazysecrets expiring [--days N]")
|
|
||||||
}
|
|
||||||
return *n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func expiring(providers []provider.Provider, o expiringOpts, out, errs io.Writer) error {
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
found []provider.Object
|
|
||||||
seen = map[string]bool{}
|
|
||||||
failed int
|
|
||||||
vaults int
|
|
||||||
wg sync.WaitGroup
|
|
||||||
limit = make(chan struct{}, listLimit)
|
|
||||||
within = o.now.AddDate(0, 0, o.days)
|
|
||||||
// A vault's lists that couldn't be read, said once per vault.
|
|
||||||
denied = map[string][]string{}
|
|
||||||
why = map[string]string{}
|
|
||||||
warnErr = func(what string, err error) {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
failed++
|
|
||||||
fmt.Fprintf(errs, "lazysecrets: %s: %s\n", what, briefly(err))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
run := func(f func(ctx context.Context)) {
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
limit <- struct{}{}
|
|
||||||
defer func() { <-limit }()
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), o.timeout)
|
|
||||||
defer cancel()
|
|
||||||
f(ctx)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
var vaultsDone sync.WaitGroup
|
|
||||||
for _, p := range providers {
|
|
||||||
if p.Kind() != provider.KindAzure {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
vaultsDone.Add(1)
|
|
||||||
run(func(ctx context.Context) {
|
|
||||||
defer vaultsDone.Done()
|
|
||||||
srcs, err := p.Sources(ctx)
|
|
||||||
if err != nil {
|
|
||||||
warnErr(p.Label(), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, src := range srcs {
|
|
||||||
vaultsDone.Add(1)
|
|
||||||
run(func(ctx context.Context) {
|
|
||||||
defer vaultsDone.Done()
|
|
||||||
cs, err := p.Containers(ctx, src)
|
|
||||||
if err != nil {
|
|
||||||
warnErr(src.Name, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, c := range cs {
|
|
||||||
mu.Lock()
|
|
||||||
fresh := !seen[c.ID]
|
|
||||||
seen[c.ID] = true
|
|
||||||
if fresh {
|
|
||||||
vaults++
|
|
||||||
}
|
|
||||||
mu.Unlock()
|
|
||||||
if !fresh { // the same vault through another account
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, t := range c.ObjectTypes() {
|
|
||||||
run(func(ctx context.Context) {
|
|
||||||
objs, err := p.Objects(ctx, c, t)
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
if err != nil {
|
|
||||||
failed++
|
|
||||||
denied[c.Name] = append(denied[c.Name], strings.ToLower(string(t))+"s")
|
|
||||||
why[c.Name] = cmp.Or(why[c.Name], briefly(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, o := range objs {
|
|
||||||
if !o.Expires.IsZero() && o.Expires.Before(within) {
|
|
||||||
found = append(found, o)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// Listing vaults queues the lists of their secrets, so wait for both.
|
|
||||||
vaultsDone.Wait()
|
|
||||||
wg.Wait()
|
|
||||||
for _, name := range slices.Sorted(maps.Keys(denied)) {
|
|
||||||
fmt.Fprintf(errs, "lazysecrets: %s: can't list %s: %s\n", name, strings.Join(denied[name], ", "), why[name])
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.SortFunc(found, func(a, b provider.Object) int { return a.Expires.Compare(b.Expires) })
|
|
||||||
if len(found) == 0 {
|
|
||||||
vault := "vaults"
|
|
||||||
if vaults == 1 {
|
|
||||||
vault = "vault"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(errs, "nothing expires within %d days (checked %d of %d %s)\n", o.days, vaults-len(denied), vaults, vault)
|
|
||||||
if failed > 0 {
|
|
||||||
fmt.Fprintf(errs, "lazysecrets: %d lists couldn't be read: they weren't checked\n", failed)
|
|
||||||
return errIncomplete
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
|
||||||
fmt.Fprintln(tw, "LEFT\tEXPIRES\tTYPE\tVAULT\tNAME\tLINK")
|
|
||||||
for _, x := range found {
|
|
||||||
left := "expired"
|
|
||||||
if d := x.Expires.Sub(o.now); d > 0 {
|
|
||||||
left = fmt.Sprintf("%dd", int(d.Hours()/24))
|
|
||||||
}
|
|
||||||
link := ""
|
|
||||||
if r, err := ref.ForObject(x, ""); err == nil {
|
|
||||||
link = r.String()
|
|
||||||
}
|
|
||||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", left, x.Expires.Local().Format("2006-01-02"),
|
|
||||||
strings.ToLower(string(x.Type)), x.Container.Name, x.Name, link)
|
|
||||||
}
|
|
||||||
if err := tw.Flush(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if failed > 0 {
|
|
||||||
fmt.Fprintf(errs, "lazysecrets: %d lists couldn't be read: the list may be incomplete\n", failed)
|
|
||||||
}
|
|
||||||
return errExpiring
|
|
||||||
}
|
|
||||||
|
|
||||||
// briefly is err on one line: for a sign-in, the command that signs in.
|
|
||||||
func briefly(err error) string {
|
|
||||||
var auth *provider.AuthError
|
|
||||||
if errors.As(err, &auth) && len(auth.Login) > 0 {
|
|
||||||
return "sign-in required: " + strings.Join(auth.Login, " ")
|
|
||||||
}
|
|
||||||
line, _, _ := strings.Cut(err.Error(), "\n")
|
|
||||||
return line
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestExpiring(t *testing.T) {
|
|
||||||
providers := []provider.Provider{fake.Azure(0), fake.Databricks(0)}
|
|
||||||
now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
var out, errs bytes.Buffer
|
|
||||||
err := expiring(providers, expiringOpts{days: 30, timeout: time.Minute, now: now}, &out, &errs)
|
|
||||||
if !errors.Is(err, errExpiring) {
|
|
||||||
t.Fatalf("err %v (want errExpiring: something listed)", err)
|
|
||||||
}
|
|
||||||
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
|
||||||
if len(lines) != 2 || !strings.HasPrefix(lines[1], "expired 2026-03-01 certificate kv-app-prod old-tls") ||
|
|
||||||
!strings.Contains(lines[1], "lazysecrets://azure/kv-app-prod.vault.azure.net/certificates/old-tls") {
|
|
||||||
t.Errorf("out:\n%s", out.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(errs.String(), "kv-restricted: can't list ") || strings.Count(errs.String(), "kv-restricted") != 1 {
|
|
||||||
t.Errorf("unreadable vaults not said:\n%s", errs.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Further ahead, the soonest first.
|
|
||||||
out.Reset()
|
|
||||||
expiring(providers, expiringOpts{days: 400, timeout: time.Minute, now: now}, &out, &errs)
|
|
||||||
if got := strings.Count(out.String(), "\n"); got != 4 || !strings.Contains(out.String(), "app-tls") {
|
|
||||||
t.Errorf("400 days:\n%s", out.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing listed, but not everything checked.
|
|
||||||
errs.Reset()
|
|
||||||
err = expiring(providers, expiringOpts{days: 0, timeout: time.Minute, now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)}, &out, &errs)
|
|
||||||
if !errors.Is(err, errIncomplete) {
|
|
||||||
t.Errorf("err %v, want errIncomplete", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := parseExpiring([]string{"--days", "-1"}, 30); err == nil {
|
|
||||||
t.Error("negative days accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-23
@@ -4,7 +4,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -34,12 +33,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
switch err := run(); {
|
if err := run(); err != nil {
|
||||||
case errors.Is(err, errExpiring):
|
|
||||||
os.Exit(1) // listed: a pipeline fails on it
|
|
||||||
case errors.Is(err, errIncomplete):
|
|
||||||
os.Exit(2)
|
|
||||||
case err != nil:
|
|
||||||
fmt.Fprintln(os.Stderr, "lazysecrets:", err)
|
fmt.Fprintln(os.Stderr, "lazysecrets:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -124,10 +118,6 @@ func run() error {
|
|||||||
@Microsoft.KeyVault(...) or {{secrets/scope/key}}
|
@Microsoft.KeyVault(...) or {{secrets/scope/key}}
|
||||||
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
|
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
|
||||||
for scripts: VAR=$(lazysecrets get <ref>)
|
for scripts: VAR=$(lazysecrets get <ref>)
|
||||||
lazysecrets [flags] expiring [--days N]
|
|
||||||
list what has expired or will within N days
|
|
||||||
(default 30) in every vault; exits 1 if any,
|
|
||||||
2 if it couldn't check everything
|
|
||||||
lazysecrets [flags] keybindings print every action and its keys (with your
|
lazysecrets [flags] keybindings print every action and its keys (with your
|
||||||
config applied), as config to copy and edit
|
config applied), as config to copy and edit
|
||||||
lazysecrets handler install [--terminal NAME]
|
lazysecrets handler install [--terminal NAME]
|
||||||
@@ -150,7 +140,6 @@ Flags:
|
|||||||
}
|
}
|
||||||
|
|
||||||
var open, getRef *ref.Ref
|
var open, getRef *ref.Ref
|
||||||
var expiringArgs []string
|
|
||||||
printKeys := false
|
printKeys := false
|
||||||
switch args := flag.Args(); {
|
switch args := flag.Args(); {
|
||||||
case len(args) == 1 && args[0] == "keybindings":
|
case len(args) == 1 && args[0] == "keybindings":
|
||||||
@@ -161,8 +150,6 @@ Flags:
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
getRef = &r
|
getRef = &r
|
||||||
case len(args) > 0 && args[0] == "expiring":
|
|
||||||
expiringArgs = append([]string{}, args[1:]...)
|
|
||||||
case len(args) > 0 && args[0] == "handler":
|
case len(args) > 0 && args[0] == "handler":
|
||||||
return handler(*configPath, args[1:])
|
return handler(*configPath, args[1:])
|
||||||
case len(args) > 0 && args[0] == "launch":
|
case len(args) > 0 && args[0] == "launch":
|
||||||
@@ -312,15 +299,6 @@ Flags:
|
|||||||
// the standard logger, which would draw over the TUI.
|
// the standard logger, which would draw over the TUI.
|
||||||
log.SetOutput(io.Discard)
|
log.SetOutput(io.Discard)
|
||||||
|
|
||||||
if expiringArgs != nil {
|
|
||||||
days, err := parseExpiring(expiringArgs, expiryDays)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "lazysecrets:", err)
|
|
||||||
return errIncomplete // 2, not 1: nothing was checked
|
|
||||||
}
|
|
||||||
return expiring(providers, expiringOpts{days: days, timeout: *timeout, now: time.Now()}, os.Stdout, os.Stderr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if getRef != nil {
|
if getRef != nil {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -71,21 +71,6 @@ on one goes to it. That makes no requests of its own: expiry dates come
|
|||||||
with the lists. It says how many vaults it covers; `ctrl+a` in search
|
with the lists. It says how many vaults it covers; `ctrl+a` in search
|
||||||
loads the rest.
|
loads the rest.
|
||||||
|
|
||||||
For a pipeline or a cron job, `lazysecrets expiring` lists them in every
|
|
||||||
vault of every account, loading each vault's lists (a few at a time):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ lazysecrets expiring --days 30
|
|
||||||
LEFT EXPIRES TYPE VAULT NAME LINK
|
|
||||||
expired 2026-03-01 certificate kv-app-prod old-tls lazysecrets://azure/kv-app-prod.vault.azure.net/certificates/old-tls
|
|
||||||
12d 2026-10-23 secret kv-app-prod api-key lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key
|
|
||||||
```
|
|
||||||
|
|
||||||
`--days` defaults to `expiryWarningDays`. Accounts that need a sign-in and
|
|
||||||
lists it can't read are named on stderr, one line each. Like grep, it
|
|
||||||
exits 0 when nothing expires, 1 when it lists something, and 2 when it
|
|
||||||
couldn't check everything.
|
|
||||||
|
|
||||||
On a vault, the right pane shows its settings: resource group, location,
|
On a vault, the right pane shows its settings: resource group, location,
|
||||||
SKU, whether access is by Azure roles or access policies, soft delete and
|
SKU, whether access is by Azure roles or access policies, soft delete and
|
||||||
purge protection, which networks can reach it, private endpoints and tags.
|
purge protection, which networks can reach it, private endpoints and tags.
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@ theme:
|
|||||||
activeBorderColor: ["#ff966c", bold] # focused pane
|
activeBorderColor: ["#ff966c", bold] # focused pane
|
||||||
inactiveBorderColor: ["#589ed7"]
|
inactiveBorderColor: ["#589ed7"]
|
||||||
selectedLineBgColor: ["#2d3f76"] # cursor row background (default: palette 8)
|
selectedLineBgColor: ["#2d3f76"] # cursor row background (default: palette 8)
|
||||||
inactiveViewSelectedLineBgColor: ["#2f334d"]
|
inactiveViewSelectedLineBgColor: ["#2f334d"] # in the other panes (default: palette 8, not bold)
|
||||||
defaultFgColor: ["#c8d3f5"]
|
defaultFgColor: ["#c8d3f5"]
|
||||||
optionsTextColor: ["#82aaff"] # keys and labels
|
optionsTextColor: ["#82aaff"] # keys and labels
|
||||||
searchMatchColor: ["#ffc777", bold]
|
searchMatchColor: ["#ffc777", bold]
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
if last, ok := git("log", "-1", "--format=%h · %ar · %an: %s", "--", rel); ok && last != "" {
|
if last, ok := git("log", "-1", "--format=%h · %ar", "--", rel); ok && last != "" {
|
||||||
out = append(out, provider.Attr{Label: "last commit", Value: last})
|
out = append(out, provider.Attr{Label: "last commit", Value: last})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -505,7 +505,7 @@ func TestFileDetails(t *testing.T) {
|
|||||||
|
|
||||||
d := details("secrets/app.yaml")
|
d := details("secrets/app.yaml")
|
||||||
if d["encryption"].Value != "sops · age" || d["keys"].Value != "age age1xxxx" || d["git"].Value != "tracked" ||
|
if d["encryption"].Value != "sops · age" || d["keys"].Value != "age age1xxxx" || d["git"].Value != "tracked" ||
|
||||||
!strings.Contains(d["last commit"].Value, "t: files") || d["encrypted"].Value == "" {
|
!strings.HasSuffix(d["last commit"].Value, " ago") || strings.Contains(d["last commit"].Value, ":") || d["encrypted"].Value == "" {
|
||||||
t.Errorf("sops yaml: %+v", d)
|
t.Errorf("sops yaml: %+v", d)
|
||||||
}
|
}
|
||||||
if d := details("prod.env"); d["keys"].Value != "age age1xxxx" {
|
if d := details("prod.env"); d["keys"].Value != "age age1xxxx" {
|
||||||
|
|||||||
@@ -489,7 +489,7 @@ func (m *Model) setVersions(items []provider.Object) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard()}
|
cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard(), tea.RequestBackgroundColor}
|
||||||
if m.cachePath != "" || m.placeDir != "" {
|
if m.cachePath != "" || m.placeDir != "" {
|
||||||
cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} }))
|
cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} }))
|
||||||
}
|
}
|
||||||
@@ -514,6 +514,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case tea.BackgroundColorMsg:
|
||||||
|
m.st = m.st.OnBackground(msg.Color, msg.IsDark())
|
||||||
|
return m, nil
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.w, m.h = msg.Width, msg.Height
|
m.w, m.h = msg.Width, msg.Height
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
package styles
|
package styles
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"image/color"
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
@@ -32,6 +34,10 @@ type Styles struct {
|
|||||||
KindAzure lipgloss.Style
|
KindAzure lipgloss.Style
|
||||||
KindDatabricks lipgloss.Style
|
KindDatabricks lipgloss.Style
|
||||||
KindRepo 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 {
|
func Default() Styles {
|
||||||
@@ -43,9 +49,12 @@ func Default() Styles {
|
|||||||
TitleBlurred: lipgloss.NewStyle(),
|
TitleBlurred: lipgloss.NewStyle(),
|
||||||
Footer: lipgloss.NewStyle().Faint(true),
|
Footer: lipgloss.NewStyle().Faint(true),
|
||||||
|
|
||||||
// A background rather than reverse, so the row keeps its colours.
|
// 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),
|
Selected: lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true),
|
||||||
SelectedBlurred: lipgloss.NewStyle().Bold(true),
|
SelectedBlurred: lipgloss.NewStyle().Background(lipgloss.BrightBlack),
|
||||||
Muted: lipgloss.NewStyle().Faint(true),
|
Muted: lipgloss.NewStyle().Faint(true),
|
||||||
Error: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
Error: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
||||||
Notice: lipgloss.NewStyle().Foreground(lipgloss.Green),
|
Notice: lipgloss.NewStyle().Foreground(lipgloss.Green),
|
||||||
@@ -74,3 +83,23 @@ func (s Styles) Kind(k provider.Kind) lipgloss.Style {
|
|||||||
}
|
}
|
||||||
return s.KindAzure
|
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
|
||||||
|
}
|
||||||
@@ -70,8 +70,10 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
|||||||
st.BorderBlurred = v.fg(lipgloss.NewStyle())
|
st.BorderBlurred = v.fg(lipgloss.NewStyle())
|
||||||
case "selectedLineBgColor":
|
case "selectedLineBgColor":
|
||||||
st.Selected = v.bg(lipgloss.NewStyle()).Bold(true)
|
st.Selected = v.bg(lipgloss.NewStyle()).Bold(true)
|
||||||
|
st.themed = true
|
||||||
case "inactiveViewSelectedLineBgColor":
|
case "inactiveViewSelectedLineBgColor":
|
||||||
st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true)
|
st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true)
|
||||||
|
st.themedBlurred = true
|
||||||
case "defaultFgColor":
|
case "defaultFgColor":
|
||||||
fg = v.color
|
fg = v.color
|
||||||
case "optionsTextColor":
|
case "optionsTextColor":
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package styles
|
package styles
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -62,3 +63,32 @@ func TestEmptyThemeIsDefault(t *testing.T) {
|
|||||||
t.Errorf("empty theme: %v %v reverse=%v bg=%v", err, fg, st.Selected.GetReverse(), st.Selected.GetBackground())
|
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