mirror of
https://github.com/lukaszraczylo/lolcathost.git
synced 2026-06-28 03:13:09 +00:00
Initial commit.
This commit is contained in:
@@ -0,0 +1,904 @@
|
||||
// Package tui provides the main Bubble Tea application.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/lukaszraczylo/lolcathost/internal/client"
|
||||
"github.com/lukaszraczylo/lolcathost/internal/config"
|
||||
"github.com/lukaszraczylo/lolcathost/internal/protocol"
|
||||
"github.com/lukaszraczylo/lolcathost/internal/version"
|
||||
)
|
||||
|
||||
// ViewMode represents the current view mode.
|
||||
type ViewMode int
|
||||
|
||||
const (
|
||||
ViewList ViewMode = iota
|
||||
ViewForm
|
||||
ViewPresets
|
||||
ViewGroups
|
||||
ViewHelp
|
||||
ViewSearch
|
||||
)
|
||||
|
||||
// Model is the main Bubble Tea model.
|
||||
type Model struct {
|
||||
// Client
|
||||
client *client.Client
|
||||
connected bool
|
||||
|
||||
// Config
|
||||
configPath string
|
||||
config *config.Manager
|
||||
|
||||
// Views
|
||||
mode ViewMode
|
||||
list *ListView
|
||||
form *Form
|
||||
presetPicker *PresetPicker
|
||||
groupPicker *GroupPicker
|
||||
searchInput textinput.Model
|
||||
|
||||
// State
|
||||
width int
|
||||
height int
|
||||
message string
|
||||
messageStyle string // "error" or "success"
|
||||
messageTime time.Time
|
||||
searchTerm string
|
||||
allGroups []string // All groups including empty ones
|
||||
|
||||
// Update notification
|
||||
updateAvailable bool
|
||||
updateVersion string
|
||||
updateURL string
|
||||
|
||||
// Version info for update checking
|
||||
version string
|
||||
githubOwner string
|
||||
githubRepo string
|
||||
}
|
||||
|
||||
// Message types
|
||||
type (
|
||||
connectMsg struct{ err error }
|
||||
refreshMsg struct {
|
||||
entries []protocol.HostEntry
|
||||
err error
|
||||
}
|
||||
toggleMsg struct {
|
||||
alias string
|
||||
err error
|
||||
}
|
||||
presetMsg struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
addMsg struct {
|
||||
domain string
|
||||
err error
|
||||
}
|
||||
deleteMsg struct {
|
||||
alias string
|
||||
err error
|
||||
}
|
||||
addPresetMsg struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
deletePresetMsg struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
refreshPresetsMsg struct {
|
||||
presets []protocol.PresetInfo
|
||||
err error
|
||||
}
|
||||
addGroupMsg struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
renameGroupMsg struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
deleteGroupMsg struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
refreshGroupsMsg struct {
|
||||
groups []string
|
||||
err error
|
||||
}
|
||||
clearMsgMsg struct{}
|
||||
tickMsg struct{}
|
||||
updateMsg struct {
|
||||
version string
|
||||
url string
|
||||
}
|
||||
)
|
||||
|
||||
// NewModel creates a new TUI model.
|
||||
func NewModel(socketPath, configPath string) *Model {
|
||||
searchInput := textinput.New()
|
||||
searchInput.Placeholder = "Search..."
|
||||
searchInput.CharLimit = 100
|
||||
searchInput.Width = 50
|
||||
|
||||
return &Model{
|
||||
client: client.New(socketPath),
|
||||
configPath: configPath,
|
||||
config: config.NewManager(configPath),
|
||||
list: NewListView(),
|
||||
form: NewForm(),
|
||||
presetPicker: NewPresetPicker(),
|
||||
groupPicker: NewGroupPicker(),
|
||||
searchInput: searchInput,
|
||||
mode: ViewList,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the model.
|
||||
func (m *Model) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.connect(),
|
||||
tea.SetWindowTitle("lolcathost"),
|
||||
m.tick(),
|
||||
m.checkForUpdate(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *Model) connect() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := m.client.Connect(); err != nil {
|
||||
return connectMsg{err: err}
|
||||
}
|
||||
return connectMsg{err: nil}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) refresh() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
entries, err := m.client.List()
|
||||
if err != nil {
|
||||
return refreshMsg{entries: nil, err: err}
|
||||
}
|
||||
return refreshMsg{entries: entries, err: nil}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) toggle(alias string, enabled bool) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
_, err := m.client.Set(alias, enabled, false)
|
||||
return toggleMsg{alias: alias, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) applyPreset(name string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := m.client.ApplyPreset(name)
|
||||
return presetMsg{name: name, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) addHost(domain, ip, alias, group string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
_, err := m.client.Add(domain, ip, alias, group, false)
|
||||
return addMsg{domain: domain, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) deleteHost(alias string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := m.client.Delete(alias)
|
||||
return deleteMsg{alias: alias, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) addPreset(name string, enable, disable []string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := m.client.AddPreset(name, enable, disable)
|
||||
return addPresetMsg{name: name, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) deletePreset(name string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := m.client.DeletePreset(name)
|
||||
return deletePresetMsg{name: name, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) refreshPresets() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
presets, err := m.client.ListPresets()
|
||||
return refreshPresetsMsg{presets: presets, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) addGroup(name string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := m.client.AddGroup(name)
|
||||
return addGroupMsg{name: name, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) renameGroup(oldName, newName string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := m.client.RenameGroup(oldName, newName)
|
||||
return renameGroupMsg{name: newName, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) deleteGroup(name string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := m.client.DeleteGroup(name)
|
||||
return deleteGroupMsg{name: name, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) refreshGroups() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
groups, err := m.client.ListGroups()
|
||||
return refreshGroupsMsg{groups: groups, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) tick() tea.Cmd {
|
||||
return tea.Tick(time.Second*3, func(t time.Time) tea.Msg {
|
||||
return tickMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Model) clearMsg() tea.Cmd {
|
||||
return tea.Tick(time.Second*3, func(t time.Time) tea.Msg {
|
||||
return clearMsgMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Model) checkForUpdate() tea.Cmd {
|
||||
if m.githubOwner == "" || m.githubRepo == "" {
|
||||
return nil
|
||||
}
|
||||
return func() tea.Msg {
|
||||
checker := version.NewChecker(m.githubOwner, m.githubRepo, m.version)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if update := checker.CheckForUpdate(ctx); update != nil {
|
||||
return updateMsg{version: update.LatestVersion, url: update.ReleaseURL}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Update handles messages.
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.list.SetSize(msg.Width, msg.Height-10)
|
||||
m.form.SetSize(msg.Width, msg.Height)
|
||||
m.presetPicker.SetSize(msg.Width, msg.Height)
|
||||
m.groupPicker.SetSize(msg.Width, msg.Height)
|
||||
// Set search input width
|
||||
searchWidth := msg.Width - 20
|
||||
if searchWidth > 60 {
|
||||
searchWidth = 60
|
||||
}
|
||||
m.searchInput.Width = searchWidth
|
||||
|
||||
case tea.KeyMsg:
|
||||
cmd := m.handleKey(msg)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case connectMsg:
|
||||
if msg.err != nil {
|
||||
m.connected = false
|
||||
m.setError(fmt.Sprintf("Failed to connect: %v", msg.err))
|
||||
} else {
|
||||
m.connected = true
|
||||
cmds = append(cmds, m.refresh())
|
||||
cmds = append(cmds, m.refreshPresets())
|
||||
cmds = append(cmds, m.refreshGroups())
|
||||
m.loadConfig()
|
||||
}
|
||||
|
||||
case refreshMsg:
|
||||
if msg.err != nil {
|
||||
m.setError(fmt.Sprintf("Refresh failed: %v", msg.err))
|
||||
// Mark as disconnected to trigger reconnect
|
||||
m.connected = false
|
||||
m.client.Close()
|
||||
} else if msg.entries != nil {
|
||||
m.list.SetItems(msg.entries)
|
||||
}
|
||||
|
||||
case toggleMsg:
|
||||
if msg.err != nil {
|
||||
m.list.SetError(msg.alias, true)
|
||||
m.setError(fmt.Sprintf("Toggle failed: %v", msg.err))
|
||||
} else {
|
||||
m.list.SetPending(msg.alias, false)
|
||||
cmds = append(cmds, m.refresh())
|
||||
m.setSuccess("Entry toggled")
|
||||
}
|
||||
|
||||
case presetMsg:
|
||||
if msg.err != nil {
|
||||
m.setError(fmt.Sprintf("Preset failed: %v", msg.err))
|
||||
} else {
|
||||
cmds = append(cmds, m.refresh())
|
||||
m.setSuccess(fmt.Sprintf("Applied preset: %s", msg.name))
|
||||
}
|
||||
m.mode = ViewList
|
||||
|
||||
case addMsg:
|
||||
if msg.err != nil {
|
||||
m.setError(fmt.Sprintf("Add failed: %v", msg.err))
|
||||
} else {
|
||||
cmds = append(cmds, m.refresh())
|
||||
m.setSuccess(fmt.Sprintf("Added host: %s", msg.domain))
|
||||
}
|
||||
m.mode = ViewList
|
||||
|
||||
case deleteMsg:
|
||||
if msg.err != nil {
|
||||
m.setError(fmt.Sprintf("Delete failed: %v", msg.err))
|
||||
} else {
|
||||
cmds = append(cmds, m.refresh())
|
||||
m.setSuccess(fmt.Sprintf("Deleted: %s", msg.alias))
|
||||
}
|
||||
|
||||
case addPresetMsg:
|
||||
if msg.err != nil {
|
||||
m.setError(fmt.Sprintf("Add preset failed: %v", msg.err))
|
||||
} else {
|
||||
cmds = append(cmds, m.refreshPresets())
|
||||
m.setSuccess(fmt.Sprintf("Added preset: %s", msg.name))
|
||||
}
|
||||
m.presetPicker.CancelForm()
|
||||
|
||||
case deletePresetMsg:
|
||||
if msg.err != nil {
|
||||
m.setError(fmt.Sprintf("Delete preset failed: %v", msg.err))
|
||||
} else {
|
||||
cmds = append(cmds, m.refreshPresets())
|
||||
m.setSuccess(fmt.Sprintf("Deleted preset: %s", msg.name))
|
||||
}
|
||||
m.presetPicker.CancelForm()
|
||||
|
||||
case refreshPresetsMsg:
|
||||
if msg.err == nil && msg.presets != nil {
|
||||
m.presetPicker.SetPresetsWithInfo(msg.presets)
|
||||
}
|
||||
|
||||
case addGroupMsg:
|
||||
if msg.err != nil {
|
||||
m.setError(fmt.Sprintf("Add group failed: %v", msg.err))
|
||||
} else {
|
||||
cmds = append(cmds, m.refreshGroups())
|
||||
cmds = append(cmds, m.refresh()) // Refresh list to show new group
|
||||
m.setSuccess(fmt.Sprintf("Added group: %s", msg.name))
|
||||
}
|
||||
m.groupPicker.CancelForm()
|
||||
|
||||
case renameGroupMsg:
|
||||
if msg.err != nil {
|
||||
m.setError(fmt.Sprintf("Rename group failed: %v", msg.err))
|
||||
} else {
|
||||
cmds = append(cmds, m.refreshGroups())
|
||||
cmds = append(cmds, m.refresh())
|
||||
m.setSuccess(fmt.Sprintf("Renamed group to: %s", msg.name))
|
||||
}
|
||||
m.groupPicker.CancelForm()
|
||||
|
||||
case deleteGroupMsg:
|
||||
if msg.err != nil {
|
||||
m.setError(fmt.Sprintf("Delete group failed: %v", msg.err))
|
||||
} else {
|
||||
cmds = append(cmds, m.refreshGroups())
|
||||
cmds = append(cmds, m.refresh())
|
||||
m.setSuccess(fmt.Sprintf("Deleted group: %s", msg.name))
|
||||
}
|
||||
m.groupPicker.CancelForm()
|
||||
|
||||
case refreshGroupsMsg:
|
||||
if msg.err == nil && msg.groups != nil {
|
||||
m.allGroups = msg.groups
|
||||
m.groupPicker.SetGroups(msg.groups)
|
||||
}
|
||||
|
||||
case clearMsgMsg:
|
||||
if time.Since(m.messageTime) >= time.Second*3 {
|
||||
m.message = ""
|
||||
}
|
||||
|
||||
case tickMsg:
|
||||
// Reconnect if disconnected
|
||||
if !m.connected {
|
||||
cmds = append(cmds, m.connect())
|
||||
}
|
||||
cmds = append(cmds, m.tick())
|
||||
|
||||
case updateMsg:
|
||||
if msg.version != "" {
|
||||
m.updateAvailable = true
|
||||
m.updateVersion = msg.version
|
||||
m.updateURL = msg.url
|
||||
}
|
||||
}
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
|
||||
// Global keys
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return tea.Quit
|
||||
}
|
||||
|
||||
// Mode-specific keys
|
||||
switch m.mode {
|
||||
case ViewList:
|
||||
return m.handleListKey(msg)
|
||||
case ViewForm:
|
||||
return m.handleFormKey(msg)
|
||||
case ViewPresets:
|
||||
return m.handlePresetKey(msg)
|
||||
case ViewGroups:
|
||||
return m.handleGroupKey(msg)
|
||||
case ViewHelp:
|
||||
return m.handleHelpKey(msg)
|
||||
case ViewSearch:
|
||||
return m.handleSearchKey(msg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) handleListKey(msg tea.KeyMsg) tea.Cmd {
|
||||
switch msg.String() {
|
||||
case "q":
|
||||
return tea.Quit
|
||||
case "esc":
|
||||
// Clear search if active
|
||||
if m.searchTerm != "" {
|
||||
m.searchTerm = ""
|
||||
m.searchInput.Reset()
|
||||
}
|
||||
case "up", "k":
|
||||
m.list.MoveUp()
|
||||
case "down", "j":
|
||||
m.list.MoveDown()
|
||||
case " ", "enter":
|
||||
return m.toggleSelected()
|
||||
case "n":
|
||||
m.mode = ViewForm
|
||||
m.form.SetGroups(m.allGroups)
|
||||
m.form.Init()
|
||||
case "e":
|
||||
if item := m.list.Selected(); item != nil {
|
||||
m.mode = ViewForm
|
||||
m.form.SetGroups(m.allGroups)
|
||||
m.form.InitEdit(item.Entry.Domain, item.Entry.IP, item.Entry.Alias, item.Entry.Group)
|
||||
}
|
||||
case "d":
|
||||
if item := m.list.Selected(); item != nil {
|
||||
return m.deleteHost(item.Entry.Alias)
|
||||
}
|
||||
case "p":
|
||||
m.mode = ViewPresets
|
||||
case "g":
|
||||
m.mode = ViewGroups
|
||||
return m.refreshGroups()
|
||||
case "/":
|
||||
m.mode = ViewSearch
|
||||
m.searchInput.Focus()
|
||||
case "?":
|
||||
m.mode = ViewHelp
|
||||
case "r":
|
||||
return m.refresh()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) handleFormKey(msg tea.KeyMsg) tea.Cmd {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.mode = ViewList
|
||||
return nil
|
||||
case "enter":
|
||||
if errMsg := m.form.Validate(); errMsg != "" {
|
||||
m.setError(errMsg)
|
||||
return m.clearMsg()
|
||||
}
|
||||
domain, ip, group := m.form.Values()
|
||||
if m.form.IsEdit() {
|
||||
// For edit, delete old and add new (simple approach)
|
||||
oldAlias := m.form.EditAlias()
|
||||
return tea.Sequence(
|
||||
func() tea.Msg {
|
||||
m.client.Delete(oldAlias)
|
||||
return nil
|
||||
},
|
||||
m.addHost(domain, ip, "", group), // Empty alias = auto-generate
|
||||
)
|
||||
}
|
||||
return m.addHost(domain, ip, "", group) // Empty alias = auto-generate
|
||||
}
|
||||
|
||||
return m.form.Update(msg)
|
||||
}
|
||||
|
||||
func (m *Model) handlePresetKey(msg tea.KeyMsg) tea.Cmd {
|
||||
// Handle based on preset picker mode
|
||||
switch m.presetPicker.Mode() {
|
||||
case PresetModeSelect:
|
||||
return m.handlePresetSelectKey(msg)
|
||||
case PresetModeAdd, PresetModeEdit:
|
||||
return m.handlePresetFormKey(msg)
|
||||
case PresetModeConfirmDelete:
|
||||
return m.handlePresetDeleteKey(msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) handlePresetSelectKey(msg tea.KeyMsg) tea.Cmd {
|
||||
switch msg.String() {
|
||||
case "esc", "q":
|
||||
m.mode = ViewList
|
||||
case "up", "k":
|
||||
m.presetPicker.MoveUp()
|
||||
case "down", "j":
|
||||
m.presetPicker.MoveDown()
|
||||
case "enter":
|
||||
if preset := m.presetPicker.Selected(); preset != "" {
|
||||
return m.applyPreset(preset)
|
||||
}
|
||||
case "n":
|
||||
m.presetPicker.InitAdd()
|
||||
case "e":
|
||||
m.presetPicker.InitEdit()
|
||||
case "d":
|
||||
m.presetPicker.InitDelete()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) handlePresetFormKey(msg tea.KeyMsg) tea.Cmd {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.presetPicker.CancelForm()
|
||||
return nil
|
||||
case "enter":
|
||||
if errMsg := m.presetPicker.ValidateForm(); errMsg != "" {
|
||||
m.setError(errMsg)
|
||||
return m.clearMsg()
|
||||
}
|
||||
name, enable, disable := m.presetPicker.FormValues()
|
||||
if m.presetPicker.IsEdit() {
|
||||
// For edit, delete old and add new
|
||||
oldName := m.presetPicker.EditName()
|
||||
return tea.Sequence(
|
||||
func() tea.Msg {
|
||||
m.client.DeletePreset(oldName)
|
||||
return nil
|
||||
},
|
||||
m.addPreset(name, enable, disable),
|
||||
)
|
||||
}
|
||||
return m.addPreset(name, enable, disable)
|
||||
}
|
||||
return m.presetPicker.Update(msg)
|
||||
}
|
||||
|
||||
func (m *Model) handlePresetDeleteKey(msg tea.KeyMsg) tea.Cmd {
|
||||
switch msg.String() {
|
||||
case "y", "Y":
|
||||
if preset := m.presetPicker.Selected(); preset != "" {
|
||||
return m.deletePreset(preset)
|
||||
}
|
||||
m.presetPicker.CancelForm()
|
||||
case "n", "N", "esc":
|
||||
m.presetPicker.CancelForm()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) handleGroupKey(msg tea.KeyMsg) tea.Cmd {
|
||||
// Handle based on group picker mode
|
||||
switch m.groupPicker.Mode() {
|
||||
case GroupModeSelect:
|
||||
return m.handleGroupSelectKey(msg)
|
||||
case GroupModeAdd, GroupModeRename:
|
||||
return m.handleGroupFormKey(msg)
|
||||
case GroupModeConfirmDelete:
|
||||
return m.handleGroupDeleteKey(msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) handleGroupSelectKey(msg tea.KeyMsg) tea.Cmd {
|
||||
switch msg.String() {
|
||||
case "esc", "q":
|
||||
m.mode = ViewList
|
||||
case "up", "k":
|
||||
m.groupPicker.MoveUp()
|
||||
case "down", "j":
|
||||
m.groupPicker.MoveDown()
|
||||
case "n":
|
||||
m.groupPicker.InitAdd()
|
||||
case "r":
|
||||
m.groupPicker.InitRename()
|
||||
case "d":
|
||||
m.groupPicker.InitDelete()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) handleGroupFormKey(msg tea.KeyMsg) tea.Cmd {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.groupPicker.CancelForm()
|
||||
return nil
|
||||
case "enter":
|
||||
if errMsg := m.groupPicker.ValidateForm(); errMsg != "" {
|
||||
m.setError(errMsg)
|
||||
return m.clearMsg()
|
||||
}
|
||||
name := m.groupPicker.FormValue()
|
||||
if m.groupPicker.IsRename() {
|
||||
oldName := m.groupPicker.EditName()
|
||||
return m.renameGroup(oldName, name)
|
||||
}
|
||||
return m.addGroup(name)
|
||||
}
|
||||
return m.groupPicker.Update(msg)
|
||||
}
|
||||
|
||||
func (m *Model) handleGroupDeleteKey(msg tea.KeyMsg) tea.Cmd {
|
||||
switch msg.String() {
|
||||
case "y", "Y":
|
||||
if group := m.groupPicker.Selected(); group != "" {
|
||||
return m.deleteGroup(group)
|
||||
}
|
||||
m.groupPicker.CancelForm()
|
||||
case "n", "N", "esc":
|
||||
m.groupPicker.CancelForm()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) handleHelpKey(msg tea.KeyMsg) tea.Cmd {
|
||||
switch msg.String() {
|
||||
case "esc", "q", "?":
|
||||
m.mode = ViewList
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) handleSearchKey(msg tea.KeyMsg) tea.Cmd {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.mode = ViewList
|
||||
m.searchTerm = ""
|
||||
m.searchInput.Reset()
|
||||
return nil
|
||||
case "enter":
|
||||
m.searchTerm = m.searchInput.Value()
|
||||
m.mode = ViewList
|
||||
return nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (m *Model) toggleSelected() tea.Cmd {
|
||||
item := m.list.Selected()
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.list.SetPending(item.Entry.Alias, true)
|
||||
return m.toggle(item.Entry.Alias, !item.Entry.Enabled)
|
||||
}
|
||||
|
||||
func (m *Model) loadConfig() {
|
||||
if err := m.config.Load(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cfg := m.config.Get()
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var presetNames []string
|
||||
for _, p := range cfg.Presets {
|
||||
presetNames = append(presetNames, p.Name)
|
||||
}
|
||||
m.presetPicker.SetPresets(presetNames)
|
||||
}
|
||||
|
||||
func (m *Model) setError(msg string) {
|
||||
m.message = msg
|
||||
m.messageStyle = "error"
|
||||
m.messageTime = time.Now()
|
||||
}
|
||||
|
||||
func (m *Model) setSuccess(msg string) {
|
||||
m.message = msg
|
||||
m.messageStyle = "success"
|
||||
m.messageTime = time.Now()
|
||||
}
|
||||
|
||||
// View renders the UI.
|
||||
func (m *Model) View() string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Title with version
|
||||
title := titleStyle.Render("lolcathost - Host Management")
|
||||
sb.WriteString(title)
|
||||
|
||||
// Update notification
|
||||
if m.updateAvailable {
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(updateStyle.Render(fmt.Sprintf("Update available: v%s", m.updateVersion)))
|
||||
}
|
||||
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Main content based on mode
|
||||
switch m.mode {
|
||||
case ViewList:
|
||||
sb.WriteString(m.list.ViewFiltered(m.searchTerm))
|
||||
case ViewForm:
|
||||
sb.WriteString(m.form.View())
|
||||
case ViewPresets:
|
||||
sb.WriteString(m.presetPicker.View())
|
||||
case ViewGroups:
|
||||
sb.WriteString(m.groupPicker.View())
|
||||
case ViewHelp:
|
||||
sb.WriteString(m.helpView())
|
||||
case ViewSearch:
|
||||
sb.WriteString(m.searchView())
|
||||
}
|
||||
|
||||
// Message
|
||||
if m.message != "" {
|
||||
sb.WriteString("\n")
|
||||
if m.messageStyle == "error" {
|
||||
sb.WriteString(errorMsgStyle.Render(m.message))
|
||||
} else {
|
||||
sb.WriteString(successMsgStyle.Render(m.message))
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate remaining space for footer positioning
|
||||
currentContent := sb.String()
|
||||
currentLines := strings.Count(currentContent, "\n") + 1
|
||||
|
||||
// Fill space to push footer to bottom (reserve 3 lines for footer)
|
||||
footerHeight := 3
|
||||
remainingLines := m.height - currentLines - footerHeight
|
||||
if remainingLines > 0 {
|
||||
sb.WriteString(strings.Repeat("\n", remainingLines))
|
||||
}
|
||||
|
||||
// Footer (help bar + status bar)
|
||||
if m.mode == ViewList {
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(m.helpBar())
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(m.statusBar())
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (m *Model) helpBar() string {
|
||||
return helpBarStyle.Render(fmt.Sprintf("%s/%s: Navigate %s: Toggle %s: New %s: Edit %s: Delete %s: Presets %s: Groups %s: Search %s: Help %s: Quit",
|
||||
helpKeyStyle.Render("↑↓"),
|
||||
helpKeyStyle.Render("jk"),
|
||||
helpKeyStyle.Render("Space"),
|
||||
helpKeyStyle.Render("n"),
|
||||
helpKeyStyle.Render("e"),
|
||||
helpKeyStyle.Render("d"),
|
||||
helpKeyStyle.Render("p"),
|
||||
helpKeyStyle.Render("g"),
|
||||
helpKeyStyle.Render("/"),
|
||||
helpKeyStyle.Render("?"),
|
||||
helpKeyStyle.Render("q")))
|
||||
}
|
||||
|
||||
func (m *Model) statusBar() string {
|
||||
var status string
|
||||
if m.connected {
|
||||
status = connectedStyle.String()
|
||||
} else {
|
||||
status = disconnectedStyle.String()
|
||||
}
|
||||
|
||||
active := fmt.Sprintf("%d active", m.list.ActiveCount())
|
||||
total := fmt.Sprintf("%d total", m.list.Len())
|
||||
|
||||
return statusBarStyle.Render(fmt.Sprintf("%s | %s | %s", status, active, total))
|
||||
}
|
||||
|
||||
func (m *Model) helpView() string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(titleStyle.Render("Help"))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
help := []struct{ key, desc string }{
|
||||
{"↑/↓ or j/k", "Navigate up/down"},
|
||||
{"Space/Enter", "Toggle entry on/off"},
|
||||
{"n", "Add new entry"},
|
||||
{"e", "Edit selected entry"},
|
||||
{"d", "Delete selected entry"},
|
||||
{"p", "Open preset manager"},
|
||||
{"g", "Open group manager"},
|
||||
{"/", "Search"},
|
||||
{"r", "Refresh list"},
|
||||
{"?", "Toggle this help"},
|
||||
{"q", "Quit"},
|
||||
}
|
||||
|
||||
for _, h := range help {
|
||||
sb.WriteString(fmt.Sprintf(" %s %s\n",
|
||||
helpKeyStyle.Width(15).Render(h.key),
|
||||
helpDescStyle.Render(h.desc)))
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(helpDescStyle.Render("Press ? or Esc to close"))
|
||||
|
||||
return dialogStyle.Render(sb.String())
|
||||
}
|
||||
|
||||
func (m *Model) searchView() string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(titleStyle.Render("Search"))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
sb.WriteString(inputFocusStyle.Render(m.searchInput.View()))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(helpDescStyle.Render("Enter to search • Esc to cancel"))
|
||||
|
||||
return dialogStyle.Render(sb.String())
|
||||
}
|
||||
|
||||
// Run starts the TUI application.
|
||||
func Run(socketPath, configPath string) error {
|
||||
return RunWithVersion(socketPath, configPath, "dev", "", "")
|
||||
}
|
||||
|
||||
// RunWithVersion starts the TUI application with version info for update checking.
|
||||
func RunWithVersion(socketPath, configPath, version, githubOwner, githubRepo string) error {
|
||||
m := NewModel(socketPath, configPath)
|
||||
m.version = version
|
||||
m.githubOwner = githubOwner
|
||||
m.githubRepo = githubRepo
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user