├── images ├── logo.png └── sshm.gif ├── main.go ├── internal ├── config │ ├── permissions_unix.go │ ├── permissions_windows.go │ ├── keybindings.go │ └── keybindings_test.go ├── version │ ├── version_test.go │ └── version.go ├── validation │ ├── ssh.go │ └── ssh_test.go ├── ui │ ├── sort.go │ ├── utils.go │ ├── model.go │ ├── styles.go │ ├── help_form.go │ ├── file_selector.go │ ├── tui.go │ ├── move_form.go │ ├── info_form.go │ ├── view.go │ ├── search_test.go │ ├── table.go │ └── add_form.go ├── connectivity │ ├── ping_test.go │ └── ping.go └── history │ ├── history_test.go │ ├── port_forward_test.go │ └── history.go ├── cmd ├── edit.go ├── add.go ├── move.go ├── edit_test.go ├── add_test.go ├── search_test.go ├── root_test.go ├── search.go └── root.go ├── .gitignore ├── .github ├── workflows │ └── release.yml └── copilot-instructions.md ├── LICENSE ├── Makefile ├── go.mod ├── install ├── README.md ├── windows.ps1 └── unix.sh ├── .goreleaser.yaml └── go.sum /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gu1llaum-3/sshm/HEAD/images/logo.png -------------------------------------------------------------------------------- /images/sshm.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gu1llaum-3/sshm/HEAD/images/sshm.gif -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/Gu1llaum-3/sshm/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /internal/config/permissions_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package config 4 | 5 | import "os" 6 | 7 | // SetSecureFilePermissions configures secure permissions on Unix systems 8 | func SetSecureFilePermissions(filepath string) error { 9 | // Set file permissions to 0600 (owner read/write only) 10 | return os.Chmod(filepath, 0600) 11 | } 12 | -------------------------------------------------------------------------------- /internal/config/permissions_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package config 4 | 5 | import ( 6 | "os" 7 | ) 8 | 9 | // SetSecureFilePermissions configures secure permissions on Windows 10 | func SetSecureFilePermissions(filepath string) error { 11 | // On Windows, file permissions work differently 12 | // We ensure the file is not read-only and has basic permissions 13 | info, err := os.Stat(filepath) 14 | if err != nil { 15 | return err 16 | } 17 | 18 | // Ensure the file is not read-only 19 | if info.Mode()&os.ModeType == 0 { 20 | return os.Chmod(filepath, 0600) 21 | } 22 | 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /cmd/edit.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/Gu1llaum-3/sshm/internal/ui" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var editCmd = &cobra.Command{ 12 | Use: "edit ", 13 | Short: "Edit an existing SSH host configuration", 14 | Long: `Edit an existing SSH host configuration with an interactive form.`, 15 | Args: cobra.ExactArgs(1), 16 | Run: func(cmd *cobra.Command, args []string) { 17 | hostname := args[0] 18 | 19 | err := ui.RunEditForm(hostname, configFile) 20 | if err != nil { 21 | fmt.Printf("Error editing host: %v\n", err) 22 | } 23 | }, 24 | } 25 | 26 | func init() { 27 | RootCmd.AddCommand(editCmd) 28 | } 29 | -------------------------------------------------------------------------------- /cmd/add.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/Gu1llaum-3/sshm/internal/ui" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var addCmd = &cobra.Command{ 12 | Use: "add [hostname]", 13 | Short: "Add a new SSH host configuration", 14 | Long: `Add a new SSH host configuration with an interactive form.`, 15 | Args: cobra.MaximumNArgs(1), 16 | Run: func(cmd *cobra.Command, args []string) { 17 | var hostname string 18 | if len(args) > 0 { 19 | hostname = args[0] 20 | } 21 | 22 | err := ui.RunAddForm(hostname, configFile) 23 | if err != nil { 24 | fmt.Printf("Error adding host: %v\n", err) 25 | } 26 | }, 27 | } 28 | 29 | func init() { 30 | RootCmd.AddCommand(addCmd) 31 | } 32 | -------------------------------------------------------------------------------- /cmd/move.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/Gu1llaum-3/sshm/internal/ui" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var moveCmd = &cobra.Command{ 12 | Use: "move ", 13 | Short: "Move an existing SSH host configuration to another config file", 14 | Long: `Move an existing SSH host configuration to another config file with an interactive file selector.`, 15 | Args: cobra.ExactArgs(1), 16 | Run: func(cmd *cobra.Command, args []string) { 17 | hostname := args[0] 18 | 19 | err := ui.RunMoveForm(hostname, configFile) 20 | if err != nil { 21 | fmt.Printf("Error moving host: %v\n", err) 22 | } 23 | }, 24 | } 25 | 26 | func init() { 27 | RootCmd.AddCommand(moveCmd) 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Go workspace file 18 | go.work 19 | 20 | # IDE files 21 | .vscode/ 22 | .idea/ 23 | *.swp 24 | *.swo 25 | *~ 26 | 27 | # OS generated files 28 | .DS_Store 29 | .DS_Store? 30 | ._* 31 | .Spotlight-V100 32 | .Trashes 33 | ehthumbs.db 34 | Thumbs.db 35 | 36 | # Build artifacts 37 | dist/ 38 | build/ 39 | bin/ 40 | 41 | # Temporary files 42 | *.tmp 43 | *.temp 44 | temp/ 45 | 46 | # Log files 47 | *.log 48 | 49 | # Project specific 50 | sshm 51 | sshm-* 52 | !sshm-test 53 | release/ 54 | 55 | # Backup files 56 | *.backup 57 | *.bak 58 | 59 | # Local development files 60 | .env 61 | .env.local 62 | .env.development.local 63 | .env.test.local 64 | .env.production.local 65 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release with GoReleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write 10 | # Required for Homebrew tap updates 11 | issues: write 12 | pull-requests: write 13 | 14 | jobs: 15 | goreleaser: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | with: 21 | # Fetch full history for changelog generation 22 | fetch-depth: 0 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: '1.23' 28 | cache: true 29 | 30 | - name: Run GoReleaser 31 | uses: goreleaser/goreleaser-action@v6 32 | with: 33 | distribution: goreleaser 34 | version: '~> v2' 35 | args: release --clean 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | # Token for updating Homebrew tap (create this secret in your repo settings) 39 | HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Guillaume Archambault 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build build-local test clean release snapshot 2 | 3 | # Version can be overridden via environment variable or command line 4 | VERSION ?= dev 5 | 6 | # Go build flags 7 | LDFLAGS := -s -w -X github.com/Gu1llaum-3/sshm/cmd.AppVersion=$(VERSION) 8 | 9 | # Build with specific version 10 | build: 11 | @mkdir -p dist 12 | go build -ldflags="$(LDFLAGS)" -o dist/sshm . 13 | 14 | # Build with git version 15 | build-local: VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") 16 | build-local: build 17 | 18 | # Run tests 19 | test: 20 | go test ./... 21 | 22 | # Clean build artifacts 23 | clean: 24 | rm -rf dist 25 | 26 | # Release with GoReleaser (requires tag) 27 | release: 28 | @if [ -z "$(shell git tag --points-at HEAD)" ]; then \ 29 | echo "Error: No git tag found at current commit. Create a tag first with: git tag vX.Y.Z"; \ 30 | exit 1; \ 31 | fi 32 | goreleaser release --clean 33 | 34 | # Build snapshot (without tag) 35 | snapshot: 36 | goreleaser release --snapshot --clean 37 | 38 | # Check GoReleaser config 39 | release-check: 40 | goreleaser check 41 | 42 | # Run GoReleaser in dry-run mode 43 | release-dry-run: 44 | goreleaser release --snapshot --skip=publish --clean 45 | -------------------------------------------------------------------------------- /internal/version/version_test.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestParseVersion(t *testing.T) { 8 | tests := []struct { 9 | version string 10 | expected []int 11 | }{ 12 | {"v1.2.3", []int{1, 2, 3}}, 13 | {"1.2.3", []int{1, 2, 3}}, 14 | {"v2.0.0", []int{2, 0, 0}}, 15 | {"1.2.3-beta", []int{1, 2, 3}}, 16 | {"1.2.3-rc1", []int{1, 2, 3}}, 17 | {"dev", []int{0}}, 18 | } 19 | 20 | for _, test := range tests { 21 | result := parseVersion(test.version) 22 | if len(result) != len(test.expected) { 23 | t.Errorf("parseVersion(%q) length = %d, want %d", test.version, len(result), len(test.expected)) 24 | continue 25 | } 26 | for i, v := range result { 27 | if v != test.expected[i] { 28 | t.Errorf("parseVersion(%q)[%d] = %d, want %d", test.version, i, v, test.expected[i]) 29 | break 30 | } 31 | } 32 | } 33 | } 34 | 35 | func TestCompareVersions(t *testing.T) { 36 | tests := []struct { 37 | v1 string 38 | v2 string 39 | expected int 40 | }{ 41 | {"v1.0.0", "v1.0.1", -1}, 42 | {"v1.0.1", "v1.0.0", 1}, 43 | {"v1.0.0", "v1.0.0", 0}, 44 | {"1.2.3", "1.2.4", -1}, 45 | {"2.0.0", "1.9.9", 1}, 46 | {"1.2.3-beta", "1.2.3", 0}, // Should ignore suffixes 47 | {"1.2.3", "1.2.3-rc1", 0}, 48 | } 49 | 50 | for _, test := range tests { 51 | result := compareVersions(test.v1, test.v2) 52 | if result != test.expected { 53 | t.Errorf("compareVersions(%q, %q) = %d, want %d", test.v1, test.v2, result, test.expected) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Gu1llaum-3/sshm 2 | 3 | go 1.23.1 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.21.0 7 | github.com/charmbracelet/bubbletea v1.3.6 8 | github.com/charmbracelet/lipgloss v1.1.0 9 | github.com/spf13/cobra v1.9.1 10 | golang.org/x/crypto v0.41.0 11 | ) 12 | 13 | require ( 14 | github.com/atotto/clipboard v0.1.4 // indirect 15 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 16 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 17 | github.com/charmbracelet/x/ansi v0.9.3 // indirect 18 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 19 | github.com/charmbracelet/x/term v0.2.1 // indirect 20 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 21 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 22 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 23 | github.com/mattn/go-isatty v0.0.20 // indirect 24 | github.com/mattn/go-localereader v0.0.1 // indirect 25 | github.com/mattn/go-runewidth v0.0.16 // indirect 26 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 27 | github.com/muesli/cancelreader v0.2.2 // indirect 28 | github.com/muesli/termenv v0.16.0 // indirect 29 | github.com/rivo/uniseg v0.4.7 // indirect 30 | github.com/spf13/pflag v1.0.6 // indirect 31 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 32 | golang.org/x/sync v0.16.0 // indirect 33 | golang.org/x/sys v0.35.0 // indirect 34 | golang.org/x/text v0.28.0 // indirect 35 | ) 36 | -------------------------------------------------------------------------------- /cmd/edit_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func TestEditCommand(t *testing.T) { 11 | // Test that the edit command is properly configured 12 | if editCmd.Use != "edit " { 13 | t.Errorf("Expected Use 'edit ', got '%s'", editCmd.Use) 14 | } 15 | 16 | if editCmd.Short != "Edit an existing SSH host configuration" { 17 | t.Errorf("Expected Short description, got '%s'", editCmd.Short) 18 | } 19 | 20 | // Test that it requires exactly 1 argument 21 | err := editCmd.Args(editCmd, []string{}) 22 | if err == nil { 23 | t.Error("Expected error for no arguments") 24 | } 25 | 26 | err = editCmd.Args(editCmd, []string{"host1", "host2"}) 27 | if err == nil { 28 | t.Error("Expected error for too many arguments") 29 | } 30 | 31 | err = editCmd.Args(editCmd, []string{"hostname"}) 32 | if err != nil { 33 | t.Errorf("Expected no error for 1 argument, got %v", err) 34 | } 35 | } 36 | 37 | func TestEditCommandRegistration(t *testing.T) { 38 | // Check that edit command is registered with root command 39 | found := false 40 | for _, cmd := range RootCmd.Commands() { 41 | if cmd.Name() == "edit" { 42 | found = true 43 | break 44 | } 45 | } 46 | if !found { 47 | t.Error("Edit command not found in root command") 48 | } 49 | } 50 | 51 | func TestEditCommandHelp(t *testing.T) { 52 | // Test help output 53 | cmd := &cobra.Command{} 54 | cmd.AddCommand(editCmd) 55 | 56 | buf := new(bytes.Buffer) 57 | cmd.SetOut(buf) 58 | cmd.SetArgs([]string{"edit", "--help"}) 59 | 60 | // This should not return an error for help 61 | err := cmd.Execute() 62 | if err != nil { 63 | t.Errorf("Expected no error for help command, got %v", err) 64 | } 65 | 66 | output := buf.String() 67 | if !contains(output, "Edit an existing SSH host configuration") { 68 | t.Error("Help output should contain command description") 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /cmd/add_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func TestAddCommand(t *testing.T) { 11 | // Test that the add command is properly configured 12 | if addCmd.Use != "add [hostname]" { 13 | t.Errorf("Expected Use 'add [hostname]', got '%s'", addCmd.Use) 14 | } 15 | 16 | if addCmd.Short != "Add a new SSH host configuration" { 17 | t.Errorf("Expected Short description, got '%s'", addCmd.Short) 18 | } 19 | 20 | // Test that it accepts maximum 1 argument 21 | err := addCmd.Args(addCmd, []string{"host1", "host2"}) 22 | if err == nil { 23 | t.Error("Expected error for too many arguments") 24 | } 25 | 26 | // Test that it accepts 0 or 1 argument 27 | err = addCmd.Args(addCmd, []string{}) 28 | if err != nil { 29 | t.Errorf("Expected no error for 0 arguments, got %v", err) 30 | } 31 | 32 | err = addCmd.Args(addCmd, []string{"hostname"}) 33 | if err != nil { 34 | t.Errorf("Expected no error for 1 argument, got %v", err) 35 | } 36 | } 37 | 38 | func TestAddCommandRegistration(t *testing.T) { 39 | // Check that add command is registered with root command 40 | found := false 41 | for _, cmd := range RootCmd.Commands() { 42 | if cmd.Name() == "add" { 43 | found = true 44 | break 45 | } 46 | } 47 | if !found { 48 | t.Error("Add command not found in root command") 49 | } 50 | } 51 | 52 | func TestAddCommandHelp(t *testing.T) { 53 | // Test help output 54 | cmd := &cobra.Command{} 55 | cmd.AddCommand(addCmd) 56 | 57 | buf := new(bytes.Buffer) 58 | cmd.SetOut(buf) 59 | cmd.SetArgs([]string{"add", "--help"}) 60 | 61 | // This should not return an error for help 62 | err := cmd.Execute() 63 | if err != nil { 64 | t.Errorf("Expected no error for help command, got %v", err) 65 | } 66 | 67 | output := buf.String() 68 | if !contains(output, "Add a new SSH host configuration") { 69 | t.Error("Help output should contain command description") 70 | } 71 | } 72 | 73 | // Helper function to check if string contains substring 74 | func contains(s, substr string) bool { 75 | return len(s) >= len(substr) && (s == substr || len(substr) == 0 || 76 | (len(s) > len(substr) && (s[:len(substr)] == substr || 77 | s[len(s)-len(substr):] == substr || 78 | containsSubstring(s, substr)))) 79 | } 80 | 81 | func containsSubstring(s, substr string) bool { 82 | for i := 0; i <= len(s)-len(substr); i++ { 83 | if s[i:i+len(substr)] == substr { 84 | return true 85 | } 86 | } 87 | return false 88 | } 89 | -------------------------------------------------------------------------------- /install/README.md: -------------------------------------------------------------------------------- 1 | # Installation Scripts 2 | 3 | This directory contains installation scripts for SSHM. 4 | 5 | ## Unix/Linux/macOS Installation 6 | 7 | ### Quick Install (Recommended) 8 | 9 | ```bash 10 | curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh | bash 11 | ``` 12 | 13 | **Note:** When using the pipe method, the installer will automatically proceed with installation if SSHM is already installed. 14 | 15 | ## Windows Installation 16 | 17 | ### Quick Install (Recommended) 18 | 19 | ```powershell 20 | irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex 21 | ``` 22 | 23 | ### Install Options 24 | 25 | **Force install without prompts:** 26 | ```powershell 27 | iex "& { $(irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1) } -Force" 28 | ``` 29 | 30 | **Custom installation directory:** 31 | ```powershell 32 | iex "& { $(irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1) } -InstallDir 'C:\tools'" 33 | ``` 34 | 35 | ## Unix/Linux/macOS Advanced Options 36 | 37 | **Force install without prompts:** 38 | ```bash 39 | FORCE_INSTALL=true bash -c "$(curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh)" 40 | ``` 41 | 42 | **Disable auto-install when using pipe:** 43 | ```bash 44 | FORCE_INSTALL=false bash -c "$(curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh)" 45 | ``` 46 | 47 | ### Manual Install 48 | 49 | 1. Download the script: 50 | ```bash 51 | curl -O https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh 52 | ``` 53 | 54 | 2. Make it executable: 55 | ```bash 56 | chmod +x unix.sh 57 | ``` 58 | 59 | 3. Run the installer: 60 | ```bash 61 | ./unix.sh 62 | ``` 63 | 64 | ## What the installer does 65 | 66 | 1. **Detects your system** - Automatically detects your OS (Linux/macOS) and architecture (AMD64/ARM64) 67 | 2. **Fetches latest version** - Gets the latest release from GitHub 68 | 3. **Downloads binary** - Downloads the appropriate binary for your system 69 | 4. **Installs to /usr/local/bin** - Installs the binary with proper permissions 70 | 5. **Verifies installation** - Checks that the installation was successful 71 | 72 | ## Supported Platforms 73 | 74 | - **Linux**: AMD64, ARM64 75 | - **macOS**: AMD64 (Intel), ARM64 (Apple Silicon) 76 | 77 | ## Requirements 78 | 79 | - `curl` - for downloading 80 | - `tar` - for extracting archives 81 | - `sudo` access - for installing to `/usr/local/bin` 82 | 83 | ## Uninstall 84 | 85 | To uninstall SSHM: 86 | 87 | ```bash 88 | sudo rm /usr/local/bin/sshm 89 | ``` 90 | -------------------------------------------------------------------------------- /internal/validation/ssh.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "os" 7 | "path/filepath" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | // ValidateHostname checks if a hostname is valid 14 | func ValidateHostname(hostname string) bool { 15 | if len(hostname) == 0 || len(hostname) > 253 { 16 | return false 17 | } 18 | if strings.HasPrefix(hostname, ".") || strings.HasSuffix(hostname, ".") { 19 | return false 20 | } 21 | if strings.Contains(hostname, " ") { 22 | return false 23 | } 24 | 25 | hostnameRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`) 26 | return hostnameRegex.MatchString(hostname) 27 | } 28 | 29 | // ValidateIP checks if an IP address is valid 30 | func ValidateIP(ip string) bool { 31 | return net.ParseIP(ip) != nil 32 | } 33 | 34 | // ValidatePort checks if a port is valid 35 | func ValidatePort(port string) bool { 36 | if port == "" { 37 | return true // Empty port defaults to 22 38 | } 39 | portNum, err := strconv.Atoi(port) 40 | return err == nil && portNum >= 1 && portNum <= 65535 41 | } 42 | 43 | // ValidateHostName checks if a host name is valid for SSH config 44 | func ValidateHostName(name string) bool { 45 | if len(name) == 0 || len(name) > 50 { 46 | return false 47 | } 48 | // Host name cannot contain whitespace or special SSH config characters 49 | return !strings.ContainsAny(name, " \t\n\r#") 50 | } 51 | 52 | // ValidateIdentityFile checks if an identity file path is valid 53 | func ValidateIdentityFile(path string) bool { 54 | if path == "" { 55 | return true // Optional field 56 | } 57 | // Expand ~ to home directory 58 | if strings.HasPrefix(path, "~/") { 59 | homeDir, err := os.UserHomeDir() 60 | if err != nil { 61 | return false 62 | } 63 | path = filepath.Join(homeDir, path[2:]) 64 | } 65 | _, err := os.Stat(path) 66 | return err == nil 67 | } 68 | 69 | // ValidateHost validates all host fields 70 | func ValidateHost(name, hostname, port, identity string) error { 71 | if strings.TrimSpace(name) == "" { 72 | return fmt.Errorf("host name is required") 73 | } 74 | 75 | if !ValidateHostName(name) { 76 | return fmt.Errorf("invalid host name: cannot contain spaces or special characters") 77 | } 78 | 79 | if strings.TrimSpace(hostname) == "" { 80 | return fmt.Errorf("hostname/IP is required") 81 | } 82 | 83 | if !ValidateHostname(hostname) && !ValidateIP(hostname) { 84 | return fmt.Errorf("invalid hostname or IP address format") 85 | } 86 | 87 | if !ValidatePort(port) { 88 | return fmt.Errorf("port must be between 1 and 65535") 89 | } 90 | 91 | if identity != "" && !ValidateIdentityFile(identity) { 92 | return fmt.Errorf("identity file does not exist: %s", identity) 93 | } 94 | 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /internal/ui/sort.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | 7 | "github.com/Gu1llaum-3/sshm/internal/config" 8 | ) 9 | 10 | // sortHosts sorts hosts according to the current sort mode 11 | func (m Model) sortHosts(hosts []config.SSHHost) []config.SSHHost { 12 | if m.historyManager == nil { 13 | return sortHostsByName(hosts) 14 | } 15 | 16 | switch m.sortMode { 17 | case SortByLastUsed: 18 | return m.historyManager.SortHostsByLastUsed(hosts) 19 | case SortByName: 20 | fallthrough 21 | default: 22 | return sortHostsByName(hosts) 23 | } 24 | } 25 | 26 | // sortHostsByName sorts a slice of SSH hosts alphabetically by name 27 | func sortHostsByName(hosts []config.SSHHost) []config.SSHHost { 28 | sorted := make([]config.SSHHost, len(hosts)) 29 | copy(sorted, hosts) 30 | 31 | sort.Slice(sorted, func(i, j int) bool { 32 | return strings.ToLower(sorted[i].Name) < strings.ToLower(sorted[j].Name) 33 | }) 34 | 35 | return sorted 36 | } 37 | 38 | // filterHosts filters hosts according to the search query (name or tags) 39 | func (m Model) filterHosts(query string) []config.SSHHost { 40 | subqueries := strings.Split(query, " ") 41 | subqueriesLength := len(subqueries) 42 | subfilteredHosts := make([][]config.SSHHost, subqueriesLength) 43 | for i, subquery := range subqueries { 44 | subfilteredHosts[i] = m.filterHostsByWord(subquery) 45 | } 46 | 47 | // return the intersection of search results 48 | result := make([]config.SSHHost, 0) 49 | tempMap := map[string]int{} 50 | for _, hosts := range subfilteredHosts { 51 | for _, host := range hosts { 52 | if _, ok := tempMap[host.Name]; !ok { 53 | tempMap[host.Name] = 1 54 | } else { 55 | tempMap[host.Name] = tempMap[host.Name] + 1 56 | } 57 | 58 | if tempMap[host.Name] == subqueriesLength { 59 | result = append(result, host) 60 | } 61 | } 62 | } 63 | 64 | return result 65 | } 66 | 67 | // filterHostsByWord filters hosts according to a single word 68 | func (m Model) filterHostsByWord(word string) []config.SSHHost { 69 | var filtered []config.SSHHost 70 | 71 | if word == "" { 72 | filtered = m.hosts 73 | } else { 74 | word = strings.ToLower(word) 75 | 76 | for _, host := range m.hosts { 77 | // Check the hostname 78 | if strings.Contains(strings.ToLower(host.Name), word) { 79 | filtered = append(filtered, host) 80 | continue 81 | } 82 | 83 | // Check the hostname 84 | if strings.Contains(strings.ToLower(host.Hostname), word) { 85 | filtered = append(filtered, host) 86 | continue 87 | } 88 | 89 | // Check the user 90 | if strings.Contains(strings.ToLower(host.User), word) { 91 | filtered = append(filtered, host) 92 | continue 93 | } 94 | 95 | // Check the tags 96 | for _, tag := range host.Tags { 97 | if strings.Contains(strings.ToLower(tag), word) { 98 | filtered = append(filtered, host) 99 | break 100 | } 101 | } 102 | } 103 | } 104 | 105 | return m.sortHosts(filtered) 106 | } 107 | -------------------------------------------------------------------------------- /internal/ui/utils.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Gu1llaum-3/sshm/internal/connectivity" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // formatTimeAgo formats a time into a readable "X time ago" string 11 | func formatTimeAgo(t time.Time) string { 12 | now := time.Now() 13 | duration := now.Sub(t) 14 | 15 | switch { 16 | case duration < time.Minute: 17 | seconds := int(duration.Seconds()) 18 | if seconds <= 1 { 19 | return "1 second ago" 20 | } 21 | return fmt.Sprintf("%d seconds ago", seconds) 22 | case duration < time.Hour: 23 | minutes := int(duration.Minutes()) 24 | if minutes == 1 { 25 | return "1 minute ago" 26 | } 27 | return fmt.Sprintf("%d minutes ago", minutes) 28 | case duration < 24*time.Hour: 29 | hours := int(duration.Hours()) 30 | if hours == 1 { 31 | return "1 hour ago" 32 | } 33 | return fmt.Sprintf("%d hours ago", hours) 34 | case duration < 7*24*time.Hour: 35 | days := int(duration.Hours() / 24) 36 | if days == 1 { 37 | return "1 day ago" 38 | } 39 | return fmt.Sprintf("%d days ago", days) 40 | case duration < 30*24*time.Hour: 41 | weeks := int(duration.Hours() / (24 * 7)) 42 | if weeks == 1 { 43 | return "1 week ago" 44 | } 45 | return fmt.Sprintf("%d weeks ago", weeks) 46 | case duration < 365*24*time.Hour: 47 | months := int(duration.Hours() / (24 * 30)) 48 | if months == 1 { 49 | return "1 month ago" 50 | } 51 | return fmt.Sprintf("%d months ago", months) 52 | default: 53 | years := int(duration.Hours() / (24 * 365)) 54 | if years == 1 { 55 | return "1 year ago" 56 | } 57 | return fmt.Sprintf("%d years ago", years) 58 | } 59 | } 60 | 61 | // formatConfigFile formats a config file path for display 62 | func formatConfigFile(filePath string) string { 63 | if filePath == "" { 64 | return "Unknown" 65 | } 66 | // Show just the filename and parent directory for readability 67 | parts := strings.Split(filePath, "/") 68 | if len(parts) >= 2 { 69 | return fmt.Sprintf(".../%s/%s", parts[len(parts)-2], parts[len(parts)-1]) 70 | } 71 | return filePath 72 | } 73 | 74 | // getPingStatusIndicator returns a colored circle indicator based on ping status 75 | func (m *Model) getPingStatusIndicator(hostName string) string { 76 | if m.pingManager == nil { 77 | return "⚫" // Gray circle for unknown 78 | } 79 | 80 | status := m.pingManager.GetStatus(hostName) 81 | switch status { 82 | case connectivity.StatusOnline: 83 | return "🟢" // Green circle for online 84 | case connectivity.StatusOffline: 85 | return "🔴" // Red circle for offline 86 | case connectivity.StatusConnecting: 87 | return "🟡" // Yellow circle for connecting 88 | default: 89 | return "⚫" // Gray circle for unknown 90 | } 91 | } 92 | 93 | // extractHostNameFromTableRow extracts the host name from the first column, 94 | // removing the ping status indicator 95 | func extractHostNameFromTableRow(firstColumn string) string { 96 | // The first column format is: "🟢 hostname" or "⚫ hostname" etc. 97 | // We need to remove the emoji and space to get just the hostname 98 | parts := strings.Fields(firstColumn) 99 | if len(parts) >= 2 { 100 | // Return everything after the first part (the emoji) 101 | return strings.Join(parts[1:], " ") 102 | } 103 | // Fallback: if there's no space, return the whole string 104 | return firstColumn 105 | } 106 | -------------------------------------------------------------------------------- /cmd/search_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestSearchCommand(t *testing.T) { 9 | // Test that the search command is properly configured 10 | if searchCmd.Use != "search [query]" { 11 | t.Errorf("Expected Use 'search [query]', got '%s'", searchCmd.Use) 12 | } 13 | 14 | if searchCmd.Short != "Search SSH hosts by name, hostname, or tags" { 15 | t.Errorf("Expected Short description, got '%s'", searchCmd.Short) 16 | } 17 | 18 | // Test that it accepts maximum 1 argument 19 | err := searchCmd.Args(searchCmd, []string{"query1", "query2"}) 20 | if err == nil { 21 | t.Error("Expected error for too many arguments") 22 | } 23 | 24 | // Test that it accepts 0 or 1 argument 25 | err = searchCmd.Args(searchCmd, []string{}) 26 | if err != nil { 27 | t.Errorf("Expected no error for 0 arguments, got %v", err) 28 | } 29 | 30 | err = searchCmd.Args(searchCmd, []string{"query"}) 31 | if err != nil { 32 | t.Errorf("Expected no error for 1 argument, got %v", err) 33 | } 34 | } 35 | 36 | func TestSearchCommandRegistration(t *testing.T) { 37 | // Check that search command is registered with root command 38 | found := false 39 | for _, cmd := range RootCmd.Commands() { 40 | if cmd.Name() == "search" { 41 | found = true 42 | break 43 | } 44 | } 45 | if !found { 46 | t.Error("Search command not found in root command") 47 | } 48 | } 49 | 50 | func TestSearchCommandFlags(t *testing.T) { 51 | // Test that flags are properly configured 52 | flags := searchCmd.Flags() 53 | 54 | // Check format flag 55 | formatFlag := flags.Lookup("format") 56 | if formatFlag == nil { 57 | t.Error("Expected --format flag to be defined") 58 | } 59 | 60 | // Check tags flag 61 | tagsFlag := flags.Lookup("tags") 62 | if tagsFlag == nil { 63 | t.Error("Expected --tags flag to be defined") 64 | } 65 | 66 | // Check names flag 67 | namesFlag := flags.Lookup("names") 68 | if namesFlag == nil { 69 | t.Error("Expected --names flag to be defined") 70 | } 71 | } 72 | 73 | func TestSearchCommandHelp(t *testing.T) { 74 | // Test that the command has the right help properties 75 | // Instead of executing --help, just check the Long description 76 | if searchCmd.Long == "" { 77 | t.Error("Search command should have a Long description") 78 | } 79 | 80 | if !strings.Contains(searchCmd.Long, "Search") { 81 | t.Error("Long description should contain information about searching") 82 | } 83 | } 84 | 85 | func TestFormatOutput(t *testing.T) { 86 | tests := []struct { 87 | name string 88 | format string 89 | valid bool 90 | }{ 91 | {"table format", "table", true}, 92 | {"json format", "json", true}, 93 | {"simple format", "simple", true}, 94 | {"invalid format", "invalid", false}, 95 | {"empty format", "", true}, // Should default to table 96 | } 97 | 98 | for _, tt := range tests { 99 | t.Run(tt.name, func(t *testing.T) { 100 | valid := isValidFormat(tt.format) 101 | if valid != tt.valid { 102 | t.Errorf("isValidFormat(%q) = %v, want %v", tt.format, valid, tt.valid) 103 | } 104 | }) 105 | } 106 | } 107 | 108 | // Helper function to validate format (this would be in the actual search.go) 109 | func isValidFormat(format string) bool { 110 | if format == "" { 111 | return true // Default to table 112 | } 113 | validFormats := []string{"table", "json", "simple"} 114 | for _, valid := range validFormats { 115 | if format == valid { 116 | return true 117 | } 118 | } 119 | return false 120 | } 121 | -------------------------------------------------------------------------------- /internal/ui/model.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/Gu1llaum-3/sshm/internal/config" 5 | "github.com/Gu1llaum-3/sshm/internal/connectivity" 6 | "github.com/Gu1llaum-3/sshm/internal/history" 7 | "github.com/Gu1llaum-3/sshm/internal/version" 8 | 9 | "github.com/charmbracelet/bubbles/table" 10 | "github.com/charmbracelet/bubbles/textinput" 11 | "github.com/charmbracelet/lipgloss" 12 | ) 13 | 14 | // SortMode defines the available sorting modes 15 | type SortMode int 16 | 17 | const ( 18 | SortByName SortMode = iota 19 | SortByLastUsed 20 | ) 21 | 22 | func (s SortMode) String() string { 23 | switch s { 24 | case SortByName: 25 | return "Name (A-Z)" 26 | case SortByLastUsed: 27 | return "Last Login" 28 | default: 29 | return "Name (A-Z)" 30 | } 31 | } 32 | 33 | // ViewMode defines the current view state 34 | type ViewMode int 35 | 36 | const ( 37 | ViewList ViewMode = iota 38 | ViewAdd 39 | ViewEdit 40 | ViewMove 41 | ViewInfo 42 | ViewPortForward 43 | ViewHelp 44 | ViewFileSelector 45 | ) 46 | 47 | // PortForwardType defines the type of port forwarding 48 | type PortForwardType int 49 | 50 | const ( 51 | LocalForward PortForwardType = iota 52 | RemoteForward 53 | DynamicForward 54 | ) 55 | 56 | func (p PortForwardType) String() string { 57 | switch p { 58 | case LocalForward: 59 | return "Local (-L)" 60 | case RemoteForward: 61 | return "Remote (-R)" 62 | case DynamicForward: 63 | return "Dynamic (-D)" 64 | default: 65 | return "Local (-L)" 66 | } 67 | } 68 | 69 | // Model represents the state of the user interface 70 | type Model struct { 71 | table table.Model 72 | searchInput textinput.Model 73 | hosts []config.SSHHost 74 | filteredHosts []config.SSHHost 75 | searchMode bool 76 | deleteMode bool 77 | deleteHost string 78 | historyManager *history.HistoryManager 79 | pingManager *connectivity.PingManager 80 | sortMode SortMode 81 | configFile string // Path to the SSH config file 82 | 83 | // Application configuration 84 | appConfig *config.AppConfig 85 | 86 | // Version update information 87 | updateInfo *version.UpdateInfo 88 | currentVersion string 89 | 90 | // View management 91 | viewMode ViewMode 92 | addForm *addFormModel 93 | editForm *editFormModel 94 | moveForm *moveFormModel 95 | infoForm *infoFormModel 96 | portForwardForm *portForwardModel 97 | helpForm *helpModel 98 | fileSelectorForm *fileSelectorModel 99 | 100 | // Terminal size and styles 101 | width int 102 | height int 103 | styles Styles 104 | ready bool 105 | 106 | // Error handling 107 | errorMessage string 108 | showingError bool 109 | } 110 | 111 | // updateTableStyles updates the table header border color based on focus state 112 | func (m *Model) updateTableStyles() { 113 | s := table.DefaultStyles() 114 | s.Selected = m.styles.Selected 115 | 116 | if m.searchMode { 117 | // When in search mode, use secondary color for table header 118 | s.Header = s.Header. 119 | BorderStyle(lipgloss.NormalBorder()). 120 | BorderForeground(lipgloss.Color(SecondaryColor)). 121 | BorderBottom(true). 122 | Bold(false) 123 | } else { 124 | // When table is focused, use primary color for table header 125 | s.Header = s.Header. 126 | BorderStyle(lipgloss.NormalBorder()). 127 | BorderForeground(lipgloss.Color(PrimaryColor)). 128 | BorderBottom(true). 129 | Bold(false) 130 | } 131 | 132 | m.table.SetStyles(s) 133 | } 134 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | // GitHubRelease represents a GitHub release response 14 | type GitHubRelease struct { 15 | TagName string `json:"tag_name"` 16 | Name string `json:"name"` 17 | HTMLURL string `json:"html_url"` 18 | Prerelease bool `json:"prerelease"` 19 | Draft bool `json:"draft"` 20 | } 21 | 22 | // UpdateInfo contains information about available updates 23 | type UpdateInfo struct { 24 | Available bool 25 | CurrentVer string 26 | LatestVer string 27 | ReleaseURL string 28 | ReleaseName string 29 | } 30 | 31 | // parseVersion extracts version numbers from a version string (e.g., "v1.2.3" -> [1, 2, 3]) 32 | func parseVersion(version string) []int { 33 | // Remove 'v' prefix if present 34 | version = strings.TrimPrefix(version, "v") 35 | 36 | parts := strings.Split(version, ".") 37 | nums := make([]int, len(parts)) 38 | 39 | for i, part := range parts { 40 | // Remove any non-numeric suffixes (e.g., "1-beta", "2-rc1") 41 | numPart := strings.FieldsFunc(part, func(r rune) bool { 42 | return r == '-' || r == '+' || r == '_' 43 | })[0] 44 | 45 | if num, err := strconv.Atoi(numPart); err == nil { 46 | nums[i] = num 47 | } 48 | } 49 | 50 | return nums 51 | } 52 | 53 | // compareVersions compares two version strings 54 | // Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 55 | func compareVersions(v1, v2 string) int { 56 | nums1 := parseVersion(v1) 57 | nums2 := parseVersion(v2) 58 | 59 | // Pad with zeros to make lengths equal 60 | maxLen := len(nums1) 61 | if len(nums2) > maxLen { 62 | maxLen = len(nums2) 63 | } 64 | 65 | for len(nums1) < maxLen { 66 | nums1 = append(nums1, 0) 67 | } 68 | for len(nums2) < maxLen { 69 | nums2 = append(nums2, 0) 70 | } 71 | 72 | // Compare each part 73 | for i := 0; i < maxLen; i++ { 74 | if nums1[i] < nums2[i] { 75 | return -1 76 | } 77 | if nums1[i] > nums2[i] { 78 | return 1 79 | } 80 | } 81 | 82 | return 0 83 | } 84 | 85 | // CheckForUpdates checks GitHub for the latest release of sshm 86 | func CheckForUpdates(ctx context.Context, currentVersion string) (*UpdateInfo, error) { 87 | // Skip version check if current version is "dev" 88 | if currentVersion == "dev" { 89 | return &UpdateInfo{ 90 | Available: false, 91 | CurrentVer: currentVersion, 92 | }, nil 93 | } 94 | 95 | // Create HTTP client with timeout 96 | client := &http.Client{ 97 | Timeout: 10 * time.Second, 98 | } 99 | 100 | // Create request with context 101 | req, err := http.NewRequestWithContext(ctx, "GET", 102 | "https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest", nil) 103 | if err != nil { 104 | return nil, fmt.Errorf("failed to create request: %w", err) 105 | } 106 | 107 | // Set user agent 108 | req.Header.Set("User-Agent", "sshm/"+currentVersion) 109 | 110 | // Make the request 111 | resp, err := client.Do(req) 112 | if err != nil { 113 | return nil, fmt.Errorf("failed to fetch latest release: %w", err) 114 | } 115 | defer resp.Body.Close() 116 | 117 | if resp.StatusCode != http.StatusOK { 118 | return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) 119 | } 120 | 121 | // Parse the response 122 | var release GitHubRelease 123 | if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { 124 | return nil, fmt.Errorf("failed to parse response: %w", err) 125 | } 126 | 127 | // Skip pre-releases and drafts 128 | if release.Prerelease || release.Draft { 129 | return &UpdateInfo{ 130 | Available: false, 131 | CurrentVer: currentVersion, 132 | }, nil 133 | } 134 | 135 | // Compare versions 136 | updateAvailable := compareVersions(currentVersion, release.TagName) < 0 137 | 138 | return &UpdateInfo{ 139 | Available: updateAvailable, 140 | CurrentVer: currentVersion, 141 | LatestVer: release.TagName, 142 | ReleaseURL: release.HTMLURL, 143 | ReleaseName: release.Name, 144 | }, nil 145 | } 146 | -------------------------------------------------------------------------------- /internal/ui/styles.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | // Theme colors 6 | var ( 7 | // Primary interface color - easily modifiable 8 | PrimaryColor = "#00ADD8" // Official Go logo blue color 9 | 10 | // Secondary colors 11 | SecondaryColor = "240" // Gray 12 | ErrorColor = "1" // Red 13 | SuccessColor = "36" // Green (for reference if needed) 14 | ) 15 | 16 | // Styles struct centralizes all lipgloss styles 17 | type Styles struct { 18 | // Layout 19 | App lipgloss.Style 20 | Header lipgloss.Style 21 | 22 | // Search styles 23 | SearchFocused lipgloss.Style 24 | SearchUnfocused lipgloss.Style 25 | 26 | // Table styles 27 | TableFocused lipgloss.Style 28 | TableUnfocused lipgloss.Style 29 | Selected lipgloss.Style 30 | 31 | // Info and help styles 32 | SortInfo lipgloss.Style 33 | HelpText lipgloss.Style 34 | 35 | // Error and confirmation styles 36 | Error lipgloss.Style 37 | ErrorText lipgloss.Style 38 | 39 | // Form styles (for add/edit forms) 40 | FormTitle lipgloss.Style 41 | FormField lipgloss.Style 42 | FormHelp lipgloss.Style 43 | FormContainer lipgloss.Style 44 | Label lipgloss.Style 45 | FocusedLabel lipgloss.Style 46 | HelpSection lipgloss.Style 47 | } 48 | 49 | // NewStyles creates a new Styles struct with the given terminal width 50 | func NewStyles(width int) Styles { 51 | return Styles{ 52 | // Main app container 53 | App: lipgloss.NewStyle(). 54 | Padding(1), 55 | 56 | // Header style 57 | Header: lipgloss.NewStyle(). 58 | Foreground(lipgloss.Color(PrimaryColor)). 59 | Bold(true). 60 | Align(lipgloss.Center), 61 | 62 | // Search styles 63 | SearchFocused: lipgloss.NewStyle(). 64 | BorderStyle(lipgloss.RoundedBorder()). 65 | BorderForeground(lipgloss.Color(PrimaryColor)). 66 | Padding(0, 1), 67 | 68 | SearchUnfocused: lipgloss.NewStyle(). 69 | BorderStyle(lipgloss.RoundedBorder()). 70 | BorderForeground(lipgloss.Color(SecondaryColor)). 71 | Padding(0, 1), 72 | 73 | // Table styles 74 | TableFocused: lipgloss.NewStyle(). 75 | BorderStyle(lipgloss.NormalBorder()). 76 | BorderForeground(lipgloss.Color(PrimaryColor)), 77 | 78 | TableUnfocused: lipgloss.NewStyle(). 79 | BorderStyle(lipgloss.NormalBorder()). 80 | BorderForeground(lipgloss.Color(SecondaryColor)), 81 | 82 | // Style for selected items 83 | Selected: lipgloss.NewStyle(). 84 | Foreground(lipgloss.Color("229")). 85 | Background(lipgloss.Color(PrimaryColor)). 86 | Bold(false), 87 | 88 | // Info styles 89 | SortInfo: lipgloss.NewStyle(). 90 | Foreground(lipgloss.Color(SecondaryColor)), 91 | 92 | HelpText: lipgloss.NewStyle(). 93 | Foreground(lipgloss.Color(SecondaryColor)), 94 | 95 | // Error style 96 | Error: lipgloss.NewStyle(). 97 | BorderStyle(lipgloss.RoundedBorder()). 98 | BorderForeground(lipgloss.Color(ErrorColor)). 99 | Padding(1, 2), 100 | 101 | // Error text style (no border, just red text) 102 | ErrorText: lipgloss.NewStyle(). 103 | Foreground(lipgloss.Color(ErrorColor)). 104 | Bold(true), 105 | 106 | // Form styles 107 | FormTitle: lipgloss.NewStyle(). 108 | Foreground(lipgloss.Color("#FFFDF5")). 109 | Background(lipgloss.Color(PrimaryColor)). 110 | Padding(0, 1), 111 | 112 | FormField: lipgloss.NewStyle(). 113 | Foreground(lipgloss.Color(PrimaryColor)), 114 | 115 | FormHelp: lipgloss.NewStyle(). 116 | Foreground(lipgloss.Color("#626262")), 117 | 118 | FormContainer: lipgloss.NewStyle(). 119 | BorderStyle(lipgloss.RoundedBorder()). 120 | BorderForeground(lipgloss.Color(PrimaryColor)). 121 | Padding(1, 2), 122 | 123 | Label: lipgloss.NewStyle(). 124 | Foreground(lipgloss.Color(SecondaryColor)), 125 | 126 | FocusedLabel: lipgloss.NewStyle(). 127 | Foreground(lipgloss.Color(PrimaryColor)), 128 | 129 | HelpSection: lipgloss.NewStyle(). 130 | Padding(0, 2), 131 | } 132 | } 133 | 134 | // Application ASCII title 135 | const asciiTitle = ` 136 | _____ _____ __ __ _____ 137 | | __| __| | | | 138 | |__ |__ | | | | | 139 | |_____|_____|__|__|_|_|_| 140 | ` 141 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: sshm 3 | 4 | before: 5 | hooks: 6 | - go mod tidy 7 | - go test ./... 8 | 9 | builds: 10 | - id: sshm 11 | main: ./main.go 12 | binary: sshm 13 | goos: 14 | - linux 15 | - windows 16 | - darwin 17 | goarch: 18 | - amd64 19 | - arm64 20 | - "386" 21 | - arm 22 | goarm: 23 | - "6" 24 | - "7" 25 | ignore: 26 | # Skip ARM for Windows (not commonly used) 27 | - goos: windows 28 | goarch: arm 29 | - goos: windows 30 | goarch: arm64 31 | env: 32 | - CGO_ENABLED=0 33 | ldflags: 34 | - -s -w 35 | - -X github.com/Gu1llaum-3/sshm/cmd.AppVersion={{.Version}} 36 | flags: 37 | - -trimpath 38 | 39 | archives: 40 | - id: sshm 41 | formats: [ "tar.gz" ] 42 | # Use zip for Windows 43 | format_overrides: 44 | - goos: windows 45 | formats: [ "zip" ] 46 | # Template for archive name 47 | name_template: >- 48 | {{ .ProjectName }}_ {{- title .Os }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }} 49 | files: 50 | - LICENSE 51 | - README.md 52 | 53 | checksum: 54 | name_template: "checksums.txt" 55 | algorithm: sha256 56 | 57 | changelog: 58 | use: github 59 | sort: asc 60 | filters: 61 | exclude: 62 | - "^docs:" 63 | - "^test:" 64 | - "^ci:" 65 | - "^chore:" 66 | - "^build:" 67 | groups: 68 | - title: Features 69 | regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' 70 | order: 0 71 | - title: Bug fixes 72 | regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' 73 | order: 1 74 | - title: Others 75 | order: 999 76 | 77 | # Homebrew tap configuration (Formula pour CLI) 78 | brews: 79 | - name: sshm 80 | repository: 81 | owner: Gu1llaum-3 82 | name: homebrew-sshm 83 | # Token with repo permissions for your homebrew-sshm repo 84 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 85 | commit_author: 86 | name: goreleaserbot 87 | email: bot@goreleaser.com 88 | commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" 89 | homepage: "https://github.com/Gu1llaum-3/sshm" 90 | description: "A modern SSH connection manager for your terminal" 91 | license: MIT 92 | skip_upload: auto 93 | # Test command to verify installation 94 | test: | 95 | system "#{bin}/sshm --version" 96 | 97 | # Release configuration 98 | release: 99 | github: 100 | owner: Gu1llaum-3 101 | name: sshm 102 | prerelease: auto 103 | draft: false 104 | replace_existing_draft: true 105 | target_commitish: "{{ .Commit }}" 106 | name_template: "{{.ProjectName}} {{.Version}}" 107 | header: | 108 | ## SSHM {{.Version}} 109 | 110 | Thank you for downloading SSHM! 111 | 112 | footer: | 113 | ## Installation 114 | 115 | ### Homebrew (macOS/Linux) 116 | ```bash 117 | brew tap Gu1llaum-3/sshm 118 | brew install sshm 119 | ``` 120 | 121 | ### Installation Script (Recommended) 122 | **Unix/Linux/macOS:** 123 | ```bash 124 | curl -sSL https://github.com/Gu1llaum-3/sshm/raw/main/install/unix.sh | bash 125 | ``` 126 | 127 | **Windows (PowerShell):** 128 | ```powershell 129 | iwr -useb https://github.com/Gu1llaum-3/sshm/raw/main/install/windows.ps1 | iex 130 | ``` 131 | 132 | ### Manual Installation 133 | Download the appropriate binary for your platform from the assets above, extract it, and place it in your PATH. 134 | 135 | ## Full Changelog 136 | 137 | See all changes at https://github.com/Gu1llaum-3/sshm/compare/{{.PreviousTag}}...{{.Tag}} 138 | 139 | --- 140 | 141 | 📖 **Documentation:** See the updated [README](https://github.com/Gu1llaum-3/sshm/blob/main/README.md) 142 | 143 | 🐛 **Issues:** Found a bug? Open an [issue](https://github.com/Gu1llaum-3/sshm/issues) 144 | 145 | --- 146 | 147 | Released with ❤️ by [GoReleaser](https://github.com/goreleaser/goreleaser) 148 | 149 | # Snapshot builds (for non-tag builds) 150 | snapshot: 151 | version_template: "{{ .Tag }}-snapshot-{{.ShortCommit}}" 152 | 153 | # Metadata for package managers 154 | metadata: 155 | mod_timestamp: "{{ .CommitTimestamp }}" 156 | -------------------------------------------------------------------------------- /internal/config/keybindings.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | // KeyBindings represents configurable key bindings for the application 11 | type KeyBindings struct { 12 | // Quit keys - keys that will quit the application 13 | QuitKeys []string `json:"quit_keys"` 14 | 15 | // DisableEscQuit - if true, ESC key won't quit the application (useful for vim users) 16 | DisableEscQuit bool `json:"disable_esc_quit"` 17 | } 18 | 19 | // AppConfig represents the main application configuration 20 | type AppConfig struct { 21 | KeyBindings KeyBindings `json:"key_bindings"` 22 | } 23 | 24 | // GetDefaultKeyBindings returns the default key bindings configuration 25 | func GetDefaultKeyBindings() KeyBindings { 26 | return KeyBindings{ 27 | QuitKeys: []string{"q", "ctrl+c"}, // Default keeps current behavior minus ESC 28 | DisableEscQuit: false, // Default to false for backward compatibility 29 | } 30 | } 31 | 32 | // GetDefaultAppConfig returns the default application configuration 33 | func GetDefaultAppConfig() AppConfig { 34 | return AppConfig{ 35 | KeyBindings: GetDefaultKeyBindings(), 36 | } 37 | } 38 | 39 | // GetAppConfigPath returns the path to the application config file 40 | func GetAppConfigPath() (string, error) { 41 | configDir, err := GetSSHMConfigDir() 42 | if err != nil { 43 | return "", err 44 | } 45 | 46 | return filepath.Join(configDir, "config.json"), nil 47 | } 48 | 49 | // LoadAppConfig loads the application configuration from file 50 | // If the file doesn't exist, it returns the default configuration 51 | func LoadAppConfig() (*AppConfig, error) { 52 | configPath, err := GetAppConfigPath() 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | // If config file doesn't exist, return default config and create the file 58 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 59 | defaultConfig := GetDefaultAppConfig() 60 | 61 | // Create config directory if it doesn't exist 62 | configDir := filepath.Dir(configPath) 63 | if err := os.MkdirAll(configDir, 0755); err != nil { 64 | return nil, err 65 | } 66 | 67 | // Save default config to file 68 | if err := SaveAppConfig(&defaultConfig); err != nil { 69 | // If we can't save, just return the default config without erroring 70 | // This allows the app to work even if config file can't be created 71 | return &defaultConfig, nil 72 | } 73 | 74 | return &defaultConfig, nil 75 | } 76 | 77 | // Read existing config file 78 | data, err := os.ReadFile(configPath) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | var config AppConfig 84 | if err := json.Unmarshal(data, &config); err != nil { 85 | return nil, err 86 | } 87 | 88 | // Validate and fill in missing fields with defaults 89 | config = mergeWithDefaults(config) 90 | 91 | return &config, nil 92 | } 93 | 94 | // SaveAppConfig saves the application configuration to file 95 | func SaveAppConfig(config *AppConfig) error { 96 | if config == nil { 97 | return errors.New("config cannot be nil") 98 | } 99 | 100 | configPath, err := GetAppConfigPath() 101 | if err != nil { 102 | return err 103 | } 104 | 105 | // Create config directory if it doesn't exist 106 | configDir := filepath.Dir(configPath) 107 | if err := os.MkdirAll(configDir, 0755); err != nil { 108 | return err 109 | } 110 | 111 | data, err := json.MarshalIndent(config, "", " ") 112 | if err != nil { 113 | return err 114 | } 115 | 116 | return os.WriteFile(configPath, data, 0644) 117 | } 118 | 119 | // mergeWithDefaults ensures all required fields are set with defaults if missing 120 | func mergeWithDefaults(config AppConfig) AppConfig { 121 | defaults := GetDefaultAppConfig() 122 | 123 | // If QuitKeys is empty, use defaults 124 | if len(config.KeyBindings.QuitKeys) == 0 { 125 | config.KeyBindings.QuitKeys = defaults.KeyBindings.QuitKeys 126 | } 127 | 128 | return config 129 | } 130 | 131 | // ShouldQuitOnKey checks if the given key should trigger quit based on configuration 132 | func (kb *KeyBindings) ShouldQuitOnKey(key string) bool { 133 | // Special handling for ESC key 134 | if key == "esc" { 135 | return !kb.DisableEscQuit 136 | } 137 | 138 | // Check if key is in the quit keys list 139 | for _, quitKey := range kb.QuitKeys { 140 | if quitKey == key { 141 | return true 142 | } 143 | } 144 | 145 | return false 146 | } -------------------------------------------------------------------------------- /internal/connectivity/ping_test.go: -------------------------------------------------------------------------------- 1 | package connectivity 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/Gu1llaum-3/sshm/internal/config" 9 | ) 10 | 11 | func TestNewPingManager(t *testing.T) { 12 | pm := NewPingManager(5 * time.Second) 13 | if pm == nil { 14 | t.Error("NewPingManager() returned nil") 15 | } 16 | if pm.results == nil { 17 | t.Error("PingManager.results map not initialized") 18 | } 19 | } 20 | 21 | func TestPingManager_PingHost(t *testing.T) { 22 | pm := NewPingManager(1 * time.Second) 23 | ctx := context.Background() 24 | 25 | // Test ping method exists and doesn't panic 26 | host := config.SSHHost{Name: "test", Hostname: "127.0.0.1", Port: "22"} 27 | result := pm.PingHost(ctx, host) 28 | if result == nil { 29 | t.Error("Expected ping result to be returned") 30 | } 31 | 32 | // Test with invalid host 33 | invalidHost := config.SSHHost{Name: "invalid", Hostname: "invalid.host.12345", Port: "22"} 34 | result = pm.PingHost(ctx, invalidHost) 35 | if result == nil { 36 | t.Error("Expected ping result to be returned even for invalid host") 37 | } 38 | } 39 | 40 | func TestPingManager_GetStatus(t *testing.T) { 41 | pm := NewPingManager(1 * time.Second) 42 | 43 | // Test unknown host 44 | status := pm.GetStatus("unknown.host") 45 | if status != StatusUnknown { 46 | t.Errorf("Expected StatusUnknown for unknown host, got %v", status) 47 | } 48 | 49 | // Test after ping 50 | ctx := context.Background() 51 | host := config.SSHHost{Name: "test", Hostname: "127.0.0.1", Port: "22"} 52 | pm.PingHost(ctx, host) 53 | status = pm.GetStatus("test") 54 | if status == StatusUnknown { 55 | t.Error("Expected status to be set after ping") 56 | } 57 | } 58 | 59 | func TestPingManager_PingMultipleHosts(t *testing.T) { 60 | pm := NewPingManager(1 * time.Second) 61 | hosts := []config.SSHHost{ 62 | {Name: "localhost", Hostname: "127.0.0.1", Port: "22"}, 63 | {Name: "invalid", Hostname: "invalid.host.12345", Port: "22"}, 64 | } 65 | 66 | ctx := context.Background() 67 | 68 | // Ping each host individually 69 | for _, host := range hosts { 70 | result := pm.PingHost(ctx, host) 71 | if result == nil { 72 | t.Errorf("Expected ping result for host %s", host.Name) 73 | } 74 | 75 | // Check that status was set 76 | status := pm.GetStatus(host.Name) 77 | if status == StatusUnknown { 78 | t.Errorf("Expected status to be set for host %s after ping", host.Name) 79 | } 80 | } 81 | } 82 | 83 | func TestPingManager_GetResult(t *testing.T) { 84 | pm := NewPingManager(1 * time.Second) 85 | ctx := context.Background() 86 | 87 | // Test getting result for unknown host 88 | result, exists := pm.GetResult("unknown") 89 | if exists || result != nil { 90 | t.Error("Expected no result for unknown host") 91 | } 92 | 93 | // Test after ping 94 | host := config.SSHHost{Name: "test", Hostname: "127.0.0.1", Port: "22"} 95 | pm.PingHost(ctx, host) 96 | 97 | result, exists = pm.GetResult("test") 98 | if !exists || result == nil { 99 | t.Error("Expected result to exist after ping") 100 | } 101 | if result.HostName != "test" { 102 | t.Errorf("Expected hostname 'test', got '%s'", result.HostName) 103 | } 104 | } 105 | 106 | func TestPingStatus_String(t *testing.T) { 107 | tests := []struct { 108 | status PingStatus 109 | expected string 110 | }{ 111 | {StatusUnknown, "unknown"}, 112 | {StatusConnecting, "connecting"}, 113 | {StatusOnline, "online"}, 114 | {StatusOffline, "offline"}, 115 | {PingStatus(999), "unknown"}, // Invalid status 116 | } 117 | 118 | for _, tt := range tests { 119 | t.Run(tt.expected, func(t *testing.T) { 120 | if got := tt.status.String(); got != tt.expected { 121 | t.Errorf("PingStatus.String() = %v, want %v", got, tt.expected) 122 | } 123 | }) 124 | } 125 | } 126 | 127 | func TestPingHost_Basic(t *testing.T) { 128 | // Test that the ping functionality exists 129 | pm := NewPingManager(1 * time.Second) 130 | ctx := context.Background() 131 | host := config.SSHHost{Name: "test", Hostname: "127.0.0.1", Port: "22"} 132 | 133 | // Just ensure the function doesn't panic 134 | result := pm.PingHost(ctx, host) 135 | if result == nil { 136 | t.Error("Expected ping result to be returned") 137 | } 138 | 139 | // Test that status is set 140 | status := pm.GetStatus("test") 141 | if status == StatusUnknown { 142 | t.Error("Expected status to be set after ping attempt") 143 | } 144 | } -------------------------------------------------------------------------------- /internal/ui/help_form.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/lipgloss" 6 | ) 7 | 8 | type helpModel struct { 9 | styles Styles 10 | width int 11 | height int 12 | } 13 | 14 | // helpCloseMsg is sent when the help window is closed 15 | type helpCloseMsg struct{} 16 | 17 | // NewHelpForm creates a new help form model 18 | func NewHelpForm(styles Styles, width, height int) *helpModel { 19 | return &helpModel{ 20 | styles: styles, 21 | width: width, 22 | height: height, 23 | } 24 | } 25 | 26 | func (m *helpModel) Init() tea.Cmd { 27 | return nil 28 | } 29 | 30 | func (m *helpModel) Update(msg tea.Msg) (*helpModel, tea.Cmd) { 31 | switch msg := msg.(type) { 32 | case tea.KeyMsg: 33 | switch msg.String() { 34 | case "esc", "q", "h", "enter", "ctrl+c": 35 | return m, func() tea.Msg { return helpCloseMsg{} } 36 | } 37 | } 38 | return m, nil 39 | } 40 | 41 | func (m *helpModel) View() string { 42 | // Title 43 | title := m.styles.Header.Render("📖 SSHM - Commands") 44 | 45 | // Create two columns of commands for better visual organization 46 | leftColumn := lipgloss.JoinVertical(lipgloss.Left, 47 | m.styles.FocusedLabel.Render("Navigation & Connection"), 48 | "", 49 | lipgloss.JoinHorizontal(lipgloss.Left, 50 | m.styles.FocusedLabel.Render("⏎ "), 51 | m.styles.HelpText.Render("connect to selected host")), 52 | lipgloss.JoinHorizontal(lipgloss.Left, 53 | m.styles.FocusedLabel.Render("i "), 54 | m.styles.HelpText.Render("show host information")), 55 | lipgloss.JoinHorizontal(lipgloss.Left, 56 | m.styles.FocusedLabel.Render("/ "), 57 | m.styles.HelpText.Render("search hosts")), 58 | lipgloss.JoinHorizontal(lipgloss.Left, 59 | m.styles.FocusedLabel.Render("Tab "), 60 | m.styles.HelpText.Render("switch focus")), 61 | "", 62 | m.styles.FocusedLabel.Render("Host Management"), 63 | "", 64 | lipgloss.JoinHorizontal(lipgloss.Left, 65 | m.styles.FocusedLabel.Render("a "), 66 | m.styles.HelpText.Render("add new host")), 67 | lipgloss.JoinHorizontal(lipgloss.Left, 68 | m.styles.FocusedLabel.Render("e "), 69 | m.styles.HelpText.Render("edit selected host")), 70 | lipgloss.JoinHorizontal(lipgloss.Left, 71 | m.styles.FocusedLabel.Render("m "), 72 | m.styles.HelpText.Render("move host to another config")), 73 | lipgloss.JoinHorizontal(lipgloss.Left, 74 | m.styles.FocusedLabel.Render("d "), 75 | m.styles.HelpText.Render("delete selected host")), 76 | ) 77 | 78 | rightColumn := lipgloss.JoinVertical(lipgloss.Left, 79 | m.styles.FocusedLabel.Render("Advanced Features"), 80 | "", 81 | lipgloss.JoinHorizontal(lipgloss.Left, 82 | m.styles.FocusedLabel.Render("p "), 83 | m.styles.HelpText.Render("ping all hosts")), 84 | lipgloss.JoinHorizontal(lipgloss.Left, 85 | m.styles.FocusedLabel.Render("f "), 86 | m.styles.HelpText.Render("setup port forwarding")), 87 | lipgloss.JoinHorizontal(lipgloss.Left, 88 | m.styles.FocusedLabel.Render("s "), 89 | m.styles.HelpText.Render("cycle sort modes")), 90 | lipgloss.JoinHorizontal(lipgloss.Left, 91 | m.styles.FocusedLabel.Render("n "), 92 | m.styles.HelpText.Render("sort by name")), 93 | lipgloss.JoinHorizontal(lipgloss.Left, 94 | m.styles.FocusedLabel.Render("r "), 95 | m.styles.HelpText.Render("sort by recent connection")), 96 | "", 97 | m.styles.FocusedLabel.Render("System"), 98 | "", 99 | lipgloss.JoinHorizontal(lipgloss.Left, 100 | m.styles.FocusedLabel.Render("h "), 101 | m.styles.HelpText.Render("show this help")), 102 | lipgloss.JoinHorizontal(lipgloss.Left, 103 | m.styles.FocusedLabel.Render("q "), 104 | m.styles.HelpText.Render("quit application")), 105 | lipgloss.JoinHorizontal(lipgloss.Left, 106 | m.styles.FocusedLabel.Render("ESC "), 107 | m.styles.HelpText.Render("exit current view")), 108 | ) 109 | 110 | // Join the two columns side by side 111 | columns := lipgloss.JoinHorizontal(lipgloss.Top, 112 | leftColumn, 113 | " ", // spacing between columns 114 | rightColumn, 115 | ) 116 | 117 | // Create the main content 118 | content := lipgloss.JoinVertical(lipgloss.Center, 119 | title, 120 | "", 121 | columns, 122 | "", 123 | m.styles.HelpText.Render("Press ESC, h, q or Enter to close"), 124 | ) 125 | 126 | // Center the help window 127 | return lipgloss.Place( 128 | m.width, 129 | m.height, 130 | lipgloss.Center, 131 | lipgloss.Center, 132 | m.styles.FormContainer.Render(content), 133 | ) 134 | } 135 | -------------------------------------------------------------------------------- /internal/ui/file_selector.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "github.com/Gu1llaum-3/sshm/internal/config" 7 | "strings" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | type fileSelectorModel struct { 13 | files []string // Chemins absolus des fichiers 14 | displayNames []string // Noms d'affichage conviviaux 15 | selected int 16 | styles Styles 17 | width int 18 | height int 19 | title string 20 | } 21 | 22 | type fileSelectorMsg struct { 23 | selectedFile string 24 | cancelled bool 25 | } 26 | 27 | // NewFileSelector creates a new file selector for choosing config files 28 | func NewFileSelector(title string, styles Styles, width, height int) (*fileSelectorModel, error) { 29 | files, err := config.GetAllConfigFiles() 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | return newFileSelectorFromFiles(title, styles, width, height, files) 35 | } 36 | 37 | // NewFileSelectorFromBase creates a new file selector starting from a specific base config file 38 | func NewFileSelectorFromBase(title string, styles Styles, width, height int, baseConfigFile string) (*fileSelectorModel, error) { 39 | var files []string 40 | var err error 41 | 42 | if baseConfigFile != "" { 43 | files, err = config.GetAllConfigFilesFromBase(baseConfigFile) 44 | } else { 45 | files, err = config.GetAllConfigFiles() 46 | } 47 | 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | return newFileSelectorFromFiles(title, styles, width, height, files) 53 | } 54 | 55 | // newFileSelectorFromFiles creates a file selector from a list of files 56 | func newFileSelectorFromFiles(title string, styles Styles, width, height int, files []string) (*fileSelectorModel, error) { 57 | 58 | // Convert absolute paths to more user-friendly names 59 | var displayNames []string 60 | homeDir, _ := config.GetSSHDirectory() 61 | 62 | for _, file := range files { 63 | // Check if it's the main config file 64 | mainConfig, _ := config.GetDefaultSSHConfigPath() 65 | if file == mainConfig { 66 | displayNames = append(displayNames, "Main SSH Config (~/.ssh/config)") 67 | } else { 68 | // Try to make path relative to home/.ssh/ 69 | if strings.HasPrefix(file, homeDir) { 70 | relPath, err := filepath.Rel(homeDir, file) 71 | if err == nil { 72 | displayNames = append(displayNames, fmt.Sprintf("~/.ssh/%s", relPath)) 73 | } else { 74 | displayNames = append(displayNames, file) 75 | } 76 | } else { 77 | displayNames = append(displayNames, file) 78 | } 79 | } 80 | } 81 | 82 | return &fileSelectorModel{ 83 | files: files, 84 | displayNames: displayNames, 85 | selected: 0, 86 | styles: styles, 87 | width: width, 88 | height: height, 89 | title: title, 90 | }, nil 91 | } 92 | 93 | func (m *fileSelectorModel) Init() tea.Cmd { 94 | return nil 95 | } 96 | 97 | func (m *fileSelectorModel) Update(msg tea.Msg) (*fileSelectorModel, tea.Cmd) { 98 | switch msg := msg.(type) { 99 | case tea.WindowSizeMsg: 100 | m.width = msg.Width 101 | m.height = msg.Height 102 | m.styles = NewStyles(m.width) 103 | return m, nil 104 | 105 | case tea.KeyMsg: 106 | switch msg.String() { 107 | case "ctrl+c", "esc": 108 | return m, func() tea.Msg { 109 | return fileSelectorMsg{cancelled: true} 110 | } 111 | 112 | case "enter": 113 | selectedFile := "" 114 | if m.selected < len(m.files) { 115 | selectedFile = m.files[m.selected] 116 | } 117 | return m, func() tea.Msg { 118 | return fileSelectorMsg{selectedFile: selectedFile} 119 | } 120 | 121 | case "up", "k": 122 | if m.selected > 0 { 123 | m.selected-- 124 | } 125 | 126 | case "down", "j": 127 | if m.selected < len(m.files)-1 { 128 | m.selected++ 129 | } 130 | } 131 | } 132 | 133 | return m, nil 134 | } 135 | 136 | func (m *fileSelectorModel) View() string { 137 | var b strings.Builder 138 | 139 | b.WriteString(m.styles.FormTitle.Render(m.title)) 140 | b.WriteString("\n\n") 141 | 142 | if len(m.files) == 0 { 143 | b.WriteString(m.styles.Error.Render("No SSH config files found.")) 144 | b.WriteString("\n\n") 145 | b.WriteString(m.styles.FormHelp.Render("Esc: cancel")) 146 | return b.String() 147 | } 148 | 149 | for i, displayName := range m.displayNames { 150 | if i == m.selected { 151 | b.WriteString(m.styles.Selected.Render(fmt.Sprintf("▶ %s", displayName))) 152 | } else { 153 | b.WriteString(fmt.Sprintf(" %s", displayName)) 154 | } 155 | b.WriteString("\n") 156 | } 157 | 158 | b.WriteString("\n") 159 | b.WriteString(m.styles.FormHelp.Render("↑/↓: navigate • Enter: select • Esc: cancel")) 160 | 161 | return b.String() 162 | } 163 | -------------------------------------------------------------------------------- /cmd/root_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestRootCommand(t *testing.T) { 10 | // Test that the root command is properly configured 11 | if RootCmd.Use != "sshm [host]" { 12 | t.Errorf("Expected Use 'sshm [host]', got '%s'", RootCmd.Use) 13 | } 14 | 15 | if RootCmd.Short != "SSH Manager - A modern SSH connection manager" { 16 | t.Errorf("Expected Short description, got '%s'", RootCmd.Short) 17 | } 18 | 19 | if RootCmd.Version != AppVersion { 20 | t.Errorf("Expected Version '%s', got '%s'", AppVersion, RootCmd.Version) 21 | } 22 | } 23 | 24 | func TestRootCommandFlags(t *testing.T) { 25 | // Test that persistent flags are properly configured 26 | flags := RootCmd.PersistentFlags() 27 | 28 | // Check config flag 29 | configFlag := flags.Lookup("config") 30 | if configFlag == nil { 31 | t.Error("Expected --config flag to be defined") 32 | return 33 | } 34 | if configFlag.Shorthand != "c" { 35 | t.Errorf("Expected config flag shorthand 'c', got '%s'", configFlag.Shorthand) 36 | } 37 | } 38 | 39 | func TestRootCommandSubcommands(t *testing.T) { 40 | // Test that all expected subcommands are registered 41 | // Note: completion and help are automatically added by Cobra and may not always appear in Commands() 42 | expectedCommands := []string{"add", "edit", "search"} 43 | 44 | commands := RootCmd.Commands() 45 | commandNames := make(map[string]bool) 46 | for _, cmd := range commands { 47 | commandNames[cmd.Name()] = true 48 | } 49 | 50 | for _, expected := range expectedCommands { 51 | if !commandNames[expected] { 52 | t.Errorf("Expected command '%s' not found", expected) 53 | } 54 | } 55 | 56 | // Check that we have at least the core commands 57 | if len(commandNames) < 3 { 58 | t.Errorf("Expected at least 3 commands, got %d", len(commandNames)) 59 | } 60 | } 61 | 62 | func TestRootCommandHelp(t *testing.T) { 63 | // Test help output 64 | buf := new(bytes.Buffer) 65 | RootCmd.SetOut(buf) 66 | RootCmd.SetArgs([]string{"--help"}) 67 | 68 | // This should not return an error for help 69 | err := RootCmd.Execute() 70 | if err != nil { 71 | t.Errorf("Expected no error for help command, got %v", err) 72 | } 73 | 74 | output := buf.String() 75 | if !strings.Contains(output, "modern SSH manager") { 76 | t.Error("Help output should contain command description") 77 | } 78 | if !strings.Contains(output, "Usage:") { 79 | t.Error("Help output should contain usage section") 80 | } 81 | } 82 | 83 | func TestRootCommandVersion(t *testing.T) { 84 | // Test that version command executes without error 85 | // Note: Cobra handles version output internally, so we just check for no error 86 | RootCmd.SetArgs([]string{"--version"}) 87 | 88 | // This should not return an error for version 89 | err := RootCmd.Execute() 90 | if err != nil { 91 | t.Errorf("Expected no error for version command, got %v", err) 92 | } 93 | 94 | // Reset args for other tests 95 | RootCmd.SetArgs([]string{}) 96 | } 97 | 98 | func TestExecuteFunction(t *testing.T) { 99 | // Test that Execute function exists and can be called 100 | // We can't easily test the actual execution without mocking, 101 | // but we can test that the function exists 102 | t.Log("Execute function exists and is accessible") 103 | } 104 | 105 | func TestConnectToHostFunction(t *testing.T) { 106 | // Test that connectToHost function exists and can be called 107 | // Note: We can't easily test the actual connection without a valid SSH config 108 | // and without actually connecting to a host, but we can verify the function exists 109 | t.Log("connectToHost function exists and is accessible") 110 | 111 | // The function will handle errors internally (like host not found) 112 | // We don't want to actually test the SSH connection in unit tests 113 | } 114 | 115 | func TestRunInteractiveModeFunction(t *testing.T) { 116 | // Test that runInteractiveMode function exists 117 | // We can't easily test the actual execution without mocking the UI, 118 | // but we can verify the function signature 119 | t.Log("runInteractiveMode function exists and is accessible") 120 | } 121 | 122 | func TestConfigFileVariable(t *testing.T) { 123 | // Test that configFile variable is properly initialized 124 | originalConfigFile := configFile 125 | defer func() { configFile = originalConfigFile }() 126 | 127 | // Set config file through flag 128 | RootCmd.SetArgs([]string{"--config", "/tmp/test-config"}) 129 | RootCmd.ParseFlags([]string{"--config", "/tmp/test-config"}) 130 | 131 | // The configFile variable should be updated by the flag parsing 132 | // Note: This test verifies the flag binding works 133 | } 134 | 135 | func TestVersionVariable(t *testing.T) { 136 | // Test that version variable has a default value 137 | if AppVersion == "" { 138 | t.Error("AppVersion variable should have a default value") 139 | } 140 | 141 | // Test that version is set to "dev" by default 142 | if AppVersion != "dev" { 143 | t.Logf("AppVersion is set to '%s' (expected 'dev' for development)", AppVersion) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /install/windows.ps1: -------------------------------------------------------------------------------- 1 | # SSHM Windows Installation Script 2 | # Usage: 3 | # Online: irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex 4 | # Local: .\install\windows.ps1 -LocalBinary ".\sshm.exe" 5 | 6 | param( 7 | [string]$InstallDir = "$env:LOCALAPPDATA\sshm", 8 | [switch]$Force = $false, 9 | [string]$LocalBinary = "" 10 | ) 11 | 12 | $ErrorActionPreference = "Stop" 13 | 14 | # Colors for output 15 | function Write-ColorOutput($ForegroundColor) { 16 | $fc = $host.UI.RawUI.ForegroundColor 17 | $host.UI.RawUI.ForegroundColor = $ForegroundColor 18 | if ($args) { 19 | Write-Output $args 20 | } 21 | $host.UI.RawUI.ForegroundColor = $fc 22 | } 23 | 24 | function Write-Info { Write-ColorOutput Green $args } 25 | function Write-Warning { Write-ColorOutput Yellow $args } 26 | function Write-Error { Write-ColorOutput Red $args } 27 | 28 | Write-Info "🚀 Installing SSHM - SSH Manager" 29 | Write-Info "" 30 | 31 | # Check if SSHM is already installed 32 | $existingSSHM = Get-Command sshm -ErrorAction SilentlyContinue 33 | if ($existingSSHM -and -not $Force) { 34 | $currentVersion = & sshm --version 2>$null | Select-String "version" | ForEach-Object { $_.ToString().Split()[-1] } 35 | Write-Warning "SSHM is already installed (version: $currentVersion)" 36 | $response = Read-Host "Do you want to continue with the installation? (y/N)" 37 | if ($response -ne "y" -and $response -ne "Y") { 38 | Write-Info "Installation cancelled." 39 | exit 0 40 | } 41 | } 42 | 43 | # Detect architecture 44 | $arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "386" } 45 | Write-Info "Detected platform: Windows ($arch)" 46 | 47 | # Check if using local binary 48 | if ($LocalBinary -ne "") { 49 | if (-not (Test-Path $LocalBinary)) { 50 | Write-Error "Local binary not found: $LocalBinary" 51 | exit 1 52 | } 53 | 54 | Write-Info "Using local binary: $LocalBinary" 55 | $targetPath = "$InstallDir\sshm.exe" 56 | 57 | # Create installation directory 58 | if (-not (Test-Path $InstallDir)) { 59 | Write-Info "Creating installation directory: $InstallDir" 60 | New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null 61 | } 62 | 63 | # Copy local binary 64 | Write-Info "Installing binary to: $targetPath" 65 | Copy-Item -Path $LocalBinary -Destination $targetPath -Force 66 | 67 | } else { 68 | # Online installation 69 | Write-Info "Starting online installation..." 70 | 71 | # Get latest version 72 | Write-Info "Fetching latest version..." 73 | try { 74 | $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest" 75 | $latestVersion = $latestRelease.tag_name 76 | Write-Info "Target version: $latestVersion" 77 | } catch { 78 | Write-Error "Failed to fetch version information" 79 | exit 1 80 | } 81 | 82 | # Download binary 83 | # Map architecture to match GoReleaser format 84 | $goreleaserArch = if ($arch -eq "amd64") { "x86_64" } else { "i386" } 85 | 86 | # GoReleaser format: sshm_Windows_x86_64.zip 87 | $fileName = "sshm_Windows_$goreleaserArch.zip" 88 | $downloadUrl = "https://github.com/Gu1llaum-3/sshm/releases/download/$latestVersion/$fileName" 89 | $tempFile = "$env:TEMP\$fileName" 90 | 91 | Write-Info "Downloading $fileName..." 92 | try { 93 | Invoke-WebRequest -Uri $downloadUrl -OutFile $tempFile 94 | } catch { 95 | Write-Error "Download failed" 96 | exit 1 97 | } 98 | 99 | # Create installation directory 100 | if (-not (Test-Path $InstallDir)) { 101 | New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null 102 | } 103 | 104 | # Extract archive 105 | Write-Info "Extracting..." 106 | try { 107 | Expand-Archive -Path $tempFile -DestinationPath $env:TEMP -Force 108 | # GoReleaser extracts the binary as just "sshm.exe", not with platform suffix 109 | $extractedBinary = "$env:TEMP\sshm.exe" 110 | $targetPath = "$InstallDir\sshm.exe" 111 | 112 | Move-Item -Path $extractedBinary -Destination $targetPath -Force 113 | } catch { 114 | Write-Error "Extraction failed" 115 | exit 1 116 | } 117 | 118 | # Clean up 119 | Remove-Item $tempFile -Force 120 | } 121 | 122 | # Check PATH 123 | $userPath = [Environment]::GetEnvironmentVariable("Path", "User") 124 | if ($userPath -notlike "*$InstallDir*") { 125 | Write-Warning "The directory $InstallDir is not in your PATH." 126 | Write-Info "Adding to user PATH..." 127 | [Environment]::SetEnvironmentVariable("Path", "$userPath;$InstallDir", "User") 128 | Write-Info "Please restart your terminal to use the 'sshm' command." 129 | } 130 | 131 | Write-Info "" 132 | Write-Info "✅ SSHM successfully installed to: $targetPath" 133 | Write-Info "You can now use the 'sshm' command!" 134 | 135 | # Verify installation 136 | if (Test-Path $targetPath) { 137 | Write-Info "" 138 | Write-Info "Verifying installation..." 139 | & $targetPath --version 140 | } 141 | -------------------------------------------------------------------------------- /internal/ui/tui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/Gu1llaum-3/sshm/internal/config" 9 | "github.com/Gu1llaum-3/sshm/internal/connectivity" 10 | "github.com/Gu1llaum-3/sshm/internal/history" 11 | 12 | "github.com/charmbracelet/bubbles/table" 13 | "github.com/charmbracelet/bubbles/textinput" 14 | tea "github.com/charmbracelet/bubbletea" 15 | "github.com/charmbracelet/lipgloss" 16 | ) 17 | 18 | // NewModel creates a new TUI model with the given SSH hosts 19 | func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model { 20 | // Load application configuration 21 | appConfig, err := config.LoadAppConfig() 22 | if err != nil { 23 | // Log the error but continue with default configuration 24 | fmt.Printf("Warning: Could not load application config: %v, using defaults\n", err) 25 | defaultConfig := config.GetDefaultAppConfig() 26 | appConfig = &defaultConfig 27 | } 28 | 29 | // Initialize the history manager 30 | historyManager, err := history.NewHistoryManager() 31 | if err != nil { 32 | // Log the error but continue without the history functionality 33 | fmt.Printf("Warning: Could not initialize history manager: %v\n", err) 34 | historyManager = nil 35 | } 36 | 37 | // Create initial styles (will be updated on first WindowSizeMsg) 38 | styles := NewStyles(80) // Default width 39 | 40 | // Initialize ping manager with 5 second timeout 41 | pingManager := connectivity.NewPingManager(5 * time.Second) 42 | 43 | // Create the model with default sorting by name 44 | m := Model{ 45 | hosts: hosts, 46 | historyManager: historyManager, 47 | pingManager: pingManager, 48 | sortMode: SortByName, 49 | configFile: configFile, 50 | currentVersion: currentVersion, 51 | appConfig: appConfig, 52 | styles: styles, 53 | width: 80, 54 | height: 24, 55 | ready: false, 56 | viewMode: ViewList, 57 | } 58 | 59 | // Sort hosts according to the default sort mode 60 | sortedHosts := m.sortHosts(hosts) 61 | 62 | // Create the search input 63 | ti := textinput.New() 64 | ti.Placeholder = "Search hosts or tags..." 65 | ti.CharLimit = 50 66 | ti.Width = 25 67 | 68 | // Use dynamic column width calculation (will fallback to static if width not available) 69 | nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(sortedHosts) 70 | 71 | // Create table columns 72 | columns := []table.Column{ 73 | {Title: "Name", Width: nameWidth}, 74 | {Title: "Hostname", Width: hostnameWidth}, 75 | // {Title: "User", Width: 12}, // Commented to save space 76 | // {Title: "Port", Width: 6}, // Commented to save space 77 | {Title: "Tags", Width: tagsWidth}, 78 | {Title: "Last Login", Width: lastLoginWidth}, 79 | } 80 | 81 | // Convert hosts to table rows 82 | var rows []table.Row 83 | for _, host := range sortedHosts { 84 | // Get ping status indicator 85 | statusIndicator := m.getPingStatusIndicator(host.Name) 86 | 87 | // Format tags for display 88 | var tagsStr string 89 | if len(host.Tags) > 0 { 90 | // Add the # prefix to each tag and join them with spaces 91 | var formattedTags []string 92 | for _, tag := range host.Tags { 93 | formattedTags = append(formattedTags, "#"+tag) 94 | } 95 | tagsStr = strings.Join(formattedTags, " ") 96 | } 97 | 98 | // Format last login information 99 | var lastLoginStr string 100 | if historyManager != nil { 101 | if lastConnect, exists := historyManager.GetLastConnectionTime(host.Name); exists { 102 | lastLoginStr = formatTimeAgo(lastConnect) 103 | } 104 | } 105 | 106 | rows = append(rows, table.Row{ 107 | statusIndicator + " " + host.Name, 108 | host.Hostname, 109 | // host.User, // Commented to save space 110 | // host.Port, // Commented to save space 111 | tagsStr, 112 | lastLoginStr, 113 | }) 114 | } 115 | 116 | // Create the table with initial height (will be updated on first WindowSizeMsg) 117 | t := table.New( 118 | table.WithColumns(columns), 119 | table.WithRows(rows), 120 | table.WithFocused(true), 121 | table.WithHeight(10), // Initial height, will be recalculated dynamically 122 | ) 123 | 124 | // Style the table 125 | s := table.DefaultStyles() 126 | s.Header = s.Header. 127 | BorderStyle(lipgloss.NormalBorder()). 128 | BorderForeground(lipgloss.Color(SecondaryColor)). 129 | BorderBottom(true). 130 | Bold(false) 131 | s.Selected = m.styles.Selected 132 | 133 | t.SetStyles(s) 134 | 135 | // Update the model with the table and other properties 136 | m.table = t 137 | m.searchInput = ti 138 | m.filteredHosts = sortedHosts 139 | 140 | // Initialize table styles based on initial focus state 141 | m.updateTableStyles() 142 | 143 | // The table height will be properly set on the first WindowSizeMsg 144 | // when m.ready becomes true and actual terminal dimensions are known 145 | 146 | return m 147 | } 148 | 149 | // RunInteractiveMode starts the interactive TUI interface 150 | func RunInteractiveMode(hosts []config.SSHHost, configFile, currentVersion string) error { 151 | m := NewModel(hosts, configFile, currentVersion) 152 | 153 | // Start the application in alt screen mode for clean output 154 | p := tea.NewProgram(m, tea.WithAltScreen()) 155 | _, err := p.Run() 156 | if err != nil { 157 | return fmt.Errorf("error running TUI: %w", err) 158 | } 159 | 160 | return nil 161 | } 162 | -------------------------------------------------------------------------------- /internal/history/history_test.go: -------------------------------------------------------------------------------- 1 | package history 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | // createTestHistoryManager creates a history manager with a temporary file for testing 11 | func createTestHistoryManager(t *testing.T) *HistoryManager { 12 | // Create temporary directory 13 | tempDir := t.TempDir() 14 | historyPath := filepath.Join(tempDir, "test_sshm_history.json") 15 | 16 | hm := &HistoryManager{ 17 | historyPath: historyPath, 18 | history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)}, 19 | } 20 | 21 | return hm 22 | } 23 | 24 | func TestNewHistoryManager(t *testing.T) { 25 | hm, err := NewHistoryManager() 26 | if err != nil { 27 | t.Fatalf("NewHistoryManager() error = %v", err) 28 | } 29 | if hm == nil { 30 | t.Fatal("NewHistoryManager() returned nil") 31 | } 32 | if hm.historyPath == "" { 33 | t.Error("Expected historyPath to be set") 34 | } 35 | } 36 | 37 | func TestHistoryManager_RecordConnection(t *testing.T) { 38 | hm := createTestHistoryManager(t) 39 | 40 | // Add a connection 41 | err := hm.RecordConnection("testhost") 42 | if err != nil { 43 | t.Errorf("RecordConnection() error = %v", err) 44 | } 45 | 46 | // Check that the connection was added 47 | lastUsed, exists := hm.GetLastConnectionTime("testhost") 48 | if !exists || lastUsed.IsZero() { 49 | t.Error("Expected connection to be recorded") 50 | } 51 | } 52 | 53 | func TestHistoryManager_GetLastConnectionTime(t *testing.T) { 54 | hm := createTestHistoryManager(t) 55 | 56 | // Test with no connections 57 | lastUsed, exists := hm.GetLastConnectionTime("nonexistent-testhost") 58 | if exists || !lastUsed.IsZero() { 59 | t.Error("Expected no connection for non-existent host") 60 | } 61 | 62 | // Add a connection 63 | err := hm.RecordConnection("testhost") 64 | if err != nil { 65 | t.Errorf("RecordConnection() error = %v", err) 66 | } 67 | 68 | // Test with existing connection 69 | lastUsed, exists = hm.GetLastConnectionTime("testhost") 70 | if !exists || lastUsed.IsZero() { 71 | t.Error("Expected non-zero time for existing host") 72 | } 73 | 74 | // Check that the time is recent (within last minute) 75 | if time.Since(lastUsed) > time.Minute { 76 | t.Error("Last used time seems too old") 77 | } 78 | } 79 | 80 | func TestHistoryManager_GetConnectionCount(t *testing.T) { 81 | hm := createTestHistoryManager(t) 82 | 83 | // Add same host multiple times 84 | for i := 0; i < 3; i++ { 85 | err := hm.RecordConnection("testhost-count") 86 | if err != nil { 87 | t.Errorf("RecordConnection() error = %v", err) 88 | } 89 | time.Sleep(1 * time.Millisecond) 90 | } 91 | 92 | // Should have correct count 93 | count := hm.GetConnectionCount("testhost-count") 94 | if count != 3 { 95 | t.Errorf("Expected connection count 3, got %d", count) 96 | } 97 | } 98 | 99 | func TestMigrateOldHistoryFile(t *testing.T) { 100 | // This test verifies that migration doesn't fail when called 101 | // The actual migration logic will be tested in integration tests 102 | 103 | tempDir := t.TempDir() 104 | newHistoryPath := filepath.Join(tempDir, "sshm_history.json") 105 | 106 | // Test that migration works when no old file exists (common case) 107 | if err := migrateOldHistoryFile(newHistoryPath); err != nil { 108 | t.Errorf("migrateOldHistoryFile() with no old file error = %v", err) 109 | } 110 | 111 | // Test that migration skips when new file already exists 112 | if err := os.WriteFile(newHistoryPath, []byte(`{"connections":{}}`), 0644); err != nil { 113 | t.Fatalf("Failed to write new history file: %v", err) 114 | } 115 | 116 | if err := migrateOldHistoryFile(newHistoryPath); err != nil { 117 | t.Errorf("migrateOldHistoryFile() with existing new file error = %v", err) 118 | } 119 | 120 | // File should be unchanged 121 | data, err := os.ReadFile(newHistoryPath) 122 | if err != nil { 123 | t.Errorf("Failed to read new file: %v", err) 124 | } 125 | if string(data) != `{"connections":{}}` { 126 | t.Error("New file was modified when it shouldn't have been") 127 | } 128 | } 129 | 130 | func TestMigrateOldHistoryFile_NoOldFile(t *testing.T) { 131 | // Test migration when no old file exists 132 | tempDir := t.TempDir() 133 | newHistoryPath := filepath.Join(tempDir, "sshm_history.json") 134 | 135 | // Should not return error when old file doesn't exist 136 | if err := migrateOldHistoryFile(newHistoryPath); err != nil { 137 | t.Errorf("migrateOldHistoryFile() with no old file error = %v", err) 138 | } 139 | } 140 | 141 | func TestMigrateOldHistoryFile_NewFileExists(t *testing.T) { 142 | // Test migration when new file already exists (should skip migration) 143 | tempDir := t.TempDir() 144 | newHistoryPath := filepath.Join(tempDir, "sshm_history.json") 145 | 146 | // Create new file first 147 | if err := os.WriteFile(newHistoryPath, []byte(`{"connections":{}}`), 0644); err != nil { 148 | t.Fatalf("Failed to write new history file: %v", err) 149 | } 150 | 151 | // Migration should skip when new file exists 152 | if err := migrateOldHistoryFile(newHistoryPath); err != nil { 153 | t.Errorf("migrateOldHistoryFile() with existing new file error = %v", err) 154 | } 155 | 156 | // New file should be unchanged 157 | data, err := os.ReadFile(newHistoryPath) 158 | if err != nil { 159 | t.Errorf("Failed to read new file: %v", err) 160 | } 161 | if string(data) != `{"connections":{}}` { 162 | t.Error("New file was modified when it shouldn't have been") 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /internal/ui/move_form.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/Gu1llaum-3/sshm/internal/config" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | type moveFormModel struct { 12 | fileSelector *fileSelectorModel 13 | hostName string 14 | configFile string 15 | width int 16 | height int 17 | styles Styles 18 | state moveFormState 19 | } 20 | 21 | type moveFormState int 22 | 23 | const ( 24 | moveFormSelectingFile moveFormState = iota 25 | moveFormProcessing 26 | ) 27 | 28 | type moveFormSubmitMsg struct { 29 | hostName string 30 | targetFile string 31 | err error 32 | } 33 | 34 | type moveFormCancelMsg struct{} 35 | 36 | // NewMoveForm creates a new move form for moving a host to another config file 37 | func NewMoveForm(hostName string, styles Styles, width, height int, configFile string) (*moveFormModel, error) { 38 | // Get all config files except the one containing the current host 39 | files, err := config.GetConfigFilesExcludingCurrent(hostName, configFile) 40 | if err != nil { 41 | return nil, fmt.Errorf("failed to get config files: %v", err) 42 | } 43 | 44 | if len(files) == 0 { 45 | return nil, fmt.Errorf("no includes found in SSH config file - move operation requires multiple config files") 46 | } 47 | 48 | // Create a custom file selector for move operation 49 | fileSelector, err := newFileSelectorFromFiles( 50 | fmt.Sprintf("Select destination config file for host '%s':", hostName), 51 | styles, 52 | width, 53 | height, 54 | files, 55 | ) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed to create file selector: %v", err) 58 | } 59 | 60 | return &moveFormModel{ 61 | fileSelector: fileSelector, 62 | hostName: hostName, 63 | configFile: configFile, 64 | width: width, 65 | height: height, 66 | styles: styles, 67 | state: moveFormSelectingFile, 68 | }, nil 69 | } 70 | 71 | func (m *moveFormModel) Init() tea.Cmd { 72 | return m.fileSelector.Init() 73 | } 74 | 75 | func (m *moveFormModel) Update(msg tea.Msg) (*moveFormModel, tea.Cmd) { 76 | switch msg := msg.(type) { 77 | case tea.WindowSizeMsg: 78 | m.width = msg.Width 79 | m.height = msg.Height 80 | m.styles = NewStyles(m.width) 81 | if m.fileSelector != nil { 82 | m.fileSelector.width = m.width 83 | m.fileSelector.height = m.height 84 | m.fileSelector.styles = m.styles 85 | } 86 | return m, nil 87 | 88 | case tea.KeyMsg: 89 | switch m.state { 90 | case moveFormSelectingFile: 91 | switch msg.String() { 92 | case "enter": 93 | if m.fileSelector != nil && len(m.fileSelector.files) > 0 { 94 | selectedFile := m.fileSelector.files[m.fileSelector.selected] 95 | m.state = moveFormProcessing 96 | return m, m.submitMove(selectedFile) 97 | } 98 | case "esc", "q": 99 | return m, func() tea.Msg { return moveFormCancelMsg{} } 100 | default: 101 | // Forward other keys to file selector 102 | if m.fileSelector != nil { 103 | newFileSelector, cmd := m.fileSelector.Update(msg) 104 | m.fileSelector = newFileSelector 105 | return m, cmd 106 | } 107 | } 108 | case moveFormProcessing: 109 | // Dans cet état, on attend le résultat de l'opération 110 | // Le résultat sera géré par le modèle principal 111 | switch msg.String() { 112 | case "esc", "q": 113 | return m, func() tea.Msg { return moveFormCancelMsg{} } 114 | } 115 | } 116 | } 117 | 118 | return m, nil 119 | } 120 | 121 | func (m *moveFormModel) View() string { 122 | switch m.state { 123 | case moveFormSelectingFile: 124 | if m.fileSelector != nil { 125 | return m.fileSelector.View() 126 | } 127 | return "Loading..." 128 | 129 | case moveFormProcessing: 130 | return m.styles.FormTitle.Render("Moving host...") + "\n\n" + 131 | m.styles.HelpText.Render(fmt.Sprintf("Moving host '%s' to selected config file...", m.hostName)) 132 | 133 | default: 134 | return "Unknown state" 135 | } 136 | } 137 | 138 | func (m *moveFormModel) submitMove(targetFile string) tea.Cmd { 139 | return func() tea.Msg { 140 | err := config.MoveHostToFile(m.hostName, targetFile) 141 | return moveFormSubmitMsg{ 142 | hostName: m.hostName, 143 | targetFile: targetFile, 144 | err: err, 145 | } 146 | } 147 | } 148 | 149 | // Standalone move form for CLI usage 150 | type standaloneMoveForm struct { 151 | moveFormModel *moveFormModel 152 | } 153 | 154 | func (m standaloneMoveForm) Init() tea.Cmd { 155 | return m.moveFormModel.Init() 156 | } 157 | 158 | func (m standaloneMoveForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 159 | switch msg.(type) { 160 | case moveFormCancelMsg: 161 | return m, tea.Quit 162 | case moveFormSubmitMsg: 163 | // En mode standalone, on quitte après le déplacement (succès ou erreur) 164 | return m, tea.Quit 165 | } 166 | 167 | newForm, cmd := m.moveFormModel.Update(msg) 168 | m.moveFormModel = newForm 169 | return m, cmd 170 | } 171 | 172 | func (m standaloneMoveForm) View() string { 173 | return m.moveFormModel.View() 174 | } 175 | 176 | // RunMoveForm provides backward compatibility for standalone move form 177 | func RunMoveForm(hostName string, configFile string) error { 178 | styles := NewStyles(80) 179 | moveForm, err := NewMoveForm(hostName, styles, 80, 24, configFile) 180 | if err != nil { 181 | return err 182 | } 183 | m := standaloneMoveForm{moveForm} 184 | 185 | p := tea.NewProgram(m, tea.WithAltScreen()) 186 | _, err = p.Run() 187 | return err 188 | } 189 | -------------------------------------------------------------------------------- /internal/config/keybindings_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestDefaultKeyBindings(t *testing.T) { 11 | kb := GetDefaultKeyBindings() 12 | 13 | // Test default configuration 14 | if kb.DisableEscQuit { 15 | t.Error("Default configuration should allow ESC to quit (backward compatibility)") 16 | } 17 | 18 | // Test default quit keys 19 | expectedQuitKeys := []string{"q", "ctrl+c"} 20 | if len(kb.QuitKeys) != len(expectedQuitKeys) { 21 | t.Errorf("Expected %d quit keys, got %d", len(expectedQuitKeys), len(kb.QuitKeys)) 22 | } 23 | 24 | for i, expected := range expectedQuitKeys { 25 | if i >= len(kb.QuitKeys) || kb.QuitKeys[i] != expected { 26 | t.Errorf("Expected quit key %s, got %s", expected, kb.QuitKeys[i]) 27 | } 28 | } 29 | } 30 | 31 | func TestShouldQuitOnKey(t *testing.T) { 32 | tests := []struct { 33 | name string 34 | keyBindings KeyBindings 35 | key string 36 | expectedResult bool 37 | }{ 38 | { 39 | name: "Default config - ESC should quit", 40 | keyBindings: KeyBindings{ 41 | QuitKeys: []string{"q", "ctrl+c"}, 42 | DisableEscQuit: false, 43 | }, 44 | key: "esc", 45 | expectedResult: true, 46 | }, 47 | { 48 | name: "Disabled ESC quit - ESC should not quit", 49 | keyBindings: KeyBindings{ 50 | QuitKeys: []string{"q", "ctrl+c"}, 51 | DisableEscQuit: true, 52 | }, 53 | key: "esc", 54 | expectedResult: false, 55 | }, 56 | { 57 | name: "Q key should quit", 58 | keyBindings: KeyBindings{ 59 | QuitKeys: []string{"q", "ctrl+c"}, 60 | DisableEscQuit: true, 61 | }, 62 | key: "q", 63 | expectedResult: true, 64 | }, 65 | { 66 | name: "Ctrl+C should quit", 67 | keyBindings: KeyBindings{ 68 | QuitKeys: []string{"q", "ctrl+c"}, 69 | DisableEscQuit: true, 70 | }, 71 | key: "ctrl+c", 72 | expectedResult: true, 73 | }, 74 | { 75 | name: "Other keys should not quit", 76 | keyBindings: KeyBindings{ 77 | QuitKeys: []string{"q", "ctrl+c"}, 78 | DisableEscQuit: true, 79 | }, 80 | key: "enter", 81 | expectedResult: false, 82 | }, 83 | } 84 | 85 | for _, tt := range tests { 86 | t.Run(tt.name, func(t *testing.T) { 87 | result := tt.keyBindings.ShouldQuitOnKey(tt.key) 88 | if result != tt.expectedResult { 89 | t.Errorf("ShouldQuitOnKey(%q) = %v, expected %v", tt.key, result, tt.expectedResult) 90 | } 91 | }) 92 | } 93 | } 94 | 95 | func TestAppConfigBasics(t *testing.T) { 96 | // Test default config creation 97 | defaultConfig := GetDefaultAppConfig() 98 | 99 | if defaultConfig.KeyBindings.DisableEscQuit { 100 | t.Error("Default configuration should allow ESC to quit") 101 | } 102 | 103 | expectedQuitKeys := []string{"q", "ctrl+c"} 104 | if len(defaultConfig.KeyBindings.QuitKeys) != len(expectedQuitKeys) { 105 | t.Errorf("Expected %d quit keys, got %d", len(expectedQuitKeys), len(defaultConfig.KeyBindings.QuitKeys)) 106 | } 107 | } 108 | 109 | func TestMergeWithDefaults(t *testing.T) { 110 | // Test config with missing QuitKeys 111 | incompleteConfig := AppConfig{ 112 | KeyBindings: KeyBindings{ 113 | DisableEscQuit: true, 114 | // QuitKeys is missing 115 | }, 116 | } 117 | 118 | mergedConfig := mergeWithDefaults(incompleteConfig) 119 | 120 | // Should preserve DisableEscQuit 121 | if !mergedConfig.KeyBindings.DisableEscQuit { 122 | t.Error("Should preserve DisableEscQuit as true") 123 | } 124 | 125 | // Should fill in default QuitKeys 126 | expectedQuitKeys := []string{"q", "ctrl+c"} 127 | if len(mergedConfig.KeyBindings.QuitKeys) != len(expectedQuitKeys) { 128 | t.Errorf("Expected %d quit keys, got %d", len(expectedQuitKeys), len(mergedConfig.KeyBindings.QuitKeys)) 129 | } 130 | } 131 | 132 | func TestSaveAndLoadAppConfigIntegration(t *testing.T) { 133 | // Create a temporary directory for testing 134 | tempDir, err := os.MkdirTemp("", "sshm_test") 135 | if err != nil { 136 | t.Fatalf("Failed to create temp directory: %v", err) 137 | } 138 | defer os.RemoveAll(tempDir) 139 | 140 | // Create a custom config file directly in temp directory 141 | configPath := filepath.Join(tempDir, "config.json") 142 | 143 | customConfig := AppConfig{ 144 | KeyBindings: KeyBindings{ 145 | QuitKeys: []string{"q"}, 146 | DisableEscQuit: true, 147 | }, 148 | } 149 | 150 | // Save config directly to file 151 | data, err := json.MarshalIndent(customConfig, "", " ") 152 | if err != nil { 153 | t.Fatalf("Failed to marshal config: %v", err) 154 | } 155 | 156 | err = os.WriteFile(configPath, data, 0644) 157 | if err != nil { 158 | t.Fatalf("Failed to write config file: %v", err) 159 | } 160 | 161 | // Read and unmarshal config 162 | readData, err := os.ReadFile(configPath) 163 | if err != nil { 164 | t.Fatalf("Failed to read config file: %v", err) 165 | } 166 | 167 | var loadedConfig AppConfig 168 | err = json.Unmarshal(readData, &loadedConfig) 169 | if err != nil { 170 | t.Fatalf("Failed to unmarshal config: %v", err) 171 | } 172 | 173 | // Verify the loaded config matches what we saved 174 | if !loadedConfig.KeyBindings.DisableEscQuit { 175 | t.Error("DisableEscQuit should be true") 176 | } 177 | 178 | if len(loadedConfig.KeyBindings.QuitKeys) != 1 || loadedConfig.KeyBindings.QuitKeys[0] != "q" { 179 | t.Errorf("Expected quit keys to be ['q'], got %v", loadedConfig.KeyBindings.QuitKeys) 180 | } 181 | } -------------------------------------------------------------------------------- /internal/connectivity/ping.go: -------------------------------------------------------------------------------- 1 | package connectivity 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "github.com/Gu1llaum-3/sshm/internal/config" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "golang.org/x/crypto/ssh" 12 | ) 13 | 14 | // PingStatus represents the connectivity status of an SSH host 15 | type PingStatus int 16 | 17 | const ( 18 | StatusUnknown PingStatus = iota 19 | StatusConnecting 20 | StatusOnline 21 | StatusOffline 22 | ) 23 | 24 | func (s PingStatus) String() string { 25 | switch s { 26 | case StatusUnknown: 27 | return "unknown" 28 | case StatusConnecting: 29 | return "connecting" 30 | case StatusOnline: 31 | return "online" 32 | case StatusOffline: 33 | return "offline" 34 | } 35 | return "unknown" 36 | } 37 | 38 | // HostPingResult represents the result of pinging a host 39 | type HostPingResult struct { 40 | HostName string 41 | Status PingStatus 42 | Error error 43 | Duration time.Duration 44 | } 45 | 46 | // PingManager manages SSH connectivity checks for multiple hosts 47 | type PingManager struct { 48 | results map[string]*HostPingResult 49 | mutex sync.RWMutex 50 | timeout time.Duration 51 | } 52 | 53 | // NewPingManager creates a new ping manager with the specified timeout 54 | func NewPingManager(timeout time.Duration) *PingManager { 55 | return &PingManager{ 56 | results: make(map[string]*HostPingResult), 57 | timeout: timeout, 58 | } 59 | } 60 | 61 | // GetStatus returns the current status for a host 62 | func (pm *PingManager) GetStatus(hostName string) PingStatus { 63 | pm.mutex.RLock() 64 | defer pm.mutex.RUnlock() 65 | 66 | if result, exists := pm.results[hostName]; exists { 67 | return result.Status 68 | } 69 | return StatusUnknown 70 | } 71 | 72 | // GetResult returns the complete result for a host 73 | func (pm *PingManager) GetResult(hostName string) (*HostPingResult, bool) { 74 | pm.mutex.RLock() 75 | defer pm.mutex.RUnlock() 76 | 77 | result, exists := pm.results[hostName] 78 | return result, exists 79 | } 80 | 81 | // updateStatus updates the status for a host 82 | func (pm *PingManager) updateStatus(hostName string, status PingStatus, err error, duration time.Duration) { 83 | pm.mutex.Lock() 84 | defer pm.mutex.Unlock() 85 | 86 | pm.results[hostName] = &HostPingResult{ 87 | HostName: hostName, 88 | Status: status, 89 | Error: err, 90 | Duration: duration, 91 | } 92 | } 93 | 94 | // PingHost performs an SSH connectivity check for a single host 95 | func (pm *PingManager) PingHost(ctx context.Context, host config.SSHHost) *HostPingResult { 96 | start := time.Now() 97 | 98 | // Mark as connecting 99 | pm.updateStatus(host.Name, StatusConnecting, nil, 0) 100 | 101 | // Determine the actual hostname and port 102 | hostname := host.Hostname 103 | if hostname == "" { 104 | hostname = host.Name 105 | } 106 | 107 | port := host.Port 108 | if port == "" { 109 | port = "22" 110 | } 111 | 112 | // Create context with timeout 113 | pingCtx, cancel := context.WithTimeout(ctx, pm.timeout) 114 | defer cancel() 115 | 116 | // Try to establish a TCP connection first (faster than SSH handshake) 117 | dialer := &net.Dialer{} 118 | conn, err := dialer.DialContext(pingCtx, "tcp", net.JoinHostPort(hostname, port)) 119 | if err != nil { 120 | duration := time.Since(start) 121 | pm.updateStatus(host.Name, StatusOffline, err, duration) 122 | return &HostPingResult{ 123 | HostName: host.Name, 124 | Status: StatusOffline, 125 | Error: err, 126 | Duration: duration, 127 | } 128 | } 129 | defer conn.Close() 130 | 131 | // If TCP connection succeeds, try SSH handshake 132 | sshConfig := &ssh.ClientConfig{ 133 | User: host.User, 134 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), // For ping purposes only 135 | Timeout: time.Second * 2, // Short timeout for handshake 136 | } 137 | 138 | // We don't need to authenticate, just check if SSH is responding 139 | sshConn, _, _, err := ssh.NewClientConn(conn, net.JoinHostPort(hostname, port), sshConfig) 140 | if sshConn != nil { 141 | sshConn.Close() 142 | } 143 | 144 | duration := time.Since(start) 145 | 146 | // Even if SSH handshake fails, if we got a TCP connection, consider it online 147 | // This handles cases where authentication fails but the host is reachable 148 | status := StatusOnline 149 | if err != nil && isConnectionError(err) { 150 | status = StatusOffline 151 | } 152 | 153 | pm.updateStatus(host.Name, status, err, duration) 154 | return &HostPingResult{ 155 | HostName: host.Name, 156 | Status: status, 157 | Error: err, 158 | Duration: duration, 159 | } 160 | } 161 | 162 | // PingAllHosts pings all hosts concurrently and returns a channel of results 163 | func (pm *PingManager) PingAllHosts(ctx context.Context, hosts []config.SSHHost) <-chan *HostPingResult { 164 | resultChan := make(chan *HostPingResult, len(hosts)) 165 | 166 | var wg sync.WaitGroup 167 | 168 | for _, host := range hosts { 169 | wg.Add(1) 170 | go func(h config.SSHHost) { 171 | defer wg.Done() 172 | result := pm.PingHost(ctx, h) 173 | select { 174 | case resultChan <- result: 175 | case <-ctx.Done(): 176 | return 177 | } 178 | }(host) 179 | } 180 | 181 | // Close the channel when all goroutines are done 182 | go func() { 183 | wg.Wait() 184 | close(resultChan) 185 | }() 186 | 187 | return resultChan 188 | } 189 | 190 | // isConnectionError determines if an error is a connection-related error 191 | func isConnectionError(err error) bool { 192 | if err == nil { 193 | return false 194 | } 195 | 196 | errStr := err.Error() 197 | connectionErrors := []string{ 198 | "connection refused", 199 | "no route to host", 200 | "network is unreachable", 201 | "timeout", 202 | "connection timed out", 203 | } 204 | 205 | for _, connErr := range connectionErrors { 206 | if strings.Contains(strings.ToLower(errStr), connErr) { 207 | return true 208 | } 209 | } 210 | 211 | return false 212 | } 213 | -------------------------------------------------------------------------------- /internal/validation/ssh_test.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestValidateHostname(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | hostname string 14 | want bool 15 | }{ 16 | {"valid hostname", "example.com", true}, 17 | {"valid IP", "192.168.1.1", true}, // IPs are valid hostnames too 18 | {"valid subdomain", "sub.example.com", true}, 19 | {"valid single word", "localhost", true}, 20 | {"empty hostname", "", false}, 21 | {"hostname too long", strings.Repeat("a", 254), false}, 22 | {"hostname with space", "example .com", false}, 23 | {"hostname starting with dot", ".example.com", false}, 24 | {"hostname ending with dot", "example.com.", false}, 25 | {"hostname with hyphen", "my-server.com", true}, 26 | {"hostname starting with number", "1example.com", true}, 27 | } 28 | 29 | for _, tt := range tests { 30 | t.Run(tt.name, func(t *testing.T) { 31 | if got := ValidateHostname(tt.hostname); got != tt.want { 32 | t.Errorf("ValidateHostname(%q) = %v, want %v", tt.hostname, got, tt.want) 33 | } 34 | }) 35 | } 36 | } 37 | 38 | func TestValidateIP(t *testing.T) { 39 | tests := []struct { 40 | name string 41 | ip string 42 | want bool 43 | }{ 44 | {"valid IPv4", "192.168.1.1", true}, 45 | {"valid IPv6", "2001:db8::1", true}, 46 | {"invalid IP", "256.256.256.256", false}, 47 | {"empty IP", "", false}, 48 | {"hostname not IP", "example.com", false}, 49 | {"localhost", "127.0.0.1", true}, 50 | {"zero IP", "0.0.0.0", true}, 51 | } 52 | 53 | for _, tt := range tests { 54 | t.Run(tt.name, func(t *testing.T) { 55 | if got := ValidateIP(tt.ip); got != tt.want { 56 | t.Errorf("ValidateIP(%q) = %v, want %v", tt.ip, got, tt.want) 57 | } 58 | }) 59 | } 60 | } 61 | 62 | func TestValidatePort(t *testing.T) { 63 | tests := []struct { 64 | name string 65 | port string 66 | want bool 67 | }{ 68 | {"valid port 22", "22", true}, 69 | {"valid port 80", "80", true}, 70 | {"valid port 65535", "65535", true}, 71 | {"valid port 1", "1", true}, 72 | {"empty port", "", true}, // Empty defaults to 22 73 | {"invalid port 0", "0", false}, 74 | {"invalid port 65536", "65536", false}, 75 | {"invalid port negative", "-1", false}, 76 | {"invalid port string", "abc", false}, 77 | {"invalid port with space", "22 ", false}, 78 | } 79 | 80 | for _, tt := range tests { 81 | t.Run(tt.name, func(t *testing.T) { 82 | if got := ValidatePort(tt.port); got != tt.want { 83 | t.Errorf("ValidatePort(%q) = %v, want %v", tt.port, got, tt.want) 84 | } 85 | }) 86 | } 87 | } 88 | 89 | func TestValidateHostName(t *testing.T) { 90 | tests := []struct { 91 | name string 92 | hostName string 93 | want bool 94 | }{ 95 | {"valid host name", "myserver", true}, 96 | {"valid host name with hyphen", "my-server", true}, 97 | {"valid host name with number", "server1", true}, 98 | {"empty host name", "", false}, 99 | {"host name too long", strings.Repeat("a", 51), false}, 100 | {"host name with space", "my server", false}, 101 | {"host name with tab", "my\tserver", false}, 102 | {"host name with newline", "my\nserver", false}, 103 | {"host name with hash", "my#server", false}, 104 | } 105 | 106 | for _, tt := range tests { 107 | t.Run(tt.name, func(t *testing.T) { 108 | if got := ValidateHostName(tt.hostName); got != tt.want { 109 | t.Errorf("ValidateHostName(%q) = %v, want %v", tt.hostName, got, tt.want) 110 | } 111 | }) 112 | } 113 | } 114 | 115 | func TestValidateIdentityFile(t *testing.T) { 116 | // Create a temporary file for testing 117 | tmpDir := t.TempDir() 118 | validFile := filepath.Join(tmpDir, "test_key") 119 | if err := os.WriteFile(validFile, []byte("test"), 0600); err != nil { 120 | t.Fatal(err) 121 | } 122 | 123 | tests := []struct { 124 | name string 125 | path string 126 | want bool 127 | }{ 128 | {"empty path", "", true}, // Optional field 129 | {"valid file", validFile, true}, 130 | {"non-existent file", "/path/to/nonexistent", false}, 131 | // Skip tilde path test in CI environments where ~/.ssh/id_rsa may not exist 132 | // {"tilde path", "~/.ssh/id_rsa", true}, // Will pass if file exists 133 | } 134 | 135 | for _, tt := range tests { 136 | t.Run(tt.name, func(t *testing.T) { 137 | if got := ValidateIdentityFile(tt.path); got != tt.want { 138 | t.Errorf("ValidateIdentityFile(%q) = %v, want %v", tt.path, got, tt.want) 139 | } 140 | }) 141 | } 142 | 143 | // Test tilde path separately, but only if the file actually exists 144 | t.Run("tilde path", func(t *testing.T) { 145 | tildeFile := "~/.ssh/id_rsa" 146 | // Just test that it doesn't crash, don't assume file exists 147 | result := ValidateIdentityFile(tildeFile) 148 | // Result can be true or false depending on file existence 149 | _ = result // We just care that it doesn't panic 150 | }) 151 | } 152 | 153 | func TestValidateHost(t *testing.T) { 154 | // Create a temporary file for identity testing 155 | tmpDir := t.TempDir() 156 | validIdentity := filepath.Join(tmpDir, "test_key") 157 | if err := os.WriteFile(validIdentity, []byte("test"), 0600); err != nil { 158 | t.Fatal(err) 159 | } 160 | 161 | tests := []struct { 162 | name string 163 | hostName string 164 | hostname string 165 | port string 166 | identity string 167 | wantErr bool 168 | }{ 169 | {"valid host", "myserver", "example.com", "22", "", false}, 170 | {"valid host with identity", "myserver", "192.168.1.1", "2222", validIdentity, false}, 171 | {"empty host name", "", "example.com", "22", "", true}, 172 | {"invalid host name", "my server", "example.com", "22", "", true}, 173 | {"empty hostname", "myserver", "", "22", "", true}, 174 | {"invalid hostname", "myserver", "invalid..hostname", "22", "", true}, 175 | {"invalid port", "myserver", "example.com", "99999", "", true}, 176 | {"invalid identity", "myserver", "example.com", "22", "/nonexistent", true}, 177 | } 178 | 179 | for _, tt := range tests { 180 | t.Run(tt.name, func(t *testing.T) { 181 | err := ValidateHost(tt.hostName, tt.hostname, tt.port, tt.identity) 182 | if (err != nil) != tt.wantErr { 183 | t.Errorf("ValidateHost() error = %v, wantErr %v", err, tt.wantErr) 184 | } 185 | }) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 6 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 7 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 8 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 9 | github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= 10 | github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= 11 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 12 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 13 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 14 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 15 | github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= 16 | github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 17 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 18 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 19 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 20 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 21 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 22 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 23 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 24 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 25 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 26 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 27 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 28 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 29 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 30 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 31 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 32 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 33 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 34 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 35 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 36 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 37 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 38 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 39 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 40 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 41 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 42 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 43 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 44 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 45 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 46 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 47 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 48 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 49 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 50 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 51 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 52 | golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= 53 | golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 54 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 55 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 56 | golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 57 | golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 58 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 59 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 60 | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 61 | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 62 | golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= 63 | golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= 64 | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 65 | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 66 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 67 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 68 | -------------------------------------------------------------------------------- /internal/ui/info_form.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Gu1llaum-3/sshm/internal/config" 6 | "strings" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | type infoFormModel struct { 13 | host *config.SSHHost 14 | styles Styles 15 | width int 16 | height int 17 | configFile string 18 | hostName string 19 | } 20 | 21 | // Messages for communication with parent model 22 | type infoFormEditMsg struct { 23 | hostName string 24 | } 25 | 26 | type infoFormCancelMsg struct{} 27 | 28 | // NewInfoForm creates a new info form model for displaying host details in read-only mode 29 | func NewInfoForm(hostName string, styles Styles, width, height int, configFile string) (*infoFormModel, error) { 30 | // Get the existing host configuration 31 | var host *config.SSHHost 32 | var err error 33 | 34 | if configFile != "" { 35 | host, err = config.GetSSHHostFromFile(hostName, configFile) 36 | } else { 37 | host, err = config.GetSSHHost(hostName) 38 | } 39 | 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | return &infoFormModel{ 45 | host: host, 46 | hostName: hostName, 47 | configFile: configFile, 48 | styles: styles, 49 | width: width, 50 | height: height, 51 | }, nil 52 | } 53 | 54 | func (m *infoFormModel) Init() tea.Cmd { 55 | return nil 56 | } 57 | 58 | func (m *infoFormModel) Update(msg tea.Msg) (*infoFormModel, tea.Cmd) { 59 | switch msg := msg.(type) { 60 | case tea.WindowSizeMsg: 61 | m.width = msg.Width 62 | m.height = msg.Height 63 | m.styles = NewStyles(m.width) 64 | return m, nil 65 | 66 | case tea.KeyMsg: 67 | switch msg.String() { 68 | case "ctrl+c", "esc", "q": 69 | return m, func() tea.Msg { return infoFormCancelMsg{} } 70 | 71 | case "e", "enter": 72 | // Switch to edit mode 73 | return m, func() tea.Msg { return infoFormEditMsg{hostName: m.hostName} } 74 | } 75 | } 76 | 77 | return m, nil 78 | } 79 | 80 | func (m *infoFormModel) View() string { 81 | var b strings.Builder 82 | 83 | // Title 84 | title := fmt.Sprintf("SSH Host Information: %s", m.host.Name) 85 | b.WriteString(m.styles.FormTitle.Render(title)) 86 | b.WriteString("\n\n") 87 | 88 | // Create info sections with consistent formatting 89 | sections := []struct { 90 | label string 91 | value string 92 | }{ 93 | {"Host Name", m.host.Name}, 94 | {"Config File", formatConfigFile(m.host.SourceFile)}, 95 | {"Hostname/IP", m.host.Hostname}, 96 | {"User", formatOptionalValue(m.host.User)}, 97 | {"Port", formatOptionalValue(m.host.Port)}, 98 | {"Identity File", formatOptionalValue(m.host.Identity)}, 99 | {"ProxyJump", formatOptionalValue(m.host.ProxyJump)}, 100 | {"SSH Options", formatSSHOptions(m.host.Options)}, 101 | {"Tags", formatTags(m.host.Tags)}, 102 | } 103 | 104 | // Render each section 105 | for _, section := range sections { 106 | // Label style 107 | labelStyle := lipgloss.NewStyle(). 108 | Bold(true). 109 | Foreground(lipgloss.Color("39")). // Bright blue 110 | Width(15). 111 | AlignHorizontal(lipgloss.Right) 112 | 113 | // Value style 114 | valueStyle := lipgloss.NewStyle(). 115 | Foreground(lipgloss.Color("255")) // White 116 | 117 | // If value is empty or default, use a muted style 118 | if section.value == "Not set" || section.value == "22" && section.label == "Port" { 119 | valueStyle = valueStyle.Foreground(lipgloss.Color("243")) // Gray 120 | } 121 | 122 | line := lipgloss.JoinHorizontal( 123 | lipgloss.Top, 124 | labelStyle.Render(section.label+":"), 125 | " ", 126 | valueStyle.Render(section.value), 127 | ) 128 | b.WriteString(line) 129 | b.WriteString("\n") 130 | } 131 | 132 | b.WriteString("\n") 133 | 134 | // Action instructions 135 | helpStyle := lipgloss.NewStyle(). 136 | Foreground(lipgloss.Color("243")). 137 | Italic(true) 138 | 139 | b.WriteString(helpStyle.Render("Actions:")) 140 | b.WriteString("\n") 141 | 142 | actionStyle := lipgloss.NewStyle(). 143 | Foreground(lipgloss.Color("120")). // Green 144 | Bold(true) 145 | 146 | b.WriteString(" ") 147 | b.WriteString(actionStyle.Render("e/Enter")) 148 | b.WriteString(helpStyle.Render(" - Switch to edit mode")) 149 | b.WriteString("\n") 150 | 151 | b.WriteString(" ") 152 | b.WriteString(actionStyle.Render("q/Esc")) 153 | b.WriteString(helpStyle.Render(" - Return to host list")) 154 | 155 | // Wrap in a border for better visual separation 156 | content := b.String() 157 | 158 | borderStyle := lipgloss.NewStyle(). 159 | Border(lipgloss.RoundedBorder()). 160 | BorderForeground(lipgloss.Color("39")). 161 | Padding(1). 162 | Margin(1) 163 | 164 | // Center the info window 165 | return lipgloss.Place( 166 | m.width, 167 | m.height, 168 | lipgloss.Center, 169 | lipgloss.Center, 170 | borderStyle.Render(content), 171 | ) 172 | } 173 | 174 | // Helper functions for formatting values 175 | 176 | func formatOptionalValue(value string) string { 177 | if value == "" { 178 | return "Not set" 179 | } 180 | return value 181 | } 182 | 183 | func formatSSHOptions(options string) string { 184 | if options == "" { 185 | return "Not set" 186 | } 187 | return options 188 | } 189 | 190 | func formatTags(tags []string) string { 191 | if len(tags) == 0 { 192 | return "Not set" 193 | } 194 | return strings.Join(tags, ", ") 195 | } 196 | 197 | // Standalone wrapper for info form (for testing or standalone use) 198 | type standaloneInfoForm struct { 199 | *infoFormModel 200 | } 201 | 202 | func (m standaloneInfoForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 203 | switch msg.(type) { 204 | case infoFormCancelMsg: 205 | return m, tea.Quit 206 | case infoFormEditMsg: 207 | // For standalone mode, just quit - parent should handle edit transition 208 | return m, tea.Quit 209 | } 210 | 211 | newForm, cmd := m.infoFormModel.Update(msg) 212 | m.infoFormModel = newForm 213 | return m, cmd 214 | } 215 | 216 | // RunInfoForm provides a standalone info form for testing 217 | func RunInfoForm(hostName string, configFile string) error { 218 | styles := NewStyles(80) 219 | infoForm, err := NewInfoForm(hostName, styles, 80, 24, configFile) 220 | if err != nil { 221 | return err 222 | } 223 | m := standaloneInfoForm{infoForm} 224 | 225 | p := tea.NewProgram(m, tea.WithAltScreen()) 226 | _, err = p.Run() 227 | return err 228 | } 229 | -------------------------------------------------------------------------------- /internal/history/port_forward_test.go: -------------------------------------------------------------------------------- 1 | package history 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | ) 8 | 9 | func TestPortForwardingHistory(t *testing.T) { 10 | // Create temporary directory for testing 11 | tempDir, err := os.MkdirTemp("", "sshm_test_*") 12 | if err != nil { 13 | t.Fatalf("Failed to create temp dir: %v", err) 14 | } 15 | defer os.RemoveAll(tempDir) 16 | 17 | // Create history manager with temp directory 18 | historyPath := filepath.Join(tempDir, "test_history.json") 19 | hm := &HistoryManager{ 20 | historyPath: historyPath, 21 | history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)}, 22 | } 23 | 24 | hostName := "test-server" 25 | 26 | // Test recording port forwarding configuration 27 | err = hm.RecordPortForwarding(hostName, "local", "8080", "localhost", "80", "127.0.0.1") 28 | if err != nil { 29 | t.Fatalf("Failed to record port forwarding: %v", err) 30 | } 31 | 32 | // Test retrieving port forwarding configuration 33 | config := hm.GetPortForwardingConfig(hostName) 34 | if config == nil { 35 | t.Fatalf("Expected port forwarding config to exist") 36 | } 37 | 38 | // Verify the saved configuration 39 | if config.Type != "local" { 40 | t.Errorf("Expected Type 'local', got %s", config.Type) 41 | } 42 | if config.LocalPort != "8080" { 43 | t.Errorf("Expected LocalPort '8080', got %s", config.LocalPort) 44 | } 45 | if config.RemoteHost != "localhost" { 46 | t.Errorf("Expected RemoteHost 'localhost', got %s", config.RemoteHost) 47 | } 48 | if config.RemotePort != "80" { 49 | t.Errorf("Expected RemotePort '80', got %s", config.RemotePort) 50 | } 51 | if config.BindAddress != "127.0.0.1" { 52 | t.Errorf("Expected BindAddress '127.0.0.1', got %s", config.BindAddress) 53 | } 54 | 55 | // Test updating configuration with different values 56 | err = hm.RecordPortForwarding(hostName, "remote", "3000", "app-server", "8000", "") 57 | if err != nil { 58 | t.Fatalf("Failed to record updated port forwarding: %v", err) 59 | } 60 | 61 | // Verify the updated configuration 62 | config = hm.GetPortForwardingConfig(hostName) 63 | if config == nil { 64 | t.Fatalf("Expected port forwarding config to exist after update") 65 | } 66 | 67 | if config.Type != "remote" { 68 | t.Errorf("Expected updated Type 'remote', got %s", config.Type) 69 | } 70 | if config.LocalPort != "3000" { 71 | t.Errorf("Expected updated LocalPort '3000', got %s", config.LocalPort) 72 | } 73 | if config.RemoteHost != "app-server" { 74 | t.Errorf("Expected updated RemoteHost 'app-server', got %s", config.RemoteHost) 75 | } 76 | if config.RemotePort != "8000" { 77 | t.Errorf("Expected updated RemotePort '8000', got %s", config.RemotePort) 78 | } 79 | if config.BindAddress != "" { 80 | t.Errorf("Expected updated BindAddress to be empty, got %s", config.BindAddress) 81 | } 82 | 83 | // Test dynamic forwarding 84 | err = hm.RecordPortForwarding(hostName, "dynamic", "1080", "", "", "0.0.0.0") 85 | if err != nil { 86 | t.Fatalf("Failed to record dynamic port forwarding: %v", err) 87 | } 88 | 89 | config = hm.GetPortForwardingConfig(hostName) 90 | if config == nil { 91 | t.Fatalf("Expected port forwarding config to exist for dynamic forwarding") 92 | } 93 | 94 | if config.Type != "dynamic" { 95 | t.Errorf("Expected Type 'dynamic', got %s", config.Type) 96 | } 97 | if config.LocalPort != "1080" { 98 | t.Errorf("Expected LocalPort '1080', got %s", config.LocalPort) 99 | } 100 | if config.RemoteHost != "" { 101 | t.Errorf("Expected RemoteHost to be empty for dynamic forwarding, got %s", config.RemoteHost) 102 | } 103 | if config.RemotePort != "" { 104 | t.Errorf("Expected RemotePort to be empty for dynamic forwarding, got %s", config.RemotePort) 105 | } 106 | if config.BindAddress != "0.0.0.0" { 107 | t.Errorf("Expected BindAddress '0.0.0.0', got %s", config.BindAddress) 108 | } 109 | } 110 | 111 | func TestPortForwardingHistoryPersistence(t *testing.T) { 112 | // Create temporary directory for testing 113 | tempDir, err := os.MkdirTemp("", "sshm_test_*") 114 | if err != nil { 115 | t.Fatalf("Failed to create temp dir: %v", err) 116 | } 117 | defer os.RemoveAll(tempDir) 118 | 119 | historyPath := filepath.Join(tempDir, "test_history.json") 120 | 121 | // Create first history manager and record data 122 | hm1 := &HistoryManager{ 123 | historyPath: historyPath, 124 | history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)}, 125 | } 126 | 127 | hostName := "persistent-server" 128 | err = hm1.RecordPortForwarding(hostName, "local", "9090", "db-server", "5432", "") 129 | if err != nil { 130 | t.Fatalf("Failed to record port forwarding: %v", err) 131 | } 132 | 133 | // Create second history manager and load data 134 | hm2 := &HistoryManager{ 135 | historyPath: historyPath, 136 | history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)}, 137 | } 138 | 139 | err = hm2.loadHistory() 140 | if err != nil { 141 | t.Fatalf("Failed to load history: %v", err) 142 | } 143 | 144 | // Verify the loaded configuration 145 | config := hm2.GetPortForwardingConfig(hostName) 146 | if config == nil { 147 | t.Fatalf("Expected port forwarding config to be loaded from file") 148 | } 149 | 150 | if config.Type != "local" { 151 | t.Errorf("Expected loaded Type 'local', got %s", config.Type) 152 | } 153 | if config.LocalPort != "9090" { 154 | t.Errorf("Expected loaded LocalPort '9090', got %s", config.LocalPort) 155 | } 156 | if config.RemoteHost != "db-server" { 157 | t.Errorf("Expected loaded RemoteHost 'db-server', got %s", config.RemoteHost) 158 | } 159 | if config.RemotePort != "5432" { 160 | t.Errorf("Expected loaded RemotePort '5432', got %s", config.RemotePort) 161 | } 162 | } 163 | 164 | func TestGetPortForwardingConfigNonExistent(t *testing.T) { 165 | // Create temporary directory for testing 166 | tempDir, err := os.MkdirTemp("", "sshm_test_*") 167 | if err != nil { 168 | t.Fatalf("Failed to create temp dir: %v", err) 169 | } 170 | defer os.RemoveAll(tempDir) 171 | 172 | historyPath := filepath.Join(tempDir, "test_history.json") 173 | hm := &HistoryManager{ 174 | historyPath: historyPath, 175 | history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)}, 176 | } 177 | 178 | // Test getting configuration for non-existent host 179 | config := hm.GetPortForwardingConfig("non-existent-host") 180 | if config != nil { 181 | t.Errorf("Expected nil config for non-existent host, got %+v", config) 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /internal/ui/view.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | ) 9 | 10 | // View renders the complete user interface 11 | func (m Model) View() string { 12 | if !m.ready { 13 | return "Loading..." 14 | } 15 | 16 | // Handle different view modes 17 | switch m.viewMode { 18 | case ViewAdd: 19 | if m.addForm != nil { 20 | return m.addForm.View() 21 | } 22 | case ViewEdit: 23 | if m.editForm != nil { 24 | return m.editForm.View() 25 | } 26 | case ViewMove: 27 | if m.moveForm != nil { 28 | return m.moveForm.View() 29 | } 30 | case ViewInfo: 31 | if m.infoForm != nil { 32 | return m.infoForm.View() 33 | } 34 | case ViewPortForward: 35 | if m.portForwardForm != nil { 36 | return m.portForwardForm.View() 37 | } 38 | case ViewHelp: 39 | if m.helpForm != nil { 40 | return m.helpForm.View() 41 | } 42 | case ViewFileSelector: 43 | if m.fileSelectorForm != nil { 44 | return m.fileSelectorForm.View() 45 | } 46 | case ViewList: 47 | return m.renderListView() 48 | } 49 | 50 | return m.renderListView() 51 | } 52 | 53 | // renderListView renders the main list interface 54 | func (m Model) renderListView() string { 55 | // Build the interface components 56 | components := []string{} 57 | 58 | // Add the ASCII title 59 | components = append(components, m.styles.Header.Render(asciiTitle)) 60 | 61 | // Add update notification if available (between title and search) 62 | if m.updateInfo != nil && m.updateInfo.Available { 63 | updateText := fmt.Sprintf("🚀 Update available: %s → %s", 64 | m.updateInfo.CurrentVer, 65 | m.updateInfo.LatestVer) 66 | 67 | updateStyle := lipgloss.NewStyle(). 68 | Foreground(lipgloss.Color("10")). // Green color 69 | Bold(true). 70 | Align(lipgloss.Center) // Center the notification 71 | 72 | components = append(components, updateStyle.Render(updateText)) 73 | } 74 | 75 | // Add error message if there's one to show 76 | if m.showingError && m.errorMessage != "" { 77 | errorStyle := lipgloss.NewStyle(). 78 | Foreground(lipgloss.Color("9")). // Red color 79 | Background(lipgloss.Color("1")). // Dark red background 80 | Bold(true). 81 | Padding(0, 1). 82 | Border(lipgloss.RoundedBorder()). 83 | BorderForeground(lipgloss.Color("9")). 84 | Align(lipgloss.Center) 85 | 86 | components = append(components, errorStyle.Render("❌ "+m.errorMessage)) 87 | } 88 | 89 | // Add the search bar with the appropriate style based on focus 90 | searchPrompt := "Search (/ to focus): " 91 | if m.searchMode { 92 | components = append(components, m.styles.SearchFocused.Render(searchPrompt+m.searchInput.View())) 93 | } else { 94 | components = append(components, m.styles.SearchUnfocused.Render(searchPrompt+m.searchInput.View())) 95 | } 96 | 97 | // Add the table with the appropriate style based on focus 98 | if m.searchMode { 99 | // The table is not focused, use the unfocused style 100 | components = append(components, m.styles.TableUnfocused.Render(m.table.View())) 101 | } else { 102 | // The table is focused, use the focused style with the primary color 103 | components = append(components, m.styles.TableFocused.Render(m.table.View())) 104 | } 105 | 106 | // Add the help text 107 | var helpText string 108 | if !m.searchMode { 109 | helpText = " ↑/↓: navigate • Enter: connect • p: ping all • i: info • h: help • q: quit" 110 | } else { 111 | helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit" 112 | } 113 | components = append(components, m.styles.HelpText.Render(helpText)) 114 | 115 | // Join all components vertically with appropriate spacing 116 | mainView := m.styles.App.Render( 117 | lipgloss.JoinVertical( 118 | lipgloss.Left, 119 | components..., 120 | ), 121 | ) 122 | 123 | // If in delete mode, overlay the confirmation dialog 124 | if m.deleteMode { 125 | // Combine the main view with the confirmation dialog overlay 126 | confirmation := m.renderDeleteConfirmation() 127 | 128 | // Center the confirmation dialog on the screen 129 | centeredConfirmation := lipgloss.Place( 130 | m.width, 131 | m.height, 132 | lipgloss.Center, 133 | lipgloss.Center, 134 | confirmation, 135 | ) 136 | 137 | return centeredConfirmation 138 | } 139 | 140 | return mainView 141 | } 142 | 143 | // renderDeleteConfirmation renders a clean delete confirmation dialog 144 | func (m Model) renderDeleteConfirmation() string { 145 | // Remove emojis (uncertain width depending on terminal) to stabilize the frame 146 | title := "DELETE SSH HOST" 147 | question := fmt.Sprintf("Are you sure you want to delete host '%s'?", m.deleteHost) 148 | action := "This action cannot be undone." 149 | help := "Enter: confirm • Esc: cancel" 150 | 151 | // Individual styles (do not affect width via internal centering) 152 | titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("196")) 153 | questionStyle := lipgloss.NewStyle() 154 | actionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("203")) 155 | helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) 156 | 157 | lines := []string{ 158 | titleStyle.Render(title), 159 | "", 160 | questionStyle.Render(question), 161 | "", 162 | actionStyle.Render(action), 163 | "", 164 | helpStyle.Render(help), 165 | } 166 | 167 | // Compute the real maximum width (ANSI-safe via lipgloss.Width) 168 | maxw := 0 169 | for _, ln := range lines { 170 | w := lipgloss.Width(ln) 171 | if w > maxw { 172 | maxw = w 173 | } 174 | } 175 | // Minimal width for aesthetics 176 | if maxw < 40 { 177 | maxw = 40 178 | } 179 | 180 | // Build the raw text block (without centering) then apply the container style 181 | raw := strings.Join(lines, "\n") 182 | 183 | // Container style: wider horizontal padding, stable border 184 | box := lipgloss.NewStyle(). 185 | Border(lipgloss.RoundedBorder()). 186 | BorderForeground(lipgloss.Color("196")). 187 | PaddingTop(1).PaddingBottom(1).PaddingLeft(2).PaddingRight(2). 188 | Width(maxw + 4) // +4 = internal margin (2 spaces of left/right padding) 189 | 190 | return box.Render(raw) 191 | } 192 | 193 | // renderUpdateNotification renders the update notification banner 194 | func (m Model) renderUpdateNotification() string { 195 | if m.updateInfo == nil || !m.updateInfo.Available { 196 | return "" 197 | } 198 | 199 | // Create the notification message 200 | message := fmt.Sprintf("🚀 Update available: %s → %s", 201 | m.updateInfo.CurrentVer, 202 | m.updateInfo.LatestVer) 203 | 204 | // Add release URL if available 205 | if m.updateInfo.ReleaseURL != "" { 206 | message += fmt.Sprintf(" • View release: %s", m.updateInfo.ReleaseURL) 207 | } 208 | 209 | // Style the notification with a bright color to make it stand out 210 | notificationStyle := lipgloss.NewStyle(). 211 | Foreground(lipgloss.Color("#00FF00")). // Bright green 212 | Bold(true). 213 | Padding(0, 1). 214 | Border(lipgloss.RoundedBorder()). 215 | BorderForeground(lipgloss.Color("#00AA00")) // Darker green border 216 | 217 | return notificationStyle.Render(message) 218 | } 219 | -------------------------------------------------------------------------------- /cmd/search.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/Gu1llaum-3/sshm/internal/config" 9 | 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var ( 14 | // outputFormat defines the output format (table, json, simple) 15 | outputFormat string 16 | // tagsOnly limits search to tags only 17 | tagsOnly bool 18 | // namesOnly limits search to host names only 19 | namesOnly bool 20 | ) 21 | 22 | var searchCmd = &cobra.Command{ 23 | Use: "search [query]", 24 | Short: "Search SSH hosts by name, hostname, or tags", 25 | Long: `Search through your SSH hosts configuration by name, hostname, or tags. 26 | The search is case-insensitive and will match partial strings. 27 | 28 | Examples: 29 | sshm search web # Search for hosts containing "web" 30 | sshm search --tags dev # Search only in tags for "dev" 31 | sshm search --names prod # Search only in host names for "prod" 32 | sshm search --format json server # Output results in JSON format`, 33 | Args: cobra.MaximumNArgs(1), 34 | Run: runSearch, 35 | } 36 | 37 | func runSearch(cmd *cobra.Command, args []string) { 38 | // Parse SSH configurations 39 | var hosts []config.SSHHost 40 | var err error 41 | 42 | if configFile != "" { 43 | hosts, err = config.ParseSSHConfigFile(configFile) 44 | } else { 45 | hosts, err = config.ParseSSHConfig() 46 | } 47 | 48 | if err != nil { 49 | fmt.Fprintf(os.Stderr, "Error reading SSH config file: %v\n", err) 50 | os.Exit(1) 51 | } 52 | 53 | if len(hosts) == 0 { 54 | fmt.Println("No SSH hosts found in your configuration file.") 55 | os.Exit(1) 56 | } 57 | 58 | // Get search query 59 | var query string 60 | if len(args) > 0 { 61 | query = args[0] 62 | } 63 | 64 | // Filter hosts based on search criteria 65 | filteredHosts := filterHosts(hosts, query, tagsOnly, namesOnly) 66 | 67 | // Display results 68 | if len(filteredHosts) == 0 { 69 | if query == "" { 70 | fmt.Println("No hosts found.") 71 | } else { 72 | fmt.Printf("No hosts found matching '%s'.\n", query) 73 | } 74 | return 75 | } 76 | 77 | // Output results in specified format 78 | switch outputFormat { 79 | case "json": 80 | outputJSON(filteredHosts) 81 | case "simple": 82 | outputSimple(filteredHosts) 83 | default: 84 | outputTable(filteredHosts) 85 | } 86 | } 87 | 88 | // filterHosts filters hosts according to the search query and options 89 | func filterHosts(hosts []config.SSHHost, query string, tagsOnly, namesOnly bool) []config.SSHHost { 90 | var filtered []config.SSHHost 91 | 92 | if query == "" { 93 | return hosts 94 | } 95 | 96 | query = strings.ToLower(query) 97 | 98 | for _, host := range hosts { 99 | matched := false 100 | 101 | // Search in names if not tags-only 102 | if !tagsOnly { 103 | // Check the host name 104 | if strings.Contains(strings.ToLower(host.Name), query) { 105 | matched = true 106 | } 107 | 108 | // Check the hostname if not names-only 109 | if !namesOnly && !matched && strings.Contains(strings.ToLower(host.Hostname), query) { 110 | matched = true 111 | } 112 | } 113 | 114 | // Search in tags if not names-only 115 | if !namesOnly && !matched { 116 | for _, tag := range host.Tags { 117 | if strings.Contains(strings.ToLower(tag), query) { 118 | matched = true 119 | break 120 | } 121 | } 122 | } 123 | 124 | if matched { 125 | filtered = append(filtered, host) 126 | } 127 | } 128 | 129 | return filtered 130 | } 131 | 132 | // outputTable displays results in a formatted table 133 | func outputTable(hosts []config.SSHHost) { 134 | if len(hosts) == 0 { 135 | return 136 | } 137 | 138 | // Calculate column widths 139 | nameWidth := 4 // "Name" 140 | hostWidth := 8 // "Hostname" 141 | userWidth := 4 // "User" 142 | tagsWidth := 4 // "Tags" 143 | 144 | for _, host := range hosts { 145 | if len(host.Name) > nameWidth { 146 | nameWidth = len(host.Name) 147 | } 148 | if len(host.Hostname) > hostWidth { 149 | hostWidth = len(host.Hostname) 150 | } 151 | if len(host.User) > userWidth { 152 | userWidth = len(host.User) 153 | } 154 | tagsStr := strings.Join(host.Tags, ", ") 155 | if len(tagsStr) > tagsWidth { 156 | tagsWidth = len(tagsStr) 157 | } 158 | } 159 | 160 | // Add padding 161 | nameWidth += 2 162 | hostWidth += 2 163 | userWidth += 2 164 | tagsWidth += 2 165 | 166 | // Print header 167 | fmt.Printf("%-*s %-*s %-*s %-*s\n", nameWidth, "Name", hostWidth, "Hostname", userWidth, "User", tagsWidth, "Tags") 168 | fmt.Printf("%s %s %s %s\n", 169 | strings.Repeat("-", nameWidth), 170 | strings.Repeat("-", hostWidth), 171 | strings.Repeat("-", userWidth), 172 | strings.Repeat("-", tagsWidth)) 173 | 174 | // Print hosts 175 | for _, host := range hosts { 176 | user := host.User 177 | if user == "" { 178 | user = "-" 179 | } 180 | tags := strings.Join(host.Tags, ", ") 181 | if tags == "" { 182 | tags = "-" 183 | } 184 | fmt.Printf("%-*s %-*s %-*s %-*s\n", nameWidth, host.Name, hostWidth, host.Hostname, userWidth, user, tagsWidth, tags) 185 | } 186 | 187 | fmt.Printf("\nFound %d host(s)\n", len(hosts)) 188 | } 189 | 190 | // outputSimple displays results in simple format (one per line) 191 | func outputSimple(hosts []config.SSHHost) { 192 | for _, host := range hosts { 193 | fmt.Println(host.Name) 194 | } 195 | } 196 | 197 | // outputJSON displays results in JSON format 198 | func outputJSON(hosts []config.SSHHost) { 199 | fmt.Println("[") 200 | for i, host := range hosts { 201 | fmt.Printf(" {\n") 202 | fmt.Printf(" \"name\": \"%s\",\n", escapeJSON(host.Name)) 203 | fmt.Printf(" \"hostname\": \"%s\",\n", escapeJSON(host.Hostname)) 204 | fmt.Printf(" \"user\": \"%s\",\n", escapeJSON(host.User)) 205 | fmt.Printf(" \"port\": \"%s\",\n", escapeJSON(host.Port)) 206 | fmt.Printf(" \"identity\": \"%s\",\n", escapeJSON(host.Identity)) 207 | fmt.Printf(" \"proxy_jump\": \"%s\",\n", escapeJSON(host.ProxyJump)) 208 | fmt.Printf(" \"options\": \"%s\",\n", escapeJSON(host.Options)) 209 | fmt.Printf(" \"tags\": [") 210 | for j, tag := range host.Tags { 211 | fmt.Printf("\"%s\"", escapeJSON(tag)) 212 | if j < len(host.Tags)-1 { 213 | fmt.Printf(", ") 214 | } 215 | } 216 | fmt.Printf("]\n") 217 | if i < len(hosts)-1 { 218 | fmt.Printf(" },\n") 219 | } else { 220 | fmt.Printf(" }\n") 221 | } 222 | } 223 | fmt.Println("]") 224 | } 225 | 226 | // escapeJSON escapes special characters for JSON output 227 | func escapeJSON(s string) string { 228 | s = strings.ReplaceAll(s, "\\", "\\\\") 229 | s = strings.ReplaceAll(s, "\"", "\\\"") 230 | s = strings.ReplaceAll(s, "\n", "\\n") 231 | s = strings.ReplaceAll(s, "\r", "\\r") 232 | s = strings.ReplaceAll(s, "\t", "\\t") 233 | return s 234 | } 235 | 236 | func init() { 237 | // Add search command to root 238 | RootCmd.AddCommand(searchCmd) 239 | 240 | // Add flags 241 | searchCmd.Flags().StringVarP(&outputFormat, "format", "f", "table", "Output format (table, json, simple)") 242 | searchCmd.Flags().BoolVar(&tagsOnly, "tags", false, "Search only in tags") 243 | searchCmd.Flags().BoolVar(&namesOnly, "names", false, "Search only in host names") 244 | } 245 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "strings" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/Gu1llaum-3/sshm/internal/config" 14 | "github.com/Gu1llaum-3/sshm/internal/history" 15 | "github.com/Gu1llaum-3/sshm/internal/ui" 16 | "github.com/Gu1llaum-3/sshm/internal/version" 17 | 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | // AppVersion will be set at build time via -ldflags 22 | var AppVersion = "dev" 23 | 24 | // configFile holds the path to the SSH config file 25 | var configFile string 26 | 27 | // RootCmd is the base command when called without any subcommands 28 | var RootCmd = &cobra.Command{ 29 | Use: "sshm [host]", 30 | Short: "SSH Manager - A modern SSH connection manager", 31 | Long: `SSHM is a modern SSH manager for your terminal. 32 | 33 | Main usage: 34 | Running 'sshm' (without arguments) opens the interactive TUI window to browse, search, and connect to your SSH hosts graphically. 35 | Running 'sshm ' connects directly to the specified host and records the connection in your history. 36 | 37 | You can also use sshm in CLI mode for other operations like adding, editing, or searching hosts. 38 | 39 | Hosts are read from your ~/.ssh/config file by default.`, 40 | Version: AppVersion, 41 | Args: cobra.ArbitraryArgs, 42 | SilenceUsage: true, 43 | SilenceErrors: true, // We'll handle errors ourselves 44 | RunE: func(cmd *cobra.Command, args []string) error { 45 | // If no arguments provided, run interactive mode 46 | if len(args) == 0 { 47 | runInteractiveMode() 48 | return nil 49 | } 50 | 51 | // If a host name is provided, connect directly 52 | hostName := args[0] 53 | connectToHost(hostName) 54 | return nil 55 | }, 56 | } 57 | 58 | func runInteractiveMode() { 59 | // Parse SSH configurations 60 | var hosts []config.SSHHost 61 | var err error 62 | 63 | if configFile != "" { 64 | hosts, err = config.ParseSSHConfigFile(configFile) 65 | } else { 66 | hosts, err = config.ParseSSHConfig() 67 | } 68 | 69 | if err != nil { 70 | log.Fatalf("Error reading SSH config file: %v", err) 71 | } 72 | 73 | if len(hosts) == 0 { 74 | fmt.Println("No SSH hosts found in your ~/.ssh/config file.") 75 | fmt.Print("Would you like to add a new host now? [y/N]: ") 76 | var response string 77 | _, err := fmt.Scanln(&response) 78 | if err == nil && (response == "y" || response == "Y") { 79 | err := ui.RunAddForm("", configFile) 80 | if err != nil { 81 | fmt.Printf("Error adding host: %v\n", err) 82 | } 83 | // After adding, try to reload hosts and continue if any exist 84 | if configFile != "" { 85 | hosts, err = config.ParseSSHConfigFile(configFile) 86 | } else { 87 | hosts, err = config.ParseSSHConfig() 88 | } 89 | if err != nil || len(hosts) == 0 { 90 | fmt.Println("No hosts available, exiting.") 91 | os.Exit(1) 92 | } 93 | } else { 94 | fmt.Println("No hosts available, exiting.") 95 | os.Exit(1) 96 | } 97 | } 98 | 99 | // Run the interactive TUI 100 | if err := ui.RunInteractiveMode(hosts, configFile, AppVersion); err != nil { 101 | log.Fatalf("Error running interactive mode: %v", err) 102 | } 103 | } 104 | 105 | func connectToHost(hostName string) { 106 | // Quick check if host exists without full parsing (optimized for connection) 107 | var hostFound bool 108 | var err error 109 | 110 | if configFile != "" { 111 | hostFound, err = config.QuickHostExistsInFile(hostName, configFile) 112 | } else { 113 | hostFound, err = config.QuickHostExists(hostName) 114 | } 115 | 116 | if err != nil { 117 | log.Fatalf("Error checking SSH config: %v", err) 118 | } 119 | 120 | if !hostFound { 121 | fmt.Printf("Error: Host '%s' not found in SSH configuration.\n", hostName) 122 | fmt.Println("Use 'sshm' to see available hosts.") 123 | os.Exit(1) 124 | } 125 | 126 | // Record the connection in history 127 | historyManager, err := history.NewHistoryManager() 128 | if err != nil { 129 | // Log the error but don't prevent the connection 130 | fmt.Printf("Warning: Could not initialize connection history: %v\n", err) 131 | } else { 132 | err = historyManager.RecordConnection(hostName) 133 | if err != nil { 134 | // Log the error but don't prevent the connection 135 | fmt.Printf("Warning: Could not record connection history: %v\n", err) 136 | } 137 | } 138 | 139 | // Build and execute the SSH command 140 | fmt.Printf("Connecting to %s...\n", hostName) 141 | 142 | var sshCmd *exec.Cmd 143 | var args []string 144 | 145 | if configFile != "" { 146 | args = append(args, "-F", configFile) 147 | } 148 | args = append(args, hostName) 149 | 150 | // Note: We don't add RemoteCommand here because if it's configured in SSH config, 151 | // SSH will handle it automatically. Adding it as a command line argument would conflict. 152 | 153 | sshCmd = exec.Command("ssh", args...) 154 | 155 | // Set up the command to use the same stdin, stdout, and stderr as the parent process 156 | sshCmd.Stdin = os.Stdin 157 | sshCmd.Stdout = os.Stdout 158 | sshCmd.Stderr = os.Stderr 159 | 160 | // Execute the SSH command 161 | err = sshCmd.Run() 162 | if err != nil { 163 | if exitError, ok := err.(*exec.ExitError); ok { 164 | // SSH command failed, exit with the same code 165 | if status, ok := exitError.Sys().(syscall.WaitStatus); ok { 166 | os.Exit(status.ExitStatus()) 167 | } 168 | } 169 | fmt.Printf("Error executing SSH command: %v\n", err) 170 | os.Exit(1) 171 | } 172 | } 173 | 174 | // getVersionWithUpdateCheck returns a custom version string with update check 175 | func getVersionWithUpdateCheck() string { 176 | versionText := fmt.Sprintf("sshm version %s", AppVersion) 177 | 178 | // Check for updates 179 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 180 | defer cancel() 181 | 182 | updateInfo, err := version.CheckForUpdates(ctx, AppVersion) 183 | if err != nil { 184 | // Return just version if check fails 185 | return versionText + "\n" 186 | } 187 | 188 | if updateInfo != nil && updateInfo.Available { 189 | versionText += fmt.Sprintf("\n🚀 Update available: %s → %s (%s)", 190 | updateInfo.CurrentVer, 191 | updateInfo.LatestVer, 192 | updateInfo.ReleaseURL) 193 | } 194 | 195 | return versionText + "\n" 196 | } 197 | 198 | // Execute adds all child commands to the root command and sets flags appropriately. 199 | func Execute() { 200 | // Custom error handling for unknown commands that might be host names 201 | if err := RootCmd.Execute(); err != nil { 202 | // Check if this is an "unknown command" error and the argument might be a host name 203 | errStr := err.Error() 204 | if strings.Contains(errStr, "unknown command") { 205 | // Extract the command name from the error 206 | parts := strings.Split(errStr, "\"") 207 | if len(parts) >= 2 { 208 | potentialHost := parts[1] 209 | // Try to connect to this as a host 210 | connectToHost(potentialHost) 211 | return 212 | } 213 | } 214 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 215 | os.Exit(1) 216 | } 217 | } 218 | 219 | func init() { 220 | // Add the config file flag 221 | RootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)") 222 | 223 | // Set custom version template with update check 224 | RootCmd.SetVersionTemplate(getVersionWithUpdateCheck()) 225 | } 226 | -------------------------------------------------------------------------------- /install/unix.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | INSTALL_DIR="/usr/local/bin" 4 | EXECUTABLE_NAME=sshm 5 | EXECUTABLE_PATH="$INSTALL_DIR/$EXECUTABLE_NAME" 6 | USE_SUDO="false" 7 | OS="" 8 | ARCH="" 9 | FORCE_INSTALL="${FORCE_INSTALL:-false}" 10 | SSHM_VERSION="${SSHM_VERSION:-latest}" 11 | 12 | RED='\033[0;31m' 13 | PURPLE='\033[0;35m' 14 | GREEN='\033[0;32m' 15 | YELLOW='\033[1;33m' 16 | NC='\033[0m' 17 | 18 | usage() { 19 | printf "${PURPLE}SSHM Installation Script${NC}\n\n" 20 | printf "Usage:\n" 21 | printf " Default (latest stable): ${GREEN}bash install.sh${NC}\n" 22 | printf " Specific version: ${GREEN}SSHM_VERSION=v1.8.0 bash install.sh${NC}\n" 23 | printf " Beta/pre-release: ${GREEN}SSHM_VERSION=v1.8.1-beta bash install.sh${NC}\n" 24 | printf " Force install: ${GREEN}FORCE_INSTALL=true bash install.sh${NC}\n" 25 | printf " Custom install directory: ${GREEN}INSTALL_DIR=/opt/bin bash install.sh${NC}\n\n" 26 | printf "Environment variables:\n" 27 | printf " SSHM_VERSION - Version to install (default: latest)\n" 28 | printf " FORCE_INSTALL - Skip confirmation prompts (default: false)\n" 29 | printf " INSTALL_DIR - Installation directory (default: /usr/local/bin)\n\n" 30 | } 31 | 32 | setSystem() { 33 | ARCH=$(uname -m) 34 | case $ARCH in 35 | i386|i686) ARCH="amd64" ;; 36 | x86_64) ARCH="amd64";; 37 | armv6*) ARCH="armv6" ;; 38 | armv7*) ARCH="armv7" ;; 39 | aarch64*) ARCH="arm64" ;; 40 | arm64) ARCH="arm64" ;; 41 | esac 42 | 43 | OS=$(echo `uname`|tr '[:upper:]' '[:lower:]') 44 | 45 | # Determine if we need sudo 46 | if [ "$OS" = "linux" ]; then 47 | USE_SUDO="true" 48 | fi 49 | if [ "$OS" = "darwin" ]; then 50 | USE_SUDO="true" 51 | fi 52 | } 53 | 54 | runAsRoot() { 55 | local CMD="$*" 56 | if [ "$USE_SUDO" = "true" ]; then 57 | printf "${PURPLE}We need sudo access to install SSHM to $INSTALL_DIR ${NC}\n" 58 | CMD="sudo $CMD" 59 | fi 60 | $CMD 61 | } 62 | 63 | getLatestVersion() { 64 | if [ "$SSHM_VERSION" = "latest" ]; then 65 | printf "${YELLOW}Fetching latest stable version...${NC}\n" 66 | LATEST_VERSION=$(curl -s https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') 67 | if [ -z "$LATEST_VERSION" ]; then 68 | printf "${RED}Failed to fetch latest version${NC}\n" 69 | exit 1 70 | fi 71 | else 72 | printf "${YELLOW}Using specified version: $SSHM_VERSION${NC}\n" 73 | # Validate that the specified version exists 74 | RELEASE_CHECK=$(curl -s "https://api.github.com/repos/Gu1llaum-3/sshm/releases/tags/$SSHM_VERSION" | grep '"tag_name":') 75 | if [ -z "$RELEASE_CHECK" ]; then 76 | printf "${RED}Version $SSHM_VERSION not found. Available versions:${NC}\n" 77 | curl -s https://api.github.com/repos/Gu1llaum-3/sshm/releases | grep '"tag_name":' | head -10 | sed -E 's/.*"([^"]+)".*/ - \1/' 78 | exit 1 79 | fi 80 | LATEST_VERSION="$SSHM_VERSION" 81 | fi 82 | printf "${GREEN}Installing version: $LATEST_VERSION${NC}\n" 83 | } 84 | 85 | downloadBinary() { 86 | # Map OS names to match GoReleaser format 87 | local GORELEASER_OS="$OS" 88 | case $OS in 89 | "darwin") GORELEASER_OS="Darwin" ;; 90 | "linux") GORELEASER_OS="Linux" ;; 91 | "windows") GORELEASER_OS="Windows" ;; 92 | esac 93 | 94 | # Map architecture names to match GoReleaser format 95 | local GORELEASER_ARCH="$ARCH" 96 | case $ARCH in 97 | "amd64") GORELEASER_ARCH="x86_64" ;; 98 | "arm64") GORELEASER_ARCH="arm64" ;; 99 | "386") GORELEASER_ARCH="i386" ;; 100 | "armv6") GORELEASER_ARCH="armv6" ;; 101 | "armv7") GORELEASER_ARCH="armv7" ;; 102 | esac 103 | 104 | # GoReleaser format: sshm_Linux_armv7.tar.gz 105 | GITHUB_FILE="sshm_${GORELEASER_OS}_${GORELEASER_ARCH}.tar.gz" 106 | GITHUB_URL="https://github.com/Gu1llaum-3/sshm/releases/download/$LATEST_VERSION/$GITHUB_FILE" 107 | 108 | printf "${YELLOW}Downloading $GITHUB_FILE...${NC}\n" 109 | curl -L "$GITHUB_URL" --progress-bar --output "sshm-tmp.tar.gz" 110 | 111 | if [ $? -ne 0 ]; then 112 | printf "${RED}Failed to download binary${NC}\n" 113 | exit 1 114 | fi 115 | 116 | # Extract the binary 117 | tar -xzf "sshm-tmp.tar.gz" 118 | if [ $? -ne 0 ]; then 119 | printf "${RED}Failed to extract binary${NC}\n" 120 | exit 1 121 | fi 122 | 123 | # GoReleaser extracts the binary as just "sshm", not with the platform suffix 124 | EXTRACTED_BINARY="./sshm" 125 | if [ ! -f "$EXTRACTED_BINARY" ]; then 126 | printf "${RED}Could not find extracted binary: $EXTRACTED_BINARY${NC}\n" 127 | exit 1 128 | fi 129 | 130 | mv "$EXTRACTED_BINARY" "sshm-tmp" 131 | rm -f "sshm-tmp.tar.gz" 132 | } 133 | 134 | install() { 135 | printf "${YELLOW}Installing SSHM...${NC}\n" 136 | 137 | # Backup old version if it exists to prevent interference during installation 138 | OLD_BACKUP="" 139 | if [ -f "$EXECUTABLE_PATH" ]; then 140 | OLD_BACKUP="$EXECUTABLE_PATH.backup.$$" 141 | runAsRoot mv "$EXECUTABLE_PATH" "$OLD_BACKUP" 142 | fi 143 | 144 | chmod +x "sshm-tmp" 145 | if [ $? -ne 0 ]; then 146 | printf "${RED}Failed to set permissions${NC}\n" 147 | # Restore backup if installation fails 148 | if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then 149 | runAsRoot mv "$OLD_BACKUP" "$EXECUTABLE_PATH" 150 | fi 151 | exit 1 152 | fi 153 | 154 | runAsRoot mv "sshm-tmp" "$EXECUTABLE_PATH" 155 | if [ $? -ne 0 ]; then 156 | printf "${RED}Failed to install binary${NC}\n" 157 | # Restore backup if installation fails 158 | if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then 159 | runAsRoot mv "$OLD_BACKUP" "$EXECUTABLE_PATH" 160 | fi 161 | exit 1 162 | fi 163 | 164 | # Clean up backup if installation succeeded 165 | if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then 166 | runAsRoot rm -f "$OLD_BACKUP" 167 | fi 168 | } 169 | 170 | cleanup() { 171 | rm -f "sshm-tmp" "sshm-tmp.tar.gz" "sshm-${OS}-${ARCH}" 172 | } 173 | 174 | checkExisting() { 175 | if command -v sshm >/dev/null 2>&1; then 176 | CURRENT_VERSION=$(sshm --version 2>/dev/null | grep -o 'version.*' | cut -d' ' -f2 || echo "unknown") 177 | printf "${YELLOW}SSHM is already installed (version: $CURRENT_VERSION)${NC}\n" 178 | 179 | # Check if FORCE_INSTALL is set 180 | if [ "$FORCE_INSTALL" = "true" ]; then 181 | printf "${GREEN}Force install enabled, proceeding with installation...${NC}\n" 182 | return 183 | fi 184 | 185 | # Check if running via pipe (stdin is not a terminal) 186 | if [ ! -t 0 ]; then 187 | printf "${YELLOW}Running via pipe - automatically proceeding with installation...${NC}\n" 188 | printf "${YELLOW}Use 'FORCE_INSTALL=false bash -c \"\$(curl -sSL ...)\"' to disable auto-install${NC}\n" 189 | return 190 | fi 191 | 192 | printf "${YELLOW}Do you want to overwrite it? [y/N]: ${NC}" 193 | read -r response 194 | case "$response" in 195 | [yY][eE][sS]|[yY]) 196 | printf "${GREEN}Proceeding with installation...${NC}\n" 197 | ;; 198 | *) 199 | printf "${GREEN}Installation cancelled.${NC}\n" 200 | exit 0 201 | ;; 202 | esac 203 | fi 204 | } 205 | 206 | main() { 207 | # Check for help argument 208 | if [ "$1" = "-h" ] || [ "$1" = "--help" ] || [ "$1" = "help" ]; then 209 | usage 210 | exit 0 211 | fi 212 | 213 | printf "${PURPLE}Installing SSHM - SSH Connection Manager${NC}\n\n" 214 | 215 | # Set up system detection 216 | setSystem 217 | printf "${GREEN}Detected system: $OS ($ARCH)${NC}\n" 218 | 219 | # Get and validate version FIRST (this can fail early) 220 | getLatestVersion 221 | 222 | # Check if already installed (this might prompt user) 223 | checkExisting 224 | 225 | # Download and install 226 | downloadBinary 227 | install 228 | cleanup 229 | 230 | printf "\n${GREEN}✅ SSHM was installed successfully to: ${NC}$EXECUTABLE_PATH\n" 231 | printf "${GREEN}You can now use 'sshm' command to manage your SSH connections!${NC}\n\n" 232 | 233 | # Show version 234 | printf "${YELLOW}Verifying installation...${NC}\n" 235 | if command -v sshm >/dev/null 2>&1; then 236 | # Use the full path to ensure we're using the newly installed version 237 | "$EXECUTABLE_PATH" --version 2>/dev/null || echo "Version check failed, but installation completed" 238 | else 239 | printf "${RED}Warning: 'sshm' command not found in PATH. You may need to restart your terminal or add $INSTALL_DIR to your PATH.${NC}\n" 240 | fi 241 | } 242 | 243 | # Trap to cleanup on exit 244 | trap cleanup EXIT 245 | 246 | main "$@" 247 | -------------------------------------------------------------------------------- /internal/ui/search_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/Gu1llaum-3/sshm/internal/config" 7 | "github.com/charmbracelet/bubbles/table" 8 | "github.com/charmbracelet/bubbles/textinput" 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | // createTestModel creates a model with test data for testing 13 | func createTestModel() Model { 14 | hosts := []config.SSHHost{ 15 | {Name: "server1", Hostname: "server1.example.com", User: "user1"}, 16 | {Name: "server2", Hostname: "server2.example.com", User: "user2"}, 17 | {Name: "server3", Hostname: "server3.example.com", User: "user3"}, 18 | {Name: "web-server", Hostname: "web.example.com", User: "webuser"}, 19 | {Name: "db-server", Hostname: "db.example.com", User: "dbuser"}, 20 | } 21 | 22 | m := Model{ 23 | hosts: hosts, 24 | filteredHosts: hosts, 25 | searchInput: textinput.New(), 26 | table: table.New(), 27 | searchMode: false, 28 | ready: true, 29 | width: 80, 30 | height: 24, 31 | styles: NewStyles(80), 32 | } 33 | 34 | // Initialize table with test data 35 | m.updateTableColumns() 36 | m.updateTableRows() 37 | 38 | return m 39 | } 40 | 41 | func TestSearchModeToggle(t *testing.T) { 42 | m := createTestModel() 43 | 44 | // Initially should not be in search mode 45 | if m.searchMode { 46 | t.Error("Model should not start in search mode") 47 | } 48 | 49 | // Simulate pressing "/" to enter search mode 50 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} 51 | newModel, _ := m.Update(keyMsg) 52 | m = newModel.(Model) 53 | 54 | // Should now be in search mode 55 | if !m.searchMode { 56 | t.Error("Model should be in search mode after pressing '/'") 57 | } 58 | 59 | // The search input should be focused 60 | if !m.searchInput.Focused() { 61 | t.Error("Search input should be focused in search mode") 62 | } 63 | } 64 | 65 | func TestSearchFiltering(t *testing.T) { 66 | m := createTestModel() 67 | 68 | // Enter search mode 69 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} 70 | newModel, _ := m.Update(keyMsg) 71 | m = newModel.(Model) 72 | 73 | // Type "server" in search 74 | for _, char := range "server" { 75 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}} 76 | newModel, _ := m.Update(keyMsg) 77 | m = newModel.(Model) 78 | } 79 | 80 | // Should filter to only hosts containing "server" 81 | expectedHosts := []string{"server1", "server2", "server3", "web-server", "db-server"} 82 | if len(m.filteredHosts) != len(expectedHosts) { 83 | t.Errorf("Expected %d filtered hosts, got %d", len(expectedHosts), len(m.filteredHosts)) 84 | } 85 | 86 | // Check that all filtered hosts contain "server" 87 | for _, host := range m.filteredHosts { 88 | found := false 89 | for _, expected := range expectedHosts { 90 | if host.Name == expected { 91 | found = true 92 | break 93 | } 94 | } 95 | if !found { 96 | t.Errorf("Unexpected host in filtered results: %s", host.Name) 97 | } 98 | } 99 | } 100 | 101 | func TestSearchFilteringSpecific(t *testing.T) { 102 | m := createTestModel() 103 | 104 | // Enter search mode 105 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} 106 | newModel, _ := m.Update(keyMsg) 107 | m = newModel.(Model) 108 | 109 | // Type "web" in search 110 | for _, char := range "web" { 111 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}} 112 | newModel, _ := m.Update(keyMsg) 113 | m = newModel.(Model) 114 | } 115 | 116 | // Should filter to only hosts containing "web" 117 | if len(m.filteredHosts) != 1 { 118 | t.Errorf("Expected 1 filtered host, got %d", len(m.filteredHosts)) 119 | } 120 | 121 | if len(m.filteredHosts) > 0 && m.filteredHosts[0].Name != "web-server" { 122 | t.Errorf("Expected 'web-server', got '%s'", m.filteredHosts[0].Name) 123 | } 124 | } 125 | 126 | func TestSearchClearReturnToOriginal(t *testing.T) { 127 | m := createTestModel() 128 | originalHostCount := len(m.hosts) 129 | 130 | // Enter search mode and type something 131 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} 132 | newModel, _ := m.Update(keyMsg) 133 | m = newModel.(Model) 134 | 135 | // Type "web" in search 136 | for _, char := range "web" { 137 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}} 138 | newModel, _ := m.Update(keyMsg) 139 | m = newModel.(Model) 140 | } 141 | 142 | // Should have filtered results 143 | if len(m.filteredHosts) >= originalHostCount { 144 | t.Error("Search should have filtered down the results") 145 | } 146 | 147 | // Clear the search by simulating backspace 148 | for i := 0; i < 3; i++ { // "web" is 3 characters 149 | keyMsg := tea.KeyMsg{Type: tea.KeyBackspace} 150 | newModel, _ := m.Update(keyMsg) 151 | m = newModel.(Model) 152 | } 153 | 154 | // Should return to all hosts 155 | if len(m.filteredHosts) != originalHostCount { 156 | t.Errorf("Expected %d hosts after clearing search, got %d", originalHostCount, len(m.filteredHosts)) 157 | } 158 | } 159 | 160 | func TestCursorPositionAfterFiltering(t *testing.T) { 161 | m := createTestModel() 162 | 163 | // Move cursor down to position 2 (third item) 164 | m.table.SetCursor(2) 165 | initialCursor := m.table.Cursor() 166 | 167 | if initialCursor != 2 { 168 | t.Errorf("Expected cursor at position 2, got %d", initialCursor) 169 | } 170 | 171 | // Enter search mode 172 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} 173 | newModel, _ := m.Update(keyMsg) 174 | m = newModel.(Model) 175 | 176 | // Type "web" - this will filter to only 1 result 177 | for _, char := range "web" { 178 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}} 179 | newModel, _ := m.Update(keyMsg) 180 | m = newModel.(Model) 181 | } 182 | 183 | // Cursor should be reset to 0 since filtered results has only 1 item 184 | // and cursor position 2 would be out of bounds 185 | if len(m.filteredHosts) == 1 && m.table.Cursor() != 0 { 186 | t.Errorf("Expected cursor to be reset to 0 when filtered results are smaller, got %d", m.table.Cursor()) 187 | } 188 | } 189 | 190 | func TestTabSwitchBetweenSearchAndTable(t *testing.T) { 191 | m := createTestModel() 192 | 193 | // Enter search mode 194 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} 195 | newModel, _ := m.Update(keyMsg) 196 | m = newModel.(Model) 197 | 198 | if !m.searchMode { 199 | t.Error("Should be in search mode") 200 | } 201 | 202 | // Press Tab to switch to table 203 | keyMsg = tea.KeyMsg{Type: tea.KeyTab} 204 | newModel, _ = m.Update(keyMsg) 205 | m = newModel.(Model) 206 | 207 | if m.searchMode { 208 | t.Error("Should not be in search mode after Tab") 209 | } 210 | 211 | // Press Tab again to switch back to search 212 | keyMsg = tea.KeyMsg{Type: tea.KeyTab} 213 | newModel, _ = m.Update(keyMsg) 214 | m = newModel.(Model) 215 | 216 | if !m.searchMode { 217 | t.Error("Should be in search mode after second Tab") 218 | } 219 | } 220 | 221 | func TestEnterExitsSearchMode(t *testing.T) { 222 | m := createTestModel() 223 | 224 | // Enter search mode 225 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} 226 | newModel, _ := m.Update(keyMsg) 227 | m = newModel.(Model) 228 | 229 | if !m.searchMode { 230 | t.Error("Should be in search mode") 231 | } 232 | 233 | // Press Enter to exit search mode 234 | keyMsg = tea.KeyMsg{Type: tea.KeyEnter} 235 | newModel, _ = m.Update(keyMsg) 236 | m = newModel.(Model) 237 | 238 | if m.searchMode { 239 | t.Error("Should not be in search mode after Enter") 240 | } 241 | } 242 | 243 | func TestSearchModeDoesNotTriggerOnEmptyInput(t *testing.T) { 244 | m := createTestModel() 245 | originalHostCount := len(m.hosts) 246 | 247 | // Enter search mode 248 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} 249 | newModel, _ := m.Update(keyMsg) 250 | m = newModel.(Model) 251 | 252 | // At this point, filteredHosts should still be the same as the original hosts 253 | // because entering search mode should not trigger filtering with empty input 254 | if len(m.filteredHosts) != originalHostCount { 255 | t.Errorf("Expected %d hosts when entering search mode, got %d", originalHostCount, len(m.filteredHosts)) 256 | } 257 | } 258 | 259 | func TestSearchByHostname(t *testing.T) { 260 | m := createTestModel() 261 | 262 | // Enter search mode 263 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} 264 | newModel, _ := m.Update(keyMsg) 265 | m = newModel.(Model) 266 | 267 | // Search by hostname part "example.com" 268 | searchTerm := "example.com" 269 | for _, char := range searchTerm { 270 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}} 271 | newModel, _ := m.Update(keyMsg) 272 | m = newModel.(Model) 273 | } 274 | 275 | // All hosts should match since they all have "example.com" in hostname 276 | if len(m.filteredHosts) != len(m.hosts) { 277 | t.Errorf("Expected all %d hosts to match hostname search, got %d", len(m.hosts), len(m.filteredHosts)) 278 | } 279 | } 280 | 281 | func TestSearchByUser(t *testing.T) { 282 | m := createTestModel() 283 | 284 | // Enter search mode 285 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} 286 | newModel, _ := m.Update(keyMsg) 287 | m = newModel.(Model) 288 | 289 | // Search by user "user1" 290 | searchTerm := "user1" 291 | for _, char := range searchTerm { 292 | keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}} 293 | newModel, _ := m.Update(keyMsg) 294 | m = newModel.(Model) 295 | } 296 | 297 | // Only server1 should match 298 | if len(m.filteredHosts) != 1 { 299 | t.Errorf("Expected 1 host to match user search, got %d", len(m.filteredHosts)) 300 | } 301 | 302 | if len(m.filteredHosts) > 0 && m.filteredHosts[0].Name != "server1" { 303 | t.Errorf("Expected 'server1' to match user search, got '%s'", m.filteredHosts[0].Name) 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /internal/history/history.go: -------------------------------------------------------------------------------- 1 | package history 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | "sort" 8 | "time" 9 | 10 | "github.com/Gu1llaum-3/sshm/internal/config" 11 | ) 12 | 13 | // ConnectionHistory represents the history of SSH connections 14 | type ConnectionHistory struct { 15 | Connections map[string]ConnectionInfo `json:"connections"` 16 | } 17 | 18 | // PortForwardConfig stores port forwarding configuration 19 | type PortForwardConfig struct { 20 | Type string `json:"type"` // "local", "remote", "dynamic" 21 | LocalPort string `json:"local_port"` 22 | RemoteHost string `json:"remote_host"` 23 | RemotePort string `json:"remote_port"` 24 | BindAddress string `json:"bind_address"` 25 | } 26 | 27 | // ConnectionInfo stores information about a specific connection 28 | type ConnectionInfo struct { 29 | HostName string `json:"host_name"` 30 | LastConnect time.Time `json:"last_connect"` 31 | ConnectCount int `json:"connect_count"` 32 | PortForwarding *PortForwardConfig `json:"port_forwarding,omitempty"` 33 | } 34 | 35 | // HistoryManager manages the connection history 36 | type HistoryManager struct { 37 | historyPath string 38 | history *ConnectionHistory 39 | } 40 | 41 | // NewHistoryManager creates a new history manager 42 | func NewHistoryManager() (*HistoryManager, error) { 43 | configDir, err := config.GetSSHMConfigDir() 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | // Ensure config dir exists 49 | if err := os.MkdirAll(configDir, 0755); err != nil { 50 | return nil, err 51 | } 52 | 53 | historyPath := filepath.Join(configDir, "sshm_history.json") 54 | 55 | // Migration: check if old history file exists and migrate it 56 | if err := migrateOldHistoryFile(historyPath); err != nil { 57 | // Don't fail if migration fails, just log it 58 | // In a production environment, you might want to log this properly 59 | } 60 | 61 | hm := &HistoryManager{ 62 | historyPath: historyPath, 63 | history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)}, 64 | } 65 | 66 | // Load existing history if it exists 67 | err = hm.loadHistory() 68 | if err != nil { 69 | // If file doesn't exist, that's okay - we'll create it when needed 70 | if !os.IsNotExist(err) { 71 | return nil, err 72 | } 73 | } 74 | 75 | return hm, nil 76 | } 77 | 78 | // migrateOldHistoryFile migrates the old history file from ~/.ssh to ~/.config/sshm 79 | // TODO: Remove this migration logic in v2.0.0 (introduced in v1.6.0) 80 | func migrateOldHistoryFile(newHistoryPath string) error { 81 | // Check if new file already exists, skip migration 82 | if _, err := os.Stat(newHistoryPath); err == nil { 83 | return nil // New file exists, no migration needed 84 | } 85 | 86 | // Get old history file path - use same logic as SSH config location 87 | sshDir, err := config.GetSSHDirectory() 88 | if err != nil { 89 | return err 90 | } 91 | oldHistoryPath := filepath.Join(sshDir, "sshm_history.json") 92 | 93 | // Check if old file exists 94 | if _, err := os.Stat(oldHistoryPath); os.IsNotExist(err) { 95 | return nil // Old file doesn't exist, nothing to migrate 96 | } 97 | 98 | // Read old file 99 | data, err := os.ReadFile(oldHistoryPath) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | // Write to new location 105 | if err := os.WriteFile(newHistoryPath, data, 0644); err != nil { 106 | return err 107 | } 108 | 109 | // Remove old file only if write was successful 110 | if err := os.Remove(oldHistoryPath); err != nil { 111 | // Don't fail if we can't remove the old file 112 | // The migration was successful even if cleanup failed 113 | } 114 | 115 | return nil 116 | } 117 | 118 | // loadHistory loads the connection history from the JSON file 119 | func (hm *HistoryManager) loadHistory() error { 120 | data, err := os.ReadFile(hm.historyPath) 121 | if err != nil { 122 | return err 123 | } 124 | 125 | return json.Unmarshal(data, hm.history) 126 | } 127 | 128 | // saveHistory saves the connection history to the JSON file 129 | func (hm *HistoryManager) saveHistory() error { 130 | // Ensure the directory exists 131 | dir := filepath.Dir(hm.historyPath) 132 | if err := os.MkdirAll(dir, 0700); err != nil { 133 | return err 134 | } 135 | 136 | data, err := json.MarshalIndent(hm.history, "", " ") 137 | if err != nil { 138 | return err 139 | } 140 | 141 | return os.WriteFile(hm.historyPath, data, 0600) 142 | } 143 | 144 | // RecordConnection records a new connection for the specified host 145 | func (hm *HistoryManager) RecordConnection(hostName string) error { 146 | now := time.Now() 147 | 148 | if conn, exists := hm.history.Connections[hostName]; exists { 149 | // Update existing connection 150 | conn.LastConnect = now 151 | conn.ConnectCount++ 152 | hm.history.Connections[hostName] = conn 153 | } else { 154 | // Create new connection record 155 | hm.history.Connections[hostName] = ConnectionInfo{ 156 | HostName: hostName, 157 | LastConnect: now, 158 | ConnectCount: 1, 159 | } 160 | } 161 | 162 | return hm.saveHistory() 163 | } 164 | 165 | // GetLastConnectionTime returns the last connection time for a host 166 | func (hm *HistoryManager) GetLastConnectionTime(hostName string) (time.Time, bool) { 167 | if conn, exists := hm.history.Connections[hostName]; exists { 168 | return conn.LastConnect, true 169 | } 170 | return time.Time{}, false 171 | } 172 | 173 | // GetConnectionCount returns the total number of connections for a host 174 | func (hm *HistoryManager) GetConnectionCount(hostName string) int { 175 | if conn, exists := hm.history.Connections[hostName]; exists { 176 | return conn.ConnectCount 177 | } 178 | return 0 179 | } 180 | 181 | // SortHostsByLastUsed sorts hosts by their last connection time (most recent first) 182 | func (hm *HistoryManager) SortHostsByLastUsed(hosts []config.SSHHost) []config.SSHHost { 183 | sorted := make([]config.SSHHost, len(hosts)) 184 | copy(sorted, hosts) 185 | 186 | sort.Slice(sorted, func(i, j int) bool { 187 | timeI, existsI := hm.GetLastConnectionTime(sorted[i].Name) 188 | timeJ, existsJ := hm.GetLastConnectionTime(sorted[j].Name) 189 | 190 | // If both have history, sort by most recent first 191 | if existsI && existsJ { 192 | return timeI.After(timeJ) 193 | } 194 | 195 | // Hosts with history come before hosts without history 196 | if existsI && !existsJ { 197 | return true 198 | } 199 | if !existsI && existsJ { 200 | return false 201 | } 202 | 203 | // If neither has history, sort alphabetically 204 | return sorted[i].Name < sorted[j].Name 205 | }) 206 | 207 | return sorted 208 | } 209 | 210 | // SortHostsByMostUsed sorts hosts by their connection count (most used first) 211 | func (hm *HistoryManager) SortHostsByMostUsed(hosts []config.SSHHost) []config.SSHHost { 212 | sorted := make([]config.SSHHost, len(hosts)) 213 | copy(sorted, hosts) 214 | 215 | sort.Slice(sorted, func(i, j int) bool { 216 | countI := hm.GetConnectionCount(sorted[i].Name) 217 | countJ := hm.GetConnectionCount(sorted[j].Name) 218 | 219 | // If counts are different, sort by count (highest first) 220 | if countI != countJ { 221 | return countI > countJ 222 | } 223 | 224 | // If counts are equal, sort by most recent 225 | timeI, existsI := hm.GetLastConnectionTime(sorted[i].Name) 226 | timeJ, existsJ := hm.GetLastConnectionTime(sorted[j].Name) 227 | 228 | if existsI && existsJ { 229 | return timeI.After(timeJ) 230 | } 231 | 232 | // If neither has history, sort alphabetically 233 | return sorted[i].Name < sorted[j].Name 234 | }) 235 | 236 | return sorted 237 | } 238 | 239 | // CleanupOldEntries removes connection history for hosts that no longer exist 240 | func (hm *HistoryManager) CleanupOldEntries(currentHosts []config.SSHHost) error { 241 | // Create a set of current host names 242 | currentHostNames := make(map[string]bool) 243 | for _, host := range currentHosts { 244 | currentHostNames[host.Name] = true 245 | } 246 | 247 | // Remove entries for hosts that no longer exist 248 | for hostName := range hm.history.Connections { 249 | if !currentHostNames[hostName] { 250 | delete(hm.history.Connections, hostName) 251 | } 252 | } 253 | 254 | return hm.saveHistory() 255 | } 256 | 257 | // GetAllConnectionsInfo returns all connection information sorted by last connection time 258 | func (hm *HistoryManager) GetAllConnectionsInfo() []ConnectionInfo { 259 | var connections []ConnectionInfo 260 | for _, conn := range hm.history.Connections { 261 | connections = append(connections, conn) 262 | } 263 | 264 | sort.Slice(connections, func(i, j int) bool { 265 | return connections[i].LastConnect.After(connections[j].LastConnect) 266 | }) 267 | 268 | return connections 269 | } 270 | 271 | // RecordPortForwarding saves port forwarding configuration for a host 272 | func (hm *HistoryManager) RecordPortForwarding(hostName, forwardType, localPort, remoteHost, remotePort, bindAddress string) error { 273 | now := time.Now() 274 | 275 | portForwardConfig := &PortForwardConfig{ 276 | Type: forwardType, 277 | LocalPort: localPort, 278 | RemoteHost: remoteHost, 279 | RemotePort: remotePort, 280 | BindAddress: bindAddress, 281 | } 282 | 283 | if conn, exists := hm.history.Connections[hostName]; exists { 284 | // Update existing connection 285 | conn.LastConnect = now 286 | conn.ConnectCount++ 287 | conn.PortForwarding = portForwardConfig 288 | hm.history.Connections[hostName] = conn 289 | } else { 290 | // Create new connection record 291 | hm.history.Connections[hostName] = ConnectionInfo{ 292 | HostName: hostName, 293 | LastConnect: now, 294 | ConnectCount: 1, 295 | PortForwarding: portForwardConfig, 296 | } 297 | } 298 | 299 | return hm.saveHistory() 300 | } 301 | 302 | // GetPortForwardingConfig retrieves the last used port forwarding configuration for a host 303 | func (hm *HistoryManager) GetPortForwardingConfig(hostName string) *PortForwardConfig { 304 | if conn, exists := hm.history.Connections[hostName]; exists { 305 | return conn.PortForwarding 306 | } 307 | return nil 308 | } 309 | -------------------------------------------------------------------------------- /internal/ui/table.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/Gu1llaum-3/sshm/internal/config" 7 | "github.com/Gu1llaum-3/sshm/internal/history" 8 | 9 | "github.com/charmbracelet/bubbles/table" 10 | ) 11 | 12 | // calculateDynamicColumnWidths calculates optimal column widths based on terminal width 13 | // and content length, ensuring all content fits when possible 14 | func (m *Model) calculateDynamicColumnWidths(hosts []config.SSHHost) (int, int, int, int) { 15 | if m.width <= 0 { 16 | // Fallback to static widths if terminal width is not available 17 | return calculateNameColumnWidth(hosts), 25, calculateTagsColumnWidth(hosts), calculateLastLoginColumnWidth(hosts, m.historyManager) 18 | } 19 | 20 | // Calculate content lengths 21 | maxNameLength := 8 // Minimum for "Name" header + status indicator 22 | maxHostnameLength := 8 // Minimum for "Hostname" header 23 | maxTagsLength := 8 // Minimum for "Tags" header 24 | maxLastLoginLength := 12 // Minimum for "Last Login" header 25 | 26 | for _, host := range hosts { 27 | // Name column includes status indicator (2 chars) + space (1 char) + name 28 | nameLength := 3 + len(host.Name) 29 | if nameLength > maxNameLength { 30 | maxNameLength = nameLength 31 | } 32 | 33 | if len(host.Hostname) > maxHostnameLength { 34 | maxHostnameLength = len(host.Hostname) 35 | } 36 | 37 | // Calculate tags string length 38 | var tagsStr string 39 | if len(host.Tags) > 0 { 40 | var formattedTags []string 41 | for _, tag := range host.Tags { 42 | formattedTags = append(formattedTags, "#"+tag) 43 | } 44 | tagsStr = strings.Join(formattedTags, " ") 45 | } 46 | if len(tagsStr) > maxTagsLength { 47 | maxTagsLength = len(tagsStr) 48 | } 49 | 50 | // Calculate last login length 51 | if m.historyManager != nil { 52 | if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists { 53 | timeStr := formatTimeAgo(lastConnect) 54 | if len(timeStr) > maxLastLoginLength { 55 | maxLastLoginLength = len(timeStr) 56 | } 57 | } 58 | } 59 | } 60 | 61 | // Add padding to each column 62 | maxNameLength += 2 63 | maxHostnameLength += 2 64 | maxTagsLength += 2 65 | maxLastLoginLength += 2 66 | 67 | // Calculate available width (minus borders and separators) 68 | // Table has borders (2 chars) + column separators (3 chars between 4 columns) 69 | availableWidth := m.width - 5 70 | 71 | totalNeededWidth := maxNameLength + maxHostnameLength + maxTagsLength + maxLastLoginLength 72 | 73 | if totalNeededWidth <= availableWidth { 74 | // Everything fits perfectly 75 | return maxNameLength, maxHostnameLength, maxTagsLength, maxLastLoginLength 76 | } 77 | 78 | // Need to adjust widths - prioritize columns by importance 79 | // Priority: Name > Hostname > Last Login > Tags 80 | 81 | // Calculate minimum widths 82 | minNameWidth := 15 // Enough for status + short name 83 | minHostnameWidth := 15 84 | minLastLoginWidth := 12 85 | minTagsWidth := 10 86 | 87 | remainingWidth := availableWidth 88 | 89 | // Allocate minimum widths first 90 | nameWidth := minNameWidth 91 | hostnameWidth := minHostnameWidth 92 | lastLoginWidth := minLastLoginWidth 93 | tagsWidth := minTagsWidth 94 | 95 | remainingWidth -= (nameWidth + hostnameWidth + lastLoginWidth + tagsWidth) 96 | 97 | // Distribute remaining space proportionally 98 | if remainingWidth > 0 { 99 | // Calculate how much each column wants beyond minimum 100 | nameWant := maxNameLength - minNameWidth 101 | hostnameWant := maxHostnameLength - minHostnameWidth 102 | lastLoginWant := maxLastLoginLength - minLastLoginWidth 103 | tagsWant := maxTagsLength - minTagsWidth 104 | 105 | totalWant := nameWant + hostnameWant + lastLoginWant + tagsWant 106 | 107 | if totalWant > 0 { 108 | // Distribute proportionally 109 | nameExtra := (nameWant * remainingWidth) / totalWant 110 | hostnameExtra := (hostnameWant * remainingWidth) / totalWant 111 | lastLoginExtra := (lastLoginWant * remainingWidth) / totalWant 112 | tagsExtra := remainingWidth - nameExtra - hostnameExtra - lastLoginExtra 113 | 114 | nameWidth += nameExtra 115 | hostnameWidth += hostnameExtra 116 | lastLoginWidth += lastLoginExtra 117 | tagsWidth += tagsExtra 118 | } 119 | } 120 | 121 | return nameWidth, hostnameWidth, tagsWidth, lastLoginWidth 122 | } 123 | 124 | // updateTableRows updates the table with filtered hosts 125 | func (m *Model) updateTableRows() { 126 | var rows []table.Row 127 | hostsToShow := m.filteredHosts 128 | if hostsToShow == nil { 129 | hostsToShow = m.hosts 130 | } 131 | 132 | for _, host := range hostsToShow { 133 | // Get ping status indicator 134 | statusIndicator := m.getPingStatusIndicator(host.Name) 135 | 136 | // Format tags for display 137 | var tagsStr string 138 | if len(host.Tags) > 0 { 139 | // Add the # prefix to each tag and join them with spaces 140 | var formattedTags []string 141 | for _, tag := range host.Tags { 142 | formattedTags = append(formattedTags, "#"+tag) 143 | } 144 | tagsStr = strings.Join(formattedTags, " ") 145 | } 146 | 147 | // Format last login information 148 | var lastLoginStr string 149 | if m.historyManager != nil { 150 | if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists { 151 | lastLoginStr = formatTimeAgo(lastConnect) 152 | } 153 | } 154 | 155 | rows = append(rows, table.Row{ 156 | statusIndicator + " " + host.Name, 157 | host.Hostname, 158 | // host.User, // Commented to save space 159 | // host.Port, // Commented to save space 160 | tagsStr, 161 | lastLoginStr, 162 | }) 163 | } 164 | 165 | m.table.SetRows(rows) 166 | 167 | // Update table height and columns based on current terminal size 168 | m.updateTableHeight() 169 | m.updateTableColumns() 170 | } 171 | 172 | // updateTableHeight dynamically adjusts table height based on terminal size 173 | func (m *Model) updateTableHeight() { 174 | if !m.ready { 175 | return 176 | } 177 | 178 | // Calculate dynamic table height based on terminal size 179 | // Layout breakdown: 180 | // - ASCII title: 5 lines (1 empty + 4 text lines) 181 | // - Update banner : 1 line (if present) 182 | // - Search bar: 1 line 183 | // - Help text: 1 line 184 | // - App margins/spacing: 3 lines 185 | // - Safety margin: 3 lines (to ensure UI elements are always visible) 186 | // Total reserved: 14 lines minimum to preserve essential UI elements 187 | reservedHeight := 14 188 | availableHeight := m.height - reservedHeight 189 | hostCount := len(m.table.Rows()) 190 | 191 | // Minimum height should be at least 3 rows for basic usability 192 | // Even in very small terminals, we want to show at least header + 2 hosts 193 | minTableHeight := 4 // 1 header + 3 data rows minimum 194 | maxTableHeight := availableHeight 195 | if maxTableHeight < minTableHeight { 196 | maxTableHeight = minTableHeight 197 | } 198 | 199 | tableHeight := 1 // header 200 | dataRowsNeeded := hostCount 201 | maxDataRows := maxTableHeight - 1 // subtract 1 for header 202 | 203 | if dataRowsNeeded <= maxDataRows { 204 | // We have enough space for all hosts 205 | tableHeight += dataRowsNeeded 206 | } else { 207 | // We need to limit to available space 208 | tableHeight += maxDataRows 209 | } 210 | 211 | // Add one extra line to prevent the last host from being hidden 212 | // This compensates for table rendering quirks in bubble tea 213 | tableHeight += 1 214 | 215 | // Update table height 216 | m.table.SetHeight(tableHeight) 217 | } 218 | 219 | // updateTableColumns dynamically adjusts table column widths based on terminal size 220 | func (m *Model) updateTableColumns() { 221 | if !m.ready { 222 | return 223 | } 224 | 225 | hostsToShow := m.filteredHosts 226 | if hostsToShow == nil { 227 | hostsToShow = m.hosts 228 | } 229 | 230 | // Use dynamic column width calculation 231 | nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(hostsToShow) 232 | 233 | // Create new columns with updated widths and sort indicators 234 | nameTitle := "Name" 235 | lastLoginTitle := "Last Login" 236 | 237 | // Add sort indicators based on current sort mode 238 | switch m.sortMode { 239 | case SortByName: 240 | nameTitle += " ↓" 241 | case SortByLastUsed: 242 | lastLoginTitle += " ↓" 243 | } 244 | 245 | columns := []table.Column{ 246 | {Title: nameTitle, Width: nameWidth}, 247 | {Title: "Hostname", Width: hostnameWidth}, 248 | // {Title: "User", Width: userWidth}, // Commented to save space 249 | // {Title: "Port", Width: portWidth}, // Commented to save space 250 | {Title: "Tags", Width: tagsWidth}, 251 | {Title: lastLoginTitle, Width: lastLoginWidth}, 252 | } 253 | 254 | m.table.SetColumns(columns) 255 | } 256 | 257 | // max returns the maximum of two integers 258 | func max(a, b int) int { 259 | if a > b { 260 | return a 261 | } 262 | return b 263 | } 264 | 265 | // Legacy functions for compatibility 266 | 267 | // calculateNameColumnWidth calculates the optimal width for the Name column 268 | // based on the longest hostname, with a minimum of 8 and maximum of 40 characters 269 | func calculateNameColumnWidth(hosts []config.SSHHost) int { 270 | maxLength := 8 // Minimum width to accommodate the "Name" header 271 | 272 | for _, host := range hosts { 273 | if len(host.Name) > maxLength { 274 | maxLength = len(host.Name) 275 | } 276 | } 277 | 278 | // Add some padding (2 characters) for better visual spacing 279 | maxLength += 2 280 | 281 | // Limit the maximum width to avoid extremely large columns 282 | if maxLength > 40 { 283 | maxLength = 40 284 | } 285 | 286 | return maxLength 287 | } 288 | 289 | // calculateTagsColumnWidth calculates the optimal width for the Tags column 290 | // based on the longest tag string, with a minimum of 8 and maximum of 40 characters 291 | func calculateTagsColumnWidth(hosts []config.SSHHost) int { 292 | maxLength := 8 // Minimum width to accommodate the "Tags" header 293 | 294 | for _, host := range hosts { 295 | // Format tags exactly as they appear in the table 296 | var tagsStr string 297 | if len(host.Tags) > 0 { 298 | // Add the # prefix to each tag and join them with spaces 299 | var formattedTags []string 300 | for _, tag := range host.Tags { 301 | formattedTags = append(formattedTags, "#"+tag) 302 | } 303 | tagsStr = strings.Join(formattedTags, " ") 304 | } 305 | 306 | if len(tagsStr) > maxLength { 307 | maxLength = len(tagsStr) 308 | } 309 | } 310 | 311 | // Add some padding (2 characters) for better visual spacing 312 | maxLength += 2 313 | 314 | // Limit the maximum width to avoid extremely large columns 315 | if maxLength > 40 { 316 | maxLength = 40 317 | } 318 | 319 | return maxLength 320 | } 321 | 322 | // calculateLastLoginColumnWidth calculates the optimal width for the Last Login column 323 | // based on the longest time format, with a minimum of 12 and maximum of 20 characters 324 | func calculateLastLoginColumnWidth(hosts []config.SSHHost, historyManager *history.HistoryManager) int { 325 | maxLength := 12 // Minimum width to accommodate the "Last Login" header 326 | 327 | if historyManager != nil { 328 | for _, host := range hosts { 329 | if lastConnect, exists := historyManager.GetLastConnectionTime(host.Name); exists { 330 | timeStr := formatTimeAgo(lastConnect) 331 | if len(timeStr) > maxLength { 332 | maxLength = len(timeStr) 333 | } 334 | } 335 | } 336 | } 337 | 338 | // Add some padding (2 characters) for better visual spacing 339 | maxLength += 2 340 | 341 | // Limit the maximum width to avoid extremely large columns 342 | if maxLength > 20 { 343 | maxLength = 20 344 | } 345 | 346 | return maxLength 347 | } 348 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # GitHub Copilot Instructions for Go + Bubble Tea (TUI) 2 | 3 | These project-level instructions tell Copilot how to generate idiomatic, production-quality Go code using the Bubble Tea ecosystem. **Follow and prefer these rules over generic patterns.** 4 | 5 | --- 6 | 7 | ## 1) Project Scope & Goals 8 | 9 | * Build terminal UIs with **[Bubble Tea](https://github.com/charmbracelet/bubbletea)** and **Bubbles** components. 10 | * Use **Lip Gloss** for styling and **Huh**/**Bubbles forms** for prompts where useful. 11 | * Favor **small, composable models** and **message-driven state**. 12 | * Prioritize **maintainability, testability, and clear separation** of update vs. view. 13 | 14 | --- 15 | 16 | ## 2) Go Conventions to Prefer 17 | 18 | * Go version: **1.22+**. 19 | * Module: `go.mod` with minimal, pinned dependencies; use `go get -u` only deliberately. 20 | * Code style: `gofmt`, `go vet`, `staticcheck` (when available), `golangci-lint`. 21 | * Names: short, meaningful; exported symbols require GoDoc comments. 22 | * Errors: return wrapped errors with `%w` and `errors.Is/As`. No panics for flow control. 23 | * Concurrency: use `context.Context` and `errgroup` where applicable. Avoid goroutine leaks; cancel contexts in `Quit`/`Stop`. 24 | * Testing: `*_test.go`, table-driven tests, golden tests for `View()` when helpful. 25 | * Logging: prefer structured logs (e.g., `slog`) and keep logs separate from UI rendering. 26 | 27 | --- 28 | 29 | ## 3) Bubble Tea Architecture Rules 30 | 31 | ### 3.1 Model layout 32 | 33 | ```go 34 | // Model holds all state needed to render and update. 35 | type Model struct { 36 | width, height int 37 | ready bool 38 | 39 | // Domain state 40 | items []Item 41 | cursor int 42 | err error 43 | 44 | // Child components 45 | list list.Model 46 | spinner spinner.Model 47 | 48 | // Styles 49 | styles Styles 50 | } 51 | ``` 52 | 53 | **Guidelines** 54 | 55 | * Keep **domain state** (data) separate from **UI components** (Bubbles models) and **styles**. 56 | * Add a `Styles` struct to centralize Lip Gloss styles; initialize once. 57 | * Track terminal size (`width`, `height`); re-calc layout on `tea.WindowSizeMsg`. 58 | 59 | ### 3.2 Init 60 | 61 | * Return **batch** of startup commands for IO (e.g., loading data) and component inits. 62 | * Never block in `Init`; do IO in `tea.Cmd`s. 63 | 64 | ```go 65 | func (m Model) Init() tea.Cmd { 66 | return tea.Batch(m.spinner.Tick, loadItemsCmd()) 67 | } 68 | ``` 69 | 70 | ### 3.3 Update 71 | 72 | * **Pure function** style: transform `Model` + `Msg` → `(Model, Cmd)`. 73 | * Always handle `tea.WindowSizeMsg` to set `m.width`/`m.height` and recompute layout. 74 | * Use **type-switched** message handling; push side effects into `tea.Cmd`s. 75 | * Bubble components: call `Update(msg)` on children and **return their Cmd**. 76 | 77 | ```go 78 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 79 | switch msg := msg.(type) { 80 | case tea.WindowSizeMsg: 81 | m.width, m.height = msg.Width, msg.Height 82 | m.styles = NewStyles(m.width) // recompute if responsive 83 | return m, nil 84 | 85 | case errMsg: 86 | m.err = msg 87 | return m, nil 88 | 89 | case itemsLoaded: 90 | m.items = msg 91 | return m, nil 92 | } 93 | 94 | // delegate to children last 95 | var cmd tea.Cmd 96 | m.spinner, cmd = m.spinner.Update(msg) 97 | return m, cmd 98 | } 99 | ``` 100 | 101 | ### 3.4 View 102 | 103 | * **Never** mutate state in `View()`. 104 | * Compose layout with Lip Gloss; gracefully handle small terminals. 105 | * Put errors and help at the bottom. 106 | 107 | ```go 108 | func (m Model) View() string { 109 | if !m.ready { 110 | return m.styles.Loading.Render(m.spinner.View() + " Loading…") 111 | } 112 | main := lipgloss.JoinVertical(lipgloss.Left, 113 | m.styles.Title.Render("My App"), 114 | m.list.View(), 115 | ) 116 | if m.err != nil { 117 | main += "\n" + m.styles.Error.Render(m.err.Error()) 118 | } 119 | return m.styles.App.Render(main) 120 | } 121 | ``` 122 | 123 | ### 3.5 Messages & Commands 124 | 125 | * Define **typed messages** for domain events, not raw strings. 126 | * Each async operation returns a **message type**; handle errors with a dedicated `errMsg`. 127 | 128 | ```go 129 | type itemsLoaded []Item 130 | 131 | type errMsg error 132 | 133 | func loadItemsCmd() tea.Cmd { 134 | return func() tea.Msg { 135 | items, err := fetchItems() 136 | if err != nil { return errMsg(err) } 137 | return itemsLoaded(items) 138 | } 139 | } 140 | ``` 141 | 142 | ### 3.6 Keys & Help 143 | 144 | * Centralize keybindings and help text. Prefer `bubbles/key` + `bubbles/help`. 145 | 146 | ```go 147 | type keyMap struct { 148 | Up, Down, Select, Quit key.Binding 149 | } 150 | 151 | var keys = keyMap{ 152 | Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), 153 | Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), 154 | Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), 155 | Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), 156 | } 157 | ``` 158 | 159 | Handle keys in `Update` using `key.Matches(msg, keys.X)` and show a `help.Model` in the footer. 160 | 161 | ### 3.7 Submodels (Component Composition) 162 | 163 | * For complex screens, create **submodels** with their own `(Model, Init, Update, View)` and wire them into a parent. 164 | * Exchange messages via **custom Msg types** and/or **parent state**. 165 | * Keep submodels **pure**; IO still goes through parent-level `tea.Cmd`s or via submodel commands returned to parent. 166 | 167 | ### 3.8 Program Options 168 | 169 | * Start programs with `tea.NewProgram(m, tea.WithOutput(os.Stdout), tea.WithAltScreen())` when full-screen; avoid AltScreen for simple tools. 170 | * Always handle **TTY absence** (e.g., piping); fall back to non-interactive. 171 | 172 | --- 173 | 174 | ## 4) Styling with Lip Gloss 175 | 176 | * Maintain a single `Styles` struct with named styles. 177 | * Compute widths once per resize; avoid per-cell Lip Gloss allocations in tight loops. 178 | * Use `lipgloss.JoinHorizontal/Vertical` for layout; avoid manual spacing where possible. 179 | 180 | ```go 181 | type Styles struct { 182 | App, Title, Error, Loading lipgloss.Style 183 | } 184 | 185 | func NewStyles(width int) Styles { 186 | return Styles{ 187 | App: lipgloss.NewStyle().Padding(1), 188 | Title: lipgloss.NewStyle().Bold(true), 189 | Error: lipgloss.NewStyle().Foreground(lipgloss.Color("9")), 190 | Loading: lipgloss.NewStyle().Faint(true), 191 | } 192 | } 193 | ``` 194 | 195 | --- 196 | 197 | ## 5) IO, Concurrency & Performance 198 | 199 | * **Never** perform blocking IO in `Update` directly; always return a `tea.Cmd` that does the work. 200 | * Use `context.Context` inside commands; respect cancellation when program exits. 201 | * Be careful with **goroutine leaks**: ensure commands stop when model quits. 202 | * Batch commands with `tea.Batch` to keep updates snappy. 203 | * For large lists, prefer `bubbles/list` with virtualization; avoid generating huge strings per frame. 204 | * Debounce high-frequency events (typing) with timer-based commands. 205 | 206 | --- 207 | 208 | ## 6) Error Handling & UX 209 | 210 | * Represent recoverable errors in the UI; do not exit on first error. 211 | * Use `errMsg` for async failures; show a concise, styled error line. 212 | * For fatal initialization errors, return `tea.Quit` with an explanatory message printed once. 213 | 214 | --- 215 | 216 | ## 7) Keys, Shortcuts, and Accessibility 217 | 218 | * Provide **discoverable shortcuts** via a footer help view. 219 | * Offer Emacs-style alternatives where it makes sense (e.g., `ctrl+n/p`). 220 | * Use consistent navigation patterns across screens. 221 | 222 | --- 223 | 224 | ## 8) Testing Strategy 225 | 226 | * Unit test message handling with deterministic messages. 227 | * Snapshot/golden-test `View()` output for known terminal sizes. 228 | * Fuzz-test parsers/formatters used by the UI. 229 | 230 | ```go 231 | func TestUpdate_Select(t *testing.T) { 232 | m := newTestModel() 233 | _, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) 234 | if got, want := m.cursor, 1; got != want { t.Fatalf("cursor=%d want %d", got, want) } 235 | } 236 | ``` 237 | 238 | --- 239 | 240 | ## 9) Project Structure Template 241 | 242 | ``` 243 | cmd/ 244 | app/ 245 | main.go 246 | internal/ 247 | tui/ 248 | model.go // root model, styles 249 | update.go // Update + messages 250 | view.go // View 251 | keys.go // keymap/help 252 | components/ // submodels 253 | domain/ // business logic, pure Go 254 | io/ // adapters (API, FS, net) 255 | 256 | Makefile // lint, test, run targets 257 | ``` 258 | 259 | --- 260 | 261 | ## 10) Scaffolding Snippets (Ask Copilot to use these) 262 | 263 | ### 10.1 Root main.go 264 | 265 | ```go 266 | package main 267 | 268 | import ( 269 | "context" 270 | "log" 271 | "os" 272 | 273 | tea "github.com/charmbracelet/bubbletea" 274 | ) 275 | 276 | func main() { 277 | if !isTTY() { // optional: detect piping 278 | log.Println("Non-interactive mode not implemented.") 279 | return 280 | } 281 | 282 | p := tea.NewProgram(NewModel(), tea.WithAltScreen()) 283 | if _, err := p.Run(); err != nil { 284 | log.Fatalf("error: %v", err) 285 | } 286 | } 287 | ``` 288 | 289 | ### 10.2 NewModel() 290 | 291 | ```go 292 | func NewModel() Model { 293 | s := NewStyles(0) 294 | return Model{ 295 | list: newList(), 296 | spinner: spinner.New(), 297 | styles: s, 298 | } 299 | } 300 | ``` 301 | 302 | ### 10.3 Custom messages 303 | 304 | ```go 305 | type ( 306 | errMsg error 307 | itemsLoaded []Item 308 | ) 309 | ``` 310 | 311 | ### 10.4 Command helper 312 | 313 | ```go 314 | func do(cmd func(context.Context) (tea.Msg, error)) tea.Cmd { 315 | return func() tea.Msg { 316 | ctx, cancel := context.WithCancel(context.Background()) 317 | defer cancel() 318 | msg, err := cmd(ctx) 319 | if err != nil { return errMsg(err) } 320 | return msg 321 | } 322 | } 323 | ``` 324 | 325 | --- 326 | 327 | ## 11) Dependencies to Prefer 328 | 329 | * `github.com/charmbracelet/bubbletea` 330 | * `github.com/charmbracelet/bubbles` 331 | * `github.com/charmbracelet/lipgloss` 332 | * `golang.org/x/sync/errgroup` (for non-UI workloads) 333 | * `log/slog` (Go 1.21+) for logging 334 | 335 | Pin versions in `go.mod`. Avoid extra UI deps unless justified. 336 | 337 | --- 338 | 339 | ## 12) Copilot Prompting Rules (Important) 340 | 341 | * When the user writes a new TUI screen, **scaffold** `(Model, Init, Update, View)` with: 342 | 343 | * Window size handling 344 | * Keymap/help wiring 345 | * Styles struct and `NewStyles(width)` 346 | * Commands for all IO 347 | * Prefer **typed messages** and return **`tea.Cmd`**; do not perform blocking work in `Update`. 348 | * Always update child bubble components via `child.Update(msg)` and collect cmds with `tea.Batch`. 349 | * Generate **tests** for key message paths. 350 | * Include **help footer** with keybindings. 351 | * Keep `View()` pure and free of side effects. 352 | 353 | **Bad** 354 | 355 | * Doing HTTP/FS work directly in `Update`. 356 | * Printing to stdout from `Update`/`View`. 357 | * Storing `context.Context` in the model. 358 | * Creating goroutines that outlive the program. 359 | 360 | **Good** 361 | 362 | * Commands that return typed messages. 363 | * Centralized keymap + help. 364 | * Single source of truth for styles. 365 | * Small submodels and composition. 366 | 367 | --- 368 | 369 | ## 13) Security & Reliability 370 | 371 | * Validate all external inputs; sanitize strings rendered into the terminal. 372 | * Respect user locale and UTF-8; avoid slicing strings by bytes for widths (use `lipgloss.Width`). 373 | * Handle small terminal sizes (min-width fallbacks). 374 | * Ensure graceful shutdown; propagate quit via `tea.Quit` and cancel pending work. 375 | 376 | --- 377 | 378 | ## 14) Makefile Targets (suggested) 379 | 380 | ``` 381 | .PHONY: run test lint fmt tidy 382 | run:; go run ./cmd/app 383 | fmt:; go fmt ./... 384 | lint:; golangci-lint run 385 | test:; go test ./... 386 | tidy:; go mod tidy 387 | ``` 388 | 389 | --- 390 | 391 | ## 15) Example Key Handling Pattern 392 | 393 | ```go 394 | case tea.KeyMsg: 395 | switch { 396 | case key.Matches(msg, keys.Quit): 397 | return m, tea.Quit 398 | case key.Matches(msg, keys.Up): 399 | if m.cursor > 0 { m.cursor-- } 400 | case key.Matches(msg, keys.Down): 401 | if m.cursor < len(m.items)-1 { m.cursor++ } 402 | } 403 | ``` 404 | 405 | --- 406 | 407 | ## 16) Documentation & Comments 408 | 409 | * Exported types/functions must have a sentence GoDoc. 410 | * At the top of each file, include a short comment describing its responsibility. 411 | * For non-obvious state transitions, include a brief state diagram in comments. 412 | 413 | --- 414 | 415 | ## 17) Acceptance Criteria for Generated Code 416 | 417 | * Builds with `go build ./...` 418 | * Passes `go vet` and `golangci-lint` (if configured) 419 | * Has at least one table-driven test per major update path 420 | * Handles window resize and quit 421 | * No side effects in `View()` 422 | * Commands wrap errors and return `errMsg` 423 | 424 | --- 425 | 426 | *End of instructions.* 427 | -------------------------------------------------------------------------------- /internal/ui/add_form.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/user" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/Gu1llaum-3/sshm/internal/config" 11 | "github.com/Gu1llaum-3/sshm/internal/validation" 12 | 13 | "github.com/charmbracelet/bubbles/textinput" 14 | tea "github.com/charmbracelet/bubbletea" 15 | ) 16 | 17 | type addFormModel struct { 18 | inputs []textinput.Model 19 | focused int 20 | currentTab int // 0 = General, 1 = Advanced 21 | err string 22 | styles Styles 23 | success bool 24 | width int 25 | height int 26 | configFile string 27 | } 28 | 29 | // NewAddForm creates a new add form model 30 | func NewAddForm(hostname string, styles Styles, width, height int, configFile string) *addFormModel { 31 | // Get current user for default 32 | currentUser, _ := user.Current() 33 | defaultUser := "root" 34 | if currentUser != nil { 35 | defaultUser = currentUser.Username 36 | } 37 | 38 | // Find default identity file 39 | homeDir, _ := os.UserHomeDir() 40 | defaultIdentity := filepath.Join(homeDir, ".ssh", "id_rsa") 41 | 42 | // Check for other common key types 43 | keyTypes := []string{"id_ed25519", "id_ecdsa", "id_rsa"} 44 | for _, keyType := range keyTypes { 45 | keyPath := filepath.Join(homeDir, ".ssh", keyType) 46 | if _, err := os.Stat(keyPath); err == nil { 47 | defaultIdentity = keyPath 48 | break 49 | } 50 | } 51 | 52 | inputs := make([]textinput.Model, 10) // Increased from 9 to 10 for RequestTTY 53 | 54 | // Name input 55 | inputs[nameInput] = textinput.New() 56 | inputs[nameInput].Placeholder = "server-name" 57 | inputs[nameInput].Focus() 58 | inputs[nameInput].CharLimit = 50 59 | inputs[nameInput].Width = 30 60 | if hostname != "" { 61 | inputs[nameInput].SetValue(hostname) 62 | } 63 | 64 | // Hostname input 65 | inputs[hostnameInput] = textinput.New() 66 | inputs[hostnameInput].Placeholder = "192.168.1.100 or example.com" 67 | inputs[hostnameInput].CharLimit = 100 68 | inputs[hostnameInput].Width = 30 69 | 70 | // User input 71 | inputs[userInput] = textinput.New() 72 | inputs[userInput].Placeholder = defaultUser 73 | inputs[userInput].CharLimit = 50 74 | inputs[userInput].Width = 30 75 | 76 | // Port input 77 | inputs[portInput] = textinput.New() 78 | inputs[portInput].Placeholder = "22" 79 | inputs[portInput].CharLimit = 5 80 | inputs[portInput].Width = 30 81 | 82 | // Identity input 83 | inputs[identityInput] = textinput.New() 84 | inputs[identityInput].Placeholder = defaultIdentity 85 | inputs[identityInput].CharLimit = 200 86 | inputs[identityInput].Width = 50 87 | 88 | // ProxyJump input 89 | inputs[proxyJumpInput] = textinput.New() 90 | inputs[proxyJumpInput].Placeholder = "user@jump-host:port or existing-host-name" 91 | inputs[proxyJumpInput].CharLimit = 200 92 | inputs[proxyJumpInput].Width = 50 93 | 94 | // SSH Options input 95 | inputs[optionsInput] = textinput.New() 96 | inputs[optionsInput].Placeholder = "-o Compression=yes -o ServerAliveInterval=60" 97 | inputs[optionsInput].CharLimit = 500 98 | inputs[optionsInput].Width = 70 99 | 100 | // Tags input 101 | inputs[tagsInput] = textinput.New() 102 | inputs[tagsInput].Placeholder = "production, web, database" 103 | inputs[tagsInput].CharLimit = 200 104 | inputs[tagsInput].Width = 50 105 | 106 | // Remote Command input 107 | inputs[remoteCommandInput] = textinput.New() 108 | inputs[remoteCommandInput].Placeholder = "ls -la, htop, bash" 109 | inputs[remoteCommandInput].CharLimit = 300 110 | inputs[remoteCommandInput].Width = 70 111 | 112 | // RequestTTY input 113 | inputs[requestTTYInput] = textinput.New() 114 | inputs[requestTTYInput].Placeholder = "yes, no, force, auto" 115 | inputs[requestTTYInput].CharLimit = 10 116 | inputs[requestTTYInput].Width = 30 117 | 118 | return &addFormModel{ 119 | inputs: inputs, 120 | focused: nameInput, 121 | currentTab: tabGeneral, // Start on General tab 122 | styles: styles, 123 | width: width, 124 | height: height, 125 | configFile: configFile, 126 | } 127 | } 128 | 129 | const ( 130 | tabGeneral = iota 131 | tabAdvanced 132 | ) 133 | 134 | const ( 135 | nameInput = iota 136 | hostnameInput 137 | userInput 138 | portInput 139 | identityInput 140 | proxyJumpInput 141 | tagsInput 142 | // Advanced tab inputs 143 | optionsInput 144 | remoteCommandInput 145 | requestTTYInput 146 | ) 147 | 148 | // Messages for communication with parent model 149 | type addFormSubmitMsg struct { 150 | hostname string 151 | err error 152 | } 153 | 154 | type addFormCancelMsg struct{} 155 | 156 | func (m *addFormModel) Init() tea.Cmd { 157 | return textinput.Blink 158 | } 159 | 160 | func (m *addFormModel) Update(msg tea.Msg) (*addFormModel, tea.Cmd) { 161 | var cmds []tea.Cmd 162 | 163 | switch msg := msg.(type) { 164 | case tea.WindowSizeMsg: 165 | m.width = msg.Width 166 | m.height = msg.Height 167 | m.styles = NewStyles(m.width) 168 | return m, nil 169 | 170 | case tea.KeyMsg: 171 | switch msg.String() { 172 | case "ctrl+c", "esc": 173 | return m, func() tea.Msg { return addFormCancelMsg{} } 174 | 175 | case "ctrl+s": 176 | // Allow submission from any field with Ctrl+S (Save) 177 | return m, m.submitForm() 178 | 179 | case "ctrl+j": 180 | // Switch to next tab 181 | m.currentTab = (m.currentTab + 1) % 2 182 | m.focused = m.getFirstInputForTab(m.currentTab) 183 | return m, m.updateFocus() 184 | 185 | case "ctrl+k": 186 | // Switch to previous tab 187 | m.currentTab = (m.currentTab - 1 + 2) % 2 188 | m.focused = m.getFirstInputForTab(m.currentTab) 189 | return m, m.updateFocus() 190 | 191 | case "tab", "shift+tab", "enter", "up", "down": 192 | return m, m.handleNavigation(msg.String()) 193 | } 194 | 195 | case addFormSubmitMsg: 196 | if msg.err != nil { 197 | m.err = msg.err.Error() 198 | } else { 199 | m.success = true 200 | m.err = "" 201 | // Don't quit here, let parent handle the success 202 | } 203 | return m, nil 204 | } 205 | 206 | // Update inputs 207 | cmd := make([]tea.Cmd, len(m.inputs)) 208 | for i := range m.inputs { 209 | m.inputs[i], cmd[i] = m.inputs[i].Update(msg) 210 | } 211 | cmds = append(cmds, cmd...) 212 | 213 | return m, tea.Batch(cmds...) 214 | } 215 | 216 | // getFirstInputForTab returns the first input index for a given tab 217 | func (m *addFormModel) getFirstInputForTab(tab int) int { 218 | switch tab { 219 | case tabGeneral: 220 | return nameInput 221 | case tabAdvanced: 222 | return optionsInput 223 | default: 224 | return nameInput 225 | } 226 | } 227 | 228 | // getInputsForCurrentTab returns the input indices for the current tab 229 | func (m *addFormModel) getInputsForCurrentTab() []int { 230 | switch m.currentTab { 231 | case tabGeneral: 232 | return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, tagsInput} 233 | case tabAdvanced: 234 | return []int{optionsInput, remoteCommandInput, requestTTYInput} 235 | default: 236 | return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, tagsInput} 237 | } 238 | } 239 | 240 | // updateFocus updates focus for inputs 241 | func (m *addFormModel) updateFocus() tea.Cmd { 242 | var cmds []tea.Cmd 243 | for i := range m.inputs { 244 | if i == m.focused { 245 | cmds = append(cmds, m.inputs[i].Focus()) 246 | } else { 247 | m.inputs[i].Blur() 248 | } 249 | } 250 | return tea.Batch(cmds...) 251 | } 252 | 253 | // handleNavigation handles tab/arrow navigation within the current tab 254 | func (m *addFormModel) handleNavigation(key string) tea.Cmd { 255 | currentTabInputs := m.getInputsForCurrentTab() 256 | 257 | // Find current position within the tab 258 | currentPos := 0 259 | for i, input := range currentTabInputs { 260 | if input == m.focused { 261 | currentPos = i 262 | break 263 | } 264 | } 265 | 266 | // Handle form submission on last field of Advanced tab 267 | if key == "enter" && m.currentTab == tabAdvanced && currentPos == len(currentTabInputs)-1 { 268 | return m.submitForm() 269 | } 270 | 271 | // Navigate within current tab 272 | if key == "up" || key == "shift+tab" { 273 | currentPos-- 274 | } else { 275 | currentPos++ 276 | } 277 | 278 | // Wrap around within current tab 279 | if currentPos >= len(currentTabInputs) { 280 | currentPos = 0 281 | } else if currentPos < 0 { 282 | currentPos = len(currentTabInputs) - 1 283 | } 284 | 285 | m.focused = currentTabInputs[currentPos] 286 | return m.updateFocus() 287 | } 288 | 289 | func (m *addFormModel) View() string { 290 | if m.success { 291 | return "" 292 | } 293 | 294 | // Check if terminal height is sufficient 295 | if !m.isHeightSufficient() { 296 | return m.renderHeightWarning() 297 | } 298 | 299 | var b strings.Builder 300 | 301 | b.WriteString(m.styles.FormTitle.Render("Add SSH Host Configuration")) 302 | b.WriteString("\n\n") 303 | 304 | // Render tabs 305 | b.WriteString(m.renderTabs()) 306 | b.WriteString("\n\n") 307 | 308 | // Render current tab content 309 | switch m.currentTab { 310 | case tabGeneral: 311 | b.WriteString(m.renderGeneralTab()) 312 | case tabAdvanced: 313 | b.WriteString(m.renderAdvancedTab()) 314 | } 315 | 316 | if m.err != "" { 317 | b.WriteString(m.styles.Error.Render("Error: " + m.err)) 318 | b.WriteString("\n\n") 319 | } 320 | 321 | // Help text 322 | b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Ctrl+J/K: switch tabs")) 323 | b.WriteString("\n") 324 | b.WriteString(m.styles.FormHelp.Render("Enter on last field: submit • Ctrl+S: save • Ctrl+C/Esc: cancel")) 325 | b.WriteString("\n") 326 | b.WriteString(m.styles.FormHelp.Render("* Required fields")) 327 | 328 | return b.String() 329 | } 330 | 331 | // getMinimumHeight calculates the minimum height needed to display the form 332 | func (m *addFormModel) getMinimumHeight() int { 333 | // Title: 1 line + 2 newlines = 3 334 | titleLines := 3 335 | // Tabs: 1 line + 2 newlines = 3 336 | tabLines := 3 337 | // Fields in current tab 338 | var fieldsCount int 339 | if m.currentTab == tabGeneral { 340 | fieldsCount = 7 // 7 fields in general tab 341 | } else { 342 | fieldsCount = 3 // 3 fields in advanced tab 343 | } 344 | // Each field: label (1) + input (1) + spacing (2) = 4 lines per field, but let's be more conservative 345 | fieldsLines := fieldsCount * 3 // Reduced from 4 to 3 346 | // Help text: 3 lines 347 | helpLines := 3 348 | // Error message space when needed: 2 lines 349 | errorLines := 0 // Only count when there's actually an error 350 | if m.err != "" { 351 | errorLines = 2 352 | } 353 | 354 | return titleLines + tabLines + fieldsLines + helpLines + errorLines + 1 // +1 minimal safety margin 355 | } 356 | 357 | // isHeightSufficient checks if the current terminal height is sufficient 358 | func (m *addFormModel) isHeightSufficient() bool { 359 | return m.height >= m.getMinimumHeight() 360 | } 361 | 362 | // renderHeightWarning renders a warning message when height is insufficient 363 | func (m *addFormModel) renderHeightWarning() string { 364 | required := m.getMinimumHeight() 365 | current := m.height 366 | 367 | warning := m.styles.ErrorText.Render("⚠️ Terminal height is too small!") 368 | details := m.styles.FormField.Render(fmt.Sprintf("Current: %d lines, Required: %d lines", current, required)) 369 | instruction := m.styles.FormHelp.Render("Please resize your terminal window and try again.") 370 | instruction2 := m.styles.FormHelp.Render("Press Ctrl+C to cancel or resize terminal window.") 371 | 372 | return warning + "\n\n" + details + "\n\n" + instruction + "\n" + instruction2 373 | } 374 | 375 | // renderTabs renders the tab headers 376 | func (m *addFormModel) renderTabs() string { 377 | var generalTab, advancedTab string 378 | 379 | if m.currentTab == tabGeneral { 380 | generalTab = m.styles.FocusedLabel.Render("[ General ]") 381 | advancedTab = m.styles.FormField.Render(" Advanced ") 382 | } else { 383 | generalTab = m.styles.FormField.Render(" General ") 384 | advancedTab = m.styles.FocusedLabel.Render("[ Advanced ]") 385 | } 386 | 387 | return generalTab + " " + advancedTab 388 | } 389 | 390 | // renderGeneralTab renders the general tab content 391 | func (m *addFormModel) renderGeneralTab() string { 392 | var b strings.Builder 393 | 394 | fields := []struct { 395 | index int 396 | label string 397 | }{ 398 | {nameInput, "Host Name *"}, 399 | {hostnameInput, "Hostname/IP *"}, 400 | {userInput, "User"}, 401 | {portInput, "Port"}, 402 | {identityInput, "Identity File"}, 403 | {proxyJumpInput, "ProxyJump"}, 404 | {tagsInput, "Tags (comma-separated)"}, 405 | } 406 | 407 | for _, field := range fields { 408 | fieldStyle := m.styles.FormField 409 | if m.focused == field.index { 410 | fieldStyle = m.styles.FocusedLabel 411 | } 412 | b.WriteString(fieldStyle.Render(field.label)) 413 | b.WriteString("\n") 414 | b.WriteString(m.inputs[field.index].View()) 415 | b.WriteString("\n\n") 416 | } 417 | 418 | return b.String() 419 | } 420 | 421 | // renderAdvancedTab renders the advanced tab content 422 | func (m *addFormModel) renderAdvancedTab() string { 423 | var b strings.Builder 424 | 425 | fields := []struct { 426 | index int 427 | label string 428 | }{ 429 | {optionsInput, "SSH Options"}, 430 | {remoteCommandInput, "Remote Command"}, 431 | {requestTTYInput, "Request TTY"}, 432 | } 433 | 434 | for _, field := range fields { 435 | fieldStyle := m.styles.FormField 436 | if m.focused == field.index { 437 | fieldStyle = m.styles.FocusedLabel 438 | } 439 | b.WriteString(fieldStyle.Render(field.label)) 440 | b.WriteString("\n") 441 | b.WriteString(m.inputs[field.index].View()) 442 | b.WriteString("\n\n") 443 | } 444 | 445 | return b.String() 446 | } 447 | 448 | // Standalone wrapper for add form 449 | type standaloneAddForm struct { 450 | *addFormModel 451 | } 452 | 453 | func (m standaloneAddForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 454 | switch msg := msg.(type) { 455 | case addFormSubmitMsg: 456 | if msg.err != nil { 457 | m.addFormModel.err = msg.err.Error() 458 | } else { 459 | m.addFormModel.success = true 460 | return m, tea.Quit 461 | } 462 | return m, nil 463 | case addFormCancelMsg: 464 | return m, tea.Quit 465 | } 466 | 467 | newForm, cmd := m.addFormModel.Update(msg) 468 | m.addFormModel = newForm 469 | return m, cmd 470 | } 471 | 472 | // RunAddForm provides backward compatibility for standalone add form 473 | func RunAddForm(hostname string, configFile string) error { 474 | styles := NewStyles(80) 475 | addForm := NewAddForm(hostname, styles, 80, 24, configFile) 476 | m := standaloneAddForm{addForm} 477 | 478 | p := tea.NewProgram(m, tea.WithAltScreen()) 479 | _, err := p.Run() 480 | return err 481 | } 482 | 483 | func (m *addFormModel) submitForm() tea.Cmd { 484 | return func() tea.Msg { 485 | // Get values 486 | name := strings.TrimSpace(m.inputs[nameInput].Value()) 487 | hostname := strings.TrimSpace(m.inputs[hostnameInput].Value()) 488 | user := strings.TrimSpace(m.inputs[userInput].Value()) 489 | port := strings.TrimSpace(m.inputs[portInput].Value()) 490 | identity := strings.TrimSpace(m.inputs[identityInput].Value()) 491 | proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value()) 492 | options := strings.TrimSpace(m.inputs[optionsInput].Value()) 493 | remoteCommand := strings.TrimSpace(m.inputs[remoteCommandInput].Value()) 494 | requestTTY := strings.TrimSpace(m.inputs[requestTTYInput].Value()) 495 | 496 | // Set defaults 497 | if user == "" { 498 | user = m.inputs[userInput].Placeholder 499 | } 500 | if port == "" { 501 | port = "22" 502 | } 503 | // Do not auto-fill identity with placeholder if left empty; keep it empty so it's optional 504 | 505 | // Validate all fields 506 | if err := validation.ValidateHost(name, hostname, port, identity); err != nil { 507 | return addFormSubmitMsg{err: err} 508 | } 509 | 510 | tagsStr := strings.TrimSpace(m.inputs[tagsInput].Value()) 511 | var tags []string 512 | if tagsStr != "" { 513 | for _, tag := range strings.Split(tagsStr, ",") { 514 | tag = strings.TrimSpace(tag) 515 | if tag != "" { 516 | tags = append(tags, tag) 517 | } 518 | } 519 | } 520 | 521 | // Create host configuration 522 | host := config.SSHHost{ 523 | Name: name, 524 | Hostname: hostname, 525 | User: user, 526 | Port: port, 527 | Identity: identity, 528 | ProxyJump: proxyJump, 529 | Options: config.ParseSSHOptionsFromCommand(options), 530 | RemoteCommand: remoteCommand, 531 | RequestTTY: requestTTY, 532 | Tags: tags, 533 | } 534 | 535 | // Add to config 536 | var err error 537 | if m.configFile != "" { 538 | err = config.AddSSHHostToFile(host, m.configFile) 539 | } else { 540 | err = config.AddSSHHost(host) 541 | } 542 | return addFormSubmitMsg{hostname: name, err: err} 543 | } 544 | } 545 | --------------------------------------------------------------------------------