From 9c1c1eb9c6be5267b30de931b0d20bc2eba56f88 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Fri, 28 Nov 2025 12:31:45 +0000 Subject: [PATCH] Create CNAME --- .github/workflows/release.yml | 1 + README.md | 7 +- cmd/lolcathost/main.go | 6 +- docs/CNAME | 1 + docs/index.html | 16 +- go.sum | 4 + internal/client/client.go | 21 ++ internal/daemon/hosts.go | 17 ++ internal/daemon/server.go | 22 ++ internal/installer/installer.go | 59 ++--- internal/protocol/protocol.go | 45 ++-- internal/tui/app.go | 228 ++++++++++++++++---- internal/tui/backups.go | 285 +++++++++++++++++++++++++ internal/tui/form.go | 6 + internal/tui/list.go | 9 + internal/tui/presets.go | 366 ++++++++++++++++++++++++++------ semver.yaml | 2 + 17 files changed, 939 insertions(+), 156 deletions(-) create mode 100644 docs/CNAME create mode 100644 internal/tui/backups.go 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 @@

Get started in under a minute

+
+

+ + Homebrew (macOS/Linux) +

+
brew install lukaszraczylo/brew-taps/lolcathost
+sudo lolcathost --install
+

@@ -300,7 +308,7 @@ sudo ./build/lolcathost --install
  • 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
  • @@ -417,7 +425,11 @@ sudo ./build/lolcathost --install

    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