summaryrefslogtreecommitdiff
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
parent13272b89c8e343f53f0633fd057d7a0054113524 (diff)
Release v0.5.2v0.5.2
-rw-r--r--.gitignore2
-rw-r--r--CLAUDE.md4
-rw-r--r--FISH_INTEGRATION.md9
-rw-r--r--Magefile.go22
-rw-r--r--README.md25
-rw-r--r--internal/cli/cli.go36
-rw-r--r--internal/cli/cli_test.go35
-rw-r--r--internal/shell/shell.go124
-rw-r--r--internal/shell/shell_internal_test.go54
-rw-r--r--internal/store/store.go214
-rw-r--r--internal/store/store_test.go115
-rw-r--r--internal/version/version.go2
12 files changed, 587 insertions, 55 deletions
diff --git a/.gitignore b/.gitignore
index 7396b10..f48416a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
-bin/
+/foostore
*.test
coverage.out
coverage.html
diff --git a/CLAUDE.md b/CLAUDE.md
index 8a36f8d..6c106b8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,7 +9,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Building and running
```bash
-mage # build (produces ./bin/foostore)
+mage # build (produces ./foostore)
mage install # install to $GOPATH/bin (default ~/go/bin)
mage test # run all tests
mage vet # run go vet
@@ -18,7 +18,7 @@ mage vet # run go vet
Or run directly after building:
```bash
-./bin/foostore [command] [args]
+./foostore [command] [args]
```
## Testing
diff --git a/FISH_INTEGRATION.md b/FISH_INTEGRATION.md
index 0a0ced2..c1e7aee 100644
--- a/FISH_INTEGRATION.md
+++ b/FISH_INTEGRATION.md
@@ -53,6 +53,15 @@ ge import file.txt backup/
ge import file.txt backup/ force
```
+In interactive mode, empty `Enter` opens the fuzzy picker with direct action keys:
+- `Enter` select
+- `Ctrl-T` cat
+- `Ctrl-Y` paste
+- `Ctrl-O` open
+- `Ctrl-E` edit
+
+The picker preview remains metadata-only for safety (no decrypted secret preview).
+
### Dynamic Entry Completion
For better security, entry completion only works when the `PIN` environment variable is set:
diff --git a/Magefile.go b/Magefile.go
index bbfc87a..0d68f39 100644
--- a/Magefile.go
+++ b/Magefile.go
@@ -16,7 +16,7 @@ import (
)
const (
- binary = "./bin/foostore"
+ binary = "./foostore"
binaryName = "foostore"
mainPkg = "./cmd/foostore"
)
@@ -24,10 +24,11 @@ const (
// Default builds the binary so that a bare `mage` invocation is equivalent to `mage build`.
func Default() { mg.Deps(Build) }
-// Build compiles the binary to ./bin/foostore.
+// Build compiles the binary to ./foostore.
func Build() error {
- mg.Deps(createBinDir)
fmt.Println("Building", binary)
+ // Remove legacy output path from older builds to avoid confusion.
+ _ = os.Remove("./bin/foostore")
return sh.RunV("go", "build", "-o", binary, mainPkg)
}
@@ -87,13 +88,12 @@ func Uninstall() error {
return nil
}
-// Clean removes the ./bin directory.
+// Clean removes the local build artifact.
func Clean() error {
- fmt.Println("Cleaning", "./bin")
- return os.RemoveAll("./bin")
-}
-
-// createBinDir ensures ./bin exists before the build step writes the binary.
-func createBinDir() error {
- return os.MkdirAll("./bin", 0o755)
+ fmt.Println("Cleaning", binary)
+ if err := os.Remove(binary); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ _ = os.Remove("./bin/foostore")
+ return nil
}
diff --git a/README.md b/README.md
index 6852f12..5d0e2b7 100644
--- a/README.md
+++ b/README.md
@@ -57,3 +57,28 @@ set -e PIN # clear when done
```
See `FISH_INTEGRATION.md` for more details.
+
+## Interactive Picker UX
+
+In interactive shell mode (`foostore` with no arguments), pressing `Enter` on an empty line opens an enhanced fuzzy picker.
+
+- `Enter`: select entry (updates `last`)
+- `Ctrl-T`: `cat` selected entry
+- `Ctrl-Y`: `paste` selected entry
+- `Ctrl-O`: `open` selected entry
+- `Ctrl-E`: `edit` selected entry
+- `Esc`: cancel picker
+
+The preview is metadata-only (description/type/hash suffix). Decrypted secret content is not shown in the picker preview.
+
+Optional picker customization:
+
+```bash
+# presets: bold (default), clean, neon, mono
+export FOOSTORE_TUI_THEME=clean
+
+# append raw extra fzf options
+export FOOSTORE_FZF_OPTS="--cycle --no-mouse"
+```
+
+PIN entry uses masked feedback (`*`) and vi-style line editing.
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index ef00010..7152496 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -156,6 +156,7 @@ func (c *CLI) run(ctx context.Context, argv []string) int {
// c.lastResult is updated by dispatch and accessible between iterations.
func (c *CLI) shellLoop(ctx context.Context) int {
ec := 0
+ logMsg("Interactive mode (vi keys): Ctrl-] for normal mode, i for insert | Enter fuzzy picker | ctrl-t/y/o/e (or alt-t/y/o/e) in picker")
for {
line, err := c.sh.ReadLine(ctx)
@@ -170,12 +171,16 @@ func (c *CLI) shellLoop(ctx context.Context) int {
argv := strings.Fields(line)
if len(argv) == 0 {
- // Empty input — run fzf picker just as the Ruby nil branch does.
- result, fzfErr := c.st.Fzf(ctx)
+ // Empty input — run fzf picker.
+ result, fzfErr := c.st.FzfInteractive(ctx)
if fzfErr != nil {
warn(fzfErr.Error())
- } else if result != "" {
- c.lastResult = result
+ continue
+ }
+ if result.Description != "" {
+ c.lastResult = result.Description
+ logMsg(fmt.Sprintf("Picked: %s", result.Description))
+ ec = c.dispatchPickerAction(ctx, result)
}
continue
}
@@ -198,6 +203,29 @@ func (c *CLI) shellLoop(ctx context.Context) int {
return ec
}
+func pickerActionArgv(action store.PickerAction, description string) []string {
+ switch action {
+ case store.PickerCat:
+ return []string{"cat", description}
+ case store.PickerPaste:
+ return []string{"paste", description}
+ case store.PickerOpen:
+ return []string{"open", description}
+ case store.PickerEdit:
+ return []string{"edit", description}
+ default:
+ return nil
+ }
+}
+
+func (c *CLI) dispatchPickerAction(ctx context.Context, result store.PickerResult) int {
+ argv := pickerActionArgv(result.Action, result.Description)
+ if len(argv) == 0 {
+ return 0
+ }
+ return c.dispatch(ctx, argv)
+}
+
// dispatch routes a parsed argv slice to the appropriate handler.
// It returns an exit code and updates c.lastResult when a non-empty result
// is produced. The function is split into helpers to keep each branch under
diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go
index 250601c..2ad52db 100644
--- a/internal/cli/cli_test.go
+++ b/internal/cli/cli_test.go
@@ -364,6 +364,41 @@ func TestMakeActionFn_nil(t *testing.T) {
}
}
+// ---- pickerActionArgv -------------------------------------------------------
+
+// TestPickerActionArgv verifies the direct mapping from picker action keys to
+// CLI argv commands used by interactive empty-line fzf selection.
+func TestPickerActionArgv(t *testing.T) {
+ desc := "docs/secret.txt"
+
+ cases := []struct {
+ name string
+ action store.PickerAction
+ want []string
+ }{
+ {name: "select", action: store.PickerSelect, want: nil},
+ {name: "cat", action: store.PickerCat, want: []string{"cat", desc}},
+ {name: "paste", action: store.PickerPaste, want: []string{"paste", desc}},
+ {name: "open", action: store.PickerOpen, want: []string{"open", desc}},
+ {name: "edit", action: store.PickerEdit, want: []string{"edit", desc}},
+ {name: "unknown", action: store.PickerAction("weird"), want: nil},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := pickerActionArgv(tc.action, desc)
+ if len(got) != len(tc.want) {
+ t.Fatalf("pickerActionArgv(%q) len = %d; want %d (%v)", tc.action, len(got), len(tc.want), got)
+ }
+ for i := range got {
+ if got[i] != tc.want[i] {
+ t.Fatalf("pickerActionArgv(%q)[%d] = %q; want %q", tc.action, i, got[i], tc.want[i])
+ }
+ }
+ })
+ }
+}
+
// ---- remaining dispatchSearch branches --------------------------------------
// TestDispatch_searchActions exercises all SearchActions entries on an empty
diff --git a/internal/shell/shell.go b/internal/shell/shell.go
index 16c6366..195d1ab 100644
--- a/internal/shell/shell.go
+++ b/internal/shell/shell.go
@@ -5,6 +5,7 @@
package shell
import (
+ "bufio"
"context"
"fmt"
"io"
@@ -20,6 +21,74 @@ type Shell struct {
rl *readline.Instance
}
+func shellPrompt() string {
+ if os.Getenv("NO_COLOR") != "" {
+ return "% "
+ }
+ // Bright cyan prompt marker for better visibility.
+ return "\x1b[1;96m%\x1b[0m "
+}
+
+// viInputFilter provides a small vi-style modal key layer for readline.
+// It is used as a reliability fallback because VimMode handling can vary
+// by terminal; this keeps navigation deterministic.
+type viInputFilter struct {
+ normalMode bool
+}
+
+func newVIInputFilter() *viInputFilter {
+ // Start in insert mode to keep command entry ergonomic.
+ return &viInputFilter{normalMode: false}
+}
+
+// filter maps typed runes into readline control runes.
+// Returns (mappedRune, true) to pass into readline, or (_, false) to swallow.
+func (v *viInputFilter) filter(r rune) (rune, bool) {
+ switch r {
+ case readline.CharEnter, readline.CharCtrlJ:
+ // Enter submits the line and returns to insert mode for next prompt.
+ v.normalMode = false
+ return r, true
+ case readline.CharEsc, 29: // Esc or Ctrl-]
+ v.normalMode = true
+ return 0, false
+ }
+
+ if !v.normalMode {
+ return r, true
+ }
+
+ switch r {
+ case 'i':
+ v.normalMode = false
+ return 0, false
+ case 'a':
+ v.normalMode = false
+ return readline.CharForward, true
+ case 'h':
+ return readline.CharBackward, true
+ case 'j':
+ return readline.CharNext, true
+ case 'k':
+ return readline.CharPrev, true
+ case 'l':
+ return readline.CharForward, true
+ case 'w', 'W':
+ return readline.MetaForward, true
+ case 'b', 'B':
+ return readline.MetaBackward, true
+ case '0', '^':
+ return readline.CharLineStart, true
+ case '$':
+ return readline.CharLineEnd, true
+ case 'x':
+ return readline.MetaDeleteKey, true
+ default:
+ // In normal mode, unknown keys should not insert text.
+ return readline.CharBell, true
+ }
+}
+
// prefixCompleter implements readline.AutoCompleter by delegating to a
// caller-supplied function that returns completions for a given prefix.
// This mirrors the Ruby implementation's Readline.completion_proc.
@@ -63,11 +132,15 @@ func (p *prefixCompleter) Do(line []rune, pos int) (newLine [][]rune, length int
// - tab completion via completionFn
// - manual history saving so we can deduplicate entries ourselves
func New(completionFn func(prefix string) []string) (*Shell, error) {
+ viFilter := newVIInputFilter()
cfg := &readline.Config{
- Prompt: "% ",
- VimMode: true,
+ Prompt: shellPrompt(),
+ VimMode: false,
HistoryLimit: 500,
AutoComplete: &prefixCompleter{fn: completionFn},
+ FuncFilterInputRune: func(r rune) (rune, bool) {
+ return viFilter.filter(r)
+ },
// Disable automatic history saving so ReadLine can deduplicate
// entries before committing them, matching the Ruby behaviour:
// Readline::HISTORY.pop if argv.empty? ||
@@ -141,17 +214,48 @@ func (s *Shell) ReadPassword(prompt string) (string, error) {
return string(bytes), nil
}
-// ReadPassword prints prompt then reads a password from the terminal without
-// echoing characters. It uses golang.org/x/term for reliable cross-platform
-// masked input, bypassing the readline library which does not always display
-// the prompt correctly before the process is fully interactive.
+// ReadPassword prints prompt then reads a password from the terminal using
+// readline in Vim mode with masked visual feedback ("*").
+//
+// For non-interactive input (stdin is not a terminal), it falls back to
+// reading a single line from stdin.
func ReadPassword(prompt string) (string, error) {
- fmt.Print(prompt)
- defer fmt.Println() // move to next line after the user presses Enter
+ fd := int(os.Stdin.Fd())
+ if !term.IsTerminal(fd) {
+ fmt.Print(prompt)
+ defer fmt.Println() // move to next line after input is complete
+
+ r := bufio.NewReader(os.Stdin)
+ line, err := r.ReadString('\n')
+ if err != nil && err != io.EOF {
+ return "", err
+ }
+ return strings.TrimRight(line, "\r\n"), nil
+ }
+
+ viFilter := newVIInputFilter()
+ rl, err := readline.NewFromConfig(&readline.Config{
+ FuncFilterInputRune: func(r rune) (rune, bool) {
+ return viFilter.filter(r)
+ },
+ Prompt: prompt,
+ VimMode: false,
+ EnableMask: true,
+ MaskRune: '*',
+ HistoryFile: "",
+ DisableAutoSaveHistory: true,
+ })
+ if err != nil {
+ return "", err
+ }
+ defer rl.Close()
- b, err := term.ReadPassword(int(os.Stdin.Fd()))
+ line, err := rl.Readline()
if err != nil {
+ if err == readline.ErrInterrupt {
+ return "", fmt.Errorf("interrupted")
+ }
return "", err
}
- return string(b), nil
+ return strings.TrimRight(line, "\r\n"), nil
}
diff --git a/internal/shell/shell_internal_test.go b/internal/shell/shell_internal_test.go
index 73284d7..16cda0a 100644
--- a/internal/shell/shell_internal_test.go
+++ b/internal/shell/shell_internal_test.go
@@ -5,6 +5,8 @@ package shell
import (
"strings"
"testing"
+
+ "github.com/ergochat/readline"
)
// TestPrefixCompleterDo exercises the Do method of prefixCompleter against a
@@ -96,6 +98,58 @@ func TestPrefixCompleterDo(t *testing.T) {
}
}
+// TestVIInputFilter verifies vi-style modal key translation used by shell and
+// PIN prompts through FuncFilterInputRune.
+func TestVIInputFilter(t *testing.T) {
+ v := newVIInputFilter()
+
+ // Insert mode passes regular typing through unchanged.
+ if r, ok := v.filter('x'); !ok || r != 'x' {
+ t.Fatalf("insert mode passthrough got (%q,%v), want ('x',true)", r, ok)
+ }
+
+ // Esc enters normal mode and is swallowed.
+ if r, ok := v.filter(readline.CharEsc); ok || r != 0 {
+ t.Fatalf("esc got (%q,%v), want (0,false)", r, ok)
+ }
+
+ // Normal-mode movement mappings.
+ cases := []struct {
+ in rune
+ want rune
+ }{
+ {'h', readline.CharBackward},
+ {'j', readline.CharNext},
+ {'k', readline.CharPrev},
+ {'l', readline.CharForward},
+ {'w', readline.MetaForward},
+ {'b', readline.MetaBackward},
+ {'0', readline.CharLineStart},
+ {'$', readline.CharLineEnd},
+ }
+ for _, tc := range cases {
+ if r, ok := v.filter(tc.in); !ok || r != tc.want {
+ t.Fatalf("normal mapping %q got (%q,%v), want (%q,true)", tc.in, r, ok, tc.want)
+ }
+ }
+
+ // "i" returns to insert mode and is swallowed.
+ if r, ok := v.filter('i'); ok || r != 0 {
+ t.Fatalf("i got (%q,%v), want (0,false)", r, ok)
+ }
+ if r, ok := v.filter('z'); !ok || r != 'z' {
+ t.Fatalf("insert mode after i got (%q,%v), want ('z',true)", r, ok)
+ }
+
+ // Ctrl-] should also enter normal mode.
+ if r, ok := v.filter(29); ok || r != 0 {
+ t.Fatalf("ctrl-] got (%q,%v), want (0,false)", r, ok)
+ }
+ if r, ok := v.filter('h'); !ok || r != readline.CharBackward {
+ t.Fatalf("normal mode after ctrl-] got (%q,%v), want (CharBackward,true)", r, ok)
+ }
+}
+
// toStrings converts a [][]rune to []string for readable error output.
func toStrings(runes [][]rune) []string {
out := make([]string, len(runes))
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.
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
index 47d4692..270fe00 100644
--- a/internal/store/store_test.go
+++ b/internal/store/store_test.go
@@ -850,6 +850,121 @@ func TestFzfEmpty(t *testing.T) {
}
}
+// TestParsePickerAction verifies fzf key line parsing for supported picker
+// actions and unknown values.
+func TestParsePickerAction(t *testing.T) {
+ cases := []struct {
+ name string
+ keyLine string
+ want PickerAction
+ ok bool
+ }{
+ {name: "enter", keyLine: "enter", want: PickerSelect, ok: true},
+ {name: "blank means enter", keyLine: "", want: PickerSelect, ok: true},
+ {name: "cat", keyLine: "ctrl-t", want: PickerCat, ok: true},
+ {name: "paste", keyLine: "ctrl-y", want: PickerPaste, ok: true},
+ {name: "open", keyLine: "ctrl-o", want: PickerOpen, ok: true},
+ {name: "edit", keyLine: "ctrl-e", want: PickerEdit, ok: true},
+ {name: "unknown", keyLine: "ctrl-x", want: "", ok: false},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, ok := parsePickerAction(tc.keyLine)
+ if ok != tc.ok {
+ t.Fatalf("parsePickerAction(%q) ok = %v; want %v", tc.keyLine, ok, tc.ok)
+ }
+ if got != tc.want {
+ t.Fatalf("parsePickerAction(%q) = %q; want %q", tc.keyLine, got, tc.want)
+ }
+ })
+ }
+}
+
+// TestParsePickerResult checks output decoding from fzf --expect mode.
+func TestParsePickerResult(t *testing.T) {
+ idMap := map[string]string{
+ "1": "alpha/secret.txt",
+ "2": "photo.jpg",
+ }
+
+ cases := []struct {
+ name string
+ output string
+ want PickerResult
+ }{
+ {
+ name: "ctrl-y paste",
+ output: "ctrl-y\n1\talpha/secret.txt\tTEXT\tabcdef1234\n",
+ want: PickerResult{Description: "alpha/secret.txt", Action: PickerPaste},
+ },
+ {
+ name: "enter select",
+ output: "enter\n2\tphoto.jpg\tBINARY\tff00ff00ff\n",
+ want: PickerResult{Description: "photo.jpg", Action: PickerSelect},
+ },
+ {
+ name: "cancel or empty output",
+ output: "",
+ want: PickerResult{},
+ },
+ {
+ name: "unknown key ignored",
+ output: "ctrl-x\n1\talpha/secret.txt\tTEXT\tabcdef1234\n",
+ want: PickerResult{},
+ },
+ {
+ name: "unknown id ignored",
+ output: "enter\n999\tmissing\tTEXT\tabcdef1234\n",
+ want: PickerResult{},
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := parsePickerResult(tc.output, idMap)
+ if got != tc.want {
+ t.Fatalf("parsePickerResult() = %+v; want %+v", got, tc.want)
+ }
+ })
+ }
+}
+
+// TestPickerColorTheme verifies supported theme presets and fallback behavior.
+func TestPickerColorTheme(t *testing.T) {
+ if got := pickerColorTheme("bold"); got == "" {
+ t.Fatal("pickerColorTheme(bold) returned empty string")
+ }
+ if got := pickerColorTheme("clean"); got == "" {
+ t.Fatal("pickerColorTheme(clean) returned empty string")
+ }
+ if got := pickerColorTheme("mono"); got == "" {
+ t.Fatal("pickerColorTheme(mono) returned empty string")
+ }
+ // Unknown themes must fall back to bold.
+ if pickerColorTheme("unknown") != pickerColorTheme("bold") {
+ t.Fatal("unknown theme did not fall back to bold")
+ }
+}
+
+// TestBuildFzfArgs_envOverrides verifies default args and extra user opts.
+func TestBuildFzfArgs_envOverrides(t *testing.T) {
+ t.Setenv("FOOSTORE_TUI_THEME", "clean")
+ t.Setenv("FOOSTORE_FZF_OPTS", "--cycle --no-mouse")
+
+ args := buildFzfArgs(12)
+ joined := strings.Join(args, " ")
+ if !strings.Contains(joined, "--expect=enter,ctrl-t,ctrl-y,ctrl-o,ctrl-e") {
+ t.Fatalf("expected --expect in args, got: %v", args)
+ }
+ if !strings.Contains(joined, "--cycle") || !strings.Contains(joined, "--no-mouse") {
+ t.Fatalf("expected FOOSTORE_FZF_OPTS to be appended, got: %v", args)
+ }
+ if !strings.Contains(joined, "--color=") {
+ t.Fatalf("expected color option, got: %v", args)
+ }
+}
+
// --- TestRemoveInteractiveInvalidThenDecline ---------------------------------
// TestRemoveInteractiveInvalidThenDecline exercises the retry loop in
diff --git a/internal/version/version.go b/internal/version/version.go
index 4a3c9d9..0e5f693 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -2,4 +2,4 @@
package version
// Version is the current release version of foostore.
-const const Version = "v0.5.1"
+const Version = "v0.5.2"