3 Commits

Author SHA1 Message Date
167e4c0a09 feat: add SSH connection history with Ctrl+H navigation
Track SSH connections (configured + manual) with searchable history view.
Press Ctrl+H to view history, Ctrl+L to return. Add manual connections
to config with 'a'.

- Parse and store manual SSH connections
- History TUI with search and filtering
- Connection count and timestamps
2025-10-15 21:10:38 +02:00
825c534ebe feat(ui): add tabbed forms with height validation
- Implement General/Advanced tabs for add/edit forms
- Add terminal height detection with user-friendly warnings
- Add Ctrl+J/K tab navigation and SSH RemoteCommand/RequestTTY fields
2025-10-13 21:55:08 +02:00
c1457af73a feat: add support for SSH RemoteCommand and RequestTTY in host configuration and TUI forms
- Allow users to specify a RemoteCommand to execute on SSH connection, both via TUI and config file
- Add RequestTTY option (yes, no, force, auto) to host configuration and forms
- Update config parsing and writing to handle new fields
- Improve TUI forms to support editing and adding these options
- Fix edit form standalone mode to allow proper quit/save via keyboard shortcuts
2025-10-12 20:25:20 +02:00
14 changed files with 1887 additions and 180 deletions

View File

@@ -34,7 +34,7 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
- **🔍 Smart Search** - Find hosts quickly with built-in filtering and search - **🔍 Smart Search** - Find hosts quickly with built-in filtering and search
- **📝 Real-time Status** - Live SSH connectivity indicators with asynchronous ping checks and color-coded status - **📝 Real-time Status** - Live SSH connectivity indicators with asynchronous ping checks and color-coded status
- **🔔 Smart Updates** - Automatic version checking with update notifications - **🔔 Smart Updates** - Automatic version checking with update notifications
- **📈 Connection History** - Track your SSH connections with last login timestamps - **📈 Connection History** - Track both configured and manual SSH connections with timestamps and usage counts
### 🛠️ **Technical Features** ### 🛠️ **Technical Features**
- **🔒 Secure** - Works directly with your existing `~/.ssh/config` file - **🔒 Secure** - Works directly with your existing `~/.ssh/config` file
@@ -106,6 +106,7 @@ sshm
- `d` - Delete selected host - `d` - Delete selected host
- `m` - Move host to another config file (requires SSH Include directives) - `m` - Move host to another config file (requires SSH Include directives)
- `f` - Port forwarding setup - `f` - Port forwarding setup
- `Ctrl+H` - Switch to connection history view
- `q` - Quit - `q` - Quit
- `/` - Search/filter hosts - `/` - Search/filter hosts
@@ -285,6 +286,47 @@ sshm web-01
- **Error handling** - Clear messages if host doesn't exist or configuration issues - **Error handling** - Clear messages if host doesn't exist or configuration issues
- **Config file support** - Works with custom config files using `-c` flag - **Config file support** - Works with custom config files using `-c` flag
### Connection History
SSHM automatically tracks all your SSH connections, including both configured hosts and manual connections made outside of SSHM.
**Access History:**
Press `Ctrl+H` from the main interface to switch to the history view. Press `Ctrl+L` to return to the main host list.
**Features:**
- **Automatic tracking** - Records all SSH connections with timestamps and connection counts
- **Manual connection detection** - Captures `ssh user@host -p port -i key` commands made in your terminal
- **Visual indicators** - Manual connections (not in your SSH config) are marked with ★
- **Search & filter** - Find connections quickly using the search bar
- **Add to config** - Press `a` on any manual connection (★) to add it to your SSH config
- **Persistent storage** - History is saved in `~/.config/sshm/sshm_history.json`
**Tracked Information:**
- Host name or hostname for manual connections
- Username and hostname
- Port number
- Last connection timestamp
- Total connection count
**Use Cases:**
- Review your recent SSH activity
- Find frequently used manual connections
- Promote manual connections to permanent SSH config entries
- Track when you last connected to a host
**Example Workflow:**
```bash
# Make a manual SSH connection
ssh deploy@192.168.1.100 -p 2222 -i ~/.ssh/custom_key
# Launch SSHM and press Ctrl+H to view history
sshm
# Press Ctrl+H → see the manual connection with ★ indicator
# Press 'a' to add it to your SSH config
# Give it a name like "deploy-server" and save
# Press Ctrl+L to return to main list → now it's a configured host
```
### Backup Configuration ### Backup Configuration
SSHM automatically creates backups of your SSH configuration files before making any changes to ensure your configurations are safe. SSHM automatically creates backups of your SSH configuration files before making any changes to ensure your configurations are safe.

View File

@@ -49,6 +49,7 @@ Hosts are read from your ~/.ssh/config file by default.`,
} }
// If a host name is provided, connect directly // If a host name is provided, connect directly
// (manual SSH commands are handled in Execute() before reaching here)
hostName := args[0] hostName := args[0]
connectToHost(hostName) connectToHost(hostName)
return nil return nil
@@ -140,11 +141,84 @@ func connectToHost(hostName string) {
fmt.Printf("Connecting to %s...\n", hostName) fmt.Printf("Connecting to %s...\n", hostName)
var sshCmd *exec.Cmd var sshCmd *exec.Cmd
var args []string
if configFile != "" { if configFile != "" {
sshCmd = exec.Command("ssh", "-F", configFile, hostName) args = append(args, "-F", configFile)
} else {
sshCmd = exec.Command("ssh", hostName)
} }
args = append(args, hostName)
// Note: We don't add RemoteCommand here because if it's configured in SSH config,
// SSH will handle it automatically. Adding it as a command line argument would conflict.
sshCmd = exec.Command("ssh", args...)
// Set up the command to use the same stdin, stdout, and stderr as the parent process
sshCmd.Stdin = os.Stdin
sshCmd.Stdout = os.Stdout
sshCmd.Stderr = os.Stderr
// Execute the SSH command
err = sshCmd.Run()
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
// SSH command failed, exit with the same code
if status, ok := exitError.Sys().(syscall.WaitStatus); ok {
os.Exit(status.ExitStatus())
}
}
fmt.Printf("Error executing SSH command: %v\n", err)
os.Exit(1)
}
}
// connectManualSSH handles manual SSH connections like: sshm -p 2222 user@host
func connectManualSSH(args []string) {
// Parse the manual connection arguments
conn, ok := history.ParseSSHArgs(args)
if !ok || conn.Hostname == "" {
fmt.Println("Error: Invalid SSH connection arguments")
fmt.Println("Usage: sshm [-p port] [-i identity] [user@]hostname")
os.Exit(1)
}
// Record the manual connection in history
historyManager, err := history.NewHistoryManager()
if err != nil {
// Log the error but don't prevent the connection
fmt.Printf("Warning: Could not initialize connection history: %v\n", err)
} else {
err = historyManager.RecordManualConnection(*conn)
if err != nil {
// Log the error but don't prevent the connection
fmt.Printf("Warning: Could not record connection history: %v\n", err)
}
}
// Build and execute the SSH command
fmt.Printf("Connecting to %s@%s:%s...\n", conn.User, conn.Hostname, conn.Port)
// Build SSH arguments
var sshArgs []string
// Add port if not default
if conn.Port != "" && conn.Port != "22" {
sshArgs = append(sshArgs, "-p", conn.Port)
}
// Add identity file if specified
if conn.Identity != "" {
sshArgs = append(sshArgs, "-i", conn.Identity)
}
// Add user@host or just host
if conn.User != "" {
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", conn.User, conn.Hostname))
} else {
sshArgs = append(sshArgs, conn.Hostname)
}
sshCmd := exec.Command("ssh", sshArgs...)
// Set up the command to use the same stdin, stdout, and stderr as the parent process // Set up the command to use the same stdin, stdout, and stderr as the parent process
sshCmd.Stdin = os.Stdin sshCmd.Stdin = os.Stdin
@@ -191,6 +265,29 @@ func getVersionWithUpdateCheck() string {
// Execute adds all child commands to the root command and sets flags appropriately. // Execute adds all child commands to the root command and sets flags appropriately.
func Execute() { func Execute() {
// Check if this looks like a manual SSH command BEFORE Cobra parses flags
// This prevents Cobra from complaining about unknown flags like -p, -i, etc.
if len(os.Args) > 1 {
// Check if any argument looks like a manual SSH connection
args := os.Args[1:]
// Skip if it's a known subcommand
knownCommands := []string{"add", "edit", "search", "move", "help", "completion", "version", "--version", "-v"}
isSubcommand := false
for _, cmd := range knownCommands {
if args[0] == cmd {
isSubcommand = true
break
}
}
// If not a subcommand and looks like manual SSH, handle it directly
if !isSubcommand && history.IsManualSSHCommand(args) {
connectManualSSH(args)
return
}
}
// Custom error handling for unknown commands that might be host names // Custom error handling for unknown commands that might be host names
if err := RootCmd.Execute(); err != nil { if err := RootCmd.Execute(); err != nil {
// Check if this is an "unknown command" error and the argument might be a host name // Check if this is an "unknown command" error and the argument might be a host name

View File

@@ -13,15 +13,17 @@ import (
// SSHHost represents an SSH host configuration // SSHHost represents an SSH host configuration
type SSHHost struct { type SSHHost struct {
Name string Name string
Hostname string Hostname string
User string User string
Port string Port string
Identity string Identity string
ProxyJump string ProxyJump string
Options string Options string
Tags []string RemoteCommand string // Command to execute after SSH connection
SourceFile string // Path to the config file where this host is defined RequestTTY string // Request TTY (yes, no, force, auto)
Tags []string
SourceFile string // Path to the config file where this host is defined
// Temporary field to handle multiple aliases during parsing // Temporary field to handle multiple aliases during parsing
aliasNames []string `json:"-"` // Do not serialize this field aliasNames []string `json:"-"` // Do not serialize this field
@@ -326,6 +328,14 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
if currentHost != nil { if currentHost != nil {
currentHost.ProxyJump = value currentHost.ProxyJump = value
} }
case "remotecommand":
if currentHost != nil {
currentHost.RemoteCommand = value
}
case "requesttty":
if currentHost != nil {
currentHost.RequestTTY = value
}
default: default:
// Handle other SSH options // Handle other SSH options
if currentHost != nil && strings.TrimSpace(line) != "" { if currentHost != nil && strings.TrimSpace(line) != "" {
@@ -603,6 +613,20 @@ func AddSSHHostToFile(host SSHHost, configPath string) error {
} }
} }
if host.RemoteCommand != "" {
_, err = file.WriteString(fmt.Sprintf(" RemoteCommand %s\n", host.RemoteCommand))
if err != nil {
return err
}
}
if host.RequestTTY != "" {
_, err = file.WriteString(fmt.Sprintf(" RequestTTY %s\n", host.RequestTTY))
if err != nil {
return err
}
}
// Write SSH options // Write SSH options
if host.Options != "" { if host.Options != "" {
// Split options by newlines and write each one // Split options by newlines and write each one
@@ -1020,6 +1044,12 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" { if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
} }
if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
}
if newHost.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+newHost.RequestTTY)
}
// Write SSH options // Write SSH options
if newHost.Options != "" { if newHost.Options != "" {
options := strings.Split(newHost.Options, "\n") options := strings.Split(newHost.Options, "\n")
@@ -1068,6 +1098,12 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" { if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
} }
if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
}
if newHost.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+newHost.RequestTTY)
}
// Write SSH options // Write SSH options
if newHost.Options != "" { if newHost.Options != "" {
options := strings.Split(newHost.Options, "\n") options := strings.Split(newHost.Options, "\n")
@@ -1152,6 +1188,12 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" { if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
} }
if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
}
if newHost.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+newHost.RequestTTY)
}
// Write SSH options // Write SSH options
if newHost.Options != "" { if newHost.Options != "" {
options := strings.Split(newHost.Options, "\n") options := strings.Split(newHost.Options, "\n")
@@ -1200,6 +1242,12 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" { if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
} }
if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
}
if newHost.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+newHost.RequestTTY)
}
// Write SSH options // Write SSH options
if newHost.Options != "" { if newHost.Options != "" {
options := strings.Split(newHost.Options, "\n") options := strings.Split(newHost.Options, "\n")
@@ -1694,6 +1742,12 @@ func UpdateMultiHostBlock(originalHosts, newHosts []string, commonProperties SSH
if commonProperties.ProxyJump != "" { if commonProperties.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump) newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump)
} }
if commonProperties.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+commonProperties.RemoteCommand)
}
if commonProperties.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+commonProperties.RequestTTY)
}
// Write SSH options // Write SSH options
if commonProperties.Options != "" { if commonProperties.Options != "" {
@@ -1774,6 +1828,12 @@ func UpdateMultiHostBlock(originalHosts, newHosts []string, commonProperties SSH
if commonProperties.ProxyJump != "" { if commonProperties.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump) newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump)
} }
if commonProperties.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+commonProperties.RemoteCommand)
}
if commonProperties.RequestTTY != "" {
newLines = append(newLines, " RequestTTY "+commonProperties.RequestTTY)
}
// Write SSH options // Write SSH options
if commonProperties.Options != "" { if commonProperties.Options != "" {

View File

@@ -2,6 +2,7 @@ package history
import ( import (
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -306,3 +307,99 @@ func (hm *HistoryManager) GetPortForwardingConfig(hostName string) *PortForwardC
} }
return nil return nil
} }
// ManualConnection represents a manual SSH connection (e.g., ssh user@host -p 2222)
type ManualConnection struct {
User string
Hostname string
Port string
Identity string
}
// RecordManualConnection records a manual SSH connection (like ssh user@host -p 2222 -i key)
// These are stored with a generated host name like "manual:user@host:port"
func (hm *HistoryManager) RecordManualConnection(conn ManualConnection) error {
// Generate a unique identifier for this manual connection
hostID := generateManualHostID(conn)
now := time.Now()
if existingConn, exists := hm.history.Connections[hostID]; exists {
// Update existing connection
existingConn.LastConnect = now
existingConn.ConnectCount++
hm.history.Connections[hostID] = existingConn
} else {
// Create new connection record
hm.history.Connections[hostID] = ConnectionInfo{
HostName: hostID,
LastConnect: now,
ConnectCount: 1,
}
}
return hm.saveHistory()
}
// generateManualHostID generates a unique ID for manual connections
func generateManualHostID(conn ManualConnection) string {
// Format: manual:user@hostname:port
user := conn.User
if user == "" {
user = "default"
}
port := conn.Port
if port == "" {
port = "22"
}
return fmt.Sprintf("manual:%s@%s:%s", user, conn.Hostname, port)
}
// IsManualConnection checks if a hostname represents a manual connection
func IsManualConnection(hostName string) bool {
return len(hostName) > 7 && hostName[:7] == "manual:"
}
// ParseManualConnectionID parses a manual connection ID back into its components
func ParseManualConnectionID(hostID string) (user, hostname, port string, ok bool) {
if !IsManualConnection(hostID) {
return "", "", "", false
}
// Remove "manual:" prefix
parts := hostID[7:] // Skip "manual:"
// Split by last ':'
lastColon := -1
for i := len(parts) - 1; i >= 0; i-- {
if parts[i] == ':' {
lastColon = i
break
}
}
if lastColon == -1 {
return "", "", "", false
}
port = parts[lastColon+1:]
userHost := parts[:lastColon]
// Split user@host
atSign := -1
for i := 0; i < len(userHost); i++ {
if userHost[i] == '@' {
atSign = i
break
}
}
if atSign == -1 {
return "", "", "", false
}
user = userHost[:atSign]
hostname = userHost[atSign+1:]
return user, hostname, port, true
}

View File

@@ -0,0 +1,95 @@
package history
import (
"os/user"
"strings"
)
// ParseSSHArgs parses SSH command line arguments and extracts connection details
// It handles formats like: user@host, -p port, -i identity, etc.
func ParseSSHArgs(args []string) (*ManualConnection, bool) {
if len(args) == 0 {
return nil, false
}
conn := &ManualConnection{
Port: "22", // Default SSH port
}
// Get current user as default
currentUser, err := user.Current()
if err == nil {
conn.User = currentUser.Username
}
// Parse arguments
for i := 0; i < len(args); i++ {
arg := args[i]
// Handle -p <port> or -p<port>
if arg == "-p" {
if i+1 < len(args) {
conn.Port = args[i+1]
i++
}
} else if strings.HasPrefix(arg, "-p") {
conn.Port = arg[2:]
} else if arg == "-i" {
// Handle -i <identity>
if i+1 < len(args) {
conn.Identity = args[i+1]
i++
}
} else if arg == "-F" || arg == "-c" || arg == "--config" {
// Skip config file arguments - these are handled separately
if i+1 < len(args) {
i++
}
return nil, false
} else if strings.HasPrefix(arg, "-") {
// Skip other SSH options like -v, -A, -X, etc.
// If they have a value, skip it too
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
i++
}
continue
} else if strings.Contains(arg, "@") {
// Parse user@hostname
parts := strings.SplitN(arg, "@", 2)
if len(parts) == 2 {
conn.User = parts[0]
conn.Hostname = parts[1]
}
} else if conn.Hostname == "" {
// If no @, treat as just hostname
conn.Hostname = arg
}
}
// If we got a hostname, this is a valid manual connection
if conn.Hostname != "" {
return conn, true
}
return nil, false
}
// IsManualSSHCommand checks if the arguments represent a manual SSH connection
// (not a configured host name)
func IsManualSSHCommand(args []string) bool {
if len(args) == 0 {
return false
}
// Check for SSH flags that indicate manual connection
for _, arg := range args {
if arg == "-p" || strings.HasPrefix(arg, "-p") {
return true
}
if strings.Contains(arg, "@") {
return true
}
}
return false
}

View File

@@ -0,0 +1,277 @@
package history
import (
"testing"
)
func TestParseSSHArgs(t *testing.T) {
tests := []struct {
name string
args []string
wantConn *ManualConnection
wantOk bool
}{
{
name: "user@host",
args: []string{"user@example.com"},
wantConn: &ManualConnection{
User: "user",
Hostname: "example.com",
Port: "22",
},
wantOk: true,
},
{
name: "user@host with -p port",
args: []string{"-p", "2222", "user@example.com"},
wantConn: &ManualConnection{
User: "user",
Hostname: "example.com",
Port: "2222",
},
wantOk: true,
},
{
name: "user@host with -p2222 (no space)",
args: []string{"-p2222", "user@example.com"},
wantConn: &ManualConnection{
User: "user",
Hostname: "example.com",
Port: "2222",
},
wantOk: true,
},
{
name: "user@host with -i identity",
args: []string{"-i", "~/.ssh/id_rsa", "user@example.com"},
wantConn: &ManualConnection{
User: "user",
Hostname: "example.com",
Port: "22",
Identity: "~/.ssh/id_rsa",
},
wantOk: true,
},
{
name: "complete connection",
args: []string{"-p", "2222", "-i", "~/.ssh/id_rsa", "guillaume@127.0.0.1"},
wantConn: &ManualConnection{
User: "guillaume",
Hostname: "127.0.0.1",
Port: "2222",
Identity: "~/.ssh/id_rsa",
},
wantOk: true,
},
{
name: "just hostname (no user)",
args: []string{"example.com"},
wantConn: &ManualConnection{
Hostname: "example.com",
Port: "22",
// User will be current system user, so we don't check it
},
wantOk: true,
},
{
name: "config file args should return false",
args: []string{"-F", "~/.ssh/config", "host"},
wantConn: nil,
wantOk: false,
},
{
name: "empty args",
args: []string{},
wantConn: nil,
wantOk: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotConn, gotOk := ParseSSHArgs(tt.args)
if gotOk != tt.wantOk {
t.Errorf("ParseSSHArgs() gotOk = %v, want %v", gotOk, tt.wantOk)
return
}
if !tt.wantOk {
if gotConn != nil {
t.Errorf("ParseSSHArgs() gotConn = %v, want nil", gotConn)
}
return
}
if gotConn == nil {
t.Errorf("ParseSSHArgs() gotConn = nil, want non-nil")
return
}
if gotConn.User != tt.wantConn.User {
// Skip user check if wantConn.User is empty (current user)
if tt.wantConn.User != "" {
t.Errorf("ParseSSHArgs() User = %v, want %v", gotConn.User, tt.wantConn.User)
}
}
if gotConn.Hostname != tt.wantConn.Hostname {
t.Errorf("ParseSSHArgs() Hostname = %v, want %v", gotConn.Hostname, tt.wantConn.Hostname)
}
if gotConn.Port != tt.wantConn.Port {
t.Errorf("ParseSSHArgs() Port = %v, want %v", gotConn.Port, tt.wantConn.Port)
}
if gotConn.Identity != tt.wantConn.Identity {
t.Errorf("ParseSSHArgs() Identity = %v, want %v", gotConn.Identity, tt.wantConn.Identity)
}
})
}
}
func TestIsManualSSHCommand(t *testing.T) {
tests := []struct {
name string
args []string
want bool
}{
{
name: "user@host is manual",
args: []string{"user@example.com"},
want: true,
},
{
name: "with -p flag is manual",
args: []string{"-p", "2222", "host"},
want: true,
},
{
name: "with -p2222 is manual",
args: []string{"-p2222", "host"},
want: true,
},
{
name: "just hostname is not manual",
args: []string{"myhost"},
want: false,
},
{
name: "empty is not manual",
args: []string{},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsManualSSHCommand(tt.args); got != tt.want {
t.Errorf("IsManualSSHCommand() = %v, want %v", got, tt.want)
}
})
}
}
func TestManualConnectionID(t *testing.T) {
tests := []struct {
name string
conn ManualConnection
wantHostID string
wantUser string
wantHostname string
wantPort string
}{
{
name: "complete connection",
conn: ManualConnection{
User: "guillaume",
Hostname: "127.0.0.1",
Port: "2222",
Identity: "~/.ssh/id_rsa",
},
wantHostID: "manual:guillaume@127.0.0.1:2222",
wantUser: "guillaume",
wantHostname: "127.0.0.1",
wantPort: "2222",
},
{
name: "default port",
conn: ManualConnection{
User: "user",
Hostname: "example.com",
Port: "",
},
wantHostID: "manual:user@example.com:22",
wantUser: "user",
wantHostname: "example.com",
wantPort: "22",
},
{
name: "no user specified",
conn: ManualConnection{
Hostname: "example.com",
Port: "2222",
},
wantHostID: "manual:default@example.com:2222",
wantUser: "default",
wantHostname: "example.com",
wantPort: "2222",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test generation
gotHostID := generateManualHostID(tt.conn)
if gotHostID != tt.wantHostID {
t.Errorf("generateManualHostID() = %v, want %v", gotHostID, tt.wantHostID)
}
// Test IsManualConnection
if !IsManualConnection(gotHostID) {
t.Errorf("IsManualConnection(%v) = false, want true", gotHostID)
}
// Test parsing
user, hostname, port, ok := ParseManualConnectionID(gotHostID)
if !ok {
t.Errorf("ParseManualConnectionID() ok = false, want true")
}
if user != tt.wantUser {
t.Errorf("ParseManualConnectionID() user = %v, want %v", user, tt.wantUser)
}
if hostname != tt.wantHostname {
t.Errorf("ParseManualConnectionID() hostname = %v, want %v", hostname, tt.wantHostname)
}
if port != tt.wantPort {
t.Errorf("ParseManualConnectionID() port = %v, want %v", port, tt.wantPort)
}
})
}
}
func TestParseManualConnectionID_Invalid(t *testing.T) {
tests := []struct {
name string
hostID string
}{
{
name: "not a manual connection",
hostID: "myhost",
},
{
name: "missing components",
hostID: "manual:invalid",
},
{
name: "no @ sign",
hostID: "manual:hostname:22",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, _, ok := ParseManualConnectionID(tt.hostID)
if ok {
t.Errorf("ParseManualConnectionID() ok = true, want false for invalid input")
}
})
}
}

View File

@@ -1,6 +1,7 @@
package ui package ui
import ( import (
"fmt"
"os" "os"
"os/user" "os/user"
"path/filepath" "path/filepath"
@@ -16,6 +17,7 @@ import (
type addFormModel struct { type addFormModel struct {
inputs []textinput.Model inputs []textinput.Model
focused int focused int
currentTab int // 0 = General, 1 = Advanced
err string err string
styles Styles styles Styles
success bool success bool
@@ -47,7 +49,7 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
} }
} }
inputs := make([]textinput.Model, 8) inputs := make([]textinput.Model, 10) // Increased from 9 to 10 for RequestTTY
// Name input // Name input
inputs[nameInput] = textinput.New() inputs[nameInput] = textinput.New()
@@ -101,9 +103,22 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
inputs[tagsInput].CharLimit = 200 inputs[tagsInput].CharLimit = 200
inputs[tagsInput].Width = 50 inputs[tagsInput].Width = 50
// Remote Command input
inputs[remoteCommandInput] = textinput.New()
inputs[remoteCommandInput].Placeholder = "ls -la, htop, bash"
inputs[remoteCommandInput].CharLimit = 300
inputs[remoteCommandInput].Width = 70
// RequestTTY input
inputs[requestTTYInput] = textinput.New()
inputs[requestTTYInput].Placeholder = "yes, no, force, auto"
inputs[requestTTYInput].CharLimit = 10
inputs[requestTTYInput].Width = 30
return &addFormModel{ return &addFormModel{
inputs: inputs, inputs: inputs,
focused: nameInput, focused: nameInput,
currentTab: tabGeneral, // Start on General tab
styles: styles, styles: styles,
width: width, width: width,
height: height, height: height,
@@ -111,6 +126,11 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
} }
} }
const (
tabGeneral = iota
tabAdvanced
)
const ( const (
nameInput = iota nameInput = iota
hostnameInput hostnameInput
@@ -118,8 +138,11 @@ const (
portInput portInput
identityInput identityInput
proxyJumpInput proxyJumpInput
optionsInput
tagsInput tagsInput
// Advanced tab inputs
optionsInput
remoteCommandInput
requestTTYInput
) )
// Messages for communication with parent model // Messages for communication with parent model
@@ -153,36 +176,20 @@ func (m *addFormModel) Update(msg tea.Msg) (*addFormModel, tea.Cmd) {
// Allow submission from any field with Ctrl+S (Save) // Allow submission from any field with Ctrl+S (Save)
return m, m.submitForm() return m, m.submitForm()
case "ctrl+j":
// Switch to next tab
m.currentTab = (m.currentTab + 1) % 2
m.focused = m.getFirstInputForTab(m.currentTab)
return m, m.updateFocus()
case "ctrl+k":
// Switch to previous tab
m.currentTab = (m.currentTab - 1 + 2) % 2
m.focused = m.getFirstInputForTab(m.currentTab)
return m, m.updateFocus()
case "tab", "shift+tab", "enter", "up", "down": case "tab", "shift+tab", "enter", "up", "down":
s := msg.String() return m, m.handleNavigation(msg.String())
// Handle form submission
if s == "enter" && m.focused == len(m.inputs)-1 {
return m, m.submitForm()
}
// Cycle inputs
if s == "up" || s == "shift+tab" {
m.focused--
} else {
m.focused++
}
if m.focused > len(m.inputs)-1 {
m.focused = 0
} else if m.focused < 0 {
m.focused = len(m.inputs) - 1
}
for i := range m.inputs {
if i == m.focused {
cmds = append(cmds, m.inputs[i].Focus())
continue
}
m.inputs[i].Blur()
}
return m, tea.Batch(cmds...)
} }
case addFormSubmitMsg: case addFormSubmitMsg:
@@ -206,32 +213,104 @@ func (m *addFormModel) Update(msg tea.Msg) (*addFormModel, tea.Cmd) {
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
// getFirstInputForTab returns the first input index for a given tab
func (m *addFormModel) getFirstInputForTab(tab int) int {
switch tab {
case tabGeneral:
return nameInput
case tabAdvanced:
return optionsInput
default:
return nameInput
}
}
// getInputsForCurrentTab returns the input indices for the current tab
func (m *addFormModel) getInputsForCurrentTab() []int {
switch m.currentTab {
case tabGeneral:
return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, tagsInput}
case tabAdvanced:
return []int{optionsInput, remoteCommandInput, requestTTYInput}
default:
return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, tagsInput}
}
}
// updateFocus updates focus for inputs
func (m *addFormModel) updateFocus() tea.Cmd {
var cmds []tea.Cmd
for i := range m.inputs {
if i == m.focused {
cmds = append(cmds, m.inputs[i].Focus())
} else {
m.inputs[i].Blur()
}
}
return tea.Batch(cmds...)
}
// handleNavigation handles tab/arrow navigation within the current tab
func (m *addFormModel) handleNavigation(key string) tea.Cmd {
currentTabInputs := m.getInputsForCurrentTab()
// Find current position within the tab
currentPos := 0
for i, input := range currentTabInputs {
if input == m.focused {
currentPos = i
break
}
}
// Handle form submission on last field of Advanced tab
if key == "enter" && m.currentTab == tabAdvanced && currentPos == len(currentTabInputs)-1 {
return m.submitForm()
}
// Navigate within current tab
if key == "up" || key == "shift+tab" {
currentPos--
} else {
currentPos++
}
// Wrap around within current tab
if currentPos >= len(currentTabInputs) {
currentPos = 0
} else if currentPos < 0 {
currentPos = len(currentTabInputs) - 1
}
m.focused = currentTabInputs[currentPos]
return m.updateFocus()
}
func (m *addFormModel) View() string { func (m *addFormModel) View() string {
if m.success { if m.success {
return "" return ""
} }
// Check if terminal height is sufficient
if !m.isHeightSufficient() {
return m.renderHeightWarning()
}
var b strings.Builder var b strings.Builder
b.WriteString(m.styles.FormTitle.Render("Add SSH Host Configuration")) b.WriteString(m.styles.FormTitle.Render("Add SSH Host Configuration"))
b.WriteString("\n\n") b.WriteString("\n\n")
fields := []string{ // Render tabs
"Host Name *", b.WriteString(m.renderTabs())
"Hostname/IP *", b.WriteString("\n\n")
"User",
"Port",
"Identity File",
"ProxyJump",
"SSH Options",
"Tags (comma-separated)",
}
for i, field := range fields { // Render current tab content
b.WriteString(m.styles.FormField.Render(field)) switch m.currentTab {
b.WriteString("\n") case tabGeneral:
b.WriteString(m.inputs[i].View()) b.WriteString(m.renderGeneralTab())
b.WriteString("\n\n") case tabAdvanced:
b.WriteString(m.renderAdvancedTab())
} }
if m.err != "" { if m.err != "" {
@@ -239,13 +318,133 @@ func (m *addFormModel) View() string {
b.WriteString("\n\n") b.WriteString("\n\n")
} }
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+S: save • Ctrl+C/Esc: cancel")) // Help text
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Ctrl+J/K: switch tabs"))
b.WriteString("\n")
b.WriteString(m.styles.FormHelp.Render("Enter on last field: submit • Ctrl+S: save • Ctrl+C/Esc: cancel"))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(m.styles.FormHelp.Render("* Required fields")) b.WriteString(m.styles.FormHelp.Render("* Required fields"))
return b.String() return b.String()
} }
// getMinimumHeight calculates the minimum height needed to display the form
func (m *addFormModel) getMinimumHeight() int {
// Title: 1 line + 2 newlines = 3
titleLines := 3
// Tabs: 1 line + 2 newlines = 3
tabLines := 3
// Fields in current tab
var fieldsCount int
if m.currentTab == tabGeneral {
fieldsCount = 7 // 7 fields in general tab
} else {
fieldsCount = 3 // 3 fields in advanced tab
}
// Each field: label (1) + input (1) + spacing (2) = 4 lines per field, but let's be more conservative
fieldsLines := fieldsCount * 3 // Reduced from 4 to 3
// Help text: 3 lines
helpLines := 3
// Error message space when needed: 2 lines
errorLines := 0 // Only count when there's actually an error
if m.err != "" {
errorLines = 2
}
return titleLines + tabLines + fieldsLines + helpLines + errorLines + 1 // +1 minimal safety margin
}
// isHeightSufficient checks if the current terminal height is sufficient
func (m *addFormModel) isHeightSufficient() bool {
return m.height >= m.getMinimumHeight()
}
// renderHeightWarning renders a warning message when height is insufficient
func (m *addFormModel) renderHeightWarning() string {
required := m.getMinimumHeight()
current := m.height
warning := m.styles.ErrorText.Render("⚠️ Terminal height is too small!")
details := m.styles.FormField.Render(fmt.Sprintf("Current: %d lines, Required: %d lines", current, required))
instruction := m.styles.FormHelp.Render("Please resize your terminal window and try again.")
instruction2 := m.styles.FormHelp.Render("Press Ctrl+C to cancel or resize terminal window.")
return warning + "\n\n" + details + "\n\n" + instruction + "\n" + instruction2
}
// renderTabs renders the tab headers
func (m *addFormModel) renderTabs() string {
var generalTab, advancedTab string
if m.currentTab == tabGeneral {
generalTab = m.styles.FocusedLabel.Render("[ General ]")
advancedTab = m.styles.FormField.Render(" Advanced ")
} else {
generalTab = m.styles.FormField.Render(" General ")
advancedTab = m.styles.FocusedLabel.Render("[ Advanced ]")
}
return generalTab + " " + advancedTab
}
// renderGeneralTab renders the general tab content
func (m *addFormModel) renderGeneralTab() string {
var b strings.Builder
fields := []struct {
index int
label string
}{
{nameInput, "Host Name *"},
{hostnameInput, "Hostname/IP *"},
{userInput, "User"},
{portInput, "Port"},
{identityInput, "Identity File"},
{proxyJumpInput, "ProxyJump"},
{tagsInput, "Tags (comma-separated)"},
}
for _, field := range fields {
fieldStyle := m.styles.FormField
if m.focused == field.index {
fieldStyle = m.styles.FocusedLabel
}
b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n")
b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n")
}
return b.String()
}
// renderAdvancedTab renders the advanced tab content
func (m *addFormModel) renderAdvancedTab() string {
var b strings.Builder
fields := []struct {
index int
label string
}{
{optionsInput, "SSH Options"},
{remoteCommandInput, "Remote Command"},
{requestTTYInput, "Request TTY"},
}
for _, field := range fields {
fieldStyle := m.styles.FormField
if m.focused == field.index {
fieldStyle = m.styles.FocusedLabel
}
b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n")
b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n")
}
return b.String()
}
// Standalone wrapper for add form // Standalone wrapper for add form
type standaloneAddForm struct { type standaloneAddForm struct {
*addFormModel *addFormModel
@@ -291,6 +490,8 @@ func (m *addFormModel) submitForm() tea.Cmd {
identity := strings.TrimSpace(m.inputs[identityInput].Value()) identity := strings.TrimSpace(m.inputs[identityInput].Value())
proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value()) proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value())
options := strings.TrimSpace(m.inputs[optionsInput].Value()) options := strings.TrimSpace(m.inputs[optionsInput].Value())
remoteCommand := strings.TrimSpace(m.inputs[remoteCommandInput].Value())
requestTTY := strings.TrimSpace(m.inputs[requestTTYInput].Value())
// Set defaults // Set defaults
if user == "" { if user == "" {
@@ -319,14 +520,16 @@ func (m *addFormModel) submitForm() tea.Cmd {
// Create host configuration // Create host configuration
host := config.SSHHost{ host := config.SSHHost{
Name: name, Name: name,
Hostname: hostname, Hostname: hostname,
User: user, User: user,
Port: port, Port: port,
Identity: identity, Identity: identity,
ProxyJump: proxyJump, ProxyJump: proxyJump,
Options: config.ParseSSHOptionsFromCommand(options), Options: config.ParseSSHOptionsFromCommand(options),
Tags: tags, RemoteCommand: remoteCommand,
RequestTTY: requestTTY,
Tags: tags,
} }
// Add to config // Add to config

View File

@@ -9,7 +9,6 @@ import (
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
) )
const ( const (
@@ -29,8 +28,8 @@ type editFormModel struct {
inputs []textinput.Model inputs []textinput.Model
focusArea int // 0=hosts, 1=properties focusArea int // 0=hosts, 1=properties
focused int focused int
currentTab int // 0=General, 1=Advanced (only applies when focusArea == focusAreaProperties)
err string err string
success bool
styles Styles styles Styles
originalName string originalName string
originalHosts []string // Store original host names for multi-host detection originalHosts []string // Store original host names for multi-host detection
@@ -92,7 +91,7 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
} }
} }
inputs := make([]textinput.Model, 7) // Reduced from 8 since we removed nameInput inputs := make([]textinput.Model, 9) // Increased from 8 to 9 for RequestTTY
// Hostname input // Hostname input
inputs[0] = textinput.New() inputs[0] = textinput.New()
@@ -147,11 +146,26 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
inputs[6].SetValue(strings.Join(host.Tags, ", ")) inputs[6].SetValue(strings.Join(host.Tags, ", "))
} }
// Remote Command input
inputs[7] = textinput.New()
inputs[7].Placeholder = "ls -la, htop, bash"
inputs[7].CharLimit = 300
inputs[7].Width = 70
inputs[7].SetValue(host.RemoteCommand)
// RequestTTY input
inputs[8] = textinput.New()
inputs[8].Placeholder = "yes, no, force, auto"
inputs[8].CharLimit = 10
inputs[8].Width = 30
inputs[8].SetValue(host.RequestTTY)
return &editFormModel{ return &editFormModel{
hostInputs: hostInputs, hostInputs: hostInputs,
inputs: inputs, inputs: inputs,
focusArea: focusAreaHosts, // Start with hosts focused for multi-host editing focusArea: focusAreaHosts, // Start with hosts focused for multi-host editing
focused: 0, focused: 0,
currentTab: 0, // Start on General tab
originalName: hostName, originalName: hostName,
originalHosts: hostNames, originalHosts: hostNames,
host: host, host: host,
@@ -235,6 +249,157 @@ func (m *editFormModel) updateFocus() tea.Cmd {
return textinput.Blink return textinput.Blink
} }
// getPropertiesForCurrentTab returns the property input indices for the current tab
func (m *editFormModel) getPropertiesForCurrentTab() []int {
switch m.currentTab {
case 0: // General
return []int{0, 1, 2, 3, 4, 6} // hostname, user, port, identity, proxyjump, tags
case 1: // Advanced
return []int{5, 7, 8} // options, remotecommand, requesttty
default:
return []int{0, 1, 2, 3, 4, 6}
}
}
// getFirstPropertyForTab returns the first property index for a given tab
func (m *editFormModel) getFirstPropertyForTab(tab int) int {
properties := []int{0, 1, 2, 3, 4, 6} // General tab
if tab == 1 {
properties = []int{5, 7, 8} // Advanced tab
}
if len(properties) > 0 {
return properties[0]
}
return 0
}
// handleEditNavigation handles navigation in the edit form with tab support
func (m *editFormModel) handleEditNavigation(key string) tea.Cmd {
if m.focusArea == focusAreaHosts {
// Navigate in hosts area
if key == "up" || key == "shift+tab" {
m.focused--
} else {
m.focused++
}
if m.focused >= len(m.hostInputs) {
// Move to properties area, keep current tab
m.focusArea = focusAreaProperties
// Keep the current tab instead of forcing it to 0
m.focused = m.getFirstPropertyForTab(m.currentTab)
} else if m.focused < 0 {
m.focused = len(m.hostInputs) - 1
}
} else {
// Navigate in properties area within current tab
currentTabProperties := m.getPropertiesForCurrentTab()
// Find current position within the tab
currentPos := 0
for i, prop := range currentTabProperties {
if prop == m.focused {
currentPos = i
break
}
}
// Handle form submission on last field of Advanced tab
if key == "enter" && m.currentTab == 1 && currentPos == len(currentTabProperties)-1 {
return m.submitEditForm()
}
// Navigate within current tab
if key == "up" || key == "shift+tab" {
currentPos--
} else {
currentPos++
}
// Handle transitions between areas and tabs
if currentPos >= len(currentTabProperties) {
// Move to next area/tab
if m.currentTab == 0 {
// Move to advanced tab
m.currentTab = 1
m.focused = m.getFirstPropertyForTab(1)
} else {
// Move back to hosts area
m.focusArea = focusAreaHosts
m.focused = 0
}
} else if currentPos < 0 {
// Move to previous area/tab
if m.currentTab == 1 {
// Move to general tab
m.currentTab = 0
properties := m.getPropertiesForCurrentTab()
m.focused = properties[len(properties)-1]
} else {
// Move to hosts area
m.focusArea = focusAreaHosts
m.focused = len(m.hostInputs) - 1
}
} else {
m.focused = currentTabProperties[currentPos]
}
}
return m.updateFocus()
}
// getMinimumHeight calculates the minimum height needed to display the edit form
func (m *editFormModel) getMinimumHeight() int {
// Title: 1 line + 2 newlines = 3
titleLines := 3
// Config file info: 1 line + 2 newlines = 3
configLines := 3
// Host Names section: title (1) + spacing (2) = 3
hostSectionLines := 3
// Host inputs: number of hosts * 3 lines each (reduced from 4)
hostLines := len(m.hostInputs) * 3
// Properties section: title (1) + spacing (2) = 3
propertiesSectionLines := 3
// Tabs: 1 line + 2 newlines = 3
tabLines := 3
// Fields in current tab
var fieldsCount int
if m.currentTab == 0 {
fieldsCount = 6 // 6 fields in general tab
} else {
fieldsCount = 3 // 3 fields in advanced tab
}
// Each field: reduced from 4 to 3 lines per field
fieldsLines := fieldsCount * 3
// Help text: 3 lines
helpLines := 3
// Error message space when needed: 2 lines
errorLines := 0 // Only count when there's actually an error
if m.err != "" {
errorLines = 2
}
return titleLines + configLines + hostSectionLines + hostLines + propertiesSectionLines + tabLines + fieldsLines + helpLines + errorLines + 1 // +1 minimal safety margin
}
// isHeightSufficient checks if the current terminal height is sufficient
func (m *editFormModel) isHeightSufficient() bool {
return m.height >= m.getMinimumHeight()
}
// renderHeightWarning renders a warning message when height is insufficient
func (m *editFormModel) renderHeightWarning() string {
required := m.getMinimumHeight()
current := m.height
warning := m.styles.ErrorText.Render("⚠️ Terminal height is too small!")
details := m.styles.FormField.Render(fmt.Sprintf("Current: %d lines, Required: %d lines", current, required))
instruction := m.styles.FormHelp.Render("Please resize your terminal window and try again.")
instruction2 := m.styles.FormHelp.Render("Press Ctrl+C to cancel or resize terminal window.")
return warning + "\n\n" + details + "\n\n" + instruction + "\n" + instruction2
}
func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
@@ -247,51 +412,33 @@ func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "ctrl+c", "esc": case "ctrl+c", "esc":
m.err = "" m.err = ""
m.success = false
return m, func() tea.Msg { return editFormCancelMsg{} } return m, func() tea.Msg { return editFormCancelMsg{} }
case "ctrl+s": case "ctrl+s":
// Allow submission from any field with Ctrl+S (Save) // Allow submission from any field with Ctrl+S (Save)
return m, m.submitEditForm() return m, m.submitEditForm()
case "tab", "shift+tab", "enter", "up", "down": case "ctrl+j":
s := msg.String() // Switch to next tab
m.currentTab = (m.currentTab + 1) % 2
// Handle form submission // If we're in hosts area, stay there. If in properties, go to the first field of the new tab
totalFields := len(m.hostInputs) + len(m.inputs)
currentGlobalIndex := m.focused
if m.focusArea == focusAreaProperties { if m.focusArea == focusAreaProperties {
currentGlobalIndex = len(m.hostInputs) + m.focused m.focused = m.getFirstPropertyForTab(m.currentTab)
} }
if s == "enter" && currentGlobalIndex == totalFields-1 {
return m, m.submitEditForm()
}
// Cycle inputs
if s == "up" || s == "shift+tab" {
currentGlobalIndex--
} else {
currentGlobalIndex++
}
if currentGlobalIndex >= totalFields {
currentGlobalIndex = 0
} else if currentGlobalIndex < 0 {
currentGlobalIndex = totalFields - 1
}
// Update focus area and focused index based on global index
if currentGlobalIndex < len(m.hostInputs) {
m.focusArea = focusAreaHosts
m.focused = currentGlobalIndex
} else {
m.focusArea = focusAreaProperties
m.focused = currentGlobalIndex - len(m.hostInputs)
}
return m, m.updateFocus() return m, m.updateFocus()
case "ctrl+k":
// Switch to previous tab
m.currentTab = (m.currentTab - 1 + 2) % 2
// If we're in hosts area, stay there. If in properties, go to the first field of the new tab
if m.focusArea == focusAreaProperties {
m.focused = m.getFirstPropertyForTab(m.currentTab)
}
return m, m.updateFocus()
case "tab", "shift+tab", "enter", "up", "down":
return m, m.handleEditNavigation(msg.String())
case "ctrl+a": case "ctrl+a":
// Add a new host input // Add a new host input
return m, m.addHostInput() return m, m.addHostInput()
@@ -306,10 +453,10 @@ func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case editFormSubmitMsg: case editFormSubmitMsg:
if msg.err != nil { if msg.err != nil {
m.err = msg.err.Error() m.err = msg.err.Error()
m.success = false
} else { } else {
m.success = true // Success: let the wrapper handle this
m.err = "" // In TUI mode, this will be handled by the parent
// In standalone mode, the wrapper will quit
} }
return m, nil return m, nil
} }
@@ -332,15 +479,13 @@ func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m *editFormModel) View() string { func (m *editFormModel) View() string {
var b strings.Builder // Check if terminal height is sufficient
if !m.isHeightSufficient() {
if m.success { return m.renderHeightWarning()
b.WriteString(m.styles.FormField.Foreground(lipgloss.Color("#10B981")).Render("✓ Host updated successfully!"))
b.WriteString("\n\n")
b.WriteString(m.styles.FormHelp.Render("Press Ctrl+C or Esc to go back"))
return b.String()
} }
var b strings.Builder
if m.err != "" { if m.err != "" {
b.WriteString(m.styles.Error.Render("Error: " + m.err)) b.WriteString(m.styles.Error.Render("Error: " + m.err))
b.WriteString("\n\n") b.WriteString("\n\n")
@@ -377,25 +522,16 @@ func (m *editFormModel) View() string {
b.WriteString(m.styles.FormTitle.Render("Common Properties")) b.WriteString(m.styles.FormTitle.Render("Common Properties"))
b.WriteString("\n\n") b.WriteString("\n\n")
fields := []string{ // Render tabs for properties
"Hostname/IP *", b.WriteString(m.renderEditTabs())
"User", b.WriteString("\n\n")
"Port",
"Identity File",
"Proxy Jump",
"SSH Options",
"Tags (comma-separated)",
}
for i, field := range fields { // Render current tab content
fieldStyle := m.styles.FormField switch m.currentTab {
if m.focusArea == focusAreaProperties && m.focused == i { case 0: // General
fieldStyle = m.styles.FocusedLabel b.WriteString(m.renderEditGeneralTab())
} case 1: // Advanced
b.WriteString(fieldStyle.Render(field)) b.WriteString(m.renderEditAdvancedTab())
b.WriteString("\n")
b.WriteString(m.inputs[i].View())
b.WriteString("\n\n")
} }
if m.err != "" { if m.err != "" {
@@ -405,10 +541,10 @@ func (m *editFormModel) View() string {
// Show different help based on number of hosts // Show different help based on number of hosts
if len(m.hostInputs) > 1 { if len(m.hostInputs) > 1 {
b.WriteString(m.styles.FormHelp.Render("Tab/↑↓/Enter: navigate • Ctrl+A: add host • Ctrl+D: delete host")) b.WriteString(m.styles.FormHelp.Render("Tab/↑↓/Enter: navigate • Ctrl+J/K: switch tabs • Ctrl+A: add host • Ctrl+D: delete host"))
b.WriteString("\n") b.WriteString("\n")
} else { } else {
b.WriteString(m.styles.FormHelp.Render("Tab/↑↓/Enter: navigate • Ctrl+A: add host")) b.WriteString(m.styles.FormHelp.Render("Tab/↑↓/Enter: navigate • Ctrl+J/K: switch tabs • Ctrl+A: add host"))
b.WriteString("\n") b.WriteString("\n")
} }
b.WriteString(m.styles.FormHelp.Render("Ctrl+S: save • Ctrl+C/Esc: cancel • * Required fields")) b.WriteString(m.styles.FormHelp.Render("Ctrl+S: save • Ctrl+C/Esc: cancel • * Required fields"))
@@ -416,6 +552,102 @@ func (m *editFormModel) View() string {
return b.String() return b.String()
} }
// renderEditTabs renders the tab headers for properties
func (m *editFormModel) renderEditTabs() string {
var generalTab, advancedTab string
if m.currentTab == 0 {
generalTab = m.styles.FocusedLabel.Render("[ General ]")
advancedTab = m.styles.FormField.Render(" Advanced ")
} else {
generalTab = m.styles.FormField.Render(" General ")
advancedTab = m.styles.FocusedLabel.Render("[ Advanced ]")
}
return generalTab + " " + advancedTab
}
// renderEditGeneralTab renders the general tab content for properties
func (m *editFormModel) renderEditGeneralTab() string {
var b strings.Builder
fields := []struct {
index int
label string
}{
{0, "Hostname/IP *"},
{1, "User"},
{2, "Port"},
{3, "Identity File"},
{4, "Proxy Jump"},
{6, "Tags (comma-separated)"},
}
for _, field := range fields {
fieldStyle := m.styles.FormField
if m.focusArea == focusAreaProperties && m.focused == field.index {
fieldStyle = m.styles.FocusedLabel
}
b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n")
b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n")
}
return b.String()
}
// renderEditAdvancedTab renders the advanced tab content for properties
func (m *editFormModel) renderEditAdvancedTab() string {
var b strings.Builder
fields := []struct {
index int
label string
}{
{5, "SSH Options"},
{7, "Remote Command"},
{8, "Request TTY"},
}
for _, field := range fields {
fieldStyle := m.styles.FormField
if m.focusArea == focusAreaProperties && m.focused == field.index {
fieldStyle = m.styles.FocusedLabel
}
b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n")
b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n")
}
return b.String()
}
// Standalone wrapper for edit form
type standaloneEditForm struct {
*editFormModel
}
func (m standaloneEditForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case editFormSubmitMsg:
if msg.err != nil {
m.editFormModel.err = msg.err.Error()
return m, nil
} else {
// Success: quit the program
return m, tea.Quit
}
case editFormCancelMsg:
return m, tea.Quit
}
newForm, cmd := m.editFormModel.Update(msg)
m.editFormModel = newForm.(*editFormModel)
return m, cmd
}
// RunEditForm runs the edit form as a standalone program // RunEditForm runs the edit form as a standalone program
func RunEditForm(hostName string, configFile string) error { func RunEditForm(hostName string, configFile string) error {
styles := NewStyles(80) // Default width styles := NewStyles(80) // Default width
@@ -424,17 +656,10 @@ func RunEditForm(hostName string, configFile string) error {
return err return err
} }
p := tea.NewProgram(editForm, tea.WithAltScreen()) m := standaloneEditForm{editForm}
p := tea.NewProgram(m, tea.WithAltScreen())
_, err = p.Run() _, err = p.Run()
if err != nil { return err
return err
}
if editForm.err != "" {
return fmt.Errorf(editForm.err)
}
return nil
} }
func (m *editFormModel) submitEditForm() tea.Cmd { func (m *editFormModel) submitEditForm() tea.Cmd {
@@ -453,12 +678,14 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
} }
// Get property values using direct indices // Get property values using direct indices
hostname := strings.TrimSpace(m.inputs[0].Value()) // hostnameInput hostname := strings.TrimSpace(m.inputs[0].Value()) // hostnameInput
user := strings.TrimSpace(m.inputs[1].Value()) // userInput user := strings.TrimSpace(m.inputs[1].Value()) // userInput
port := strings.TrimSpace(m.inputs[2].Value()) // portInput port := strings.TrimSpace(m.inputs[2].Value()) // portInput
identity := strings.TrimSpace(m.inputs[3].Value()) // identityInput identity := strings.TrimSpace(m.inputs[3].Value()) // identityInput
proxyJump := strings.TrimSpace(m.inputs[4].Value()) // proxyJumpInput proxyJump := strings.TrimSpace(m.inputs[4].Value()) // proxyJumpInput
options := strings.TrimSpace(m.inputs[5].Value()) // optionsInput options := strings.TrimSpace(m.inputs[5].Value()) // optionsInput
remoteCommand := strings.TrimSpace(m.inputs[7].Value()) // remoteCommandInput
requestTTY := strings.TrimSpace(m.inputs[8].Value()) // requestTTYInput
// Set defaults // Set defaults
if port == "" { if port == "" {
@@ -491,13 +718,15 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
// Create the common host configuration // Create the common host configuration
commonHost := config.SSHHost{ commonHost := config.SSHHost{
Hostname: hostname, Hostname: hostname,
User: user, User: user,
Port: port, Port: port,
Identity: identity, Identity: identity,
ProxyJump: proxyJump, ProxyJump: proxyJump,
Options: options, Options: options,
Tags: tags, RemoteCommand: remoteCommand,
RequestTTY: requestTTY,
Tags: tags,
} }
var err error var err error

View File

@@ -47,31 +47,34 @@ func (m *helpModel) View() string {
m.styles.FocusedLabel.Render("Navigation & Connection"), m.styles.FocusedLabel.Render("Navigation & Connection"),
"", "",
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("⏎ "), m.styles.FocusedLabel.Render("⏎ "),
m.styles.HelpText.Render("connect to selected host")), m.styles.HelpText.Render("connect to selected host")),
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("i "), m.styles.FocusedLabel.Render("i "),
m.styles.HelpText.Render("show host information")), m.styles.HelpText.Render("show host information")),
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("/ "), m.styles.FocusedLabel.Render("/ "),
m.styles.HelpText.Render("search hosts")), m.styles.HelpText.Render("search hosts")),
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("Tab "), m.styles.FocusedLabel.Render("Tab "),
m.styles.HelpText.Render("switch focus")), m.styles.HelpText.Render("switch focus")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("Ctrl+H "),
m.styles.HelpText.Render("switch to history view")),
"", "",
m.styles.FocusedLabel.Render("Host Management"), m.styles.FocusedLabel.Render("Host Management"),
"", "",
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("a "), m.styles.FocusedLabel.Render("a "),
m.styles.HelpText.Render("add new host")), m.styles.HelpText.Render("add new host")),
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("e "), m.styles.FocusedLabel.Render("e "),
m.styles.HelpText.Render("edit selected host")), m.styles.HelpText.Render("edit selected host")),
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("m "), m.styles.FocusedLabel.Render("m "),
m.styles.HelpText.Render("move host to another config")), m.styles.HelpText.Render("move host to another config")),
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("d "), m.styles.FocusedLabel.Render("d "),
m.styles.HelpText.Render("delete selected host")), m.styles.HelpText.Render("delete selected host")),
) )
@@ -79,31 +82,31 @@ func (m *helpModel) View() string {
m.styles.FocusedLabel.Render("Advanced Features"), m.styles.FocusedLabel.Render("Advanced Features"),
"", "",
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("p "), m.styles.FocusedLabel.Render("p "),
m.styles.HelpText.Render("ping all hosts")), m.styles.HelpText.Render("ping all hosts")),
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("f "), m.styles.FocusedLabel.Render("f "),
m.styles.HelpText.Render("setup port forwarding")), m.styles.HelpText.Render("setup port forwarding")),
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("s "), m.styles.FocusedLabel.Render("s "),
m.styles.HelpText.Render("cycle sort modes")), m.styles.HelpText.Render("cycle sort modes")),
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("n "), m.styles.FocusedLabel.Render("n "),
m.styles.HelpText.Render("sort by name")), m.styles.HelpText.Render("sort by name")),
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("r "), m.styles.FocusedLabel.Render("r "),
m.styles.HelpText.Render("sort by recent connection")), m.styles.HelpText.Render("sort by recent connection")),
"", "",
m.styles.FocusedLabel.Render("System"), m.styles.FocusedLabel.Render("System"),
"", "",
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("h "), m.styles.FocusedLabel.Render("h "),
m.styles.HelpText.Render("show this help")), m.styles.HelpText.Render("show this help")),
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("q "), m.styles.FocusedLabel.Render("q "),
m.styles.HelpText.Render("quit application")), m.styles.HelpText.Render("quit application")),
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("ESC "), m.styles.FocusedLabel.Render("ESC "),
m.styles.HelpText.Render("exit current view")), m.styles.HelpText.Render("exit current view")),
) )

530
internal/ui/history_tui.go Normal file
View File

@@ -0,0 +1,530 @@
package ui
import (
"fmt"
"os/exec"
"strings"
"time"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/history"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// HistoryModel represents the TUI model for history view
type HistoryModel struct {
table table.Model
connections []history.ConnectionInfo
searchInput textinput.Model
searchActive bool
filteredConns []history.ConnectionInfo
configFile string
currentVersion string
styles Styles
width int
height int
showAddForm bool
addForm *addFormModel
selectedConn *history.ConnectionInfo
err string
}
// NewHistoryModel creates a new history TUI model
func NewHistoryModel(connections []history.ConnectionInfo, configFile, currentVersion string) HistoryModel {
styles := NewStyles(80)
// Create search input (different placeholder than main interface)
searchInput := textinput.New()
searchInput.Placeholder = "Search connections..."
searchInput.CharLimit = 50
searchInput.Width = 25 // Same width as main interface
m := HistoryModel{
connections: connections,
filteredConns: connections,
searchInput: searchInput,
configFile: configFile,
currentVersion: currentVersion,
styles: styles,
}
m.updateTable()
return m
}
// Init initializes the history model
func (m HistoryModel) Init() tea.Cmd {
return nil
}
// Update handles messages for the history model
func (m HistoryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
// Handle add form if active
if m.showAddForm && m.addForm != nil {
switch msg := msg.(type) {
case addFormSubmitMsg:
if msg.err != nil {
m.err = msg.err.Error()
} else {
m.showAddForm = false
m.addForm = nil
// Return to main list and refresh hosts
return m, func() tea.Msg { return refreshHostsMsg{} }
}
case addFormCancelMsg:
m.showAddForm = false
m.addForm = nil
return m, nil
}
newForm, cmd := m.addForm.Update(msg)
m.addForm = newForm
return m, cmd
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.styles = NewStyles(m.width)
m.updateTable()
return m, nil
case tea.KeyMsg:
// Handle search mode
if m.searchActive {
switch msg.String() {
case "esc", "ctrl+c":
m.searchActive = false
m.searchInput.Blur()
m.searchInput.SetValue("")
m.filteredConns = m.connections
m.updateTable()
return m, nil
case "enter":
m.searchActive = false
m.searchInput.Blur()
return m, nil
default:
m.searchInput, cmd = m.searchInput.Update(msg)
cmds = append(cmds, cmd)
m.filterConnections()
m.updateTable()
return m, tea.Batch(cmds...)
}
}
// Normal mode key handling
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "ctrl+l":
// Return to main list view
return m, func() tea.Msg { return returnToListMsg{} }
case "enter":
// Connect to selected host
if len(m.filteredConns) > 0 {
selectedIdx := m.table.Cursor()
if selectedIdx < len(m.filteredConns) {
conn := m.filteredConns[selectedIdx]
return m, m.connectToHistory(conn)
}
}
case "a":
// Add manual connection to config
if len(m.filteredConns) > 0 {
selectedIdx := m.table.Cursor()
if selectedIdx < len(m.filteredConns) {
conn := m.filteredConns[selectedIdx]
// Only allow adding manual connections to config
if history.IsManualConnection(conn.HostName) {
m.selectedConn = &conn
m.showAddForm = true
m.addForm = m.createAddFormFromConnection(conn)
return m, m.addForm.Init()
}
}
}
case "d":
// Delete connection from history
if len(m.filteredConns) > 0 {
selectedIdx := m.table.Cursor()
if selectedIdx < len(m.filteredConns) {
conn := m.filteredConns[selectedIdx]
return m, m.deleteFromHistory(conn)
}
}
case "/":
// Activate search
m.searchActive = true
m.searchInput.Focus()
return m, textinput.Blink
}
}
// Update table
m.table, cmd = m.table.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
// View renders the history TUI
func (m HistoryModel) View() string {
if m.showAddForm && m.addForm != nil {
return m.addForm.View()
}
// Build the interface components (same structure as main view)
components := []string{}
// Add the ASCII title
components = append(components, m.styles.Header.Render(asciiTitle))
// Add error message if there's one to show
if m.err != "" {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("9")). // Red color
Background(lipgloss.Color("1")). // Dark red background
Bold(true).
Padding(0, 1).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("9")).
Align(lipgloss.Center)
components = append(components, errorStyle.Render("❌ "+m.err))
}
// Add the search bar with the appropriate style based on focus
searchPrompt := "Search (/ to focus): "
if m.searchActive {
components = append(components, m.styles.SearchFocused.Render(searchPrompt+m.searchInput.View()))
} else {
components = append(components, m.styles.SearchUnfocused.Render(searchPrompt+m.searchInput.View()))
}
// Add the table with the appropriate style based on focus
if m.searchActive {
// The table is not focused, use the unfocused style
components = append(components, m.styles.TableUnfocused.Render(m.table.View()))
} else {
// The table is focused, use the focused style
components = append(components, m.styles.TableFocused.Render(m.table.View()))
}
// Add the help text
var helpText string
if !m.searchActive {
helpText = " ↑/↓: navigate • Enter: connect • Ctrl+L: list • a: add to config (★) • d: delete • q: quit"
} else {
helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit"
}
components = append(components, m.styles.HelpText.Render(helpText))
// Join all components vertically with appropriate spacing
mainView := m.styles.App.Render(
lipgloss.JoinVertical(
lipgloss.Left,
components...,
),
)
return mainView
} // updateTable updates the table with current filtered connections
func (m *HistoryModel) updateTable() {
columns := []table.Column{
{Title: "Host", Width: 22}, // Host name with ★ for manual connections
{Title: "User", Width: 15},
{Title: "Hostname", Width: 25},
{Title: "Port", Width: 6},
{Title: "Last Connect", Width: 20},
{Title: "Count", Width: 6},
}
// Load SSH hosts to get details for configured connections
var sshHosts []config.SSHHost
var err error
if m.configFile != "" {
sshHosts, err = config.ParseSSHConfigFile(m.configFile)
} else {
sshHosts, err = config.ParseSSHConfig()
}
if err != nil {
sshHosts = []config.SSHHost{}
}
// Create a map for quick lookup
hostsMap := make(map[string]config.SSHHost)
for _, host := range sshHosts {
hostsMap[host.Name] = host
}
rows := []table.Row{}
for _, conn := range m.filteredConns {
var hostDisplay, user, hostname, port string
// Parse manual connections
if history.IsManualConnection(conn.HostName) {
u, h, p, ok := history.ParseManualConnectionID(conn.HostName)
if ok {
hostDisplay = "★" // Star indicates this can be added to config
user = u
hostname = h
port = p
}
} else {
// For configured hosts, show the host name
hostDisplay = conn.HostName
if host, exists := hostsMap[conn.HostName]; exists {
user = host.User
hostname = host.Hostname
port = host.Port
if port == "" {
port = "22"
}
}
}
lastConnect := formatTimeSince(conn.LastConnect)
rows = append(rows, table.Row{
hostDisplay,
user,
hostname,
port,
lastConnect,
fmt.Sprintf("%d", conn.ConnectCount),
})
}
// Calculate dynamic table height (same logic as main interface)
tableHeight := m.calculateTableHeight(len(rows))
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(tableHeight),
)
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color(PrimaryColor)).
BorderBottom(true).
Bold(true)
s.Selected = s.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color(PrimaryColor)).
Bold(false)
t.SetStyles(s)
m.table = t
}
// calculateTableHeight calculates the appropriate height for the table based on terminal size
func (m *HistoryModel) calculateTableHeight(rowCount int) int {
// Calculate dynamic table height based on terminal size
// Layout breakdown (same as main interface):
// - ASCII title: 5 lines (1 empty + 4 text lines)
// - Search bar: 1 line
// - Help text: 1 line
// - App margins/spacing: 3 lines
// - Safety margin: 3 lines
// Total reserved: 13 lines
reservedHeight := 13
availableHeight := m.height - reservedHeight
// Add 1 if there's an error message showing
if m.err != "" {
availableHeight -= 3 // Error box takes about 3 lines
}
// Minimum height should be at least 3 rows for basic usability
minTableHeight := 4 // 1 header + 3 data rows minimum
maxTableHeight := availableHeight
if maxTableHeight < minTableHeight {
maxTableHeight = minTableHeight
}
tableHeight := 1 // header
dataRowsNeeded := rowCount
maxDataRows := maxTableHeight - 1 // subtract 1 for header
if dataRowsNeeded <= maxDataRows {
// We have enough space for all connections
tableHeight += dataRowsNeeded
} else {
// We need to limit to available space
tableHeight += maxDataRows
}
// Add one extra line to prevent the last row from being hidden
tableHeight += 1
return tableHeight
}
// filterConnections filters connections based on search input
func (m *HistoryModel) filterConnections() {
searchTerm := strings.ToLower(m.searchInput.Value())
if searchTerm == "" {
m.filteredConns = m.connections
return
}
m.filteredConns = []history.ConnectionInfo{}
for _, conn := range m.connections {
// Search in hostname
if strings.Contains(strings.ToLower(conn.HostName), searchTerm) {
m.filteredConns = append(m.filteredConns, conn)
continue
}
// For manual connections, search in parsed fields
if history.IsManualConnection(conn.HostName) {
user, hostname, _, ok := history.ParseManualConnectionID(conn.HostName)
if ok {
if strings.Contains(strings.ToLower(user), searchTerm) ||
strings.Contains(strings.ToLower(hostname), searchTerm) {
m.filteredConns = append(m.filteredConns, conn)
}
}
}
}
}
// connectToHistory connects to a host from history
func (m HistoryModel) connectToHistory(conn history.ConnectionInfo) tea.Cmd {
var sshArgs []string
if history.IsManualConnection(conn.HostName) {
// Manual connection
user, hostname, port, ok := history.ParseManualConnectionID(conn.HostName)
if !ok {
return nil
}
if port != "" && port != "22" {
sshArgs = append(sshArgs, "-p", port)
}
if user != "" {
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", user, hostname))
} else {
sshArgs = append(sshArgs, hostname)
}
} else {
// Configured host
if m.configFile != "" {
sshArgs = append(sshArgs, "-F", m.configFile)
}
sshArgs = append(sshArgs, conn.HostName)
}
// Execute SSH using tea.ExecProcess for proper terminal handling
sshCmd := exec.Command("ssh", sshArgs...)
return tea.ExecProcess(sshCmd, func(err error) tea.Msg {
return tea.Quit()
})
}
// deleteFromHistory removes a connection from history
func (m HistoryModel) deleteFromHistory(conn history.ConnectionInfo) tea.Cmd {
return func() tea.Msg {
historyManager, err := history.NewHistoryManager()
if err != nil {
return tea.Quit
}
// Remove from history
// This would need a new method in history manager
// For now, just quit
_ = historyManager
return tea.Quit
}
}
// createAddFormFromConnection creates an add form pre-filled with connection details
func (m *HistoryModel) createAddFormFromConnection(conn history.ConnectionInfo) *addFormModel {
user, hostname, port, ok := history.ParseManualConnectionID(conn.HostName)
if !ok {
return nil
}
// Create form with empty name (user will choose)
form := NewAddForm("", m.styles, m.width, m.height, m.configFile)
// Pre-fill the form with connection details
form.inputs[hostnameInput].SetValue(hostname)
form.inputs[userInput].SetValue(user)
if port != "22" && port != "" {
form.inputs[portInput].SetValue(port)
}
// Leave name field empty for user to choose
// form.inputs[nameInput].SetValue("") // Already empty by default
return form
}
// formatTimeSince formats a time duration in human-readable format
func formatTimeSince(t time.Time) string {
duration := time.Since(t)
switch {
case duration < time.Minute:
return "just now"
case duration < time.Hour:
mins := int(duration.Minutes())
if mins == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", mins)
case duration < 24*time.Hour:
hours := int(duration.Hours())
if hours == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", hours)
case duration < 7*24*time.Hour:
days := int(duration.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
case duration < 30*24*time.Hour:
weeks := int(duration.Hours() / 24 / 7)
if weeks == 1 {
return "1 week ago"
}
return fmt.Sprintf("%d weeks ago", weeks)
default:
months := int(duration.Hours() / 24 / 30)
if months == 1 {
return "1 month ago"
}
if months < 12 {
return fmt.Sprintf("%d months ago", months)
}
years := months / 12
if years == 1 {
return "1 year ago"
}
return fmt.Sprintf("%d years ago", years)
}
}

View File

@@ -42,6 +42,7 @@ const (
ViewPortForward ViewPortForward
ViewHelp ViewHelp
ViewFileSelector ViewFileSelector
ViewHistory
) )
// PortForwardType defines the type of port forwarding // PortForwardType defines the type of port forwarding
@@ -81,7 +82,7 @@ type Model struct {
configFile string // Path to the SSH config file configFile string // Path to the SSH config file
// Application configuration // Application configuration
appConfig *config.AppConfig appConfig *config.AppConfig
// Version update information // Version update information
updateInfo *version.UpdateInfo updateInfo *version.UpdateInfo
@@ -96,6 +97,7 @@ type Model struct {
portForwardForm *portForwardModel portForwardForm *portForwardModel
helpForm *helpModel helpForm *helpModel
fileSelectorForm *fileSelectorModel fileSelectorForm *fileSelectorModel
historyView *HistoryModel
// Terminal size and styles // Terminal size and styles
width int width int

View File

@@ -33,7 +33,8 @@ type Styles struct {
HelpText lipgloss.Style HelpText lipgloss.Style
// Error and confirmation styles // Error and confirmation styles
Error lipgloss.Style Error lipgloss.Style
ErrorText lipgloss.Style
// Form styles (for add/edit forms) // Form styles (for add/edit forms)
FormTitle lipgloss.Style FormTitle lipgloss.Style
@@ -62,12 +63,14 @@ func NewStyles(width int) Styles {
SearchFocused: lipgloss.NewStyle(). SearchFocused: lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()). BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(PrimaryColor)). BorderForeground(lipgloss.Color(PrimaryColor)).
Padding(0, 1), Padding(0, 1).
Width(50), // Fixed width to prevent expansion
SearchUnfocused: lipgloss.NewStyle(). SearchUnfocused: lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()). BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(SecondaryColor)). BorderForeground(lipgloss.Color(SecondaryColor)).
Padding(0, 1), Padding(0, 1).
Width(50), // Fixed width to prevent expansion
// Table styles // Table styles
TableFocused: lipgloss.NewStyle(). TableFocused: lipgloss.NewStyle().
@@ -97,6 +100,11 @@ func NewStyles(width int) Styles {
BorderForeground(lipgloss.Color(ErrorColor)). BorderForeground(lipgloss.Color(ErrorColor)).
Padding(1, 2), Padding(1, 2),
// Error text style (no border, just red text)
ErrorText: lipgloss.NewStyle().
Foreground(lipgloss.Color(ErrorColor)).
Bold(true),
// Form styles // Form styles
FormTitle: lipgloss.NewStyle(). FormTitle: lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFDF5")). Foreground(lipgloss.Color("#FFFDF5")).

View File

@@ -20,6 +20,8 @@ type (
versionCheckMsg *version.UpdateInfo versionCheckMsg *version.UpdateInfo
versionErrorMsg error versionErrorMsg error
errorMsg string errorMsg string
returnToListMsg struct{}
refreshHostsMsg struct{}
) )
// startPingAllCmd creates a command to ping all hosts concurrently // startPingAllCmd creates a command to ping all hosts concurrently
@@ -166,6 +168,40 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
case returnToListMsg:
// Return to list view from history
m.viewMode = ViewList
m.historyView = nil
return m, nil
case refreshHostsMsg:
// Refresh hosts after adding from history
var hosts []config.SSHHost
var err error
if m.configFile != "" {
hosts, err = config.ParseSSHConfigFile(m.configFile)
} else {
hosts, err = config.ParseSSHConfig()
}
if err != nil {
return m, nil
}
m.hosts = m.sortHosts(hosts)
// Reapply search filter if there is one active
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.hosts
}
m.updateTableRows()
m.viewMode = ViewList
m.historyView = nil
return m, nil
case addFormSubmitMsg: case addFormSubmitMsg:
if msg.err != nil { if msg.err != nil {
// Show error in form // Show error in form
@@ -434,6 +470,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.fileSelectorForm = newForm m.fileSelectorForm = newForm
return m, cmd return m, cmd
} }
case ViewHistory:
if m.historyView != nil {
newView, cmd := m.historyView.Update(msg)
if histView, ok := newView.(HistoryModel); ok {
m.historyView = &histView
return m, cmd
}
}
case ViewList: case ViewList:
// Handle list view keys // Handle list view keys
return m.handleListViewKeys(msg) return m.handleListViewKeys(msg)
@@ -705,6 +749,22 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.viewMode = ViewHelp m.viewMode = ViewHelp
return m, nil return m, nil
} }
case "ctrl+h":
if !m.searchMode && !m.deleteMode {
// Switch to history view
if m.historyManager != nil {
connections := m.historyManager.GetAllConnectionsInfo()
historyView := NewHistoryModel(connections, m.configFile, m.currentVersion)
historyView.width = m.width
historyView.height = m.height
historyView.styles = m.styles
// Force table update with correct dimensions
historyView.updateTable()
m.historyView = &historyView
m.viewMode = ViewHistory
return m, nil
}
}
case "s": case "s":
if !m.searchMode && !m.deleteMode { if !m.searchMode && !m.deleteMode {
// Cycle through sort modes (only 2 modes now) // Cycle through sort modes (only 2 modes now)

View File

@@ -43,6 +43,10 @@ func (m Model) View() string {
if m.fileSelectorForm != nil { if m.fileSelectorForm != nil {
return m.fileSelectorForm.View() return m.fileSelectorForm.View()
} }
case ViewHistory:
if m.historyView != nil {
return m.historyView.View()
}
case ViewList: case ViewList:
return m.renderListView() return m.renderListView()
} }
@@ -106,7 +110,7 @@ func (m Model) renderListView() string {
// Add the help text // Add the help text
var helpText string var helpText string
if !m.searchMode { if !m.searchMode {
helpText = " ↑/↓: navigate • Enter: connect • p: ping all • i: info • h: help • q: quit" helpText = " ↑/↓: navigate • Enter: connect • Ctrl+H: history • i: info • h: help • q: quit"
} else { } else {
helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit" helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit"
} }