mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2026-01-27 03:04:21 +01:00
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:
@@ -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()
|
||||
}
|
||||
@@ -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)},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user