diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-02 11:03:15 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-02 11:03:15 +0200 |
| commit | 630aa260cf92a3fb2f36952bed3be449ea14468c (patch) | |
| tree | 60c83e3ca5c088d58d2ed4f6849794fb5432ebbc | |
| parent | d7a20676e0f6f243b9ad15ccf2bc82ec641bc97b (diff) | |
picker: extract fzf UI logic from store package (task 400)
| -rw-r--r-- | internal/picker/picker.go | 152 | ||||
| -rw-r--r-- | internal/store/store.go | 143 |
2 files changed, 182 insertions, 113 deletions
diff --git a/internal/picker/picker.go b/internal/picker/picker.go new file mode 100644 index 0000000..a8d5e86 --- /dev/null +++ b/internal/picker/picker.go @@ -0,0 +1,152 @@ +package picker + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strconv" + "strings" +) + +// Entry represents one selectable row in the interactive picker. +type Entry struct { + RowID int + Description string + Kind string + HashSuffix string +} + +// Selection is the parsed fzf output. +// Key is the pressed key from --expect (e.g. "ctrl-t", "enter", or ""). +type Selection struct { + Description string + Key string +} + +// Run executes fzf and parses its output into a Selection. +func Run(ctx context.Context, entries []Entry) (Selection, error) { + if len(entries) == 0 { + return Selection{}, nil + } + if _, err := exec.LookPath("fzf"); err != nil { + return Selection{}, fmt.Errorf("fzf not found in PATH") + } + + input, idToDescription := BuildInput(entries) + args := BuildArgs( + len(entries), + os.Getenv("FOOSTORE_TUI_THEME"), + os.Getenv("FOOSTORE_FZF_OPTS"), + ) + + cmd := exec.CommandContext(ctx, "fzf", args...) + cmd.Stdin = strings.NewReader(input) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + // Non-zero exit (for example ESC cancel) is treated as no selection. + return Selection{}, nil + } + + return ParseSelection(out.String(), idToDescription), nil +} + +// BuildInput formats picker rows as tab-delimited input and builds an id map. +func BuildInput(entries []Entry) (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, + SanitizeField(e.Description), + SanitizeField(e.Kind), + SanitizeField(e.HashSuffix), + ) + } + return b.String(), idToDescription +} + +// BuildArgs returns fzf arguments for the interactive picker. +func BuildArgs(entryCount int, theme, extraOpts string) []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=" + ColorTheme(theme), + } + if extra := strings.TrimSpace(extraOpts); extra != "" { + args = append(args, strings.Fields(extra)...) + } + return args +} + +// ColorTheme returns the fzf --color value for a named theme. +func ColorTheme(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 ColorTheme("bold") + } +} + +// SanitizeField removes tabs/newlines so each row stays tab-delimited. +func SanitizeField(s string) string { + s = strings.ReplaceAll(s, "\t", " ") + s = strings.ReplaceAll(s, "\n", " ") + return strings.TrimSpace(s) +} + +// ParseSelection decodes fzf --expect output. +func ParseSelection(output string, idToDescription map[string]string) Selection { + lines := strings.Split(strings.TrimRight(output, "\n"), "\n") + if len(lines) < 2 { + return Selection{} + } + + key := strings.TrimSpace(lines[0]) + row := strings.TrimSpace(lines[1]) + if row == "" { + return Selection{} + } + + id := row + if parts := strings.SplitN(row, "\t", 2); len(parts) > 0 { + id = strings.TrimSpace(parts[0]) + } + + description, ok := idToDescription[id] + if !ok || description == "" { + return Selection{} + } + + return Selection{ + Description: description, + Key: key, + } +} diff --git a/internal/store/store.go b/internal/store/store.go index e1fb4e8..527ece8 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -6,7 +6,6 @@ package store import ( "bufio" - "bytes" "context" "crypto/sha256" "encoding/hex" @@ -17,10 +16,10 @@ import ( "path/filepath" "regexp" "sort" - "strconv" "strings" "codeberg.org/snonux/foostore/internal/config" + "codeberg.org/snonux/foostore/internal/picker" ) // Action describes what to do with each matching secret during a Search call. @@ -244,13 +243,6 @@ func (s *Store) actionExport(ctx context.Context, idx *Index, fullPath bool) err return d.Export(ctx, s.cfg.ExportDir, destFile) } -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) { @@ -276,7 +268,7 @@ func (s *Store) FzfInteractive(ctx context.Context) (PickerResult, error) { } sort.Sort(indexes) - entries := make([]pickerEntry, 0, len(indexes)) + entries := make([]picker.Entry, 0, len(indexes)) for i, idx := range indexes { kind := "TEXT" if idx.IsBinary() { @@ -286,132 +278,57 @@ func (s *Store) FzfInteractive(ctx context.Context) (PickerResult, error) { if len(idx.Hash) >= 63 { hashSuffix = idx.Hash[53:63] } - entries = append(entries, pickerEntry{ - rowID: i + 1, - description: idx.Description, - kind: kind, - hashSuffix: hashSuffix, + entries = append(entries, picker.Entry{ + RowID: i + 1, + Description: idx.Description, + Kind: kind, + HashSuffix: hashSuffix, }) } return runFzfInteractive(ctx, 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") +func runFzfInteractive(ctx context.Context, entries []picker.Entry) (PickerResult, error) { + selection, err := picker.Run(ctx, entries) + if err != nil { + return PickerResult{}, err } - 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., Escape/no match) is treated as cancel. + action, ok := parsePickerAction(selection.Key) + if !ok { + return PickerResult{}, nil + } + if selection.Description == "" { 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 + return PickerResult{ + Description: selection.Description, + Action: action, + }, nil } 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 + return picker.BuildArgs( + entryCount, + os.Getenv("FOOSTORE_TUI_THEME"), + os.Getenv("FOOSTORE_FZF_OPTS"), + ) } 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) + return picker.ColorTheme(theme) } func parsePickerResult(output string, idToDescription map[string]string) PickerResult { - lines := strings.Split(strings.TrimRight(output, "\n"), "\n") - if len(lines) < 2 { + selection := picker.ParseSelection(output, idToDescription) + action, ok := parsePickerAction(selection.Key) + if !ok || selection.Description == "" { 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]) - } - - description, ok := idToDescription[id] - if !ok || description == "" { - return PickerResult{} - } - return PickerResult{ - Description: description, + Description: selection.Description, Action: action, } } |
