go/pkg/cli/list.go
2026-02-21 18:12:37 +00:00

144 lines
2.8 KiB
Go

package cli
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"golang.org/x/term"
)
// listModel is the internal bubbletea model for interactive list selection.
type listModel struct {
items []string
cursor int
title string
selected bool
quitted bool
}
func newListModel(items []string, title string) *listModel {
return &listModel{
items: items,
title: title,
}
}
func (m *listModel) moveDown() {
m.cursor++
if m.cursor >= len(m.items) {
m.cursor = 0
}
}
func (m *listModel) moveUp() {
m.cursor--
if m.cursor < 0 {
m.cursor = len(m.items) - 1
}
}
func (m *listModel) Init() tea.Cmd {
return nil
}
func (m *listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyUp, tea.KeyShiftTab:
m.moveUp()
case tea.KeyDown, tea.KeyTab:
m.moveDown()
case tea.KeyEnter:
m.selected = true
return m, tea.Quit
case tea.KeyEscape, tea.KeyCtrlC:
m.quitted = true
return m, tea.Quit
case tea.KeyRunes:
switch string(msg.Runes) {
case "j":
m.moveDown()
case "k":
m.moveUp()
}
}
}
return m, nil
}
func (m *listModel) View() string {
var sb strings.Builder
if m.title != "" {
sb.WriteString(BoldStyle.Render(m.title) + "\n\n")
}
for i, item := range m.items {
cursor := " "
style := DimStyle
if i == m.cursor {
cursor = AccentStyle.Render(Glyph(":pointer:")) + " "
style = BoldStyle
}
sb.WriteString(fmt.Sprintf("%s%s\n", cursor, style.Render(item)))
}
sb.WriteString("\n" + DimStyle.Render("↑/↓ navigate • enter select • esc cancel"))
return sb.String()
}
// ListOption configures InteractiveList behaviour.
type ListOption func(*listConfig)
type listConfig struct {
height int
}
// WithListHeight sets the visible height of the list (number of items shown).
func WithListHeight(n int) ListOption {
return func(c *listConfig) {
c.height = n
}
}
// InteractiveList presents an interactive scrollable list and returns the
// selected item's index and value. Returns -1 and empty string if cancelled.
//
// Falls back to numbered Select() when stdin is not a terminal (e.g. piped input).
//
// idx, value := cli.InteractiveList("Pick a repo:", repos)
func InteractiveList(title string, items []string, opts ...ListOption) (int, string) {
if len(items) == 0 {
return -1, ""
}
// Fall back to simple Select if not a terminal
if !term.IsTerminal(0) {
result, err := Select(title, items)
if err != nil {
return -1, ""
}
for i, item := range items {
if item == result {
return i, result
}
}
return -1, ""
}
m := newListModel(items, title)
p := tea.NewProgram(m)
finalModel, err := p.Run()
if err != nil {
return -1, ""
}
final := finalModel.(*listModel)
if final.quitted || !final.selected {
return -1, ""
}
return final.cursor, final.items[final.cursor]
}