mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2026-01-27 03:04:21 +01:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be3dcaa1cd | |||
| b67f5abbbc | |||
| b587defabc | |||
| 22586484c7 | |||
| 420db56ff5 | |||
|
|
7600eaaa9b | ||
|
|
e0dd32993a | ||
| 1cea3795e4 | |||
| 2ade315ddc |
50
README.md
50
README.md
@@ -34,6 +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
|
||||
- **🔒 Secure** - Works directly with your existing `~/.ssh/config` file
|
||||
- **📁 Custom Config Support** - Use any SSH configuration file with the `-c` flag
|
||||
- **📂 SSH Include Support** - Full support for SSH Include directives to organize configurations across multiple files
|
||||
- **⚙️ SSH Options Support** - Add any SSH configuration option through intuitive forms
|
||||
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
|
||||
|
||||
@@ -272,7 +273,55 @@ sshm edit hostname -c /path/to/custom/ssh_config
|
||||
|
||||
SSHM works directly with your standard SSH configuration file (`~/.ssh/config`). It adds special comment tags for enhanced functionality while maintaining full compatibility with standard SSH tools.
|
||||
|
||||
### SSH Include Support
|
||||
|
||||
SSHM fully supports SSH Include directives, allowing you to organize your SSH configurations across multiple files. This is particularly useful for managing large numbers of hosts or organizing configurations by environment, project, or team.
|
||||
|
||||
**Include Examples:**
|
||||
```ssh
|
||||
# Main ~/.ssh/config file
|
||||
Host personal-server
|
||||
HostName personal.example.com
|
||||
User myuser
|
||||
|
||||
# Include work-related configurations
|
||||
Include work-servers.conf
|
||||
|
||||
# Include all configurations from a directory
|
||||
Include projects/*
|
||||
|
||||
# Include with relative paths
|
||||
Include ~/.ssh/configs/production.conf
|
||||
```
|
||||
|
||||
**Organization Examples:**
|
||||
|
||||
*work-servers.conf:*
|
||||
```ssh
|
||||
# Tags: work, production
|
||||
Host prod-web-01
|
||||
HostName 10.0.1.10
|
||||
User deploy
|
||||
ProxyJump bastion.company.com
|
||||
|
||||
# Tags: work, staging
|
||||
Host staging-api
|
||||
HostName staging-api.company.com
|
||||
User developer
|
||||
```
|
||||
|
||||
*projects/client-alpha.conf:*
|
||||
```ssh
|
||||
# Tags: client, development
|
||||
Host client-alpha-dev
|
||||
HostName dev.client-alpha.com
|
||||
User admin
|
||||
Port 2222
|
||||
```
|
||||
|
||||
**Example configuration:**
|
||||
Include ~/.ssh/conf.d/*
|
||||
|
||||
```ssh
|
||||
# Tags: production, web, frontend
|
||||
Host web-prod-01
|
||||
@@ -452,6 +501,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
||||
|
||||
- [Charm](https://charm.sh/) for the amazing TUI libraries
|
||||
- [Cobra](https://cobra.dev/) for the excellent CLI framework
|
||||
- [@yimeng](https://github.com/yimeng) for contributing SSH Include directive support
|
||||
- The Go community for building such fantastic tools
|
||||
|
||||
---
|
||||
|
||||
@@ -74,10 +74,10 @@ downloadBinary() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find the extracted binary
|
||||
EXTRACTED_BINARY=$(find . -name "sshm-${OS}-${ARCH}" -type f)
|
||||
if [ -z "$EXTRACTED_BINARY" ]; then
|
||||
printf "${RED}Could not find extracted binary${NC}\n"
|
||||
# Check if the expected binary exists (no find needed)
|
||||
EXTRACTED_BINARY="./sshm-${OS}-${ARCH}"
|
||||
if [ ! -f "$EXTRACTED_BINARY" ]; then
|
||||
printf "${RED}Could not find extracted binary: $EXTRACTED_BINARY${NC}\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -88,17 +88,37 @@ downloadBinary() {
|
||||
install() {
|
||||
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"
|
||||
if [ $? -ne 0 ]; then
|
||||
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
|
||||
fi
|
||||
|
||||
runAsRoot mv "sshm-tmp" "$EXECUTABLE_PATH"
|
||||
if [ $? -ne 0 ]; then
|
||||
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
|
||||
fi
|
||||
|
||||
# Clean up backup if installation succeeded
|
||||
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
|
||||
runAsRoot rm -f "$OLD_BACKUP"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
@@ -161,7 +181,8 @@ main() {
|
||||
# Show version
|
||||
printf "${YELLOW}Verifying installation...${NC}\n"
|
||||
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
|
||||
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
|
||||
|
||||
@@ -13,14 +13,15 @@ import (
|
||||
|
||||
// SSHHost represents an SSH host configuration
|
||||
type SSHHost struct {
|
||||
Name string
|
||||
Hostname string
|
||||
User string
|
||||
Port string
|
||||
Identity string
|
||||
ProxyJump string
|
||||
Options string
|
||||
Tags []string
|
||||
Name string
|
||||
Hostname string
|
||||
User string
|
||||
Port string
|
||||
Identity string
|
||||
ProxyJump string
|
||||
Options string
|
||||
Tags []string
|
||||
SourceFile string // Path to the config file where this host is defined
|
||||
}
|
||||
|
||||
// GetDefaultSSHConfigPath returns the default SSH config path for the current platform
|
||||
@@ -96,25 +97,45 @@ func ParseSSHConfig() ([]SSHHost, error) {
|
||||
|
||||
// ParseSSHConfigFile parses a specific SSH config file and returns the list of hosts
|
||||
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)
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
// Ensure .ssh directory exists with proper permissions
|
||||
if err := ensureSSHDirectory(); err != nil {
|
||||
return nil, fmt.Errorf("failed to create .ssh directory: %w", err)
|
||||
// Only create the main config file, not included files
|
||||
if absPath == getMainConfigPath() {
|
||||
// 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)
|
||||
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
|
||||
// File doesn't exist, return empty host list
|
||||
return []SSHHost{}, nil
|
||||
}
|
||||
|
||||
@@ -168,16 +189,31 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
||||
value := strings.Join(parts[1:], " ")
|
||||
|
||||
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":
|
||||
// New host, save previous one if it exists
|
||||
if currentHost != nil {
|
||||
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
|
||||
currentHost = &SSHHost{
|
||||
Name: value,
|
||||
Port: "22", // Default port
|
||||
Tags: pendingTags, // Assign pending tags to this host
|
||||
Name: value,
|
||||
Port: "22", // Default port
|
||||
Tags: pendingTags, // Assign pending tags to this host
|
||||
SourceFile: absPath, // Track which file this host comes from
|
||||
}
|
||||
// Clear pending tags for next host
|
||||
pendingTags = nil
|
||||
@@ -222,6 +258,65 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
||||
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
|
||||
}
|
||||
|
||||
// Skip backup files created by sshm (*.backup)
|
||||
if strings.HasSuffix(match, ".backup") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip markdown files (*.md)
|
||||
if strings.HasSuffix(match, ".md") {
|
||||
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
|
||||
func AddSSHHost(host SSHHost) error {
|
||||
configPath, err := GetDefaultSSHConfigPath()
|
||||
@@ -446,11 +541,7 @@ func GetSSHHostFromFile(hostName string, configPath string) (*SSHHost, error) {
|
||||
|
||||
// UpdateSSHHost updates an existing SSH host configuration
|
||||
func UpdateSSHHost(oldName string, newHost SSHHost) error {
|
||||
configPath, err := GetDefaultSSHConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return UpdateSSHHostInFile(oldName, newHost, configPath)
|
||||
return UpdateSSHHostV2(oldName, newHost)
|
||||
}
|
||||
|
||||
// UpdateSSHHostInFile updates an existing SSH host configuration in a specific file
|
||||
@@ -605,11 +696,7 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
|
||||
|
||||
// DeleteSSHHost removes an SSH host configuration from the config file
|
||||
func DeleteSSHHost(hostName string) error {
|
||||
configPath, err := GetDefaultSSHConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return DeleteSSHHostFromFile(hostName, configPath)
|
||||
return DeleteSSHHostV2(hostName)
|
||||
}
|
||||
|
||||
// DeleteSSHHostFromFile deletes an SSH host from a specific config file
|
||||
@@ -693,3 +780,115 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
|
||||
newContent := strings.Join(newLines, "\n")
|
||||
return os.WriteFile(configPath, []byte(newContent), 0600)
|
||||
}
|
||||
|
||||
// FindHostInAllConfigs finds a host in all configuration files and returns the host with its source file
|
||||
func FindHostInAllConfigs(hostName string) (*SSHHost, error) {
|
||||
hosts, err := ParseSSHConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
if host.Name == hostName {
|
||||
return &host, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("host '%s' not found in any configuration file", hostName)
|
||||
}
|
||||
|
||||
// GetAllConfigFiles returns all SSH config files (main + included files)
|
||||
func GetAllConfigFiles() ([]string, error) {
|
||||
configPath, err := GetDefaultSSHConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
processedFiles := make(map[string]bool)
|
||||
_, _ = parseSSHConfigFileWithProcessedFiles(configPath, processedFiles)
|
||||
|
||||
files := make([]string, 0, len(processedFiles))
|
||||
for file := range processedFiles {
|
||||
files = append(files, file)
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// GetAllConfigFilesFromBase returns all SSH config files starting from a specific base config file
|
||||
func GetAllConfigFilesFromBase(baseConfigPath string) ([]string, error) {
|
||||
if baseConfigPath == "" {
|
||||
// Fallback to default behavior
|
||||
return GetAllConfigFiles()
|
||||
}
|
||||
|
||||
processedFiles := make(map[string]bool)
|
||||
_, _ = parseSSHConfigFileWithProcessedFiles(baseConfigPath, processedFiles)
|
||||
|
||||
files := make([]string, 0, len(processedFiles))
|
||||
for file := range processedFiles {
|
||||
files = append(files, file)
|
||||
}
|
||||
|
||||
return files, nil
|
||||
} // UpdateSSHHostV2 updates an existing SSH host configuration, searching in all config files
|
||||
func UpdateSSHHostV2(oldName string, newHost SSHHost) error {
|
||||
// Find the host to determine which file it's in
|
||||
existingHost, err := FindHostInAllConfigs(oldName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the host in its source file
|
||||
newHost.SourceFile = existingHost.SourceFile
|
||||
return UpdateSSHHostInFile(oldName, newHost, existingHost.SourceFile)
|
||||
}
|
||||
|
||||
// DeleteSSHHostV2 removes an SSH host configuration, searching in all config files
|
||||
func DeleteSSHHostV2(hostName string) error {
|
||||
// Find the host to determine which file it's in
|
||||
existingHost, err := FindHostInAllConfigs(hostName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the host from its source file
|
||||
return DeleteSSHHostFromFile(hostName, existingHost.SourceFile)
|
||||
}
|
||||
|
||||
// AddSSHHostWithFileSelection adds a new SSH host to a user-specified config file
|
||||
func AddSSHHostWithFileSelection(host SSHHost, targetFile string) error {
|
||||
if targetFile == "" {
|
||||
// Use default file if none specified
|
||||
return AddSSHHost(host)
|
||||
}
|
||||
return AddSSHHostToFile(host, targetFile)
|
||||
}
|
||||
|
||||
// GetIncludedConfigFiles returns a list of config files that can be used for adding hosts
|
||||
func GetIncludedConfigFiles() ([]string, error) {
|
||||
allFiles, err := GetAllConfigFiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter out files that don't exist or can't be written to
|
||||
var writableFiles []string
|
||||
mainConfig, err := GetDefaultSSHConfigPath()
|
||||
if err == nil {
|
||||
writableFiles = append(writableFiles, mainConfig)
|
||||
}
|
||||
|
||||
for _, file := range allFiles {
|
||||
if file == mainConfig {
|
||||
continue // Already added
|
||||
}
|
||||
|
||||
// Check if file exists and is writable
|
||||
if info, err := os.Stat(file); err == nil && !info.IsDir() {
|
||||
writableFiles = append(writableFiles, file)
|
||||
}
|
||||
}
|
||||
|
||||
return writableFiles, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
@@ -71,3 +72,628 @@ func TestEnsureSSHDirectory(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
if host.SourceFile != mainConfig {
|
||||
t.Errorf("main-host SourceFile incorrect: expected=%s, got=%s", mainConfig, host.SourceFile)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if host.SourceFile != includedConfig {
|
||||
t.Errorf("included-host SourceFile incorrect: expected=%s, got=%s", includedConfig, host.SourceFile)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if host.SourceFile != subConfig {
|
||||
t.Errorf("sub-host SourceFile incorrect: expected=%s, got=%s", subConfig, host.SourceFile)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if host.SourceFile != mainConfig {
|
||||
t.Errorf("another-host SourceFile incorrect: expected=%s, got=%s", mainConfig, host.SourceFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSHConfigExcludesBackupFiles(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create main config file with include pattern
|
||||
mainConfig := filepath.Join(tempDir, "config")
|
||||
mainConfigContent := `Host main-host
|
||||
HostName example.com
|
||||
|
||||
Include *.conf
|
||||
`
|
||||
|
||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main config: %v", err)
|
||||
}
|
||||
|
||||
// Create a regular config file
|
||||
regularConfig := filepath.Join(tempDir, "regular.conf")
|
||||
regularConfigContent := `Host regular-host
|
||||
HostName regular.example.com
|
||||
`
|
||||
|
||||
err = os.WriteFile(regularConfig, []byte(regularConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create regular config: %v", err)
|
||||
}
|
||||
|
||||
// Create a backup file that should be excluded
|
||||
backupConfig := filepath.Join(tempDir, "regular.conf.backup")
|
||||
backupConfigContent := `Host backup-host
|
||||
HostName backup.example.com
|
||||
`
|
||||
|
||||
err = os.WriteFile(backupConfig, []byte(backupConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create backup config: %v", err)
|
||||
}
|
||||
|
||||
// Parse the config file
|
||||
hosts, err := ParseSSHConfigFile(mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||
}
|
||||
|
||||
// Should only get main-host and regular-host, not backup-host
|
||||
expectedHosts := map[string]bool{
|
||||
"main-host": false,
|
||||
"regular-host": 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 (backup files should be excluded)", host.Name)
|
||||
} else {
|
||||
expectedHosts[host.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check that backup-host was not included
|
||||
for _, host := range hosts {
|
||||
if host.Name == "backup-host" {
|
||||
t.Error("backup-host should not be included (backup files should be excluded)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindHostInAllConfigs(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
|
||||
|
||||
Include included.conf
|
||||
`
|
||||
|
||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main config: %v", err)
|
||||
}
|
||||
|
||||
// Create included config file
|
||||
includedConfig := filepath.Join(tempDir, "included.conf")
|
||||
includedConfigContent := `Host included-host
|
||||
HostName included.example.com
|
||||
User includeduser
|
||||
`
|
||||
|
||||
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create included config: %v", err)
|
||||
}
|
||||
|
||||
// Test finding host from main config
|
||||
host, err := GetSSHHostFromFile("main-host", mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSSHHostFromFile() error = %v", err)
|
||||
}
|
||||
if host.Name != "main-host" || host.Hostname != "example.com" {
|
||||
t.Errorf("main-host not found correctly: name=%s, hostname=%s", host.Name, host.Hostname)
|
||||
}
|
||||
if host.SourceFile != mainConfig {
|
||||
t.Errorf("main-host SourceFile incorrect: expected=%s, got=%s", mainConfig, host.SourceFile)
|
||||
}
|
||||
|
||||
// Test finding host from included config
|
||||
// Note: This tests the full parsing with includes
|
||||
hosts, err := ParseSSHConfigFile(mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||
}
|
||||
|
||||
var includedHost *SSHHost
|
||||
for _, h := range hosts {
|
||||
if h.Name == "included-host" {
|
||||
includedHost = &h
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if includedHost == nil {
|
||||
t.Fatal("included-host not found")
|
||||
}
|
||||
if includedHost.Hostname != "included.example.com" || includedHost.User != "includeduser" {
|
||||
t.Errorf("included-host properties incorrect: hostname=%s, user=%s", includedHost.Hostname, includedHost.User)
|
||||
}
|
||||
if includedHost.SourceFile != includedConfig {
|
||||
t.Errorf("included-host SourceFile incorrect: expected=%s, got=%s", includedConfig, includedHost.SourceFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllConfigFiles(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
|
||||
|
||||
Include included.conf
|
||||
Include subdir/*.conf
|
||||
`
|
||||
|
||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main config: %v", err)
|
||||
}
|
||||
|
||||
// Create included config file
|
||||
includedConfig := filepath.Join(tempDir, "included.conf")
|
||||
err = os.WriteFile(includedConfig, []byte("Host included-host\n HostName included.example.com\n"), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create included config: %v", err)
|
||||
}
|
||||
|
||||
// Create subdirectory with config files
|
||||
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")
|
||||
err = os.WriteFile(subConfig, []byte("Host sub-host\n HostName sub.example.com\n"), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create sub config: %v", err)
|
||||
}
|
||||
|
||||
// Parse to populate the processed files map
|
||||
_, err = ParseSSHConfigFile(mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||
}
|
||||
|
||||
// Note: GetAllConfigFiles() uses a fresh parse, so we test it indirectly
|
||||
// by checking that all files are found during parsing
|
||||
hosts, err := ParseSSHConfigFile(mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||
}
|
||||
|
||||
// Check that hosts from all files are found
|
||||
sourceFiles := make(map[string]bool)
|
||||
for _, host := range hosts {
|
||||
sourceFiles[host.SourceFile] = true
|
||||
}
|
||||
|
||||
expectedFiles := []string{mainConfig, includedConfig, subConfig}
|
||||
for _, expectedFile := range expectedFiles {
|
||||
if !sourceFiles[expectedFile] {
|
||||
t.Errorf("Expected config file not found in SourceFile: %s", expectedFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllConfigFilesFromBase(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
|
||||
|
||||
Include included.conf
|
||||
`
|
||||
|
||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main config: %v", err)
|
||||
}
|
||||
|
||||
// Create included config file
|
||||
includedConfig := filepath.Join(tempDir, "included.conf")
|
||||
includedConfigContent := `Host included-host
|
||||
HostName included.example.com
|
||||
|
||||
Include subdir/*.conf
|
||||
`
|
||||
|
||||
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create included config: %v", err)
|
||||
}
|
||||
|
||||
// Create subdirectory with config files
|
||||
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")
|
||||
err = os.WriteFile(subConfig, []byte("Host sub-host\n HostName sub.example.com\n"), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create sub config: %v", err)
|
||||
}
|
||||
|
||||
// Create an isolated config file that should not be included
|
||||
isolatedConfig := filepath.Join(tempDir, "isolated.conf")
|
||||
err = os.WriteFile(isolatedConfig, []byte("Host isolated-host\n HostName isolated.example.com\n"), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create isolated config: %v", err)
|
||||
}
|
||||
|
||||
// Test GetAllConfigFilesFromBase with main config as base
|
||||
files, err := GetAllConfigFilesFromBase(mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllConfigFilesFromBase() error = %v", err)
|
||||
}
|
||||
|
||||
// Should find main config, included config, and sub config, but not isolated config
|
||||
expectedFiles := map[string]bool{
|
||||
mainConfig: false,
|
||||
includedConfig: false,
|
||||
subConfig: false,
|
||||
}
|
||||
|
||||
if len(files) != len(expectedFiles) {
|
||||
t.Errorf("Expected %d config files, got %d", len(expectedFiles), len(files))
|
||||
for i, file := range files {
|
||||
t.Logf("Found file %d: %s", i+1, file)
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if _, expected := expectedFiles[file]; expected {
|
||||
expectedFiles[file] = true
|
||||
} else if file == isolatedConfig {
|
||||
t.Errorf("Isolated config file should not be included: %s", file)
|
||||
} else {
|
||||
t.Logf("Unexpected file found: %s", file)
|
||||
}
|
||||
}
|
||||
|
||||
// Check that all expected files were found
|
||||
for file, found := range expectedFiles {
|
||||
if !found {
|
||||
t.Errorf("Expected config file not found: %s", file)
|
||||
}
|
||||
}
|
||||
|
||||
// Test GetAllConfigFilesFromBase with isolated config as base (should only return itself)
|
||||
isolatedFiles, err := GetAllConfigFilesFromBase(isolatedConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllConfigFilesFromBase() error = %v", err)
|
||||
}
|
||||
|
||||
if len(isolatedFiles) != 1 || isolatedFiles[0] != isolatedConfig {
|
||||
t.Errorf("Expected only isolated config file, got: %v", isolatedFiles)
|
||||
}
|
||||
|
||||
// Test with empty base config file path (should fallback to default behavior)
|
||||
defaultFiles, err := GetAllConfigFilesFromBase("")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllConfigFilesFromBase('') error = %v", err)
|
||||
}
|
||||
|
||||
// Should behave like GetAllConfigFiles()
|
||||
allFiles, err := GetAllConfigFiles()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllConfigFiles() error = %v", err)
|
||||
}
|
||||
|
||||
if len(defaultFiles) != len(allFiles) {
|
||||
t.Errorf("GetAllConfigFilesFromBase('') should behave like GetAllConfigFiles(). Got %d vs %d files", len(defaultFiles), len(allFiles))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type editFormModel struct {
|
||||
@@ -16,6 +17,7 @@ type editFormModel struct {
|
||||
success bool
|
||||
styles Styles
|
||||
originalName string
|
||||
host *config.SSHHost // Store the original host with SourceFile
|
||||
width int
|
||||
height int
|
||||
configFile string
|
||||
@@ -102,6 +104,7 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
|
||||
inputs: inputs,
|
||||
focused: nameInput,
|
||||
originalName: hostName,
|
||||
host: host,
|
||||
configFile: configFile,
|
||||
styles: styles,
|
||||
width: width,
|
||||
@@ -201,6 +204,24 @@ func (m *editFormModel) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(m.styles.FormTitle.Render("Edit SSH Host Configuration"))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Show source file information
|
||||
if m.host != nil && m.host.SourceFile != "" {
|
||||
b.WriteString("\n") // Ligne d'espace avant Config file
|
||||
|
||||
// Style for "Config file:" label in primary color
|
||||
labelStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#00ADD8")). // Primary color
|
||||
Bold(true)
|
||||
|
||||
// Style for the file path in white
|
||||
pathStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFFFF"))
|
||||
|
||||
configInfo := labelStyle.Render("Config file: ") + pathStyle.Render(formatConfigFile(m.host.SourceFile))
|
||||
b.WriteString(configInfo)
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
fields := []string{
|
||||
|
||||
162
internal/ui/file_selector.go
Normal file
162
internal/ui/file_selector.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sshm/internal/config"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type fileSelectorModel struct {
|
||||
files []string // Chemins absolus des fichiers
|
||||
displayNames []string // Noms d'affichage conviviaux
|
||||
selected int
|
||||
styles Styles
|
||||
width int
|
||||
height int
|
||||
title string
|
||||
}
|
||||
|
||||
type fileSelectorMsg struct {
|
||||
selectedFile string
|
||||
cancelled bool
|
||||
}
|
||||
|
||||
// NewFileSelector creates a new file selector for choosing config files
|
||||
func NewFileSelector(title string, styles Styles, width, height int) (*fileSelectorModel, error) {
|
||||
files, err := config.GetAllConfigFiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newFileSelectorFromFiles(title, styles, width, height, files)
|
||||
}
|
||||
|
||||
// NewFileSelectorFromBase creates a new file selector starting from a specific base config file
|
||||
func NewFileSelectorFromBase(title string, styles Styles, width, height int, baseConfigFile string) (*fileSelectorModel, error) {
|
||||
var files []string
|
||||
var err error
|
||||
|
||||
if baseConfigFile != "" {
|
||||
files, err = config.GetAllConfigFilesFromBase(baseConfigFile)
|
||||
} else {
|
||||
files, err = config.GetAllConfigFiles()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newFileSelectorFromFiles(title, styles, width, height, files)
|
||||
}
|
||||
|
||||
// newFileSelectorFromFiles creates a file selector from a list of files
|
||||
func newFileSelectorFromFiles(title string, styles Styles, width, height int, files []string) (*fileSelectorModel, error) {
|
||||
|
||||
// Convert absolute paths to more user-friendly names
|
||||
var displayNames []string
|
||||
homeDir, _ := config.GetSSHDirectory()
|
||||
|
||||
for _, file := range files {
|
||||
// Check if it's the main config file
|
||||
mainConfig, _ := config.GetDefaultSSHConfigPath()
|
||||
if file == mainConfig {
|
||||
displayNames = append(displayNames, "Main SSH Config (~/.ssh/config)")
|
||||
} else {
|
||||
// Try to make path relative to home/.ssh/
|
||||
if strings.HasPrefix(file, homeDir) {
|
||||
relPath, err := filepath.Rel(homeDir, file)
|
||||
if err == nil {
|
||||
displayNames = append(displayNames, fmt.Sprintf("~/.ssh/%s", relPath))
|
||||
} else {
|
||||
displayNames = append(displayNames, file)
|
||||
}
|
||||
} else {
|
||||
displayNames = append(displayNames, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &fileSelectorModel{
|
||||
files: files,
|
||||
displayNames: displayNames,
|
||||
selected: 0,
|
||||
styles: styles,
|
||||
width: width,
|
||||
height: height,
|
||||
title: title,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *fileSelectorModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *fileSelectorModel) Update(msg tea.Msg) (*fileSelectorModel, 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":
|
||||
return m, func() tea.Msg {
|
||||
return fileSelectorMsg{cancelled: true}
|
||||
}
|
||||
|
||||
case "enter":
|
||||
selectedFile := ""
|
||||
if m.selected < len(m.files) {
|
||||
selectedFile = m.files[m.selected]
|
||||
}
|
||||
return m, func() tea.Msg {
|
||||
return fileSelectorMsg{selectedFile: selectedFile}
|
||||
}
|
||||
|
||||
case "up", "k":
|
||||
if m.selected > 0 {
|
||||
m.selected--
|
||||
}
|
||||
|
||||
case "down", "j":
|
||||
if m.selected < len(m.files)-1 {
|
||||
m.selected++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *fileSelectorModel) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(m.styles.FormTitle.Render(m.title))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if len(m.files) == 0 {
|
||||
b.WriteString(m.styles.Error.Render("No SSH config files found."))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(m.styles.FormHelp.Render("Esc: cancel"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
for i, displayName := range m.displayNames {
|
||||
if i == m.selected {
|
||||
b.WriteString(m.styles.Selected.Render(fmt.Sprintf("▶ %s", displayName)))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf(" %s", displayName))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.styles.FormHelp.Render("↑/↓: navigate • Enter: select • Esc: cancel"))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
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),
|
||||
)
|
||||
}
|
||||
228
internal/ui/info_form.go
Normal file
228
internal/ui/info_form.go
Normal file
@@ -0,0 +1,228 @@
|
||||
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},
|
||||
{"Config File", formatConfigFile(m.host.SourceFile)},
|
||||
{"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,10 @@ const (
|
||||
ViewList ViewMode = iota
|
||||
ViewAdd
|
||||
ViewEdit
|
||||
ViewInfo
|
||||
ViewPortForward
|
||||
ViewHelp
|
||||
ViewFileSelector
|
||||
)
|
||||
|
||||
// PortForwardType defines the type of port forwarding
|
||||
@@ -74,10 +77,13 @@ type Model struct {
|
||||
configFile string // Path to the SSH config file
|
||||
|
||||
// View management
|
||||
viewMode ViewMode
|
||||
addForm *addFormModel
|
||||
editForm *editFormModel
|
||||
portForwardForm *portForwardModel
|
||||
viewMode ViewMode
|
||||
addForm *addFormModel
|
||||
editForm *editFormModel
|
||||
infoForm *infoFormModel
|
||||
portForwardForm *portForwardModel
|
||||
helpForm *helpModel
|
||||
fileSelectorForm *fileSelectorModel
|
||||
|
||||
// Terminal size and styles
|
||||
width int
|
||||
|
||||
@@ -42,6 +42,7 @@ type Styles struct {
|
||||
FormContainer lipgloss.Style
|
||||
Label lipgloss.Style
|
||||
FocusedLabel lipgloss.Style
|
||||
HelpSection lipgloss.Style
|
||||
}
|
||||
|
||||
// NewStyles creates a new Styles struct with the given terminal width
|
||||
@@ -88,8 +89,7 @@ func NewStyles(width int) Styles {
|
||||
Foreground(lipgloss.Color(SecondaryColor)),
|
||||
|
||||
HelpText: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(SecondaryColor)).
|
||||
MarginTop(1),
|
||||
Foreground(lipgloss.Color(SecondaryColor)),
|
||||
|
||||
// Error style
|
||||
Error: lipgloss.NewStyle().
|
||||
@@ -118,8 +118,10 @@ func NewStyles(width int) Styles {
|
||||
Foreground(lipgloss.Color(SecondaryColor)),
|
||||
|
||||
FocusedLabel: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(PrimaryColor)).
|
||||
Bold(true),
|
||||
Foreground(lipgloss.Color(PrimaryColor)),
|
||||
|
||||
HelpSection: lipgloss.NewStyle().
|
||||
Padding(0, 2),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -122,8 +122,8 @@ func (m *Model) updateTableRows() {
|
||||
rows = append(rows, table.Row{
|
||||
host.Name,
|
||||
host.Hostname,
|
||||
host.User,
|
||||
host.Port,
|
||||
// host.User, // Commented to save space
|
||||
// host.Port, // Commented to save space
|
||||
tagsStr,
|
||||
lastLoginStr,
|
||||
})
|
||||
@@ -146,16 +146,17 @@ func (m *Model) updateTableHeight() {
|
||||
// Layout breakdown:
|
||||
// - ASCII title: 5 lines (1 empty + 4 text lines)
|
||||
// - Search bar: 1 line
|
||||
// - Sort info: 1 line
|
||||
// - Help text: 2 lines (multi-line text)
|
||||
// - App margins/spacing: 2 lines
|
||||
// Total reserved: 11 lines, mais réduisons à 7 pour forcer plus d'espace
|
||||
reservedHeight := 7 // Réduction agressive pour tester
|
||||
// - Help text: 1 line
|
||||
// - App margins/spacing: 3 lines
|
||||
// - Safety margin: 3 lines (to ensure UI elements are always visible)
|
||||
// Total reserved: 13 lines minimum to preserve essential UI elements
|
||||
reservedHeight := 13
|
||||
availableHeight := m.height - reservedHeight
|
||||
hostCount := len(m.table.Rows())
|
||||
|
||||
// Minimum height should be at least 5 rows for usability
|
||||
minTableHeight := 6 // 1 header + 5 data rows
|
||||
// Minimum height should be at least 3 rows for basic usability
|
||||
// Even in very small terminals, we want to show at least header + 2 hosts
|
||||
minTableHeight := 4 // 1 header + 3 data rows minimum
|
||||
maxTableHeight := availableHeight
|
||||
if maxTableHeight < minTableHeight {
|
||||
maxTableHeight = minTableHeight
|
||||
@@ -173,7 +174,8 @@ func (m *Model) updateTableHeight() {
|
||||
tableHeight += maxDataRows
|
||||
}
|
||||
|
||||
// FORCE: Ajoutons une ligne supplémentaire pour résoudre le problème
|
||||
// Add one extra line to prevent the last host from being hidden
|
||||
// This compensates for table rendering quirks in bubble tea
|
||||
tableHeight += 1
|
||||
|
||||
// Update table height
|
||||
@@ -198,11 +200,11 @@ func (m *Model) updateTableColumns() {
|
||||
|
||||
// Fixed column widths
|
||||
hostnameWidth := 25
|
||||
userWidth := 12
|
||||
portWidth := 6
|
||||
// userWidth := 12 // Commented to save space
|
||||
// portWidth := 6 // Commented to save space
|
||||
|
||||
// Calculate total width needed for all columns
|
||||
totalFixedWidth := hostnameWidth + userWidth + portWidth
|
||||
totalFixedWidth := hostnameWidth // + userWidth + portWidth // Commented columns
|
||||
totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth
|
||||
totalWidth := totalFixedWidth + totalVariableWidth
|
||||
|
||||
@@ -226,14 +228,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{
|
||||
{Title: "Name", Width: nameWidth},
|
||||
{Title: nameTitle, Width: nameWidth},
|
||||
{Title: "Hostname", Width: hostnameWidth},
|
||||
{Title: "User", Width: userWidth},
|
||||
{Title: "Port", Width: portWidth},
|
||||
// {Title: "User", Width: userWidth}, // Commented to save space
|
||||
// {Title: "Port", Width: portWidth}, // Commented to save space
|
||||
{Title: "Tags", Width: tagsWidth},
|
||||
{Title: "Last Login", Width: lastLoginWidth},
|
||||
{Title: lastLoginTitle, Width: lastLoginWidth},
|
||||
}
|
||||
|
||||
m.table.SetColumns(columns)
|
||||
|
||||
@@ -61,8 +61,8 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
columns := []table.Column{
|
||||
{Title: "Name", Width: nameWidth},
|
||||
{Title: "Hostname", Width: 25},
|
||||
{Title: "User", Width: 12},
|
||||
{Title: "Port", Width: 6},
|
||||
// {Title: "User", Width: 12}, // Commented to save space
|
||||
// {Title: "Port", Width: 6}, // Commented to save space
|
||||
{Title: "Tags", Width: tagsWidth},
|
||||
{Title: "Last Login", Width: lastLoginWidth},
|
||||
}
|
||||
@@ -92,8 +92,8 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
rows = append(rows, table.Row{
|
||||
host.Name,
|
||||
host.Hostname,
|
||||
host.User,
|
||||
host.Port,
|
||||
// host.User, // Commented to save space
|
||||
// host.Port, // Commented to save space
|
||||
tagsStr,
|
||||
lastLoginStr,
|
||||
})
|
||||
|
||||
@@ -46,11 +46,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.editForm.height = m.height
|
||||
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 {
|
||||
m.portForwardForm.width = m.width
|
||||
m.portForwardForm.height = m.height
|
||||
m.portForwardForm.styles = m.styles
|
||||
}
|
||||
if m.helpForm != nil {
|
||||
m.helpForm.width = m.width
|
||||
m.helpForm.height = m.height
|
||||
m.helpForm.styles = m.styles
|
||||
}
|
||||
if m.fileSelectorForm != nil {
|
||||
m.fileSelectorForm.width = m.width
|
||||
m.fileSelectorForm.height = m.height
|
||||
m.fileSelectorForm.styles = m.styles
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case addFormSubmitMsg:
|
||||
@@ -141,6 +156,43 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
|
||||
case infoFormCancelMsg:
|
||||
// Cancel: return to list view
|
||||
m.viewMode = ViewList
|
||||
m.infoForm = nil
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
|
||||
case fileSelectorMsg:
|
||||
if msg.cancelled {
|
||||
// Cancel: return to list view
|
||||
m.viewMode = ViewList
|
||||
m.fileSelectorForm = nil
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
} else {
|
||||
// File selected: proceed to add form with selected file
|
||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, msg.selectedFile)
|
||||
m.viewMode = ViewAdd
|
||||
m.fileSelectorForm = nil
|
||||
return m, textinput.Blink
|
||||
}
|
||||
|
||||
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:
|
||||
if msg.err != nil {
|
||||
// Show error in form
|
||||
@@ -180,6 +232,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.table.Focus()
|
||||
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:
|
||||
// Handle view-specific key presses
|
||||
switch m.viewMode {
|
||||
@@ -197,6 +256,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.editForm = newForm
|
||||
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:
|
||||
if m.portForwardForm != nil {
|
||||
var newForm *portForwardModel
|
||||
@@ -204,6 +270,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.portForwardForm = newForm
|
||||
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 ViewFileSelector:
|
||||
if m.fileSelectorForm != nil {
|
||||
var newForm *fileSelectorModel
|
||||
newForm, cmd = m.fileSelectorForm.Update(msg)
|
||||
m.fileSelectorForm = newForm
|
||||
return m, cmd
|
||||
}
|
||||
case ViewList:
|
||||
// Handle list view keys
|
||||
return m.handleListViewKeys(msg)
|
||||
@@ -356,11 +436,58 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
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":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Add a new host
|
||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile)
|
||||
m.viewMode = ViewAdd
|
||||
// Check if there are multiple config files starting from the current base config
|
||||
var configFiles []string
|
||||
var err error
|
||||
|
||||
if m.configFile != "" {
|
||||
// Use the specified config file as base
|
||||
configFiles, err = config.GetAllConfigFilesFromBase(m.configFile)
|
||||
} else {
|
||||
// Use the default config file as base
|
||||
configFiles, err = config.GetAllConfigFiles()
|
||||
}
|
||||
|
||||
if err != nil || len(configFiles) <= 1 {
|
||||
// Only one config file (or error), go directly to add form
|
||||
var configFile string
|
||||
if len(configFiles) == 1 {
|
||||
configFile = configFiles[0]
|
||||
} else {
|
||||
configFile = m.configFile
|
||||
}
|
||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, configFile)
|
||||
m.viewMode = ViewAdd
|
||||
} else {
|
||||
// Multiple config files, show file selector
|
||||
fileSelectorForm, err := NewFileSelectorFromBase("Select config file to add host to:", m.styles, m.width, m.height, m.configFile)
|
||||
if err != nil {
|
||||
// Fallback to default behavior if file selector fails
|
||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile)
|
||||
m.viewMode = ViewAdd
|
||||
} else {
|
||||
m.fileSelectorForm = fileSelectorForm
|
||||
m.viewMode = ViewFileSelector
|
||||
}
|
||||
}
|
||||
return m, textinput.Blink
|
||||
}
|
||||
case "d":
|
||||
@@ -386,6 +513,13 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
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":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Cycle through sort modes (only 2 modes now)
|
||||
|
||||
@@ -2,6 +2,7 @@ package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -55,3 +56,16 @@ func formatTimeAgo(t time.Time) string {
|
||||
return fmt.Sprintf("%d years ago", years)
|
||||
}
|
||||
}
|
||||
|
||||
// formatConfigFile formats a config file path for display
|
||||
func formatConfigFile(filePath string) string {
|
||||
if filePath == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
// Show just the filename and parent directory for readability
|
||||
parts := strings.Split(filePath, "/")
|
||||
if len(parts) >= 2 {
|
||||
return fmt.Sprintf(".../%s/%s", parts[len(parts)-2], parts[len(parts)-1])
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
@@ -23,10 +23,22 @@ func (m Model) View() string {
|
||||
if m.editForm != nil {
|
||||
return m.editForm.View()
|
||||
}
|
||||
case ViewInfo:
|
||||
if m.infoForm != nil {
|
||||
return m.infoForm.View()
|
||||
}
|
||||
case ViewPortForward:
|
||||
if m.portForwardForm != nil {
|
||||
return m.portForwardForm.View()
|
||||
}
|
||||
case ViewHelp:
|
||||
if m.helpForm != nil {
|
||||
return m.helpForm.View()
|
||||
}
|
||||
case ViewFileSelector:
|
||||
if m.fileSelectorForm != nil {
|
||||
return m.fileSelectorForm.View()
|
||||
}
|
||||
case ViewList:
|
||||
return m.renderListView()
|
||||
}
|
||||
@@ -50,10 +62,6 @@ func (m Model) renderListView() string {
|
||||
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
|
||||
if m.searchMode {
|
||||
// The table is not focused, use the unfocused style
|
||||
@@ -66,9 +74,9 @@ func (m Model) renderListView() string {
|
||||
// Add the help text
|
||||
var helpText string
|
||||
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 {
|
||||
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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user