summaryrefslogtreecommitdiff
path: root/internal/store/store.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-23 22:43:59 +0200
committerPaul Buetow <paul@buetow.org>2026-02-23 22:43:59 +0200
commitb14a1ccfbf60b0866911788176491af5907702eb (patch)
treeeccb1293b62e3ccb8d7e24f9a6170e5c2141bc3b /internal/store/store.go
parent13272b89c8e343f53f0633fd057d7a0054113524 (diff)
Release v0.5.2v0.5.2
Diffstat (limited to 'internal/store/store.go')
-rw-r--r--internal/store/store.go214
1 files changed, 188 insertions, 26 deletions
diff --git a/internal/store/store.go b/internal/store/store.go
index 3bed381..44b22c3 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -17,6 +17,7 @@ import (
"path/filepath"
"regexp"
"sort"
+ "strconv"
"strings"
"codeberg.org/snonux/foostore/internal/config"
@@ -37,6 +38,24 @@ const (
ActionEdit // export, edit in external editor, reimport
)
+// PickerAction describes the action requested from the interactive fzf picker.
+type PickerAction string
+
+const (
+ PickerSelect PickerAction = "select"
+ PickerCat PickerAction = "cat"
+ PickerPaste PickerAction = "paste"
+ PickerOpen PickerAction = "open"
+ PickerEdit PickerAction = "edit"
+)
+
+// PickerResult is the selected description plus the desired action from fzf.
+// Description is empty when the picker was cancelled.
+type PickerResult struct {
+ Description string
+ Action PickerAction
+}
+
// Store provides all secret-store operations.
// regexCache avoids recompiling the same search-term regexp on every WalkIndexes call.
type Store struct {
@@ -217,50 +236,193 @@ func (s *Store) actionExport(ctx context.Context, idx *Index, fullPath bool) err
return d.Export(ctx, s.cfg.ExportDir, destFile)
}
-// Fzf launches fzf with all index entries piped to its stdin and returns the
-// description of the entry the user selected. All entries are collected first
-// so that cipher initialisation happens before the pipe is opened (matching
-// the Ruby note: "Need to read an index first before opening the pipe to
-// initialize the encryption PIN").
-// Returns ("", nil) when fzf is not installed or the user presses Escape.
+type pickerEntry struct {
+ rowID int
+ description string
+ kind string
+ hashSuffix string
+}
+
+// Fzf launches fzf and returns only the selected description for compatibility
+// with callers that do not care about picker action keys.
func (s *Store) Fzf(ctx context.Context) (string, error) {
- // Collect all entries before opening the fzf pipe so the cipher is ready.
- var entries []string
+ result, err := s.FzfInteractive(ctx)
+ if err != nil {
+ return "", err
+ }
+ return result.Description, nil
+}
+
+// FzfInteractive launches fzf with helper bars, preview metadata, and action
+// key bindings, then returns both the selected description and action.
+func (s *Store) FzfInteractive(ctx context.Context) (PickerResult, error) {
+ var indexes IndexSlice
if err := s.WalkIndexes(ctx, "", func(idx *Index) error {
- entries = append(entries, idx.String())
+ indexes = append(indexes, idx)
return nil
}); err != nil {
- return "", err
+ return PickerResult{}, err
+ }
+ if len(indexes) == 0 {
+ return PickerResult{}, nil
}
- if len(entries) == 0 {
- return "", nil
+ sort.Sort(indexes)
+ entries := make([]pickerEntry, 0, len(indexes))
+ for i, idx := range indexes {
+ kind := "TEXT"
+ if idx.IsBinary() {
+ kind = "BINARY"
+ }
+ hashSuffix := ""
+ if len(idx.Hash) >= 63 {
+ hashSuffix = idx.Hash[53:63]
+ }
+ entries = append(entries, pickerEntry{
+ rowID: i + 1,
+ description: idx.Description,
+ kind: kind,
+ hashSuffix: hashSuffix,
+ })
}
- return runFzf(ctx, entries)
+ return runFzfInteractive(ctx, entries)
}
-// runFzf pipes entries to fzf and returns the description of the selected line.
-// Returns ("", nil) if fzf exits with a non-zero status (user cancelled).
-func runFzf(ctx context.Context, entries []string) (string, error) {
- cmd := exec.CommandContext(ctx, "fzf")
- cmd.Stdin = strings.NewReader(strings.Join(entries, ""))
+func runFzfInteractive(ctx context.Context, entries []pickerEntry) (PickerResult, error) {
+ if len(entries) == 0 {
+ return PickerResult{}, nil
+ }
+ if _, err := exec.LookPath("fzf"); err != nil {
+ return PickerResult{}, fmt.Errorf("fzf not found in PATH")
+ }
+
+ input, idToDescription := buildFzfInput(entries)
+
+ cmd := exec.CommandContext(ctx, "fzf", buildFzfArgs(len(entries))...)
+ cmd.Stdin = strings.NewReader(input)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
- // Any non-zero exit from fzf (e.g., 130 for Escape, 1 for no match)
- // is treated as no selection — the caller receives ("", nil).
- return "", nil
+ // Any non-zero exit from fzf (e.g., Escape/no match) is treated as cancel.
+ return PickerResult{}, nil
+ }
+
+ return parsePickerResult(out.String(), idToDescription), nil
+}
+
+func buildFzfInput(entries []pickerEntry) (string, map[string]string) {
+ var b strings.Builder
+ idToDescription := make(map[string]string, len(entries))
+ for _, e := range entries {
+ id := strconv.Itoa(e.rowID)
+ idToDescription[id] = e.description
+ fmt.Fprintf(
+ &b,
+ "%s\t%s\t%s\t%s\n",
+ id,
+ sanitizePickerField(e.description),
+ sanitizePickerField(e.kind),
+ sanitizePickerField(e.hashSuffix),
+ )
+ }
+ return b.String(), idToDescription
+}
+
+func buildFzfArgs(entryCount int) []string {
+ header := "enter select | ctrl-t/alt-t cat | ctrl-y/alt-y paste | ctrl-o/alt-o open | ctrl-e/alt-e edit | esc cancel"
+ status := fmt.Sprintf("foostore interactive picker | %d entries | metadata preview only", entryCount)
+ args := []string{
+ "--height=80%",
+ "--layout=reverse",
+ "--border",
+ "--ansi",
+ "--delimiter=\t",
+ "--with-nth=2,3,4",
+ "--prompt=secret> ",
+ "--expect=enter,ctrl-t,ctrl-y,ctrl-o,ctrl-e,alt-t,alt-y,alt-o,alt-e",
+ "--bind=ctrl-t:ignore,ctrl-y:ignore,ctrl-o:ignore,ctrl-e:ignore,alt-t:ignore,alt-y:ignore,alt-o:ignore,alt-e:ignore",
+ "--header=" + header + "\n" + status,
+ "--preview-window=down,6,wrap,border-top",
+ "--preview=printf 'entry: %s\\nkind: %s\\nhash suffix: %s\\n' {2} {3} {4}",
+ "--color=" + pickerColorTheme(os.Getenv("FOOSTORE_TUI_THEME")),
+ }
+ if extra := strings.TrimSpace(os.Getenv("FOOSTORE_FZF_OPTS")); extra != "" {
+ args = append(args, strings.Fields(extra)...)
+ }
+ return args
+}
+
+func pickerColorTheme(theme string) string {
+ switch strings.ToLower(strings.TrimSpace(theme)) {
+ case "", "bold":
+ return "fg:#f8fafc,bg:#0b1220,hl:#f59e0b,fg+:#ffffff,bg+:#1d4ed8,hl+:#fde047,info:#22d3ee,prompt:#f43f5e,pointer:#10b981,marker:#a78bfa,spinner:#fb7185,header:#38bdf8,border:#334155,separator:#0ea5e9,query:#e2e8f0,label:#f472b6"
+ case "clean":
+ return "fg:#e5e7eb,bg:#111827,hl:#93c5fd,fg+:#f9fafb,bg+:#1f2937,hl+:#93c5fd,info:#a7f3d0,prompt:#fbbf24,pointer:#34d399,marker:#34d399,spinner:#fbbf24,header:#a7f3d0,border:#374151"
+ case "neon":
+ return "fg:#d1fae5,bg:#020617,hl:#f0abfc,fg+:#ffffff,bg+:#0f172a,hl+:#f9a8d4,info:#67e8f9,prompt:#22d3ee,pointer:#22c55e,marker:#f472b6,spinner:#a78bfa,header:#38bdf8,border:#1d4ed8,separator:#22d3ee,query:#bbf7d0,label:#f0abfc"
+ case "mono":
+ return "fg:#e5e5e5,bg:#111111,hl:#ffffff,fg+:#ffffff,bg+:#222222,hl+:#ffffff,info:#d4d4d4,prompt:#ffffff,pointer:#ffffff,marker:#ffffff,spinner:#ffffff,header:#d4d4d4,border:#444444"
+ default:
+ return pickerColorTheme("bold")
+ }
+}
+
+func sanitizePickerField(s string) string {
+ s = strings.ReplaceAll(s, "\t", " ")
+ s = strings.ReplaceAll(s, "\n", " ")
+ return strings.TrimSpace(s)
+}
+
+func parsePickerResult(output string, idToDescription map[string]string) PickerResult {
+ lines := strings.Split(strings.TrimRight(output, "\n"), "\n")
+ if len(lines) < 2 {
+ return PickerResult{}
+ }
+
+ action, ok := parsePickerAction(lines[0])
+ if !ok {
+ return PickerResult{}
+ }
+
+ row := strings.TrimSpace(lines[1])
+ if row == "" {
+ return PickerResult{}
+ }
+
+ id := row
+ if parts := strings.SplitN(row, "\t", 2); len(parts) > 0 {
+ id = strings.TrimSpace(parts[0])
}
- line := strings.TrimRight(out.String(), "\n")
- if line == "" {
- return "", nil
+ description, ok := idToDescription[id]
+ if !ok || description == "" {
+ return PickerResult{}
+ }
+
+ return PickerResult{
+ Description: description,
+ Action: action,
+ }
+}
+
+func parsePickerAction(keyLine string) (PickerAction, bool) {
+ switch strings.TrimSpace(keyLine) {
+ case "", "enter":
+ return PickerSelect, true
+ case "ctrl-t", "alt-t":
+ return PickerCat, true
+ case "ctrl-y", "alt-y":
+ return PickerPaste, true
+ case "ctrl-o", "alt-o":
+ return PickerOpen, true
+ case "ctrl-e", "alt-e":
+ return PickerEdit, true
+ default:
+ return "", false
}
- // The format is "<description>; (BINARY) ...<hashSuffix>\n" — take the part before ";".
- return strings.TrimSpace(strings.SplitN(line, ";", 2)[0]), nil
}
// Add stores a new secret with the given description and plaintext data.