mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2025-09-06 21:00:45 +02:00
• 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.
351 lines
8.6 KiB
Go
351 lines
8.6 KiB
Go
package ui
|
|
|
|
import (
|
|
"sshm/internal/config"
|
|
"sshm/internal/validation"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
type editFormModel struct {
|
|
inputs []textinput.Model
|
|
focused int
|
|
err string
|
|
success bool
|
|
styles Styles
|
|
originalName string
|
|
host *config.SSHHost // Store the original host with SourceFile
|
|
width int
|
|
height int
|
|
configFile string
|
|
}
|
|
|
|
// NewEditForm creates a new edit form model
|
|
func NewEditForm(hostName string, styles Styles, width, height int, configFile string) (*editFormModel, 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
|
|
}
|
|
|
|
inputs := make([]textinput.Model, 8)
|
|
|
|
// Name input
|
|
inputs[nameInput] = textinput.New()
|
|
inputs[nameInput].Placeholder = "server-name"
|
|
inputs[nameInput].Focus()
|
|
inputs[nameInput].CharLimit = 50
|
|
inputs[nameInput].Width = 30
|
|
inputs[nameInput].SetValue(host.Name)
|
|
|
|
// Hostname input
|
|
inputs[hostnameInput] = textinput.New()
|
|
inputs[hostnameInput].Placeholder = "192.168.1.100 or example.com"
|
|
inputs[hostnameInput].CharLimit = 100
|
|
inputs[hostnameInput].Width = 30
|
|
inputs[hostnameInput].SetValue(host.Hostname)
|
|
|
|
// User input
|
|
inputs[userInput] = textinput.New()
|
|
inputs[userInput].Placeholder = "root"
|
|
inputs[userInput].CharLimit = 50
|
|
inputs[userInput].Width = 30
|
|
inputs[userInput].SetValue(host.User)
|
|
|
|
// Port input
|
|
inputs[portInput] = textinput.New()
|
|
inputs[portInput].Placeholder = "22"
|
|
inputs[portInput].CharLimit = 5
|
|
inputs[portInput].Width = 30
|
|
inputs[portInput].SetValue(host.Port)
|
|
|
|
// Identity input
|
|
inputs[identityInput] = textinput.New()
|
|
inputs[identityInput].Placeholder = "~/.ssh/id_rsa"
|
|
inputs[identityInput].CharLimit = 200
|
|
inputs[identityInput].Width = 50
|
|
inputs[identityInput].SetValue(host.Identity)
|
|
|
|
// ProxyJump input
|
|
inputs[proxyJumpInput] = textinput.New()
|
|
inputs[proxyJumpInput].Placeholder = "user@jump-host:port or existing-host-name"
|
|
inputs[proxyJumpInput].CharLimit = 200
|
|
inputs[proxyJumpInput].Width = 50
|
|
inputs[proxyJumpInput].SetValue(host.ProxyJump)
|
|
|
|
// SSH Options input
|
|
inputs[optionsInput] = textinput.New()
|
|
inputs[optionsInput].Placeholder = "-o Compression=yes -o ServerAliveInterval=60"
|
|
inputs[optionsInput].CharLimit = 500
|
|
inputs[optionsInput].Width = 70
|
|
inputs[optionsInput].SetValue(config.FormatSSHOptionsForCommand(host.Options))
|
|
|
|
// Tags input
|
|
inputs[tagsInput] = textinput.New()
|
|
inputs[tagsInput].Placeholder = "production, web, database"
|
|
inputs[tagsInput].CharLimit = 200
|
|
inputs[tagsInput].Width = 50
|
|
if len(host.Tags) > 0 {
|
|
inputs[tagsInput].SetValue(strings.Join(host.Tags, ", "))
|
|
}
|
|
|
|
return &editFormModel{
|
|
inputs: inputs,
|
|
focused: nameInput,
|
|
originalName: hostName,
|
|
host: host,
|
|
configFile: configFile,
|
|
styles: styles,
|
|
width: width,
|
|
height: height,
|
|
}, nil
|
|
}
|
|
|
|
// Messages for communication with parent model
|
|
type editFormSubmitMsg struct {
|
|
hostname string
|
|
err error
|
|
}
|
|
|
|
type editFormCancelMsg struct{}
|
|
|
|
func (m *editFormModel) Init() tea.Cmd {
|
|
return textinput.Blink
|
|
}
|
|
|
|
func (m *editFormModel) Update(msg tea.Msg) (*editFormModel, tea.Cmd) {
|
|
var cmds []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 editFormCancelMsg{} }
|
|
|
|
case "ctrl+enter":
|
|
// Allow submission from any field with Ctrl+Enter
|
|
return m, m.submitEditForm()
|
|
|
|
case "tab", "shift+tab", "enter", "up", "down":
|
|
s := msg.String()
|
|
|
|
// Handle form submission
|
|
if s == "enter" && m.focused == len(m.inputs)-1 {
|
|
return m, m.submitEditForm()
|
|
}
|
|
|
|
// Cycle inputs
|
|
if s == "up" || s == "shift+tab" {
|
|
m.focused--
|
|
} else {
|
|
m.focused++
|
|
}
|
|
|
|
if m.focused > len(m.inputs)-1 {
|
|
m.focused = 0
|
|
} else if m.focused < 0 {
|
|
m.focused = len(m.inputs) - 1
|
|
}
|
|
|
|
for i := range m.inputs {
|
|
if i == m.focused {
|
|
cmds = append(cmds, m.inputs[i].Focus())
|
|
continue
|
|
}
|
|
m.inputs[i].Blur()
|
|
}
|
|
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
case editFormSubmitMsg:
|
|
if msg.err != nil {
|
|
m.err = msg.err.Error()
|
|
} else {
|
|
m.success = true
|
|
m.err = ""
|
|
// Don't quit here, let parent handle the success
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// Update inputs
|
|
cmd := make([]tea.Cmd, len(m.inputs))
|
|
for i := range m.inputs {
|
|
m.inputs[i], cmd[i] = m.inputs[i].Update(msg)
|
|
}
|
|
cmds = append(cmds, cmd...)
|
|
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m *editFormModel) View() string {
|
|
if m.success {
|
|
return ""
|
|
}
|
|
|
|
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{
|
|
"Host Name *",
|
|
"Hostname/IP *",
|
|
"User",
|
|
"Port",
|
|
"Identity File",
|
|
"ProxyJump",
|
|
"SSH Options",
|
|
"Tags (comma-separated)",
|
|
}
|
|
|
|
for i, field := range fields {
|
|
b.WriteString(m.styles.FormField.Render(field))
|
|
b.WriteString("\n")
|
|
b.WriteString(m.inputs[i].View())
|
|
b.WriteString("\n\n")
|
|
}
|
|
|
|
if m.err != "" {
|
|
b.WriteString(m.styles.Error.Render("Error: " + m.err))
|
|
b.WriteString("\n\n")
|
|
}
|
|
|
|
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
|
|
b.WriteString("\n")
|
|
b.WriteString(m.styles.FormHelp.Render("* Required fields"))
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// Standalone wrapper for edit form
|
|
type standaloneEditForm struct {
|
|
*editFormModel
|
|
}
|
|
|
|
func (m standaloneEditForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case editFormSubmitMsg:
|
|
if msg.err != nil {
|
|
m.editFormModel.err = msg.err.Error()
|
|
} else {
|
|
m.editFormModel.success = true
|
|
return m, tea.Quit
|
|
}
|
|
return m, nil
|
|
case editFormCancelMsg:
|
|
return m, tea.Quit
|
|
}
|
|
|
|
newForm, cmd := m.editFormModel.Update(msg)
|
|
m.editFormModel = newForm
|
|
return m, cmd
|
|
}
|
|
|
|
// RunEditForm provides backward compatibility for standalone edit form
|
|
func RunEditForm(hostName string, configFile string) error {
|
|
styles := NewStyles(80)
|
|
editForm, err := NewEditForm(hostName, styles, 80, 24, configFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m := standaloneEditForm{editForm}
|
|
|
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
|
_, err = p.Run()
|
|
return err
|
|
}
|
|
|
|
func (m *editFormModel) submitEditForm() tea.Cmd {
|
|
return func() tea.Msg {
|
|
// Get values
|
|
name := strings.TrimSpace(m.inputs[nameInput].Value())
|
|
hostname := strings.TrimSpace(m.inputs[hostnameInput].Value())
|
|
user := strings.TrimSpace(m.inputs[userInput].Value())
|
|
port := strings.TrimSpace(m.inputs[portInput].Value())
|
|
identity := strings.TrimSpace(m.inputs[identityInput].Value())
|
|
proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value())
|
|
options := strings.TrimSpace(m.inputs[optionsInput].Value())
|
|
|
|
// Set defaults
|
|
if port == "" {
|
|
port = "22"
|
|
}
|
|
// Do not auto-fill identity with placeholder if left empty; keep it empty so it's optional
|
|
|
|
// Validate all fields
|
|
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
|
|
return editFormSubmitMsg{err: err}
|
|
}
|
|
|
|
// Parse tags
|
|
tagsStr := strings.TrimSpace(m.inputs[tagsInput].Value())
|
|
var tags []string
|
|
if tagsStr != "" {
|
|
for _, tag := range strings.Split(tagsStr, ",") {
|
|
tag = strings.TrimSpace(tag)
|
|
if tag != "" {
|
|
tags = append(tags, tag)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create updated host configuration
|
|
host := config.SSHHost{
|
|
Name: name,
|
|
Hostname: hostname,
|
|
User: user,
|
|
Port: port,
|
|
Identity: identity,
|
|
ProxyJump: proxyJump,
|
|
Options: config.ParseSSHOptionsFromCommand(options),
|
|
Tags: tags,
|
|
}
|
|
|
|
// Update the configuration
|
|
var err error
|
|
if m.configFile != "" {
|
|
err = config.UpdateSSHHostInFile(m.originalName, host, m.configFile)
|
|
} else {
|
|
err = config.UpdateSSHHost(m.originalName, host)
|
|
}
|
|
return editFormSubmitMsg{hostname: name, err: err}
|
|
}
|
|
}
|