diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0b1a6ea..e713860 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -51,6 +51,7 @@ jobs:
release:
needs: version
+ if: needs.version.outputs.version_tag != ''
runs-on: ubuntu-latest
permissions:
contents: write
diff --git a/README.md b/README.md
index b7b02c4..b9e287c 100644
--- a/README.md
+++ b/README.md
@@ -87,7 +87,7 @@ The installer will:
- Install the binary to `/usr/local/bin/lolcathost`
- Create a LaunchDaemon (macOS) or systemd service (Linux)
- Start the daemon automatically
-- Create the default config at `~/.config/lolcathost/config.yaml`
+- Create the default config at `/etc/lolcathost/config.yaml`
## Quick Start
@@ -117,7 +117,10 @@ lolcathost
### Config File Location
-Default: `~/.config/lolcathost/config.yaml`
+The configuration is stored at `/etc/lolcathost/config.yaml` and managed by the daemon.
+
+- **TUI/CLI changes**: All changes made through the TUI or CLI are automatically saved to this file
+- **Manual editing**: To edit manually, use `sudo nano /etc/lolcathost/config.yaml` (changes are picked up automatically via hot-reload)
### Example Configuration
diff --git a/cmd/lolcathost/main.go b/cmd/lolcathost/main.go
index 89a71e3..61a733b 100644
--- a/cmd/lolcathost/main.go
+++ b/cmd/lolcathost/main.go
@@ -88,7 +88,7 @@ func main() {
args := flag.Args()
if len(args) == 0 {
// No subcommand - launch TUI
- runTUI(*configPath)
+ runTUI()
return
}
@@ -163,7 +163,7 @@ func runDaemon(configPath string) {
}
}
-func runTUI(configPath string) {
+func runTUI() {
// Check installation
if err := installer.CheckInstallation(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
@@ -171,7 +171,7 @@ func runTUI(configPath string) {
os.Exit(1)
}
- if err := tui.RunWithVersion(protocol.SocketPath, configPath, appVersion, githubOwner, githubRepo); err != nil {
+ if err := tui.RunWithVersion(protocol.SocketPath, appVersion, githubOwner, githubRepo); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
diff --git a/docs/CNAME b/docs/CNAME
new file mode 100644
index 0000000..b8dbacb
--- /dev/null
+++ b/docs/CNAME
@@ -0,0 +1 @@
+lolcathost.raczylo.com
\ No newline at end of file
diff --git a/docs/index.html b/docs/index.html
index a1d2a64..413f967 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -273,6 +273,14 @@
Config File Location
-
Default: ~/.config/lolcathost/config.yaml
+
Configuration is stored at /etc/lolcathost/config.yaml and managed by the daemon.
+
+ - TUI/CLI changes: Automatically saved to this file
+ - Manual editing: Use
sudo nano /etc/lolcathost/config.yaml (hot-reload enabled)
+
Example Configuration
diff --git a/go.sum b/go.sum
index 8a36fb8..66031d1 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
+github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -14,6 +16,8 @@ github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxH
github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg=
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
diff --git a/internal/client/client.go b/internal/client/client.go
index 4db93bd..90d2219 100644
--- a/internal/client/client.go
+++ b/internal/client/client.go
@@ -346,6 +346,27 @@ func (c *Client) ListBackups() ([]protocol.BackupInfo, error) {
return data.Backups, nil
}
+// GetBackupContent returns the content of a backup file.
+func (c *Client) GetBackupContent(backupName string) (string, error) {
+ req, _ := protocol.NewRequest(protocol.RequestBackupContent, protocol.BackupContentPayload{
+ BackupName: backupName,
+ })
+
+ resp, err := c.send(req)
+ if err != nil {
+ return "", err
+ }
+ if !resp.IsOK() {
+ return "", fmt.Errorf("backup content failed: %s", resp.Message)
+ }
+
+ var data protocol.BackupContentData
+ if err := resp.ParseData(&data); err != nil {
+ return "", err
+ }
+ return data.Content, nil
+}
+
// RenameGroup renames a group.
func (c *Client) RenameGroup(oldName, newName string) error {
req, _ := protocol.NewRequest(protocol.RequestRenameGroup, protocol.RenameGroupPayload{
diff --git a/internal/daemon/hosts.go b/internal/daemon/hosts.go
index c43fe28..9f561d4 100644
--- a/internal/daemon/hosts.go
+++ b/internal/daemon/hosts.go
@@ -292,6 +292,23 @@ type BackupInfo struct {
Size int64
}
+// GetBackupContent returns the content of a backup file.
+func (m *HostsManager) GetBackupContent(name string) (string, error) {
+ backupPath := filepath.Join(m.backupDir, name)
+
+ // Validate backup name to prevent path traversal
+ if filepath.Base(name) != name || !strings.HasPrefix(name, "hosts.") || !strings.HasSuffix(name, ".bak") {
+ return "", fmt.Errorf("invalid backup name")
+ }
+
+ content, err := os.ReadFile(backupPath)
+ if err != nil {
+ return "", fmt.Errorf("failed to read backup: %w", err)
+ }
+
+ return string(content), nil
+}
+
// RestoreBackup restores a backup by name.
func (m *HostsManager) RestoreBackup(name string) error {
backupPath := filepath.Join(m.backupDir, name)
diff --git a/internal/daemon/server.go b/internal/daemon/server.go
index f1aa6cd..b39a159 100644
--- a/internal/daemon/server.go
+++ b/internal/daemon/server.go
@@ -258,6 +258,9 @@ func (s *Server) handleRequest(req *protocol.Request, creds *PeerCredentials) *p
case protocol.RequestBackups:
return s.handleBackups()
+ case protocol.RequestBackupContent:
+ return s.handleBackupContent(req)
+
case protocol.RequestAdd:
resp := s.handleAdd(req)
if s.auditLogger != nil {
@@ -514,6 +517,25 @@ func (s *Server) handleBackups() *protocol.Response {
return resp
}
+func (s *Server) handleBackupContent(req *protocol.Request) *protocol.Response {
+ var payload protocol.BackupContentPayload
+ if err := req.ParsePayload(&payload); err != nil {
+ return protocol.NewErrorResponse(protocol.ErrCodeInvalidRequest, "invalid payload")
+ }
+
+ if payload.BackupName == "" {
+ return protocol.NewErrorResponse(protocol.ErrCodeInvalidRequest, "backup name is required")
+ }
+
+ content, err := s.hosts.GetBackupContent(payload.BackupName)
+ if err != nil {
+ return protocol.NewErrorResponse(protocol.ErrCodeNotFound, fmt.Sprintf("failed to get backup content: %v", err))
+ }
+
+ resp, _ := protocol.NewOKResponse(protocol.BackupContentData{Content: content})
+ return resp
+}
+
func (s *Server) handleAdd(req *protocol.Request) *protocol.Response {
var payload protocol.AddPayload
if err := req.ParsePayload(&payload); err != nil {
diff --git a/internal/installer/installer.go b/internal/installer/installer.go
index 9f44c87..ee7f794 100644
--- a/internal/installer/installer.go
+++ b/internal/installer/installer.go
@@ -10,6 +10,7 @@ import (
"runtime"
"strconv"
"strings"
+ "time"
"github.com/lukaszraczylo/lolcathost/internal/config"
)
@@ -337,18 +338,35 @@ func (i *Installer) installLaunchDaemon() error {
plistPath := filepath.Join(LaunchDaemonDir, "com.lolcathost.daemon.plist")
plistContent := fmt.Sprintf(LaunchDaemonPlist, i.binaryPath)
+ // Unload if already loaded (do this before writing plist)
+ i.log(" Stopping existing daemon if running...")
+ exec.Command("launchctl", "bootout", "system/com.lolcathost.daemon").Run()
+
+ // Give launchd time to fully unload the service
+ time.Sleep(500 * time.Millisecond)
+
+ // Remove old plist to ensure clean state
+ os.Remove(plistPath)
+
i.log(" Writing LaunchDaemon plist...")
if err := os.WriteFile(plistPath, []byte(plistContent), 0644); err != nil {
return fmt.Errorf("failed to write plist: %w", err)
}
- // Unload if already loaded
- exec.Command("launchctl", "bootout", "system/com.lolcathost.daemon").Run()
-
// Bootstrap the daemon
i.log(" Starting daemon...")
- if err := exec.Command("launchctl", "bootstrap", "system", plistPath).Run(); err != nil {
- return fmt.Errorf("failed to bootstrap daemon: %w", err)
+ cmd := exec.Command("launchctl", "bootstrap", "system", plistPath)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ // Exit code 5 means "service already loaded" - try kickstart instead
+ if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 5 {
+ i.log(" Service already registered, restarting...")
+ if err := exec.Command("launchctl", "kickstart", "-k", "system/com.lolcathost.daemon").Run(); err != nil {
+ return fmt.Errorf("failed to restart daemon: %w", err)
+ }
+ return nil
+ }
+ return fmt.Errorf("failed to bootstrap daemon: %w (output: %s)", err, string(output))
}
return nil
@@ -399,18 +417,10 @@ func (i *Installer) uninstallSystemdService() {
}
func (i *Installer) createDefaultConfig() error {
- // Get the real user's home directory
- username := os.Getenv("SUDO_USER")
- if username == "" {
- return nil // Can't determine user
- }
-
- u, err := user.Lookup(username)
- if err != nil {
- return fmt.Errorf("failed to lookup user: %w", err)
- }
-
- configPath := filepath.Join(u.HomeDir, ".config", "lolcathost", "config.yaml")
+ // Config is stored at /etc/lolcathost/config.yaml and managed by the daemon.
+ // The daemon creates a default config if none exists when it starts.
+ // No user-level config is created to avoid confusion with two config files.
+ configPath := "/etc/lolcathost/config.yaml"
// Check if config already exists
if _, err := os.Stat(configPath); err == nil {
@@ -418,21 +428,18 @@ func (i *Installer) createDefaultConfig() error {
return nil
}
+ // Create config directory
+ configDir := filepath.Dir(configPath)
+ if err := os.MkdirAll(configDir, 0755); err != nil {
+ return fmt.Errorf("failed to create config directory: %w", err)
+ }
+
i.log(" Creating default config at %s...", configPath)
if err := config.CreateDefault(configPath); err != nil {
return err
}
- // Change ownership to the real user
- uid, _ := strconv.Atoi(u.Uid)
- gid, _ := strconv.Atoi(u.Gid)
-
- configDir := filepath.Dir(configPath)
- os.Chown(configDir, uid, gid)
- os.Chown(filepath.Dir(configDir), uid, gid)
- os.Chown(configPath, uid, gid)
-
return nil
}
diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go
index 00b540a..6038a1a 100644
--- a/internal/protocol/protocol.go
+++ b/internal/protocol/protocol.go
@@ -13,23 +13,24 @@ const SocketPath = "/var/run/lolcathost.sock"
type RequestType string
const (
- RequestPing RequestType = "ping"
- RequestStatus RequestType = "status"
- RequestList RequestType = "list"
- RequestSet RequestType = "set"
- RequestAdd RequestType = "add"
- RequestDelete RequestType = "delete"
- RequestSync RequestType = "sync"
- RequestPreset RequestType = "preset"
- RequestRollback RequestType = "rollback"
- RequestBackups RequestType = "backups"
- RequestAddGroup RequestType = "add_group"
- RequestDeleteGroup RequestType = "delete_group"
- RequestRenameGroup RequestType = "rename_group"
- RequestListGroups RequestType = "list_groups"
- RequestAddPreset RequestType = "add_preset"
- RequestDeletePreset RequestType = "delete_preset"
- RequestListPresets RequestType = "list_presets"
+ RequestPing RequestType = "ping"
+ RequestStatus RequestType = "status"
+ RequestList RequestType = "list"
+ RequestSet RequestType = "set"
+ RequestAdd RequestType = "add"
+ RequestDelete RequestType = "delete"
+ RequestSync RequestType = "sync"
+ RequestPreset RequestType = "preset"
+ RequestRollback RequestType = "rollback"
+ RequestBackups RequestType = "backups"
+ RequestAddGroup RequestType = "add_group"
+ RequestDeleteGroup RequestType = "delete_group"
+ RequestRenameGroup RequestType = "rename_group"
+ RequestListGroups RequestType = "list_groups"
+ RequestAddPreset RequestType = "add_preset"
+ RequestDeletePreset RequestType = "delete_preset"
+ RequestListPresets RequestType = "list_presets"
+ RequestBackupContent RequestType = "backup_content"
)
// ErrorCode defines standard error codes.
@@ -71,6 +72,11 @@ type RollbackPayload struct {
BackupName string `json:"backup_name"`
}
+// BackupContentPayload is the payload for backup_content requests.
+type BackupContentPayload struct {
+ BackupName string `json:"backup_name"`
+}
+
// AddPayload is the payload for add requests.
type AddPayload struct {
Domain string `json:"domain"`
@@ -169,6 +175,11 @@ type BackupInfo struct {
Size int64 `json:"size"`
}
+// BackupContentData is the data for backup_content responses.
+type BackupContentData struct {
+ Content string `json:"content"`
+}
+
// NewRequest creates a new request with the given type and payload.
func NewRequest(reqType RequestType, payload interface{}) (*Request, error) {
req := &Request{Type: reqType}
diff --git a/internal/tui/app.go b/internal/tui/app.go
index 3e9375f..d420db3 100644
--- a/internal/tui/app.go
+++ b/internal/tui/app.go
@@ -24,6 +24,7 @@ const (
ViewForm
ViewPresets
ViewGroups
+ ViewBackups
ViewHelp
ViewSearch
)
@@ -34,16 +35,13 @@ type Model struct {
client *client.Client
connected bool
- // Config
- configPath string
- config *config.Manager
-
// Views
mode ViewMode
list *ListView
form *Form
presetPicker *PresetPicker
groupPicker *GroupPicker
+ backupPicker *BackupPicker
searchInput textinput.Model
// State
@@ -117,6 +115,18 @@ type (
groups []string
err error
}
+ rollbackMsg struct {
+ name string
+ err error
+ }
+ refreshBackupsMsg struct {
+ backups []protocol.BackupInfo
+ err error
+ }
+ backupContentMsg struct {
+ content string
+ err error
+ }
clearMsgMsg struct{}
tickMsg struct{}
updateMsg struct {
@@ -126,7 +136,7 @@ type (
)
// NewModel creates a new TUI model.
-func NewModel(socketPath, configPath string) *Model {
+func NewModel(socketPath string) *Model {
searchInput := textinput.New()
searchInput.Placeholder = "Search..."
searchInput.CharLimit = 100
@@ -134,12 +144,11 @@ func NewModel(socketPath, configPath string) *Model {
return &Model{
client: client.New(socketPath),
- configPath: configPath,
- config: config.NewManager(configPath),
list: NewListView(),
form: NewForm(),
presetPicker: NewPresetPicker(),
groupPicker: NewGroupPicker(),
+ backupPicker: NewBackupPicker(),
searchInput: searchInput,
mode: ViewList,
}
@@ -251,6 +260,27 @@ func (m *Model) refreshGroups() tea.Cmd {
}
}
+func (m *Model) rollback(backupName string) tea.Cmd {
+ return func() tea.Msg {
+ err := m.client.Rollback(backupName)
+ return rollbackMsg{name: backupName, err: err}
+ }
+}
+
+func (m *Model) refreshBackups() tea.Cmd {
+ return func() tea.Msg {
+ backups, err := m.client.ListBackups()
+ return refreshBackupsMsg{backups: backups, err: err}
+ }
+}
+
+func (m *Model) fetchBackupContent(backupName string) tea.Cmd {
+ return func() tea.Msg {
+ content, err := m.client.GetBackupContent(backupName)
+ return backupContentMsg{content: content, err: err}
+ }
+}
+
func (m *Model) tick() tea.Cmd {
return tea.Tick(time.Second*3, func(t time.Time) tea.Msg {
return tickMsg{}
@@ -291,6 +321,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.form.SetSize(msg.Width, msg.Height)
m.presetPicker.SetSize(msg.Width, msg.Height)
m.groupPicker.SetSize(msg.Width, msg.Height)
+ m.backupPicker.SetSize(msg.Width, msg.Height)
// Set search input width
searchWidth := msg.Width - 20
if searchWidth > 60 {
@@ -313,7 +344,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, m.refresh())
cmds = append(cmds, m.refreshPresets())
cmds = append(cmds, m.refreshGroups())
- m.loadConfig()
}
case refreshMsg:
@@ -421,6 +451,30 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.groupPicker.SetGroups(msg.groups)
}
+ case rollbackMsg:
+ if msg.err != nil {
+ m.setError(fmt.Sprintf("Rollback failed: %v", msg.err))
+ } else {
+ cmds = append(cmds, m.refresh())
+ m.setSuccess("Restored from backup")
+ }
+ m.backupPicker.Cancel()
+ m.mode = ViewList
+
+ case refreshBackupsMsg:
+ if msg.err == nil && msg.backups != nil {
+ m.backupPicker.SetBackups(msg.backups)
+ // Fetch content for the first backup
+ if len(msg.backups) > 0 {
+ cmds = append(cmds, m.fetchBackupContent(msg.backups[0].Name))
+ }
+ }
+
+ case backupContentMsg:
+ if msg.err == nil {
+ m.backupPicker.SetPreviewContent(msg.content)
+ }
+
case clearMsgMsg:
if time.Since(m.messageTime) >= time.Second*3 {
m.message = ""
@@ -461,6 +515,8 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
return m.handlePresetKey(msg)
case ViewGroups:
return m.handleGroupKey(msg)
+ case ViewBackups:
+ return m.handleBackupKey(msg)
case ViewHelp:
return m.handleHelpKey(msg)
case ViewSearch:
@@ -502,9 +558,14 @@ func (m *Model) handleListKey(msg tea.KeyMsg) tea.Cmd {
}
case "p":
m.mode = ViewPresets
+ // Pass available aliases to preset picker
+ m.presetPicker.SetAvailableAliases(m.list.GetAliases())
case "g":
m.mode = ViewGroups
return m.refreshGroups()
+ case "b":
+ m.mode = ViewBackups
+ return m.refreshBackups()
case "/":
m.mode = ViewSearch
m.searchInput.Focus()
@@ -551,6 +612,8 @@ func (m *Model) handlePresetKey(msg tea.KeyMsg) tea.Cmd {
return m.handlePresetSelectKey(msg)
case PresetModeAdd, PresetModeEdit:
return m.handlePresetFormKey(msg)
+ case PresetModePickEnable, PresetModePickDisable:
+ return m.handlePresetPickerKey(msg)
case PresetModeConfirmDelete:
return m.handlePresetDeleteKey(msg)
}
@@ -585,27 +648,54 @@ func (m *Model) handlePresetFormKey(msg tea.KeyMsg) tea.Cmd {
m.presetPicker.CancelForm()
return nil
case "enter":
- if errMsg := m.presetPicker.ValidateForm(); errMsg != "" {
- m.setError(errMsg)
- return m.clearMsg()
+ // Check which field is focused
+ switch m.presetPicker.Focus() {
+ case PresetFieldEnable:
+ m.presetPicker.OpenEnablePicker()
+ return nil
+ case PresetFieldDisable:
+ m.presetPicker.OpenDisablePicker()
+ return nil
+ case PresetFieldSave:
+ // Save the preset
+ if errMsg := m.presetPicker.ValidateForm(); errMsg != "" {
+ m.setError(errMsg)
+ return m.clearMsg()
+ }
+ name, enable, disable := m.presetPicker.FormValues()
+ if m.presetPicker.IsEdit() {
+ // For edit, delete old and add new
+ oldName := m.presetPicker.EditName()
+ return tea.Sequence(
+ func() tea.Msg {
+ m.client.DeletePreset(oldName)
+ return nil
+ },
+ m.addPreset(name, enable, disable),
+ )
+ }
+ return m.addPreset(name, enable, disable)
}
- name, enable, disable := m.presetPicker.FormValues()
- if m.presetPicker.IsEdit() {
- // For edit, delete old and add new
- oldName := m.presetPicker.EditName()
- return tea.Sequence(
- func() tea.Msg {
- m.client.DeletePreset(oldName)
- return nil
- },
- m.addPreset(name, enable, disable),
- )
- }
- return m.addPreset(name, enable, disable)
}
return m.presetPicker.Update(msg)
}
+func (m *Model) handlePresetPickerKey(msg tea.KeyMsg) tea.Cmd {
+ switch msg.String() {
+ case "esc":
+ m.presetPicker.ClosePicker()
+ case "enter":
+ m.presetPicker.ClosePicker()
+ case "up", "k":
+ m.presetPicker.PickerMoveUp()
+ case "down", "j":
+ m.presetPicker.PickerMoveDown()
+ case " ":
+ m.presetPicker.TogglePickerSelection()
+ }
+ return nil
+}
+
func (m *Model) handlePresetDeleteKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "y", "Y":
@@ -683,6 +773,57 @@ func (m *Model) handleGroupDeleteKey(msg tea.KeyMsg) tea.Cmd {
return nil
}
+func (m *Model) handleBackupKey(msg tea.KeyMsg) tea.Cmd {
+ switch m.backupPicker.Mode() {
+ case BackupModeSelect:
+ return m.handleBackupSelectKey(msg)
+ case BackupModeConfirmRestore:
+ return m.handleBackupRestoreKey(msg)
+ }
+ return nil
+}
+
+func (m *Model) handleBackupSelectKey(msg tea.KeyMsg) tea.Cmd {
+ switch msg.String() {
+ case "esc", "q":
+ m.mode = ViewList
+ case "up", "k":
+ m.backupPicker.MoveUp()
+ // Fetch content for newly selected backup
+ if backup := m.backupPicker.Selected(); backup != "" && m.backupPicker.PreviewContent() == "" {
+ return m.fetchBackupContent(backup)
+ }
+ case "down", "j":
+ m.backupPicker.MoveDown()
+ // Fetch content for newly selected backup
+ if backup := m.backupPicker.Selected(); backup != "" && m.backupPicker.PreviewContent() == "" {
+ return m.fetchBackupContent(backup)
+ }
+ case "shift+up", "K":
+ m.backupPicker.ScrollPreviewUp()
+ case "shift+down", "J":
+ m.backupPicker.ScrollPreviewDown()
+ case "enter":
+ m.backupPicker.InitRestore()
+ case "r":
+ return m.refreshBackups()
+ }
+ return nil
+}
+
+func (m *Model) handleBackupRestoreKey(msg tea.KeyMsg) tea.Cmd {
+ switch msg.String() {
+ case "y", "Y":
+ if backup := m.backupPicker.Selected(); backup != "" {
+ return m.rollback(backup)
+ }
+ m.backupPicker.Cancel()
+ case "n", "N", "esc":
+ m.backupPicker.Cancel()
+ }
+ return nil
+}
+
func (m *Model) handleHelpKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "esc", "q", "?":
@@ -719,23 +860,6 @@ func (m *Model) toggleSelected() tea.Cmd {
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"
@@ -774,6 +898,8 @@ func (m *Model) View() string {
sb.WriteString(m.presetPicker.View())
case ViewGroups:
sb.WriteString(m.groupPicker.View())
+ case ViewBackups:
+ sb.WriteString(m.backupPicker.View())
case ViewHelp:
sb.WriteString(m.helpView())
case ViewSearch:
@@ -813,7 +939,7 @@ func (m *Model) View() 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",
+ return helpBarStyle.Render(fmt.Sprintf("%s/%s: Navigate %s: Toggle %s: New %s: Edit %s: Delete %s: Presets %s: Groups %s: Backups %s: Search %s: Help %s: Quit",
helpKeyStyle.Render("↑↓"),
helpKeyStyle.Render("jk"),
helpKeyStyle.Render("Space"),
@@ -822,6 +948,7 @@ func (m *Model) helpBar() string {
helpKeyStyle.Render("d"),
helpKeyStyle.Render("p"),
helpKeyStyle.Render("g"),
+ helpKeyStyle.Render("b"),
helpKeyStyle.Render("/"),
helpKeyStyle.Render("?"),
helpKeyStyle.Render("q")))
@@ -855,6 +982,7 @@ func (m *Model) helpView() string {
{"d", "Delete selected entry"},
{"p", "Open preset manager"},
{"g", "Open group manager"},
+ {"b", "Open backup manager"},
{"/", "Search"},
{"r", "Refresh list"},
{"?", "Toggle this help"},
@@ -867,6 +995,14 @@ func (m *Model) helpView() string {
helpDescStyle.Render(h.desc)))
}
+ // Show blocked domains
+ sb.WriteString("\n")
+ sb.WriteString(inputLabelStyle.Render("Blocked Domains:"))
+ sb.WriteString("\n")
+ blockedDomains := config.GetBlockedDomains()
+ sb.WriteString(helpDescStyle.Render(" " + strings.Join(blockedDomains, ", ")))
+ sb.WriteString("\n")
+
sb.WriteString("\n")
sb.WriteString(helpDescStyle.Render("Press ? or Esc to close"))
@@ -887,13 +1023,13 @@ func (m *Model) searchView() string {
}
// Run starts the TUI application.
-func Run(socketPath, configPath string) error {
- return RunWithVersion(socketPath, configPath, "dev", "", "")
+func Run(socketPath string) error {
+ return RunWithVersion(socketPath, "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)
+func RunWithVersion(socketPath, version, githubOwner, githubRepo string) error {
+ m := NewModel(socketPath)
m.version = version
m.githubOwner = githubOwner
m.githubRepo = githubRepo
diff --git a/internal/tui/backups.go b/internal/tui/backups.go
new file mode 100644
index 0000000..21ac2aa
--- /dev/null
+++ b/internal/tui/backups.go
@@ -0,0 +1,285 @@
+// Package tui provides the backup picker component.
+package tui
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/lukaszraczylo/lolcathost/internal/protocol"
+)
+
+// BackupMode represents the backup view mode.
+type BackupMode int
+
+const (
+ BackupModeSelect BackupMode = iota
+ BackupModeConfirmRestore
+)
+
+// BackupPicker handles the backup selection and restore UI.
+type BackupPicker struct {
+ backups []protocol.BackupInfo
+ cursor int
+ width int
+ height int
+ mode BackupMode
+ previewContent string
+ previewScroll int
+}
+
+// NewBackupPicker creates a new backup picker.
+func NewBackupPicker() *BackupPicker {
+ return &BackupPicker{
+ mode: BackupModeSelect,
+ }
+}
+
+// SetBackups updates the available backups.
+func (b *BackupPicker) SetBackups(backups []protocol.BackupInfo) {
+ b.backups = backups
+ if b.cursor >= len(backups) {
+ b.cursor = max(0, len(backups)-1)
+ }
+}
+
+// SetSize sets the picker dimensions.
+func (b *BackupPicker) SetSize(width, height int) {
+ b.width = width
+ b.height = height
+}
+
+// MoveUp moves the cursor up.
+func (b *BackupPicker) MoveUp() {
+ if b.cursor > 0 {
+ b.cursor--
+ b.previewContent = "" // Clear preview to trigger reload
+ b.previewScroll = 0
+ }
+}
+
+// MoveDown moves the cursor down.
+func (b *BackupPicker) MoveDown() {
+ if b.cursor < len(b.backups)-1 {
+ b.cursor++
+ b.previewContent = "" // Clear preview to trigger reload
+ b.previewScroll = 0
+ }
+}
+
+// SetPreviewContent sets the preview content for the current backup.
+func (b *BackupPicker) SetPreviewContent(content string) {
+ b.previewContent = content
+ b.previewScroll = 0
+}
+
+// PreviewContent returns the current preview content.
+func (b *BackupPicker) PreviewContent() string {
+ return b.previewContent
+}
+
+// ScrollPreviewUp scrolls the preview up.
+func (b *BackupPicker) ScrollPreviewUp() {
+ if b.previewScroll > 0 {
+ b.previewScroll--
+ }
+}
+
+// ScrollPreviewDown scrolls the preview down.
+func (b *BackupPicker) ScrollPreviewDown() {
+ b.previewScroll++
+}
+
+// Selected returns the currently selected backup name.
+func (b *BackupPicker) Selected() string {
+ if b.cursor >= 0 && b.cursor < len(b.backups) {
+ return b.backups[b.cursor].Name
+ }
+ return ""
+}
+
+// SelectedInfo returns the currently selected backup info.
+func (b *BackupPicker) SelectedInfo() *protocol.BackupInfo {
+ if b.cursor >= 0 && b.cursor < len(b.backups) {
+ return &b.backups[b.cursor]
+ }
+ return nil
+}
+
+// Len returns the number of backups.
+func (b *BackupPicker) Len() int {
+ return len(b.backups)
+}
+
+// Mode returns the current mode.
+func (b *BackupPicker) Mode() BackupMode {
+ return b.mode
+}
+
+// InitRestore starts restore confirmation.
+func (b *BackupPicker) InitRestore() {
+ if b.SelectedInfo() == nil {
+ return
+ }
+ b.mode = BackupModeConfirmRestore
+}
+
+// Cancel cancels the current operation.
+func (b *BackupPicker) Cancel() {
+ b.mode = BackupModeSelect
+}
+
+// View renders the backup picker.
+func (b *BackupPicker) View() string {
+ switch b.mode {
+ case BackupModeConfirmRestore:
+ return b.restoreView()
+ default:
+ return b.selectView()
+ }
+}
+
+func (b *BackupPicker) selectView() string {
+ if len(b.backups) == 0 {
+ var sb strings.Builder
+ sb.WriteString(titleStyle.Render("Backups"))
+ sb.WriteString("\n\n")
+ sb.WriteString(helpDescStyle.Render("No backups available."))
+ sb.WriteString("\n\n")
+ sb.WriteString(helpDescStyle.Render("Backups are created automatically when hosts are modified."))
+ sb.WriteString("\n\n")
+ sb.WriteString(helpDescStyle.Render("Esc cancel"))
+ return dialogStyle.Render(sb.String())
+ }
+
+ // Build left panel (backup list)
+ var leftSb strings.Builder
+ leftSb.WriteString(titleStyle.Render("Backups"))
+ leftSb.WriteString("\n\n")
+ leftSb.WriteString(helpDescStyle.Render(fmt.Sprintf("%d backup(s)", len(b.backups))))
+ leftSb.WriteString("\n\n")
+
+ for i, backup := range b.backups {
+ timestamp := time.Unix(backup.Timestamp, 0).Format("2006-01-02 15:04:05")
+ sizeStr := formatSize(backup.Size)
+ line := fmt.Sprintf("%s (%s)", timestamp, sizeStr)
+
+ if i == b.cursor {
+ leftSb.WriteString(presetSelectedStyle.Render("▸ " + line))
+ } else {
+ leftSb.WriteString(presetItemStyle.Render(" " + line))
+ }
+ leftSb.WriteString("\n")
+ }
+
+ leftSb.WriteString("\n")
+ leftSb.WriteString(helpDescStyle.Render("↑↓ navigate • Enter restore • Esc cancel"))
+
+ // Build right panel (preview)
+ var rightSb strings.Builder
+ rightSb.WriteString(titleStyle.Render("Preview"))
+ rightSb.WriteString("\n\n")
+
+ if b.previewContent == "" {
+ rightSb.WriteString(helpDescStyle.Render("Loading..."))
+ } else {
+ // Show content with scroll support
+ lines := strings.Split(b.previewContent, "\n")
+ previewHeight := b.height - 12 // Reserve space for title, borders, help
+ if previewHeight < 5 {
+ previewHeight = 5
+ }
+
+ // Clamp scroll position
+ maxScroll := len(lines) - previewHeight
+ if maxScroll < 0 {
+ maxScroll = 0
+ }
+ if b.previewScroll > maxScroll {
+ b.previewScroll = maxScroll
+ }
+
+ // Get visible lines
+ endLine := b.previewScroll + previewHeight
+ if endLine > len(lines) {
+ endLine = len(lines)
+ }
+
+ visibleLines := lines[b.previewScroll:endLine]
+ for _, line := range visibleLines {
+ // Truncate long lines
+ if len(line) > 50 {
+ line = line[:47] + "..."
+ }
+ rightSb.WriteString(helpDescStyle.Render(line))
+ rightSb.WriteString("\n")
+ }
+
+ // Show scroll indicator
+ if len(lines) > previewHeight {
+ rightSb.WriteString("\n")
+ rightSb.WriteString(helpDescStyle.Render(fmt.Sprintf("Lines %d-%d of %d (Shift+↑↓ scroll)", b.previewScroll+1, endLine, len(lines))))
+ }
+ }
+
+ // Style the panels
+ leftWidth := 45
+ rightWidth := b.width - leftWidth - 10
+ if rightWidth < 30 {
+ rightWidth = 30
+ }
+
+ leftPanel := lipgloss.NewStyle().
+ Width(leftWidth).
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(colorAccent).
+ Padding(1, 2).
+ Render(leftSb.String())
+
+ rightPanel := lipgloss.NewStyle().
+ Width(rightWidth).
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(colorMuted).
+ Padding(1, 2).
+ Render(rightSb.String())
+
+ return lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, " ", rightPanel)
+}
+
+func (b *BackupPicker) restoreView() string {
+ var sb strings.Builder
+
+ backup := b.SelectedInfo()
+ timestamp := ""
+ if backup != nil {
+ timestamp = time.Unix(backup.Timestamp, 0).Format("2006-01-02 15:04:05")
+ }
+
+ sb.WriteString(titleStyle.Render("Restore Backup"))
+ sb.WriteString("\n\n")
+ sb.WriteString(errorMsgStyle.Render(fmt.Sprintf("Restore /etc/hosts from backup '%s'?", timestamp)))
+ sb.WriteString("\n\n")
+ sb.WriteString(helpDescStyle.Render("This will replace your current hosts file."))
+ sb.WriteString("\n\n")
+ sb.WriteString(helpDescStyle.Render("y confirm • n/Esc cancel"))
+
+ return dialogStyle.Render(sb.String())
+}
+
+// formatSize formats bytes to human readable format.
+func formatSize(bytes int64) string {
+ const (
+ KB = 1024
+ MB = KB * 1024
+ )
+
+ switch {
+ case bytes >= MB:
+ return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB))
+ case bytes >= KB:
+ return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB))
+ default:
+ return fmt.Sprintf("%d B", bytes)
+ }
+}
diff --git a/internal/tui/form.go b/internal/tui/form.go
index aa7cc69..70b66d0 100644
--- a/internal/tui/form.go
+++ b/internal/tui/form.go
@@ -7,6 +7,7 @@ import (
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/lukaszraczylo/lolcathost/internal/config"
)
// FormMode represents the form mode.
@@ -243,6 +244,11 @@ func (f *Form) Validate() string {
return "Group is required"
}
+ // Check if domain is blocked
+ if config.IsBlockedDomain(domain) {
+ return fmt.Sprintf("Domain '%s' is blocked (Apple system domain)", domain)
+ }
+
return ""
}
diff --git a/internal/tui/list.go b/internal/tui/list.go
index 72a0188..f6b0567 100644
--- a/internal/tui/list.go
+++ b/internal/tui/list.go
@@ -95,6 +95,15 @@ func (l *ListView) SelectedAlias() string {
return ""
}
+// GetAliases returns all available aliases.
+func (l *ListView) GetAliases() []string {
+ aliases := make([]string, len(l.items))
+ for i, item := range l.items {
+ aliases[i] = item.Entry.Alias
+ }
+ return aliases
+}
+
// SetPending marks an item as pending.
func (l *ListView) SetPending(alias string, pending bool) {
for i := range l.items {
diff --git a/internal/tui/presets.go b/internal/tui/presets.go
index c79df6f..13b7498 100644
--- a/internal/tui/presets.go
+++ b/internal/tui/presets.go
@@ -17,6 +17,8 @@ const (
PresetModeAdd
PresetModeEdit
PresetModeConfirmDelete
+ PresetModePickEnable // Multi-select picker for enable aliases
+ PresetModePickDisable // Multi-select picker for disable aliases
)
// PresetFormField represents a form field index.
@@ -26,19 +28,26 @@ const (
PresetFieldName PresetFormField = iota
PresetFieldEnable
PresetFieldDisable
+ PresetFieldSave
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
+ presets []protocol.PresetInfo
+ cursor int
+ width int
+ height int
+ mode PresetMode
+ fields []textinput.Model
+ focus PresetFormField
+ editName string // Original name when editing
+ availableAliases []string // Available host aliases for reference
+
+ // Multi-select picker state
+ pickerCursor int
+ selectedEnable map[string]bool
+ selectedDisable map[string]bool
}
// NewPresetPicker creates a new preset picker.
@@ -61,8 +70,10 @@ func NewPresetPicker() *PresetPicker {
fields[PresetFieldDisable].CharLimit = 500
return &PresetPicker{
- fields: fields,
- mode: PresetModeSelect,
+ fields: fields,
+ mode: PresetModeSelect,
+ selectedEnable: make(map[string]bool),
+ selectedDisable: make(map[string]bool),
}
}
@@ -85,6 +96,11 @@ func (p *PresetPicker) SetPresetsWithInfo(presets []protocol.PresetInfo) {
}
}
+// SetAvailableAliases sets the list of available host aliases for reference.
+func (p *PresetPicker) SetAvailableAliases(aliases []string) {
+ p.availableAliases = aliases
+}
+
// SetSize sets the picker dimensions.
func (p *PresetPicker) SetSize(width, height int) {
p.width = width
@@ -141,6 +157,11 @@ func (p *PresetPicker) SetMode(mode PresetMode) {
p.mode = mode
}
+// Focus returns the currently focused form field.
+func (p *PresetPicker) Focus() PresetFormField {
+ return p.focus
+}
+
// InitAdd initializes the form for adding a new preset.
func (p *PresetPicker) InitAdd() {
p.mode = PresetModeAdd
@@ -150,6 +171,10 @@ func (p *PresetPicker) InitAdd() {
}
p.focus = PresetFieldName
p.fields[PresetFieldName].Focus()
+ // Clear selections
+ p.selectedEnable = make(map[string]bool)
+ p.selectedDisable = make(map[string]bool)
+ p.pickerCursor = 0
}
// InitEdit initializes the form for editing an existing preset.
@@ -163,8 +188,17 @@ func (p *PresetPicker) InitEdit() {
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, ","))
+
+ // Initialize selections from preset (only include aliases that exist)
+ p.selectedEnable = make(map[string]bool)
+ p.selectedDisable = make(map[string]bool)
+ for _, alias := range p.filterExistingAliases(preset.Enable) {
+ p.selectedEnable[alias] = true
+ }
+ for _, alias := range p.filterExistingAliases(preset.Disable) {
+ p.selectedDisable[alias] = true
+ }
+ p.pickerCursor = 0
p.focus = PresetFieldName
p.fields[PresetFieldName].Focus()
@@ -199,47 +233,35 @@ func (p *PresetPicker) Update(msg tea.KeyMsg) tea.Cmd {
return nil
}
- // Update the focused field
- var cmd tea.Cmd
- p.fields[p.focus], cmd = p.fields[p.focus].Update(msg)
- return cmd
+ // Only update text field if focused on name
+ if p.focus == PresetFieldName {
+ var cmd tea.Cmd
+ p.fields[PresetFieldName], cmd = p.fields[PresetFieldName].Update(msg)
+ return cmd
+ }
+ return nil
}
func (p *PresetPicker) nextField() {
- p.fields[p.focus].Blur()
+ // Only blur/focus the name field (it's the only text input)
+ if p.focus == PresetFieldName {
+ p.fields[PresetFieldName].Blur()
+ }
p.focus = (p.focus + 1) % PresetFieldCount
- p.fields[p.focus].Focus()
+ if p.focus == PresetFieldName {
+ p.fields[PresetFieldName].Focus()
+ }
}
func (p *PresetPicker) prevField() {
- p.fields[p.focus].Blur()
+ // Only blur/focus the name field (it's the only text input)
+ if p.focus == PresetFieldName {
+ p.fields[PresetFieldName].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)
- }
- }
+ if p.focus == PresetFieldName {
+ p.fields[PresetFieldName].Focus()
}
-
- 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.
@@ -254,23 +276,98 @@ func (p *PresetPicker) IsEdit() bool {
// ValidateForm validates the form values.
func (p *PresetPicker) ValidateForm() string {
- name, enable, disable := p.FormValues()
+ name := strings.TrimSpace(p.fields[PresetFieldName].Value())
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"
+ if len(p.selectedEnable) == 0 && len(p.selectedDisable) == 0 {
+ return "Select at least one alias to enable or disable"
}
return ""
}
+// FormValues returns the current form values using the selection maps.
+func (p *PresetPicker) FormValues() (name string, enable, disable []string) {
+ name = strings.TrimSpace(p.fields[PresetFieldName].Value())
+
+ for alias := range p.selectedEnable {
+ enable = append(enable, alias)
+ }
+ for alias := range p.selectedDisable {
+ disable = append(disable, alias)
+ }
+
+ return name, enable, disable
+}
+
+// OpenEnablePicker opens the alias picker for enable selection.
+func (p *PresetPicker) OpenEnablePicker() {
+ p.mode = PresetModePickEnable
+ p.pickerCursor = 0
+}
+
+// OpenDisablePicker opens the alias picker for disable selection.
+func (p *PresetPicker) OpenDisablePicker() {
+ p.mode = PresetModePickDisable
+ p.pickerCursor = 0
+}
+
+// ClosePicker closes the alias picker and returns to form.
+func (p *PresetPicker) ClosePicker() {
+ if p.editName != "" {
+ p.mode = PresetModeEdit
+ } else {
+ p.mode = PresetModeAdd
+ }
+}
+
+// TogglePickerSelection toggles the currently highlighted alias.
+func (p *PresetPicker) TogglePickerSelection() {
+ filtered := p.getFilteredAliases()
+ if p.pickerCursor >= len(filtered) {
+ return
+ }
+ alias := filtered[p.pickerCursor]
+
+ if p.mode == PresetModePickEnable {
+ if p.selectedEnable[alias] {
+ delete(p.selectedEnable, alias)
+ } else {
+ p.selectedEnable[alias] = true
+ }
+ } else if p.mode == PresetModePickDisable {
+ if p.selectedDisable[alias] {
+ delete(p.selectedDisable, alias)
+ } else {
+ p.selectedDisable[alias] = true
+ }
+ }
+}
+
+// PickerMoveUp moves picker cursor up.
+func (p *PresetPicker) PickerMoveUp() {
+ if p.pickerCursor > 0 {
+ p.pickerCursor--
+ }
+}
+
+// PickerMoveDown moves picker cursor down.
+func (p *PresetPicker) PickerMoveDown() {
+ filtered := p.getFilteredAliases()
+ if p.pickerCursor < len(filtered)-1 {
+ p.pickerCursor++
+ }
+}
+
// View renders the preset picker.
func (p *PresetPicker) View() string {
switch p.mode {
case PresetModeAdd, PresetModeEdit:
return p.formView()
+ case PresetModePickEnable, PresetModePickDisable:
+ return p.pickerView()
case PresetModeConfirmDelete:
return p.deleteView()
default:
@@ -282,6 +379,8 @@ func (p *PresetPicker) selectView() string {
var sb strings.Builder
sb.WriteString(titleStyle.Render("Presets"))
+ sb.WriteString("\n")
+ sb.WriteString(helpDescStyle.Render("Quickly enable/disable multiple hosts at once"))
sb.WriteString("\n\n")
if len(p.presets) == 0 {
@@ -292,14 +391,26 @@ func (p *PresetPicker) selectView() string {
for i, preset := range p.presets {
if i == p.cursor {
sb.WriteString(presetSelectedStyle.Render("▸ " + preset.Name))
+ sb.WriteString("\n")
+ // Show details for selected preset (only aliases that exist)
+ enableList := p.filterExistingAliases(preset.Enable)
+ disableList := p.filterExistingAliases(preset.Disable)
+ if len(enableList) > 0 {
+ sb.WriteString(enabledStyle.Render(" ● Enable: " + strings.Join(enableList, ", ")))
+ sb.WriteString("\n")
+ }
+ if len(disableList) > 0 {
+ sb.WriteString(disabledStyle.Render(" ○ Disable: " + strings.Join(disableList, ", ")))
+ sb.WriteString("\n")
+ }
} else {
sb.WriteString(presetItemStyle.Render(" " + preset.Name))
+ sb.WriteString("\n")
}
- sb.WriteString("\n")
}
}
- sb.WriteString("\n\n")
+ sb.WriteString("\n")
sb.WriteString(helpDescStyle.Render("↑↓ navigate • Enter apply • n new • e edit • d delete • Esc cancel"))
return dialogStyle.Render(sb.String())
@@ -314,25 +425,160 @@ func (p *PresetPicker) formView() string {
}
sb.WriteString(titleStyle.Render(title))
+ sb.WriteString("\n")
+ sb.WriteString(helpDescStyle.Render("A preset lets you toggle multiple hosts with one action"))
sb.WriteString("\n\n")
- labels := []string{"Name:", "Enable aliases (comma-separated):", "Disable aliases (comma-separated):"}
+ // Name field
+ sb.WriteString(inputLabelStyle.Render("Name:"))
+ sb.WriteString("\n")
+ style := inputStyle
+ if p.focus == PresetFieldName {
+ style = inputFocusStyle
+ }
+ sb.WriteString(style.Render(p.fields[PresetFieldName].View()))
+ sb.WriteString("\n\n")
- for i, label := range labels {
- sb.WriteString(inputLabelStyle.Render(label))
- sb.WriteString("\n")
-
- style := inputStyle
- if PresetFormField(i) == p.focus {
- style = inputFocusStyle
+ // Enable selection (button-style)
+ enableLabel := "Enable hosts:"
+ if p.focus == PresetFieldEnable {
+ enableLabel = "▸ Enable hosts: (press Enter to select)"
+ }
+ sb.WriteString(inputLabelStyle.Render(enableLabel))
+ sb.WriteString("\n")
+ if len(p.selectedEnable) > 0 {
+ var enableList []string
+ for alias := range p.selectedEnable {
+ enableList = append(enableList, alias)
}
+ sb.WriteString(enabledStyle.Render(" ● " + strings.Join(enableList, ", ")))
+ } else {
+ sb.WriteString(helpDescStyle.Render(" (none selected)"))
+ }
+ sb.WriteString("\n\n")
- sb.WriteString(style.Render(p.fields[i].View()))
- sb.WriteString("\n\n")
+ // Disable selection (button-style)
+ disableLabel := "Disable hosts:"
+ if p.focus == PresetFieldDisable {
+ disableLabel = "▸ Disable hosts: (press Enter to select)"
+ }
+ sb.WriteString(inputLabelStyle.Render(disableLabel))
+ sb.WriteString("\n")
+ if len(p.selectedDisable) > 0 {
+ var disableList []string
+ for alias := range p.selectedDisable {
+ disableList = append(disableList, alias)
+ }
+ sb.WriteString(disabledStyle.Render(" ○ " + strings.Join(disableList, ", ")))
+ } else {
+ sb.WriteString(helpDescStyle.Render(" (none selected)"))
+ }
+ sb.WriteString("\n\n")
+
+ // Save button
+ if p.focus == PresetFieldSave {
+ sb.WriteString(presetSelectedStyle.Render("▸ [ Save Preset ]"))
+ } else {
+ sb.WriteString(presetItemStyle.Render(" [ Save Preset ]"))
+ }
+ sb.WriteString("\n\n")
+
+ sb.WriteString(helpDescStyle.Render("Tab/↓ next • Enter select/save • Esc cancel"))
+
+ return dialogStyle.Render(sb.String())
+}
+
+// getFilteredAliases returns aliases filtered for the current picker mode.
+// Enable picker hides items already in disable list, and vice versa.
+func (p *PresetPicker) getFilteredAliases() []string {
+ var filtered []string
+ for _, alias := range p.availableAliases {
+ if p.mode == PresetModePickEnable {
+ // Don't show items already in disable list (unless also in enable)
+ if !p.selectedDisable[alias] || p.selectedEnable[alias] {
+ filtered = append(filtered, alias)
+ }
+ } else {
+ // Don't show items already in enable list (unless also in disable)
+ if !p.selectedEnable[alias] || p.selectedDisable[alias] {
+ filtered = append(filtered, alias)
+ }
+ }
+ }
+ return filtered
+}
+
+// filterExistingAliases filters a list of aliases to only include those that exist.
+func (p *PresetPicker) filterExistingAliases(aliases []string) []string {
+ if len(p.availableAliases) == 0 {
+ return aliases
+ }
+ existsMap := make(map[string]bool)
+ for _, alias := range p.availableAliases {
+ existsMap[alias] = true
+ }
+ var filtered []string
+ for _, alias := range aliases {
+ if existsMap[alias] {
+ filtered = append(filtered, alias)
+ }
+ }
+ return filtered
+}
+
+func (p *PresetPicker) pickerView() string {
+ var sb strings.Builder
+
+ title := "Select hosts to ENABLE"
+ if p.mode == PresetModePickDisable {
+ title = "Select hosts to DISABLE"
}
+ sb.WriteString(titleStyle.Render(title))
sb.WriteString("\n")
- sb.WriteString(helpDescStyle.Render("Tab/↓ next • Shift+Tab/↑ prev • Enter save • Esc cancel"))
+ sb.WriteString(helpDescStyle.Render("Space to toggle • Enter to confirm • Esc to cancel"))
+ sb.WriteString("\n\n")
+
+ filtered := p.getFilteredAliases()
+
+ if len(filtered) == 0 {
+ if len(p.availableAliases) == 0 {
+ sb.WriteString(helpDescStyle.Render("No hosts available. Add some hosts first."))
+ } else {
+ sb.WriteString(helpDescStyle.Render("All hosts are already in the other list."))
+ }
+ } else {
+ // Clamp cursor to filtered list
+ if p.pickerCursor >= len(filtered) {
+ p.pickerCursor = len(filtered) - 1
+ }
+
+ for i, alias := range filtered {
+ var indicator string
+ if p.mode == PresetModePickEnable {
+ if p.selectedEnable[alias] {
+ indicator = enabledStyle.Render("[●]")
+ } else {
+ indicator = helpDescStyle.Render("[ ]")
+ }
+ } else {
+ if p.selectedDisable[alias] {
+ indicator = disabledStyle.Render("[○]")
+ } else {
+ indicator = helpDescStyle.Render("[ ]")
+ }
+ }
+
+ line := indicator + " " + alias
+
+ if i == p.pickerCursor {
+ sb.WriteString(presetSelectedStyle.Render("▸ " + line))
+ } else {
+ sb.WriteString(presetItemStyle.Render(" " + line))
+ }
+ sb.WriteString("\n")
+ }
+ }
return dialogStyle.Render(sb.String())
}
diff --git a/semver.yaml b/semver.yaml
index 95d3a1d..ef1a9b7 100644
--- a/semver.yaml
+++ b/semver.yaml
@@ -1,5 +1,7 @@
version: 1
+prefix: "v"
+
force:
major: 0
minor: 1