mirror of
https://github.com/lukaszraczylo/lolcathost.git
synced 2026-06-05 23:29:18 +00:00
1155 lines
27 KiB
Go
1155 lines
27 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/charmbracelet/lipgloss"
|
|
|
|
"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.Width = 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(),
|
|
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) 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.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())
|
|
}
|
|
|
|
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.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 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.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 {
|
|
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.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 PresetModePickEnable, PresetModePickDisable:
|
|
return m.handlePresetPickerKey(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":
|
|
// 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.KeyMsg) 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 " ":
|
|
m.presetPicker.TogglePickerSelection()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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) handleBackupKey(msg tea.KeyMsg) 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.KeyMsg) 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.KeyMsg) 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.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) handleConfirmDeleteKey(msg tea.KeyMsg) 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() 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())
|
|
}
|
|
|
|
// Run starts the TUI application.
|
|
func Run(socketPath string) error {
|
|
return RunWithVersion(socketPath, "dev", "", "")
|
|
}
|
|
|
|
// 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, tea.WithAltScreen())
|
|
|
|
_, err := p.Run()
|
|
return err
|
|
}
|