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:
2025-09-05 17:04:11 +02:00
parent b67f5abbbc
commit be3dcaa1cd
9 changed files with 751 additions and 41 deletions

View File

@@ -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{

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
}{
{"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)},

View File

@@ -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

View File

@@ -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":

View File

@@ -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
}

View File

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