diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-23 22:43:59 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-23 22:43:59 +0200 |
| commit | b14a1ccfbf60b0866911788176491af5907702eb (patch) | |
| tree | eccb1293b62e3ccb8d7e24f9a6170e5c2141bc3b /internal | |
| parent | 13272b89c8e343f53f0633fd057d7a0054113524 (diff) | |
Release v0.5.2v0.5.2
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/cli/cli.go | 36 | ||||
| -rw-r--r-- | internal/cli/cli_test.go | 35 | ||||
| -rw-r--r-- | internal/shell/shell.go | 124 | ||||
| -rw-r--r-- | internal/shell/shell_internal_test.go | 54 | ||||
| -rw-r--r-- | internal/store/store.go | 214 | ||||
| -rw-r--r-- | internal/store/store_test.go | 115 | ||||
| -rw-r--r-- | internal/version/version.go | 2 |
7 files changed, 539 insertions, 41 deletions
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" |
