diff --git a/internal/config/ssh.go b/internal/config/ssh.go index eb8cbb9..1e2b88b 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -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 @@ -209,9 +210,10 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[ } // 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 @@ -286,6 +288,16 @@ func processIncludeDirective(pattern string, baseConfigPath string, processedFil 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 { @@ -529,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 @@ -688,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 @@ -776,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 +} diff --git a/internal/config/ssh_test.go b/internal/config/ssh_test.go index d13128d..4abbbc3 100644 --- a/internal/config/ssh_test.go +++ b/internal/config/ssh_test.go @@ -76,7 +76,7 @@ func TestEnsureSSHDirectory(t *testing.T) { 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 @@ -90,7 +90,7 @@ 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) @@ -103,7 +103,7 @@ Host another-host User includeduser Port 2222 ` - + err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600) if err != nil { t.Fatalf("Failed to create included config: %v", err) @@ -122,7 +122,7 @@ Host another-host 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) @@ -158,18 +158,30 @@ Host another-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) + } } } @@ -186,7 +198,7 @@ Host another-host 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 @@ -194,7 +206,7 @@ func TestParseSSHConfigWithCircularInclude(t *testing.T) { Include config2 ` - + err := os.WriteFile(config1, []byte(config1Content), 0600) if err != nil { t.Fatalf("Failed to create config1: %v", err) @@ -207,7 +219,7 @@ Include config2 Include config1 ` - + err = os.WriteFile(config2, []byte(config2Content), 0600) if err != nil { t.Fatalf("Failed to create config2: %v", err) @@ -247,7 +259,7 @@ Include config1 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 @@ -258,7 +270,7 @@ 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) @@ -288,7 +300,7 @@ Host another-host 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 @@ -311,7 +323,7 @@ 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) @@ -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)) + } +} diff --git a/internal/ui/edit_form.go b/internal/ui/edit_form.go index fb0c76c..e22d5ad 100644 --- a/internal/ui/edit_form.go +++ b/internal/ui/edit_form.go @@ -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{ diff --git a/internal/ui/file_selector.go b/internal/ui/file_selector.go new file mode 100644 index 0000000..71055e8 --- /dev/null +++ b/internal/ui/file_selector.go @@ -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() +} diff --git a/internal/ui/info_form.go b/internal/ui/info_form.go index c4f09db..00160db 100644 --- a/internal/ui/info_form.go +++ b/internal/ui/info_form.go @@ -91,6 +91,7 @@ func (m *infoFormModel) View() 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)}, diff --git a/internal/ui/model.go b/internal/ui/model.go index aba5735..a223777 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -38,6 +38,7 @@ const ( ViewInfo ViewPortForward ViewHelp + ViewFileSelector ) // PortForwardType defines the type of port forwarding @@ -76,12 +77,13 @@ type Model struct { configFile string // Path to the SSH config file // View management - viewMode ViewMode - addForm *addFormModel - editForm *editFormModel - infoForm *infoFormModel - portForwardForm *portForwardModel - helpForm *helpModel + viewMode ViewMode + addForm *addFormModel + editForm *editFormModel + infoForm *infoFormModel + portForwardForm *portForwardModel + helpForm *helpModel + fileSelectorForm *fileSelectorModel // Terminal size and styles width int diff --git a/internal/ui/update.go b/internal/ui/update.go index c65b384..b751927 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -61,6 +61,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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: @@ -158,6 +163,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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) @@ -257,6 +277,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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) @@ -427,9 +454,40 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } 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": diff --git a/internal/ui/utils.go b/internal/ui/utils.go index 61f21bf..3c5a169 100644 --- a/internal/ui/utils.go +++ b/internal/ui/utils.go @@ -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 +} diff --git a/internal/ui/view.go b/internal/ui/view.go index 40bea97..c3e4167 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -35,6 +35,10 @@ func (m Model) View() string { if m.helpForm != nil { return m.helpForm.View() } + case ViewFileSelector: + if m.fileSelectorForm != nil { + return m.fileSelectorForm.View() + } case ViewList: return m.renderListView() }