Files
lolcathost/internal/tui/list.go
T
lukaszraczylo 22f8f7a008 refactor(tui): move to Bubble Tea, Lip Gloss and Bubbles v2
Switch imports to the charm.land v2 modules. Return tea.View from Model.View,
set alt screen and window title there, use KeyPressMsg and the "space" key
name, and call SetWidth on text inputs.
2026-10-04 10:12:33 +01:00

434 lines
9.8 KiB
Go

// Package tui provides the list view component.
package tui
import (
"fmt"
"strings"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
"github.com/lukaszraczylo/lolcathost/internal/protocol"
)
// EntryItem represents a displayable host entry.
type EntryItem struct {
Entry protocol.HostEntry
Pending bool
HasError bool
}
// ListView handles the list of host entries.
type ListView struct {
items []EntryItem
groups map[string][]int // group name -> indices in items
groupOrder []string // ordered group names
cursor int
width int
height int
}
// NewListView creates a new list view.
func NewListView() *ListView {
return &ListView{
groups: make(map[string][]int),
}
}
// SetItems updates the list items.
func (l *ListView) SetItems(entries []protocol.HostEntry) {
l.items = make([]EntryItem, len(entries))
l.groups = make(map[string][]int)
l.groupOrder = nil
groupSeen := make(map[string]bool)
for i, e := range entries {
l.items[i] = EntryItem{Entry: e}
if !groupSeen[e.Group] {
groupSeen[e.Group] = true
l.groupOrder = append(l.groupOrder, e.Group)
}
l.groups[e.Group] = append(l.groups[e.Group], i)
}
// Reset cursor if out of bounds
if l.cursor >= len(l.items) {
l.cursor = max(0, len(l.items)-1)
}
}
// SetSize sets the view dimensions.
func (l *ListView) SetSize(width, height int) {
l.width = width
l.height = height
}
// MoveUp moves the cursor up.
func (l *ListView) MoveUp() {
if l.cursor > 0 {
l.cursor--
}
}
// MoveDown moves the cursor down.
func (l *ListView) MoveDown() {
if l.cursor < len(l.items)-1 {
l.cursor++
}
}
// Selected returns the currently selected item.
func (l *ListView) Selected() *EntryItem {
if l.cursor >= 0 && l.cursor < len(l.items) {
return &l.items[l.cursor]
}
return nil
}
// SelectedAlias returns the alias of the selected item.
func (l *ListView) SelectedAlias() string {
if item := l.Selected(); item != nil {
return item.Entry.Alias
}
return ""
}
// GetAliases returns all available aliases.
func (l *ListView) GetAliases() []string {
aliases := make([]string, len(l.items))
for i, item := range l.items {
aliases[i] = item.Entry.Alias
}
return aliases
}
// SetPending marks an item as pending.
func (l *ListView) SetPending(alias string, pending bool) {
for i := range l.items {
if l.items[i].Entry.Alias == alias {
l.items[i].Pending = pending
break
}
}
}
// SetError marks an item as having an error.
func (l *ListView) SetError(alias string, hasError bool) {
for i := range l.items {
if l.items[i].Entry.Alias == alias {
l.items[i].HasError = hasError
break
}
}
}
// UpdateEntry updates an entry's enabled state.
func (l *ListView) UpdateEntry(alias string, enabled bool) {
for i := range l.items {
if l.items[i].Entry.Alias == alias {
l.items[i].Entry.Enabled = enabled
l.items[i].Pending = false
l.items[i].HasError = false
break
}
}
}
// Len returns the number of items.
func (l *ListView) Len() int {
return len(l.items)
}
// ActiveCount returns the number of enabled entries.
func (l *ListView) ActiveCount() int {
count := 0
for _, item := range l.items {
if item.Entry.Enabled {
count++
}
}
return count
}
// FindByAlias finds an item by alias.
func (l *ListView) FindByAlias(alias string) *EntryItem {
for i := range l.items {
if l.items[i].Entry.Alias == alias {
return &l.items[i]
}
}
return nil
}
// Filter filters items by search term.
func (l *ListView) Filter(term string) []EntryItem {
if term == "" {
return l.items
}
term = strings.ToLower(term)
var filtered []EntryItem
for _, item := range l.items {
if strings.Contains(strings.ToLower(item.Entry.Domain), term) ||
strings.Contains(strings.ToLower(item.Entry.Alias), term) ||
strings.Contains(strings.ToLower(item.Entry.IP), term) ||
strings.Contains(strings.ToLower(item.Entry.Group), term) {
filtered = append(filtered, item)
}
}
return filtered
}
// ViewFiltered renders the list filtered by search term.
func (l *ListView) ViewFiltered(searchTerm string) string {
if searchTerm == "" {
return l.View()
}
filtered := l.Filter(searchTerm)
if len(filtered) == 0 {
emptyStyle := lipgloss.NewStyle().Foreground(colorMuted)
return "\n" + emptyStyle.Render(fmt.Sprintf(" No results for '%s'. Press Esc to clear search.", searchTerm)) + "\n"
}
var sb strings.Builder
// Show search indicator
searchIndicator := lipgloss.NewStyle().
Foreground(colorWarning).
Bold(true).
Render(fmt.Sprintf(" Search: %s (%d results)", searchTerm, len(filtered)))
sb.WriteString(searchIndicator)
sb.WriteString("\n")
// Group header style - bright colors for dark terminals
groupHeaderStyle := lipgloss.NewStyle().
Bold(true).
Foreground(colorGroupHeader).
Background(lipgloss.Color("238")).
Padding(0, 1).
MarginTop(1)
// Organize filtered items by group
groupItems := make(map[string][]EntryItem)
var groupOrder []string
groupSeen := make(map[string]bool)
for _, item := range filtered {
group := item.Entry.Group
if !groupSeen[group] {
groupSeen[group] = true
groupOrder = append(groupOrder, group)
}
groupItems[group] = append(groupItems[group], item)
}
for _, groupName := range groupOrder {
items := groupItems[groupName]
if len(items) == 0 {
continue
}
// Group header
headerText := fmt.Sprintf(" %s (%d)", strings.ToUpper(groupName), len(items))
sb.WriteString(groupHeaderStyle.Render(headerText))
sb.WriteString("\n")
// Build rows for this group's table
var rows [][]string
for _, item := range items {
status := l.getStatusString(item)
rows = append(rows, []string{
truncate(item.Entry.Domain, 30),
truncate(item.Entry.IP, 15),
status,
})
}
// Create table for this group
t := table.New().
Border(lipgloss.HiddenBorder()).
Headers("DOMAIN", "IP ADDRESS", "STATUS").
Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style {
// Header row
if row == table.HeaderRow {
return lipgloss.NewStyle().
Bold(true).
Foreground(colorHeader).
Padding(0, 1)
}
baseStyle := lipgloss.NewStyle().Padding(0, 1)
if row >= 0 && row < len(items) {
item := items[row]
// Disabled rows are muted
if !item.Entry.Enabled && !item.Pending && !item.HasError {
return baseStyle.Foreground(colorMuted)
}
// Status column gets colored based on status
if col == 2 { // STATUS column
if item.HasError {
return baseStyle.Foreground(colorError)
}
if item.Pending {
return baseStyle.Foreground(colorWarning)
}
if item.Entry.Enabled {
return baseStyle.Foreground(colorSuccess)
}
}
}
return baseStyle
})
sb.WriteString(t.Render())
sb.WriteString("\n")
}
return sb.String()
}
// GetGroups returns all group names.
func (l *ListView) GetGroups() []string {
return l.groupOrder
}
// View renders the list with groups as headers.
func (l *ListView) View() string {
if len(l.items) == 0 {
emptyStyle := lipgloss.NewStyle().Foreground(colorMuted)
return "\n" + emptyStyle.Render(" No host entries configured. Press 'n' to add a new entry.") + "\n"
}
var sb strings.Builder
// Group header style - bright colors for dark terminals
groupHeaderStyle := lipgloss.NewStyle().
Bold(true).
Foreground(colorGroupHeader).
Background(lipgloss.Color("238")).
Padding(0, 1).
MarginTop(1)
for _, groupName := range l.groupOrder {
indices := l.groups[groupName]
if len(indices) == 0 {
continue
}
// Group header
headerText := fmt.Sprintf(" %s (%d)", strings.ToUpper(groupName), len(indices))
sb.WriteString(groupHeaderStyle.Render(headerText))
sb.WriteString("\n")
// Build rows for this group's table
var rows [][]string
// Store actual item indices for cursor matching
itemIndices := make([]int, len(indices))
copy(itemIndices, indices)
for _, idx := range indices {
item := l.items[idx]
status := l.getStatusString(item)
rows = append(rows, []string{
truncate(item.Entry.Domain, 30),
truncate(item.Entry.IP, 15),
status,
})
}
// Create table for this group
t := table.New().
Border(lipgloss.HiddenBorder()).
Headers("DOMAIN", "IP ADDRESS", "STATUS").
Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style {
// Header row
if row == table.HeaderRow {
return lipgloss.NewStyle().
Bold(true).
Foreground(colorHeader).
Padding(0, 1)
}
baseStyle := lipgloss.NewStyle().Padding(0, 1)
// Check if this row is selected
if row >= 0 && row < len(itemIndices) {
actualItemIdx := itemIndices[row]
isSelected := actualItemIdx == l.cursor
item := l.items[actualItemIdx]
// Selected row gets background highlight
if isSelected {
return baseStyle.
Background(colorSelectedBg).
Foreground(colorSelectedFg)
}
// Disabled rows are muted
if !item.Entry.Enabled && !item.Pending && !item.HasError {
return baseStyle.Foreground(colorMuted)
}
// Status column gets colored based on status
if col == 2 { // STATUS column
if item.HasError {
return baseStyle.Foreground(colorError)
}
if item.Pending {
return baseStyle.Foreground(colorWarning)
}
if item.Entry.Enabled {
return baseStyle.Foreground(colorSuccess)
}
}
}
return baseStyle
})
sb.WriteString(t.Render())
sb.WriteString("\n")
}
return sb.String()
}
func (l *ListView) getStatusString(item EntryItem) string {
if item.HasError {
return "✗ Error"
}
if item.Pending {
return "◐ Pending"
}
if item.Entry.Enabled {
return "● Active"
}
return "○ Disabled"
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
if maxLen <= 3 {
return s[:maxLen]
}
return s[:maxLen-3] + "..."
}
func max(a, b int) int {
if a > b {
return a
}
return b
}