mirror of
https://github.com/lukaszraczylo/lolcathost.git
synced 2026-06-05 23:29:18 +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
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
// Package tui provides the form component for adding/editing entries.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// FormMode represents the form mode.
|
||||
type FormMode int
|
||||
|
||||
const (
|
||||
FormModeAdd FormMode = iota
|
||||
FormModeEdit
|
||||
)
|
||||
|
||||
// FormField represents a form field index.
|
||||
type FormField int
|
||||
|
||||
const (
|
||||
FieldDomain FormField = iota
|
||||
FieldIP
|
||||
FieldGroup
|
||||
FieldCount
|
||||
)
|
||||
|
||||
// Form handles the add/edit entry form.
|
||||
type Form struct {
|
||||
mode FormMode
|
||||
fields []textinput.Model
|
||||
focus FormField
|
||||
width int
|
||||
height int
|
||||
editAlias string // Original alias when editing
|
||||
|
||||
// Group dropdown
|
||||
groups []string
|
||||
groupCursor int
|
||||
groupFocused bool
|
||||
}
|
||||
|
||||
// NewForm creates a new form.
|
||||
func NewForm() *Form {
|
||||
fields := make([]textinput.Model, FieldCount)
|
||||
|
||||
// Domain field
|
||||
fields[FieldDomain] = textinput.New()
|
||||
fields[FieldDomain].Placeholder = "example.com"
|
||||
fields[FieldDomain].CharLimit = 253
|
||||
|
||||
// IP field
|
||||
fields[FieldIP] = textinput.New()
|
||||
fields[FieldIP].Placeholder = "127.0.0.1"
|
||||
fields[FieldIP].CharLimit = 45 // IPv6 max
|
||||
|
||||
// Group field (not used as text input, but kept for compatibility)
|
||||
fields[FieldGroup] = textinput.New()
|
||||
fields[FieldGroup].Placeholder = "development"
|
||||
fields[FieldGroup].CharLimit = 63
|
||||
|
||||
return &Form{
|
||||
fields: fields,
|
||||
focus: FieldDomain,
|
||||
groups: []string{"default"},
|
||||
}
|
||||
}
|
||||
|
||||
// SetGroups sets the available groups for the dropdown.
|
||||
func (f *Form) SetGroups(groups []string) {
|
||||
if len(groups) == 0 {
|
||||
f.groups = []string{"default"}
|
||||
} else {
|
||||
f.groups = groups
|
||||
}
|
||||
// Reset cursor if out of bounds
|
||||
if f.groupCursor >= len(f.groups) {
|
||||
f.groupCursor = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the form for adding a new entry.
|
||||
func (f *Form) Init() {
|
||||
f.mode = FormModeAdd
|
||||
f.editAlias = ""
|
||||
|
||||
for i := range f.fields {
|
||||
f.fields[i].Reset()
|
||||
}
|
||||
|
||||
f.fields[FieldIP].SetValue("127.0.0.1")
|
||||
f.groupCursor = 0
|
||||
f.groupFocused = false
|
||||
f.focus = FieldDomain
|
||||
f.fields[FieldDomain].Focus()
|
||||
}
|
||||
|
||||
// InitEdit initializes the form for editing an existing entry.
|
||||
func (f *Form) InitEdit(domain, ip, alias, group string) {
|
||||
f.mode = FormModeEdit
|
||||
f.editAlias = alias
|
||||
|
||||
f.fields[FieldDomain].SetValue(domain)
|
||||
f.fields[FieldIP].SetValue(ip)
|
||||
|
||||
// Find the group in the list
|
||||
f.groupCursor = 0
|
||||
for i, g := range f.groups {
|
||||
if g == group {
|
||||
f.groupCursor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
f.groupFocused = false
|
||||
f.focus = FieldDomain
|
||||
f.fields[FieldDomain].Focus()
|
||||
}
|
||||
|
||||
// SetSize sets the form dimensions.
|
||||
func (f *Form) SetSize(width, height int) {
|
||||
f.width = width
|
||||
f.height = height
|
||||
|
||||
inputWidth := min(50, width-10)
|
||||
for i := range f.fields {
|
||||
f.fields[i].Width = inputWidth
|
||||
}
|
||||
}
|
||||
|
||||
// Update handles input events.
|
||||
func (f *Form) Update(msg tea.Msg) tea.Cmd {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
// Handle group dropdown navigation
|
||||
if f.focus == FieldGroup {
|
||||
switch msg.String() {
|
||||
case "tab":
|
||||
f.nextField()
|
||||
return nil
|
||||
case "shift+tab":
|
||||
f.prevField()
|
||||
return nil
|
||||
case "up", "k":
|
||||
if f.groupCursor > 0 {
|
||||
f.groupCursor--
|
||||
}
|
||||
return nil
|
||||
case "down", "j":
|
||||
if f.groupCursor < len(f.groups)-1 {
|
||||
f.groupCursor++
|
||||
}
|
||||
return nil
|
||||
case "left":
|
||||
if f.groupCursor > 0 {
|
||||
f.groupCursor--
|
||||
}
|
||||
return nil
|
||||
case "right":
|
||||
if f.groupCursor < len(f.groups)-1 {
|
||||
f.groupCursor++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle text input fields
|
||||
switch msg.String() {
|
||||
case "tab", "down":
|
||||
f.nextField()
|
||||
return nil
|
||||
case "shift+tab", "up":
|
||||
f.prevField()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Update the focused text field (only for Domain and IP)
|
||||
if f.focus != FieldGroup {
|
||||
var cmd tea.Cmd
|
||||
f.fields[f.focus], cmd = f.fields[f.focus].Update(msg)
|
||||
return cmd
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Form) nextField() {
|
||||
if f.focus != FieldGroup {
|
||||
f.fields[f.focus].Blur()
|
||||
}
|
||||
f.focus = (f.focus + 1) % FieldCount
|
||||
if f.focus != FieldGroup {
|
||||
f.fields[f.focus].Focus()
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Form) prevField() {
|
||||
if f.focus != FieldGroup {
|
||||
f.fields[f.focus].Blur()
|
||||
}
|
||||
f.focus = (f.focus - 1 + FieldCount) % FieldCount
|
||||
if f.focus != FieldGroup {
|
||||
f.fields[f.focus].Focus()
|
||||
}
|
||||
}
|
||||
|
||||
// Values returns the form values (domain, ip, group).
|
||||
func (f *Form) Values() (domain, ip, group string) {
|
||||
group = ""
|
||||
if f.groupCursor < len(f.groups) {
|
||||
group = f.groups[f.groupCursor]
|
||||
}
|
||||
return strings.TrimSpace(f.fields[FieldDomain].Value()),
|
||||
strings.TrimSpace(f.fields[FieldIP].Value()),
|
||||
group
|
||||
}
|
||||
|
||||
// EditAlias returns the original alias when editing.
|
||||
func (f *Form) EditAlias() string {
|
||||
return f.editAlias
|
||||
}
|
||||
|
||||
// IsEdit returns true if in edit mode.
|
||||
func (f *Form) IsEdit() bool {
|
||||
return f.mode == FormModeEdit
|
||||
}
|
||||
|
||||
// Validate validates the form values.
|
||||
func (f *Form) Validate() string {
|
||||
domain, ip, group := f.Values()
|
||||
|
||||
if domain == "" {
|
||||
return "Domain is required"
|
||||
}
|
||||
if ip == "" {
|
||||
return "IP address is required"
|
||||
}
|
||||
if group == "" {
|
||||
return "Group is required"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// View renders the form.
|
||||
func (f *Form) View() string {
|
||||
var sb strings.Builder
|
||||
|
||||
title := "Add New Entry"
|
||||
if f.mode == FormModeEdit {
|
||||
title = "Edit Entry"
|
||||
}
|
||||
|
||||
sb.WriteString(titleStyle.Render(title))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Domain field
|
||||
sb.WriteString(inputLabelStyle.Render("Domain:"))
|
||||
sb.WriteString("\n")
|
||||
style := inputStyle
|
||||
if f.focus == FieldDomain {
|
||||
style = inputFocusStyle
|
||||
}
|
||||
sb.WriteString(style.Render(f.fields[FieldDomain].View()))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// IP field
|
||||
sb.WriteString(inputLabelStyle.Render("IP Address:"))
|
||||
sb.WriteString("\n")
|
||||
style = inputStyle
|
||||
if f.focus == FieldIP {
|
||||
style = inputFocusStyle
|
||||
}
|
||||
sb.WriteString(style.Render(f.fields[FieldIP].View()))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Group dropdown
|
||||
sb.WriteString(inputLabelStyle.Render("Group:"))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(f.renderGroupDropdown())
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(helpDescStyle.Render("Tab/↓ next • Shift+Tab/↑ prev • ←→ select group • Enter save • Esc cancel"))
|
||||
|
||||
return dialogStyle.Render(sb.String())
|
||||
}
|
||||
|
||||
func (f *Form) renderGroupDropdown() string {
|
||||
isFocused := f.focus == FieldGroup
|
||||
|
||||
// Get current group name
|
||||
currentGroup := "default"
|
||||
if f.groupCursor < len(f.groups) {
|
||||
currentGroup = f.groups[f.groupCursor]
|
||||
}
|
||||
|
||||
// Build the selector content: ◀ group_name ▶
|
||||
var content string
|
||||
if isFocused {
|
||||
// Show arrows when focused
|
||||
leftArrow := "◀"
|
||||
rightArrow := "▶"
|
||||
if f.groupCursor == 0 {
|
||||
leftArrow = " " // dim or hide left arrow at start
|
||||
}
|
||||
if f.groupCursor >= len(f.groups)-1 {
|
||||
rightArrow = " " // dim or hide right arrow at end
|
||||
}
|
||||
content = leftArrow + " " + currentGroup + " " + rightArrow
|
||||
} else {
|
||||
content = " " + currentGroup + " "
|
||||
}
|
||||
|
||||
// Show position indicator if multiple groups
|
||||
if len(f.groups) > 1 {
|
||||
content += fmt.Sprintf(" (%d/%d)", f.groupCursor+1, len(f.groups))
|
||||
}
|
||||
|
||||
// Apply border style
|
||||
if isFocused {
|
||||
return inputFocusStyle.Render(content)
|
||||
}
|
||||
return inputStyle.Render(content)
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// Package tui provides the group management component.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// GroupMode represents the group view mode.
|
||||
type GroupMode int
|
||||
|
||||
const (
|
||||
GroupModeSelect GroupMode = iota
|
||||
GroupModeAdd
|
||||
GroupModeRename
|
||||
GroupModeConfirmDelete
|
||||
)
|
||||
|
||||
// GroupPicker handles the group selection and management UI.
|
||||
type GroupPicker struct {
|
||||
groups []string
|
||||
cursor int
|
||||
width int
|
||||
height int
|
||||
mode GroupMode
|
||||
input textinput.Model
|
||||
editName string // Original name when renaming
|
||||
}
|
||||
|
||||
// NewGroupPicker creates a new group picker.
|
||||
func NewGroupPicker() *GroupPicker {
|
||||
input := textinput.New()
|
||||
input.Placeholder = "group-name"
|
||||
input.CharLimit = 63
|
||||
|
||||
return &GroupPicker{
|
||||
input: input,
|
||||
mode: GroupModeSelect,
|
||||
}
|
||||
}
|
||||
|
||||
// SetGroups updates the available groups.
|
||||
func (g *GroupPicker) SetGroups(groups []string) {
|
||||
g.groups = groups
|
||||
if g.cursor >= len(groups) {
|
||||
g.cursor = max(0, len(groups)-1)
|
||||
}
|
||||
}
|
||||
|
||||
// SetSize sets the picker dimensions.
|
||||
func (g *GroupPicker) SetSize(width, height int) {
|
||||
g.width = width
|
||||
g.height = height
|
||||
g.input.Width = min(50, width-10)
|
||||
}
|
||||
|
||||
// MoveUp moves the cursor up.
|
||||
func (g *GroupPicker) MoveUp() {
|
||||
if g.cursor > 0 {
|
||||
g.cursor--
|
||||
}
|
||||
}
|
||||
|
||||
// MoveDown moves the cursor down.
|
||||
func (g *GroupPicker) MoveDown() {
|
||||
if g.cursor < len(g.groups)-1 {
|
||||
g.cursor++
|
||||
}
|
||||
}
|
||||
|
||||
// Selected returns the currently selected group.
|
||||
func (g *GroupPicker) Selected() string {
|
||||
if g.cursor >= 0 && g.cursor < len(g.groups) {
|
||||
return g.groups[g.cursor]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Len returns the number of groups.
|
||||
func (g *GroupPicker) Len() int {
|
||||
return len(g.groups)
|
||||
}
|
||||
|
||||
// Mode returns the current mode.
|
||||
func (g *GroupPicker) Mode() GroupMode {
|
||||
return g.mode
|
||||
}
|
||||
|
||||
// InitAdd initializes the form for adding a new group.
|
||||
func (g *GroupPicker) InitAdd() {
|
||||
g.mode = GroupModeAdd
|
||||
g.editName = ""
|
||||
g.input.Reset()
|
||||
g.input.Focus()
|
||||
}
|
||||
|
||||
// InitRename initializes the form for renaming an existing group.
|
||||
func (g *GroupPicker) InitRename() {
|
||||
selected := g.Selected()
|
||||
if selected == "" {
|
||||
return
|
||||
}
|
||||
|
||||
g.mode = GroupModeRename
|
||||
g.editName = selected
|
||||
g.input.SetValue(selected)
|
||||
g.input.Focus()
|
||||
}
|
||||
|
||||
// InitDelete starts delete confirmation.
|
||||
func (g *GroupPicker) InitDelete() {
|
||||
if g.Selected() == "" {
|
||||
return
|
||||
}
|
||||
g.mode = GroupModeConfirmDelete
|
||||
}
|
||||
|
||||
// CancelForm cancels the current form operation.
|
||||
func (g *GroupPicker) CancelForm() {
|
||||
g.mode = GroupModeSelect
|
||||
g.editName = ""
|
||||
g.input.Reset()
|
||||
g.input.Blur()
|
||||
}
|
||||
|
||||
// Update handles input events for form mode.
|
||||
func (g *GroupPicker) Update(msg tea.KeyMsg) tea.Cmd {
|
||||
var cmd tea.Cmd
|
||||
g.input, cmd = g.input.Update(msg)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// FormValue returns the form input value.
|
||||
func (g *GroupPicker) FormValue() string {
|
||||
return strings.TrimSpace(g.input.Value())
|
||||
}
|
||||
|
||||
// EditName returns the original name when renaming.
|
||||
func (g *GroupPicker) EditName() string {
|
||||
return g.editName
|
||||
}
|
||||
|
||||
// IsRename returns true if in rename mode.
|
||||
func (g *GroupPicker) IsRename() bool {
|
||||
return g.mode == GroupModeRename
|
||||
}
|
||||
|
||||
// ValidateForm validates the form value.
|
||||
func (g *GroupPicker) ValidateForm() string {
|
||||
value := g.FormValue()
|
||||
if value == "" {
|
||||
return "Group name is required"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// View renders the group picker.
|
||||
func (g *GroupPicker) View() string {
|
||||
switch g.mode {
|
||||
case GroupModeAdd, GroupModeRename:
|
||||
return g.formView()
|
||||
case GroupModeConfirmDelete:
|
||||
return g.deleteView()
|
||||
default:
|
||||
return g.selectView()
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GroupPicker) selectView() string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(titleStyle.Render("Groups"))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
if len(g.groups) == 0 {
|
||||
sb.WriteString(helpDescStyle.Render("No groups configured."))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(helpDescStyle.Render("Press 'n' to create one"))
|
||||
} else {
|
||||
for i, group := range g.groups {
|
||||
if i == g.cursor {
|
||||
sb.WriteString(presetSelectedStyle.Render("▸ " + group))
|
||||
} else {
|
||||
sb.WriteString(presetItemStyle.Render(" " + group))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(helpDescStyle.Render("↑↓ navigate • n new • r rename • d delete • Esc back"))
|
||||
|
||||
return dialogStyle.Render(sb.String())
|
||||
}
|
||||
|
||||
func (g *GroupPicker) formView() string {
|
||||
var sb strings.Builder
|
||||
|
||||
title := "Add New Group"
|
||||
if g.mode == GroupModeRename {
|
||||
title = "Rename Group"
|
||||
}
|
||||
|
||||
sb.WriteString(titleStyle.Render(title))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
sb.WriteString(inputLabelStyle.Render("Name:"))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(inputFocusStyle.Render(g.input.View()))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(helpDescStyle.Render("Enter save • Esc cancel"))
|
||||
|
||||
return dialogStyle.Render(sb.String())
|
||||
}
|
||||
|
||||
func (g *GroupPicker) deleteView() string {
|
||||
var sb strings.Builder
|
||||
|
||||
groupName := g.Selected()
|
||||
|
||||
sb.WriteString(titleStyle.Render("Delete Group"))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(errorMsgStyle.Render("Are you sure you want to delete group '" + groupName + "'?"))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(helpDescStyle.Render("This will remove all hosts in this group!"))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(helpDescStyle.Render("y confirm • n/Esc cancel"))
|
||||
|
||||
return dialogStyle.Render(sb.String())
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// Package tui provides the list view component.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
"github.com/lukaszraczylo/lolcathost/internal/protocol"
|
||||
)
|
||||
|
||||
// EntryItem represents a displayable host entry.
|
||||
type EntryItem struct {
|
||||
Entry protocol.HostEntry
|
||||
Pending bool
|
||||
HasError bool
|
||||
}
|
||||
|
||||
// ListView handles the list of host entries.
|
||||
type ListView struct {
|
||||
items []EntryItem
|
||||
groups map[string][]int // group name -> indices in items
|
||||
groupOrder []string // ordered group names
|
||||
cursor int
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
// NewListView creates a new list view.
|
||||
func NewListView() *ListView {
|
||||
return &ListView{
|
||||
groups: make(map[string][]int),
|
||||
}
|
||||
}
|
||||
|
||||
// SetItems updates the list items.
|
||||
func (l *ListView) SetItems(entries []protocol.HostEntry) {
|
||||
l.items = make([]EntryItem, len(entries))
|
||||
l.groups = make(map[string][]int)
|
||||
l.groupOrder = nil
|
||||
|
||||
groupSeen := make(map[string]bool)
|
||||
|
||||
for i, e := range entries {
|
||||
l.items[i] = EntryItem{Entry: e}
|
||||
|
||||
if !groupSeen[e.Group] {
|
||||
groupSeen[e.Group] = true
|
||||
l.groupOrder = append(l.groupOrder, e.Group)
|
||||
}
|
||||
|
||||
l.groups[e.Group] = append(l.groups[e.Group], i)
|
||||
}
|
||||
|
||||
// Reset cursor if out of bounds
|
||||
if l.cursor >= len(l.items) {
|
||||
l.cursor = max(0, len(l.items)-1)
|
||||
}
|
||||
}
|
||||
|
||||
// SetSize sets the view dimensions.
|
||||
func (l *ListView) SetSize(width, height int) {
|
||||
l.width = width
|
||||
l.height = height
|
||||
}
|
||||
|
||||
// MoveUp moves the cursor up.
|
||||
func (l *ListView) MoveUp() {
|
||||
if l.cursor > 0 {
|
||||
l.cursor--
|
||||
}
|
||||
}
|
||||
|
||||
// MoveDown moves the cursor down.
|
||||
func (l *ListView) MoveDown() {
|
||||
if l.cursor < len(l.items)-1 {
|
||||
l.cursor++
|
||||
}
|
||||
}
|
||||
|
||||
// Selected returns the currently selected item.
|
||||
func (l *ListView) Selected() *EntryItem {
|
||||
if l.cursor >= 0 && l.cursor < len(l.items) {
|
||||
return &l.items[l.cursor]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SelectedAlias returns the alias of the selected item.
|
||||
func (l *ListView) SelectedAlias() string {
|
||||
if item := l.Selected(); item != nil {
|
||||
return item.Entry.Alias
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetPending marks an item as pending.
|
||||
func (l *ListView) SetPending(alias string, pending bool) {
|
||||
for i := range l.items {
|
||||
if l.items[i].Entry.Alias == alias {
|
||||
l.items[i].Pending = pending
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetError marks an item as having an error.
|
||||
func (l *ListView) SetError(alias string, hasError bool) {
|
||||
for i := range l.items {
|
||||
if l.items[i].Entry.Alias == alias {
|
||||
l.items[i].HasError = hasError
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateEntry updates an entry's enabled state.
|
||||
func (l *ListView) UpdateEntry(alias string, enabled bool) {
|
||||
for i := range l.items {
|
||||
if l.items[i].Entry.Alias == alias {
|
||||
l.items[i].Entry.Enabled = enabled
|
||||
l.items[i].Pending = false
|
||||
l.items[i].HasError = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Len returns the number of items.
|
||||
func (l *ListView) Len() int {
|
||||
return len(l.items)
|
||||
}
|
||||
|
||||
// ActiveCount returns the number of enabled entries.
|
||||
func (l *ListView) ActiveCount() int {
|
||||
count := 0
|
||||
for _, item := range l.items {
|
||||
if item.Entry.Enabled {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// FindByAlias finds an item by alias.
|
||||
func (l *ListView) FindByAlias(alias string) *EntryItem {
|
||||
for i := range l.items {
|
||||
if l.items[i].Entry.Alias == alias {
|
||||
return &l.items[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Filter filters items by search term.
|
||||
func (l *ListView) Filter(term string) []EntryItem {
|
||||
if term == "" {
|
||||
return l.items
|
||||
}
|
||||
|
||||
term = strings.ToLower(term)
|
||||
var filtered []EntryItem
|
||||
for _, item := range l.items {
|
||||
if strings.Contains(strings.ToLower(item.Entry.Domain), term) ||
|
||||
strings.Contains(strings.ToLower(item.Entry.Alias), term) ||
|
||||
strings.Contains(strings.ToLower(item.Entry.IP), term) ||
|
||||
strings.Contains(strings.ToLower(item.Entry.Group), term) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// ViewFiltered renders the list filtered by search term.
|
||||
func (l *ListView) ViewFiltered(searchTerm string) string {
|
||||
if searchTerm == "" {
|
||||
return l.View()
|
||||
}
|
||||
|
||||
filtered := l.Filter(searchTerm)
|
||||
if len(filtered) == 0 {
|
||||
emptyStyle := lipgloss.NewStyle().Foreground(colorMuted)
|
||||
return "\n" + emptyStyle.Render(fmt.Sprintf(" No results for '%s'. Press Esc to clear search.", searchTerm)) + "\n"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Show search indicator
|
||||
searchIndicator := lipgloss.NewStyle().
|
||||
Foreground(colorWarning).
|
||||
Bold(true).
|
||||
Render(fmt.Sprintf(" Search: %s (%d results)", searchTerm, len(filtered)))
|
||||
sb.WriteString(searchIndicator)
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Group header style - bright colors for dark terminals
|
||||
groupHeaderStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(colorGroupHeader).
|
||||
Background(lipgloss.Color("238")).
|
||||
Padding(0, 1).
|
||||
MarginTop(1)
|
||||
|
||||
// Organize filtered items by group
|
||||
groupItems := make(map[string][]EntryItem)
|
||||
var groupOrder []string
|
||||
groupSeen := make(map[string]bool)
|
||||
|
||||
for _, item := range filtered {
|
||||
group := item.Entry.Group
|
||||
if !groupSeen[group] {
|
||||
groupSeen[group] = true
|
||||
groupOrder = append(groupOrder, group)
|
||||
}
|
||||
groupItems[group] = append(groupItems[group], item)
|
||||
}
|
||||
|
||||
for _, groupName := range groupOrder {
|
||||
items := groupItems[groupName]
|
||||
if len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Group header
|
||||
headerText := fmt.Sprintf(" %s (%d)", strings.ToUpper(groupName), len(items))
|
||||
sb.WriteString(groupHeaderStyle.Render(headerText))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Build rows for this group's table
|
||||
var rows [][]string
|
||||
for _, item := range items {
|
||||
status := l.getStatusString(item)
|
||||
rows = append(rows, []string{
|
||||
truncate(item.Entry.Domain, 30),
|
||||
truncate(item.Entry.IP, 15),
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
// Create table for this group
|
||||
t := table.New().
|
||||
Border(lipgloss.HiddenBorder()).
|
||||
Headers("DOMAIN", "IP ADDRESS", "STATUS").
|
||||
Rows(rows...).
|
||||
StyleFunc(func(row, col int) lipgloss.Style {
|
||||
// Header row
|
||||
if row == table.HeaderRow {
|
||||
return lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(colorHeader).
|
||||
Padding(0, 1)
|
||||
}
|
||||
|
||||
baseStyle := lipgloss.NewStyle().Padding(0, 1)
|
||||
|
||||
if row >= 0 && row < len(items) {
|
||||
item := items[row]
|
||||
|
||||
// Disabled rows are muted
|
||||
if !item.Entry.Enabled && !item.Pending && !item.HasError {
|
||||
return baseStyle.Foreground(colorMuted)
|
||||
}
|
||||
|
||||
// Status column gets colored based on status
|
||||
if col == 2 { // STATUS column
|
||||
if item.HasError {
|
||||
return baseStyle.Foreground(colorError)
|
||||
}
|
||||
if item.Pending {
|
||||
return baseStyle.Foreground(colorWarning)
|
||||
}
|
||||
if item.Entry.Enabled {
|
||||
return baseStyle.Foreground(colorSuccess)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return baseStyle
|
||||
})
|
||||
|
||||
sb.WriteString(t.Render())
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// GroupCount returns the number of groups.
|
||||
func (l *ListView) GroupCount() int {
|
||||
return len(l.groupOrder)
|
||||
}
|
||||
|
||||
// GetGroups returns all group names.
|
||||
func (l *ListView) GetGroups() []string {
|
||||
return l.groupOrder
|
||||
}
|
||||
|
||||
// View renders the list with groups as headers.
|
||||
func (l *ListView) View() string {
|
||||
if len(l.items) == 0 {
|
||||
emptyStyle := lipgloss.NewStyle().Foreground(colorMuted)
|
||||
return "\n" + emptyStyle.Render(" No host entries configured. Press 'n' to add a new entry.") + "\n"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Group header style - bright colors for dark terminals
|
||||
groupHeaderStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(colorGroupHeader).
|
||||
Background(lipgloss.Color("238")).
|
||||
Padding(0, 1).
|
||||
MarginTop(1)
|
||||
|
||||
for _, groupName := range l.groupOrder {
|
||||
indices := l.groups[groupName]
|
||||
if len(indices) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Group header
|
||||
headerText := fmt.Sprintf(" %s (%d)", strings.ToUpper(groupName), len(indices))
|
||||
sb.WriteString(groupHeaderStyle.Render(headerText))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Build rows for this group's table
|
||||
var rows [][]string
|
||||
// Store actual item indices for cursor matching
|
||||
itemIndices := make([]int, len(indices))
|
||||
copy(itemIndices, indices)
|
||||
|
||||
for _, idx := range indices {
|
||||
item := l.items[idx]
|
||||
status := l.getStatusString(item)
|
||||
rows = append(rows, []string{
|
||||
truncate(item.Entry.Domain, 30),
|
||||
truncate(item.Entry.IP, 15),
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
// Create table for this group
|
||||
t := table.New().
|
||||
Border(lipgloss.HiddenBorder()).
|
||||
Headers("DOMAIN", "IP ADDRESS", "STATUS").
|
||||
Rows(rows...).
|
||||
StyleFunc(func(row, col int) lipgloss.Style {
|
||||
// Header row
|
||||
if row == table.HeaderRow {
|
||||
return lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(colorHeader).
|
||||
Padding(0, 1)
|
||||
}
|
||||
|
||||
baseStyle := lipgloss.NewStyle().Padding(0, 1)
|
||||
|
||||
// Check if this row is selected
|
||||
if row >= 0 && row < len(itemIndices) {
|
||||
actualItemIdx := itemIndices[row]
|
||||
isSelected := actualItemIdx == l.cursor
|
||||
item := l.items[actualItemIdx]
|
||||
|
||||
// Selected row gets background highlight
|
||||
if isSelected {
|
||||
return baseStyle.
|
||||
Background(colorSelectedBg).
|
||||
Foreground(colorSelectedFg)
|
||||
}
|
||||
|
||||
// Disabled rows are muted
|
||||
if !item.Entry.Enabled && !item.Pending && !item.HasError {
|
||||
return baseStyle.Foreground(colorMuted)
|
||||
}
|
||||
|
||||
// Status column gets colored based on status
|
||||
if col == 2 { // STATUS column
|
||||
if item.HasError {
|
||||
return baseStyle.Foreground(colorError)
|
||||
}
|
||||
if item.Pending {
|
||||
return baseStyle.Foreground(colorWarning)
|
||||
}
|
||||
if item.Entry.Enabled {
|
||||
return baseStyle.Foreground(colorSuccess)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return baseStyle
|
||||
})
|
||||
|
||||
sb.WriteString(t.Render())
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (l *ListView) getStatusString(item EntryItem) string {
|
||||
if item.HasError {
|
||||
return "✗ Error"
|
||||
}
|
||||
if item.Pending {
|
||||
return "◐ Pending"
|
||||
}
|
||||
if item.Entry.Enabled {
|
||||
return "● Active"
|
||||
}
|
||||
return "○ Disabled"
|
||||
}
|
||||
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
if maxLen <= 3 {
|
||||
return s[:maxLen]
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/lukaszraczylo/lolcathost/internal/protocol"
|
||||
)
|
||||
|
||||
func TestListView_SetItems(t *testing.T) {
|
||||
lv := NewListView()
|
||||
|
||||
entries := []protocol.HostEntry{
|
||||
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"},
|
||||
{Domain: "b.com", IP: "127.0.0.1", Alias: "b", Enabled: false, Group: "dev"},
|
||||
{Domain: "c.com", IP: "192.168.1.1", Alias: "c", Enabled: true, Group: "staging"},
|
||||
}
|
||||
|
||||
lv.SetItems(entries)
|
||||
|
||||
assert.Equal(t, 3, lv.Len())
|
||||
assert.Len(t, lv.groups, 2)
|
||||
assert.Contains(t, lv.groupOrder, "dev")
|
||||
assert.Contains(t, lv.groupOrder, "staging")
|
||||
}
|
||||
|
||||
func TestListView_Navigation(t *testing.T) {
|
||||
lv := NewListView()
|
||||
entries := []protocol.HostEntry{
|
||||
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"},
|
||||
{Domain: "b.com", IP: "127.0.0.1", Alias: "b", Enabled: false, Group: "dev"},
|
||||
{Domain: "c.com", IP: "192.168.1.1", Alias: "c", Enabled: true, Group: "staging"},
|
||||
}
|
||||
lv.SetItems(entries)
|
||||
|
||||
// Initial position
|
||||
assert.Equal(t, 0, lv.cursor)
|
||||
|
||||
// Move down
|
||||
lv.MoveDown()
|
||||
assert.Equal(t, 1, lv.cursor)
|
||||
|
||||
lv.MoveDown()
|
||||
assert.Equal(t, 2, lv.cursor)
|
||||
|
||||
// Can't move past end
|
||||
lv.MoveDown()
|
||||
assert.Equal(t, 2, lv.cursor)
|
||||
|
||||
// Move up
|
||||
lv.MoveUp()
|
||||
assert.Equal(t, 1, lv.cursor)
|
||||
|
||||
lv.MoveUp()
|
||||
assert.Equal(t, 0, lv.cursor)
|
||||
|
||||
// Can't move before start
|
||||
lv.MoveUp()
|
||||
assert.Equal(t, 0, lv.cursor)
|
||||
}
|
||||
|
||||
func TestListView_Selected(t *testing.T) {
|
||||
lv := NewListView()
|
||||
|
||||
t.Run("empty list", func(t *testing.T) {
|
||||
item := lv.Selected()
|
||||
assert.Nil(t, item)
|
||||
})
|
||||
|
||||
t.Run("with items", func(t *testing.T) {
|
||||
entries := []protocol.HostEntry{
|
||||
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"},
|
||||
{Domain: "b.com", IP: "127.0.0.1", Alias: "b", Enabled: false, Group: "dev"},
|
||||
}
|
||||
lv.SetItems(entries)
|
||||
|
||||
item := lv.Selected()
|
||||
require.NotNil(t, item)
|
||||
assert.Equal(t, "a.com", item.Entry.Domain)
|
||||
|
||||
lv.MoveDown()
|
||||
item = lv.Selected()
|
||||
require.NotNil(t, item)
|
||||
assert.Equal(t, "b.com", item.Entry.Domain)
|
||||
})
|
||||
}
|
||||
|
||||
func TestListView_SelectedAlias(t *testing.T) {
|
||||
lv := NewListView()
|
||||
|
||||
t.Run("empty list", func(t *testing.T) {
|
||||
alias := lv.SelectedAlias()
|
||||
assert.Empty(t, alias)
|
||||
})
|
||||
|
||||
t.Run("with items", func(t *testing.T) {
|
||||
entries := []protocol.HostEntry{
|
||||
{Domain: "a.com", IP: "127.0.0.1", Alias: "my-alias", Enabled: true, Group: "dev"},
|
||||
}
|
||||
lv.SetItems(entries)
|
||||
|
||||
alias := lv.SelectedAlias()
|
||||
assert.Equal(t, "my-alias", alias)
|
||||
})
|
||||
}
|
||||
|
||||
func TestListView_SetPending(t *testing.T) {
|
||||
lv := NewListView()
|
||||
entries := []protocol.HostEntry{
|
||||
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"},
|
||||
}
|
||||
lv.SetItems(entries)
|
||||
|
||||
assert.False(t, lv.items[0].Pending)
|
||||
|
||||
lv.SetPending("a", true)
|
||||
assert.True(t, lv.items[0].Pending)
|
||||
|
||||
lv.SetPending("a", false)
|
||||
assert.False(t, lv.items[0].Pending)
|
||||
|
||||
// Non-existent alias should not panic
|
||||
lv.SetPending("nonexistent", true)
|
||||
}
|
||||
|
||||
func TestListView_SetError(t *testing.T) {
|
||||
lv := NewListView()
|
||||
entries := []protocol.HostEntry{
|
||||
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"},
|
||||
}
|
||||
lv.SetItems(entries)
|
||||
|
||||
assert.False(t, lv.items[0].HasError)
|
||||
|
||||
lv.SetError("a", true)
|
||||
assert.True(t, lv.items[0].HasError)
|
||||
|
||||
lv.SetError("a", false)
|
||||
assert.False(t, lv.items[0].HasError)
|
||||
}
|
||||
|
||||
func TestListView_UpdateEntry(t *testing.T) {
|
||||
lv := NewListView()
|
||||
entries := []protocol.HostEntry{
|
||||
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: false, Group: "dev"},
|
||||
}
|
||||
lv.SetItems(entries)
|
||||
lv.items[0].Pending = true
|
||||
lv.items[0].HasError = true
|
||||
|
||||
lv.UpdateEntry("a", true)
|
||||
|
||||
assert.True(t, lv.items[0].Entry.Enabled)
|
||||
assert.False(t, lv.items[0].Pending)
|
||||
assert.False(t, lv.items[0].HasError)
|
||||
}
|
||||
|
||||
func TestListView_ActiveCount(t *testing.T) {
|
||||
lv := NewListView()
|
||||
entries := []protocol.HostEntry{
|
||||
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"},
|
||||
{Domain: "b.com", IP: "127.0.0.1", Alias: "b", Enabled: false, Group: "dev"},
|
||||
{Domain: "c.com", IP: "192.168.1.1", Alias: "c", Enabled: true, Group: "staging"},
|
||||
}
|
||||
lv.SetItems(entries)
|
||||
|
||||
assert.Equal(t, 2, lv.ActiveCount())
|
||||
}
|
||||
|
||||
func TestListView_FindByAlias(t *testing.T) {
|
||||
lv := NewListView()
|
||||
entries := []protocol.HostEntry{
|
||||
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"},
|
||||
{Domain: "b.com", IP: "127.0.0.1", Alias: "b", Enabled: false, Group: "dev"},
|
||||
}
|
||||
lv.SetItems(entries)
|
||||
|
||||
t.Run("found", func(t *testing.T) {
|
||||
item := lv.FindByAlias("b")
|
||||
require.NotNil(t, item)
|
||||
assert.Equal(t, "b.com", item.Entry.Domain)
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
item := lv.FindByAlias("nonexistent")
|
||||
assert.Nil(t, item)
|
||||
})
|
||||
}
|
||||
|
||||
func TestListView_Filter(t *testing.T) {
|
||||
lv := NewListView()
|
||||
entries := []protocol.HostEntry{
|
||||
{Domain: "myapp.com", IP: "127.0.0.1", Alias: "myapp", Enabled: true, Group: "dev"},
|
||||
{Domain: "api.myapp.com", IP: "127.0.0.1", Alias: "api", Enabled: false, Group: "dev"},
|
||||
{Domain: "other.com", IP: "192.168.1.1", Alias: "other", Enabled: true, Group: "staging"},
|
||||
}
|
||||
lv.SetItems(entries)
|
||||
|
||||
t.Run("empty term", func(t *testing.T) {
|
||||
filtered := lv.Filter("")
|
||||
assert.Len(t, filtered, 3)
|
||||
})
|
||||
|
||||
t.Run("by domain", func(t *testing.T) {
|
||||
filtered := lv.Filter("myapp")
|
||||
assert.Len(t, filtered, 2)
|
||||
})
|
||||
|
||||
t.Run("by alias", func(t *testing.T) {
|
||||
filtered := lv.Filter("api")
|
||||
assert.Len(t, filtered, 1)
|
||||
assert.Equal(t, "api.myapp.com", filtered[0].Entry.Domain)
|
||||
})
|
||||
|
||||
t.Run("by IP", func(t *testing.T) {
|
||||
filtered := lv.Filter("192.168")
|
||||
assert.Len(t, filtered, 1)
|
||||
assert.Equal(t, "other.com", filtered[0].Entry.Domain)
|
||||
})
|
||||
|
||||
t.Run("case insensitive", func(t *testing.T) {
|
||||
filtered := lv.Filter("MYAPP")
|
||||
assert.Len(t, filtered, 2)
|
||||
})
|
||||
|
||||
t.Run("no match", func(t *testing.T) {
|
||||
filtered := lv.Filter("nonexistent")
|
||||
assert.Empty(t, filtered)
|
||||
})
|
||||
}
|
||||
|
||||
func TestListView_View(t *testing.T) {
|
||||
t.Run("empty list", func(t *testing.T) {
|
||||
lv := NewListView()
|
||||
view := lv.View()
|
||||
assert.Contains(t, view, "No host entries")
|
||||
})
|
||||
|
||||
t.Run("with items", func(t *testing.T) {
|
||||
lv := NewListView()
|
||||
entries := []protocol.HostEntry{
|
||||
{Domain: "example.com", IP: "127.0.0.1", Alias: "example", Enabled: true, Group: "dev"},
|
||||
}
|
||||
lv.SetItems(entries)
|
||||
|
||||
view := lv.View()
|
||||
// Group header is shown as section title (uppercase)
|
||||
assert.Contains(t, view, "DEV")
|
||||
// Table headers
|
||||
assert.Contains(t, view, "DOMAIN")
|
||||
assert.Contains(t, view, "IP ADDRESS")
|
||||
assert.Contains(t, view, "STATUS")
|
||||
// Data is in the view
|
||||
assert.Contains(t, view, "example.com")
|
||||
assert.Contains(t, view, "127.0.0.1")
|
||||
assert.Contains(t, view, "Active")
|
||||
})
|
||||
}
|
||||
|
||||
func TestListView_SetSize(t *testing.T) {
|
||||
lv := NewListView()
|
||||
lv.SetSize(80, 24)
|
||||
|
||||
assert.Equal(t, 80, lv.width)
|
||||
assert.Equal(t, 24, lv.height)
|
||||
}
|
||||
|
||||
func TestListView_CursorBounds(t *testing.T) {
|
||||
lv := NewListView()
|
||||
|
||||
// Set items
|
||||
entries := []protocol.HostEntry{
|
||||
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"},
|
||||
{Domain: "b.com", IP: "127.0.0.1", Alias: "b", Enabled: true, Group: "dev"},
|
||||
}
|
||||
lv.SetItems(entries)
|
||||
lv.cursor = 1
|
||||
|
||||
// Set fewer items - cursor should be adjusted
|
||||
entries = []protocol.HostEntry{
|
||||
{Domain: "a.com", IP: "127.0.0.1", Alias: "a", Enabled: true, Group: "dev"},
|
||||
}
|
||||
lv.SetItems(entries)
|
||||
|
||||
assert.Equal(t, 0, lv.cursor)
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
maxLen int
|
||||
expected string
|
||||
}{
|
||||
{"short", 10, "short"},
|
||||
{"exactly10!", 10, "exactly10!"},
|
||||
{"this is too long", 10, "this is..."},
|
||||
{"", 5, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
result := truncate(tt.input, tt.maxLen)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMax(t *testing.T) {
|
||||
assert.Equal(t, 5, max(3, 5))
|
||||
assert.Equal(t, 5, max(5, 3))
|
||||
assert.Equal(t, 5, max(5, 5))
|
||||
assert.Equal(t, 0, max(0, -1))
|
||||
}
|
||||
|
||||
// Matrix test for navigation
|
||||
func TestListView_Navigation_Matrix(t *testing.T) {
|
||||
sizes := []int{1, 5, 10, 100}
|
||||
|
||||
for _, size := range sizes {
|
||||
t.Run("size="+string(rune('0'+size)), func(t *testing.T) {
|
||||
lv := NewListView()
|
||||
|
||||
entries := make([]protocol.HostEntry, size)
|
||||
for i := range entries {
|
||||
entries[i] = protocol.HostEntry{
|
||||
Domain: "domain" + string(rune('a'+i%26)) + ".com",
|
||||
IP: "127.0.0.1",
|
||||
Alias: "alias" + string(rune('a'+i%26)),
|
||||
Enabled: true,
|
||||
Group: "dev",
|
||||
}
|
||||
}
|
||||
lv.SetItems(entries)
|
||||
|
||||
// Move to end
|
||||
for i := 0; i < size*2; i++ {
|
||||
lv.MoveDown()
|
||||
}
|
||||
assert.Equal(t, size-1, lv.cursor)
|
||||
|
||||
// Move to start
|
||||
for i := 0; i < size*2; i++ {
|
||||
lv.MoveUp()
|
||||
}
|
||||
assert.Equal(t, 0, lv.cursor)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkListView_SetItems(b *testing.B) {
|
||||
entries := make([]protocol.HostEntry, 100)
|
||||
for i := range entries {
|
||||
entries[i] = protocol.HostEntry{
|
||||
Domain: "domain.com",
|
||||
IP: "127.0.0.1",
|
||||
Alias: "alias",
|
||||
Enabled: true,
|
||||
Group: "dev",
|
||||
}
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
lv := NewListView()
|
||||
lv.SetItems(entries)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkListView_Filter(b *testing.B) {
|
||||
lv := NewListView()
|
||||
entries := make([]protocol.HostEntry, 100)
|
||||
for i := range entries {
|
||||
entries[i] = protocol.HostEntry{
|
||||
Domain: "domain" + string(rune('a'+i%26)) + ".com",
|
||||
IP: "127.0.0.1",
|
||||
Alias: "alias" + string(rune('a'+i%26)),
|
||||
Enabled: true,
|
||||
Group: "dev",
|
||||
}
|
||||
}
|
||||
lv.SetItems(entries)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = lv.Filter("domain")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkListView_View(b *testing.B) {
|
||||
lv := NewListView()
|
||||
entries := make([]protocol.HostEntry, 50)
|
||||
for i := range entries {
|
||||
entries[i] = protocol.HostEntry{
|
||||
Domain: "domain.com",
|
||||
IP: "127.0.0.1",
|
||||
Alias: "alias",
|
||||
Enabled: i%2 == 0,
|
||||
Group: "group" + string(rune('a'+i%5)),
|
||||
}
|
||||
}
|
||||
lv.SetItems(entries)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = lv.View()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
// Package tui provides the preset picker component.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lukaszraczylo/lolcathost/internal/protocol"
|
||||
)
|
||||
|
||||
// PresetMode represents the preset view mode.
|
||||
type PresetMode int
|
||||
|
||||
const (
|
||||
PresetModeSelect PresetMode = iota
|
||||
PresetModeAdd
|
||||
PresetModeEdit
|
||||
PresetModeConfirmDelete
|
||||
)
|
||||
|
||||
// PresetFormField represents a form field index.
|
||||
type PresetFormField int
|
||||
|
||||
const (
|
||||
PresetFieldName PresetFormField = iota
|
||||
PresetFieldEnable
|
||||
PresetFieldDisable
|
||||
PresetFieldCount
|
||||
)
|
||||
|
||||
// PresetPicker handles the preset selection and management UI.
|
||||
type PresetPicker struct {
|
||||
presets []protocol.PresetInfo
|
||||
cursor int
|
||||
width int
|
||||
height int
|
||||
mode PresetMode
|
||||
fields []textinput.Model
|
||||
focus PresetFormField
|
||||
editName string // Original name when editing
|
||||
}
|
||||
|
||||
// NewPresetPicker creates a new preset picker.
|
||||
func NewPresetPicker() *PresetPicker {
|
||||
fields := make([]textinput.Model, PresetFieldCount)
|
||||
|
||||
// Name field
|
||||
fields[PresetFieldName] = textinput.New()
|
||||
fields[PresetFieldName].Placeholder = "preset-name"
|
||||
fields[PresetFieldName].CharLimit = 63
|
||||
|
||||
// Enable field
|
||||
fields[PresetFieldEnable] = textinput.New()
|
||||
fields[PresetFieldEnable].Placeholder = "alias1,alias2,alias3"
|
||||
fields[PresetFieldEnable].CharLimit = 500
|
||||
|
||||
// Disable field
|
||||
fields[PresetFieldDisable] = textinput.New()
|
||||
fields[PresetFieldDisable].Placeholder = "alias1,alias2,alias3"
|
||||
fields[PresetFieldDisable].CharLimit = 500
|
||||
|
||||
return &PresetPicker{
|
||||
fields: fields,
|
||||
mode: PresetModeSelect,
|
||||
}
|
||||
}
|
||||
|
||||
// SetPresets updates the available presets (legacy method for compatibility).
|
||||
func (p *PresetPicker) SetPresets(presets []string) {
|
||||
p.presets = make([]protocol.PresetInfo, len(presets))
|
||||
for i, name := range presets {
|
||||
p.presets[i] = protocol.PresetInfo{Name: name}
|
||||
}
|
||||
if p.cursor >= len(presets) {
|
||||
p.cursor = max(0, len(presets)-1)
|
||||
}
|
||||
}
|
||||
|
||||
// SetPresetsWithInfo updates the available presets with full info.
|
||||
func (p *PresetPicker) SetPresetsWithInfo(presets []protocol.PresetInfo) {
|
||||
p.presets = presets
|
||||
if p.cursor >= len(presets) {
|
||||
p.cursor = max(0, len(presets)-1)
|
||||
}
|
||||
}
|
||||
|
||||
// SetSize sets the picker dimensions.
|
||||
func (p *PresetPicker) SetSize(width, height int) {
|
||||
p.width = width
|
||||
p.height = height
|
||||
|
||||
inputWidth := min(60, width-10)
|
||||
for i := range p.fields {
|
||||
p.fields[i].Width = inputWidth
|
||||
}
|
||||
}
|
||||
|
||||
// MoveUp moves the cursor up.
|
||||
func (p *PresetPicker) MoveUp() {
|
||||
if p.cursor > 0 {
|
||||
p.cursor--
|
||||
}
|
||||
}
|
||||
|
||||
// MoveDown moves the cursor down.
|
||||
func (p *PresetPicker) MoveDown() {
|
||||
if p.cursor < len(p.presets)-1 {
|
||||
p.cursor++
|
||||
}
|
||||
}
|
||||
|
||||
// Selected returns the currently selected preset name.
|
||||
func (p *PresetPicker) Selected() string {
|
||||
if p.cursor >= 0 && p.cursor < len(p.presets) {
|
||||
return p.presets[p.cursor].Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SelectedInfo returns the currently selected preset info.
|
||||
func (p *PresetPicker) SelectedInfo() *protocol.PresetInfo {
|
||||
if p.cursor >= 0 && p.cursor < len(p.presets) {
|
||||
return &p.presets[p.cursor]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Len returns the number of presets.
|
||||
func (p *PresetPicker) Len() int {
|
||||
return len(p.presets)
|
||||
}
|
||||
|
||||
// Mode returns the current mode.
|
||||
func (p *PresetPicker) Mode() PresetMode {
|
||||
return p.mode
|
||||
}
|
||||
|
||||
// SetMode sets the mode.
|
||||
func (p *PresetPicker) SetMode(mode PresetMode) {
|
||||
p.mode = mode
|
||||
}
|
||||
|
||||
// InitAdd initializes the form for adding a new preset.
|
||||
func (p *PresetPicker) InitAdd() {
|
||||
p.mode = PresetModeAdd
|
||||
p.editName = ""
|
||||
for i := range p.fields {
|
||||
p.fields[i].Reset()
|
||||
}
|
||||
p.focus = PresetFieldName
|
||||
p.fields[PresetFieldName].Focus()
|
||||
}
|
||||
|
||||
// InitEdit initializes the form for editing an existing preset.
|
||||
func (p *PresetPicker) InitEdit() {
|
||||
preset := p.SelectedInfo()
|
||||
if preset == nil {
|
||||
return
|
||||
}
|
||||
|
||||
p.mode = PresetModeEdit
|
||||
p.editName = preset.Name
|
||||
|
||||
p.fields[PresetFieldName].SetValue(preset.Name)
|
||||
p.fields[PresetFieldEnable].SetValue(strings.Join(preset.Enable, ","))
|
||||
p.fields[PresetFieldDisable].SetValue(strings.Join(preset.Disable, ","))
|
||||
|
||||
p.focus = PresetFieldName
|
||||
p.fields[PresetFieldName].Focus()
|
||||
}
|
||||
|
||||
// InitDelete starts delete confirmation.
|
||||
func (p *PresetPicker) InitDelete() {
|
||||
if p.SelectedInfo() == nil {
|
||||
return
|
||||
}
|
||||
p.mode = PresetModeConfirmDelete
|
||||
}
|
||||
|
||||
// CancelForm cancels the current form operation.
|
||||
func (p *PresetPicker) CancelForm() {
|
||||
p.mode = PresetModeSelect
|
||||
p.editName = ""
|
||||
for i := range p.fields {
|
||||
p.fields[i].Reset()
|
||||
p.fields[i].Blur()
|
||||
}
|
||||
}
|
||||
|
||||
// Update handles input events for form mode.
|
||||
func (p *PresetPicker) Update(msg tea.KeyMsg) tea.Cmd {
|
||||
switch msg.String() {
|
||||
case "tab", "down":
|
||||
p.nextField()
|
||||
return nil
|
||||
case "shift+tab", "up":
|
||||
p.prevField()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update the focused field
|
||||
var cmd tea.Cmd
|
||||
p.fields[p.focus], cmd = p.fields[p.focus].Update(msg)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (p *PresetPicker) nextField() {
|
||||
p.fields[p.focus].Blur()
|
||||
p.focus = (p.focus + 1) % PresetFieldCount
|
||||
p.fields[p.focus].Focus()
|
||||
}
|
||||
|
||||
func (p *PresetPicker) prevField() {
|
||||
p.fields[p.focus].Blur()
|
||||
p.focus = (p.focus - 1 + PresetFieldCount) % PresetFieldCount
|
||||
p.fields[p.focus].Focus()
|
||||
}
|
||||
|
||||
// FormValues returns the form values (name, enable list, disable list).
|
||||
func (p *PresetPicker) FormValues() (name string, enable, disable []string) {
|
||||
name = strings.TrimSpace(p.fields[PresetFieldName].Value())
|
||||
|
||||
enableStr := strings.TrimSpace(p.fields[PresetFieldEnable].Value())
|
||||
if enableStr != "" {
|
||||
for _, s := range strings.Split(enableStr, ",") {
|
||||
if trimmed := strings.TrimSpace(s); trimmed != "" {
|
||||
enable = append(enable, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disableStr := strings.TrimSpace(p.fields[PresetFieldDisable].Value())
|
||||
if disableStr != "" {
|
||||
for _, s := range strings.Split(disableStr, ",") {
|
||||
if trimmed := strings.TrimSpace(s); trimmed != "" {
|
||||
disable = append(disable, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return name, enable, disable
|
||||
}
|
||||
|
||||
// EditName returns the original name when editing.
|
||||
func (p *PresetPicker) EditName() string {
|
||||
return p.editName
|
||||
}
|
||||
|
||||
// IsEdit returns true if in edit mode.
|
||||
func (p *PresetPicker) IsEdit() bool {
|
||||
return p.mode == PresetModeEdit
|
||||
}
|
||||
|
||||
// ValidateForm validates the form values.
|
||||
func (p *PresetPicker) ValidateForm() string {
|
||||
name, enable, disable := p.FormValues()
|
||||
|
||||
if name == "" {
|
||||
return "Preset name is required"
|
||||
}
|
||||
if len(enable) == 0 && len(disable) == 0 {
|
||||
return "At least one alias to enable or disable is required"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// View renders the preset picker.
|
||||
func (p *PresetPicker) View() string {
|
||||
switch p.mode {
|
||||
case PresetModeAdd, PresetModeEdit:
|
||||
return p.formView()
|
||||
case PresetModeConfirmDelete:
|
||||
return p.deleteView()
|
||||
default:
|
||||
return p.selectView()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PresetPicker) selectView() string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(titleStyle.Render("Presets"))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
if len(p.presets) == 0 {
|
||||
sb.WriteString(helpDescStyle.Render("No presets configured."))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(helpDescStyle.Render("Press 'n' to create one"))
|
||||
} else {
|
||||
for i, preset := range p.presets {
|
||||
if i == p.cursor {
|
||||
sb.WriteString(presetSelectedStyle.Render("▸ " + preset.Name))
|
||||
} else {
|
||||
sb.WriteString(presetItemStyle.Render(" " + preset.Name))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(helpDescStyle.Render("↑↓ navigate • Enter apply • n new • e edit • d delete • Esc cancel"))
|
||||
|
||||
return dialogStyle.Render(sb.String())
|
||||
}
|
||||
|
||||
func (p *PresetPicker) formView() string {
|
||||
var sb strings.Builder
|
||||
|
||||
title := "Add New Preset"
|
||||
if p.mode == PresetModeEdit {
|
||||
title = "Edit Preset"
|
||||
}
|
||||
|
||||
sb.WriteString(titleStyle.Render(title))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
labels := []string{"Name:", "Enable aliases (comma-separated):", "Disable aliases (comma-separated):"}
|
||||
|
||||
for i, label := range labels {
|
||||
sb.WriteString(inputLabelStyle.Render(label))
|
||||
sb.WriteString("\n")
|
||||
|
||||
style := inputStyle
|
||||
if PresetFormField(i) == p.focus {
|
||||
style = inputFocusStyle
|
||||
}
|
||||
|
||||
sb.WriteString(style.Render(p.fields[i].View()))
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(helpDescStyle.Render("Tab/↓ next • Shift+Tab/↑ prev • Enter save • Esc cancel"))
|
||||
|
||||
return dialogStyle.Render(sb.String())
|
||||
}
|
||||
|
||||
func (p *PresetPicker) deleteView() string {
|
||||
var sb strings.Builder
|
||||
|
||||
preset := p.SelectedInfo()
|
||||
presetName := ""
|
||||
if preset != nil {
|
||||
presetName = preset.Name
|
||||
}
|
||||
|
||||
sb.WriteString(titleStyle.Render("Delete Preset"))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(errorMsgStyle.Render("Are you sure you want to delete preset '" + presetName + "'?"))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(helpDescStyle.Render("y confirm • n/Esc cancel"))
|
||||
|
||||
return dialogStyle.Render(sb.String())
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Package tui provides the terminal user interface.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Colors - matching kportal style, optimized for dark terminals
|
||||
var (
|
||||
colorPrimary = lipgloss.Color("205") // Pink/Magenta
|
||||
colorSuccess = lipgloss.Color("42") // Green
|
||||
colorWarning = lipgloss.Color("220") // Yellow
|
||||
colorError = lipgloss.Color("196") // Red
|
||||
colorMuted = lipgloss.Color("245") // Gray (brighter for dark terminals)
|
||||
colorAccent = lipgloss.Color("141") // Light purple (brighter for dark terminals)
|
||||
colorHeader = lipgloss.Color("220") // Yellow for headers
|
||||
colorSelectedBg = lipgloss.Color("236") // Gray background for selection
|
||||
colorSelectedFg = lipgloss.Color("255") // White foreground for selection
|
||||
colorGroupHeader = lipgloss.Color("213") // Light pink for group headers
|
||||
)
|
||||
|
||||
// Title and header styles
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(colorHeader).
|
||||
Padding(0, 1)
|
||||
)
|
||||
|
||||
// Status indicators
|
||||
var (
|
||||
enabledStyle = lipgloss.NewStyle().
|
||||
Foreground(colorSuccess).
|
||||
Bold(true)
|
||||
|
||||
disabledStyle = lipgloss.NewStyle().
|
||||
Foreground(colorMuted)
|
||||
|
||||
pendingStyle = lipgloss.NewStyle().
|
||||
Foreground(colorWarning)
|
||||
|
||||
errorIndicatorStyle = lipgloss.NewStyle().
|
||||
Foreground(colorError)
|
||||
)
|
||||
|
||||
// Status bar and help
|
||||
var (
|
||||
statusBarStyle = lipgloss.NewStyle().
|
||||
Foreground(colorMuted)
|
||||
|
||||
connectedStyle = lipgloss.NewStyle().
|
||||
Foreground(colorSuccess).
|
||||
SetString("Connected")
|
||||
|
||||
disconnectedStyle = lipgloss.NewStyle().
|
||||
Foreground(colorError).
|
||||
SetString("Disconnected")
|
||||
|
||||
helpBarStyle = lipgloss.NewStyle().
|
||||
Foreground(colorMuted)
|
||||
|
||||
helpKeyStyle = lipgloss.NewStyle().
|
||||
Foreground(colorHeader).
|
||||
Bold(true)
|
||||
|
||||
helpDescStyle = lipgloss.NewStyle().
|
||||
Foreground(colorMuted)
|
||||
)
|
||||
|
||||
// Message styles
|
||||
var (
|
||||
errorMsgStyle = lipgloss.NewStyle().
|
||||
Foreground(colorError).
|
||||
Bold(true).
|
||||
MarginTop(1)
|
||||
|
||||
successMsgStyle = lipgloss.NewStyle().
|
||||
Foreground(colorSuccess).
|
||||
MarginTop(1)
|
||||
|
||||
updateStyle = lipgloss.NewStyle().
|
||||
Foreground(colorSuccess).
|
||||
Bold(true)
|
||||
)
|
||||
|
||||
// Form styles
|
||||
var (
|
||||
inputLabelStyle = lipgloss.NewStyle().
|
||||
Foreground(colorPrimary).
|
||||
Bold(true)
|
||||
|
||||
inputStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorMuted).
|
||||
Padding(0, 1)
|
||||
|
||||
inputFocusStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorPrimary).
|
||||
Padding(0, 1)
|
||||
)
|
||||
|
||||
// Dialog/modal styles
|
||||
var (
|
||||
dialogStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorAccent).
|
||||
Padding(1, 2)
|
||||
|
||||
presetItemStyle = lipgloss.NewStyle().
|
||||
Padding(0, 1)
|
||||
|
||||
presetSelectedStyle = lipgloss.NewStyle().
|
||||
Background(colorSelectedBg).
|
||||
Foreground(colorSelectedFg).
|
||||
Padding(0, 1)
|
||||
)
|
||||
|
||||
// Indicator returns the appropriate status indicator string.
|
||||
func Indicator(enabled bool, pending bool, hasError bool) string {
|
||||
if hasError {
|
||||
return errorIndicatorStyle.Render("✗")
|
||||
}
|
||||
if pending {
|
||||
return pendingStyle.Render("◐")
|
||||
}
|
||||
if enabled {
|
||||
return enabledStyle.Render("●")
|
||||
}
|
||||
return disabledStyle.Render("○")
|
||||
}
|
||||
|
||||
// StatusText returns the status text with appropriate styling
|
||||
func StatusText(enabled bool, pending bool, hasError bool) string {
|
||||
if hasError {
|
||||
return errorIndicatorStyle.Render("✗ Error")
|
||||
}
|
||||
if pending {
|
||||
return pendingStyle.Render("◐ Pending")
|
||||
}
|
||||
if enabled {
|
||||
return enabledStyle.Render("● Active")
|
||||
}
|
||||
return disabledStyle.Render("○ Disabled")
|
||||
}
|
||||
|
||||
// HelpItem formats a help item.
|
||||
func HelpItem(key, desc string) string {
|
||||
return helpKeyStyle.Render(key) + " " + helpDescStyle.Render(desc)
|
||||
}
|
||||
Reference in New Issue
Block a user