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