Files
lolcathost/internal/tui/app.go
T
2025-11-28 12:57:23 +00:00

905 lines
20 KiB
Go

// 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
}