mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2026-01-27 03:04:21 +01:00
Compare commits
7 Commits
1.3.0
...
146d04c9b7
| Author | SHA1 | Date | |
|---|---|---|---|
| 146d04c9b7 | |||
| 22586484c7 | |||
| 420db56ff5 | |||
|
|
7600eaaa9b | ||
|
|
e0dd32993a | ||
| 1cea3795e4 | |||
| 2ade315ddc |
@@ -74,10 +74,10 @@ downloadBinary() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Find the extracted binary
|
# Check if the expected binary exists (no find needed)
|
||||||
EXTRACTED_BINARY=$(find . -name "sshm-${OS}-${ARCH}" -type f)
|
EXTRACTED_BINARY="./sshm-${OS}-${ARCH}"
|
||||||
if [ -z "$EXTRACTED_BINARY" ]; then
|
if [ ! -f "$EXTRACTED_BINARY" ]; then
|
||||||
printf "${RED}Could not find extracted binary${NC}\n"
|
printf "${RED}Could not find extracted binary: $EXTRACTED_BINARY${NC}\n"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -88,17 +88,37 @@ downloadBinary() {
|
|||||||
install() {
|
install() {
|
||||||
printf "${YELLOW}Installing SSHM...${NC}\n"
|
printf "${YELLOW}Installing SSHM...${NC}\n"
|
||||||
|
|
||||||
|
# Backup old version if it exists to prevent interference during installation
|
||||||
|
OLD_BACKUP=""
|
||||||
|
if [ -f "$EXECUTABLE_PATH" ]; then
|
||||||
|
OLD_BACKUP="$EXECUTABLE_PATH.backup.$$"
|
||||||
|
runAsRoot mv "$EXECUTABLE_PATH" "$OLD_BACKUP"
|
||||||
|
fi
|
||||||
|
|
||||||
chmod +x "sshm-tmp"
|
chmod +x "sshm-tmp"
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
printf "${RED}Failed to set permissions${NC}\n"
|
printf "${RED}Failed to set permissions${NC}\n"
|
||||||
|
# Restore backup if installation fails
|
||||||
|
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
|
||||||
|
runAsRoot mv "$OLD_BACKUP" "$EXECUTABLE_PATH"
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
runAsRoot mv "sshm-tmp" "$EXECUTABLE_PATH"
|
runAsRoot mv "sshm-tmp" "$EXECUTABLE_PATH"
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
printf "${RED}Failed to install binary${NC}\n"
|
printf "${RED}Failed to install binary${NC}\n"
|
||||||
|
# Restore backup if installation fails
|
||||||
|
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
|
||||||
|
runAsRoot mv "$OLD_BACKUP" "$EXECUTABLE_PATH"
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Clean up backup if installation succeeded
|
||||||
|
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
|
||||||
|
runAsRoot rm -f "$OLD_BACKUP"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
@@ -161,7 +181,8 @@ main() {
|
|||||||
# Show version
|
# Show version
|
||||||
printf "${YELLOW}Verifying installation...${NC}\n"
|
printf "${YELLOW}Verifying installation...${NC}\n"
|
||||||
if command -v sshm >/dev/null 2>&1; then
|
if command -v sshm >/dev/null 2>&1; then
|
||||||
sshm --version
|
# Use the full path to ensure we're using the newly installed version
|
||||||
|
"$EXECUTABLE_PATH" --version 2>/dev/null || echo "Version check failed, but installation completed"
|
||||||
else
|
else
|
||||||
printf "${RED}Warning: 'sshm' command not found in PATH. You may need to restart your terminal or add $INSTALL_DIR to your PATH.${NC}\n"
|
printf "${RED}Warning: 'sshm' command not found in PATH. You may need to restart your terminal or add $INSTALL_DIR to your PATH.${NC}\n"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -96,25 +96,45 @@ func ParseSSHConfig() ([]SSHHost, error) {
|
|||||||
|
|
||||||
// ParseSSHConfigFile parses a specific SSH config file and returns the list of hosts
|
// ParseSSHConfigFile parses a specific SSH config file and returns the list of hosts
|
||||||
func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
||||||
|
return parseSSHConfigFileWithProcessedFiles(configPath, make(map[string]bool))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSSHConfigFileWithProcessedFiles parses SSH config with include support
|
||||||
|
func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[string]bool) ([]SSHHost, error) {
|
||||||
|
// Resolve absolute path to prevent infinite recursion
|
||||||
|
absPath, err := filepath.Abs(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve absolute path for %s: %w", configPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for circular includes
|
||||||
|
if processedFiles[absPath] {
|
||||||
|
return []SSHHost{}, nil // Skip already processed files silently
|
||||||
|
}
|
||||||
|
processedFiles[absPath] = true
|
||||||
|
|
||||||
// Check if the file exists, otherwise create it (and the parent directory if needed)
|
// Check if the file exists, otherwise create it (and the parent directory if needed)
|
||||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
// Ensure .ssh directory exists with proper permissions
|
// Only create the main config file, not included files
|
||||||
if err := ensureSSHDirectory(); err != nil {
|
if absPath == getMainConfigPath() {
|
||||||
return nil, fmt.Errorf("failed to create .ssh directory: %w", err)
|
// Ensure .ssh directory exists with proper permissions
|
||||||
|
if err := ensureSSHDirectory(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create .ssh directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create SSH config file: %w", err)
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
// Set secure permissions on the config file
|
||||||
|
if err := SetSecureFilePermissions(configPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set secure permissions: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY, 0600)
|
// File doesn't exist, return empty host list
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create SSH config file: %w", err)
|
|
||||||
}
|
|
||||||
file.Close()
|
|
||||||
|
|
||||||
// Set secure permissions on the config file
|
|
||||||
if err := SetSecureFilePermissions(configPath); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to set secure permissions: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// File created, return empty host list
|
|
||||||
return []SSHHost{}, nil
|
return []SSHHost{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,11 +188,25 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
|||||||
value := strings.Join(parts[1:], " ")
|
value := strings.Join(parts[1:], " ")
|
||||||
|
|
||||||
switch key {
|
switch key {
|
||||||
|
case "include":
|
||||||
|
// Handle Include directive
|
||||||
|
includeHosts, err := processIncludeDirective(value, configPath, processedFiles)
|
||||||
|
if err != nil {
|
||||||
|
// Don't fail the entire parse if include fails, just skip it
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hosts = append(hosts, includeHosts...)
|
||||||
case "host":
|
case "host":
|
||||||
// New host, save previous one if it exists
|
// New host, save previous one if it exists
|
||||||
if currentHost != nil {
|
if currentHost != nil {
|
||||||
hosts = append(hosts, *currentHost)
|
hosts = append(hosts, *currentHost)
|
||||||
}
|
}
|
||||||
|
// Skip hosts with wildcards (*, ?) as they are typically patterns, not actual hosts
|
||||||
|
if strings.ContainsAny(value, "*?") {
|
||||||
|
currentHost = nil
|
||||||
|
pendingTags = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Create new host
|
// Create new host
|
||||||
currentHost = &SSHHost{
|
currentHost = &SSHHost{
|
||||||
Name: value,
|
Name: value,
|
||||||
@@ -222,6 +256,55 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
|||||||
return hosts, scanner.Err()
|
return hosts, scanner.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// processIncludeDirective processes an Include directive and returns hosts from included files
|
||||||
|
func processIncludeDirective(pattern string, baseConfigPath string, processedFiles map[string]bool) ([]SSHHost, error) {
|
||||||
|
// Expand tilde to home directory
|
||||||
|
if strings.HasPrefix(pattern, "~") {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||||
|
}
|
||||||
|
pattern = filepath.Join(homeDir, pattern[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pattern is not absolute, make it relative to the base config directory
|
||||||
|
if !filepath.IsAbs(pattern) {
|
||||||
|
baseDir := filepath.Dir(baseConfigPath)
|
||||||
|
pattern = filepath.Join(baseDir, pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use glob to find matching files
|
||||||
|
matches, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to glob pattern %s: %w", pattern, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var allHosts []SSHHost
|
||||||
|
for _, match := range matches {
|
||||||
|
// Skip directories
|
||||||
|
if info, err := os.Stat(match); err == nil && info.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively parse the included file
|
||||||
|
hosts, err := parseSSHConfigFileWithProcessedFiles(match, processedFiles)
|
||||||
|
if err != nil {
|
||||||
|
// Skip files that can't be parsed rather than failing completely
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allHosts = append(allHosts, hosts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return allHosts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMainConfigPath returns the main SSH config path for comparison
|
||||||
|
func getMainConfigPath() string {
|
||||||
|
configPath, _ := GetDefaultSSHConfigPath()
|
||||||
|
absPath, _ := filepath.Abs(configPath)
|
||||||
|
return absPath
|
||||||
|
}
|
||||||
|
|
||||||
// AddSSHHost adds a new SSH host to the config file
|
// AddSSHHost adds a new SSH host to the config file
|
||||||
func AddSSHHost(host SSHHost) error {
|
func AddSSHHost(host SSHHost) error {
|
||||||
configPath, err := GetDefaultSSHConfigPath()
|
configPath, err := GetDefaultSSHConfigPath()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -71,3 +72,296 @@ func TestEnsureSSHDirectory(t *testing.T) {
|
|||||||
t.Fatalf("ensureSSHDirectory() error = %v", err)
|
t.Fatalf("ensureSSHDirectory() error = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseSSHConfigWithInclude(t *testing.T) {
|
||||||
|
// Create temporary directory for test files
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create main config file
|
||||||
|
mainConfig := filepath.Join(tempDir, "config")
|
||||||
|
mainConfigContent := `Host main-host
|
||||||
|
HostName example.com
|
||||||
|
User mainuser
|
||||||
|
|
||||||
|
Include included.conf
|
||||||
|
Include subdir/*
|
||||||
|
|
||||||
|
Host another-host
|
||||||
|
HostName another.example.com
|
||||||
|
User anotheruser
|
||||||
|
`
|
||||||
|
|
||||||
|
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create main config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create included file
|
||||||
|
includedConfig := filepath.Join(tempDir, "included.conf")
|
||||||
|
includedConfigContent := `Host included-host
|
||||||
|
HostName included.example.com
|
||||||
|
User includeduser
|
||||||
|
Port 2222
|
||||||
|
`
|
||||||
|
|
||||||
|
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create included config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create subdirectory with another config file
|
||||||
|
subDir := filepath.Join(tempDir, "subdir")
|
||||||
|
err = os.MkdirAll(subDir, 0700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create subdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subConfig := filepath.Join(subDir, "sub.conf")
|
||||||
|
subConfigContent := `Host sub-host
|
||||||
|
HostName sub.example.com
|
||||||
|
User subuser
|
||||||
|
IdentityFile ~/.ssh/sub_key
|
||||||
|
`
|
||||||
|
|
||||||
|
err = os.WriteFile(subConfig, []byte(subConfigContent), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create sub config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the main config file
|
||||||
|
hosts, err := ParseSSHConfigFile(mainConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that we got all expected hosts
|
||||||
|
expectedHosts := map[string]struct{}{
|
||||||
|
"main-host": {},
|
||||||
|
"included-host": {},
|
||||||
|
"sub-host": {},
|
||||||
|
"another-host": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hosts) != len(expectedHosts) {
|
||||||
|
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
if _, exists := expectedHosts[host.Name]; !exists {
|
||||||
|
t.Errorf("Unexpected host found: %s", host.Name)
|
||||||
|
}
|
||||||
|
delete(expectedHosts, host.Name)
|
||||||
|
|
||||||
|
// Validate specific host properties
|
||||||
|
switch host.Name {
|
||||||
|
case "main-host":
|
||||||
|
if host.Hostname != "example.com" || host.User != "mainuser" {
|
||||||
|
t.Errorf("main-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||||
|
}
|
||||||
|
case "included-host":
|
||||||
|
if host.Hostname != "included.example.com" || host.User != "includeduser" || host.Port != "2222" {
|
||||||
|
t.Errorf("included-host properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port)
|
||||||
|
}
|
||||||
|
case "sub-host":
|
||||||
|
if host.Hostname != "sub.example.com" || host.User != "subuser" || host.Identity != "~/.ssh/sub_key" {
|
||||||
|
t.Errorf("sub-host properties incorrect: hostname=%s, user=%s, identity=%s", host.Hostname, host.User, host.Identity)
|
||||||
|
}
|
||||||
|
case "another-host":
|
||||||
|
if host.Hostname != "another.example.com" || host.User != "anotheruser" {
|
||||||
|
t.Errorf("another-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that all expected hosts were found
|
||||||
|
if len(expectedHosts) > 0 {
|
||||||
|
var missing []string
|
||||||
|
for host := range expectedHosts {
|
||||||
|
missing = append(missing, host)
|
||||||
|
}
|
||||||
|
t.Errorf("Missing hosts: %v", missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSSHConfigWithCircularInclude(t *testing.T) {
|
||||||
|
// Create temporary directory for test files
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create config1 that includes config2
|
||||||
|
config1 := filepath.Join(tempDir, "config1")
|
||||||
|
config1Content := `Host host1
|
||||||
|
HostName example1.com
|
||||||
|
|
||||||
|
Include config2
|
||||||
|
`
|
||||||
|
|
||||||
|
err := os.WriteFile(config1, []byte(config1Content), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create config1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config2 that includes config1 (circular)
|
||||||
|
config2 := filepath.Join(tempDir, "config2")
|
||||||
|
config2Content := `Host host2
|
||||||
|
HostName example2.com
|
||||||
|
|
||||||
|
Include config1
|
||||||
|
`
|
||||||
|
|
||||||
|
err = os.WriteFile(config2, []byte(config2Content), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create config2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the config file - should not cause infinite recursion
|
||||||
|
hosts, err := ParseSSHConfigFile(config1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should get both hosts exactly once
|
||||||
|
expectedHosts := map[string]bool{
|
||||||
|
"host1": false,
|
||||||
|
"host2": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
if _, exists := expectedHosts[host.Name]; !exists {
|
||||||
|
t.Errorf("Unexpected host found: %s", host.Name)
|
||||||
|
} else {
|
||||||
|
if expectedHosts[host.Name] {
|
||||||
|
t.Errorf("Host %s found multiple times", host.Name)
|
||||||
|
}
|
||||||
|
expectedHosts[host.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all hosts were found
|
||||||
|
for hostName, found := range expectedHosts {
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Host %s not found", hostName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSSHConfigWithNonExistentInclude(t *testing.T) {
|
||||||
|
// Create temporary directory for test files
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create main config file with non-existent include
|
||||||
|
mainConfig := filepath.Join(tempDir, "config")
|
||||||
|
mainConfigContent := `Host main-host
|
||||||
|
HostName example.com
|
||||||
|
|
||||||
|
Include non-existent-file.conf
|
||||||
|
|
||||||
|
Host another-host
|
||||||
|
HostName another.example.com
|
||||||
|
`
|
||||||
|
|
||||||
|
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create main config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse should succeed and ignore the non-existent include
|
||||||
|
hosts, err := ParseSSHConfigFile(mainConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should get the hosts that exist, ignoring the failed include
|
||||||
|
if len(hosts) != 2 {
|
||||||
|
t.Errorf("Expected 2 hosts, got %d", len(hosts))
|
||||||
|
}
|
||||||
|
|
||||||
|
hostNames := make(map[string]bool)
|
||||||
|
for _, host := range hosts {
|
||||||
|
hostNames[host.Name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hostNames["main-host"] || !hostNames["another-host"] {
|
||||||
|
t.Errorf("Expected main-host and another-host, got: %v", hostNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSSHConfigWithWildcardHosts(t *testing.T) {
|
||||||
|
// Create temporary directory for test files
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create config file with wildcard hosts
|
||||||
|
configFile := filepath.Join(tempDir, "config")
|
||||||
|
configContent := `# Wildcard patterns should be ignored
|
||||||
|
Host *.example.com
|
||||||
|
User defaultuser
|
||||||
|
IdentityFile ~/.ssh/id_rsa
|
||||||
|
|
||||||
|
Host server-*
|
||||||
|
Port 2222
|
||||||
|
|
||||||
|
Host *
|
||||||
|
ServerAliveInterval 60
|
||||||
|
|
||||||
|
# Real hosts should be included
|
||||||
|
Host real-server
|
||||||
|
HostName real.example.com
|
||||||
|
User realuser
|
||||||
|
|
||||||
|
Host another-real-server
|
||||||
|
HostName another.example.com
|
||||||
|
User anotheruser
|
||||||
|
`
|
||||||
|
|
||||||
|
err := os.WriteFile(configFile, []byte(configContent), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the config file
|
||||||
|
hosts, err := ParseSSHConfigFile(configFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should only get real hosts, not wildcard patterns
|
||||||
|
expectedHosts := map[string]bool{
|
||||||
|
"real-server": false,
|
||||||
|
"another-real-server": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hosts) != len(expectedHosts) {
|
||||||
|
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
|
||||||
|
for _, host := range hosts {
|
||||||
|
t.Logf("Found host: %s", host.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
if _, expected := expectedHosts[host.Name]; !expected {
|
||||||
|
t.Errorf("Unexpected host found: %s", host.Name)
|
||||||
|
} else {
|
||||||
|
expectedHosts[host.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that all expected hosts were found
|
||||||
|
for hostName, found := range expectedHosts {
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Expected host %s not found", hostName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify host properties
|
||||||
|
for _, host := range hosts {
|
||||||
|
switch host.Name {
|
||||||
|
case "real-server":
|
||||||
|
if host.Hostname != "real.example.com" || host.User != "realuser" {
|
||||||
|
t.Errorf("real-server properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||||
|
}
|
||||||
|
case "another-real-server":
|
||||||
|
if host.Hostname != "another.example.com" || host.User != "anotheruser" {
|
||||||
|
t.Errorf("another-real-server properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
109
internal/ui/help_form.go
Normal file
109
internal/ui/help_form.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type helpModel struct {
|
||||||
|
styles Styles
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpCloseMsg is sent when the help window is closed
|
||||||
|
type helpCloseMsg struct{}
|
||||||
|
|
||||||
|
// NewHelpForm creates a new help form model
|
||||||
|
func NewHelpForm(styles Styles, width, height int) *helpModel {
|
||||||
|
return &helpModel{
|
||||||
|
styles: styles,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *helpModel) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *helpModel) Update(msg tea.Msg) (*helpModel, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "q", "h", "enter", "ctrl+c":
|
||||||
|
return m, func() tea.Msg { return helpCloseMsg{} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *helpModel) View() string {
|
||||||
|
// Title
|
||||||
|
title := m.styles.Header.Render("📖 SSHM - Help & Commands")
|
||||||
|
|
||||||
|
// Create horizontal sections with compact layout
|
||||||
|
line1 := lipgloss.JoinHorizontal(lipgloss.Center,
|
||||||
|
m.styles.FocusedLabel.Render("🧭 ↑/↓/j/k"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("navigate"),
|
||||||
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("⏎"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("connect"),
|
||||||
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("a/e/d"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("add/edit/delete"),
|
||||||
|
)
|
||||||
|
|
||||||
|
line2 := lipgloss.JoinHorizontal(lipgloss.Center,
|
||||||
|
m.styles.FocusedLabel.Render("Tab"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("switch focus"),
|
||||||
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("f"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("port forward"),
|
||||||
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("s/r/n"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("sort modes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
line3 := lipgloss.JoinHorizontal(lipgloss.Center,
|
||||||
|
m.styles.FocusedLabel.Render("/"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("search"),
|
||||||
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("h"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("help"),
|
||||||
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("q/ESC"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("quit"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create the main content
|
||||||
|
content := lipgloss.JoinVertical(lipgloss.Center,
|
||||||
|
title,
|
||||||
|
"",
|
||||||
|
line1,
|
||||||
|
"",
|
||||||
|
line2,
|
||||||
|
"",
|
||||||
|
line3,
|
||||||
|
"",
|
||||||
|
m.styles.HelpText.Render("Press ESC, h, q or Enter to close"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Center the help window
|
||||||
|
return lipgloss.Place(
|
||||||
|
m.width,
|
||||||
|
m.height,
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
m.styles.FormContainer.Render(content),
|
||||||
|
)
|
||||||
|
}
|
||||||
227
internal/ui/info_form.go
Normal file
227
internal/ui/info_form.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sshm/internal/config"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type infoFormModel struct {
|
||||||
|
host *config.SSHHost
|
||||||
|
styles Styles
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
configFile string
|
||||||
|
hostName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages for communication with parent model
|
||||||
|
type infoFormEditMsg struct {
|
||||||
|
hostName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type infoFormCancelMsg struct{}
|
||||||
|
|
||||||
|
// NewInfoForm creates a new info form model for displaying host details in read-only mode
|
||||||
|
func NewInfoForm(hostName string, styles Styles, width, height int, configFile string) (*infoFormModel, error) {
|
||||||
|
// Get the existing host configuration
|
||||||
|
var host *config.SSHHost
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if configFile != "" {
|
||||||
|
host, err = config.GetSSHHostFromFile(hostName, configFile)
|
||||||
|
} else {
|
||||||
|
host, err = config.GetSSHHost(hostName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &infoFormModel{
|
||||||
|
host: host,
|
||||||
|
hostName: hostName,
|
||||||
|
configFile: configFile,
|
||||||
|
styles: styles,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *infoFormModel) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *infoFormModel) Update(msg tea.Msg) (*infoFormModel, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
m.styles = NewStyles(m.width)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "ctrl+c", "esc", "q":
|
||||||
|
return m, func() tea.Msg { return infoFormCancelMsg{} }
|
||||||
|
|
||||||
|
case "e", "enter":
|
||||||
|
// Switch to edit mode
|
||||||
|
return m, func() tea.Msg { return infoFormEditMsg{hostName: m.hostName} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *infoFormModel) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// Title
|
||||||
|
title := fmt.Sprintf("SSH Host Information: %s", m.host.Name)
|
||||||
|
b.WriteString(m.styles.FormTitle.Render(title))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Create info sections with consistent formatting
|
||||||
|
sections := []struct {
|
||||||
|
label string
|
||||||
|
value string
|
||||||
|
}{
|
||||||
|
{"Host Name", m.host.Name},
|
||||||
|
{"Hostname/IP", m.host.Hostname},
|
||||||
|
{"User", formatOptionalValue(m.host.User)},
|
||||||
|
{"Port", formatOptionalValue(m.host.Port)},
|
||||||
|
{"Identity File", formatOptionalValue(m.host.Identity)},
|
||||||
|
{"ProxyJump", formatOptionalValue(m.host.ProxyJump)},
|
||||||
|
{"SSH Options", formatSSHOptions(m.host.Options)},
|
||||||
|
{"Tags", formatTags(m.host.Tags)},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render each section
|
||||||
|
for _, section := range sections {
|
||||||
|
// Label style
|
||||||
|
labelStyle := lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("39")). // Bright blue
|
||||||
|
Width(15).
|
||||||
|
AlignHorizontal(lipgloss.Right)
|
||||||
|
|
||||||
|
// Value style
|
||||||
|
valueStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("255")) // White
|
||||||
|
|
||||||
|
// If value is empty or default, use a muted style
|
||||||
|
if section.value == "Not set" || section.value == "22" && section.label == "Port" {
|
||||||
|
valueStyle = valueStyle.Foreground(lipgloss.Color("243")) // Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
line := lipgloss.JoinHorizontal(
|
||||||
|
lipgloss.Top,
|
||||||
|
labelStyle.Render(section.label+":"),
|
||||||
|
" ",
|
||||||
|
valueStyle.Render(section.value),
|
||||||
|
)
|
||||||
|
b.WriteString(line)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Action instructions
|
||||||
|
helpStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("243")).
|
||||||
|
Italic(true)
|
||||||
|
|
||||||
|
b.WriteString(helpStyle.Render("Actions:"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
actionStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("120")). // Green
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(actionStyle.Render("e/Enter"))
|
||||||
|
b.WriteString(helpStyle.Render(" - Switch to edit mode"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(actionStyle.Render("q/Esc"))
|
||||||
|
b.WriteString(helpStyle.Render(" - Return to host list"))
|
||||||
|
|
||||||
|
// Wrap in a border for better visual separation
|
||||||
|
content := b.String()
|
||||||
|
|
||||||
|
borderStyle := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("39")).
|
||||||
|
Padding(1).
|
||||||
|
Margin(1)
|
||||||
|
|
||||||
|
// Center the info window
|
||||||
|
return lipgloss.Place(
|
||||||
|
m.width,
|
||||||
|
m.height,
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
borderStyle.Render(content),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for formatting values
|
||||||
|
|
||||||
|
func formatOptionalValue(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return "Not set"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSSHOptions(options string) string {
|
||||||
|
if options == "" {
|
||||||
|
return "Not set"
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTags(tags []string) string {
|
||||||
|
if len(tags) == 0 {
|
||||||
|
return "Not set"
|
||||||
|
}
|
||||||
|
return strings.Join(tags, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone wrapper for info form (for testing or standalone use)
|
||||||
|
type standaloneInfoForm struct {
|
||||||
|
*infoFormModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m standaloneInfoForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.(type) {
|
||||||
|
case infoFormCancelMsg:
|
||||||
|
return m, tea.Quit
|
||||||
|
case infoFormEditMsg:
|
||||||
|
// For standalone mode, just quit - parent should handle edit transition
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
|
||||||
|
newForm, cmd := m.infoFormModel.Update(msg)
|
||||||
|
m.infoFormModel = newForm
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunInfoForm provides a standalone info form for testing
|
||||||
|
func RunInfoForm(hostName string, configFile string) error {
|
||||||
|
styles := NewStyles(80)
|
||||||
|
infoForm, err := NewInfoForm(hostName, styles, 80, 24, configFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m := standaloneInfoForm{infoForm}
|
||||||
|
|
||||||
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
|
_, err = p.Run()
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -35,7 +35,9 @@ const (
|
|||||||
ViewList ViewMode = iota
|
ViewList ViewMode = iota
|
||||||
ViewAdd
|
ViewAdd
|
||||||
ViewEdit
|
ViewEdit
|
||||||
|
ViewInfo
|
||||||
ViewPortForward
|
ViewPortForward
|
||||||
|
ViewHelp
|
||||||
)
|
)
|
||||||
|
|
||||||
// PortForwardType defines the type of port forwarding
|
// PortForwardType defines the type of port forwarding
|
||||||
@@ -77,7 +79,9 @@ type Model struct {
|
|||||||
viewMode ViewMode
|
viewMode ViewMode
|
||||||
addForm *addFormModel
|
addForm *addFormModel
|
||||||
editForm *editFormModel
|
editForm *editFormModel
|
||||||
|
infoForm *infoFormModel
|
||||||
portForwardForm *portForwardModel
|
portForwardForm *portForwardModel
|
||||||
|
helpForm *helpModel
|
||||||
|
|
||||||
// Terminal size and styles
|
// Terminal size and styles
|
||||||
width int
|
width int
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type Styles struct {
|
|||||||
FormContainer lipgloss.Style
|
FormContainer lipgloss.Style
|
||||||
Label lipgloss.Style
|
Label lipgloss.Style
|
||||||
FocusedLabel lipgloss.Style
|
FocusedLabel lipgloss.Style
|
||||||
|
HelpSection lipgloss.Style
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStyles creates a new Styles struct with the given terminal width
|
// NewStyles creates a new Styles struct with the given terminal width
|
||||||
@@ -88,8 +89,7 @@ func NewStyles(width int) Styles {
|
|||||||
Foreground(lipgloss.Color(SecondaryColor)),
|
Foreground(lipgloss.Color(SecondaryColor)),
|
||||||
|
|
||||||
HelpText: lipgloss.NewStyle().
|
HelpText: lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color(SecondaryColor)).
|
Foreground(lipgloss.Color(SecondaryColor)),
|
||||||
MarginTop(1),
|
|
||||||
|
|
||||||
// Error style
|
// Error style
|
||||||
Error: lipgloss.NewStyle().
|
Error: lipgloss.NewStyle().
|
||||||
@@ -118,8 +118,10 @@ func NewStyles(width int) Styles {
|
|||||||
Foreground(lipgloss.Color(SecondaryColor)),
|
Foreground(lipgloss.Color(SecondaryColor)),
|
||||||
|
|
||||||
FocusedLabel: lipgloss.NewStyle().
|
FocusedLabel: lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color(PrimaryColor)).
|
Foreground(lipgloss.Color(PrimaryColor)),
|
||||||
Bold(true),
|
|
||||||
|
HelpSection: lipgloss.NewStyle().
|
||||||
|
Padding(0, 2),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ func (m *Model) updateTableRows() {
|
|||||||
rows = append(rows, table.Row{
|
rows = append(rows, table.Row{
|
||||||
host.Name,
|
host.Name,
|
||||||
host.Hostname,
|
host.Hostname,
|
||||||
host.User,
|
// host.User, // Commented to save space
|
||||||
host.Port,
|
// host.Port, // Commented to save space
|
||||||
tagsStr,
|
tagsStr,
|
||||||
lastLoginStr,
|
lastLoginStr,
|
||||||
})
|
})
|
||||||
@@ -146,16 +146,17 @@ func (m *Model) updateTableHeight() {
|
|||||||
// Layout breakdown:
|
// Layout breakdown:
|
||||||
// - ASCII title: 5 lines (1 empty + 4 text lines)
|
// - ASCII title: 5 lines (1 empty + 4 text lines)
|
||||||
// - Search bar: 1 line
|
// - Search bar: 1 line
|
||||||
// - Sort info: 1 line
|
// - Help text: 1 line
|
||||||
// - Help text: 2 lines (multi-line text)
|
// - App margins/spacing: 3 lines
|
||||||
// - App margins/spacing: 2 lines
|
// - Safety margin: 3 lines (to ensure UI elements are always visible)
|
||||||
// Total reserved: 11 lines, mais réduisons à 7 pour forcer plus d'espace
|
// Total reserved: 12 lines minimum to preserve essential UI elements
|
||||||
reservedHeight := 7 // Réduction agressive pour tester
|
reservedHeight := 12
|
||||||
availableHeight := m.height - reservedHeight
|
availableHeight := m.height - reservedHeight
|
||||||
hostCount := len(m.table.Rows())
|
hostCount := len(m.table.Rows())
|
||||||
|
|
||||||
// Minimum height should be at least 5 rows for usability
|
// Minimum height should be at least 3 rows for basic usability
|
||||||
minTableHeight := 6 // 1 header + 5 data rows
|
// Even in very small terminals, we want to show at least header + 2 hosts
|
||||||
|
minTableHeight := 4 // 1 header + 3 data rows minimum
|
||||||
maxTableHeight := availableHeight
|
maxTableHeight := availableHeight
|
||||||
if maxTableHeight < minTableHeight {
|
if maxTableHeight < minTableHeight {
|
||||||
maxTableHeight = minTableHeight
|
maxTableHeight = minTableHeight
|
||||||
@@ -173,9 +174,6 @@ func (m *Model) updateTableHeight() {
|
|||||||
tableHeight += maxDataRows
|
tableHeight += maxDataRows
|
||||||
}
|
}
|
||||||
|
|
||||||
// FORCE: Ajoutons une ligne supplémentaire pour résoudre le problème
|
|
||||||
tableHeight += 1
|
|
||||||
|
|
||||||
// Update table height
|
// Update table height
|
||||||
m.table.SetHeight(tableHeight)
|
m.table.SetHeight(tableHeight)
|
||||||
}
|
}
|
||||||
@@ -198,11 +196,11 @@ func (m *Model) updateTableColumns() {
|
|||||||
|
|
||||||
// Fixed column widths
|
// Fixed column widths
|
||||||
hostnameWidth := 25
|
hostnameWidth := 25
|
||||||
userWidth := 12
|
// userWidth := 12 // Commented to save space
|
||||||
portWidth := 6
|
// portWidth := 6 // Commented to save space
|
||||||
|
|
||||||
// Calculate total width needed for all columns
|
// Calculate total width needed for all columns
|
||||||
totalFixedWidth := hostnameWidth + userWidth + portWidth
|
totalFixedWidth := hostnameWidth // + userWidth + portWidth // Commented columns
|
||||||
totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth
|
totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth
|
||||||
totalWidth := totalFixedWidth + totalVariableWidth
|
totalWidth := totalFixedWidth + totalVariableWidth
|
||||||
|
|
||||||
@@ -226,14 +224,25 @@ func (m *Model) updateTableColumns() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new columns with updated widths
|
// Create new columns with updated widths and sort indicators
|
||||||
|
nameTitle := "Name"
|
||||||
|
lastLoginTitle := "Last Login"
|
||||||
|
|
||||||
|
// Add sort indicators based on current sort mode
|
||||||
|
switch m.sortMode {
|
||||||
|
case SortByName:
|
||||||
|
nameTitle += " ↓"
|
||||||
|
case SortByLastUsed:
|
||||||
|
lastLoginTitle += " ↓"
|
||||||
|
}
|
||||||
|
|
||||||
columns := []table.Column{
|
columns := []table.Column{
|
||||||
{Title: "Name", Width: nameWidth},
|
{Title: nameTitle, Width: nameWidth},
|
||||||
{Title: "Hostname", Width: hostnameWidth},
|
{Title: "Hostname", Width: hostnameWidth},
|
||||||
{Title: "User", Width: userWidth},
|
// {Title: "User", Width: userWidth}, // Commented to save space
|
||||||
{Title: "Port", Width: portWidth},
|
// {Title: "Port", Width: portWidth}, // Commented to save space
|
||||||
{Title: "Tags", Width: tagsWidth},
|
{Title: "Tags", Width: tagsWidth},
|
||||||
{Title: "Last Login", Width: lastLoginWidth},
|
{Title: lastLoginTitle, Width: lastLoginWidth},
|
||||||
}
|
}
|
||||||
|
|
||||||
m.table.SetColumns(columns)
|
m.table.SetColumns(columns)
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
|||||||
columns := []table.Column{
|
columns := []table.Column{
|
||||||
{Title: "Name", Width: nameWidth},
|
{Title: "Name", Width: nameWidth},
|
||||||
{Title: "Hostname", Width: 25},
|
{Title: "Hostname", Width: 25},
|
||||||
{Title: "User", Width: 12},
|
// {Title: "User", Width: 12}, // Commented to save space
|
||||||
{Title: "Port", Width: 6},
|
// {Title: "Port", Width: 6}, // Commented to save space
|
||||||
{Title: "Tags", Width: tagsWidth},
|
{Title: "Tags", Width: tagsWidth},
|
||||||
{Title: "Last Login", Width: lastLoginWidth},
|
{Title: "Last Login", Width: lastLoginWidth},
|
||||||
}
|
}
|
||||||
@@ -92,8 +92,8 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
|||||||
rows = append(rows, table.Row{
|
rows = append(rows, table.Row{
|
||||||
host.Name,
|
host.Name,
|
||||||
host.Hostname,
|
host.Hostname,
|
||||||
host.User,
|
// host.User, // Commented to save space
|
||||||
host.Port,
|
// host.Port, // Commented to save space
|
||||||
tagsStr,
|
tagsStr,
|
||||||
lastLoginStr,
|
lastLoginStr,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,11 +46,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.editForm.height = m.height
|
m.editForm.height = m.height
|
||||||
m.editForm.styles = m.styles
|
m.editForm.styles = m.styles
|
||||||
}
|
}
|
||||||
|
if m.infoForm != nil {
|
||||||
|
m.infoForm.width = m.width
|
||||||
|
m.infoForm.height = m.height
|
||||||
|
m.infoForm.styles = m.styles
|
||||||
|
}
|
||||||
if m.portForwardForm != nil {
|
if m.portForwardForm != nil {
|
||||||
m.portForwardForm.width = m.width
|
m.portForwardForm.width = m.width
|
||||||
m.portForwardForm.height = m.height
|
m.portForwardForm.height = m.height
|
||||||
m.portForwardForm.styles = m.styles
|
m.portForwardForm.styles = m.styles
|
||||||
}
|
}
|
||||||
|
if m.helpForm != nil {
|
||||||
|
m.helpForm.width = m.width
|
||||||
|
m.helpForm.height = m.height
|
||||||
|
m.helpForm.styles = m.styles
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case addFormSubmitMsg:
|
case addFormSubmitMsg:
|
||||||
@@ -141,6 +151,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.table.Focus()
|
m.table.Focus()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case infoFormCancelMsg:
|
||||||
|
// Cancel: return to list view
|
||||||
|
m.viewMode = ViewList
|
||||||
|
m.infoForm = nil
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case infoFormEditMsg:
|
||||||
|
// Switch from info to edit mode
|
||||||
|
editForm, err := NewEditForm(msg.hostName, m.styles, m.width, m.height, m.configFile)
|
||||||
|
if err != nil {
|
||||||
|
// Handle error - could show in UI, for now just go back to list
|
||||||
|
m.viewMode = ViewList
|
||||||
|
m.infoForm = nil
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.editForm = editForm
|
||||||
|
m.infoForm = nil
|
||||||
|
m.viewMode = ViewEdit
|
||||||
|
return m, textinput.Blink
|
||||||
|
|
||||||
case portForwardSubmitMsg:
|
case portForwardSubmitMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
// Show error in form
|
// Show error in form
|
||||||
@@ -180,6 +212,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.table.Focus()
|
m.table.Focus()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case helpCloseMsg:
|
||||||
|
// Close help: return to list view
|
||||||
|
m.viewMode = ViewList
|
||||||
|
m.helpForm = nil
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
// Handle view-specific key presses
|
// Handle view-specific key presses
|
||||||
switch m.viewMode {
|
switch m.viewMode {
|
||||||
@@ -197,6 +236,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.editForm = newForm
|
m.editForm = newForm
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
case ViewInfo:
|
||||||
|
if m.infoForm != nil {
|
||||||
|
var newForm *infoFormModel
|
||||||
|
newForm, cmd = m.infoForm.Update(msg)
|
||||||
|
m.infoForm = newForm
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
case ViewPortForward:
|
case ViewPortForward:
|
||||||
if m.portForwardForm != nil {
|
if m.portForwardForm != nil {
|
||||||
var newForm *portForwardModel
|
var newForm *portForwardModel
|
||||||
@@ -204,6 +250,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.portForwardForm = newForm
|
m.portForwardForm = newForm
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
case ViewHelp:
|
||||||
|
if m.helpForm != nil {
|
||||||
|
var newForm *helpModel
|
||||||
|
newForm, cmd = m.helpForm.Update(msg)
|
||||||
|
m.helpForm = newForm
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
case ViewList:
|
case ViewList:
|
||||||
// Handle list view keys
|
// Handle list view keys
|
||||||
return m.handleListViewKeys(msg)
|
return m.handleListViewKeys(msg)
|
||||||
@@ -356,6 +409,22 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, textinput.Blink
|
return m, textinput.Blink
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "i":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Show info for the selected host
|
||||||
|
selected := m.table.SelectedRow()
|
||||||
|
if len(selected) > 0 {
|
||||||
|
hostName := selected[0] // The hostname is in the first column
|
||||||
|
infoForm, err := NewInfoForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||||
|
if err != nil {
|
||||||
|
// Handle error - could show in UI
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.infoForm = infoForm
|
||||||
|
m.viewMode = ViewInfo
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
case "a":
|
case "a":
|
||||||
if !m.searchMode && !m.deleteMode {
|
if !m.searchMode && !m.deleteMode {
|
||||||
// Add a new host
|
// Add a new host
|
||||||
@@ -386,6 +455,13 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, textinput.Blink
|
return m, textinput.Blink
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "h":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Show help
|
||||||
|
m.helpForm = NewHelpForm(m.styles, m.width, m.height)
|
||||||
|
m.viewMode = ViewHelp
|
||||||
|
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)
|
||||||
|
|||||||
@@ -23,10 +23,18 @@ func (m Model) View() string {
|
|||||||
if m.editForm != nil {
|
if m.editForm != nil {
|
||||||
return m.editForm.View()
|
return m.editForm.View()
|
||||||
}
|
}
|
||||||
|
case ViewInfo:
|
||||||
|
if m.infoForm != nil {
|
||||||
|
return m.infoForm.View()
|
||||||
|
}
|
||||||
case ViewPortForward:
|
case ViewPortForward:
|
||||||
if m.portForwardForm != nil {
|
if m.portForwardForm != nil {
|
||||||
return m.portForwardForm.View()
|
return m.portForwardForm.View()
|
||||||
}
|
}
|
||||||
|
case ViewHelp:
|
||||||
|
if m.helpForm != nil {
|
||||||
|
return m.helpForm.View()
|
||||||
|
}
|
||||||
case ViewList:
|
case ViewList:
|
||||||
return m.renderListView()
|
return m.renderListView()
|
||||||
}
|
}
|
||||||
@@ -50,10 +58,6 @@ func (m Model) renderListView() string {
|
|||||||
components = append(components, m.styles.SearchUnfocused.Render(searchPrompt+m.searchInput.View()))
|
components = append(components, m.styles.SearchUnfocused.Render(searchPrompt+m.searchInput.View()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the sort mode indicator
|
|
||||||
sortInfo := fmt.Sprintf(" Sort: %s", m.sortMode.String())
|
|
||||||
components = append(components, m.styles.SortInfo.Render(sortInfo))
|
|
||||||
|
|
||||||
// Add the table with the appropriate style based on focus
|
// Add the table with the appropriate style based on focus
|
||||||
if m.searchMode {
|
if m.searchMode {
|
||||||
// The table is not focused, use the unfocused style
|
// The table is not focused, use the unfocused style
|
||||||
@@ -66,9 +70,9 @@ 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 = " Use ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • (f)orward • / to search • Tab to switch\n Sort: (s)witch • (r)ecent • (n)ame • q/ESC to quit"
|
helpText = " ↑/↓: navigate • Enter: connect • i: info • h: help • q: quit"
|
||||||
} else {
|
} else {
|
||||||
helpText = " Type to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit"
|
helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit"
|
||||||
}
|
}
|
||||||
components = append(components, m.styles.HelpText.Render(helpText))
|
components = append(components, m.styles.HelpText.Render(helpText))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user