diff options
Diffstat (limited to 'internal/picker/picker.go')
| -rw-r--r-- | internal/picker/picker.go | 152 |
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, + } +} |
