mirror of
https://github.com/lukaszraczylo/lolcathost.git
synced 2026-07-11 04:51:56 +00:00
Create CNAME
This commit is contained in:
@@ -51,6 +51,7 @@ jobs:
|
|||||||
|
|
||||||
release:
|
release:
|
||||||
needs: version
|
needs: version
|
||||||
|
if: needs.version.outputs.version_tag != ''
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ The installer will:
|
|||||||
- Install the binary to `/usr/local/bin/lolcathost`
|
- Install the binary to `/usr/local/bin/lolcathost`
|
||||||
- Create a LaunchDaemon (macOS) or systemd service (Linux)
|
- Create a LaunchDaemon (macOS) or systemd service (Linux)
|
||||||
- Start the daemon automatically
|
- Start the daemon automatically
|
||||||
- Create the default config at `~/.config/lolcathost/config.yaml`
|
- Create the default config at `/etc/lolcathost/config.yaml`
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -117,7 +117,10 @@ lolcathost
|
|||||||
|
|
||||||
### Config File Location
|
### 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
|
### Example Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func main() {
|
|||||||
args := flag.Args()
|
args := flag.Args()
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
// No subcommand - launch TUI
|
// No subcommand - launch TUI
|
||||||
runTUI(*configPath)
|
runTUI()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ func runDaemon(configPath string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runTUI(configPath string) {
|
func runTUI() {
|
||||||
// Check installation
|
// Check installation
|
||||||
if err := installer.CheckInstallation(); err != nil {
|
if err := installer.CheckInstallation(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
@@ -171,7 +171,7 @@ func runTUI(configPath string) {
|
|||||||
os.Exit(1)
|
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)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
lolcathost.raczylo.com
|
||||||
+14
-2
@@ -273,6 +273,14 @@
|
|||||||
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Get started in under a minute</p>
|
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Get started in under a minute</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="max-w-3xl mx-auto space-y-6">
|
<div class="max-w-3xl mx-auto space-y-6">
|
||||||
|
<div class="glass p-6 rounded-xl">
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
|
||||||
|
<i class="fas fa-beer mr-2 text-amber-500"></i>
|
||||||
|
Homebrew (macOS/Linux)
|
||||||
|
</h3>
|
||||||
|
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto"><code>brew install lukaszraczylo/brew-taps/lolcathost
|
||||||
|
sudo lolcathost --install</code></pre>
|
||||||
|
</div>
|
||||||
<div class="glass p-6 rounded-xl">
|
<div class="glass p-6 rounded-xl">
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
|
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
|
||||||
<i class="fas fa-download mr-2 text-pink-500"></i>
|
<i class="fas fa-download mr-2 text-pink-500"></i>
|
||||||
@@ -300,7 +308,7 @@ sudo ./build/lolcathost --install</code></pre>
|
|||||||
<li>Install the binary to <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">/usr/local/bin/lolcathost</code></li>
|
<li>Install the binary to <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">/usr/local/bin/lolcathost</code></li>
|
||||||
<li>Create a LaunchDaemon (macOS) or systemd service (Linux)</li>
|
<li>Create a LaunchDaemon (macOS) or systemd service (Linux)</li>
|
||||||
<li>Start the daemon automatically</li>
|
<li>Start the daemon automatically</li>
|
||||||
<li>Create the default config at <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">~/.config/lolcathost/config.yaml</code></li>
|
<li>Create the default config at <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">/etc/lolcathost/config.yaml</code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -417,7 +425,11 @@ sudo ./build/lolcathost --install</code></pre>
|
|||||||
<div class="max-w-4xl mx-auto">
|
<div class="max-w-4xl mx-auto">
|
||||||
<div class="glass p-6 rounded-xl mb-6">
|
<div class="glass p-6 rounded-xl mb-6">
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Config File Location</h3>
|
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Config File Location</h3>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mb-2">Default: <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">~/.config/lolcathost/config.yaml</code></p>
|
<p class="text-gray-600 dark:text-gray-400 mb-2">Configuration is stored at <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">/etc/lolcathost/config.yaml</code> and managed by the daemon.</p>
|
||||||
|
<ul class="list-disc list-inside text-gray-600 dark:text-gray-400 text-sm mt-2 space-y-1">
|
||||||
|
<li><strong>TUI/CLI changes:</strong> Automatically saved to this file</li>
|
||||||
|
<li><strong>Manual editing:</strong> Use <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">sudo nano /etc/lolcathost/config.yaml</code> (hot-reload enabled)</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="glass p-6 rounded-xl mb-6">
|
<div class="glass p-6 rounded-xl mb-6">
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Example Configuration</h3>
|
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Example Configuration</h3>
|
||||||
|
|||||||
@@ -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/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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
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 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
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/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 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
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 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
|
github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
|
||||||
|
|||||||
@@ -346,6 +346,27 @@ func (c *Client) ListBackups() ([]protocol.BackupInfo, error) {
|
|||||||
return data.Backups, nil
|
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.
|
// RenameGroup renames a group.
|
||||||
func (c *Client) RenameGroup(oldName, newName string) error {
|
func (c *Client) RenameGroup(oldName, newName string) error {
|
||||||
req, _ := protocol.NewRequest(protocol.RequestRenameGroup, protocol.RenameGroupPayload{
|
req, _ := protocol.NewRequest(protocol.RequestRenameGroup, protocol.RenameGroupPayload{
|
||||||
|
|||||||
@@ -292,6 +292,23 @@ type BackupInfo struct {
|
|||||||
Size int64
|
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.
|
// RestoreBackup restores a backup by name.
|
||||||
func (m *HostsManager) RestoreBackup(name string) error {
|
func (m *HostsManager) RestoreBackup(name string) error {
|
||||||
backupPath := filepath.Join(m.backupDir, name)
|
backupPath := filepath.Join(m.backupDir, name)
|
||||||
|
|||||||
@@ -258,6 +258,9 @@ func (s *Server) handleRequest(req *protocol.Request, creds *PeerCredentials) *p
|
|||||||
case protocol.RequestBackups:
|
case protocol.RequestBackups:
|
||||||
return s.handleBackups()
|
return s.handleBackups()
|
||||||
|
|
||||||
|
case protocol.RequestBackupContent:
|
||||||
|
return s.handleBackupContent(req)
|
||||||
|
|
||||||
case protocol.RequestAdd:
|
case protocol.RequestAdd:
|
||||||
resp := s.handleAdd(req)
|
resp := s.handleAdd(req)
|
||||||
if s.auditLogger != nil {
|
if s.auditLogger != nil {
|
||||||
@@ -514,6 +517,25 @@ func (s *Server) handleBackups() *protocol.Response {
|
|||||||
return resp
|
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 {
|
func (s *Server) handleAdd(req *protocol.Request) *protocol.Response {
|
||||||
var payload protocol.AddPayload
|
var payload protocol.AddPayload
|
||||||
if err := req.ParsePayload(&payload); err != nil {
|
if err := req.ParsePayload(&payload); err != nil {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/lolcathost/internal/config"
|
"github.com/lukaszraczylo/lolcathost/internal/config"
|
||||||
)
|
)
|
||||||
@@ -337,18 +338,35 @@ func (i *Installer) installLaunchDaemon() error {
|
|||||||
plistPath := filepath.Join(LaunchDaemonDir, "com.lolcathost.daemon.plist")
|
plistPath := filepath.Join(LaunchDaemonDir, "com.lolcathost.daemon.plist")
|
||||||
plistContent := fmt.Sprintf(LaunchDaemonPlist, i.binaryPath)
|
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...")
|
i.log(" Writing LaunchDaemon plist...")
|
||||||
if err := os.WriteFile(plistPath, []byte(plistContent), 0644); err != nil {
|
if err := os.WriteFile(plistPath, []byte(plistContent), 0644); err != nil {
|
||||||
return fmt.Errorf("failed to write plist: %w", err)
|
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
|
// Bootstrap the daemon
|
||||||
i.log(" Starting daemon...")
|
i.log(" Starting daemon...")
|
||||||
if err := exec.Command("launchctl", "bootstrap", "system", plistPath).Run(); err != nil {
|
cmd := exec.Command("launchctl", "bootstrap", "system", plistPath)
|
||||||
return fmt.Errorf("failed to bootstrap daemon: %w", err)
|
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
|
return nil
|
||||||
@@ -399,18 +417,10 @@ func (i *Installer) uninstallSystemdService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (i *Installer) createDefaultConfig() error {
|
func (i *Installer) createDefaultConfig() error {
|
||||||
// Get the real user's home directory
|
// Config is stored at /etc/lolcathost/config.yaml and managed by the daemon.
|
||||||
username := os.Getenv("SUDO_USER")
|
// The daemon creates a default config if none exists when it starts.
|
||||||
if username == "" {
|
// No user-level config is created to avoid confusion with two config files.
|
||||||
return nil // Can't determine user
|
configPath := "/etc/lolcathost/config.yaml"
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
// Check if config already exists
|
// Check if config already exists
|
||||||
if _, err := os.Stat(configPath); err == nil {
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
@@ -418,21 +428,18 @@ func (i *Installer) createDefaultConfig() error {
|
|||||||
return nil
|
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)
|
i.log(" Creating default config at %s...", configPath)
|
||||||
|
|
||||||
if err := config.CreateDefault(configPath); err != nil {
|
if err := config.CreateDefault(configPath); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,23 +13,24 @@ const SocketPath = "/var/run/lolcathost.sock"
|
|||||||
type RequestType string
|
type RequestType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RequestPing RequestType = "ping"
|
RequestPing RequestType = "ping"
|
||||||
RequestStatus RequestType = "status"
|
RequestStatus RequestType = "status"
|
||||||
RequestList RequestType = "list"
|
RequestList RequestType = "list"
|
||||||
RequestSet RequestType = "set"
|
RequestSet RequestType = "set"
|
||||||
RequestAdd RequestType = "add"
|
RequestAdd RequestType = "add"
|
||||||
RequestDelete RequestType = "delete"
|
RequestDelete RequestType = "delete"
|
||||||
RequestSync RequestType = "sync"
|
RequestSync RequestType = "sync"
|
||||||
RequestPreset RequestType = "preset"
|
RequestPreset RequestType = "preset"
|
||||||
RequestRollback RequestType = "rollback"
|
RequestRollback RequestType = "rollback"
|
||||||
RequestBackups RequestType = "backups"
|
RequestBackups RequestType = "backups"
|
||||||
RequestAddGroup RequestType = "add_group"
|
RequestAddGroup RequestType = "add_group"
|
||||||
RequestDeleteGroup RequestType = "delete_group"
|
RequestDeleteGroup RequestType = "delete_group"
|
||||||
RequestRenameGroup RequestType = "rename_group"
|
RequestRenameGroup RequestType = "rename_group"
|
||||||
RequestListGroups RequestType = "list_groups"
|
RequestListGroups RequestType = "list_groups"
|
||||||
RequestAddPreset RequestType = "add_preset"
|
RequestAddPreset RequestType = "add_preset"
|
||||||
RequestDeletePreset RequestType = "delete_preset"
|
RequestDeletePreset RequestType = "delete_preset"
|
||||||
RequestListPresets RequestType = "list_presets"
|
RequestListPresets RequestType = "list_presets"
|
||||||
|
RequestBackupContent RequestType = "backup_content"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrorCode defines standard error codes.
|
// ErrorCode defines standard error codes.
|
||||||
@@ -71,6 +72,11 @@ type RollbackPayload struct {
|
|||||||
BackupName string `json:"backup_name"`
|
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.
|
// AddPayload is the payload for add requests.
|
||||||
type AddPayload struct {
|
type AddPayload struct {
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
@@ -169,6 +175,11 @@ type BackupInfo struct {
|
|||||||
Size int64 `json:"size"`
|
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.
|
// NewRequest creates a new request with the given type and payload.
|
||||||
func NewRequest(reqType RequestType, payload interface{}) (*Request, error) {
|
func NewRequest(reqType RequestType, payload interface{}) (*Request, error) {
|
||||||
req := &Request{Type: reqType}
|
req := &Request{Type: reqType}
|
||||||
|
|||||||
+182
-46
@@ -24,6 +24,7 @@ const (
|
|||||||
ViewForm
|
ViewForm
|
||||||
ViewPresets
|
ViewPresets
|
||||||
ViewGroups
|
ViewGroups
|
||||||
|
ViewBackups
|
||||||
ViewHelp
|
ViewHelp
|
||||||
ViewSearch
|
ViewSearch
|
||||||
)
|
)
|
||||||
@@ -34,16 +35,13 @@ type Model struct {
|
|||||||
client *client.Client
|
client *client.Client
|
||||||
connected bool
|
connected bool
|
||||||
|
|
||||||
// Config
|
|
||||||
configPath string
|
|
||||||
config *config.Manager
|
|
||||||
|
|
||||||
// Views
|
// Views
|
||||||
mode ViewMode
|
mode ViewMode
|
||||||
list *ListView
|
list *ListView
|
||||||
form *Form
|
form *Form
|
||||||
presetPicker *PresetPicker
|
presetPicker *PresetPicker
|
||||||
groupPicker *GroupPicker
|
groupPicker *GroupPicker
|
||||||
|
backupPicker *BackupPicker
|
||||||
searchInput textinput.Model
|
searchInput textinput.Model
|
||||||
|
|
||||||
// State
|
// State
|
||||||
@@ -117,6 +115,18 @@ type (
|
|||||||
groups []string
|
groups []string
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
rollbackMsg struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
refreshBackupsMsg struct {
|
||||||
|
backups []protocol.BackupInfo
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
backupContentMsg struct {
|
||||||
|
content string
|
||||||
|
err error
|
||||||
|
}
|
||||||
clearMsgMsg struct{}
|
clearMsgMsg struct{}
|
||||||
tickMsg struct{}
|
tickMsg struct{}
|
||||||
updateMsg struct {
|
updateMsg struct {
|
||||||
@@ -126,7 +136,7 @@ type (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// NewModel creates a new TUI model.
|
// NewModel creates a new TUI model.
|
||||||
func NewModel(socketPath, configPath string) *Model {
|
func NewModel(socketPath string) *Model {
|
||||||
searchInput := textinput.New()
|
searchInput := textinput.New()
|
||||||
searchInput.Placeholder = "Search..."
|
searchInput.Placeholder = "Search..."
|
||||||
searchInput.CharLimit = 100
|
searchInput.CharLimit = 100
|
||||||
@@ -134,12 +144,11 @@ func NewModel(socketPath, configPath string) *Model {
|
|||||||
|
|
||||||
return &Model{
|
return &Model{
|
||||||
client: client.New(socketPath),
|
client: client.New(socketPath),
|
||||||
configPath: configPath,
|
|
||||||
config: config.NewManager(configPath),
|
|
||||||
list: NewListView(),
|
list: NewListView(),
|
||||||
form: NewForm(),
|
form: NewForm(),
|
||||||
presetPicker: NewPresetPicker(),
|
presetPicker: NewPresetPicker(),
|
||||||
groupPicker: NewGroupPicker(),
|
groupPicker: NewGroupPicker(),
|
||||||
|
backupPicker: NewBackupPicker(),
|
||||||
searchInput: searchInput,
|
searchInput: searchInput,
|
||||||
mode: ViewList,
|
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 {
|
func (m *Model) tick() tea.Cmd {
|
||||||
return tea.Tick(time.Second*3, func(t time.Time) tea.Msg {
|
return tea.Tick(time.Second*3, func(t time.Time) tea.Msg {
|
||||||
return tickMsg{}
|
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.form.SetSize(msg.Width, msg.Height)
|
||||||
m.presetPicker.SetSize(msg.Width, msg.Height)
|
m.presetPicker.SetSize(msg.Width, msg.Height)
|
||||||
m.groupPicker.SetSize(msg.Width, msg.Height)
|
m.groupPicker.SetSize(msg.Width, msg.Height)
|
||||||
|
m.backupPicker.SetSize(msg.Width, msg.Height)
|
||||||
// Set search input width
|
// Set search input width
|
||||||
searchWidth := msg.Width - 20
|
searchWidth := msg.Width - 20
|
||||||
if searchWidth > 60 {
|
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.refresh())
|
||||||
cmds = append(cmds, m.refreshPresets())
|
cmds = append(cmds, m.refreshPresets())
|
||||||
cmds = append(cmds, m.refreshGroups())
|
cmds = append(cmds, m.refreshGroups())
|
||||||
m.loadConfig()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case refreshMsg:
|
case refreshMsg:
|
||||||
@@ -421,6 +451,30 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.groupPicker.SetGroups(msg.groups)
|
m.groupPicker.SetGroups(msg.groups)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case rollbackMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.setError(fmt.Sprintf("Rollback failed: %v", msg.err))
|
||||||
|
} else {
|
||||||
|
cmds = append(cmds, m.refresh())
|
||||||
|
m.setSuccess("Restored from backup")
|
||||||
|
}
|
||||||
|
m.backupPicker.Cancel()
|
||||||
|
m.mode = ViewList
|
||||||
|
|
||||||
|
case refreshBackupsMsg:
|
||||||
|
if msg.err == nil && msg.backups != nil {
|
||||||
|
m.backupPicker.SetBackups(msg.backups)
|
||||||
|
// Fetch content for the first backup
|
||||||
|
if len(msg.backups) > 0 {
|
||||||
|
cmds = append(cmds, m.fetchBackupContent(msg.backups[0].Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case backupContentMsg:
|
||||||
|
if msg.err == nil {
|
||||||
|
m.backupPicker.SetPreviewContent(msg.content)
|
||||||
|
}
|
||||||
|
|
||||||
case clearMsgMsg:
|
case clearMsgMsg:
|
||||||
if time.Since(m.messageTime) >= time.Second*3 {
|
if time.Since(m.messageTime) >= time.Second*3 {
|
||||||
m.message = ""
|
m.message = ""
|
||||||
@@ -461,6 +515,8 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
|
|||||||
return m.handlePresetKey(msg)
|
return m.handlePresetKey(msg)
|
||||||
case ViewGroups:
|
case ViewGroups:
|
||||||
return m.handleGroupKey(msg)
|
return m.handleGroupKey(msg)
|
||||||
|
case ViewBackups:
|
||||||
|
return m.handleBackupKey(msg)
|
||||||
case ViewHelp:
|
case ViewHelp:
|
||||||
return m.handleHelpKey(msg)
|
return m.handleHelpKey(msg)
|
||||||
case ViewSearch:
|
case ViewSearch:
|
||||||
@@ -502,9 +558,14 @@ func (m *Model) handleListKey(msg tea.KeyMsg) tea.Cmd {
|
|||||||
}
|
}
|
||||||
case "p":
|
case "p":
|
||||||
m.mode = ViewPresets
|
m.mode = ViewPresets
|
||||||
|
// Pass available aliases to preset picker
|
||||||
|
m.presetPicker.SetAvailableAliases(m.list.GetAliases())
|
||||||
case "g":
|
case "g":
|
||||||
m.mode = ViewGroups
|
m.mode = ViewGroups
|
||||||
return m.refreshGroups()
|
return m.refreshGroups()
|
||||||
|
case "b":
|
||||||
|
m.mode = ViewBackups
|
||||||
|
return m.refreshBackups()
|
||||||
case "/":
|
case "/":
|
||||||
m.mode = ViewSearch
|
m.mode = ViewSearch
|
||||||
m.searchInput.Focus()
|
m.searchInput.Focus()
|
||||||
@@ -551,6 +612,8 @@ func (m *Model) handlePresetKey(msg tea.KeyMsg) tea.Cmd {
|
|||||||
return m.handlePresetSelectKey(msg)
|
return m.handlePresetSelectKey(msg)
|
||||||
case PresetModeAdd, PresetModeEdit:
|
case PresetModeAdd, PresetModeEdit:
|
||||||
return m.handlePresetFormKey(msg)
|
return m.handlePresetFormKey(msg)
|
||||||
|
case PresetModePickEnable, PresetModePickDisable:
|
||||||
|
return m.handlePresetPickerKey(msg)
|
||||||
case PresetModeConfirmDelete:
|
case PresetModeConfirmDelete:
|
||||||
return m.handlePresetDeleteKey(msg)
|
return m.handlePresetDeleteKey(msg)
|
||||||
}
|
}
|
||||||
@@ -585,27 +648,54 @@ func (m *Model) handlePresetFormKey(msg tea.KeyMsg) tea.Cmd {
|
|||||||
m.presetPicker.CancelForm()
|
m.presetPicker.CancelForm()
|
||||||
return nil
|
return nil
|
||||||
case "enter":
|
case "enter":
|
||||||
if errMsg := m.presetPicker.ValidateForm(); errMsg != "" {
|
// Check which field is focused
|
||||||
m.setError(errMsg)
|
switch m.presetPicker.Focus() {
|
||||||
return m.clearMsg()
|
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)
|
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 {
|
func (m *Model) handlePresetDeleteKey(msg tea.KeyMsg) tea.Cmd {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "y", "Y":
|
case "y", "Y":
|
||||||
@@ -683,6 +773,57 @@ func (m *Model) handleGroupDeleteKey(msg tea.KeyMsg) tea.Cmd {
|
|||||||
return nil
|
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 {
|
func (m *Model) handleHelpKey(msg tea.KeyMsg) tea.Cmd {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "esc", "q", "?":
|
case "esc", "q", "?":
|
||||||
@@ -719,23 +860,6 @@ func (m *Model) toggleSelected() tea.Cmd {
|
|||||||
return m.toggle(item.Entry.Alias, !item.Entry.Enabled)
|
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) {
|
func (m *Model) setError(msg string) {
|
||||||
m.message = msg
|
m.message = msg
|
||||||
m.messageStyle = "error"
|
m.messageStyle = "error"
|
||||||
@@ -774,6 +898,8 @@ func (m *Model) View() string {
|
|||||||
sb.WriteString(m.presetPicker.View())
|
sb.WriteString(m.presetPicker.View())
|
||||||
case ViewGroups:
|
case ViewGroups:
|
||||||
sb.WriteString(m.groupPicker.View())
|
sb.WriteString(m.groupPicker.View())
|
||||||
|
case ViewBackups:
|
||||||
|
sb.WriteString(m.backupPicker.View())
|
||||||
case ViewHelp:
|
case ViewHelp:
|
||||||
sb.WriteString(m.helpView())
|
sb.WriteString(m.helpView())
|
||||||
case ViewSearch:
|
case ViewSearch:
|
||||||
@@ -813,7 +939,7 @@ func (m *Model) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) helpBar() 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("↑↓"),
|
||||||
helpKeyStyle.Render("jk"),
|
helpKeyStyle.Render("jk"),
|
||||||
helpKeyStyle.Render("Space"),
|
helpKeyStyle.Render("Space"),
|
||||||
@@ -822,6 +948,7 @@ func (m *Model) helpBar() string {
|
|||||||
helpKeyStyle.Render("d"),
|
helpKeyStyle.Render("d"),
|
||||||
helpKeyStyle.Render("p"),
|
helpKeyStyle.Render("p"),
|
||||||
helpKeyStyle.Render("g"),
|
helpKeyStyle.Render("g"),
|
||||||
|
helpKeyStyle.Render("b"),
|
||||||
helpKeyStyle.Render("/"),
|
helpKeyStyle.Render("/"),
|
||||||
helpKeyStyle.Render("?"),
|
helpKeyStyle.Render("?"),
|
||||||
helpKeyStyle.Render("q")))
|
helpKeyStyle.Render("q")))
|
||||||
@@ -855,6 +982,7 @@ func (m *Model) helpView() string {
|
|||||||
{"d", "Delete selected entry"},
|
{"d", "Delete selected entry"},
|
||||||
{"p", "Open preset manager"},
|
{"p", "Open preset manager"},
|
||||||
{"g", "Open group manager"},
|
{"g", "Open group manager"},
|
||||||
|
{"b", "Open backup manager"},
|
||||||
{"/", "Search"},
|
{"/", "Search"},
|
||||||
{"r", "Refresh list"},
|
{"r", "Refresh list"},
|
||||||
{"?", "Toggle this help"},
|
{"?", "Toggle this help"},
|
||||||
@@ -867,6 +995,14 @@ func (m *Model) helpView() string {
|
|||||||
helpDescStyle.Render(h.desc)))
|
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("\n")
|
||||||
sb.WriteString(helpDescStyle.Render("Press ? or Esc to close"))
|
sb.WriteString(helpDescStyle.Render("Press ? or Esc to close"))
|
||||||
|
|
||||||
@@ -887,13 +1023,13 @@ func (m *Model) searchView() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the TUI application.
|
// Run starts the TUI application.
|
||||||
func Run(socketPath, configPath string) error {
|
func Run(socketPath string) error {
|
||||||
return RunWithVersion(socketPath, configPath, "dev", "", "")
|
return RunWithVersion(socketPath, "dev", "", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunWithVersion starts the TUI application with version info for update checking.
|
// RunWithVersion starts the TUI application with version info for update checking.
|
||||||
func RunWithVersion(socketPath, configPath, version, githubOwner, githubRepo string) error {
|
func RunWithVersion(socketPath, version, githubOwner, githubRepo string) error {
|
||||||
m := NewModel(socketPath, configPath)
|
m := NewModel(socketPath)
|
||||||
m.version = version
|
m.version = version
|
||||||
m.githubOwner = githubOwner
|
m.githubOwner = githubOwner
|
||||||
m.githubRepo = githubRepo
|
m.githubRepo = githubRepo
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/lukaszraczylo/lolcathost/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FormMode represents the form mode.
|
// FormMode represents the form mode.
|
||||||
@@ -243,6 +244,11 @@ func (f *Form) Validate() string {
|
|||||||
return "Group is required"
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,15 @@ func (l *ListView) SelectedAlias() string {
|
|||||||
return ""
|
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.
|
// SetPending marks an item as pending.
|
||||||
func (l *ListView) SetPending(alias string, pending bool) {
|
func (l *ListView) SetPending(alias string, pending bool) {
|
||||||
for i := range l.items {
|
for i := range l.items {
|
||||||
|
|||||||
+306
-60
@@ -17,6 +17,8 @@ const (
|
|||||||
PresetModeAdd
|
PresetModeAdd
|
||||||
PresetModeEdit
|
PresetModeEdit
|
||||||
PresetModeConfirmDelete
|
PresetModeConfirmDelete
|
||||||
|
PresetModePickEnable // Multi-select picker for enable aliases
|
||||||
|
PresetModePickDisable // Multi-select picker for disable aliases
|
||||||
)
|
)
|
||||||
|
|
||||||
// PresetFormField represents a form field index.
|
// PresetFormField represents a form field index.
|
||||||
@@ -26,19 +28,26 @@ const (
|
|||||||
PresetFieldName PresetFormField = iota
|
PresetFieldName PresetFormField = iota
|
||||||
PresetFieldEnable
|
PresetFieldEnable
|
||||||
PresetFieldDisable
|
PresetFieldDisable
|
||||||
|
PresetFieldSave
|
||||||
PresetFieldCount
|
PresetFieldCount
|
||||||
)
|
)
|
||||||
|
|
||||||
// PresetPicker handles the preset selection and management UI.
|
// PresetPicker handles the preset selection and management UI.
|
||||||
type PresetPicker struct {
|
type PresetPicker struct {
|
||||||
presets []protocol.PresetInfo
|
presets []protocol.PresetInfo
|
||||||
cursor int
|
cursor int
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
mode PresetMode
|
mode PresetMode
|
||||||
fields []textinput.Model
|
fields []textinput.Model
|
||||||
focus PresetFormField
|
focus PresetFormField
|
||||||
editName string // Original name when editing
|
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.
|
// NewPresetPicker creates a new preset picker.
|
||||||
@@ -61,8 +70,10 @@ func NewPresetPicker() *PresetPicker {
|
|||||||
fields[PresetFieldDisable].CharLimit = 500
|
fields[PresetFieldDisable].CharLimit = 500
|
||||||
|
|
||||||
return &PresetPicker{
|
return &PresetPicker{
|
||||||
fields: fields,
|
fields: fields,
|
||||||
mode: PresetModeSelect,
|
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.
|
// SetSize sets the picker dimensions.
|
||||||
func (p *PresetPicker) SetSize(width, height int) {
|
func (p *PresetPicker) SetSize(width, height int) {
|
||||||
p.width = width
|
p.width = width
|
||||||
@@ -141,6 +157,11 @@ func (p *PresetPicker) SetMode(mode PresetMode) {
|
|||||||
p.mode = mode
|
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.
|
// InitAdd initializes the form for adding a new preset.
|
||||||
func (p *PresetPicker) InitAdd() {
|
func (p *PresetPicker) InitAdd() {
|
||||||
p.mode = PresetModeAdd
|
p.mode = PresetModeAdd
|
||||||
@@ -150,6 +171,10 @@ func (p *PresetPicker) InitAdd() {
|
|||||||
}
|
}
|
||||||
p.focus = PresetFieldName
|
p.focus = PresetFieldName
|
||||||
p.fields[PresetFieldName].Focus()
|
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.
|
// InitEdit initializes the form for editing an existing preset.
|
||||||
@@ -163,8 +188,17 @@ func (p *PresetPicker) InitEdit() {
|
|||||||
p.editName = preset.Name
|
p.editName = preset.Name
|
||||||
|
|
||||||
p.fields[PresetFieldName].SetValue(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.focus = PresetFieldName
|
||||||
p.fields[PresetFieldName].Focus()
|
p.fields[PresetFieldName].Focus()
|
||||||
@@ -199,47 +233,35 @@ func (p *PresetPicker) Update(msg tea.KeyMsg) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the focused field
|
// Only update text field if focused on name
|
||||||
var cmd tea.Cmd
|
if p.focus == PresetFieldName {
|
||||||
p.fields[p.focus], cmd = p.fields[p.focus].Update(msg)
|
var cmd tea.Cmd
|
||||||
return cmd
|
p.fields[PresetFieldName], cmd = p.fields[PresetFieldName].Update(msg)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PresetPicker) nextField() {
|
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.focus = (p.focus + 1) % PresetFieldCount
|
||||||
p.fields[p.focus].Focus()
|
if p.focus == PresetFieldName {
|
||||||
|
p.fields[PresetFieldName].Focus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PresetPicker) prevField() {
|
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.focus = (p.focus - 1 + PresetFieldCount) % PresetFieldCount
|
||||||
p.fields[p.focus].Focus()
|
if p.focus == PresetFieldName {
|
||||||
}
|
p.fields[PresetFieldName].Focus()
|
||||||
|
|
||||||
// FormValues returns the form values (name, enable list, disable list).
|
|
||||||
func (p *PresetPicker) FormValues() (name string, enable, disable []string) {
|
|
||||||
name = strings.TrimSpace(p.fields[PresetFieldName].Value())
|
|
||||||
|
|
||||||
enableStr := strings.TrimSpace(p.fields[PresetFieldEnable].Value())
|
|
||||||
if enableStr != "" {
|
|
||||||
for _, s := range strings.Split(enableStr, ",") {
|
|
||||||
if trimmed := strings.TrimSpace(s); trimmed != "" {
|
|
||||||
enable = append(enable, trimmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
disableStr := strings.TrimSpace(p.fields[PresetFieldDisable].Value())
|
|
||||||
if disableStr != "" {
|
|
||||||
for _, s := range strings.Split(disableStr, ",") {
|
|
||||||
if trimmed := strings.TrimSpace(s); trimmed != "" {
|
|
||||||
disable = append(disable, trimmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return name, enable, disable
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditName returns the original name when editing.
|
// EditName returns the original name when editing.
|
||||||
@@ -254,23 +276,98 @@ func (p *PresetPicker) IsEdit() bool {
|
|||||||
|
|
||||||
// ValidateForm validates the form values.
|
// ValidateForm validates the form values.
|
||||||
func (p *PresetPicker) ValidateForm() string {
|
func (p *PresetPicker) ValidateForm() string {
|
||||||
name, enable, disable := p.FormValues()
|
name := strings.TrimSpace(p.fields[PresetFieldName].Value())
|
||||||
|
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return "Preset name is required"
|
return "Preset name is required"
|
||||||
}
|
}
|
||||||
if len(enable) == 0 && len(disable) == 0 {
|
if len(p.selectedEnable) == 0 && len(p.selectedDisable) == 0 {
|
||||||
return "At least one alias to enable or disable is required"
|
return "Select at least one alias to enable or disable"
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
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.
|
// View renders the preset picker.
|
||||||
func (p *PresetPicker) View() string {
|
func (p *PresetPicker) View() string {
|
||||||
switch p.mode {
|
switch p.mode {
|
||||||
case PresetModeAdd, PresetModeEdit:
|
case PresetModeAdd, PresetModeEdit:
|
||||||
return p.formView()
|
return p.formView()
|
||||||
|
case PresetModePickEnable, PresetModePickDisable:
|
||||||
|
return p.pickerView()
|
||||||
case PresetModeConfirmDelete:
|
case PresetModeConfirmDelete:
|
||||||
return p.deleteView()
|
return p.deleteView()
|
||||||
default:
|
default:
|
||||||
@@ -282,6 +379,8 @@ func (p *PresetPicker) selectView() string {
|
|||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
sb.WriteString(titleStyle.Render("Presets"))
|
sb.WriteString(titleStyle.Render("Presets"))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(helpDescStyle.Render("Quickly enable/disable multiple hosts at once"))
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
if len(p.presets) == 0 {
|
if len(p.presets) == 0 {
|
||||||
@@ -292,14 +391,26 @@ func (p *PresetPicker) selectView() string {
|
|||||||
for i, preset := range p.presets {
|
for i, preset := range p.presets {
|
||||||
if i == p.cursor {
|
if i == p.cursor {
|
||||||
sb.WriteString(presetSelectedStyle.Render("▸ " + preset.Name))
|
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 {
|
} else {
|
||||||
sb.WriteString(presetItemStyle.Render(" " + preset.Name))
|
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"))
|
sb.WriteString(helpDescStyle.Render("↑↓ navigate • Enter apply • n new • e edit • d delete • Esc cancel"))
|
||||||
|
|
||||||
return dialogStyle.Render(sb.String())
|
return dialogStyle.Render(sb.String())
|
||||||
@@ -314,25 +425,160 @@ func (p *PresetPicker) formView() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sb.WriteString(titleStyle.Render(title))
|
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")
|
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 {
|
// Enable selection (button-style)
|
||||||
sb.WriteString(inputLabelStyle.Render(label))
|
enableLabel := "Enable hosts:"
|
||||||
sb.WriteString("\n")
|
if p.focus == PresetFieldEnable {
|
||||||
|
enableLabel = "▸ Enable hosts: (press Enter to select)"
|
||||||
style := inputStyle
|
}
|
||||||
if PresetFormField(i) == p.focus {
|
sb.WriteString(inputLabelStyle.Render(enableLabel))
|
||||||
style = inputFocusStyle
|
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()))
|
// Disable selection (button-style)
|
||||||
sb.WriteString("\n\n")
|
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("\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())
|
return dialogStyle.Render(sb.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
version: 1
|
version: 1
|
||||||
|
|
||||||
|
prefix: "v"
|
||||||
|
|
||||||
force:
|
force:
|
||||||
major: 0
|
major: 0
|
||||||
minor: 1
|
minor: 1
|
||||||
|
|||||||
Reference in New Issue
Block a user