mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2025-10-19 09:27:19 +02:00
Merge branch 'feature/add-move-command' into dev
Add move command feature: - Allows moving a host from one config file to another when using includes - Available both in the TUI interface and via CLI with 'sshm move'
This commit is contained in:
commit
fe529792e3
28
cmd/move.go
Normal file
28
cmd/move.go
Normal file
@ -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 <hostname>",
|
||||||
|
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)
|
||||||
|
}
|
@ -544,17 +544,40 @@ func HostExists(hostName string) (bool, error) {
|
|||||||
|
|
||||||
// HostExistsInFile checks if a host exists in a specific config file
|
// HostExistsInFile checks if a host exists in a specific config file
|
||||||
func HostExistsInFile(hostName string, configPath string) (bool, error) {
|
func HostExistsInFile(hostName string, configPath string) (bool, error) {
|
||||||
hosts, err := ParseSSHConfigFile(configPath)
|
// Parse only the specific file, not its includes
|
||||||
if err != nil {
|
return HostExistsInSpecificFile(hostName, configPath)
|
||||||
return false, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, host := range hosts {
|
// HostExistsInSpecificFile checks if a host exists in a specific file only (no includes)
|
||||||
if host.Name == hostName {
|
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()
|
||||||
|
|
||||||
|
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 true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false, nil
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, scanner.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSSHHost retrieves a specific host configuration by name
|
// GetSSHHost retrieves a specific host configuration by name
|
||||||
@ -940,3 +963,67 @@ func GetIncludedConfigFiles() ([]string, error) {
|
|||||||
|
|
||||||
return writableFiles, nil
|
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
|
||||||
|
}
|
||||||
|
@ -813,3 +813,177 @@ Include subdir/*.conf
|
|||||||
t.Errorf("GetAllConfigFilesFromBase('') should behave like GetAllConfigFiles(). Got %d vs %d files", len(defaultFiles), len(allFiles))
|
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")
|
||||||
|
}
|
||||||
|
@ -40,64 +40,85 @@ func (m *helpModel) Update(msg tea.Msg) (*helpModel, tea.Cmd) {
|
|||||||
|
|
||||||
func (m *helpModel) View() string {
|
func (m *helpModel) View() string {
|
||||||
// Title
|
// Title
|
||||||
title := m.styles.Header.Render("📖 SSHM - Help & Commands")
|
title := m.styles.Header.Render("📖 SSHM - Commands")
|
||||||
|
|
||||||
// Create horizontal sections with compact layout
|
// Create two columns of commands for better visual organization
|
||||||
line1 := lipgloss.JoinHorizontal(lipgloss.Center,
|
leftColumn := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
m.styles.FocusedLabel.Render("🧭 ↑/↓/j/k"),
|
m.styles.FocusedLabel.Render("Navigation & Connection"),
|
||||||
" ",
|
|
||||||
m.styles.HelpText.Render("navigate"),
|
|
||||||
"",
|
"",
|
||||||
|
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||||
m.styles.FocusedLabel.Render("⏎ "),
|
m.styles.FocusedLabel.Render("⏎ "),
|
||||||
" ",
|
m.styles.HelpText.Render("connect to selected host")),
|
||||||
m.styles.HelpText.Render("connect"),
|
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||||
" ",
|
m.styles.FocusedLabel.Render("i "),
|
||||||
m.styles.FocusedLabel.Render("a/e/d"),
|
m.styles.HelpText.Render("show host information")),
|
||||||
" ",
|
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||||
m.styles.HelpText.Render("add/edit/delete"),
|
|
||||||
)
|
|
||||||
|
|
||||||
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"),
|
|
||||||
)
|
|
||||||
|
|
||||||
line3 := lipgloss.JoinHorizontal(lipgloss.Center,
|
|
||||||
m.styles.FocusedLabel.Render("/ "),
|
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.HelpText.Render("search"),
|
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")),
|
||||||
|
)
|
||||||
|
|
||||||
|
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.FocusedLabel.Render("h "),
|
||||||
" ",
|
m.styles.HelpText.Render("show this help")),
|
||||||
m.styles.HelpText.Render("help"),
|
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||||
" ",
|
m.styles.FocusedLabel.Render("q "),
|
||||||
m.styles.FocusedLabel.Render("q/ESC"),
|
m.styles.HelpText.Render("quit application")),
|
||||||
" ",
|
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||||
m.styles.HelpText.Render("quit"),
|
m.styles.FocusedLabel.Render("ESC "),
|
||||||
|
m.styles.HelpText.Render("exit current view")),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Join the two columns side by side
|
||||||
|
columns := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
|
leftColumn,
|
||||||
|
" ", // spacing between columns
|
||||||
|
rightColumn,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create the main content
|
// Create the main content
|
||||||
content := lipgloss.JoinVertical(lipgloss.Center,
|
content := lipgloss.JoinVertical(lipgloss.Center,
|
||||||
title,
|
title,
|
||||||
"",
|
"",
|
||||||
line1,
|
columns,
|
||||||
"",
|
|
||||||
line2,
|
|
||||||
"",
|
|
||||||
line3,
|
|
||||||
"",
|
"",
|
||||||
m.styles.HelpText.Render("Press ESC, h, q or Enter to close"),
|
m.styles.HelpText.Render("Press ESC, h, q or Enter to close"),
|
||||||
)
|
)
|
||||||
|
@ -37,6 +37,7 @@ const (
|
|||||||
ViewList ViewMode = iota
|
ViewList ViewMode = iota
|
||||||
ViewAdd
|
ViewAdd
|
||||||
ViewEdit
|
ViewEdit
|
||||||
|
ViewMove
|
||||||
ViewInfo
|
ViewInfo
|
||||||
ViewPortForward
|
ViewPortForward
|
||||||
ViewHelp
|
ViewHelp
|
||||||
@ -87,6 +88,7 @@ type Model struct {
|
|||||||
viewMode ViewMode
|
viewMode ViewMode
|
||||||
addForm *addFormModel
|
addForm *addFormModel
|
||||||
editForm *editFormModel
|
editForm *editFormModel
|
||||||
|
moveForm *moveFormModel
|
||||||
infoForm *infoFormModel
|
infoForm *infoFormModel
|
||||||
portForwardForm *portForwardModel
|
portForwardForm *portForwardModel
|
||||||
helpForm *helpModel
|
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.height = m.height
|
||||||
m.editForm.styles = m.styles
|
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 {
|
if m.infoForm != nil {
|
||||||
m.infoForm.width = m.width
|
m.infoForm.width = m.width
|
||||||
m.infoForm.height = m.height
|
m.infoForm.height = m.height
|
||||||
@ -240,6 +245,51 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.table.Focus()
|
m.table.Focus()
|
||||||
return m, nil
|
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:
|
case infoFormCancelMsg:
|
||||||
// Cancel: return to list view
|
// Cancel: return to list view
|
||||||
m.viewMode = ViewList
|
m.viewMode = ViewList
|
||||||
@ -340,6 +390,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.editForm = newForm
|
m.editForm = newForm
|
||||||
return m, cmd
|
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:
|
case ViewInfo:
|
||||||
if m.infoForm != nil {
|
if m.infoForm != nil {
|
||||||
var newForm *infoFormModel
|
var newForm *infoFormModel
|
||||||
@ -522,6 +579,22 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, textinput.Blink
|
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":
|
case "i":
|
||||||
if !m.searchMode && !m.deleteMode {
|
if !m.searchMode && !m.deleteMode {
|
||||||
// Show info for the selected host
|
// Show info for the selected host
|
||||||
|
@ -23,6 +23,10 @@ func (m Model) View() string {
|
|||||||
if m.editForm != nil {
|
if m.editForm != nil {
|
||||||
return m.editForm.View()
|
return m.editForm.View()
|
||||||
}
|
}
|
||||||
|
case ViewMove:
|
||||||
|
if m.moveForm != nil {
|
||||||
|
return m.moveForm.View()
|
||||||
|
}
|
||||||
case ViewInfo:
|
case ViewInfo:
|
||||||
if m.infoForm != nil {
|
if m.infoForm != nil {
|
||||||
return m.infoForm.View()
|
return m.infoForm.View()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user