mirror of
https://github.com/lukaszraczylo/lolcathost.git
synced 2026-10-12 04:55:13 +00:00
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.
434 lines
9.8 KiB
Go
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
|
|
}
|