feat: add move command to relocate SSH hosts between config files

- Add 'move' command with interactive file selector
- Implement atomic host moving between SSH config files
- Support for configs with include directives
- Add comprehensive error handling and validation
- Update help screen with improved two-column layout
This commit is contained in:
2025-09-08 16:26:30 +02:00
parent 5c832ce26f
commit 77b2b8fd22
8 changed files with 629 additions and 52 deletions

View File

@@ -40,64 +40,85 @@ func (m *helpModel) Update(msg tea.Msg) (*helpModel, tea.Cmd) {
func (m *helpModel) View() string {
// Title
title := m.styles.Header.Render("📖 SSHM - Help & Commands")
title := m.styles.Header.Render("📖 SSHM - Commands")
// Create horizontal sections with compact layout
line1 := lipgloss.JoinHorizontal(lipgloss.Center,
m.styles.FocusedLabel.Render("🧭 ↑/↓/j/k"),
" ",
m.styles.HelpText.Render("navigate"),
" ",
m.styles.FocusedLabel.Render("⏎"),
" ",
m.styles.HelpText.Render("connect"),
" ",
m.styles.FocusedLabel.Render("a/e/d"),
" ",
m.styles.HelpText.Render("add/edit/delete"),
// Create two columns of commands for better visual organization
leftColumn := lipgloss.JoinVertical(lipgloss.Left,
m.styles.FocusedLabel.Render("Navigation & Connection"),
"",
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render(" "),
m.styles.HelpText.Render("connect to selected host")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("i "),
m.styles.HelpText.Render("show host information")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("/ "),
m.styles.HelpText.Render("search hosts")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("Tab "),
m.styles.HelpText.Render("switch focus")),
"",
m.styles.FocusedLabel.Render("Host Management"),
"",
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("a "),
m.styles.HelpText.Render("add new host")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("e "),
m.styles.HelpText.Render("edit selected host")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("m "),
m.styles.HelpText.Render("move host to another config")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("d "),
m.styles.HelpText.Render("delete selected host")),
)
line2 := lipgloss.JoinHorizontal(lipgloss.Center,
m.styles.FocusedLabel.Render("Tab"),
" ",
m.styles.HelpText.Render("switch focus"),
" ",
m.styles.FocusedLabel.Render("p"),
" ",
m.styles.HelpText.Render("ping all"),
" ",
m.styles.FocusedLabel.Render("f"),
" ",
m.styles.HelpText.Render("port forward"),
" ",
m.styles.FocusedLabel.Render("s/r/n"),
" ",
m.styles.HelpText.Render("sort modes"),
rightColumn := lipgloss.JoinVertical(lipgloss.Left,
m.styles.FocusedLabel.Render("Advanced Features"),
"",
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("p "),
m.styles.HelpText.Render("ping all hosts")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("f "),
m.styles.HelpText.Render("setup port forwarding")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("s "),
m.styles.HelpText.Render("cycle sort modes")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("n "),
m.styles.HelpText.Render("sort by name")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("r "),
m.styles.HelpText.Render("sort by recent connection")),
"",
m.styles.FocusedLabel.Render("System"),
"",
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("h "),
m.styles.HelpText.Render("show this help")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("q "),
m.styles.HelpText.Render("quit application")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("ESC "),
m.styles.HelpText.Render("exit current view")),
)
line3 := lipgloss.JoinHorizontal(lipgloss.Center,
m.styles.FocusedLabel.Render("/"),
" ",
m.styles.HelpText.Render("search"),
" ",
m.styles.FocusedLabel.Render("h"),
" ",
m.styles.HelpText.Render("help"),
" ",
m.styles.FocusedLabel.Render("q/ESC"),
" ",
m.styles.HelpText.Render("quit"),
// Join the two columns side by side
columns := lipgloss.JoinHorizontal(lipgloss.Top,
leftColumn,
" ", // spacing between columns
rightColumn,
)
// Create the main content
content := lipgloss.JoinVertical(lipgloss.Center,
title,
"",
line1,
"",
line2,
"",
line3,
columns,
"",
m.styles.HelpText.Render("Press ESC, h, q or Enter to close"),
)

View File

@@ -37,6 +37,7 @@ const (
ViewList ViewMode = iota
ViewAdd
ViewEdit
ViewMove
ViewInfo
ViewPortForward
ViewHelp
@@ -87,6 +88,7 @@ type Model struct {
viewMode ViewMode
addForm *addFormModel
editForm *editFormModel
moveForm *moveFormModel
infoForm *infoFormModel
portForwardForm *portForwardModel
helpForm *helpModel

188
internal/ui/move_form.go Normal file
View File

@@ -0,0 +1,188 @@
package ui
import (
"fmt"
"github.com/Gu1llaum-3/sshm/internal/config"
tea "github.com/charmbracelet/bubbletea"
)
type moveFormModel struct {
fileSelector *fileSelectorModel
hostName string
configFile string
width int
height int
styles Styles
state moveFormState
}
type moveFormState int
const (
moveFormSelectingFile moveFormState = iota
moveFormProcessing
)
type moveFormSubmitMsg struct {
hostName string
targetFile string
err error
}
type moveFormCancelMsg struct{}
// NewMoveForm creates a new move form for moving a host to another config file
func NewMoveForm(hostName string, styles Styles, width, height int, configFile string) (*moveFormModel, error) {
// Get all config files except the one containing the current host
files, err := config.GetConfigFilesExcludingCurrent(hostName, configFile)
if err != nil {
return nil, fmt.Errorf("failed to get config files: %v", err)
}
if len(files) == 0 {
return nil, fmt.Errorf("no other config files available to move host to")
}
// Create a custom file selector for move operation
fileSelector, err := newFileSelectorFromFiles(
fmt.Sprintf("Select destination config file for host '%s':", hostName),
styles,
width,
height,
files,
)
if err != nil {
return nil, fmt.Errorf("failed to create file selector: %v", err)
}
return &moveFormModel{
fileSelector: fileSelector,
hostName: hostName,
configFile: configFile,
width: width,
height: height,
styles: styles,
state: moveFormSelectingFile,
}, nil
}
func (m *moveFormModel) Init() tea.Cmd {
return m.fileSelector.Init()
}
func (m *moveFormModel) Update(msg tea.Msg) (*moveFormModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.styles = NewStyles(m.width)
if m.fileSelector != nil {
m.fileSelector.width = m.width
m.fileSelector.height = m.height
m.fileSelector.styles = m.styles
}
return m, nil
case tea.KeyMsg:
switch m.state {
case moveFormSelectingFile:
switch msg.String() {
case "enter":
if m.fileSelector != nil && len(m.fileSelector.files) > 0 {
selectedFile := m.fileSelector.files[m.fileSelector.selected]
m.state = moveFormProcessing
return m, m.submitMove(selectedFile)
}
case "esc", "q":
return m, func() tea.Msg { return moveFormCancelMsg{} }
default:
// Forward other keys to file selector
if m.fileSelector != nil {
newFileSelector, cmd := m.fileSelector.Update(msg)
m.fileSelector = newFileSelector
return m, cmd
}
}
case moveFormProcessing:
// Dans cet état, on attend le résultat de l'opération
// Le résultat sera géré par le modèle principal
switch msg.String() {
case "esc", "q":
return m, func() tea.Msg { return moveFormCancelMsg{} }
}
}
}
return m, nil
}
func (m *moveFormModel) View() string {
switch m.state {
case moveFormSelectingFile:
if m.fileSelector != nil {
return m.fileSelector.View()
}
return "Loading..."
case moveFormProcessing:
return m.styles.FormTitle.Render("Moving host...") + "\n\n" +
m.styles.HelpText.Render(fmt.Sprintf("Moving host '%s' to selected config file...", m.hostName))
default:
return "Unknown state"
}
}
func (m *moveFormModel) submitMove(targetFile string) tea.Cmd {
return func() tea.Msg {
err := config.MoveHostToFile(m.hostName, targetFile)
return moveFormSubmitMsg{
hostName: m.hostName,
targetFile: targetFile,
err: err,
}
}
}
// Standalone move form for CLI usage
type standaloneMoveForm struct {
moveFormModel *moveFormModel
}
func (m standaloneMoveForm) Init() tea.Cmd {
return m.moveFormModel.Init()
}
func (m standaloneMoveForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case moveFormCancelMsg:
return m, tea.Quit
case moveFormSubmitMsg:
// En mode standalone, on quitte après le déplacement (succès ou erreur)
return m, tea.Quit
}
newForm, cmd := m.moveFormModel.Update(msg)
m.moveFormModel = newForm
return m, cmd
}
func (m standaloneMoveForm) View() string {
return m.moveFormModel.View()
}
// RunMoveForm provides backward compatibility for standalone move form
func RunMoveForm(hostName string, configFile string) error {
styles := NewStyles(80)
moveForm, err := NewMoveForm(hostName, styles, 80, 24, configFile)
if err != nil {
return err
}
m := standaloneMoveForm{moveForm}
p := tea.NewProgram(m, tea.WithAltScreen())
_, err = p.Run()
return err
}

View File

@@ -109,6 +109,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.editForm.height = m.height
m.editForm.styles = m.styles
}
if m.moveForm != nil {
m.moveForm.width = m.width
m.moveForm.height = m.height
m.moveForm.styles = m.styles
}
if m.infoForm != nil {
m.infoForm.width = m.width
m.infoForm.height = m.height
@@ -240,6 +245,51 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.table.Focus()
return m, nil
case moveFormSubmitMsg:
if msg.err != nil {
// En cas d'erreur, on pourrait afficher une notification ou retourner à la liste
// Pour l'instant, on retourne simplement à la liste
m.viewMode = ViewList
m.moveForm = nil
m.table.Focus()
return m, nil
} else {
// Success: refresh hosts and return to list view
var hosts []config.SSHHost
var err error
if m.configFile != "" {
hosts, err = config.ParseSSHConfigFile(m.configFile)
} else {
hosts, err = config.ParseSSHConfig()
}
if err != nil {
return m, tea.Quit
}
m.hosts = m.sortHosts(hosts)
// Reapply search filter if there is one active
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.hosts
}
m.updateTableRows()
m.viewMode = ViewList
m.moveForm = nil
m.table.Focus()
return m, nil
}
case moveFormCancelMsg:
// Cancel: return to list view
m.viewMode = ViewList
m.moveForm = nil
m.table.Focus()
return m, nil
case infoFormCancelMsg:
// Cancel: return to list view
m.viewMode = ViewList
@@ -340,6 +390,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.editForm = newForm
return m, cmd
}
case ViewMove:
if m.moveForm != nil {
var newForm *moveFormModel
newForm, cmd = m.moveForm.Update(msg)
m.moveForm = newForm
return m, cmd
}
case ViewInfo:
if m.infoForm != nil {
var newForm *infoFormModel
@@ -522,6 +579,22 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, textinput.Blink
}
}
case "m":
if !m.searchMode && !m.deleteMode {
// Move the selected host to another config file
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
moveForm, err := NewMoveForm(hostName, m.styles, m.width, m.height, m.configFile)
if err != nil {
// Handle error - could show in UI, e.g., no other config files available
return m, nil
}
m.moveForm = moveForm
m.viewMode = ViewMove
return m, textinput.Blink
}
}
case "i":
if !m.searchMode && !m.deleteMode {
// Show info for the selected host

View File

@@ -23,6 +23,10 @@ func (m Model) View() string {
if m.editForm != nil {
return m.editForm.View()
}
case ViewMove:
if m.moveForm != nil {
return m.moveForm.View()
}
case ViewInfo:
if m.infoForm != nil {
return m.infoForm.View()