mirror of
https://github.com/lukaszraczylo/lolcathost.git
synced 2026-10-12 04:55:13 +00:00
Switch imports to the charm.land v2 modules. Return tea.View from Model.View, set alt screen and window title there, use KeyPressMsg and the "space" key name, and call SetWidth on text inputs.
1156 lines
27 KiB
Go
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
|
|
}
|