diff --git a/cmd/move.go b/cmd/move.go new file mode 100644 index 0000000..e125f27 --- /dev/null +++ b/cmd/move.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "fmt" + + "github.com/Gu1llaum-3/sshm/internal/ui" + + "github.com/spf13/cobra" +) + +var moveCmd = &cobra.Command{ + Use: "move ", + Short: "Move an existing SSH host configuration to another config file", + Long: `Move an existing SSH host configuration to another config file with an interactive file selector.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + hostname := args[0] + + err := ui.RunMoveForm(hostname, configFile) + if err != nil { + fmt.Printf("Error moving host: %v\n", err) + } + }, +} + +func init() { + rootCmd.AddCommand(moveCmd) +} diff --git a/internal/config/ssh.go b/internal/config/ssh.go index 993afdb..3c1a16e 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -544,17 +544,40 @@ func HostExists(hostName string) (bool, error) { // HostExistsInFile checks if a host exists in a specific config file func HostExistsInFile(hostName string, configPath string) (bool, error) { - hosts, err := ParseSSHConfigFile(configPath) + // Parse only the specific file, not its includes + return HostExistsInSpecificFile(hostName, configPath) +} + +// HostExistsInSpecificFile checks if a host exists in a specific file only (no includes) +func HostExistsInSpecificFile(hostName string, configPath string) (bool, error) { + file, err := os.Open(configPath) if err != nil { + if os.IsNotExist(err) { + return false, nil + } return false, err } + defer file.Close() - for _, host := range hosts { - if host.Name == hostName { - return true, nil + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Check for Host declaration + if strings.HasPrefix(strings.ToLower(line), "host ") { + // Extract host names (can be multiple hosts on one line) + hostPart := strings.TrimSpace(line[5:]) // Remove "host " + hostNames := strings.Fields(hostPart) + + for _, name := range hostNames { + if name == hostName { + return true, nil + } + } } } - return false, nil + + return false, scanner.Err() } // GetSSHHost retrieves a specific host configuration by name @@ -940,3 +963,67 @@ func GetIncludedConfigFiles() ([]string, error) { return writableFiles, nil } + +// MoveHostToFile moves an SSH host from its current config file to a target config file +func MoveHostToFile(hostName string, targetConfigFile string) error { + // Find the host in all configs to get its current location and data + host, err := FindHostInAllConfigs(hostName) + if err != nil { + return err + } + + // Check if the target file is different from the current source file + if host.SourceFile == targetConfigFile { + return fmt.Errorf("host '%s' is already in the target config file '%s'", hostName, targetConfigFile) + } + + // First, add the host to the target config file + err = AddSSHHostToFile(*host, targetConfigFile) + if err != nil { + return fmt.Errorf("failed to add host to target file: %v", err) + } + + // Then, remove the host from its current source file + err = DeleteSSHHostFromFile(hostName, host.SourceFile) + if err != nil { + // If removal fails, we should try to rollback the addition, but for simplicity + // we'll just return the error. In a production environment, you might want + // to implement a proper rollback mechanism. + return fmt.Errorf("failed to remove host from source file: %v", err) + } + + return nil +} + +// GetConfigFilesExcludingCurrent returns all config files except the one containing the specified host +func GetConfigFilesExcludingCurrent(hostName string, baseConfigFile string) ([]string, error) { + // Get all config files + var allFiles []string + var err error + + if baseConfigFile != "" { + allFiles, err = GetAllConfigFilesFromBase(baseConfigFile) + } else { + allFiles, err = GetAllConfigFiles() + } + + if err != nil { + return nil, err + } + + // Find the host to get its current source file + host, err := FindHostInAllConfigs(hostName) + if err != nil { + return nil, err + } + + // Filter out the current source file + var filteredFiles []string + for _, file := range allFiles { + if file != host.SourceFile { + filteredFiles = append(filteredFiles, file) + } + } + + return filteredFiles, nil +} diff --git a/internal/config/ssh_test.go b/internal/config/ssh_test.go index ee610f3..666551f 100644 --- a/internal/config/ssh_test.go +++ b/internal/config/ssh_test.go @@ -813,3 +813,177 @@ Include subdir/*.conf t.Errorf("GetAllConfigFilesFromBase('') should behave like GetAllConfigFiles(). Got %d vs %d files", len(defaultFiles), len(allFiles)) } } + +func TestHostExistsInSpecificFile(t *testing.T) { + // Create temporary directory for test files + tempDir := t.TempDir() + + // Create main config file + mainConfig := filepath.Join(tempDir, "config") + mainConfigContent := `Host main-host + HostName example.com + User mainuser + +Include included.conf + +Host another-host + HostName another.example.com + User anotheruser +` + + err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600) + if err != nil { + t.Fatalf("Failed to create main config: %v", err) + } + + // Create included config file + includedConfig := filepath.Join(tempDir, "included.conf") + includedConfigContent := `Host included-host + HostName included.example.com + User includeduser +` + + err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600) + if err != nil { + t.Fatalf("Failed to create included config: %v", err) + } + + // Test that host exists in main config file (should ignore includes) + exists, err := HostExistsInSpecificFile("main-host", mainConfig) + if err != nil { + t.Fatalf("HostExistsInSpecificFile() error = %v", err) + } + if !exists { + t.Error("main-host should exist in main config file") + } + + // Test that host from included file does NOT exist in main config file + exists, err = HostExistsInSpecificFile("included-host", mainConfig) + if err != nil { + t.Fatalf("HostExistsInSpecificFile() error = %v", err) + } + if exists { + t.Error("included-host should NOT exist in main config file (should ignore includes)") + } + + // Test that host exists in included config file + exists, err = HostExistsInSpecificFile("included-host", includedConfig) + if err != nil { + t.Fatalf("HostExistsInSpecificFile() error = %v", err) + } + if !exists { + t.Error("included-host should exist in included config file") + } + + // Test non-existent host + exists, err = HostExistsInSpecificFile("non-existent", mainConfig) + if err != nil { + t.Fatalf("HostExistsInSpecificFile() error = %v", err) + } + if exists { + t.Error("non-existent host should not exist") + } + + // Test with non-existent file + exists, err = HostExistsInSpecificFile("any-host", "/non/existent/file") + if err != nil { + t.Fatalf("HostExistsInSpecificFile() should not return error for non-existent file: %v", err) + } + if exists { + t.Error("non-existent file should not contain any hosts") + } +} + +func TestGetConfigFilesExcludingCurrent(t *testing.T) { + // This test verifies the function works when SSH config is properly set up + // Since GetConfigFilesExcludingCurrent depends on FindHostInAllConfigs which uses the default SSH config, + // we'll test the function more directly by creating a temporary SSH config setup + + // Skip this test if we can't access SSH config directory + _, err := GetSSHDirectory() + if err != nil { + t.Skipf("Skipping test: cannot get SSH directory: %v", err) + } + + // Check if SSH config exists + defaultConfigPath, err := GetDefaultSSHConfigPath() + if err != nil { + t.Skipf("Skipping test: cannot get default SSH config path: %v", err) + } + + if _, err := os.Stat(defaultConfigPath); os.IsNotExist(err) { + t.Skipf("Skipping test: SSH config file does not exist at %s", defaultConfigPath) + } + + // Test that the function returns something for a hypothetical host + // We can't guarantee specific hosts exist, so we test the function doesn't crash + _, err = GetConfigFilesExcludingCurrent("test-host-that-probably-does-not-exist", defaultConfigPath) + if err == nil { + t.Log("GetConfigFilesExcludingCurrent() succeeded for non-existent host (expected)") + } else if strings.Contains(err.Error(), "not found") { + t.Log("GetConfigFilesExcludingCurrent() correctly reported host not found") + } else { + t.Fatalf("GetConfigFilesExcludingCurrent() unexpected error = %v", err) + } + + // Test with valid SSH config directory + if err == nil { + t.Log("GetConfigFilesExcludingCurrent() function is working correctly") + } +} + +func TestMoveHostToFile(t *testing.T) { + // This test verifies the MoveHostToFile function works when SSH config is properly set up + // Since MoveHostToFile depends on FindHostInAllConfigs which uses the default SSH config, + // we'll test the error handling and basic function behavior + + // Check if SSH config exists + defaultConfigPath, err := GetDefaultSSHConfigPath() + if err != nil { + t.Skipf("Skipping test: cannot get default SSH config path: %v", err) + } + + if _, err := os.Stat(defaultConfigPath); os.IsNotExist(err) { + t.Skipf("Skipping test: SSH config file does not exist at %s", defaultConfigPath) + } + + // Create a temporary destination config file + tempDir := t.TempDir() + destConfig := filepath.Join(tempDir, "dest.conf") + destConfigContent := `Host dest-host + HostName dest.example.com + User destuser +` + + err = os.WriteFile(destConfig, []byte(destConfigContent), 0600) + if err != nil { + t.Fatalf("Failed to create dest config: %v", err) + } + + // Test moving non-existent host (should return error) + err = MoveHostToFile("non-existent-host-12345", destConfig) + if err == nil { + t.Error("MoveHostToFile() should return error for non-existent host") + } else if !strings.Contains(err.Error(), "not found") { + t.Errorf("Expected 'not found' error, got: %v", err) + } + + // Test moving to non-existent file (should return error) + err = MoveHostToFile("any-host", "/non/existent/file") + if err == nil { + t.Error("MoveHostToFile() should return error for non-existent destination file") + } + + // Verify that the HostExistsInSpecificFile function works correctly + // This is a component that MoveHostToFile uses + exists, err := HostExistsInSpecificFile("dest-host", destConfig) + if err != nil { + t.Fatalf("HostExistsInSpecificFile() error = %v", err) + } + if !exists { + t.Error("dest-host should exist in destination config file") + } + + // Test that the component functions work for the move operation + t.Log("MoveHostToFile() error handling works correctly") +} diff --git a/internal/ui/help_form.go b/internal/ui/help_form.go index 6dff415..3b21744 100644 --- a/internal/ui/help_form.go +++ b/internal/ui/help_form.go @@ -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"), ) diff --git a/internal/ui/model.go b/internal/ui/model.go index 38194bb..60792c7 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -36,6 +36,7 @@ const ( ViewList ViewMode = iota ViewAdd ViewEdit + ViewMove ViewInfo ViewPortForward ViewHelp @@ -82,6 +83,7 @@ type Model struct { viewMode ViewMode addForm *addFormModel editForm *editFormModel + moveForm *moveFormModel infoForm *infoFormModel portForwardForm *portForwardModel helpForm *helpModel diff --git a/internal/ui/move_form.go b/internal/ui/move_form.go new file mode 100644 index 0000000..a5032e0 --- /dev/null +++ b/internal/ui/move_form.go @@ -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 +} diff --git a/internal/ui/update.go b/internal/ui/update.go index d066fa4..f682b80 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -85,6 +85,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 @@ -203,6 +208,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 @@ -303,6 +353,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 @@ -483,6 +540,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 diff --git a/internal/ui/view.go b/internal/ui/view.go index 274403a..19fb0cf 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -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()