Initial commit.

This commit is contained in:
2025-11-28 02:50:25 +00:00
commit 22552aec99
41 changed files with 10626 additions and 0 deletions
+904
View File
@@ -0,0 +1,904 @@
// Package tui provides the main Bubble Tea application.
package tui
import (
"context"
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/lukaszraczylo/lolcathost/internal/client"
"github.com/lukaszraczylo/lolcathost/internal/config"
"github.com/lukaszraczylo/lolcathost/internal/protocol"
"github.com/lukaszraczylo/lolcathost/internal/version"
)
// ViewMode represents the current view mode.
type ViewMode int
const (
ViewList ViewMode = iota
ViewForm
ViewPresets
ViewGroups
ViewHelp
ViewSearch
)
// Model is the main Bubble Tea model.
type Model struct {
// Client
client *client.Client
connected bool
// Config
configPath string
config *config.Manager
// Views
mode ViewMode
list *ListView
form *Form
presetPicker *PresetPicker
groupPicker *GroupPicker
searchInput textinput.Model
// State
width int
height int
message string
messageStyle string // "error" or "success"
messageTime time.Time
searchTerm string
allGroups []string // All groups including empty ones
// Update notification
updateAvailable bool
updateVersion string
updateURL string
// Version info for update checking
version string
githubOwner string
githubRepo string
}
// Message types
type (
connectMsg struct{ err error }
refreshMsg struct {
entries []protocol.HostEntry
err error
}
toggleMsg struct {
alias string
err error
}
presetMsg struct {
name string
err error
}
addMsg struct {
domain string
err error
}
deleteMsg struct {
alias string
err error
}
addPresetMsg struct {
name string
err error
}
deletePresetMsg struct {
name string
err error
}
refreshPresetsMsg struct {
presets []protocol.PresetInfo
err error
}
addGroupMsg struct {
name string
err error
}
renameGroupMsg struct {
name string
err error
}
deleteGroupMsg struct {
name string
err error
}
refreshGroupsMsg struct {
groups []string
err error
}
clearMsgMsg struct{}
tickMsg struct{}
updateMsg struct {
version string
url string
}
)
// NewModel creates a new TUI model.
func NewModel(socketPath, configPath string) *Model {
searchInput := textinput.New()
searchInput.Placeholder = "Search..."
searchInput.CharLimit = 100
searchInput.Width = 50
return &Model{
client: client.New(socketPath),
configPath: configPath,
config: config.NewManager(configPath),
list: NewListView(),
form: NewForm(),
presetPicker: NewPresetPicker(),
groupPicker: NewGroupPicker(),
searchInput: searchInput,
mode: ViewList,
}
}
// Init initializes the model.
func (m *Model) Init() tea.Cmd {
return tea.Batch(
m.connect(),
tea.SetWindowTitle("lolcathost"),
m.tick(),
m.checkForUpdate(),
)
}
func (m *Model) connect() tea.Cmd {
return func() tea.Msg {
if err := m.client.Connect(); err != nil {
return connectMsg{err: err}
}
return connectMsg{err: nil}
}
}
func (m *Model) refresh() tea.Cmd {
return func() tea.Msg {
entries, err := m.client.List()
if err != nil {
return refreshMsg{entries: nil, err: err}
}
return refreshMsg{entries: entries, err: nil}
}
}
func (m *Model) toggle(alias string, enabled bool) tea.Cmd {
return func() tea.Msg {
_, err := m.client.Set(alias, enabled, false)
return toggleMsg{alias: alias, err: err}
}
}
func (m *Model) applyPreset(name string) tea.Cmd {
return func() tea.Msg {
err := m.client.ApplyPreset(name)
return presetMsg{name: name, err: err}
}
}
func (m *Model) addHost(domain, ip, alias, group string) tea.Cmd {
return func() tea.Msg {
_, err := m.client.Add(domain, ip, alias, group, false)
return addMsg{domain: domain, err: err}
}
}
func (m *Model) deleteHost(alias string) tea.Cmd {
return func() tea.Msg {
err := m.client.Delete(alias)
return deleteMsg{alias: alias, err: err}
}
}
func (m *Model) addPreset(name string, enable, disable []string) tea.Cmd {
return func() tea.Msg {
err := m.client.AddPreset(name, enable, disable)
return addPresetMsg{name: name, err: err}
}
}
func (m *Model) deletePreset(name string) tea.Cmd {
return func() tea.Msg {
err := m.client.DeletePreset(name)
return deletePresetMsg{name: name, err: err}
}
}
func (m *Model) refreshPresets() tea.Cmd {
return func() tea.Msg {
presets, err := m.client.ListPresets()
return refreshPresetsMsg{presets: presets, err: err}
}
}
func (m *Model) addGroup(name string) tea.Cmd {
return func() tea.Msg {
err := m.client.AddGroup(name)
return addGroupMsg{name: name, err: err}
}
}
func (m *Model) renameGroup(oldName, newName string) tea.Cmd {
return func() tea.Msg {
err := m.client.RenameGroup(oldName, newName)
return renameGroupMsg{name: newName, err: err}
}
}
func (m *Model) deleteGroup(name string) tea.Cmd {
return func() tea.Msg {
err := m.client.DeleteGroup(name)
return deleteGroupMsg{name: name, err: err}
}
}
func (m *Model) refreshGroups() tea.Cmd {
return func() tea.Msg {
groups, err := m.client.ListGroups()
return refreshGroupsMsg{groups: groups, err: err}
}
}
func (m *Model) tick() tea.Cmd {
return tea.Tick(time.Second*3, func(t time.Time) tea.Msg {
return tickMsg{}
})
}
func (m *Model) clearMsg() tea.Cmd {
return tea.Tick(time.Second*3, func(t time.Time) tea.Msg {
return clearMsgMsg{}
})
}
func (m *Model) checkForUpdate() tea.Cmd {
if m.githubOwner == "" || m.githubRepo == "" {
return nil
}
return func() tea.Msg {
checker := version.NewChecker(m.githubOwner, m.githubRepo, m.version)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if update := checker.CheckForUpdate(ctx); update != nil {
return updateMsg{version: update.LatestVersion, url: update.ReleaseURL}
}
return nil
}
}
// Update handles messages.
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.list.SetSize(msg.Width, msg.Height-10)
m.form.SetSize(msg.Width, msg.Height)
m.presetPicker.SetSize(msg.Width, msg.Height)
m.groupPicker.SetSize(msg.Width, msg.Height)
// Set search input width
searchWidth := msg.Width - 20
if searchWidth > 60 {
searchWidth = 60
}
m.searchInput.Width = searchWidth
case tea.KeyMsg:
cmd := m.handleKey(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
case connectMsg:
if msg.err != nil {
m.connected = false
m.setError(fmt.Sprintf("Failed to connect: %v", msg.err))
} else {
m.connected = true
cmds = append(cmds, m.refresh())
cmds = append(cmds, m.refreshPresets())
cmds = append(cmds, m.refreshGroups())
m.loadConfig()
}
case refreshMsg:
if msg.err != nil {
m.setError(fmt.Sprintf("Refresh failed: %v", msg.err))
// Mark as disconnected to trigger reconnect
m.connected = false
m.client.Close()
} else if msg.entries != nil {
m.list.SetItems(msg.entries)
}
case toggleMsg:
if msg.err != nil {
m.list.SetError(msg.alias, true)
m.setError(fmt.Sprintf("Toggle failed: %v", msg.err))
} else {
m.list.SetPending(msg.alias, false)
cmds = append(cmds, m.refresh())
m.setSuccess("Entry toggled")
}
case presetMsg:
if msg.err != nil {
m.setError(fmt.Sprintf("Preset failed: %v", msg.err))
} else {
cmds = append(cmds, m.refresh())
m.setSuccess(fmt.Sprintf("Applied preset: %s", msg.name))
}
m.mode = ViewList
case addMsg:
if msg.err != nil {
m.setError(fmt.Sprintf("Add failed: %v", msg.err))
} else {
cmds = append(cmds, m.refresh())
m.setSuccess(fmt.Sprintf("Added host: %s", msg.domain))
}
m.mode = ViewList
case deleteMsg:
if msg.err != nil {
m.setError(fmt.Sprintf("Delete failed: %v", msg.err))
} else {
cmds = append(cmds, m.refresh())
m.setSuccess(fmt.Sprintf("Deleted: %s", msg.alias))
}
case addPresetMsg:
if msg.err != nil {
m.setError(fmt.Sprintf("Add preset failed: %v", msg.err))
} else {
cmds = append(cmds, m.refreshPresets())
m.setSuccess(fmt.Sprintf("Added preset: %s", msg.name))
}
m.presetPicker.CancelForm()
case deletePresetMsg:
if msg.err != nil {
m.setError(fmt.Sprintf("Delete preset failed: %v", msg.err))
} else {
cmds = append(cmds, m.refreshPresets())
m.setSuccess(fmt.Sprintf("Deleted preset: %s", msg.name))
}
m.presetPicker.CancelForm()
case refreshPresetsMsg:
if msg.err == nil && msg.presets != nil {
m.presetPicker.SetPresetsWithInfo(msg.presets)
}
case addGroupMsg:
if msg.err != nil {
m.setError(fmt.Sprintf("Add group failed: %v", msg.err))
} else {
cmds = append(cmds, m.refreshGroups())
cmds = append(cmds, m.refresh()) // Refresh list to show new group
m.setSuccess(fmt.Sprintf("Added group: %s", msg.name))
}
m.groupPicker.CancelForm()
case renameGroupMsg:
if msg.err != nil {
m.setError(fmt.Sprintf("Rename group failed: %v", msg.err))
} else {
cmds = append(cmds, m.refreshGroups())
cmds = append(cmds, m.refresh())
m.setSuccess(fmt.Sprintf("Renamed group to: %s", msg.name))
}
m.groupPicker.CancelForm()
case deleteGroupMsg:
if msg.err != nil {
m.setError(fmt.Sprintf("Delete group failed: %v", msg.err))
} else {
cmds = append(cmds, m.refreshGroups())
cmds = append(cmds, m.refresh())
m.setSuccess(fmt.Sprintf("Deleted group: %s", msg.name))
}
m.groupPicker.CancelForm()
case refreshGroupsMsg:
if msg.err == nil && msg.groups != nil {
m.allGroups = msg.groups
m.groupPicker.SetGroups(msg.groups)
}
case clearMsgMsg:
if time.Since(m.messageTime) >= time.Second*3 {
m.message = ""
}
case tickMsg:
// Reconnect if disconnected
if !m.connected {
cmds = append(cmds, m.connect())
}
cmds = append(cmds, m.tick())
case updateMsg:
if msg.version != "" {
m.updateAvailable = true
m.updateVersion = msg.version
m.updateURL = msg.url
}
}
return m, tea.Batch(cmds...)
}
func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
// Global keys
switch msg.String() {
case "ctrl+c":
return tea.Quit
}
// Mode-specific keys
switch m.mode {
case ViewList:
return m.handleListKey(msg)
case ViewForm:
return m.handleFormKey(msg)
case ViewPresets:
return m.handlePresetKey(msg)
case ViewGroups:
return m.handleGroupKey(msg)
case ViewHelp:
return m.handleHelpKey(msg)
case ViewSearch:
return m.handleSearchKey(msg)
}
return nil
}
func (m *Model) handleListKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "q":
return tea.Quit
case "esc":
// Clear search if active
if m.searchTerm != "" {
m.searchTerm = ""
m.searchInput.Reset()
}
case "up", "k":
m.list.MoveUp()
case "down", "j":
m.list.MoveDown()
case " ", "enter":
return m.toggleSelected()
case "n":
m.mode = ViewForm
m.form.SetGroups(m.allGroups)
m.form.Init()
case "e":
if item := m.list.Selected(); item != nil {
m.mode = ViewForm
m.form.SetGroups(m.allGroups)
m.form.InitEdit(item.Entry.Domain, item.Entry.IP, item.Entry.Alias, item.Entry.Group)
}
case "d":
if item := m.list.Selected(); item != nil {
return m.deleteHost(item.Entry.Alias)
}
case "p":
m.mode = ViewPresets
case "g":
m.mode = ViewGroups
return m.refreshGroups()
case "/":
m.mode = ViewSearch
m.searchInput.Focus()
case "?":
m.mode = ViewHelp
case "r":
return m.refresh()
}
return nil
}
func (m *Model) handleFormKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "esc":
m.mode = ViewList
return nil
case "enter":
if errMsg := m.form.Validate(); errMsg != "" {
m.setError(errMsg)
return m.clearMsg()
}
domain, ip, group := m.form.Values()
if m.form.IsEdit() {
// For edit, delete old and add new (simple approach)
oldAlias := m.form.EditAlias()
return tea.Sequence(
func() tea.Msg {
m.client.Delete(oldAlias)
return nil
},
m.addHost(domain, ip, "", group), // Empty alias = auto-generate
)
}
return m.addHost(domain, ip, "", group) // Empty alias = auto-generate
}
return m.form.Update(msg)
}
func (m *Model) handlePresetKey(msg tea.KeyMsg) tea.Cmd {
// Handle based on preset picker mode
switch m.presetPicker.Mode() {
case PresetModeSelect:
return m.handlePresetSelectKey(msg)
case PresetModeAdd, PresetModeEdit:
return m.handlePresetFormKey(msg)
case PresetModeConfirmDelete:
return m.handlePresetDeleteKey(msg)
}
return nil
}
func (m *Model) handlePresetSelectKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "esc", "q":
m.mode = ViewList
case "up", "k":
m.presetPicker.MoveUp()
case "down", "j":
m.presetPicker.MoveDown()
case "enter":
if preset := m.presetPicker.Selected(); preset != "" {
return m.applyPreset(preset)
}
case "n":
m.presetPicker.InitAdd()
case "e":
m.presetPicker.InitEdit()
case "d":
m.presetPicker.InitDelete()
}
return nil
}
func (m *Model) handlePresetFormKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "esc":
m.presetPicker.CancelForm()
return nil
case "enter":
if errMsg := m.presetPicker.ValidateForm(); errMsg != "" {
m.setError(errMsg)
return m.clearMsg()
}
name, enable, disable := m.presetPicker.FormValues()
if m.presetPicker.IsEdit() {
// For edit, delete old and add new
oldName := m.presetPicker.EditName()
return tea.Sequence(
func() tea.Msg {
m.client.DeletePreset(oldName)
return nil
},
m.addPreset(name, enable, disable),
)
}
return m.addPreset(name, enable, disable)
}
return m.presetPicker.Update(msg)
}
func (m *Model) handlePresetDeleteKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "y", "Y":
if preset := m.presetPicker.Selected(); preset != "" {
return m.deletePreset(preset)
}
m.presetPicker.CancelForm()
case "n", "N", "esc":
m.presetPicker.CancelForm()
}
return nil
}
func (m *Model) handleGroupKey(msg tea.KeyMsg) tea.Cmd {
// Handle based on group picker mode
switch m.groupPicker.Mode() {
case GroupModeSelect:
return m.handleGroupSelectKey(msg)
case GroupModeAdd, GroupModeRename:
return m.handleGroupFormKey(msg)
case GroupModeConfirmDelete:
return m.handleGroupDeleteKey(msg)
}
return nil
}
func (m *Model) handleGroupSelectKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "esc", "q":
m.mode = ViewList
case "up", "k":
m.groupPicker.MoveUp()
case "down", "j":
m.groupPicker.MoveDown()
case "n":
m.groupPicker.InitAdd()
case "r":
m.groupPicker.InitRename()
case "d":
m.groupPicker.InitDelete()
}
return nil
}
func (m *Model) handleGroupFormKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "esc":
m.groupPicker.CancelForm()
return nil
case "enter":
if errMsg := m.groupPicker.ValidateForm(); errMsg != "" {
m.setError(errMsg)
return m.clearMsg()
}
name := m.groupPicker.FormValue()
if m.groupPicker.IsRename() {
oldName := m.groupPicker.EditName()
return m.renameGroup(oldName, name)
}
return m.addGroup(name)
}
return m.groupPicker.Update(msg)
}
func (m *Model) handleGroupDeleteKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "y", "Y":
if group := m.groupPicker.Selected(); group != "" {
return m.deleteGroup(group)
}
m.groupPicker.CancelForm()
case "n", "N", "esc":
m.groupPicker.CancelForm()
}
return nil
}
func (m *Model) handleHelpKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "esc", "q", "?":
m.mode = ViewList
}
return nil
}
func (m *Model) handleSearchKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "esc":
m.mode = ViewList
m.searchTerm = ""
m.searchInput.Reset()
return nil
case "enter":
m.searchTerm = m.searchInput.Value()
m.mode = ViewList
return nil
}
var cmd tea.Cmd
m.searchInput, cmd = m.searchInput.Update(msg)
return cmd
}
func (m *Model) toggleSelected() tea.Cmd {
item := m.list.Selected()
if item == nil {
return nil
}
m.list.SetPending(item.Entry.Alias, true)
return m.toggle(item.Entry.Alias, !item.Entry.Enabled)
}
func (m *Model) loadConfig() {
if err := m.config.Load(); err != nil {
return
}
cfg := m.config.Get()
if cfg == nil {
return
}
var presetNames []string
for _, p := range cfg.Presets {
presetNames = append(presetNames, p.Name)
}
m.presetPicker.SetPresets(presetNames)
}
func (m *Model) setError(msg string) {
m.message = msg
m.messageStyle = "error"
m.messageTime = time.Now()
}
func (m *Model) setSuccess(msg string) {
m.message = msg
m.messageStyle = "success"
m.messageTime = time.Now()
}
// View renders the UI.
func (m *Model) View() string {
var sb strings.Builder
// Title with version
title := titleStyle.Render("lolcathost - Host Management")
sb.WriteString(title)
// Update notification
if m.updateAvailable {
sb.WriteString(" ")
sb.WriteString(updateStyle.Render(fmt.Sprintf("Update available: v%s", m.updateVersion)))
}
sb.WriteString("\n\n")
// Main content based on mode
switch m.mode {
case ViewList:
sb.WriteString(m.list.ViewFiltered(m.searchTerm))
case ViewForm:
sb.WriteString(m.form.View())
case ViewPresets:
sb.WriteString(m.presetPicker.View())
case ViewGroups:
sb.WriteString(m.groupPicker.View())
case ViewHelp:
sb.WriteString(m.helpView())
case ViewSearch:
sb.WriteString(m.searchView())
}
// Message
if m.message != "" {
sb.WriteString("\n")
if m.messageStyle == "error" {
sb.WriteString(errorMsgStyle.Render(m.message))
} else {
sb.WriteString(successMsgStyle.Render(m.message))
}
}
// Calculate remaining space for footer positioning
currentContent := sb.String()
currentLines := strings.Count(currentContent, "\n") + 1
// Fill space to push footer to bottom (reserve 3 lines for footer)
footerHeight := 3
remainingLines := m.height - currentLines - footerHeight
if remainingLines > 0 {
sb.WriteString(strings.Repeat("\n", remainingLines))
}
// Footer (help bar + status bar)
if m.mode == ViewList {
sb.WriteString("\n")
sb.WriteString(m.helpBar())
}
sb.WriteString("\n")
sb.WriteString(m.statusBar())
return sb.String()
}
func (m *Model) helpBar() string {
return helpBarStyle.Render(fmt.Sprintf("%s/%s: Navigate %s: Toggle %s: New %s: Edit %s: Delete %s: Presets %s: Groups %s: Search %s: Help %s: Quit",
helpKeyStyle.Render("↑↓"),
helpKeyStyle.Render("jk"),
helpKeyStyle.Render("Space"),
helpKeyStyle.Render("n"),
helpKeyStyle.Render("e"),
helpKeyStyle.Render("d"),
helpKeyStyle.Render("p"),
helpKeyStyle.Render("g"),
helpKeyStyle.Render("/"),
helpKeyStyle.Render("?"),
helpKeyStyle.Render("q")))
}
func (m *Model) statusBar() string {
var status string
if m.connected {
status = connectedStyle.String()
} else {
status = disconnectedStyle.String()
}
active := fmt.Sprintf("%d active", m.list.ActiveCount())
total := fmt.Sprintf("%d total", m.list.Len())
return statusBarStyle.Render(fmt.Sprintf("%s | %s | %s", status, active, total))
}
func (m *Model) helpView() string {
var sb strings.Builder
sb.WriteString(titleStyle.Render("Help"))
sb.WriteString("\n\n")
help := []struct{ key, desc string }{
{"↑/↓ or j/k", "Navigate up/down"},
{"Space/Enter", "Toggle entry on/off"},
{"n", "Add new entry"},
{"e", "Edit selected entry"},
{"d", "Delete selected entry"},
{"p", "Open preset manager"},
{"g", "Open group manager"},
{"/", "Search"},
{"r", "Refresh list"},
{"?", "Toggle this help"},
{"q", "Quit"},
}
for _, h := range help {
sb.WriteString(fmt.Sprintf(" %s %s\n",
helpKeyStyle.Width(15).Render(h.key),
helpDescStyle.Render(h.desc)))
}
sb.WriteString("\n")
sb.WriteString(helpDescStyle.Render("Press ? or Esc to close"))
return dialogStyle.Render(sb.String())
}
func (m *Model) searchView() string {
var sb strings.Builder
sb.WriteString(titleStyle.Render("Search"))
sb.WriteString("\n\n")
sb.WriteString(inputFocusStyle.Render(m.searchInput.View()))
sb.WriteString("\n\n")
sb.WriteString(helpDescStyle.Render("Enter to search • Esc to cancel"))
return dialogStyle.Render(sb.String())
}
// Run starts the TUI application.
func Run(socketPath, configPath string) error {
return RunWithVersion(socketPath, configPath, "dev", "", "")
}
// RunWithVersion starts the TUI application with version info for update checking.
func RunWithVersion(socketPath, configPath, version, githubOwner, githubRepo string) error {
m := NewModel(socketPath, configPath)
m.version = version
m.githubOwner = githubOwner
m.githubRepo = githubRepo
p := tea.NewProgram(m, tea.WithAltScreen())
_, err := p.Run()
return err
}
+336
View File
@@ -0,0 +1,336 @@
// Package tui provides the form component for adding/editing entries.
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
// FormMode represents the form mode.
type FormMode int
const (
FormModeAdd FormMode = iota
FormModeEdit
)
// FormField represents a form field index.
type FormField int
const (
FieldDomain FormField = iota
FieldIP
FieldGroup
FieldCount
)
// Form handles the add/edit entry form.
type Form struct {
mode FormMode
fields []textinput.Model
focus FormField
width int
height int
editAlias string // Original alias when editing
// Group dropdown
groups []string
groupCursor int
groupFocused bool
}
// NewForm creates a new form.
func NewForm() *Form {
fields := make([]textinput.Model, FieldCount)
// Domain field
fields[FieldDomain] = textinput.New()
fields[FieldDomain].Placeholder = "example.com"
fields[FieldDomain].CharLimit = 253
// IP field
fields[FieldIP] = textinput.New()
fields[FieldIP].Placeholder = "127.0.0.1"
fields[FieldIP].CharLimit = 45 // IPv6 max
// Group field (not used as text input, but kept for compatibility)
fields[FieldGroup] = textinput.New()
fields[FieldGroup].Placeholder = "development"
fields[FieldGroup].CharLimit = 63
return &Form{
fields: fields,
focus: FieldDomain,
groups: []string{"default"},
}
}
// SetGroups sets the available groups for the dropdown.
func (f *Form) SetGroups(groups []string) {
if len(groups) == 0 {
f.groups = []string{"default"}
} else {
f.groups = groups
}
// Reset cursor if out of bounds
if f.groupCursor >= len(f.groups) {
f.groupCursor = 0
}
}
// Init initializes the form for adding a new entry.
func (f *Form) Init() {
f.mode = FormModeAdd
f.editAlias = ""
for i := range f.fields {
f.fields[i].Reset()
}
f.fields[FieldIP].SetValue("127.0.0.1")
f.groupCursor = 0
f.groupFocused = false
f.focus = FieldDomain
f.fields[FieldDomain].Focus()
}
// InitEdit initializes the form for editing an existing entry.
func (f *Form) InitEdit(domain, ip, alias, group string) {
f.mode = FormModeEdit
f.editAlias = alias
f.fields[FieldDomain].SetValue(domain)
f.fields[FieldIP].SetValue(ip)
// Find the group in the list
f.groupCursor = 0
for i, g := range f.groups {
if g == group {
f.groupCursor = i
break
}
}
f.groupFocused = false
f.focus = FieldDomain
f.fields[FieldDomain].Focus()
}
// SetSize sets the form dimensions.
func (f *Form) SetSize(width, height int) {
f.width = width
f.height = height
inputWidth := min(50, width-10)
for i := range f.fields {
f.fields[i].Width = inputWidth
}
}
// Update handles input events.
func (f *Form) Update(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) {
case tea.KeyMsg:
// Handle group dropdown navigation
if f.focus == FieldGroup {
switch msg.String() {
case "tab":
f.nextField()
return nil
case "shift+tab":
f.prevField()
return nil
case "up", "k":
if f.groupCursor > 0 {
f.groupCursor--
}
return nil
case "down", "j":
if f.groupCursor < len(f.groups)-1 {
f.groupCursor++
}
return nil
case "left":
if f.groupCursor > 0 {
f.groupCursor--
}
return nil
case "right":
if f.groupCursor < len(f.groups)-1 {
f.groupCursor++
}
return nil
}
return nil
}
// Handle text input fields
switch msg.String() {
case "tab", "down":
f.nextField()
return nil
case "shift+tab", "up":
f.prevField()
return nil
}
}
// Update the focused text field (only for Domain and IP)
if f.focus != FieldGroup {
var cmd tea.Cmd
f.fields[f.focus], cmd = f.fields[f.focus].Update(msg)
return cmd
}
return nil
}
func (f *Form) nextField() {
if f.focus != FieldGroup {
f.fields[f.focus].Blur()
}
f.focus = (f.focus + 1) % FieldCount
if f.focus != FieldGroup {
f.fields[f.focus].Focus()
}
}
func (f *Form) prevField() {
if f.focus != FieldGroup {
f.fields[f.focus].Blur()
}
f.focus = (f.focus - 1 + FieldCount) % FieldCount
if f.focus != FieldGroup {
f.fields[f.focus].Focus()
}
}
// Values returns the form values (domain, ip, group).
func (f *Form) Values() (domain, ip, group string) {
group = ""
if f.groupCursor < len(f.groups) {
group = f.groups[f.groupCursor]
}
return strings.TrimSpace(f.fields[FieldDomain].Value()),
strings.TrimSpace(f.fields[FieldIP].Value()),
group
}
// EditAlias returns the original alias when editing.
func (f *Form) EditAlias() string {
return f.editAlias
}
// IsEdit returns true if in edit mode.
func (f *Form) IsEdit() bool {
return f.mode == FormModeEdit
}
// Validate validates the form values.
func (f *Form) Validate() string {
domain, ip, group := f.Values()
if domain == "" {
return "Domain is required"
}
if ip == "" {
return "IP address is required"
}
if group == "" {
return "Group is required"
}
return ""
}
// View renders the form.
func (f *Form) View() string {
var sb strings.Builder
title := "Add New Entry"
if f.mode == FormModeEdit {
title = "Edit Entry"
}
sb.WriteString(titleStyle.Render(title))
sb.WriteString("\n\n")
// Domain field
sb.WriteString(inputLabelStyle.Render("Domain:"))
sb.WriteString("\n")
style := inputStyle
if f.focus == FieldDomain {
style = inputFocusStyle
}
sb.WriteString(style.Render(f.fields[FieldDomain].View()))
sb.WriteString("\n\n")
// IP field
sb.WriteString(inputLabelStyle.Render("IP Address:"))
sb.WriteString("\n")
style = inputStyle
if f.focus == FieldIP {
style = inputFocusStyle
}
sb.WriteString(style.Render(f.fields[FieldIP].View()))
sb.WriteString("\n\n")
// Group dropdown
sb.WriteString(inputLabelStyle.Render("Group:"))
sb.WriteString("\n")
sb.WriteString(f.renderGroupDropdown())
sb.WriteString("\n\n")
sb.WriteString("\n")
sb.WriteString(helpDescStyle.Render("Tab/↓ next • Shift+Tab/↑ prev • ←→ select group • Enter save • Esc cancel"))
return dialogStyle.Render(sb.String())
}
func (f *Form) renderGroupDropdown() string {
isFocused := f.focus == FieldGroup
// Get current group name
currentGroup := "default"
if f.groupCursor < len(f.groups) {
currentGroup = f.groups[f.groupCursor]
}
// Build the selector content: ◀ group_name ▶
var content string
if isFocused {
// Show arrows when focused
leftArrow := "◀"
rightArrow := "▶"
if f.groupCursor == 0 {
leftArrow = " " // dim or hide left arrow at start
}
if f.groupCursor >= len(f.groups)-1 {
rightArrow = " " // dim or hide right arrow at end
}
content = leftArrow + " " + currentGroup + " " + rightArrow
} else {
content = " " + currentGroup + " "
}
// Show position indicator if multiple groups
if len(f.groups) > 1 {
content += fmt.Sprintf(" (%d/%d)", f.groupCursor+1, len(f.groups))
}
// Apply border style
if isFocused {
return inputFocusStyle.Render(content)
}
return inputStyle.Render(content)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+232
View File
@@ -0,0 +1,232 @@
// Package tui provides the group management component.
package tui
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
// GroupMode represents the group view mode.
type GroupMode int
const (
GroupModeSelect GroupMode = iota
GroupModeAdd
GroupModeRename
GroupModeConfirmDelete
)
// GroupPicker handles the group selection and management UI.
type GroupPicker struct {
groups []string
cursor int
width int
height int
mode GroupMode
input textinput.Model
editName string // Original name when renaming
}
// NewGroupPicker creates a new group picker.
func NewGroupPicker() *GroupPicker {
input := textinput.New()
input.Placeholder = "group-name"
input.CharLimit = 63
return &GroupPicker{
input: input,
mode: GroupModeSelect,
}
}
// SetGroups updates the available groups.
func (g *GroupPicker) SetGroups(groups []string) {
g.groups = groups
if g.cursor >= len(groups) {
g.cursor = max(0, len(groups)-1)
}
}
// SetSize sets the picker dimensions.
func (g *GroupPicker) SetSize(width, height int) {
g.width = width
g.height = height
g.input.Width = min(50, width-10)
}
// MoveUp moves the cursor up.
func (g *GroupPicker) MoveUp() {
if g.cursor > 0 {
g.cursor--
}
}
// MoveDown moves the cursor down.
func (g *GroupPicker) MoveDown() {
if g.cursor < len(g.groups)-1 {
g.cursor++
}
}
// Selected returns the currently selected group.
func (g *GroupPicker) Selected() string {
if g.cursor >= 0 && g.cursor < len(g.groups) {
return g.groups[g.cursor]
}
return ""
}
// Len returns the number of groups.
func (g *GroupPicker) Len() int {
return len(g.groups)
}
// Mode returns the current mode.
func (g *GroupPicker) Mode() GroupMode {
return g.mode
}
// InitAdd initializes the form for adding a new group.
func (g *GroupPicker) InitAdd() {
g.mode = GroupModeAdd
g.editName = ""
g.input.Reset()
g.input.Focus()
}
// InitRename initializes the form for renaming an existing group.
func (g *GroupPicker) InitRename() {
selected := g.Selected()
if selected == "" {
return
}
g.mode = GroupModeRename
g.editName = selected
g.input.SetValue(selected)
g.input.Focus()
}
// InitDelete starts delete confirmation.
func (g *GroupPicker) InitDelete() {
if g.Selected() == "" {
return
}
g.mode = GroupModeConfirmDelete
}
// CancelForm cancels the current form operation.
func (g *GroupPicker) CancelForm() {
g.mode = GroupModeSelect
g.editName = ""
g.input.Reset()
g.input.Blur()
}
// Update handles input events for form mode.
func (g *GroupPicker) Update(msg tea.KeyMsg) tea.Cmd {
var cmd tea.Cmd
g.input, cmd = g.input.Update(msg)
return cmd
}
// FormValue returns the form input value.
func (g *GroupPicker) FormValue() string {
return strings.TrimSpace(g.input.Value())
}
// EditName returns the original name when renaming.
func (g *GroupPicker) EditName() string {
return g.editName
}
// IsRename returns true if in rename mode.
func (g *GroupPicker) IsRename() bool {
return g.mode == GroupModeRename
}
// ValidateForm validates the form value.
func (g *GroupPicker) ValidateForm() string {
value := g.FormValue()
if value == "" {
return "Group name is required"
}
return ""
}
// View renders the group picker.
func (g *GroupPicker) View() string {
switch g.mode {
case GroupModeAdd, GroupModeRename:
return g.formView()
case GroupModeConfirmDelete:
return g.deleteView()
default:
return g.selectView()
}
}
func (g *GroupPicker) selectView() string {
var sb strings.Builder
sb.WriteString(titleStyle.Render("Groups"))
sb.WriteString("\n\n")
if len(g.groups) == 0 {
sb.WriteString(helpDescStyle.Render("No groups configured."))
sb.WriteString("\n\n")
sb.WriteString(helpDescStyle.Render("Press 'n' to create one"))
} else {
for i, group := range g.groups {
if i == g.cursor {
sb.WriteString(presetSelectedStyle.Render("▸ " + group))
} else {
sb.WriteString(presetItemStyle.Render(" " + group))
}
sb.WriteString("\n")
}
}
sb.WriteString("\n\n")
sb.WriteString(helpDescStyle.Render("↑↓ navigate • n new • r rename • d delete • Esc back"))
return dialogStyle.Render(sb.String())
}
func (g *GroupPicker) formView() string {
var sb strings.Builder
title := "Add New Group"
if g.mode == GroupModeRename {
title = "Rename Group"
}
sb.WriteString(titleStyle.Render(title))
sb.WriteString("\n\n")
sb.WriteString(inputLabelStyle.Render("Name:"))
sb.WriteString("\n")
sb.WriteString(inputFocusStyle.Render(g.input.View()))
sb.WriteString("\n\n")
sb.WriteString(helpDescStyle.Render("Enter save • Esc cancel"))
return dialogStyle.Render(sb.String())
}
func (g *GroupPicker) deleteView() string {
var sb strings.Builder
groupName := g.Selected()
sb.WriteString(titleStyle.Render("Delete Group"))
sb.WriteString("\n\n")
sb.WriteString(errorMsgStyle.Render("Are you sure you want to delete group '" + groupName + "'?"))
sb.WriteString("\n")
sb.WriteString(helpDescStyle.Render("This will remove all hosts in this group!"))
sb.WriteString("\n\n")
sb.WriteString(helpDescStyle.Render("y confirm • n/Esc cancel"))
return dialogStyle.Render(sb.String())
}
+429
View File
@@ -0,0 +1,429 @@
// Package tui provides the list view component.
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/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 ""
}
// 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()
}
// GroupCount returns the number of groups.
func (l *ListView) GroupCount() int {
return len(l.groupOrder)
}
// 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
}
+409
View File
@@ -0,0 +1,409 @@
package tui
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/lolcathost/internal/protocol"
)
func TestListView_SetItems(t *testing.T) {
lv := NewListView()
entries := []protocol.HostEntry{
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"},
{Domain: "b.com", IP: "127.0.0.1", Alias: "b", Enabled: false, Group: "dev"},
{Domain: "c.com", IP: "192.168.1.1", Alias: "c", Enabled: true, Group: "staging"},
}
lv.SetItems(entries)
assert.Equal(t, 3, lv.Len())
assert.Len(t, lv.groups, 2)
assert.Contains(t, lv.groupOrder, "dev")
assert.Contains(t, lv.groupOrder, "staging")
}
func TestListView_Navigation(t *testing.T) {
lv := NewListView()
entries := []protocol.HostEntry{
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"},
{Domain: "b.com", IP: "127.0.0.1", Alias: "b", Enabled: false, Group: "dev"},
{Domain: "c.com", IP: "192.168.1.1", Alias: "c", Enabled: true, Group: "staging"},
}
lv.SetItems(entries)
// Initial position
assert.Equal(t, 0, lv.cursor)
// Move down
lv.MoveDown()
assert.Equal(t, 1, lv.cursor)
lv.MoveDown()
assert.Equal(t, 2, lv.cursor)
// Can't move past end
lv.MoveDown()
assert.Equal(t, 2, lv.cursor)
// Move up
lv.MoveUp()
assert.Equal(t, 1, lv.cursor)
lv.MoveUp()
assert.Equal(t, 0, lv.cursor)
// Can't move before start
lv.MoveUp()
assert.Equal(t, 0, lv.cursor)
}
func TestListView_Selected(t *testing.T) {
lv := NewListView()
t.Run("empty list", func(t *testing.T) {
item := lv.Selected()
assert.Nil(t, item)
})
t.Run("with items", func(t *testing.T) {
entries := []protocol.HostEntry{
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"},
{Domain: "b.com", IP: "127.0.0.1", Alias: "b", Enabled: false, Group: "dev"},
}
lv.SetItems(entries)
item := lv.Selected()
require.NotNil(t, item)
assert.Equal(t, "a.com", item.Entry.Domain)
lv.MoveDown()
item = lv.Selected()
require.NotNil(t, item)
assert.Equal(t, "b.com", item.Entry.Domain)
})
}
func TestListView_SelectedAlias(t *testing.T) {
lv := NewListView()
t.Run("empty list", func(t *testing.T) {
alias := lv.SelectedAlias()
assert.Empty(t, alias)
})
t.Run("with items", func(t *testing.T) {
entries := []protocol.HostEntry{
{Domain: "a.com", IP: "127.0.0.1", Alias: "my-alias", Enabled: true, Group: "dev"},
}
lv.SetItems(entries)
alias := lv.SelectedAlias()
assert.Equal(t, "my-alias", alias)
})
}
func TestListView_SetPending(t *testing.T) {
lv := NewListView()
entries := []protocol.HostEntry{
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"},
}
lv.SetItems(entries)
assert.False(t, lv.items[0].Pending)
lv.SetPending("a", true)
assert.True(t, lv.items[0].Pending)
lv.SetPending("a", false)
assert.False(t, lv.items[0].Pending)
// Non-existent alias should not panic
lv.SetPending("nonexistent", true)
}
func TestListView_SetError(t *testing.T) {
lv := NewListView()
entries := []protocol.HostEntry{
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"},
}
lv.SetItems(entries)
assert.False(t, lv.items[0].HasError)
lv.SetError("a", true)
assert.True(t, lv.items[0].HasError)
lv.SetError("a", false)
assert.False(t, lv.items[0].HasError)
}
func TestListView_UpdateEntry(t *testing.T) {
lv := NewListView()
entries := []protocol.HostEntry{
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: false, Group: "dev"},
}
lv.SetItems(entries)
lv.items[0].Pending = true
lv.items[0].HasError = true
lv.UpdateEntry("a", true)
assert.True(t, lv.items[0].Entry.Enabled)
assert.False(t, lv.items[0].Pending)
assert.False(t, lv.items[0].HasError)
}
func TestListView_ActiveCount(t *testing.T) {
lv := NewListView()
entries := []protocol.HostEntry{
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"},
{Domain: "b.com", IP: "127.0.0.1", Alias: "b", Enabled: false, Group: "dev"},
{Domain: "c.com", IP: "192.168.1.1", Alias: "c", Enabled: true, Group: "staging"},
}
lv.SetItems(entries)
assert.Equal(t, 2, lv.ActiveCount())
}
func TestListView_FindByAlias(t *testing.T) {
lv := NewListView()
entries := []protocol.HostEntry{
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"},
{Domain: "b.com", IP: "127.0.0.1", Alias: "b", Enabled: false, Group: "dev"},
}
lv.SetItems(entries)
t.Run("found", func(t *testing.T) {
item := lv.FindByAlias("b")
require.NotNil(t, item)
assert.Equal(t, "b.com", item.Entry.Domain)
})
t.Run("not found", func(t *testing.T) {
item := lv.FindByAlias("nonexistent")
assert.Nil(t, item)
})
}
func TestListView_Filter(t *testing.T) {
lv := NewListView()
entries := []protocol.HostEntry{
{Domain: "myapp.com", IP: "127.0.0.1", Alias: "myapp", Enabled: true, Group: "dev"},
{Domain: "api.myapp.com", IP: "127.0.0.1", Alias: "api", Enabled: false, Group: "dev"},
{Domain: "other.com", IP: "192.168.1.1", Alias: "other", Enabled: true, Group: "staging"},
}
lv.SetItems(entries)
t.Run("empty term", func(t *testing.T) {
filtered := lv.Filter("")
assert.Len(t, filtered, 3)
})
t.Run("by domain", func(t *testing.T) {
filtered := lv.Filter("myapp")
assert.Len(t, filtered, 2)
})
t.Run("by alias", func(t *testing.T) {
filtered := lv.Filter("api")
assert.Len(t, filtered, 1)
assert.Equal(t, "api.myapp.com", filtered[0].Entry.Domain)
})
t.Run("by IP", func(t *testing.T) {
filtered := lv.Filter("192.168")
assert.Len(t, filtered, 1)
assert.Equal(t, "other.com", filtered[0].Entry.Domain)
})
t.Run("case insensitive", func(t *testing.T) {
filtered := lv.Filter("MYAPP")
assert.Len(t, filtered, 2)
})
t.Run("no match", func(t *testing.T) {
filtered := lv.Filter("nonexistent")
assert.Empty(t, filtered)
})
}
func TestListView_View(t *testing.T) {
t.Run("empty list", func(t *testing.T) {
lv := NewListView()
view := lv.View()
assert.Contains(t, view, "No host entries")
})
t.Run("with items", func(t *testing.T) {
lv := NewListView()
entries := []protocol.HostEntry{
{Domain: "example.com", IP: "127.0.0.1", Alias: "example", Enabled: true, Group: "dev"},
}
lv.SetItems(entries)
view := lv.View()
// Group header is shown as section title (uppercase)
assert.Contains(t, view, "DEV")
// Table headers
assert.Contains(t, view, "DOMAIN")
assert.Contains(t, view, "IP ADDRESS")
assert.Contains(t, view, "STATUS")
// Data is in the view
assert.Contains(t, view, "example.com")
assert.Contains(t, view, "127.0.0.1")
assert.Contains(t, view, "Active")
})
}
func TestListView_SetSize(t *testing.T) {
lv := NewListView()
lv.SetSize(80, 24)
assert.Equal(t, 80, lv.width)
assert.Equal(t, 24, lv.height)
}
func TestListView_CursorBounds(t *testing.T) {
lv := NewListView()
// Set items
entries := []protocol.HostEntry{
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"},
{Domain: "b.com", IP: "127.0.0.1", Alias: "b", Enabled: true, Group: "dev"},
}
lv.SetItems(entries)
lv.cursor = 1
// Set fewer items - cursor should be adjusted
entries = []protocol.HostEntry{
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"},
}
lv.SetItems(entries)
assert.Equal(t, 0, lv.cursor)
}
func TestTruncate(t *testing.T) {
tests := []struct {
input string
maxLen int
expected string
}{
{"short", 10, "short"},
{"exactly10!", 10, "exactly10!"},
{"this is too long", 10, "this is..."},
{"", 5, ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := truncate(tt.input, tt.maxLen)
assert.Equal(t, tt.expected, result)
})
}
}
func TestMax(t *testing.T) {
assert.Equal(t, 5, max(3, 5))
assert.Equal(t, 5, max(5, 3))
assert.Equal(t, 5, max(5, 5))
assert.Equal(t, 0, max(0, -1))
}
// Matrix test for navigation
func TestListView_Navigation_Matrix(t *testing.T) {
sizes := []int{1, 5, 10, 100}
for _, size := range sizes {
t.Run("size="+string(rune('0'+size)), func(t *testing.T) {
lv := NewListView()
entries := make([]protocol.HostEntry, size)
for i := range entries {
entries[i] = protocol.HostEntry{
Domain: "domain" + string(rune('a'+i%26)) + ".com",
IP: "127.0.0.1",
Alias: "alias" + string(rune('a'+i%26)),
Enabled: true,
Group: "dev",
}
}
lv.SetItems(entries)
// Move to end
for i := 0; i < size*2; i++ {
lv.MoveDown()
}
assert.Equal(t, size-1, lv.cursor)
// Move to start
for i := 0; i < size*2; i++ {
lv.MoveUp()
}
assert.Equal(t, 0, lv.cursor)
})
}
}
func BenchmarkListView_SetItems(b *testing.B) {
entries := make([]protocol.HostEntry, 100)
for i := range entries {
entries[i] = protocol.HostEntry{
Domain: "domain.com",
IP: "127.0.0.1",
Alias: "alias",
Enabled: true,
Group: "dev",
}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
lv := NewListView()
lv.SetItems(entries)
}
}
func BenchmarkListView_Filter(b *testing.B) {
lv := NewListView()
entries := make([]protocol.HostEntry, 100)
for i := range entries {
entries[i] = protocol.HostEntry{
Domain: "domain" + string(rune('a'+i%26)) + ".com",
IP: "127.0.0.1",
Alias: "alias" + string(rune('a'+i%26)),
Enabled: true,
Group: "dev",
}
}
lv.SetItems(entries)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = lv.Filter("domain")
}
}
func BenchmarkListView_View(b *testing.B) {
lv := NewListView()
entries := make([]protocol.HostEntry, 50)
for i := range entries {
entries[i] = protocol.HostEntry{
Domain: "domain.com",
IP: "127.0.0.1",
Alias: "alias",
Enabled: i%2 == 0,
Group: "group" + string(rune('a'+i%5)),
}
}
lv.SetItems(entries)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = lv.View()
}
}
+356
View File
@@ -0,0 +1,356 @@
// Package tui provides the preset picker component.
package tui
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/lukaszraczylo/lolcathost/internal/protocol"
)
// PresetMode represents the preset view mode.
type PresetMode int
const (
PresetModeSelect PresetMode = iota
PresetModeAdd
PresetModeEdit
PresetModeConfirmDelete
)
// PresetFormField represents a form field index.
type PresetFormField int
const (
PresetFieldName PresetFormField = iota
PresetFieldEnable
PresetFieldDisable
PresetFieldCount
)
// PresetPicker handles the preset selection and management UI.
type PresetPicker struct {
presets []protocol.PresetInfo
cursor int
width int
height int
mode PresetMode
fields []textinput.Model
focus PresetFormField
editName string // Original name when editing
}
// NewPresetPicker creates a new preset picker.
func NewPresetPicker() *PresetPicker {
fields := make([]textinput.Model, PresetFieldCount)
// Name field
fields[PresetFieldName] = textinput.New()
fields[PresetFieldName].Placeholder = "preset-name"
fields[PresetFieldName].CharLimit = 63
// Enable field
fields[PresetFieldEnable] = textinput.New()
fields[PresetFieldEnable].Placeholder = "alias1,alias2,alias3"
fields[PresetFieldEnable].CharLimit = 500
// Disable field
fields[PresetFieldDisable] = textinput.New()
fields[PresetFieldDisable].Placeholder = "alias1,alias2,alias3"
fields[PresetFieldDisable].CharLimit = 500
return &PresetPicker{
fields: fields,
mode: PresetModeSelect,
}
}
// SetPresets updates the available presets (legacy method for compatibility).
func (p *PresetPicker) SetPresets(presets []string) {
p.presets = make([]protocol.PresetInfo, len(presets))
for i, name := range presets {
p.presets[i] = protocol.PresetInfo{Name: name}
}
if p.cursor >= len(presets) {
p.cursor = max(0, len(presets)-1)
}
}
// SetPresetsWithInfo updates the available presets with full info.
func (p *PresetPicker) SetPresetsWithInfo(presets []protocol.PresetInfo) {
p.presets = presets
if p.cursor >= len(presets) {
p.cursor = max(0, len(presets)-1)
}
}
// SetSize sets the picker dimensions.
func (p *PresetPicker) SetSize(width, height int) {
p.width = width
p.height = height
inputWidth := min(60, width-10)
for i := range p.fields {
p.fields[i].Width = inputWidth
}
}
// MoveUp moves the cursor up.
func (p *PresetPicker) MoveUp() {
if p.cursor > 0 {
p.cursor--
}
}
// MoveDown moves the cursor down.
func (p *PresetPicker) MoveDown() {
if p.cursor < len(p.presets)-1 {
p.cursor++
}
}
// Selected returns the currently selected preset name.
func (p *PresetPicker) Selected() string {
if p.cursor >= 0 && p.cursor < len(p.presets) {
return p.presets[p.cursor].Name
}
return ""
}
// SelectedInfo returns the currently selected preset info.
func (p *PresetPicker) SelectedInfo() *protocol.PresetInfo {
if p.cursor >= 0 && p.cursor < len(p.presets) {
return &p.presets[p.cursor]
}
return nil
}
// Len returns the number of presets.
func (p *PresetPicker) Len() int {
return len(p.presets)
}
// Mode returns the current mode.
func (p *PresetPicker) Mode() PresetMode {
return p.mode
}
// SetMode sets the mode.
func (p *PresetPicker) SetMode(mode PresetMode) {
p.mode = mode
}
// InitAdd initializes the form for adding a new preset.
func (p *PresetPicker) InitAdd() {
p.mode = PresetModeAdd
p.editName = ""
for i := range p.fields {
p.fields[i].Reset()
}
p.focus = PresetFieldName
p.fields[PresetFieldName].Focus()
}
// InitEdit initializes the form for editing an existing preset.
func (p *PresetPicker) InitEdit() {
preset := p.SelectedInfo()
if preset == nil {
return
}
p.mode = PresetModeEdit
p.editName = preset.Name
p.fields[PresetFieldName].SetValue(preset.Name)
p.fields[PresetFieldEnable].SetValue(strings.Join(preset.Enable, ","))
p.fields[PresetFieldDisable].SetValue(strings.Join(preset.Disable, ","))
p.focus = PresetFieldName
p.fields[PresetFieldName].Focus()
}
// InitDelete starts delete confirmation.
func (p *PresetPicker) InitDelete() {
if p.SelectedInfo() == nil {
return
}
p.mode = PresetModeConfirmDelete
}
// CancelForm cancels the current form operation.
func (p *PresetPicker) CancelForm() {
p.mode = PresetModeSelect
p.editName = ""
for i := range p.fields {
p.fields[i].Reset()
p.fields[i].Blur()
}
}
// Update handles input events for form mode.
func (p *PresetPicker) Update(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "tab", "down":
p.nextField()
return nil
case "shift+tab", "up":
p.prevField()
return nil
}
// Update the focused field
var cmd tea.Cmd
p.fields[p.focus], cmd = p.fields[p.focus].Update(msg)
return cmd
}
func (p *PresetPicker) nextField() {
p.fields[p.focus].Blur()
p.focus = (p.focus + 1) % PresetFieldCount
p.fields[p.focus].Focus()
}
func (p *PresetPicker) prevField() {
p.fields[p.focus].Blur()
p.focus = (p.focus - 1 + PresetFieldCount) % PresetFieldCount
p.fields[p.focus].Focus()
}
// FormValues returns the form values (name, enable list, disable list).
func (p *PresetPicker) FormValues() (name string, enable, disable []string) {
name = strings.TrimSpace(p.fields[PresetFieldName].Value())
enableStr := strings.TrimSpace(p.fields[PresetFieldEnable].Value())
if enableStr != "" {
for _, s := range strings.Split(enableStr, ",") {
if trimmed := strings.TrimSpace(s); trimmed != "" {
enable = append(enable, trimmed)
}
}
}
disableStr := strings.TrimSpace(p.fields[PresetFieldDisable].Value())
if disableStr != "" {
for _, s := range strings.Split(disableStr, ",") {
if trimmed := strings.TrimSpace(s); trimmed != "" {
disable = append(disable, trimmed)
}
}
}
return name, enable, disable
}
// EditName returns the original name when editing.
func (p *PresetPicker) EditName() string {
return p.editName
}
// IsEdit returns true if in edit mode.
func (p *PresetPicker) IsEdit() bool {
return p.mode == PresetModeEdit
}
// ValidateForm validates the form values.
func (p *PresetPicker) ValidateForm() string {
name, enable, disable := p.FormValues()
if name == "" {
return "Preset name is required"
}
if len(enable) == 0 && len(disable) == 0 {
return "At least one alias to enable or disable is required"
}
return ""
}
// View renders the preset picker.
func (p *PresetPicker) View() string {
switch p.mode {
case PresetModeAdd, PresetModeEdit:
return p.formView()
case PresetModeConfirmDelete:
return p.deleteView()
default:
return p.selectView()
}
}
func (p *PresetPicker) selectView() string {
var sb strings.Builder
sb.WriteString(titleStyle.Render("Presets"))
sb.WriteString("\n\n")
if len(p.presets) == 0 {
sb.WriteString(helpDescStyle.Render("No presets configured."))
sb.WriteString("\n\n")
sb.WriteString(helpDescStyle.Render("Press 'n' to create one"))
} else {
for i, preset := range p.presets {
if i == p.cursor {
sb.WriteString(presetSelectedStyle.Render("▸ " + preset.Name))
} else {
sb.WriteString(presetItemStyle.Render(" " + preset.Name))
}
sb.WriteString("\n")
}
}
sb.WriteString("\n\n")
sb.WriteString(helpDescStyle.Render("↑↓ navigate • Enter apply • n new • e edit • d delete • Esc cancel"))
return dialogStyle.Render(sb.String())
}
func (p *PresetPicker) formView() string {
var sb strings.Builder
title := "Add New Preset"
if p.mode == PresetModeEdit {
title = "Edit Preset"
}
sb.WriteString(titleStyle.Render(title))
sb.WriteString("\n\n")
labels := []string{"Name:", "Enable aliases (comma-separated):", "Disable aliases (comma-separated):"}
for i, label := range labels {
sb.WriteString(inputLabelStyle.Render(label))
sb.WriteString("\n")
style := inputStyle
if PresetFormField(i) == p.focus {
style = inputFocusStyle
}
sb.WriteString(style.Render(p.fields[i].View()))
sb.WriteString("\n\n")
}
sb.WriteString("\n")
sb.WriteString(helpDescStyle.Render("Tab/↓ next • Shift+Tab/↑ prev • Enter save • Esc cancel"))
return dialogStyle.Render(sb.String())
}
func (p *PresetPicker) deleteView() string {
var sb strings.Builder
preset := p.SelectedInfo()
presetName := ""
if preset != nil {
presetName = preset.Name
}
sb.WriteString(titleStyle.Render("Delete Preset"))
sb.WriteString("\n\n")
sb.WriteString(errorMsgStyle.Render("Are you sure you want to delete preset '" + presetName + "'?"))
sb.WriteString("\n\n")
sb.WriteString(helpDescStyle.Render("y confirm • n/Esc cancel"))
return dialogStyle.Render(sb.String())
}
+150
View File
@@ -0,0 +1,150 @@
// Package tui provides the terminal user interface.
package tui
import (
"github.com/charmbracelet/lipgloss"
)
// Colors - matching kportal style, optimized for dark terminals
var (
colorPrimary = lipgloss.Color("205") // Pink/Magenta
colorSuccess = lipgloss.Color("42") // Green
colorWarning = lipgloss.Color("220") // Yellow
colorError = lipgloss.Color("196") // Red
colorMuted = lipgloss.Color("245") // Gray (brighter for dark terminals)
colorAccent = lipgloss.Color("141") // Light purple (brighter for dark terminals)
colorHeader = lipgloss.Color("220") // Yellow for headers
colorSelectedBg = lipgloss.Color("236") // Gray background for selection
colorSelectedFg = lipgloss.Color("255") // White foreground for selection
colorGroupHeader = lipgloss.Color("213") // Light pink for group headers
)
// Title and header styles
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(colorHeader).
Padding(0, 1)
)
// Status indicators
var (
enabledStyle = lipgloss.NewStyle().
Foreground(colorSuccess).
Bold(true)
disabledStyle = lipgloss.NewStyle().
Foreground(colorMuted)
pendingStyle = lipgloss.NewStyle().
Foreground(colorWarning)
errorIndicatorStyle = lipgloss.NewStyle().
Foreground(colorError)
)
// Status bar and help
var (
statusBarStyle = lipgloss.NewStyle().
Foreground(colorMuted)
connectedStyle = lipgloss.NewStyle().
Foreground(colorSuccess).
SetString("Connected")
disconnectedStyle = lipgloss.NewStyle().
Foreground(colorError).
SetString("Disconnected")
helpBarStyle = lipgloss.NewStyle().
Foreground(colorMuted)
helpKeyStyle = lipgloss.NewStyle().
Foreground(colorHeader).
Bold(true)
helpDescStyle = lipgloss.NewStyle().
Foreground(colorMuted)
)
// Message styles
var (
errorMsgStyle = lipgloss.NewStyle().
Foreground(colorError).
Bold(true).
MarginTop(1)
successMsgStyle = lipgloss.NewStyle().
Foreground(colorSuccess).
MarginTop(1)
updateStyle = lipgloss.NewStyle().
Foreground(colorSuccess).
Bold(true)
)
// Form styles
var (
inputLabelStyle = lipgloss.NewStyle().
Foreground(colorPrimary).
Bold(true)
inputStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorMuted).
Padding(0, 1)
inputFocusStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorPrimary).
Padding(0, 1)
)
// Dialog/modal styles
var (
dialogStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorAccent).
Padding(1, 2)
presetItemStyle = lipgloss.NewStyle().
Padding(0, 1)
presetSelectedStyle = lipgloss.NewStyle().
Background(colorSelectedBg).
Foreground(colorSelectedFg).
Padding(0, 1)
)
// Indicator returns the appropriate status indicator string.
func Indicator(enabled bool, pending bool, hasError bool) string {
if hasError {
return errorIndicatorStyle.Render("✗")
}
if pending {
return pendingStyle.Render("◐")
}
if enabled {
return enabledStyle.Render("●")
}
return disabledStyle.Render("○")
}
// StatusText returns the status text with appropriate styling
func StatusText(enabled bool, pending bool, hasError bool) string {
if hasError {
return errorIndicatorStyle.Render("✗ Error")
}
if pending {
return pendingStyle.Render("◐ Pending")
}
if enabled {
return enabledStyle.Render("● Active")
}
return disabledStyle.Render("○ Disabled")
}
// HelpItem formats a help item.
func HelpItem(key, desc string) string {
return helpKeyStyle.Render(key) + " " + helpDescStyle.Render(desc)
}