Files
lolcathost/internal/tui/app.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

1156 lines
27 KiB
Go

// Package tui provides the main Bubble Tea application.
package tui
import (
"context"
"fmt"
"strings"
"time"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"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
ViewBackups
ViewHelp
ViewSearch
ViewConfirmDelete
)
// Model is the main Bubble Tea model.
type Model struct {
// Client
client *client.Client
connected bool
// Views
mode ViewMode
list *ListView
form *Form
presetPicker *PresetPicker
groupPicker *GroupPicker
backupPicker *BackupPicker
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
pendingDeleteAlias string // Alias of host pending delete confirmation
// 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
}
rollbackMsg struct {
name string
err error
}
refreshBackupsMsg struct {
backups []protocol.BackupInfo
err error
}
backupContentMsg struct {
content string
err error
}
clearMsgMsg struct{}
tickMsg struct{}
updateMsg struct {
version string
url string
}
)
// NewModel creates a new TUI model.
func NewModel(socketPath string) *Model {
searchInput := textinput.New()
searchInput.Placeholder = "Search..."
searchInput.CharLimit = 100
searchInput.SetWidth(50)
return &Model{
client: client.New(socketPath),
list: NewListView(),
form: NewForm(),
presetPicker: NewPresetPicker(),
groupPicker: NewGroupPicker(),
backupPicker: NewBackupPicker(),
searchInput: searchInput,
mode: ViewList,
}
}
// Init initializes the model.
func (m *Model) Init() tea.Cmd {
return tea.Batch(
m.connect(),
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) rollback(backupName string) tea.Cmd {
return func() tea.Msg {
err := m.client.Rollback(backupName)
return rollbackMsg{name: backupName, err: err}
}
}
func (m *Model) refreshBackups() tea.Cmd {
return func() tea.Msg {
backups, err := m.client.ListBackups()
return refreshBackupsMsg{backups: backups, err: err}
}
}
func (m *Model) fetchBackupContent(backupName string) tea.Cmd {
return func() tea.Msg {
content, err := m.client.GetBackupContent(backupName)
return backupContentMsg{content: content, 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)
m.backupPicker.SetSize(msg.Width, msg.Height)
// Set search input width
searchWidth := msg.Width - 20
if searchWidth > 60 {
searchWidth = 60
}
m.searchInput.SetWidth(searchWidth)
case tea.KeyPressMsg:
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())
}
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 {
// Always update the list, even if entries is nil/empty
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:
// Clear pending state regardless of success/failure
m.list.SetPending(msg.alias, false)
if msg.err != nil {
m.list.SetError(msg.alias, true)
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 rollbackMsg:
if msg.err != nil {
m.setError(fmt.Sprintf("Rollback failed: %v", msg.err))
} else {
cmds = append(cmds, m.refresh())
m.setSuccess("Restored from backup")
}
m.backupPicker.Cancel()
m.mode = ViewList
case refreshBackupsMsg:
if msg.err == nil && msg.backups != nil {
m.backupPicker.SetBackups(msg.backups)
// Fetch content for the first backup
if len(msg.backups) > 0 {
cmds = append(cmds, m.fetchBackupContent(msg.backups[0].Name))
}
}
case backupContentMsg:
if msg.err == nil {
m.backupPicker.SetPreviewContent(msg.content)
}
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.KeyPressMsg) 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 ViewBackups:
return m.handleBackupKey(msg)
case ViewHelp:
return m.handleHelpKey(msg)
case ViewSearch:
return m.handleSearchKey(msg)
case ViewConfirmDelete:
return m.handleConfirmDeleteKey(msg)
}
return nil
}
func (m *Model) handleListKey(msg tea.KeyPressMsg) 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 "space", "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 {
m.pendingDeleteAlias = item.Entry.Alias
m.mode = ViewConfirmDelete
}
case "p":
m.mode = ViewPresets
// Pass available aliases to preset picker
m.presetPicker.SetAvailableAliases(m.list.GetAliases())
case "g":
m.mode = ViewGroups
return m.refreshGroups()
case "b":
m.mode = ViewBackups
return m.refreshBackups()
case "/":
m.mode = ViewSearch
m.searchInput.Focus()
case "?":
m.mode = ViewHelp
case "r":
return m.refresh()
}
return nil
}
func (m *Model) handleFormKey(msg tea.KeyPressMsg) 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.KeyPressMsg) 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 PresetModePickEnable, PresetModePickDisable:
return m.handlePresetPickerKey(msg)
case PresetModeConfirmDelete:
return m.handlePresetDeleteKey(msg)
}
return nil
}
func (m *Model) handlePresetSelectKey(msg tea.KeyPressMsg) 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.KeyPressMsg) tea.Cmd {
switch msg.String() {
case "esc":
m.presetPicker.CancelForm()
return nil
case "enter":
// Check which field is focused
switch m.presetPicker.Focus() {
case PresetFieldEnable:
m.presetPicker.OpenEnablePicker()
return nil
case PresetFieldDisable:
m.presetPicker.OpenDisablePicker()
return nil
case PresetFieldSave:
// Save the preset
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) handlePresetPickerKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.String() {
case "esc":
m.presetPicker.ClosePicker()
case "enter":
m.presetPicker.ClosePicker()
case "up", "k":
m.presetPicker.PickerMoveUp()
case "down", "j":
m.presetPicker.PickerMoveDown()
case "space":
m.presetPicker.TogglePickerSelection()
}
return nil
}
func (m *Model) handlePresetDeleteKey(msg tea.KeyPressMsg) 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.KeyPressMsg) 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.KeyPressMsg) 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.KeyPressMsg) 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.KeyPressMsg) 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) handleBackupKey(msg tea.KeyPressMsg) tea.Cmd {
switch m.backupPicker.Mode() {
case BackupModeSelect:
return m.handleBackupSelectKey(msg)
case BackupModeConfirmRestore:
return m.handleBackupRestoreKey(msg)
}
return nil
}
func (m *Model) handleBackupSelectKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.String() {
case "esc", "q":
m.mode = ViewList
case "up", "k":
m.backupPicker.MoveUp()
// Fetch content for newly selected backup
if backup := m.backupPicker.Selected(); backup != "" && m.backupPicker.PreviewContent() == "" {
return m.fetchBackupContent(backup)
}
case "down", "j":
m.backupPicker.MoveDown()
// Fetch content for newly selected backup
if backup := m.backupPicker.Selected(); backup != "" && m.backupPicker.PreviewContent() == "" {
return m.fetchBackupContent(backup)
}
case "shift+up", "K":
m.backupPicker.ScrollPreviewUp()
case "shift+down", "J":
m.backupPicker.ScrollPreviewDown()
case "enter":
m.backupPicker.InitRestore()
case "r":
return m.refreshBackups()
}
return nil
}
func (m *Model) handleBackupRestoreKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.String() {
case "y", "Y":
if backup := m.backupPicker.Selected(); backup != "" {
return m.rollback(backup)
}
m.backupPicker.Cancel()
case "n", "N", "esc":
m.backupPicker.Cancel()
}
return nil
}
func (m *Model) handleHelpKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.String() {
case "esc", "q", "?":
m.mode = ViewList
}
return nil
}
func (m *Model) handleSearchKey(msg tea.KeyPressMsg) 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) handleConfirmDeleteKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.String() {
case "y", "Y":
alias := m.pendingDeleteAlias
m.pendingDeleteAlias = ""
m.mode = ViewList
// Set pending state for visual feedback
m.list.SetPending(alias, true)
return m.deleteHost(alias)
case "n", "N", "esc":
m.pendingDeleteAlias = ""
m.mode = ViewList
return nil
}
return nil
}
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) 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() tea.View {
v := tea.NewView(m.render())
v.AltScreen = true
v.WindowTitle = "lolcathost"
return v
}
func (m *Model) render() 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 ViewBackups:
sb.WriteString(m.backupPicker.View())
case ViewHelp:
sb.WriteString(m.helpView())
case ViewSearch:
sb.WriteString(m.searchView())
case ViewConfirmDelete:
sb.WriteString(m.confirmDeleteView())
}
// 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
// Calculate footer height dynamically (help bar lines + status bar + spacing)
footerHeight := 2 // status bar + newline before it
var helpBarContent string
if m.mode == ViewList {
helpBarContent = m.helpBar()
helpBarLines := strings.Count(helpBarContent, "\n") + 1
footerHeight += helpBarLines + 1 // +1 for newline before help bar
}
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(helpBarContent)
}
sb.WriteString("\n")
sb.WriteString(m.statusBar())
return sb.String()
}
func (m *Model) helpBar() string {
// Define help items with their display widths (without ANSI codes)
type helpItem struct {
key string
desc string
rawWidth int // width without ANSI escape codes
}
items := []helpItem{
{"↑↓/jk", "Navigate", 13},
{"Space", "Toggle", 13},
{"n", "New", 6},
{"e", "Edit", 7},
{"d", "Delete", 9},
{"p", "Presets", 10},
{"g", "Groups", 9},
{"b", "Backups", 10},
{"/", "Search", 9},
{"?", "Help", 7},
{"q", "Quit", 7},
}
separator := " "
sepWidth := 2
var lines []string
var currentLine string
var currentWidth int
for i, item := range items {
rendered := helpKeyStyle.Render(item.key) + ": " + item.desc
// Check if adding this item would exceed width
newWidth := currentWidth + item.rawWidth
if currentWidth > 0 {
newWidth += sepWidth
}
if m.width > 0 && newWidth > m.width && currentWidth > 0 {
// Start a new line
lines = append(lines, currentLine)
currentLine = rendered
currentWidth = item.rawWidth
} else {
// Add to current line
if currentWidth > 0 {
currentLine += separator
}
currentLine += rendered
currentWidth = newWidth
if i == 0 {
currentWidth = item.rawWidth
}
}
}
// Add the last line
if currentLine != "" {
lines = append(lines, currentLine)
}
return helpBarStyle.Render(strings.Join(lines, "\n"))
}
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"},
{"b", "Open backup 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)))
}
// Show blocked domains
sb.WriteString("\n")
sb.WriteString(inputLabelStyle.Render("Blocked Domains:"))
sb.WriteString("\n")
blockedDomains := config.GetBlockedDomains()
sb.WriteString(helpDescStyle.Render(" " + strings.Join(blockedDomains, ", ")))
sb.WriteString("\n")
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())
}
func (m *Model) confirmDeleteView() string {
var sb strings.Builder
sb.WriteString(titleStyle.Render("Confirm Delete"))
sb.WriteString("\n\n")
// Find the entry details for the pending delete
var domain, ip string
for _, item := range m.list.items {
if item.Entry.Alias == m.pendingDeleteAlias {
domain = item.Entry.Domain
ip = item.Entry.IP
break
}
}
warningStyle := lipgloss.NewStyle().Foreground(colorWarning).Bold(true)
sb.WriteString(warningStyle.Render("Are you sure you want to delete this host?"))
sb.WriteString("\n\n")
sb.WriteString(fmt.Sprintf(" Alias: %s\n", helpKeyStyle.Render(m.pendingDeleteAlias)))
sb.WriteString(fmt.Sprintf(" Domain: %s\n", helpDescStyle.Render(domain)))
sb.WriteString(fmt.Sprintf(" IP: %s\n", helpDescStyle.Render(ip)))
sb.WriteString("\n")
sb.WriteString(helpDescStyle.Render("y confirm • n/Esc cancel"))
return dialogStyle.Render(sb.String())
}
// RunWithVersion starts the TUI application with version info for update checking.
func RunWithVersion(socketPath, version, githubOwner, githubRepo string) error {
m := NewModel(socketPath)
m.version = version
m.githubOwner = githubOwner
m.githubRepo = githubRepo
p := tea.NewProgram(m)
_, err := p.Run()
return err
}