fix: enable editing and management of hosts from included SSH config files

• Add SourceFile field to SSHHost struct to track config file origins
• Implement FindHostInAllConfigs() to locate hosts across all config files
• Fix "host not found" errors when editing/deleting hosts from included files
• Add GetAllConfigFiles() and GetAllConfigFilesFromBase() for config discovery
• Create UpdateSSHHostV2() and DeleteSSHHostV2() for cross-file operations
• Display config file source in edit and info forms for better visibility
• Add intelligent file selector for host addition when multiple configs exist
• Support -c parameter context with proper file resolution
• Exclude .backup files from Include directive processing
• Maintain backward compatibility with existing SSH config workflows

Resolves limitation where hosts from included config files could be viewed
but not edited, deleted, or properly managed through the interface.
This commit is contained in:
Gu1llaum-3 2025-09-05 17:04:11 +02:00
parent b67f5abbbc
commit be3dcaa1cd
9 changed files with 751 additions and 41 deletions

View File

@ -13,14 +13,15 @@ import (
// SSHHost represents an SSH host configuration // SSHHost represents an SSH host configuration
type SSHHost struct { type SSHHost struct {
Name string Name string
Hostname string Hostname string
User string User string
Port string Port string
Identity string Identity string
ProxyJump string ProxyJump string
Options string Options string
Tags []string 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 // GetDefaultSSHConfigPath returns the default SSH config path for the current platform
@ -209,9 +210,10 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
} }
// Create new host // Create new host
currentHost = &SSHHost{ currentHost = &SSHHost{
Name: value, Name: value,
Port: "22", // Default port Port: "22", // Default port
Tags: pendingTags, // Assign pending tags to this host Tags: pendingTags, // Assign pending tags to this host
SourceFile: absPath, // Track which file this host comes from
} }
// Clear pending tags for next host // Clear pending tags for next host
pendingTags = nil pendingTags = nil
@ -286,6 +288,16 @@ func processIncludeDirective(pattern string, baseConfigPath string, processedFil
continue 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 // Recursively parse the included file
hosts, err := parseSSHConfigFileWithProcessedFiles(match, processedFiles) hosts, err := parseSSHConfigFileWithProcessedFiles(match, processedFiles)
if err != nil { if err != nil {
@ -529,11 +541,7 @@ func GetSSHHostFromFile(hostName string, configPath string) (*SSHHost, error) {
// UpdateSSHHost updates an existing SSH host configuration // UpdateSSHHost updates an existing SSH host configuration
func UpdateSSHHost(oldName string, newHost SSHHost) error { func UpdateSSHHost(oldName string, newHost SSHHost) error {
configPath, err := GetDefaultSSHConfigPath() return UpdateSSHHostV2(oldName, newHost)
if err != nil {
return err
}
return UpdateSSHHostInFile(oldName, newHost, configPath)
} }
// UpdateSSHHostInFile updates an existing SSH host configuration in a specific file // UpdateSSHHostInFile updates an existing SSH host configuration in a specific file
@ -688,11 +696,7 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
// DeleteSSHHost removes an SSH host configuration from the config file // DeleteSSHHost removes an SSH host configuration from the config file
func DeleteSSHHost(hostName string) error { func DeleteSSHHost(hostName string) error {
configPath, err := GetDefaultSSHConfigPath() return DeleteSSHHostV2(hostName)
if err != nil {
return err
}
return DeleteSSHHostFromFile(hostName, configPath)
} }
// DeleteSSHHostFromFile deletes an SSH host from a specific config file // DeleteSSHHostFromFile deletes an SSH host from a specific config file
@ -776,3 +780,115 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
newContent := strings.Join(newLines, "\n") newContent := strings.Join(newLines, "\n")
return os.WriteFile(configPath, []byte(newContent), 0600) 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
}

View File

@ -76,7 +76,7 @@ func TestEnsureSSHDirectory(t *testing.T) {
func TestParseSSHConfigWithInclude(t *testing.T) { func TestParseSSHConfigWithInclude(t *testing.T) {
// Create temporary directory for test files // Create temporary directory for test files
tempDir := t.TempDir() tempDir := t.TempDir()
// Create main config file // Create main config file
mainConfig := filepath.Join(tempDir, "config") mainConfig := filepath.Join(tempDir, "config")
mainConfigContent := `Host main-host mainConfigContent := `Host main-host
@ -90,7 +90,7 @@ Host another-host
HostName another.example.com HostName another.example.com
User anotheruser User anotheruser
` `
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600) err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
if err != nil { if err != nil {
t.Fatalf("Failed to create main config: %v", err) t.Fatalf("Failed to create main config: %v", err)
@ -103,7 +103,7 @@ Host another-host
User includeduser User includeduser
Port 2222 Port 2222
` `
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600) err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
if err != nil { if err != nil {
t.Fatalf("Failed to create included config: %v", err) t.Fatalf("Failed to create included config: %v", err)
@ -122,7 +122,7 @@ Host another-host
User subuser User subuser
IdentityFile ~/.ssh/sub_key IdentityFile ~/.ssh/sub_key
` `
err = os.WriteFile(subConfig, []byte(subConfigContent), 0600) err = os.WriteFile(subConfig, []byte(subConfigContent), 0600)
if err != nil { if err != nil {
t.Fatalf("Failed to create sub config: %v", err) t.Fatalf("Failed to create sub config: %v", err)
@ -158,18 +158,30 @@ Host another-host
if host.Hostname != "example.com" || host.User != "mainuser" { if host.Hostname != "example.com" || host.User != "mainuser" {
t.Errorf("main-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User) 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": case "included-host":
if host.Hostname != "included.example.com" || host.User != "includeduser" || host.Port != "2222" { 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) 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": case "sub-host":
if host.Hostname != "sub.example.com" || host.User != "subuser" || host.Identity != "~/.ssh/sub_key" { 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) 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": case "another-host":
if host.Hostname != "another.example.com" || host.User != "anotheruser" { if host.Hostname != "another.example.com" || host.User != "anotheruser" {
t.Errorf("another-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User) 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)
}
} }
} }
@ -186,7 +198,7 @@ Host another-host
func TestParseSSHConfigWithCircularInclude(t *testing.T) { func TestParseSSHConfigWithCircularInclude(t *testing.T) {
// Create temporary directory for test files // Create temporary directory for test files
tempDir := t.TempDir() tempDir := t.TempDir()
// Create config1 that includes config2 // Create config1 that includes config2
config1 := filepath.Join(tempDir, "config1") config1 := filepath.Join(tempDir, "config1")
config1Content := `Host host1 config1Content := `Host host1
@ -194,7 +206,7 @@ func TestParseSSHConfigWithCircularInclude(t *testing.T) {
Include config2 Include config2
` `
err := os.WriteFile(config1, []byte(config1Content), 0600) err := os.WriteFile(config1, []byte(config1Content), 0600)
if err != nil { if err != nil {
t.Fatalf("Failed to create config1: %v", err) t.Fatalf("Failed to create config1: %v", err)
@ -207,7 +219,7 @@ Include config2
Include config1 Include config1
` `
err = os.WriteFile(config2, []byte(config2Content), 0600) err = os.WriteFile(config2, []byte(config2Content), 0600)
if err != nil { if err != nil {
t.Fatalf("Failed to create config2: %v", err) t.Fatalf("Failed to create config2: %v", err)
@ -247,7 +259,7 @@ Include config1
func TestParseSSHConfigWithNonExistentInclude(t *testing.T) { func TestParseSSHConfigWithNonExistentInclude(t *testing.T) {
// Create temporary directory for test files // Create temporary directory for test files
tempDir := t.TempDir() tempDir := t.TempDir()
// Create main config file with non-existent include // Create main config file with non-existent include
mainConfig := filepath.Join(tempDir, "config") mainConfig := filepath.Join(tempDir, "config")
mainConfigContent := `Host main-host mainConfigContent := `Host main-host
@ -258,7 +270,7 @@ Include non-existent-file.conf
Host another-host Host another-host
HostName another.example.com HostName another.example.com
` `
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600) err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
if err != nil { if err != nil {
t.Fatalf("Failed to create main config: %v", err) t.Fatalf("Failed to create main config: %v", err)
@ -288,7 +300,7 @@ Host another-host
func TestParseSSHConfigWithWildcardHosts(t *testing.T) { func TestParseSSHConfigWithWildcardHosts(t *testing.T) {
// Create temporary directory for test files // Create temporary directory for test files
tempDir := t.TempDir() tempDir := t.TempDir()
// Create config file with wildcard hosts // Create config file with wildcard hosts
configFile := filepath.Join(tempDir, "config") configFile := filepath.Join(tempDir, "config")
configContent := `# Wildcard patterns should be ignored configContent := `# Wildcard patterns should be ignored
@ -311,7 +323,7 @@ Host another-real-server
HostName another.example.com HostName another.example.com
User anotheruser User anotheruser
` `
err := os.WriteFile(configFile, []byte(configContent), 0600) err := os.WriteFile(configFile, []byte(configContent), 0600)
if err != nil { if err != nil {
t.Fatalf("Failed to create config: %v", err) t.Fatalf("Failed to create config: %v", err)
@ -365,3 +377,323 @@ Host another-real-server
} }
} }
} }
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))
}
}

View File

@ -7,6 +7,7 @@ import (
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
) )
type editFormModel struct { type editFormModel struct {
@ -16,6 +17,7 @@ type editFormModel struct {
success bool success bool
styles Styles styles Styles
originalName string originalName string
host *config.SSHHost // Store the original host with SourceFile
width int width int
height int height int
configFile string configFile string
@ -102,6 +104,7 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
inputs: inputs, inputs: inputs,
focused: nameInput, focused: nameInput,
originalName: hostName, originalName: hostName,
host: host,
configFile: configFile, configFile: configFile,
styles: styles, styles: styles,
width: width, width: width,
@ -201,6 +204,24 @@ func (m *editFormModel) View() string {
var b strings.Builder var b strings.Builder
b.WriteString(m.styles.FormTitle.Render("Edit SSH Host Configuration")) 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") b.WriteString("\n\n")
fields := []string{ fields := []string{

View 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()
}

View File

@ -91,6 +91,7 @@ func (m *infoFormModel) View() string {
value string value string
}{ }{
{"Host Name", m.host.Name}, {"Host Name", m.host.Name},
{"Config File", formatConfigFile(m.host.SourceFile)},
{"Hostname/IP", m.host.Hostname}, {"Hostname/IP", m.host.Hostname},
{"User", formatOptionalValue(m.host.User)}, {"User", formatOptionalValue(m.host.User)},
{"Port", formatOptionalValue(m.host.Port)}, {"Port", formatOptionalValue(m.host.Port)},

View File

@ -38,6 +38,7 @@ const (
ViewInfo ViewInfo
ViewPortForward ViewPortForward
ViewHelp ViewHelp
ViewFileSelector
) )
// PortForwardType defines the type of port forwarding // PortForwardType defines the type of port forwarding
@ -76,12 +77,13 @@ type Model struct {
configFile string // Path to the SSH config file configFile string // Path to the SSH config file
// View management // View management
viewMode ViewMode viewMode ViewMode
addForm *addFormModel addForm *addFormModel
editForm *editFormModel editForm *editFormModel
infoForm *infoFormModel infoForm *infoFormModel
portForwardForm *portForwardModel portForwardForm *portForwardModel
helpForm *helpModel helpForm *helpModel
fileSelectorForm *fileSelectorModel
// Terminal size and styles // Terminal size and styles
width int width int

View File

@ -61,6 +61,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.helpForm.height = m.height m.helpForm.height = m.height
m.helpForm.styles = m.styles 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 return m, nil
case addFormSubmitMsg: case addFormSubmitMsg:
@ -158,6 +163,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.table.Focus() m.table.Focus()
return m, nil 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: case infoFormEditMsg:
// Switch from info to edit mode // Switch from info to edit mode
editForm, err := NewEditForm(msg.hostName, m.styles, m.width, m.height, m.configFile) editForm, err := NewEditForm(msg.hostName, m.styles, m.width, m.height, m.configFile)
@ -257,6 +277,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.helpForm = newForm m.helpForm = newForm
return m, cmd 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: case ViewList:
// Handle list view keys // Handle list view keys
return m.handleListViewKeys(msg) return m.handleListViewKeys(msg)
@ -427,9 +454,40 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case "a": case "a":
if !m.searchMode && !m.deleteMode { if !m.searchMode && !m.deleteMode {
// Add a new host // Check if there are multiple config files starting from the current base config
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile) var configFiles []string
m.viewMode = ViewAdd 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 return m, textinput.Blink
} }
case "d": case "d":

View File

@ -2,6 +2,7 @@ package ui
import ( import (
"fmt" "fmt"
"strings"
"time" "time"
) )
@ -55,3 +56,16 @@ func formatTimeAgo(t time.Time) string {
return fmt.Sprintf("%d years ago", years) 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
}

View File

@ -35,6 +35,10 @@ func (m Model) View() string {
if m.helpForm != nil { if m.helpForm != nil {
return m.helpForm.View() return m.helpForm.View()
} }
case ViewFileSelector:
if m.fileSelectorForm != nil {
return m.fileSelectorForm.View()
}
case ViewList: case ViewList:
return m.renderListView() return m.renderListView()
} }