feat: interactive list when running empty root command

This commit is contained in:
Yehoshua Adam Sandler
2026-02-04 12:16:28 -06:00
parent d24e4f340e
commit 42ced28ea7
3 changed files with 226 additions and 2 deletions

193
internal/tui/list.go Normal file
View File

@@ -0,0 +1,193 @@
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/yeho/doks/internal/registry"
)
type ListModel struct {
entries []*registry.Entry
cursor int
selected *registry.Entry
quitting bool
width int
height int
}
func NewListModel(entries []*registry.Entry) ListModel {
return ListModel{
entries: entries,
cursor: 0,
}
}
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.String() {
case "ctrl+c", "q", "esc":
m.quitting = true
return m, tea.Quit
case "enter":
if len(m.entries) > 0 && m.cursor < len(m.entries) {
m.selected = m.entries[m.cursor]
}
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.entries)-1 {
m.cursor++
}
case "home", "g":
m.cursor = 0
case "end", "G":
m.cursor = len(m.entries) - 1
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
return m, nil
}
func (m ListModel) View() string {
if len(m.entries) == 0 {
return NoResultsStyle.Render("No documents registered")
}
var b strings.Builder
// Title
title := fmt.Sprintf("📚 Registered Documents (%d total)", len(m.entries))
b.WriteString(TitleStyle.Render(title))
b.WriteString("\n\n")
// Entries with pagination
visibleEntries := m.entries
maxVisible := 10
startIdx := 0
if len(visibleEntries) > maxVisible {
// Show entries around cursor
startIdx = m.cursor - maxVisible/2
if startIdx < 0 {
startIdx = 0
}
endIdx := startIdx + maxVisible
if endIdx > len(visibleEntries) {
endIdx = len(visibleEntries)
startIdx = endIdx - maxVisible
if startIdx < 0 {
startIdx = 0
}
}
visibleEntries = m.entries[startIdx:endIdx]
}
for i, entry := range visibleEntries {
actualIdx := startIdx + i
isSelected := actualIdx == m.cursor
// Cursor indicator
cursor := " "
if isSelected {
cursor = CursorStyle.Render("> ")
}
// Key - use different styles for selected
var key string
if isSelected {
key = EntryKeySelected.Render(entry.Key)
} else {
key = EntryKeyStyle.Render(entry.Key)
}
// Tags
var tagsStr string
if len(entry.Tags) > 0 {
tags := make([]string, len(entry.Tags))
for i, tag := range entry.Tags {
tags[i] = "#" + tag
}
tagsStr = " " + TagsStyle.Render(strings.Join(tags, " "))
}
// Symlink indicator
var symlinkStr string
if entry.HasSymlink {
symlinkStr = " 🔗"
}
// Description (truncated)
var descStr string
if entry.Description != "" {
desc := entry.Description
maxDescLen := 40
if len(desc) > maxDescLen {
desc = desc[:maxDescLen-3] + "..."
}
if isSelected {
descStr = " " + MatchTextStyleSelected.Render("- "+desc)
} else {
descStr = " " + MatchTextStyle.Render("- "+desc)
}
}
line := fmt.Sprintf("%s%s%s%s%s", cursor, key, tagsStr, symlinkStr, descStr)
b.WriteString(line)
b.WriteString("\n")
}
// Scroll indicator
if len(m.entries) > maxVisible {
scrollInfo := fmt.Sprintf("\n Showing %d-%d of %d",
startIdx+1,
min(startIdx+maxVisible, len(m.entries)),
len(m.entries))
b.WriteString(CountStyle.Render(scrollInfo))
b.WriteString("\n")
}
// Help
help := "↑/k up • ↓/j down • g/G first/last • enter select • q/esc quit"
b.WriteString(HelpStyle.Render(help))
return b.String()
}
func (m ListModel) Selected() *registry.Entry {
return m.selected
}
func (m ListModel) Quitting() bool {
return m.quitting
}
func RunList(entries []*registry.Entry) (*registry.Entry, error) {
model := NewListModel(entries)
p := tea.NewProgram(model)
finalModel, err := p.Run()
if err != nil {
return nil, err
}
m := finalModel.(ListModel)
return m.Selected(), nil
}