refactor: move V1 code into v1/ subdirectory

- git mv cmd/ internal/ pkg/ test/ go.mod go.sum Makefile build.sh docs/ v1/
- Create v1/README.md with V1 documentation
- Update root README for V1 + V2 structure
- V1 still builds (cd v1 && go build ./cmd/hostkeeper) and 105 tests pass
- Root is now clean for V2 development
This commit is contained in:
swanadiva
2026-07-07 11:56:27 +07:00
parent 8ebdebedc8
commit 847989df75
68 changed files with 58 additions and 116 deletions
+164
View File
@@ -0,0 +1,164 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
)
// Config manages application configuration and paths
type Config struct {
appName string
configDir string
dataDir string
appConfig *models.AppConfig
}
// New creates a new Config instance
func New() (*Config, error) {
appName := "hostkeeper"
configDir, err := getConfigDir(appName)
if err != nil {
return nil, fmt.Errorf("failed to get config directory: %w", err)
}
dataDir := filepath.Join(configDir, "data")
c := &Config{
appName: appName,
configDir: configDir,
dataDir: dataDir,
}
// Create directories
if err := os.MkdirAll(configDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create config directory: %w", err)
}
if err := os.MkdirAll(dataDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create data directory: %w", err)
}
// Load or create config
if err := c.load(); err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
return c, nil
}
// getConfigDir returns the OS-appropriate config directory for the app
func getConfigDir(appName string) (string, error) {
switch runtime.GOOS {
case "darwin":
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, "Library", "Application Support", appName), nil
case "linux":
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, appName), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", appName), nil
case "windows":
appData := os.Getenv("APPDATA")
if appData != "" {
return filepath.Join(appData, appName), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, "AppData", "Roaming", appName), nil
default:
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, "."+appName), nil
}
}
// load reads the config file, or creates a default one if it doesn't exist
func (c *Config) load() error {
configPath := c.GetConfigFilePath()
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
// Create default config
c.appConfig = models.DefaultConfig()
return c.Save()
}
return err
}
c.appConfig = models.DefaultConfig()
if err := json.Unmarshal(data, c.appConfig); err != nil {
return fmt.Errorf("failed to parse config: %w", err)
}
return nil
}
// Save writes the current configuration to disk
func (c *Config) Save() error {
configPath := c.GetConfigFilePath()
data, err := json.MarshalIndent(c.appConfig, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
return os.WriteFile(configPath, data, 0600)
}
// GetAppConfig returns the application configuration
func (c *Config) GetAppConfig() *models.AppConfig {
return c.appConfig
}
// UpdateAppConfig updates the application configuration
func (c *Config) UpdateAppConfig(cfg *models.AppConfig) error {
c.appConfig = cfg
return c.Save()
}
// GetConfigDir returns the configuration directory path
func (c *Config) GetConfigDir() string {
return c.configDir
}
// GetDataDir returns the data directory path
func (c *Config) GetDataDir() string {
return c.dataDir
}
// GetConfigFilePath returns the full path to the config JSON file
func (c *Config) GetConfigFilePath() string {
return filepath.Join(c.configDir, "config.json")
}
// GetHostsFilePath returns the full path to the hosts JSON file
func (c *Config) GetHostsFilePath() string {
return filepath.Join(c.dataDir, "hosts.json")
}
// GetKeysFilePath returns the full path to the keys JSON file
func (c *Config) GetKeysFilePath() string {
return filepath.Join(c.dataDir, "keys.json")
}
// GetSnippetsFilePath returns the full path to the snippets JSON file
func (c *Config) GetSnippetsFilePath() string {
return filepath.Join(c.dataDir, "snippets.json")
}
+122
View File
@@ -0,0 +1,122 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"golang.org/x/crypto/pbkdf2"
)
const (
KeyLength = 32 // AES-256
SaltLength = 16
Iterations = 100000
)
var (
ErrInvalidPassword = errors.New("invalid password")
ErrDecryptionFailed = errors.New("decryption failed — wrong password or corrupted data")
)
// DeriveKey derives an AES-256 key from a password using PBKDF2
func DeriveKey(password string, salt []byte) []byte {
return pbkdf2.Key([]byte(password), salt, Iterations, KeyLength, sha256.New)
}
// Encrypt encrypts plaintext using AES-256-GCM with a password
func Encrypt(plaintext []byte, password string) (string, error) {
salt := make([]byte, SaltLength)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return "", err
}
key := DeriveKey(password, salt)
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, aesGCM.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := aesGCM.Seal(nil, nonce, plaintext, nil)
// Format: base64(salt + nonce + ciphertext)
result := make([]byte, 0, len(salt)+len(nonce)+len(ciphertext))
result = append(result, salt...)
result = append(result, nonce...)
result = append(result, ciphertext...)
return base64.StdEncoding.EncodeToString(result), nil
}
// Decrypt decrypts ciphertext using AES-256-GCM with a password
func Decrypt(encoded string, password string) ([]byte, error) {
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, ErrDecryptionFailed
}
if len(data) < SaltLength+12 { // 12 = minimum nonce size for GCM
return nil, ErrDecryptionFailed
}
salt := data[:SaltLength]
data = data[SaltLength:]
key := DeriveKey(password, salt)
block, err := aes.NewCipher(key)
if err != nil {
return nil, ErrDecryptionFailed
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return nil, ErrDecryptionFailed
}
nonceSize := aesGCM.NonceSize()
if len(data) < nonceSize {
return nil, ErrDecryptionFailed
}
nonce := data[:nonceSize]
ciphertext := data[nonceSize:]
plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, ErrInvalidPassword
}
return plaintext, nil
}
// IsEncrypted checks if a string looks like base64-encoded encrypted data
func IsEncrypted(data string) bool {
decoded, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return false
}
// Minimum: 16 (salt) + 12 (nonce) + 16 (min ciphertext) = 44 bytes
return len(decoded) >= 44
}
// HashPassword creates a SHA-256 hash of a password for verification
func HashPassword(password string) string {
h := sha256.Sum256([]byte(password))
return base64.StdEncoding.EncodeToString(h[:])
}
+192
View File
@@ -0,0 +1,192 @@
package knownhosts
import (
"encoding/base64"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"sync"
"time"
cryptossh "golang.org/x/crypto/ssh"
)
// KnownHosts manages the known_hosts file
type KnownHosts struct {
path string
hosts map[string]*HostKey // key = "hostname:port"
mu sync.RWMutex
}
// HostKey represents a stored host key
type HostKey struct {
Hostname string `json:"hostname"`
Port int `json:"port"`
KeyType string `json:"key_type"`
KeyData string `json:"key_data"` // Base64-encoded raw key
AddedAt time.Time `json:"added_at"`
}
// New creates a new KnownHosts manager
func New(dataDir string) (*KnownHosts, error) {
path := filepath.Join(dataDir, "known_hosts")
kh := &KnownHosts{
path: path,
hosts: make(map[string]*HostKey),
}
if err := kh.load(); err != nil {
// File doesn't exist yet, that's OK
if !os.IsNotExist(err) {
return nil, err
}
}
return kh, nil
}
// Load reads the known_hosts file
func (kh *KnownHosts) load() error {
kh.mu.Lock()
defer kh.mu.Unlock()
data, err := os.ReadFile(kh.path)
if err != nil {
return err
}
var hosts []*HostKey
if err := json.Unmarshal(data, &hosts); err != nil {
return err
}
for _, h := range hosts {
key := fmt.Sprintf("%s:%d", h.Hostname, h.Port)
kh.hosts[key] = h
}
return nil
}
// Save writes the known_hosts file
func (kh *KnownHosts) Save() error {
kh.mu.Lock()
defer kh.mu.Unlock()
return kh.saveInternal()
}
// saveInternal writes the known_hosts file without locking (caller must hold lock)
func (kh *KnownHosts) saveInternal() error {
var hosts []*HostKey
for _, h := range kh.hosts {
hosts = append(hosts, h)
}
data, err := json.MarshalIndent(hosts, "", " ")
if err != nil {
return err
}
return os.WriteFile(kh.path, data, 0600)
}
// Verify checks if a host key is known and matches
func (kh *KnownHosts) Verify(hostname string, port int, remoteKey cryptossh.PublicKey) (bool, *HostKey) {
kh.mu.RLock()
defer kh.mu.RUnlock()
key := fmt.Sprintf("%s:%d", hostname, port)
stored, ok := kh.hosts[key]
if !ok {
return false, nil // Unknown host
}
// Compare key type and data
remoteType := remoteKey.Type()
remoteData := base64.StdEncoding.EncodeToString(remoteKey.Marshal())
if stored.KeyType != remoteType || stored.KeyData != remoteData {
return false, stored // Key mismatch — potential MITM
}
return true, stored // Key matches
}
// Add stores a new host key
func (kh *KnownHosts) Add(hostname string, port int, remoteKey cryptossh.PublicKey) error {
kh.mu.Lock()
defer kh.mu.Unlock()
key := fmt.Sprintf("%s:%d", hostname, port)
kh.hosts[key] = &HostKey{
Hostname: hostname,
Port: port,
KeyType: remoteKey.Type(),
KeyData: base64.StdEncoding.EncodeToString(remoteKey.Marshal()),
AddedAt: time.Now(),
}
return kh.saveInternal()
}
// Remove removes a host key
func (kh *KnownHosts) Remove(hostname string, port int) error {
kh.mu.Lock()
defer kh.mu.Unlock()
key := fmt.Sprintf("%s:%d", hostname, port)
delete(kh.hosts, key)
return kh.saveInternal()
}
// Get returns the stored host key for a given host
func (kh *KnownHosts) Get(hostname string, port int) *HostKey {
kh.mu.RLock()
defer kh.mu.RUnlock()
key := fmt.Sprintf("%s:%d", hostname, port)
return kh.hosts[key]
}
// HostKeyCallback returns a crypto/ssh HostKeyCallback for use in SSH config
func (kh *KnownHosts) HostKeyCallback(autoAdd bool) cryptossh.HostKeyCallback {
return func(hostname string, remote net.Addr, remoteKey cryptossh.PublicKey) error {
// Extract port from address
_, portStr, err := net.SplitHostPort(remote.String())
if err != nil {
return fmt.Errorf("failed to parse address: %w", err)
}
port := 22
fmt.Sscanf(portStr, "%d", &port)
// Check if host is known
matches, stored := kh.Verify(hostname, port, remoteKey)
if matches {
return nil // Key matches, connection OK
}
if stored == nil {
// Unknown host — auto-add if enabled
if autoAdd {
if err := kh.Add(hostname, port, remoteKey); err != nil {
return fmt.Errorf("failed to add host key: %w", err)
}
return nil
}
return fmt.Errorf("host key not found for %s:%d — run 'hostkeeper trust %s' to add", hostname, port, hostname)
}
// Key mismatch — potential MITM attack
return fmt.Errorf("WARNING: host key mismatch for %s:%d!\n"+
"Stored key type: %s\n"+
"Remote key type: %s\n"+
"This could indicate a MITM attack.\n"+
"Run 'hostkeeper trust --remove %s' and try again.",
hostname, port, stored.KeyType, remoteKey.Type(), hostname)
}
}
+139
View File
@@ -0,0 +1,139 @@
package ssh
import (
"fmt"
"os"
"path/filepath"
"strings"
cryptossh "golang.org/x/crypto/ssh"
)
// getAuthMethods returns SSH authentication methods based on host config
func (c *Client) getAuthMethods() ([]cryptossh.AuthMethod, error) {
var authMethods []cryptossh.AuthMethod
switch c.host.Auth.Type {
case "password":
if c.host.Auth.Password == "" {
return nil, fmt.Errorf("password auth requires password")
}
authMethods = append(authMethods, cryptossh.Password(c.host.Auth.Password))
case "key":
signer, err := c.getKeySigner()
if err != nil {
return nil, fmt.Errorf("failed to setup key authentication: %w", err)
}
authMethods = append(authMethods, cryptossh.PublicKeys(signer))
case "both":
// Try password first
if c.host.Auth.Password != "" {
authMethods = append(authMethods, cryptossh.Password(c.host.Auth.Password))
}
// Then try key
signer, err := c.getKeySigner()
if err == nil {
authMethods = append(authMethods, cryptossh.PublicKeys(signer))
}
default:
return nil, fmt.Errorf("unsupported authentication type: %s", c.host.Auth.Type)
}
if len(authMethods) == 0 {
return nil, fmt.Errorf("no authentication methods configured")
}
return authMethods, nil
}
// getKeySigner returns an SSH signer for key-based authentication
//
// Phase 1: Loads key from KeyID as a file path, or from common SSH key locations
// Phase 2: Will integrate with the storage layer for encrypted key storage
func (c *Client) getKeySigner() (cryptossh.Signer, error) {
var keyPath string
if c.host.Auth.KeyID != "" {
// If KeyID looks like a path, use it directly
if strings.HasPrefix(c.host.Auth.KeyID, "/") || strings.HasPrefix(c.host.Auth.KeyID, "~") {
keyPath = expandPath(c.host.Auth.KeyID)
} else {
// Try ~/.ssh/<keyID>
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
keyPath = filepath.Join(home, ".ssh", c.host.Auth.KeyID)
}
} else {
// Fall back to default key locations
keyPath = getDefaultKeyPath()
}
// Read the key file
keyData, err := os.ReadFile(keyPath) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("failed to read key file %s: %w", keyPath, err)
}
// Parse the key (support passphrase-protected keys)
var signer cryptossh.Signer
if c.host.Auth.Password != "" {
signer, err = cryptossh.ParsePrivateKeyWithPassphrase(keyData, []byte(c.host.Auth.Password))
} else if c.passphraseCallback != nil {
// Try without passphrase first
signer, err = cryptossh.ParsePrivateKey(keyData)
if err != nil && strings.Contains(err.Error(), "encrypted") {
// Key is encrypted, prompt for passphrase
passphrase := c.passphraseCallback()
if passphrase != "" {
signer, err = cryptossh.ParsePrivateKeyWithPassphrase(keyData, []byte(passphrase))
}
}
} else {
signer, err = cryptossh.ParsePrivateKey(keyData)
}
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
return signer, nil
}
// getDefaultKeyPath returns the first existing default SSH key path
func getDefaultKeyPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
candidates := []string{
"id_ed25519",
"id_rsa",
"id_ecdsa",
"id_dsa",
}
for _, name := range candidates {
path := filepath.Join(home, ".ssh", name)
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
// expandPath expands ~ to the home directory
func expandPath(path string) string {
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err == nil {
return filepath.Join(home, path[2:])
}
}
return path
}
+219
View File
@@ -0,0 +1,219 @@
package ssh
import (
"context"
"fmt"
"net"
"os"
"os/signal"
"syscall"
"time"
"golang.org/x/term"
"git.tukangketik.id/swanadiva/hostkeeper/internal/errors"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
cryptossh "golang.org/x/crypto/ssh"
)
// Client represents an SSH client
type Client struct {
host *models.Host
timeout time.Duration
client *cryptossh.Client
config *cryptossh.ClientConfig
hostKeyCallback cryptossh.HostKeyCallback
passphraseCallback func() string // called to get passphrase for encrypted keys
}
// NewClient creates a new SSH client
func NewClient(host *models.Host, timeout time.Duration) *Client {
return &Client{
host: host,
timeout: timeout,
}
}
// SetHostKeyCallback sets the host key verification callback
func (c *Client) SetHostKeyCallback(cb cryptossh.HostKeyCallback) {
c.hostKeyCallback = cb
}
// SetPassphraseCallback sets the callback for getting key passphrases
func (c *Client) SetPassphraseCallback(cb func() string) {
c.passphraseCallback = cb
}
// Connect establishes an SSH connection
func (c *Client) Connect(ctx context.Context) error {
// Create SSH configuration
if err := c.setupConfig(); err != nil {
return fmt.Errorf("failed to setup SSH config: %w", err)
}
// Create connection context with timeout
connCtx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
// Establish TCP connection
address := fmt.Sprintf("%s:%d", c.host.Hostname, c.host.Port)
conn, err := c.dialTCP(connCtx, address)
if err != nil {
return errors.HandleSSHError(err)
}
// Establish SSH connection over TCP
sshConn, chans, reqs, err := cryptossh.NewClientConn(conn, address, c.config)
if err != nil {
conn.Close()
return errors.HandleSSHError(err)
}
c.client = cryptossh.NewClient(sshConn, chans, reqs)
return nil
}
// dialTCP establishes a TCP connection
func (c *Client) dialTCP(ctx context.Context, address string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, "tcp", address)
}
// setupConfig creates SSH client configuration
func (c *Client) setupConfig() error {
hostKeyCallback := cryptossh.InsecureIgnoreHostKey()
if c.hostKeyCallback != nil {
hostKeyCallback = c.hostKeyCallback
}
config := &cryptossh.ClientConfig{
User: c.host.Username,
HostKeyCallback: hostKeyCallback,
Timeout: c.timeout,
}
// Configure authentication methods
authMethods, err := c.getAuthMethods()
if err != nil {
return fmt.Errorf("failed to setup authentication: %w", err)
}
config.Auth = authMethods
c.config = config
return nil
}
// Execute runs a command on the remote server
func (c *Client) Execute(_ context.Context, cmd string) (string, error) {
if c.client == nil {
return "", fmt.Errorf("not connected to server")
}
session, err := c.client.NewSession()
if err != nil {
return "", fmt.Errorf("failed to create session: %w", err)
}
defer session.Close()
output, err := session.CombinedOutput(cmd)
if err != nil {
return string(output), fmt.Errorf("command execution failed: %w", err)
}
return string(output), nil
}
// Shell opens an interactive shell session
func (c *Client) Shell() error {
if c.client == nil {
return fmt.Errorf("not connected to server")
}
session, err := c.client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session: %w", err)
}
defer session.Close()
// Get current terminal state
fd := int(os.Stdin.Fd())
oldState, err := term.MakeRaw(fd)
if err != nil {
return fmt.Errorf("failed to set raw terminal: %w", err)
}
defer term.Restore(fd, oldState)
// Set up terminal modes
modes := cryptossh.TerminalModes{
cryptossh.ECHO: 1,
cryptossh.TTY_OP_ISPEED: 14400,
cryptossh.TTY_OP_OSPEED: 14400,
}
// Get terminal size
width, height, err := term.GetSize(fd)
if err != nil {
width = 80
height = 24
}
// Request PTY
if err := session.RequestPty("xterm-256color", height, width, modes); err != nil {
return fmt.Errorf("failed to request PTY: %w", err)
}
// Handle window changes
sigwinch := make(chan os.Signal, 1)
signal.Notify(sigwinch, os.Signal(syscall.SIGWINCH))
go func() {
for range sigwinch {
w, h, err := term.GetSize(fd)
if err != nil {
continue
}
session.WindowChange(h, w)
}
}()
defer signal.Stop(sigwinch)
// Link I/O
session.Stdin = os.Stdin
session.Stdout = os.Stdout
session.Stderr = os.Stderr
// Start shell
if err := session.Shell(); err != nil {
return fmt.Errorf("failed to start shell: %w", err)
}
// Wait for shell to exit
if err := session.Wait(); err != nil {
if exitErr, ok := err.(*cryptossh.ExitError); ok {
if exitErr.ExitStatus() != 0 {
return fmt.Errorf("shell exited with status %d", exitErr.ExitStatus())
}
return nil
}
return fmt.Errorf("shell session error: %w", err)
}
return nil
}
// Close closes the SSH connection
func (c *Client) Close() error {
if c.client != nil {
return c.client.Close()
}
return nil
}
// GetClient returns the underlying SSH client
func (c *Client) GetClient() *cryptossh.Client {
return c.client
}
// IsConnected returns true if the client has an active connection
func (c *Client) IsConnected() bool {
return c.client != nil
}
+576
View File
@@ -0,0 +1,576 @@
package storage
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/google/uuid"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/crypto"
)
// JSONStorage implements Storage interface using JSON files
type JSONStorage struct {
dataDir string
password string // master password for encryption (empty = no encryption)
mu sync.RWMutex
}
// NewJSONStorage creates a new JSON storage instance
func NewJSONStorage(dataDir string) (*JSONStorage, error) {
if err := os.MkdirAll(dataDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create data directory: %w", err)
}
s := &JSONStorage{dataDir: dataDir}
if err := s.ensureDataFiles(); err != nil {
return nil, fmt.Errorf("failed to initialize data files: %w", err)
}
return s, nil
}
// SetPassword sets the master password for encryption/decryption
func (s *JSONStorage) SetPassword(password string) {
s.password = password
}
// GetPassword returns the current master password
func (s *JSONStorage) GetPassword() string {
return s.password
}
// IsEncrypted returns whether encryption is enabled
func (s *JSONStorage) IsEncrypted() bool {
return s.password != ""
}
// IsDataEncrypted checks if the data files are actually encrypted
func (s *JSONStorage) IsDataEncrypted() bool {
path := filepath.Join(s.dataDir, "hosts.json")
data, err := os.ReadFile(path)
if err != nil {
return false
}
return crypto.IsEncrypted(string(data))
}
func (s *JSONStorage) ensureDataFiles() error {
files := map[string]string{
"hosts.json": "hosts",
"keys.json": "key_pairs",
"snippets.json": "snippets",
}
for file, key := range files {
path := filepath.Join(s.dataDir, file)
if _, err := os.Stat(path); os.IsNotExist(err) {
data := map[string]interface{}{key: []interface{}{}}
dataBytes, _ := json.MarshalIndent(data, "", " ")
if err := os.WriteFile(path, dataBytes, 0600); err != nil {
return err
}
}
}
return nil
}
// ============ Host Operations ============
func (s *JSONStorage) getHostsPath() string {
return filepath.Join(s.dataDir, "hosts.json")
}
func (s *JSONStorage) ListHosts(ctx context.Context) ([]*models.Host, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.listHostsInternal()
}
// listHostsInternal reads hosts WITHOUT locking (caller must hold lock)
func (s *JSONStorage) listHostsInternal() ([]*models.Host, error) {
var data struct {
Hosts []*models.Host `json:"hosts"`
}
if err := s.readJSON(s.getHostsPath(), &data); err != nil {
return nil, fmt.Errorf("failed to read hosts: %w", err)
}
return data.Hosts, nil
}
func (s *JSONStorage) GetHost(ctx context.Context, id string) (*models.Host, error) {
s.mu.RLock()
defer s.mu.RUnlock()
hosts, err := s.listHostsInternal()
if err != nil {
return nil, err
}
for _, host := range hosts {
if host.ID == id {
return host, nil
}
}
return nil, fmt.Errorf("host not found: %s", id)
}
func (s *JSONStorage) SaveHost(ctx context.Context, host *models.Host) error {
s.mu.Lock()
defer s.mu.Unlock()
if host.ID == "" {
host.ID = uuid.New().String()
}
if host.CreatedAt.IsZero() {
host.CreatedAt = time.Now()
}
host.UpdatedAt = time.Now()
hosts, err := s.listHostsInternal()
if err != nil {
return err
}
found := false
for i, h := range hosts {
if h.ID == host.ID {
hosts[i] = host
found = true
break
}
}
if !found {
hosts = append(hosts, host)
}
return s.replaceHosts(hosts)
}
func (s *JSONStorage) DeleteHost(ctx context.Context, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
hosts, err := s.listHostsInternal()
if err != nil {
return err
}
for i, host := range hosts {
if host.ID == id {
hosts = append(hosts[:i], hosts[i+1:]...)
return s.replaceHosts(hosts)
}
}
return fmt.Errorf("host not found: %s", id)
}
func (s *JSONStorage) replaceHosts(hosts []*models.Host) error {
var data struct {
Hosts []*models.Host `json:"hosts"`
}
data.Hosts = hosts
bytes, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal hosts: %w", err)
}
// Encrypt if password is set
if s.password != "" {
encrypted, err := crypto.Encrypt(bytes, s.password)
if err != nil {
return fmt.Errorf("failed to encrypt hosts: %w", err)
}
bytes = []byte(encrypted)
}
return os.WriteFile(s.getHostsPath(), bytes, 0600)
}
// ============ KeyPair Operations ============
func (s *JSONStorage) getKeysPath() string {
return filepath.Join(s.dataDir, "keys.json")
}
func (s *JSONStorage) ListKeyPairs(ctx context.Context) ([]*models.KeyPair, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.listKeyPairsInternal()
}
// listKeyPairsInternal reads key pairs WITHOUT locking (caller must hold lock)
func (s *JSONStorage) listKeyPairsInternal() ([]*models.KeyPair, error) {
var data struct {
KeyPairs []*models.KeyPair `json:"key_pairs"`
}
if err := s.readJSON(s.getKeysPath(), &data); err != nil {
return nil, fmt.Errorf("failed to read key pairs: %w", err)
}
return data.KeyPairs, nil
}
func (s *JSONStorage) GetKeyPair(ctx context.Context, id string) (*models.KeyPair, error) {
s.mu.RLock()
defer s.mu.RUnlock()
keys, err := s.listKeyPairsInternal()
if err != nil {
return nil, err
}
for _, key := range keys {
if key.ID == id {
return key, nil
}
}
return nil, fmt.Errorf("key pair not found: %s", id)
}
func (s *JSONStorage) SaveKeyPair(ctx context.Context, keyPair *models.KeyPair) error {
s.mu.Lock()
defer s.mu.Unlock()
if keyPair.ID == "" {
keyPair.ID = uuid.New().String()
}
if keyPair.CreatedAt.IsZero() {
keyPair.CreatedAt = time.Now()
}
keyPair.UpdatedAt = time.Now()
keys, err := s.listKeyPairsInternal()
if err != nil {
return err
}
found := false
for i, k := range keys {
if k.ID == keyPair.ID {
keys[i] = keyPair
found = true
break
}
}
if !found {
keys = append(keys, keyPair)
}
return s.replaceKeyPairs(keys)
}
func (s *JSONStorage) DeleteKeyPair(ctx context.Context, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
keys, err := s.listKeyPairsInternal()
if err != nil {
return err
}
for i, key := range keys {
if key.ID == id {
keys = append(keys[:i], keys[i+1:]...)
return s.replaceKeyPairs(keys)
}
}
return fmt.Errorf("key pair not found: %s", id)
}
func (s *JSONStorage) replaceKeyPairs(keys []*models.KeyPair) error {
var data struct {
KeyPairs []*models.KeyPair `json:"key_pairs"`
}
data.KeyPairs = keys
bytes, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal key pairs: %w", err)
}
// Encrypt if password is set
if s.password != "" {
encrypted, err := crypto.Encrypt(bytes, s.password)
if err != nil {
return fmt.Errorf("failed to encrypt key pairs: %w", err)
}
bytes = []byte(encrypted)
}
return os.WriteFile(s.getKeysPath(), bytes, 0600)
}
// ============ Snippet Operations ============
func (s *JSONStorage) getSnippetsPath() string {
return filepath.Join(s.dataDir, "snippets.json")
}
func (s *JSONStorage) ListSnippets(ctx context.Context) ([]*models.Snippet, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.listSnippetsInternal()
}
// listSnippetsInternal reads snippets WITHOUT locking (caller must hold lock)
func (s *JSONStorage) listSnippetsInternal() ([]*models.Snippet, error) {
var data struct {
Snippets []*models.Snippet `json:"snippets"`
}
if err := s.readJSON(s.getSnippetsPath(), &data); err != nil {
return nil, fmt.Errorf("failed to read snippets: %w", err)
}
return data.Snippets, nil
}
func (s *JSONStorage) GetSnippet(ctx context.Context, id string) (*models.Snippet, error) {
s.mu.RLock()
defer s.mu.RUnlock()
snippets, err := s.listSnippetsInternal()
if err != nil {
return nil, err
}
for _, snippet := range snippets {
if snippet.ID == id {
return snippet, nil
}
}
return nil, fmt.Errorf("snippet not found: %s", id)
}
func (s *JSONStorage) SaveSnippet(ctx context.Context, snippet *models.Snippet) error {
s.mu.Lock()
defer s.mu.Unlock()
if snippet.ID == "" {
snippet.ID = uuid.New().String()
}
if snippet.CreatedAt.IsZero() {
snippet.CreatedAt = time.Now()
}
snippet.UpdatedAt = time.Now()
snippets, err := s.listSnippetsInternal()
if err != nil {
return err
}
found := false
for i, sn := range snippets {
if sn.ID == snippet.ID {
snippets[i] = snippet
found = true
break
}
}
if !found {
snippets = append(snippets, snippet)
}
return s.replaceSnippets(snippets)
}
func (s *JSONStorage) DeleteSnippet(ctx context.Context, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
snippets, err := s.listSnippetsInternal()
if err != nil {
return err
}
for i, snippet := range snippets {
if snippet.ID == id {
snippets = append(snippets[:i], snippets[i+1:]...)
return s.replaceSnippets(snippets)
}
}
return fmt.Errorf("snippet not found: %s", id)
}
func (s *JSONStorage) replaceSnippets(snippets []*models.Snippet) error {
var data struct {
Snippets []*models.Snippet `json:"snippets"`
}
data.Snippets = snippets
bytes, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal snippets: %w", err)
}
// Encrypt if password is set
if s.password != "" {
encrypted, err := crypto.Encrypt(bytes, s.password)
if err != nil {
return fmt.Errorf("failed to encrypt snippets: %w", err)
}
bytes = []byte(encrypted)
}
return os.WriteFile(s.getSnippetsPath(), bytes, 0600)
}
// ============ Export/Import ============
func (s *JSONStorage) ExportData(ctx context.Context) (*ExportData, error) {
hosts, err := s.ListHosts(ctx)
if err != nil {
return nil, err
}
keys, err := s.ListKeyPairs(ctx)
if err != nil {
return nil, err
}
snippets, err := s.ListSnippets(ctx)
if err != nil {
return nil, err
}
return &ExportData{
Hosts: hosts,
KeyPairs: keys,
Snippets: snippets,
}, nil
}
func (s *JSONStorage) ImportData(ctx context.Context, data *ExportData, strategy MergeStrategy) error {
switch strategy {
case MergeStrategyReplace:
if err := s.replaceHosts(data.Hosts); err != nil {
return err
}
if err := s.replaceKeyPairs(data.KeyPairs); err != nil {
return err
}
if err := s.replaceSnippets(data.Snippets); err != nil {
return err
}
case MergeStrategyMerge:
if err := s.mergeHosts(ctx, data.Hosts); err != nil {
return err
}
if err := s.mergeKeyPairs(ctx, data.KeyPairs); err != nil {
return err
}
if err := s.mergeSnippets(ctx, data.Snippets); err != nil {
return err
}
default:
return fmt.Errorf("unknown merge strategy: %s", strategy)
}
return nil
}
func (s *JSONStorage) mergeHosts(ctx context.Context, newHosts []*models.Host) error {
existingHosts, err := s.ListHosts(ctx)
if err != nil {
return err
}
existingIDs := make(map[string]bool)
for _, host := range existingHosts {
existingIDs[host.ID] = true
}
for _, newHost := range newHosts {
if !existingIDs[newHost.ID] {
existingHosts = append(existingHosts, newHost)
}
}
return s.replaceHosts(existingHosts)
}
func (s *JSONStorage) mergeKeyPairs(ctx context.Context, newKeys []*models.KeyPair) error {
existingKeys, err := s.ListKeyPairs(ctx)
if err != nil {
return err
}
existingIDs := make(map[string]bool)
for _, key := range existingKeys {
existingIDs[key.ID] = true
}
for _, newKey := range newKeys {
if !existingIDs[newKey.ID] {
existingKeys = append(existingKeys, newKey)
}
}
return s.replaceKeyPairs(existingKeys)
}
func (s *JSONStorage) mergeSnippets(ctx context.Context, newSnippets []*models.Snippet) error {
existingSnippets, err := s.ListSnippets(ctx)
if err != nil {
return err
}
existingIDs := make(map[string]bool)
for _, snippet := range existingSnippets {
existingIDs[snippet.ID] = true
}
for _, newSnippet := range newSnippets {
if !existingIDs[newSnippet.ID] {
existingSnippets = append(existingSnippets, newSnippet)
}
}
return s.replaceSnippets(existingSnippets)
}
// ============ Helpers ============
func (s *JSONStorage) readJSON(path string, v interface{}) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
// Decrypt if password is set and data looks encrypted
if s.password != "" && crypto.IsEncrypted(string(data)) {
decrypted, err := crypto.Decrypt(string(data), s.password)
if err != nil {
return fmt.Errorf("decryption failed: %w", err)
}
data = decrypted
}
return json.Unmarshal(data, v)
}
+49
View File
@@ -0,0 +1,49 @@
package storage
import (
"context"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
)
// Storage defines the interface for data persistence
type Storage interface {
// Host operations
ListHosts(ctx context.Context) ([]*models.Host, error)
GetHost(ctx context.Context, id string) (*models.Host, error)
SaveHost(ctx context.Context, host *models.Host) error
DeleteHost(ctx context.Context, id string) error
// KeyPair operations
ListKeyPairs(ctx context.Context) ([]*models.KeyPair, error)
GetKeyPair(ctx context.Context, id string) (*models.KeyPair, error)
SaveKeyPair(ctx context.Context, keyPair *models.KeyPair) error
DeleteKeyPair(ctx context.Context, id string) error
// Snippet operations
ListSnippets(ctx context.Context) ([]*models.Snippet, error)
GetSnippet(ctx context.Context, id string) (*models.Snippet, error)
SaveSnippet(ctx context.Context, snippet *models.Snippet) error
DeleteSnippet(ctx context.Context, id string) error
// Export/Import
ExportData(ctx context.Context) (*ExportData, error)
ImportData(ctx context.Context, data *ExportData, strategy MergeStrategy) error
}
// ExportData represents the data structure for export/import
type ExportData struct {
Hosts []*models.Host `json:"hosts"`
KeyPairs []*models.KeyPair `json:"key_pairs"`
Snippets []*models.Snippet `json:"snippets"`
}
// MergeStrategy defines how imported data is merged with existing data
type MergeStrategy string
const (
// MergeStrategyReplace replaces all existing data with imported data
MergeStrategyReplace MergeStrategy = "replace"
// MergeStrategyMerge keeps existing data and adds only new items
MergeStrategyMerge MergeStrategy = "merge"
)
+137
View File
@@ -0,0 +1,137 @@
package tui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
)
// ErrorSeverity indicates the level of an error message
type ErrorSeverity int
const (
SevError ErrorSeverity = iota
SevWarning
SevInfo
)
// ErrorBanner displays a structured error message with title, details, and hints
type ErrorBanner struct {
Title string
Detail string
Hints []string
Severity ErrorSeverity
AutoDismiss bool
DismissAfter time.Duration
createdAt time.Time
visible bool
}
// NewErrorBanner creates a new error banner with the given severity
func NewErrorBanner(severity ErrorSeverity) *ErrorBanner {
return &ErrorBanner{
Severity: severity,
AutoDismiss: true,
DismissAfter: 5 * time.Second,
visible: false,
}
}
// Show displays the error banner with the given message
func (b *ErrorBanner) Show(title, detail string, hints ...string) {
b.Title = title
b.Detail = detail
b.Hints = hints
b.visible = true
b.createdAt = time.Now()
}
// Hide hides the error banner
func (b *ErrorBanner) Hide() {
b.visible = false
}
// IsVisible returns whether the banner is currently visible
func (b *ErrorBanner) IsVisible() bool {
return b.visible
}
// Update checks if auto-dismiss time has elapsed
func (b *ErrorBanner) Update() {
if b.visible && b.AutoDismiss && time.Since(b.createdAt) > b.DismissAfter {
b.visible = false
}
}
// View renders the error banner
func (b *ErrorBanner) View(width int) string {
if !b.visible {
return ""
}
var (
titleStyle lipgloss.Style
borderColor lipgloss.Color
prefix string
)
switch b.Severity {
case SevError:
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ea6962")).Bold(true)
borderColor = lipgloss.Color("#ea6962")
prefix = "✖"
case SevWarning:
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#d8a657")).Bold(true)
borderColor = lipgloss.Color("#d8a657")
prefix = "⚠"
case SevInfo:
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7daea3")).Bold(true)
borderColor = lipgloss.Color("#7daea3")
prefix = ""
}
var sb strings.Builder
// Title line with prefix
sb.WriteString(titleStyle.Render(fmt.Sprintf("%s %s", prefix, b.Title)))
// Detail line
if b.Detail != "" {
sb.WriteString("\n")
sb.WriteString(strings.Repeat(" ", len(prefix)+1))
detailStyle := lipgloss.NewStyle().Foreground(activeTheme.Fg)
sb.WriteString(detailStyle.Render(b.Detail))
}
// Hints
if len(b.Hints) > 0 {
sb.WriteString("\n")
sb.WriteString(strings.Repeat(" ", len(prefix)+1))
hintStyle := lipgloss.NewStyle().Foreground(activeTheme.FgMute)
sb.WriteString(hintStyle.Render("Hints:"))
for i, hint := range b.Hints {
sb.WriteString("\n")
sb.WriteString(strings.Repeat(" ", len(prefix)+2))
sb.WriteString(hintStyle.Render(fmt.Sprintf("%d. %s", i+1, hint)))
}
}
// Wrap in a styled box
borderStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(borderColor).
Padding(0, 1).
Width(min(width-2, 80))
return borderStyle.Render(sb.String())
}
// min returns the smaller of two integers
func min(a, b int) int {
if a < b {
return a
}
return b
}
+374
View File
@@ -0,0 +1,374 @@
package tui
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/google/uuid"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
)
type formMode int
const (
formModeAdd formMode = iota
formModeEdit
)
type fieldID int
const (
fieldName fieldID = iota
fieldHostname
fieldPort
fieldUsername
fieldAuthType
fieldPassword
fieldGroup
fieldTags
fieldNotes
fieldCount
)
var fieldLabels = map[fieldID]string{
fieldName: "Name",
fieldHostname: "Hostname",
fieldPort: "Port",
fieldUsername: "Username",
fieldAuthType: "Auth Type",
fieldPassword: "Password",
fieldGroup: "Group",
fieldTags: "Tags",
fieldNotes: "Notes",
}
// HostFormTab is a tab for adding/editing hosts
type HostFormTab struct {
mode formMode
editing *models.Host
dataDir string
inputs []textinput.Model
focus fieldID
width int
height int
err error
saved bool
}
// NewAddHostFormTab creates a new host add form tab
func NewAddHostFormTab(dataDir string) *HostFormTab {
return newHostFormTab(formModeAdd, nil, dataDir)
}
// NewEditHostFormTab creates a new host edit form tab
func NewEditHostFormTab(host *models.Host, dataDir string) *HostFormTab {
return newHostFormTab(formModeEdit, host, dataDir)
}
func newHostFormTab(mode formMode, host *models.Host, dataDir string) *HostFormTab {
inputs := make([]textinput.Model, fieldCount)
for i := range inputs {
inputs[i] = textinput.New()
inputs[i].Prompt = ""
}
inputs[fieldName].Placeholder = "My Server"
inputs[fieldHostname].Placeholder = "192.168.1.1 or server.example.com"
inputs[fieldPort].Placeholder = "22"
inputs[fieldPort].SetValue("22")
inputs[fieldUsername].Placeholder = "root"
inputs[fieldAuthType].SetValue("password")
inputs[fieldPassword].EchoMode = textinput.EchoPassword
inputs[fieldPassword].Placeholder = "Enter password"
inputs[fieldGroup].Placeholder = "production"
inputs[fieldTags].Placeholder = "web,backend"
inputs[fieldNotes].Placeholder = "Optional notes..."
if mode == formModeEdit && host != nil {
inputs[fieldName].SetValue(host.Name)
inputs[fieldHostname].SetValue(host.Hostname)
inputs[fieldPort].SetValue(strconv.Itoa(host.Port))
inputs[fieldUsername].SetValue(host.Username)
inputs[fieldAuthType].SetValue(host.Auth.Type)
if host.Auth.Password != "" {
inputs[fieldPassword].SetValue(host.Auth.Password)
}
inputs[fieldGroup].SetValue(host.Group)
inputs[fieldTags].SetValue(strings.Join(host.Tags, ","))
inputs[fieldNotes].SetValue(host.Notes)
}
inputs[fieldName].Focus()
inputs[fieldName].Prompt = "> "
return &HostFormTab{
mode: mode,
editing: host,
dataDir: dataDir,
inputs: inputs,
}
}
func (t *HostFormTab) Name() string {
if t.mode == formModeEdit {
return "Edit: " + t.editing.Name
}
return "Add Host"
}
func (t *HostFormTab) Init() tea.Cmd {
return textinput.Blink
}
func (t *HostFormTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
if t.saved {
return t, nil
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
t.width = msg.Width
t.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "esc":
return t, func() tea.Msg { return closeFormMsg{} }
case "enter":
if t.focus == fieldAuthType {
t.toggleAuthType()
return t, nil
}
if t.focus == fieldCount-1 {
return t.submit()
}
t.nextField()
case " ", "left", "right":
if t.focus == fieldAuthType {
t.toggleAuthType()
return t, nil
}
// pass through to text input (allow typing spaces, cursor nav)
var cmd tea.Cmd
t.inputs[t.focus], cmd = t.inputs[t.focus].Update(msg)
return t, cmd
case "tab", "down":
t.nextField()
case "shift+tab", "up":
t.prevField()
case "ctrl+s":
return t.submit()
default:
if t.focus == fieldAuthType {
// ignore typing on auth type field
return t, nil
}
var cmd tea.Cmd
t.inputs[t.focus], cmd = t.inputs[t.focus].Update(msg)
return t, cmd
}
}
return t, nil
}
func (t *HostFormTab) nextField() {
t.inputs[t.focus].Blur()
t.inputs[t.focus].Prompt = ""
t.focus++
if t.focus >= fieldCount {
t.focus = fieldCount - 1
}
t.inputs[t.focus].Focus()
t.inputs[t.focus].Prompt = "> "
}
func (t *HostFormTab) prevField() {
t.inputs[t.focus].Blur()
t.inputs[t.focus].Prompt = ""
t.focus--
if t.focus < 0 {
t.focus = 0
}
t.inputs[t.focus].Focus()
t.inputs[t.focus].Prompt = "> "
}
func cycleAuthType(current string) string {
switch current {
case "password":
return "key"
case "key":
return "password"
default:
return "password"
}
}
func (t *HostFormTab) toggleAuthType() {
current := t.inputs[fieldAuthType].Value()
t.inputs[fieldAuthType].SetValue(cycleAuthType(current))
}
func (t *HostFormTab) submit() (Tab, tea.Cmd) {
name := t.inputs[fieldName].Value()
hostname := t.inputs[fieldHostname].Value()
username := t.inputs[fieldUsername].Value()
if name == "" || hostname == "" || username == "" {
t.err = fmt.Errorf("name, hostname, and username are required")
return t, nil
}
port := 22
if p := t.inputs[fieldPort].Value(); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
port = parsed
}
}
authType := t.inputs[fieldAuthType].Value()
if authType == "" {
authType = "password"
}
var tags []string
if tagStr := t.inputs[fieldTags].Value(); tagStr != "" {
for _, tag := range strings.Split(tagStr, ",") {
if trimmed := strings.TrimSpace(tag); trimmed != "" {
tags = append(tags, trimmed)
}
}
}
var host *models.Host
if t.mode == formModeEdit && t.editing != nil {
host = t.editing
host.Name = name
host.Hostname = hostname
host.Port = port
host.Username = username
host.Auth.Type = authType
host.Auth.Password = t.inputs[fieldPassword].Value()
host.Group = t.inputs[fieldGroup].Value()
host.Tags = tags
host.Notes = t.inputs[fieldNotes].Value()
} else {
host = &models.Host{
ID: uuid.New().String(),
Name: name,
Hostname: hostname,
Port: port,
Username: username,
Auth: models.AuthConfig{
Type: authType,
Password: t.inputs[fieldPassword].Value(),
},
Group: t.inputs[fieldGroup].Value(),
Tags: tags,
Notes: t.inputs[fieldNotes].Value(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
t.saved = true
return t, saveHostCmd(host, t.dataDir)
}
func (t *HostFormTab) View() string {
contentW := t.width - 12
if contentW < 30 {
contentW = 30
}
if contentW > 70 {
contentW = 70
}
var inner strings.Builder
title := "Add New Host"
if t.mode == formModeEdit {
title = "Edit Host"
}
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(title)))
inner.WriteString("\n\n")
if t.err != nil {
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
inner.WriteString("\n\n")
}
for i := fieldID(0); i < fieldCount; i++ {
input := t.inputs[i]
label := fieldLabels[i]
style := SubtitleStyle
if i == t.focus {
style = HighlightStyle
}
inner.WriteString(style.Render(label + ":"))
inner.WriteString("\n")
if i == fieldAuthType {
current := input.Value()
pills := []string{"password", "key"}
var parts []string
for _, p := range pills {
if p == current {
if i == t.focus {
parts = append(parts, SelectedStyle.Render(" "+p+" "))
} else {
parts = append(parts, TagStyle.Render(" "+p+" "))
}
} else {
parts = append(parts, SubtitleStyle.Render(" "+p+" "))
}
}
inner.WriteString(" ")
inner.WriteString(strings.Join(parts, " "))
inner.WriteString("\n")
if i == t.focus {
inner.WriteString(" " + InfoStyle.Render("Space/←/→ to toggle"))
}
inner.WriteString("\n\n")
} else {
renderedInput := input.View()
inner.WriteString(" ")
inner.WriteString(renderedInput)
inner.WriteString("\n\n")
}
}
inner.WriteString("\n")
footerText := "Ctrl+Tab:switch Ctrl+Q:close Tab:next Shift+Tab:prev ↑↓:nav Enter:next Ctrl+S:save Esc:cancel"
footerWrapped := wrapFooter(footerText, contentW)
for _, line := range strings.Split(footerWrapped, "\n") {
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, SubtitleStyle.Render(line)))
inner.WriteString("\n")
}
box := BorderStyle.Render(inner.String())
var b strings.Builder
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
return b.String()
}
func (t *HostFormTab) Close() {}
+266
View File
@@ -0,0 +1,266 @@
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
)
// HostListTab is the host list tab
type HostListTab struct {
hosts []*models.Host
selectedIndex int
err error
width int
height int
}
// NewHostListTab creates a new host list tab
func NewHostListTab() *HostListTab {
return &HostListTab{
selectedIndex: 0,
}
}
// Init initializes the tab
func (t *HostListTab) Init() tea.Cmd {
return nil
}
// Name returns the tab name
func (t *HostListTab) Name() string {
return "Hosts"
}
// Update handles messages for the host list
func (t *HostListTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
t.width = msg.Width
t.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "up", "k":
if t.selectedIndex > 0 {
t.selectedIndex--
}
case "down", "j":
if t.selectedIndex < len(t.hosts)-1 {
t.selectedIndex++
}
case "enter", " ":
if len(t.hosts) > 0 {
host := t.hosts[t.selectedIndex]
return t, func() tea.Msg {
return sshConnectToMsg{host: host}
}
}
case "ctrl+n":
return t, func() tea.Msg {
return openHostFormMsg{}
}
case "ctrl+e", "e":
if len(t.hosts) > 0 {
host := t.hosts[t.selectedIndex]
return t, func() tea.Msg {
return openHostFormMsg{editing: host}
}
}
case "ctrl+f":
if len(t.hosts) > 0 {
host := t.hosts[t.selectedIndex]
return t, func() tea.Msg {
return openSFTPMsg{host: host}
}
}
case "ctrl+k":
return t, func() tea.Msg {
return openKeyListMsg{}
}
case "ctrl+p":
return t, func() tea.Msg {
return openSnippetListMsg{}
}
case "q", "ctrl+c":
return t, func() tea.Msg {
return quitMsg{}
}
}
}
return t, nil
}
// View renders the host list — responsive layout
func (t *HostListTab) View() string {
var b strings.Builder
// Error display
if t.err != nil {
b.WriteString(ErrorStyle.Render(fmt.Sprintf(" Error: %v ", t.err)))
b.WriteString("\n")
t.err = nil
}
if len(t.hosts) == 0 {
msg := SubtitleStyle.Render("(no connections — press Ctrl+N to add)")
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, msg))
return b.String()
}
// Scrolling
availH := t.height - 10
if availH < 1 {
availH = 1
}
maxHosts := availH
if maxHosts > len(t.hosts) {
maxHosts = len(t.hosts)
}
start := t.selectedIndex - maxHosts/2
if start < 0 {
start = 0
}
if start+maxHosts > len(t.hosts) {
start = len(t.hosts) - maxHosts
}
// Responsive width calculation
sidePad := adaptiveSidePad(t.width)
titlePlain := "Connection List"
// Measure content rows to determine natural box width
widestContent := lipgloss.Width(titlePlain)
for i := start; i < start+maxHosts; i++ {
host := t.hosts[i]
raw := fmt.Sprintf(" %-12s %-18s :%d", host.Name, host.Hostname, host.Port)
if w := lipgloss.Width(raw); w > widestContent {
widestContent = w
}
}
// Clamp box to terminal width
targetW := clampWidth(widestContent+sidePad*2, t.width)
innerW := targetW - sidePad*2
if innerW < 1 {
innerW = 1
}
// Build rows with adaptive format
type styledRow struct {
text string
plain string
}
var rows []styledRow
for i := start; i < start+maxHosts; i++ {
host := t.hosts[i]
var raw string
if innerW >= 35 {
raw = fmt.Sprintf(" %-12s %-18s :%d", host.Name, host.Hostname, host.Port)
} else {
raw = fmt.Sprintf(" %s %s:%d", host.Name, host.Hostname, host.Port)
}
if lipgloss.Width(raw) > innerW {
raw = truncateStr(raw, innerW)
}
var styled string
if i == t.selectedIndex {
styled = lipgloss.NewStyle().
Foreground(gbFg).
Background(gbBgSel).
Bold(true).
Render("▸ " + strings.TrimLeft(raw, " "))
} else {
styled = lipgloss.NewStyle().Foreground(gbFg).Render(raw)
}
rows = append(rows, styledRow{text: styled, plain: raw})
}
// Footer (wrapped to fit innerW)
footerText := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Enter:SSH Ctrl+N:add Ctrl+E:edit Ctrl+F:SFTP Ctrl+K:keys Ctrl+P:snippets q:quit"
footerWrapped := wrapFooter(footerText, innerW)
// Title
title := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(titlePlain)
var content strings.Builder
content.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, title))
content.WriteString("\n\n")
for _, r := range rows {
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, r.text)
content.WriteString(line)
content.WriteString("\n")
}
content.WriteString("\n")
for _, line := range strings.Split(footerWrapped, "\n") {
content.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, SubtitleStyle.Render(line)))
content.WriteString("\n")
}
box := BorderStyle.Render(content.String())
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
return b.String()
}
// Close is a no-op for host list tab
func (t *HostListTab) Close() {}
// SetHosts sets the host list
func (t *HostListTab) SetHosts(hosts []*models.Host) {
t.hosts = hosts
if len(hosts) > 0 && t.selectedIndex >= len(hosts) {
t.selectedIndex = len(hosts) - 1
}
}
// Hosts returns the host list
func (t *HostListTab) Hosts() []*models.Host {
return t.hosts
}
// SelectedIndex returns the selected index
func (t *HostListTab) SelectedIndex() int {
return t.selectedIndex
}
// FindHostListTab finds the first HostListTab in a list of tabs
func FindHostListTab(tabs []Tab) *HostListTab {
for _, tab := range tabs {
if ht, ok := tab.(*HostListTab); ok {
return ht
}
}
return nil
}
// formatTagsForTUI formats tags for TUI display
func formatTagsForTUI(tags []string) string {
if len(tags) == 0 {
return ""
}
var formatted []string
for _, tag := range tags {
formatted = append(formatted, "["+tag+"]")
}
return strings.Join(formatted, " ")
}
+251
View File
@@ -0,0 +1,251 @@
package tui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/google/uuid"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
)
type keyFormMode int
const (
keyFormAdd keyFormMode = iota
keyFormEdit
)
type keyFieldID int
const (
keyFieldName keyFieldID = iota
keyFieldType
keyFieldPrivateKey
keyFieldPassphrase
keyFieldCount
)
var keyFieldLabels = map[keyFieldID]string{
keyFieldName: "Name",
keyFieldType: "Type (rsa/ed25519/ecdsa)",
keyFieldPrivateKey: "Private Key (PEM)",
keyFieldPassphrase: "Passphrase",
}
// KeyFormTab is a tab for adding/editing SSH key pairs
type KeyFormTab struct {
mode keyFormMode
editing *models.KeyPair
dataDir string
inputs []textinput.Model
focus keyFieldID
width int
height int
err error
saved bool
}
func NewAddKeyFormTab(dataDir string) *KeyFormTab {
return newKeyFormTab(keyFormAdd, nil, dataDir)
}
func NewEditKeyFormTab(key *models.KeyPair, dataDir string) *KeyFormTab {
return newKeyFormTab(keyFormEdit, key, dataDir)
}
func newKeyFormTab(mode keyFormMode, key *models.KeyPair, dataDir string) *KeyFormTab {
inputs := make([]textinput.Model, keyFieldCount)
for i := range inputs {
inputs[i] = textinput.New()
inputs[i].Prompt = ""
}
inputs[keyFieldName].Placeholder = "My SSH Key"
inputs[keyFieldType].Placeholder = "ed25519"
inputs[keyFieldType].SetValue("ed25519")
inputs[keyFieldPrivateKey].Placeholder = "-----BEGIN OPENSSH PRIVATE KEY-----"
inputs[keyFieldPassphrase].Placeholder = "Optional passphrase"
if mode == keyFormEdit && key != nil {
inputs[keyFieldName].SetValue(key.Name)
inputs[keyFieldType].SetValue(key.Type)
inputs[keyFieldPrivateKey].SetValue(key.PrivateKey)
inputs[keyFieldPassphrase].SetValue(key.Passphrase)
}
inputs[keyFieldName].Focus()
inputs[keyFieldName].Prompt = "> "
return &KeyFormTab{
mode: mode,
editing: key,
dataDir: dataDir,
inputs: inputs,
}
}
func (t *KeyFormTab) Name() string {
if t.mode == keyFormEdit {
return "Edit Key"
}
return "Add Key"
}
func (t *KeyFormTab) Init() tea.Cmd {
return textinput.Blink
}
func (t *KeyFormTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
if t.saved {
return t, nil
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
t.width = msg.Width
t.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "esc":
return t, func() tea.Msg { return closeFormMsg{} }
case "enter":
if t.focus == keyFieldCount-1 {
return t.submit()
}
t.nextField()
case "tab", "down":
t.nextField()
case "shift+tab", "up":
t.prevField()
case "ctrl+s":
return t.submit()
default:
var cmd tea.Cmd
t.inputs[t.focus], cmd = t.inputs[t.focus].Update(msg)
return t, cmd
}
}
return t, nil
}
func (t *KeyFormTab) nextField() {
t.inputs[t.focus].Blur()
t.inputs[t.focus].Prompt = ""
t.focus++
if t.focus >= keyFieldCount {
t.focus = keyFieldCount - 1
}
t.inputs[t.focus].Focus()
t.inputs[t.focus].Prompt = "> "
}
func (t *KeyFormTab) prevField() {
t.inputs[t.focus].Blur()
t.inputs[t.focus].Prompt = ""
t.focus--
if t.focus < 0 {
t.focus = 0
}
t.inputs[t.focus].Focus()
t.inputs[t.focus].Prompt = "> "
}
func (t *KeyFormTab) submit() (Tab, tea.Cmd) {
name := t.inputs[keyFieldName].Value()
privateKey := t.inputs[keyFieldPrivateKey].Value()
if name == "" || privateKey == "" {
t.err = fmt.Errorf("name and private key are required")
return t, nil
}
var key *models.KeyPair
if t.mode == keyFormEdit && t.editing != nil {
key = t.editing
key.Name = name
key.Type = t.inputs[keyFieldType].Value()
key.PrivateKey = privateKey
key.Passphrase = t.inputs[keyFieldPassphrase].Value()
} else {
key = &models.KeyPair{
ID: uuid.New().String(),
Name: name,
Type: t.inputs[keyFieldType].Value(),
PrivateKey: privateKey,
Passphrase: t.inputs[keyFieldPassphrase].Value(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
t.saved = true
return t, saveKeyCmd(key, t.dataDir)
}
func (t *KeyFormTab) View() string {
contentW := t.width - 12
if contentW < 30 {
contentW = 30
}
if contentW > 70 {
contentW = 70
}
var inner strings.Builder
title := "Add SSH Key"
if t.mode == keyFormEdit {
title = "Edit SSH Key"
}
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(title)))
inner.WriteString("\n\n")
if t.err != nil {
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
inner.WriteString("\n\n")
}
for i := keyFieldID(0); i < keyFieldCount; i++ {
input := t.inputs[i]
label := keyFieldLabels[i]
style := SubtitleStyle
if i == t.focus {
style = HighlightStyle
}
inner.WriteString(style.Render(label + ":"))
inner.WriteString("\n ")
inner.WriteString(input.View())
inner.WriteString("\n\n")
}
inner.WriteString("\n")
footerText := "Ctrl+Tab:switch Ctrl+Q:close Tab:next Shift+Tab:prev ↑↓:nav Enter:next Ctrl+S:save Esc:cancel"
footerWrapped := wrapFooter(footerText, contentW)
for _, line := range strings.Split(footerWrapped, "\n") {
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, SubtitleStyle.Render(line)))
inner.WriteString("\n")
}
box := BorderStyle.Render(inner.String())
var b strings.Builder
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
return b.String()
}
func (t *KeyFormTab) Close() {}
+267
View File
@@ -0,0 +1,267 @@
package tui
import (
"context"
"fmt"
"strings"
"sync"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
type keyListState int
const (
keyListLoading keyListState = iota
keyListReady
keyListError
)
// KeyListTab displays and manages SSH key pairs
type KeyListTab struct {
dataDir string
keys []*models.KeyPair
selected int
state keyListState
err error
width int
height int
mu sync.Mutex
}
func NewKeyListTab(dataDir string) *KeyListTab {
return &KeyListTab{
dataDir: dataDir,
state: keyListLoading,
}
}
func (t *KeyListTab) Name() string { return "SSH Keys" }
func (t *KeyListTab) Init() tea.Cmd {
return func() tea.Msg {
store, err := storage.NewJSONStorage(t.dataDir)
if err != nil {
return keyListLoadedMsg{err: err}
}
keys, err := store.ListKeyPairs(context.Background())
if err != nil {
return keyListLoadedMsg{err: err}
}
return keyListLoadedMsg{keys: keys}
}
}
func (t *KeyListTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
t.mu.Lock()
t.width = msg.Width
t.height = msg.Height
t.mu.Unlock()
case keyListLoadedMsg:
t.mu.Lock()
if msg.err != nil {
t.state = keyListError
t.err = msg.err
} else {
t.state = keyListReady
t.keys = msg.keys
}
t.mu.Unlock()
case saveKeyResultMsg:
return t, t.Init()
case deleteKeyResultMsg:
if msg.err != nil {
t.mu.Lock()
t.err = msg.err
t.mu.Unlock()
}
return t, t.Init()
case tea.KeyMsg:
switch msg.String() {
case "up", "k":
t.mu.Lock()
if t.selected > 0 {
t.selected--
}
t.mu.Unlock()
case "down", "j":
t.mu.Lock()
if t.selected < len(t.keys)-1 {
t.selected++
}
t.mu.Unlock()
case "ctrl+n":
return t, func() tea.Msg { return openKeyFormMsg{} }
case "ctrl+e":
t.mu.Lock()
keys := t.keys
idx := t.selected
t.mu.Unlock()
if len(keys) > 0 && idx >= 0 && idx < len(keys) {
return t, func() tea.Msg { return openKeyFormMsg{editing: keys[idx]} }
}
case "delete", "d":
t.mu.Lock()
keys := t.keys
idx := t.selected
t.mu.Unlock()
if len(keys) > 0 && idx >= 0 && idx < len(keys) {
return t, deleteKeyCmd(keys[idx].ID, t.dataDir)
}
case "esc":
return t, func() tea.Msg { return closeFormMsg{} }
}
}
return t, nil
}
func (t *KeyListTab) View() string {
t.mu.Lock()
defer t.mu.Unlock()
if t.state == keyListLoading {
return lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
SubtitleStyle.Render("Loading keys..."))
}
if t.state == keyListError {
var b strings.Builder
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
b.WriteString("\n")
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
SubtitleStyle.Render("Press Esc to go back")))
return b.String()
}
type styledRow struct {
text string
plain string
}
titlePlain := "SSH Key Pairs"
// Build content rows
var rows []styledRow
if t.err != nil {
errLine := fmt.Sprintf("Error: %v", t.err)
rows = append(rows, styledRow{text: ErrorStyle.Render(errLine), plain: errLine})
}
if len(t.keys) == 0 {
empty := "No SSH keys stored."
hint := "Press Ctrl+N to add a new key."
rows = append(rows, styledRow{text: SubtitleStyle.Render(empty), plain: empty})
rows = append(rows, styledRow{text: SubtitleStyle.Render(hint), plain: hint})
} else {
for i, key := range t.keys {
var plain string
if key.Type != "" {
plain = fmt.Sprintf(" %s (%s)", key.Name, key.Type)
} else {
plain = fmt.Sprintf(" %s", key.Name)
}
var styled string
if i == t.selected {
styled = lipgloss.NewStyle().
Foreground(gbFg).
Background(gbBgSel).
Bold(true).
Render("▸ " + strings.TrimLeft(plain, " "))
} else {
styled = lipgloss.NewStyle().Foreground(gbFg).Render(plain)
}
rows = append(rows, styledRow{text: styled, plain: plain})
}
}
// Responsive width
sidePad := adaptiveSidePad(t.width)
widestContent := lipgloss.Width(titlePlain)
for _, r := range rows {
if w := lipgloss.Width(r.plain); w > widestContent {
widestContent = w
}
}
targetW := clampWidth(widestContent+sidePad*2, t.width)
innerW := targetW - sidePad*2
if innerW < 1 {
innerW = 1
}
// Footer (wrapped)
footerText := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Ctrl+N:add Ctrl+E:edit D:delete Esc:back"
footerWrapped := wrapFooter(footerText, innerW)
// Render
var inner strings.Builder
titleStyled := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(titlePlain)
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, titleStyled))
inner.WriteString("\n\n")
for _, r := range rows {
plain := r.plain
if lipgloss.Width(plain) > innerW {
plain = truncateStr(plain, innerW)
}
styled := r.text
if lipgloss.Width(r.plain) > innerW {
styled = truncateStr(r.text, innerW)
}
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, styled)
inner.WriteString(line)
inner.WriteString("\n")
}
inner.WriteString("\n")
for _, line := range strings.Split(footerWrapped, "\n") {
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, SubtitleStyle.Render(line)))
inner.WriteString("\n")
}
box := BorderStyle.Render(inner.String())
var b strings.Builder
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
return b.String()
}
func (t *KeyListTab) Close() {}
// SetKeys updates the key list data directly (used for refresh)
func (t *KeyListTab) SetKeys(keys []*models.KeyPair) {
t.mu.Lock()
defer t.mu.Unlock()
t.state = keyListReady
t.keys = keys
}
// FindKeyListTab finds the first KeyListTab in a list of tabs
func FindKeyListTab(tabs []Tab) *KeyListTab {
for _, tab := range tabs {
if kt, ok := tab.(*KeyListTab); ok {
return kt
}
}
return nil
}
// keyListLoadedMsg carries the loaded key list
type keyListLoadedMsg struct {
keys []*models.KeyPair
err error
}
+183
View File
@@ -0,0 +1,183 @@
package tui
import (
"context"
tea "github.com/charmbracelet/bubbletea"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
// quitMsg signals the TUI to exit
type quitMsg struct{}
// sshConnectToMsg signals the TUI to connect to a host via native SSH
type sshConnectToMsg struct {
host *models.Host
}
// sshExitMsg signals that a native SSH session has ended
type sshExitMsg struct {
err error
}
// openHostFormMsg signals the TUI to open a host form tab
type openHostFormMsg struct {
editing *models.Host // nil for add mode
}
// closeFormMsg signals the TUI to close the active form tab
type closeFormMsg struct{}
// saveHostMsg is produced when the form needs to save a host
type saveHostMsg struct {
host *models.Host
dataDir string
}
// saveHostResultMsg is produced after a save attempt
type saveHostResultMsg struct {
host *models.Host
err error
}
// openSFTPMsg signals the TUI to open an SFTP browser tab
type openSFTPMsg struct {
host *models.Host
}
// loadedHostsMsg is produced after reloading hosts from storage
type loadedHostsMsg struct {
hosts []*models.Host
}
// Key management messages
type openKeyListMsg struct{}
type openKeyFormMsg struct {
editing *models.KeyPair
}
type saveKeyMsg struct {
key *models.KeyPair
dataDir string
}
type saveKeyResultMsg struct {
key *models.KeyPair
err error
}
type deleteKeyMsg struct {
id string
dataDir string
}
type deleteKeyResultMsg struct {
err error
}
// Snippet management messages
type openSnippetListMsg struct{}
type openSnippetFormMsg struct {
editing *models.Snippet
}
type saveSnippetMsg struct {
snippet *models.Snippet
dataDir string
}
type saveSnippetResultMsg struct {
snippet *models.Snippet
err error
}
type deleteSnippetMsg struct {
id string
dataDir string
}
type deleteSnippetResultMsg struct {
err error
}
// saveKeyCmd creates a command that saves a key pair to storage
func saveKeyCmd(key *models.KeyPair, dataDir string) tea.Cmd {
return func() tea.Msg {
store, err := storage.NewJSONStorage(dataDir)
if err != nil {
return saveKeyResultMsg{err: err}
}
ctx := context.Background()
if err := store.SaveKeyPair(ctx, key); err != nil {
return saveKeyResultMsg{err: err}
}
return saveKeyResultMsg{key: key}
}
}
// deleteKeyCmd creates a command that deletes a key pair
func deleteKeyCmd(id, dataDir string) tea.Cmd {
return func() tea.Msg {
store, err := storage.NewJSONStorage(dataDir)
if err != nil {
return deleteKeyResultMsg{err: err}
}
ctx := context.Background()
if err := store.DeleteKeyPair(ctx, id); err != nil {
return deleteKeyResultMsg{err: err}
}
return deleteKeyResultMsg{}
}
}
// saveSnippetCmd creates a command that saves a snippet to storage
func saveSnippetCmd(snippet *models.Snippet, dataDir string) tea.Cmd {
return func() tea.Msg {
store, err := storage.NewJSONStorage(dataDir)
if err != nil {
return saveSnippetResultMsg{err: err}
}
ctx := context.Background()
if err := store.SaveSnippet(ctx, snippet); err != nil {
return saveSnippetResultMsg{err: err}
}
return saveSnippetResultMsg{snippet: snippet}
}
}
// deleteSnippetCmd creates a command that deletes a snippet
func deleteSnippetCmd(id, dataDir string) tea.Cmd {
return func() tea.Msg {
store, err := storage.NewJSONStorage(dataDir)
if err != nil {
return deleteSnippetResultMsg{err: err}
}
ctx := context.Background()
if err := store.DeleteSnippet(ctx, id); err != nil {
return deleteSnippetResultMsg{err: err}
}
return deleteSnippetResultMsg{}
}
}
// saveHostCmd creates a command that saves a host to storage
func saveHostCmd(host *models.Host, dataDir string) tea.Cmd {
return func() tea.Msg {
store, err := storage.NewJSONStorage(dataDir)
if err != nil {
return saveHostResultMsg{err: err}
}
ctx := context.Background()
if err := store.SaveHost(ctx, host); err != nil {
return saveHostResultMsg{err: err}
}
return saveHostResultMsg{host: host}
}
}
// openEncryptPromptMsg shows the encryption setup prompt
type openEncryptPromptMsg struct{}
+218
View File
@@ -0,0 +1,218 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// passwordPromptMode determines what the password prompt is for
type passwordPromptMode int
const (
// passwordModeSetup — first time enabling encryption, ask for new password
passwordModeSetup passwordPromptMode = iota
// passwordModeUnlock — encryption already enabled, ask for existing password
passwordModeUnlock
)
// PasswordPromptTab shows a password prompt for encryption setup or unlock
type PasswordPromptTab struct {
mode passwordPromptMode
inputs []textinput.Model
focus int
width int
height int
err error
dataDir string
onComplete func(password string) // called with password on success
}
// NewPasswordPromptTab creates a new password prompt tab
func NewPasswordPromptTab(mode passwordPromptMode, dataDir string, onComplete func(string)) *PasswordPromptTab {
p := &PasswordPromptTab{
mode: mode,
dataDir: dataDir,
onComplete: onComplete,
}
// Password field
passwordInput := textinput.New()
passwordInput.Placeholder = "Enter master password"
passwordInput.EchoMode = textinput.EchoPassword
passwordInput.EchoCharacter = '•'
passwordInput.Focus()
// Confirm password field (only for setup mode)
confirmInput := textinput.New()
confirmInput.Placeholder = "Confirm password"
confirmInput.EchoMode = textinput.EchoPassword
confirmInput.EchoCharacter = '•'
if mode == passwordModeSetup {
p.inputs = []textinput.Model{passwordInput, confirmInput}
} else {
p.inputs = []textinput.Model{passwordInput}
}
return p
}
func (p *PasswordPromptTab) Name() string {
if p.mode == passwordModeSetup {
return "Setup Encryption"
}
return "Unlock Storage"
}
func (p *PasswordPromptTab) Init() tea.Cmd {
return textinput.Blink
}
func (p *PasswordPromptTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.width = msg.Width
p.height = msg.Height
return p, nil
case tea.KeyMsg:
switch msg.String() {
case "tab", "down":
p.focus++
if p.focus >= len(p.inputs) {
p.focus = 0
}
for i := range p.inputs {
if i == p.focus {
p.inputs[i].Focus()
} else {
p.inputs[i].Blur()
}
}
return p, nil
case "shift+tab", "up":
p.focus--
if p.focus < 0 {
p.focus = len(p.inputs) - 1
}
for i := range p.inputs {
if i == p.focus {
p.inputs[i].Focus()
} else {
p.inputs[i].Blur()
}
}
return p, nil
case "enter":
password := p.inputs[0].Value()
if password == "" {
p.err = fmt.Errorf("password cannot be empty")
return p, nil
}
if p.mode == passwordModeSetup && len(p.inputs) > 1 {
confirm := p.inputs[1].Value()
if password != confirm {
p.err = fmt.Errorf("passwords do not match")
return p, nil
}
}
// Success — call onComplete
if p.onComplete != nil {
p.onComplete(password)
}
return p, func() tea.Msg { return passwordSetMsg{} }
case "esc":
// Cancel — go back or quit
return p, func() tea.Msg { return closeFormMsg{} }
default:
// Update current input
var cmd tea.Cmd
p.inputs[p.focus], cmd = p.inputs[p.focus].Update(msg)
return p, cmd
}
}
return p, nil
}
func (p *PasswordPromptTab) Close() {
// Nothing to clean up
}
func (p *PasswordPromptTab) View() string {
var b strings.Builder
title := "Setup Master Password"
if p.mode == passwordModeUnlock {
title = "Enter Master Password"
}
b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center,
AppTitleStyle.Render(title)))
b.WriteString("\n\n")
if p.mode == passwordModeSetup {
b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center,
SubtitleStyle.Render("Encrypt all sensitive data (passwords, keys) with AES-256")))
b.WriteString("\n")
b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center,
SubtitleStyle.Render("You will need this password to access your data")))
b.WriteString("\n\n")
}
// Input fields
contentW := p.width - 8
if contentW > 60 {
contentW = 60
}
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).
Padding(1, 2).
Width(contentW)
var fields strings.Builder
for i, input := range p.inputs {
label := "Password:"
if i == 1 {
label = "Confirm:"
}
fields.WriteString(SubtitleStyle.Render(" " + label))
fields.WriteString("\n")
fields.WriteString(input.View())
fields.WriteString("\n\n")
}
b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center, box.Render(fields.String())))
if p.err != nil {
b.WriteString("\n")
b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center,
ErrorStyle.Render(p.err.Error())))
}
// Footer
footerText := "Enter:confirm Esc:cancel Tab:next field"
b.WriteString("\n\n")
for _, line := range strings.Split(wrapFooter(footerText, p.width), "\n") {
b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center, SubtitleStyle.Render(line)))
}
return b.String()
}
// passwordSetMsg is sent when password is successfully set
type passwordSetMsg struct {
password string
}
+125
View File
@@ -0,0 +1,125 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// Responsive breakpoints
const (
widthCompact = 60 // mobile / Termux
widthMedium = 100 // tablet
)
// boxOverhead is the horizontal chars consumed by BorderStyle (border 2 + padding 4)
const boxOverhead = 6
// wrapFooter wraps a footer string into multiple lines that fit availW.
// Words are split on double-space separators (" ") and grouped greedily.
// Returns the wrapped string with "\n" line breaks.
func wrapFooter(text string, availW int) string {
if availW < 1 {
availW = 1
}
if lipgloss.Width(text) <= availW {
return text
}
words := strings.Split(text, " ")
var lines []string
var current strings.Builder
for _, word := range words {
word = strings.TrimSpace(word)
if word == "" {
continue
}
if current.Len() == 0 {
current.WriteString(word)
} else if current.Len()+2+lipgloss.Width(word) <= availW {
current.WriteString(" ")
current.WriteString(word)
} else {
lines = append(lines, current.String())
current.Reset()
current.WriteString(word)
}
}
if current.Len() > 0 {
lines = append(lines, current.String())
}
return strings.Join(lines, "\n")
}
// adaptiveSidePad returns horizontal padding based on terminal width.
// wide: 6, medium: 3, compact: 1
func adaptiveSidePad(termWidth int) int {
switch {
case termWidth < widthCompact:
return 1
case termWidth < widthMedium:
return 3
default:
return 6
}
}
// clampWidth clamps a target box width to fit within the terminal.
// Reserves boxOverhead for border+padding. Enforces a minimum of 20.
func clampWidth(target, termWidth int) int {
maxW := termWidth - boxOverhead
if maxW < 20 {
maxW = 20
}
if target > maxW {
return maxW
}
if target < 20 {
return 20
}
return target
}
// truncateStr truncates a string to maxLen with an ellipsis character.
func truncateStr(s string, maxLen int) string {
if maxLen < 1 {
return ""
}
if lipgloss.Width(s) <= maxLen {
return s
}
if maxLen <= 1 {
return "…"
}
runes := []rune(s)
var result []rune
resultW := 0
for _, r := range runes {
rw := lipgloss.Width(string(r))
if resultW+rw > maxLen-1 {
break
}
result = append(result, r)
resultW += rw
}
return string(result) + "…"
}
// Exported wrappers for testing
// WrapFooter wraps a footer string into multiple lines that fit availW
func WrapFooter(text string, availW int) string {
return wrapFooter(text, availW)
}
// ClampWidth clamps a target box width to fit within the terminal
func ClampWidth(target, termWidth int) int {
return clampWidth(target, termWidth)
}
// TruncateStr truncates a string to maxLen with an ellipsis character
func TruncateStr(s string, maxLen int) string {
return truncateStr(s, maxLen)
}
File diff suppressed because it is too large Load Diff
+259
View File
@@ -0,0 +1,259 @@
package tui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/google/uuid"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
)
type snippetFormMode int
const (
snippetFormAdd snippetFormMode = iota
snippetFormEdit
)
type snippetFieldID int
const (
snippetFieldName snippetFieldID = iota
snippetFieldCommand
snippetFieldDescription
snippetFieldTags
snippetFieldCount
)
var snippetFieldLabels = map[snippetFieldID]string{
snippetFieldName: "Name",
snippetFieldCommand: "Command",
snippetFieldDescription: "Description",
snippetFieldTags: "Tags (comma-separated)",
}
// SnippetFormTab is a tab for adding/editing command snippets
type SnippetFormTab struct {
mode snippetFormMode
editing *models.Snippet
dataDir string
inputs []textinput.Model
focus snippetFieldID
width int
height int
err error
saved bool
}
func NewAddSnippetFormTab(dataDir string) *SnippetFormTab {
return newSnippetFormTab(snippetFormAdd, nil, dataDir)
}
func NewEditSnippetFormTab(snippet *models.Snippet, dataDir string) *SnippetFormTab {
return newSnippetFormTab(snippetFormEdit, snippet, dataDir)
}
func newSnippetFormTab(mode snippetFormMode, sn *models.Snippet, dataDir string) *SnippetFormTab {
inputs := make([]textinput.Model, snippetFieldCount)
for i := range inputs {
inputs[i] = textinput.New()
inputs[i].Prompt = ""
}
inputs[snippetFieldName].Placeholder = "Check logs"
inputs[snippetFieldCommand].Placeholder = "journalctl -u nginx --no-pager -n 100"
inputs[snippetFieldDescription].Placeholder = "View last 100 nginx log entries"
inputs[snippetFieldTags].Placeholder = "nginx,logs,troubleshooting"
if mode == snippetFormEdit && sn != nil {
inputs[snippetFieldName].SetValue(sn.Name)
inputs[snippetFieldCommand].SetValue(sn.Command)
inputs[snippetFieldDescription].SetValue(sn.Description)
inputs[snippetFieldTags].SetValue(strings.Join(sn.Tags, ","))
}
inputs[snippetFieldName].Focus()
inputs[snippetFieldName].Prompt = "> "
return &SnippetFormTab{
mode: mode,
editing: sn,
dataDir: dataDir,
inputs: inputs,
}
}
func (t *SnippetFormTab) Name() string {
if t.mode == snippetFormEdit {
return "Edit Snippet"
}
return "Add Snippet"
}
func (t *SnippetFormTab) Init() tea.Cmd {
return textinput.Blink
}
func (t *SnippetFormTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
if t.saved {
return t, nil
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
t.width = msg.Width
t.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "esc":
return t, func() tea.Msg { return closeFormMsg{} }
case "enter":
if t.focus == snippetFieldCount-1 {
return t.submit()
}
t.nextField()
case "tab", "down":
t.nextField()
case "shift+tab", "up":
t.prevField()
case "ctrl+s":
return t.submit()
default:
var cmd tea.Cmd
t.inputs[t.focus], cmd = t.inputs[t.focus].Update(msg)
return t, cmd
}
}
return t, nil
}
func (t *SnippetFormTab) nextField() {
t.inputs[t.focus].Blur()
t.inputs[t.focus].Prompt = ""
t.focus++
if t.focus >= snippetFieldCount {
t.focus = snippetFieldCount - 1
}
t.inputs[t.focus].Focus()
t.inputs[t.focus].Prompt = "> "
}
func (t *SnippetFormTab) prevField() {
t.inputs[t.focus].Blur()
t.inputs[t.focus].Prompt = ""
t.focus--
if t.focus < 0 {
t.focus = 0
}
t.inputs[t.focus].Focus()
t.inputs[t.focus].Prompt = "> "
}
func (t *SnippetFormTab) submit() (Tab, tea.Cmd) {
name := t.inputs[snippetFieldName].Value()
command := t.inputs[snippetFieldCommand].Value()
if name == "" || command == "" {
t.err = fmt.Errorf("name and command are required")
return t, nil
}
var tags []string
if tagStr := t.inputs[snippetFieldTags].Value(); tagStr != "" {
for _, tag := range strings.Split(tagStr, ",") {
if trimmed := strings.TrimSpace(tag); trimmed != "" {
tags = append(tags, trimmed)
}
}
}
var sn *models.Snippet
if t.mode == snippetFormEdit && t.editing != nil {
sn = t.editing
sn.Name = name
sn.Command = command
sn.Description = t.inputs[snippetFieldDescription].Value()
sn.Tags = tags
} else {
sn = &models.Snippet{
ID: uuid.New().String(),
Name: name,
Command: command,
Description: t.inputs[snippetFieldDescription].Value(),
Tags: tags,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
t.saved = true
return t, saveSnippetCmd(sn, t.dataDir)
}
func (t *SnippetFormTab) View() string {
contentW := t.width - 12
if contentW < 30 {
contentW = 30
}
if contentW > 70 {
contentW = 70
}
var inner strings.Builder
title := "Add Snippet"
if t.mode == snippetFormEdit {
title = "Edit Snippet"
}
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(title)))
inner.WriteString("\n\n")
if t.err != nil {
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center,
ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
inner.WriteString("\n\n")
}
for i := snippetFieldID(0); i < snippetFieldCount; i++ {
input := t.inputs[i]
label := snippetFieldLabels[i]
style := SubtitleStyle
if i == t.focus {
style = HighlightStyle
}
inner.WriteString(style.Render(label + ":"))
inner.WriteString("\n ")
inner.WriteString(input.View())
inner.WriteString("\n\n")
}
inner.WriteString("\n")
footerText := "Ctrl+Tab:switch Ctrl+Q:close Tab:next Shift+Tab:prev ↑↓:nav Enter:next Ctrl+S:save Esc:cancel"
footerWrapped := wrapFooter(footerText, contentW)
for _, line := range strings.Split(footerWrapped, "\n") {
inner.WriteString(lipgloss.PlaceHorizontal(contentW, lipgloss.Center, SubtitleStyle.Render(line)))
inner.WriteString("\n")
}
box := BorderStyle.Render(inner.String())
var b strings.Builder
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
return b.String()
}
func (t *SnippetFormTab) Close() {}
+262
View File
@@ -0,0 +1,262 @@
package tui
import (
"context"
"fmt"
"strings"
"sync"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
type snippetListState int
const (
snippetListLoading snippetListState = iota
snippetListReady
snippetListError
)
// SnippetListTab displays and manages command snippets
type SnippetListTab struct {
dataDir string
snippets []*models.Snippet
selected int
state snippetListState
err error
width int
height int
mu sync.Mutex
}
func NewSnippetListTab(dataDir string) *SnippetListTab {
return &SnippetListTab{
dataDir: dataDir,
state: snippetListLoading,
}
}
func (t *SnippetListTab) Name() string { return "Snippets" }
func (t *SnippetListTab) Init() tea.Cmd {
return func() tea.Msg {
store, err := storage.NewJSONStorage(t.dataDir)
if err != nil {
return snippetListLoadedMsg{err: err}
}
snippets, err := store.ListSnippets(context.Background())
if err != nil {
return snippetListLoadedMsg{err: err}
}
return snippetListLoadedMsg{snippets: snippets}
}
}
func (t *SnippetListTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
t.mu.Lock()
t.width = msg.Width
t.height = msg.Height
t.mu.Unlock()
case snippetListLoadedMsg:
t.mu.Lock()
if msg.err != nil {
t.state = snippetListError
t.err = msg.err
} else {
t.state = snippetListReady
t.snippets = msg.snippets
}
t.mu.Unlock()
case saveSnippetResultMsg:
return t, t.Init()
case deleteSnippetResultMsg:
if msg.err != nil {
t.mu.Lock()
t.err = msg.err
t.mu.Unlock()
}
return t, t.Init()
case tea.KeyMsg:
switch msg.String() {
case "up", "k":
t.mu.Lock()
if t.selected > 0 {
t.selected--
}
t.mu.Unlock()
case "down", "j":
t.mu.Lock()
if t.selected < len(t.snippets)-1 {
t.selected++
}
t.mu.Unlock()
case "ctrl+n":
return t, func() tea.Msg { return openSnippetFormMsg{} }
case "ctrl+e":
t.mu.Lock()
snippets := t.snippets
idx := t.selected
t.mu.Unlock()
if len(snippets) > 0 && idx >= 0 && idx < len(snippets) {
return t, func() tea.Msg { return openSnippetFormMsg{editing: snippets[idx]} }
}
case "delete", "d":
t.mu.Lock()
snippets := t.snippets
idx := t.selected
t.mu.Unlock()
if len(snippets) > 0 && idx >= 0 && idx < len(snippets) {
return t, deleteSnippetCmd(snippets[idx].ID, t.dataDir)
}
case "esc":
return t, func() tea.Msg { return closeFormMsg{} }
}
}
return t, nil
}
func (t *SnippetListTab) View() string {
t.mu.Lock()
defer t.mu.Unlock()
if t.state == snippetListLoading {
return lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
SubtitleStyle.Render("Loading snippets..."))
}
if t.state == snippetListError {
var b strings.Builder
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
b.WriteString("\n")
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
SubtitleStyle.Render("Press Esc to go back")))
return b.String()
}
type styledRow struct {
text string
plain string
}
titlePlain := "Command Snippets"
var rows []styledRow
if t.err != nil {
errLine := fmt.Sprintf("Error: %v", t.err)
rows = append(rows, styledRow{text: ErrorStyle.Render(errLine), plain: errLine})
}
if len(t.snippets) == 0 {
empty := "No snippets stored."
hint := "Press Ctrl+N to add a new snippet."
rows = append(rows, styledRow{text: SubtitleStyle.Render(empty), plain: empty})
rows = append(rows, styledRow{text: SubtitleStyle.Render(hint), plain: hint})
} else {
for i, sn := range t.snippets {
var plain string
if sn.Description != "" {
plain = fmt.Sprintf(" %s — %s", sn.Name, sn.Description)
} else {
plain = fmt.Sprintf(" %s", sn.Name)
}
var styled string
if i == t.selected {
styled = lipgloss.NewStyle().
Foreground(gbFg).
Background(gbBgSel).
Bold(true).
Render("▸ " + strings.TrimLeft(plain, " "))
} else {
styled = lipgloss.NewStyle().Foreground(gbFg).Render(plain)
}
rows = append(rows, styledRow{text: styled, plain: plain})
}
}
// Responsive width
sidePad := adaptiveSidePad(t.width)
widestContent := lipgloss.Width(titlePlain)
for _, r := range rows {
if w := lipgloss.Width(r.plain); w > widestContent {
widestContent = w
}
}
targetW := clampWidth(widestContent+sidePad*2, t.width)
innerW := targetW - sidePad*2
if innerW < 1 {
innerW = 1
}
// Footer (wrapped)
footerText := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Ctrl+N:add Ctrl+E:edit D:delete Esc:back"
footerWrapped := wrapFooter(footerText, innerW)
// Render
var inner strings.Builder
titleStyled := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(titlePlain)
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, titleStyled))
inner.WriteString("\n\n")
for _, r := range rows {
styled := r.text
if lipgloss.Width(r.plain) > innerW {
styled = truncateStr(r.text, innerW)
}
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, styled)
inner.WriteString(line)
inner.WriteString("\n")
}
inner.WriteString("\n")
for _, line := range strings.Split(footerWrapped, "\n") {
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, SubtitleStyle.Render(line)))
inner.WriteString("\n")
}
box := BorderStyle.Render(inner.String())
var b strings.Builder
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
return b.String()
}
func (t *SnippetListTab) Close() {}
// SetSnippets updates the snippet list data directly (used for refresh)
func (t *SnippetListTab) SetSnippets(snippets []*models.Snippet) {
t.mu.Lock()
defer t.mu.Unlock()
t.state = snippetListReady
t.snippets = snippets
}
// FindSnippetListTab finds the first SnippetListTab in a list of tabs
func FindSnippetListTab(tabs []Tab) *SnippetListTab {
for _, tab := range tabs {
if st, ok := tab.(*SnippetListTab); ok {
return st
}
}
return nil
}
// snippetListLoadedMsg carries the loaded snippet list
type snippetListLoadedMsg struct {
snippets []*models.Snippet
err error
}
+137
View File
@@ -0,0 +1,137 @@
package tui
import (
"context"
"fmt"
"os"
"os/exec"
"strconv"
tea "github.com/charmbracelet/bubbletea"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
// sshConnectCmd builds and runs a native SSH command via tea.ExecProcess.
// Password auth: sshpass -e ssh user@host (SSHPASS env)
// Key auth: ssh -i <tmpfile> user@host (SSH_ASKPASS for passphrase)
//
// Must return tea.ExecProcess directly (NOT wrapped in another closure)
// so Bubble Tea can execute the process command correctly.
func sshConnectCmd(host *models.Host, dataDir string) tea.Cmd {
port := host.Port
if port == 0 {
port = 22
}
portStr := strconv.Itoa(port)
target := fmt.Sprintf("%s@%s", host.Username, host.Hostname)
ctrlSock := fmt.Sprintf("/tmp/hk-%s", host.ID)
env := os.Environ()
// Common SSH args
sshArgs := []string{
"-p", portStr,
"-o", "StrictHostKeyChecking=accept-new",
"-o", "ServerAliveInterval=60",
"-o", "ServerAliveCountMax=3",
"-S", ctrlSock,
"-o", "ControlMaster=auto",
}
cleanup := func() {
exec.Command("ssh", "-S", ctrlSock, "-O", "exit", target).Run()
}
switch host.Auth.Type {
case "password":
allArgs := append([]string{"-e", "ssh"}, sshArgs...)
allArgs = append(allArgs, target)
cmd := exec.Command("sshpass", allArgs...)
cmd.Env = append(env, "SSHPASS="+host.Auth.Password)
return tea.ExecProcess(cmd, func(err error) tea.Msg {
cleanup()
return sshExitMsg{err: err}
})
case "key":
keyContent, err := loadKeyContent(host, dataDir)
if err != nil {
return errorCmd(fmt.Errorf("load key: %w", err))
}
tmpFile, err := os.CreateTemp("", "hk-key-*")
if err != nil {
return errorCmd(fmt.Errorf("create temp key: %w", err))
}
tmpPath := tmpFile.Name()
if _, err := tmpFile.Write([]byte(keyContent)); err != nil {
tmpFile.Close()
os.Remove(tmpPath)
return errorCmd(fmt.Errorf("write temp key: %w", err))
}
tmpFile.Close()
os.Chmod(tmpPath, 0600)
keyArgs := append([]string{"-i", tmpPath}, sshArgs...)
keyArgs = append(keyArgs, target)
cmd := exec.Command("ssh", keyArgs...)
if host.Auth.Password != "" {
self, err := os.Executable()
if err == nil {
script := fmt.Sprintf("#!/bin/sh\nexec %q askpass\n", self)
f, err := os.CreateTemp("", "hk-askpass-*.sh")
if err == nil {
f.WriteString(script)
f.Close()
os.Chmod(f.Name(), 0700)
env = append(env,
"HK_PASSPHRASE="+host.Auth.Password,
"SSH_ASKPASS="+f.Name(),
"SSH_ASKPASS_REQUIRE=force",
)
if os.Getenv("DISPLAY") == "" {
env = append(env, "DISPLAY=:0")
}
if setsid, err := exec.LookPath("setsid"); err == nil {
newArgs := append([]string{"ssh"}, keyArgs...)
cmd = exec.Command(setsid, newArgs...)
}
}
}
}
cmd.Env = env
return tea.ExecProcess(cmd, func(err error) tea.Msg {
os.Remove(tmpPath)
cleanup()
return sshExitMsg{err: err}
})
default:
return errorCmd(fmt.Errorf("unsupported auth type: %s", host.Auth.Type))
}
}
// errorCmd returns a Cmd that sends an sshExitMsg with the given error.
func errorCmd(err error) tea.Cmd {
return func() tea.Msg {
return sshExitMsg{err: err}
}
}
// loadKeyContent reads the private key content for a host
func loadKeyContent(host *models.Host, dataDir string) (string, error) {
if host.Auth.KeyID == "" {
return "", fmt.Errorf("key auth requires key_id")
}
store, err := storage.NewJSONStorage(dataDir)
if err != nil {
return "", err
}
keyPair, err := store.GetKeyPair(context.Background(), host.Auth.KeyID)
if err != nil {
return "", fmt.Errorf("load key %s: %w", host.Auth.KeyID, err)
}
return keyPair.PrivateKey, nil
}
+59
View File
@@ -0,0 +1,59 @@
package tui
import "github.com/charmbracelet/lipgloss"
// Gruvbox Material Dark Hard palette — warm, soft, easy on eyes
var (
gbFg = lipgloss.Color("#d4be98") // primary text
gbFgMute = lipgloss.Color("#7c6f64") // secondary/hints
gbBgSel = lipgloss.Color("#45403d") // cursor row bg
gbRed = lipgloss.Color("#ea6962") // error/destructive
gbOrange = lipgloss.Color("#e78a4e") // section headers
gbYellow = lipgloss.Color("#d8a657") // accent/titles
gbGreen = lipgloss.Color("#a9b665") // active pane/success
gbAqua = lipgloss.Color("#89b482") // interactive keys
gbBlue = lipgloss.Color("#7daea3")
gbPurple = lipgloss.Color("#d3869b")
gbBorder = lipgloss.Color("#504945") // subtle border
)
// Component styles
var (
TabActiveStyle = lipgloss.NewStyle().Background(gbYellow).Foreground(lipgloss.Color("#1d2021")).Bold(true).Padding(0, 2)
TabInactiveStyle = lipgloss.NewStyle().Background(gbBorder).Foreground(gbFgMute).Padding(0, 2)
TabBarStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1d2021"))
StatusBarStyle = lipgloss.NewStyle().Background(gbGreen).Foreground(lipgloss.Color("#1d2021")).Padding(0, 1)
AppTitleStyle = lipgloss.NewStyle().Foreground(gbYellow).Bold(true)
HighlightStyle = lipgloss.NewStyle().Foreground(gbOrange).Bold(true)
SelectedStyle = lipgloss.NewStyle().Foreground(gbFg).Background(gbBgSel).Bold(true).Padding(0, 1)
ErrorStyle = lipgloss.NewStyle().Foreground(gbRed).Bold(true)
SuccessStyle = lipgloss.NewStyle().Foreground(gbGreen).Bold(true)
InfoStyle = lipgloss.NewStyle().Foreground(gbAqua)
SubtitleStyle = lipgloss.NewStyle().Foreground(gbFgMute)
HostNameStyle = lipgloss.NewStyle().Foreground(gbYellow).Bold(true)
HostDetailStyle = lipgloss.NewStyle().Foreground(gbFgMute)
TagStyle = lipgloss.NewStyle().Foreground(gbGreen)
TitleStyle = AppTitleStyle
SectionStyle = lipgloss.NewStyle().Foreground(gbOrange).Bold(true)
BorderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbBorder).Padding(1, 2)
)
// TabWidth returns the width of the tab bar content
func TabBarWidth(totalWidth int) int {
if totalWidth < 10 {
return totalWidth
}
return totalWidth - 2
}
// Pane styles for dual-pane layout
var (
StylePaneActive = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbGreen).Padding(0, 1)
StylePaneInactive = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbBorder).Padding(0, 1)
)
// Host card styles
var (
HostCardStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbBorder).Padding(0, 1)
HostCardActiveStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbGreen).Padding(0, 1)
)
+231
View File
@@ -0,0 +1,231 @@
package tui
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Tab represents a single tab in the TUI
type Tab interface {
Init() tea.Cmd
Update(tea.Msg) (Tab, tea.Cmd)
View() string
Name() string
// Close is called when the tab is removed; implement for cleanup (e.g. disconnect SSH)
Close()
}
// TabManager manages multiple tabs
type TabManager struct {
tabs []Tab
active int
width int
height int
}
// NewTabManager creates a new TabManager with an initial tab
func NewTabManager(initial Tab) *TabManager {
return &TabManager{
tabs: []Tab{initial},
active: 0,
}
}
// Active returns the currently active tab
func (tm *TabManager) Active() Tab {
if len(tm.tabs) == 0 {
return nil
}
return tm.tabs[tm.active]
}
// Add adds a new tab, switches to it, and returns its init command
func (tm *TabManager) Add(tab Tab) tea.Cmd {
tm.tabs = append(tm.tabs, tab)
tm.active = len(tm.tabs) - 1
// Forward current terminal size so new tabs know their dimensions
if tm.width > 0 && tm.height > 0 {
updated, _ := tab.Update(tea.WindowSizeMsg{Width: tm.width, Height: tm.height})
tm.tabs[tm.active] = updated
}
return tab.Init()
}
// Close removes the tab at index and returns the active tab
func (tm *TabManager) Close(index int) Tab {
if index < 0 || index >= len(tm.tabs) {
return nil
}
// Call Close for cleanup (e.g. disconnect SSH)
tm.tabs[index].Close()
tm.tabs = append(tm.tabs[:index], tm.tabs[index+1:]...)
if len(tm.tabs) == 0 {
return nil
}
if tm.active >= len(tm.tabs) {
tm.active = len(tm.tabs) - 1
}
return tm.tabs[tm.active]
}
// CloseActive closes the active tab
func (tm *TabManager) CloseActive() Tab {
if len(tm.tabs) <= 1 {
return nil
}
return tm.Close(tm.active)
}
// Next switches to the next tab
func (tm *TabManager) Next() {
if len(tm.tabs) <= 1 {
return
}
tm.active = (tm.active + 1) % len(tm.tabs)
}
// Prev switches to the previous tab
func (tm *TabManager) Prev() {
if len(tm.tabs) <= 1 {
return
}
tm.active--
if tm.active < 0 {
tm.active = len(tm.tabs) - 1
}
}
// Len returns the number of tabs
func (tm *TabManager) Len() int {
return len(tm.tabs)
}
// SetSize updates the terminal size for the tab manager
func (tm *TabManager) SetSize(width, height int) {
tm.width = width
tm.height = height
}
// Init initializes all tabs
func (tm *TabManager) Init() tea.Cmd {
var cmds []tea.Cmd
for _, t := range tm.tabs {
if cmd := t.Init(); cmd != nil {
cmds = append(cmds, cmd)
}
}
return tea.Batch(cmds...)
}
// Update sends a message to the active tab
func (tm *TabManager) Update(msg tea.Msg) (tea.Cmd, error) {
if len(tm.tabs) == 0 {
return nil, nil
}
// Handle tab-level keys
if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.String() {
case "ctrl+tab":
tm.Next()
return nil, nil
case "shift+tab":
tm.Prev()
return nil, nil
case "ctrl+q":
if closed := tm.CloseActive(); closed != nil {
return nil, nil
}
}
}
// Handle window resize — forward to ALL tabs
if wsMsg, ok := msg.(tea.WindowSizeMsg); ok {
tm.SetSize(wsMsg.Width, wsMsg.Height)
for i, t := range tm.tabs {
updated, _ := t.Update(msg)
tm.tabs[i] = updated
}
return nil, nil
}
updated, cmd := tm.tabs[tm.active].Update(msg)
tm.tabs[tm.active] = updated
return cmd, nil
}
// View renders the tab bar and active tab content
func (tm *TabManager) View() string {
if len(tm.tabs) == 0 {
return ""
}
var b strings.Builder
// Render tab bar
b.WriteString(renderTabBar(tm))
// Render active tab content
content := tm.tabs[tm.active].View()
if content != "" {
b.WriteString("\n")
b.WriteString(content)
}
return b.String()
}
// renderTabBar renders the top tab bar (responsive: truncates names on overflow)
func renderTabBar(tm *TabManager) string {
if len(tm.tabs) == 0 {
return ""
}
const cellPad = 4 // Padding(0,2) per tab = 2 left + 2 right
availW := tm.width - 2
if availW < 1 {
availW = 1
}
// Measure total width and decide if truncation is needed
totalW := 0
for _, tab := range tm.tabs {
totalW += lipgloss.Width(tab.Name()) + cellPad
}
maxNameW := 0
if totalW > availW {
perTab := availW / len(tm.tabs)
maxNameW = perTab - cellPad
if maxNameW < 1 {
maxNameW = 1
}
}
var cells []string
for i, tab := range tm.tabs {
name := tab.Name()
if maxNameW > 0 && lipgloss.Width(name) > maxNameW {
name = truncateStr(name, maxNameW)
}
if i == tm.active {
cells = append(cells, TabActiveStyle.Render(name))
} else {
cells = append(cells, TabInactiveStyle.Render(name))
}
}
bar := strings.Join(cells, "")
return TabBarStyle.Render(bar)
}
+148
View File
@@ -0,0 +1,148 @@
package tui
import "github.com/charmbracelet/lipgloss"
// Theme defines a complete color palette for the TUI
type Theme struct {
Name string
Fg lipgloss.Color
FgMute lipgloss.Color
Bg lipgloss.Color
BgSel lipgloss.Color
Red lipgloss.Color
Orange lipgloss.Color
Yellow lipgloss.Color
Green lipgloss.Color
Aqua lipgloss.Color
Blue lipgloss.Color
Purple lipgloss.Color
Border lipgloss.Color
TabBg lipgloss.Color
}
// Predefined themes
var (
ThemeDark = Theme{
Name: "dark",
Fg: lipgloss.Color("#d4be98"),
FgMute: lipgloss.Color("#7c6f64"),
Bg: lipgloss.Color("#1d2021"),
BgSel: lipgloss.Color("#45403d"),
Red: lipgloss.Color("#ea6962"),
Orange: lipgloss.Color("#e78a4e"),
Yellow: lipgloss.Color("#d8a657"),
Green: lipgloss.Color("#a9b665"),
Aqua: lipgloss.Color("#89b482"),
Blue: lipgloss.Color("#7daea3"),
Purple: lipgloss.Color("#d3869b"),
Border: lipgloss.Color("#504945"),
TabBg: lipgloss.Color("#1d2021"),
}
ThemeLight = Theme{
Name: "light",
Fg: lipgloss.Color("#3c3836"),
FgMute: lipgloss.Color("#7c6f64"),
Bg: lipgloss.Color("#f2e5bc"),
BgSel: lipgloss.Color("#d5c4a1"),
Red: lipgloss.Color("#cc241d"),
Orange: lipgloss.Color("#d65d0e"),
Yellow: lipgloss.Color("#d79921"),
Green: lipgloss.Color("#98971a"),
Aqua: lipgloss.Color("#689d6a"),
Blue: lipgloss.Color("#458588"),
Purple: lipgloss.Color("#b16286"),
Border: lipgloss.Color("#a89984"),
TabBg: lipgloss.Color("#f2e5bc"),
}
ThemeDracula = Theme{
Name: "dracula",
Fg: lipgloss.Color("#f8f8f2"),
FgMute: lipgloss.Color("#6272a4"),
Bg: lipgloss.Color("#282a36"),
BgSel: lipgloss.Color("#44475a"),
Red: lipgloss.Color("#ff5555"),
Orange: lipgloss.Color("#ffb86c"),
Yellow: lipgloss.Color("#f1fa8c"),
Green: lipgloss.Color("#50fa7b"),
Aqua: lipgloss.Color("#8be9fd"),
Blue: lipgloss.Color("#6272a4"),
Purple: lipgloss.Color("#bd93f9"),
Border: lipgloss.Color("#44475a"),
TabBg: lipgloss.Color("#282a36"),
}
)
// Themes is the registry of all available themes
var Themes = map[string]Theme{
"dark": ThemeDark,
"light": ThemeLight,
"dracula": ThemeDracula,
}
// activeTheme holds the currently active theme
var activeTheme = ThemeDark
// GetTheme returns a theme by name, defaults to dark
func GetTheme(name string) Theme {
if t, ok := Themes[name]; ok {
return t
}
return ThemeDark
}
// SetTheme applies a theme by name and updates all component styles
func SetTheme(name string) {
theme := GetTheme(name)
activeTheme = theme
applyTheme(theme)
}
// GetActiveTheme returns the currently active theme
func GetActiveTheme() Theme {
return activeTheme
}
// applyTheme updates all component styles from the given theme
func applyTheme(t Theme) {
// Palette aliases
gbFg = t.Fg
gbFgMute = t.FgMute
gbBgSel = t.BgSel
gbRed = t.Red
gbOrange = t.Orange
gbYellow = t.Yellow
gbGreen = t.Green
gbAqua = t.Aqua
gbBlue = t.Blue
gbPurple = t.Purple
gbBorder = t.Border
// Component styles
TabActiveStyle = lipgloss.NewStyle().Background(t.Yellow).Foreground(t.Bg).Bold(true).Padding(0, 2)
TabInactiveStyle = lipgloss.NewStyle().Background(t.Border).Foreground(t.FgMute).Padding(0, 2)
TabBarStyle = lipgloss.NewStyle().Background(t.TabBg)
StatusBarStyle = lipgloss.NewStyle().Background(t.Green).Foreground(t.Bg).Padding(0, 1)
AppTitleStyle = lipgloss.NewStyle().Foreground(t.Yellow).Bold(true)
HighlightStyle = lipgloss.NewStyle().Foreground(t.Orange).Bold(true)
SelectedStyle = lipgloss.NewStyle().Foreground(t.Fg).Background(t.BgSel).Bold(true).Padding(0, 1)
ErrorStyle = lipgloss.NewStyle().Foreground(t.Red).Bold(true)
SuccessStyle = lipgloss.NewStyle().Foreground(t.Green).Bold(true)
InfoStyle = lipgloss.NewStyle().Foreground(t.Aqua)
SubtitleStyle = lipgloss.NewStyle().Foreground(t.FgMute)
HostNameStyle = lipgloss.NewStyle().Foreground(t.Yellow).Bold(true)
HostDetailStyle = lipgloss.NewStyle().Foreground(t.FgMute)
TagStyle = lipgloss.NewStyle().Foreground(t.Green)
TitleStyle = AppTitleStyle
SectionStyle = lipgloss.NewStyle().Foreground(t.Orange).Bold(true)
BorderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(1, 2)
// Pane styles
StylePaneActive = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Green).Padding(0, 1)
StylePaneInactive = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(0, 1)
// Host card styles
HostCardStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(0, 1)
HostCardActiveStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Green).Padding(0, 1)
}
+384
View File
@@ -0,0 +1,384 @@
package tui
import (
"context"
tea "github.com/charmbracelet/bubbletea"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/knownhosts"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
// Screen represents different TUI screens (deprecated, use tabs)
type Screen int
const (
ScreenHostList Screen = iota
ScreenConnection
ScreenSettings
)
// Model represents the main TUI model
type Model struct {
tabs *TabManager
CurrentScreen Screen // deprecated, kept for backward compat
Hosts []*models.Host // deprecated
SelectedIndex int // deprecated
Error error
Quit bool
dataDir string
program *tea.Program
// Security
storagePassword string
knownHosts *knownhosts.KnownHosts
showEncryptPrompt bool
}
// New creates a new TUI model
func New() *Model {
hostList := NewHostListTab()
tm := NewTabManager(hostList)
return &Model{
tabs: tm,
CurrentScreen: ScreenHostList,
SelectedIndex: 0,
Quit: false,
}
}
// Init initializes the TUI
func (m *Model) Init() tea.Cmd {
if m.showEncryptPrompt {
// Show password prompt tab
tab := NewPasswordPromptTab(passwordModeSetup, m.dataDir, func(password string) {
m.storagePassword = password
})
cmd := m.tabs.Add(tab)
m.showEncryptPrompt = false
return cmd
}
return m.tabs.Init()
}
// SetProgram stores a reference to the tea.Program for sending messages from goroutines
func (m *Model) SetProgram(p *tea.Program) {
m.program = p
}
// SetStoragePassword sets the master password for encrypted storage
func (m *Model) SetStoragePassword(password string) {
m.storagePassword = password
}
// SetKnownHosts sets the known_hosts manager for host key verification
func (m *Model) SetKnownHosts(kh *knownhosts.KnownHosts) {
m.knownHosts = kh
}
// ShowEncryptPrompt sets a flag to show the encryption setup prompt on first render
func (m *Model) ShowEncryptPrompt() {
m.showEncryptPrompt = true
}
// Update handles messages and updates the model
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
// Only hard quit on ctrl+c (let tabs handle q)
if msg.String() == "ctrl+c" {
m.Quit = true
return m, tea.Quit
}
case quitMsg:
m.Quit = true
return m, tea.Quit
case sshConnectToMsg:
return m, tea.Batch(tea.ClearScreen, sshConnectCmd(msg.host, m.dataDir))
case sshExitMsg:
if msg.err != nil {
m.Error = msg.err
}
return m, tea.ClearScreen
case openHostFormMsg:
var tab Tab
if msg.editing != nil {
tab = NewEditHostFormTab(msg.editing, m.dataDir)
} else {
tab = NewAddHostFormTab(m.dataDir)
}
cmd := m.tabs.Add(tab)
return m, cmd
case passwordSetMsg:
// Password was set — store it and load hosts
m.storagePassword = msg.password
// Load hosts with the password
store, err := storage.NewJSONStorage(m.dataDir)
if err == nil {
store.SetPassword(msg.password)
ctx := context.Background()
hosts, loadErr := store.ListHosts(ctx)
if loadErr == nil {
m.Hosts = hosts
// Update host list tab if it exists
if hl, ok := m.tabs.Active().(*HostListTab); ok {
hl.SetHosts(hosts)
}
}
}
// Close the password prompt tab
if m.tabs.Len() > 1 {
m.tabs.CloseActive()
}
return m, nil
case openEncryptPromptMsg:
// Show password setup prompt
tab := NewPasswordPromptTab(passwordModeSetup, m.dataDir, func(password string) {
m.storagePassword = password
})
cmd := m.tabs.Add(tab)
return m, cmd
case openSFTPMsg:
tab := NewSFTPBrowserTab(msg.host, m.dataDir)
if m.program != nil {
tab.SetProgram(m.program)
}
if m.storagePassword != "" {
tab.SetStoragePassword(m.storagePassword)
}
if m.knownHosts != nil {
tab.SetPassphraseCallback(func() string {
// TODO: prompt for passphrase in TUI
return ""
})
}
cmd := m.tabs.Add(tab)
return m, cmd
case openKeyListMsg:
tab := NewKeyListTab(m.dataDir)
cmd := m.tabs.Add(tab)
return m, cmd
case openSnippetListMsg:
tab := NewSnippetListTab(m.dataDir)
cmd := m.tabs.Add(tab)
return m, cmd
case openKeyFormMsg:
var tab Tab
if msg.editing != nil {
tab = NewEditKeyFormTab(msg.editing, m.dataDir)
} else {
tab = NewAddKeyFormTab(m.dataDir)
}
cmd := m.tabs.Add(tab)
return m, cmd
case openSnippetFormMsg:
var tab Tab
if msg.editing != nil {
tab = NewEditSnippetFormTab(msg.editing, m.dataDir)
} else {
tab = NewAddSnippetFormTab(m.dataDir)
}
cmd := m.tabs.Add(tab)
return m, cmd
case closeFormMsg:
if m.tabs.Len() > 1 {
m.tabs.CloseActive()
}
return m, nil
case saveHostResultMsg:
if msg.err != nil {
m.Error = msg.err
return m, nil
}
// Close the form tab, switch back to host list
if m.tabs.Len() > 1 {
m.tabs.CloseActive()
}
// Reload hosts
return m, func() tea.Msg {
store, err := storage.NewJSONStorage(m.dataDir)
if err != nil {
return nil
}
hosts, err := store.ListHosts(context.Background())
if err != nil {
return nil
}
return loadedHostsMsg{hosts: hosts}
}
case loadedHostsMsg:
m.LoadHosts(msg.hosts)
case saveKeyResultMsg:
if msg.err != nil {
m.Error = msg.err
return m, nil
}
// Close form tab and switch back to list
if m.tabs.Len() > 1 {
m.tabs.CloseActive()
}
// Reload keys
return m, func() tea.Msg {
store, err := storage.NewJSONStorage(m.dataDir)
if err != nil {
return nil
}
keys, err := store.ListKeyPairs(context.Background())
if err != nil {
return nil
}
return keyListLoadedMsg{keys: keys}
}
case deleteKeyResultMsg:
if msg.err != nil {
m.Error = msg.err
}
// Reload keys
return m, func() tea.Msg {
store, err := storage.NewJSONStorage(m.dataDir)
if err != nil {
return nil
}
keys, err := store.ListKeyPairs(context.Background())
if err != nil {
return nil
}
return keyListLoadedMsg{keys: keys}
}
case keyListLoadedMsg:
if msg.err != nil {
m.Error = msg.err
return m, nil
}
if kt := FindKeyListTab(m.tabs.tabs); kt != nil {
kt.SetKeys(msg.keys)
}
case saveSnippetResultMsg:
if msg.err != nil {
m.Error = msg.err
return m, nil
}
// Close form tab and switch back to list
if m.tabs.Len() > 1 {
m.tabs.CloseActive()
}
// Reload snippets
return m, func() tea.Msg {
store, err := storage.NewJSONStorage(m.dataDir)
if err != nil {
return nil
}
snippets, err := store.ListSnippets(context.Background())
if err != nil {
return nil
}
return snippetListLoadedMsg{snippets: snippets}
}
case deleteSnippetResultMsg:
if msg.err != nil {
m.Error = msg.err
}
// Reload snippets
return m, func() tea.Msg {
store, err := storage.NewJSONStorage(m.dataDir)
if err != nil {
return nil
}
snippets, err := store.ListSnippets(context.Background())
if err != nil {
return nil
}
return snippetListLoadedMsg{snippets: snippets}
}
case snippetListLoadedMsg:
if msg.err != nil {
m.Error = msg.err
return m, nil
}
if st := FindSnippetListTab(m.tabs.tabs); st != nil {
st.SetSnippets(msg.snippets)
}
}
cmd, err := m.tabs.Update(msg)
if err != nil {
m.Error = err
}
// Sync deprecated fields
if ht := FindHostListTab(m.tabs.tabs); ht != nil {
m.Hosts = ht.Hosts()
m.SelectedIndex = ht.SelectedIndex()
}
return m, cmd
}
// View renders the TUI
func (m *Model) View() string {
if m.Quit {
m.tabs = nil
return "Thanks for using hostkeeper!\n"
}
if m.tabs == nil || m.tabs.Len() == 0 {
return "No tabs open. Press 'q' to quit.\n"
}
// Pass error to host list tab for display
if m.Error != nil {
if ht := FindHostListTab(m.tabs.tabs); ht != nil {
ht.err = m.Error
}
m.Error = nil
}
return m.tabs.View()
}
// LoadHosts loads hosts into the TUI model
func (m *Model) LoadHosts(hosts []*models.Host) {
if m.tabs == nil {
return
}
if ht := FindHostListTab(m.tabs.tabs); ht != nil {
ht.SetHosts(hosts)
m.Hosts = hosts
}
}
// SetDataDir sets the data directory for SSH connections
func (m *Model) SetDataDir(dir string) {
m.dataDir = dir
}
// TabManager returns the underlying tab manager
func (m *Model) TabManager() *TabManager {
return m.tabs
}
+119
View File
@@ -0,0 +1,119 @@
package tui
import (
"testing"
)
func TestTUIInitialization(t *testing.T) {
ui := New()
if ui == nil {
t.Fatal("Failed to initialize TUI")
}
if ui.tabs == nil {
t.Fatal("expected tabs manager to be initialized")
}
if ui.tabs.Len() != 1 {
t.Errorf("expected 1 tab, got %d", ui.tabs.Len())
}
if ui.Quit {
t.Error("expected Quit to be false")
}
// Should have a HostListTab by default
ht := FindHostListTab(ui.tabs.tabs)
if ht == nil {
t.Error("expected HostListTab to be the initial tab")
}
}
func TestTUILoadHosts(t *testing.T) {
ui := New()
if ui == nil {
t.Fatal("Failed to initialize TUI")
}
ui.LoadHosts(nil)
if ui.Hosts != nil {
t.Error("expected Hosts to be nil")
}
// Should still have a valid tab manager
if ui.tabs == nil {
t.Fatal("expected tabs manager to be valid")
}
}
func TestTabManagerBasic(t *testing.T) {
tm := NewTabManager(NewHostListTab())
if tm.Len() != 1 {
t.Errorf("expected 1 tab, got %d", tm.Len())
}
if tm.Active() == nil {
t.Fatal("expected active tab")
}
if tm.Active().Name() != "Hosts" {
t.Errorf("expected 'Hosts', got '%s'", tm.Active().Name())
}
}
func TestTabManagerNavigation(t *testing.T) {
tm := NewTabManager(NewHostListTab())
// Add a second tab
second := NewHostListTab()
tm.Add(second)
if tm.Len() != 2 {
t.Errorf("expected 2 tabs, got %d", tm.Len())
}
// Active should now be the last added tab
if tm.active != 1 {
t.Errorf("expected active index 1, got %d", tm.active)
}
// Previous
tm.Prev()
if tm.active != 0 {
t.Errorf("expected active index 0 after Prev, got %d", tm.active)
}
// Next
tm.Next()
if tm.active != 1 {
t.Errorf("expected active index 1 after Next, got %d", tm.active)
}
}
func TestTabManagerClose(t *testing.T) {
tm := NewTabManager(NewHostListTab())
second := NewHostListTab()
tm.Add(second)
tm.Add(NewHostListTab())
// Close active (last tab)
closed := tm.CloseActive()
if closed == nil {
t.Error("expected closed tab to be returned")
}
if tm.Len() != 2 {
t.Errorf("expected 2 tabs after close, got %d", tm.Len())
}
// Close all tabs except last
tm.Close(0)
if tm.Len() != 1 {
t.Errorf("expected 1 tab after close, got %d", tm.Len())
}
// Should not close the last tab via CloseActive (returns nil)
result := tm.CloseActive()
if result != nil {
t.Error("expected nil when trying to close the last tab")
}
}