summaryrefslogtreecommitdiff
path: root/internal/picker/picker.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-02 11:03:15 +0200
committerPaul Buetow <paul@buetow.org>2026-03-02 11:03:15 +0200
commit630aa260cf92a3fb2f36952bed3be449ea14468c (patch)
tree60c83e3ca5c088d58d2ed4f6849794fb5432ebbc /internal/picker/picker.go
parentd7a20676e0f6f243b9ad15ccf2bc82ec641bc97b (diff)
picker: extract fzf UI logic from store package (task 400)
Diffstat (limited to 'internal/picker/picker.go')
-rw-r--r--internal/picker/picker.go152
1 files changed, 152 insertions, 0 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,
+ }
+}