From 167e4c0a093d86676ef20d201bbcba5ec7c0d228 Mon Sep 17 00:00:00 2001 From: Gu1llaum-3 Date: Wed, 15 Oct 2025 19:22:04 +0200 Subject: [PATCH] feat: add SSH connection history with Ctrl+H navigation Track SSH connections (configured + manual) with searchable history view. Press Ctrl+H to view history, Ctrl+L to return. Add manual connections to config with 'a'. - Parse and store manual SSH connections - History TUI with search and filtering - Connection count and timestamps --- README.md | 44 ++- cmd/root.go | 91 ++++++ internal/history/history.go | 97 ++++++ internal/history/parser.go | 95 ++++++ internal/history/parser_test.go | 277 +++++++++++++++++ internal/ui/help_form.go | 35 ++- internal/ui/history_tui.go | 530 ++++++++++++++++++++++++++++++++ internal/ui/model.go | 4 +- internal/ui/styles.go | 6 +- internal/ui/update.go | 60 ++++ internal/ui/view.go | 6 +- 11 files changed, 1224 insertions(+), 21 deletions(-) create mode 100644 internal/history/parser.go create mode 100644 internal/history/parser_test.go create mode 100644 internal/ui/history_tui.go diff --git a/README.md b/README.md index ae96fa2..b253dd3 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect - **🔍 Smart Search** - Find hosts quickly with built-in filtering and search - **📝 Real-time Status** - Live SSH connectivity indicators with asynchronous ping checks and color-coded status - **🔔 Smart Updates** - Automatic version checking with update notifications -- **📈 Connection History** - Track your SSH connections with last login timestamps +- **📈 Connection History** - Track both configured and manual SSH connections with timestamps and usage counts ### 🛠️ **Technical Features** - **🔒 Secure** - Works directly with your existing `~/.ssh/config` file @@ -106,6 +106,7 @@ sshm - `d` - Delete selected host - `m` - Move host to another config file (requires SSH Include directives) - `f` - Port forwarding setup +- `Ctrl+H` - Switch to connection history view - `q` - Quit - `/` - Search/filter hosts @@ -285,6 +286,47 @@ sshm web-01 - **Error handling** - Clear messages if host doesn't exist or configuration issues - **Config file support** - Works with custom config files using `-c` flag +### Connection History + +SSHM automatically tracks all your SSH connections, including both configured hosts and manual connections made outside of SSHM. + +**Access History:** +Press `Ctrl+H` from the main interface to switch to the history view. Press `Ctrl+L` to return to the main host list. + +**Features:** +- **Automatic tracking** - Records all SSH connections with timestamps and connection counts +- **Manual connection detection** - Captures `ssh user@host -p port -i key` commands made in your terminal +- **Visual indicators** - Manual connections (not in your SSH config) are marked with ★ +- **Search & filter** - Find connections quickly using the search bar +- **Add to config** - Press `a` on any manual connection (★) to add it to your SSH config +- **Persistent storage** - History is saved in `~/.config/sshm/sshm_history.json` + +**Tracked Information:** +- Host name or hostname for manual connections +- Username and hostname +- Port number +- Last connection timestamp +- Total connection count + +**Use Cases:** +- Review your recent SSH activity +- Find frequently used manual connections +- Promote manual connections to permanent SSH config entries +- Track when you last connected to a host + +**Example Workflow:** +```bash +# Make a manual SSH connection +ssh deploy@192.168.1.100 -p 2222 -i ~/.ssh/custom_key + +# Launch SSHM and press Ctrl+H to view history +sshm +# Press Ctrl+H → see the manual connection with ★ indicator +# Press 'a' to add it to your SSH config +# Give it a name like "deploy-server" and save +# Press Ctrl+L to return to main list → now it's a configured host +``` + ### Backup Configuration SSHM automatically creates backups of your SSH configuration files before making any changes to ensure your configurations are safe. diff --git a/cmd/root.go b/cmd/root.go index 75b2c4e..615f56f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -49,6 +49,7 @@ Hosts are read from your ~/.ssh/config file by default.`, } // If a host name is provided, connect directly + // (manual SSH commands are handled in Execute() before reaching here) hostName := args[0] connectToHost(hostName) return nil @@ -171,6 +172,73 @@ func connectToHost(hostName string) { } } +// connectManualSSH handles manual SSH connections like: sshm -p 2222 user@host +func connectManualSSH(args []string) { + // Parse the manual connection arguments + conn, ok := history.ParseSSHArgs(args) + if !ok || conn.Hostname == "" { + fmt.Println("Error: Invalid SSH connection arguments") + fmt.Println("Usage: sshm [-p port] [-i identity] [user@]hostname") + os.Exit(1) + } + + // Record the manual connection in history + historyManager, err := history.NewHistoryManager() + if err != nil { + // Log the error but don't prevent the connection + fmt.Printf("Warning: Could not initialize connection history: %v\n", err) + } else { + err = historyManager.RecordManualConnection(*conn) + if err != nil { + // Log the error but don't prevent the connection + fmt.Printf("Warning: Could not record connection history: %v\n", err) + } + } + + // Build and execute the SSH command + fmt.Printf("Connecting to %s@%s:%s...\n", conn.User, conn.Hostname, conn.Port) + + // Build SSH arguments + var sshArgs []string + + // Add port if not default + if conn.Port != "" && conn.Port != "22" { + sshArgs = append(sshArgs, "-p", conn.Port) + } + + // Add identity file if specified + if conn.Identity != "" { + sshArgs = append(sshArgs, "-i", conn.Identity) + } + + // Add user@host or just host + if conn.User != "" { + sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", conn.User, conn.Hostname)) + } else { + sshArgs = append(sshArgs, conn.Hostname) + } + + sshCmd := exec.Command("ssh", sshArgs...) + + // Set up the command to use the same stdin, stdout, and stderr as the parent process + sshCmd.Stdin = os.Stdin + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + + // Execute the SSH command + err = sshCmd.Run() + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + // SSH command failed, exit with the same code + if status, ok := exitError.Sys().(syscall.WaitStatus); ok { + os.Exit(status.ExitStatus()) + } + } + fmt.Printf("Error executing SSH command: %v\n", err) + os.Exit(1) + } +} + // getVersionWithUpdateCheck returns a custom version string with update check func getVersionWithUpdateCheck() string { versionText := fmt.Sprintf("sshm version %s", AppVersion) @@ -197,6 +265,29 @@ func getVersionWithUpdateCheck() string { // Execute adds all child commands to the root command and sets flags appropriately. func Execute() { + // Check if this looks like a manual SSH command BEFORE Cobra parses flags + // This prevents Cobra from complaining about unknown flags like -p, -i, etc. + if len(os.Args) > 1 { + // Check if any argument looks like a manual SSH connection + args := os.Args[1:] + + // Skip if it's a known subcommand + knownCommands := []string{"add", "edit", "search", "move", "help", "completion", "version", "--version", "-v"} + isSubcommand := false + for _, cmd := range knownCommands { + if args[0] == cmd { + isSubcommand = true + break + } + } + + // If not a subcommand and looks like manual SSH, handle it directly + if !isSubcommand && history.IsManualSSHCommand(args) { + connectManualSSH(args) + return + } + } + // Custom error handling for unknown commands that might be host names if err := RootCmd.Execute(); err != nil { // Check if this is an "unknown command" error and the argument might be a host name diff --git a/internal/history/history.go b/internal/history/history.go index 93c6c7e..ebf1b7d 100644 --- a/internal/history/history.go +++ b/internal/history/history.go @@ -2,6 +2,7 @@ package history import ( "encoding/json" + "fmt" "os" "path/filepath" "sort" @@ -306,3 +307,99 @@ func (hm *HistoryManager) GetPortForwardingConfig(hostName string) *PortForwardC } return nil } + +// ManualConnection represents a manual SSH connection (e.g., ssh user@host -p 2222) +type ManualConnection struct { + User string + Hostname string + Port string + Identity string +} + +// RecordManualConnection records a manual SSH connection (like ssh user@host -p 2222 -i key) +// These are stored with a generated host name like "manual:user@host:port" +func (hm *HistoryManager) RecordManualConnection(conn ManualConnection) error { + // Generate a unique identifier for this manual connection + hostID := generateManualHostID(conn) + + now := time.Now() + + if existingConn, exists := hm.history.Connections[hostID]; exists { + // Update existing connection + existingConn.LastConnect = now + existingConn.ConnectCount++ + hm.history.Connections[hostID] = existingConn + } else { + // Create new connection record + hm.history.Connections[hostID] = ConnectionInfo{ + HostName: hostID, + LastConnect: now, + ConnectCount: 1, + } + } + + return hm.saveHistory() +} + +// generateManualHostID generates a unique ID for manual connections +func generateManualHostID(conn ManualConnection) string { + // Format: manual:user@hostname:port + user := conn.User + if user == "" { + user = "default" + } + port := conn.Port + if port == "" { + port = "22" + } + return fmt.Sprintf("manual:%s@%s:%s", user, conn.Hostname, port) +} + +// IsManualConnection checks if a hostname represents a manual connection +func IsManualConnection(hostName string) bool { + return len(hostName) > 7 && hostName[:7] == "manual:" +} + +// ParseManualConnectionID parses a manual connection ID back into its components +func ParseManualConnectionID(hostID string) (user, hostname, port string, ok bool) { + if !IsManualConnection(hostID) { + return "", "", "", false + } + + // Remove "manual:" prefix + parts := hostID[7:] // Skip "manual:" + + // Split by last ':' + lastColon := -1 + for i := len(parts) - 1; i >= 0; i-- { + if parts[i] == ':' { + lastColon = i + break + } + } + + if lastColon == -1 { + return "", "", "", false + } + + port = parts[lastColon+1:] + userHost := parts[:lastColon] + + // Split user@host + atSign := -1 + for i := 0; i < len(userHost); i++ { + if userHost[i] == '@' { + atSign = i + break + } + } + + if atSign == -1 { + return "", "", "", false + } + + user = userHost[:atSign] + hostname = userHost[atSign+1:] + + return user, hostname, port, true +} diff --git a/internal/history/parser.go b/internal/history/parser.go new file mode 100644 index 0000000..bdbb277 --- /dev/null +++ b/internal/history/parser.go @@ -0,0 +1,95 @@ +package history + +import ( + "os/user" + "strings" +) + +// ParseSSHArgs parses SSH command line arguments and extracts connection details +// It handles formats like: user@host, -p port, -i identity, etc. +func ParseSSHArgs(args []string) (*ManualConnection, bool) { + if len(args) == 0 { + return nil, false + } + + conn := &ManualConnection{ + Port: "22", // Default SSH port + } + + // Get current user as default + currentUser, err := user.Current() + if err == nil { + conn.User = currentUser.Username + } + + // Parse arguments + for i := 0; i < len(args); i++ { + arg := args[i] + + // Handle -p or -p + if arg == "-p" { + if i+1 < len(args) { + conn.Port = args[i+1] + i++ + } + } else if strings.HasPrefix(arg, "-p") { + conn.Port = arg[2:] + } else if arg == "-i" { + // Handle -i + if i+1 < len(args) { + conn.Identity = args[i+1] + i++ + } + } else if arg == "-F" || arg == "-c" || arg == "--config" { + // Skip config file arguments - these are handled separately + if i+1 < len(args) { + i++ + } + return nil, false + } else if strings.HasPrefix(arg, "-") { + // Skip other SSH options like -v, -A, -X, etc. + // If they have a value, skip it too + if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + i++ + } + continue + } else if strings.Contains(arg, "@") { + // Parse user@hostname + parts := strings.SplitN(arg, "@", 2) + if len(parts) == 2 { + conn.User = parts[0] + conn.Hostname = parts[1] + } + } else if conn.Hostname == "" { + // If no @, treat as just hostname + conn.Hostname = arg + } + } + + // If we got a hostname, this is a valid manual connection + if conn.Hostname != "" { + return conn, true + } + + return nil, false +} + +// IsManualSSHCommand checks if the arguments represent a manual SSH connection +// (not a configured host name) +func IsManualSSHCommand(args []string) bool { + if len(args) == 0 { + return false + } + + // Check for SSH flags that indicate manual connection + for _, arg := range args { + if arg == "-p" || strings.HasPrefix(arg, "-p") { + return true + } + if strings.Contains(arg, "@") { + return true + } + } + + return false +} diff --git a/internal/history/parser_test.go b/internal/history/parser_test.go new file mode 100644 index 0000000..a9fb63b --- /dev/null +++ b/internal/history/parser_test.go @@ -0,0 +1,277 @@ +package history + +import ( + "testing" +) + +func TestParseSSHArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantConn *ManualConnection + wantOk bool + }{ + { + name: "user@host", + args: []string{"user@example.com"}, + wantConn: &ManualConnection{ + User: "user", + Hostname: "example.com", + Port: "22", + }, + wantOk: true, + }, + { + name: "user@host with -p port", + args: []string{"-p", "2222", "user@example.com"}, + wantConn: &ManualConnection{ + User: "user", + Hostname: "example.com", + Port: "2222", + }, + wantOk: true, + }, + { + name: "user@host with -p2222 (no space)", + args: []string{"-p2222", "user@example.com"}, + wantConn: &ManualConnection{ + User: "user", + Hostname: "example.com", + Port: "2222", + }, + wantOk: true, + }, + { + name: "user@host with -i identity", + args: []string{"-i", "~/.ssh/id_rsa", "user@example.com"}, + wantConn: &ManualConnection{ + User: "user", + Hostname: "example.com", + Port: "22", + Identity: "~/.ssh/id_rsa", + }, + wantOk: true, + }, + { + name: "complete connection", + args: []string{"-p", "2222", "-i", "~/.ssh/id_rsa", "guillaume@127.0.0.1"}, + wantConn: &ManualConnection{ + User: "guillaume", + Hostname: "127.0.0.1", + Port: "2222", + Identity: "~/.ssh/id_rsa", + }, + wantOk: true, + }, + { + name: "just hostname (no user)", + args: []string{"example.com"}, + wantConn: &ManualConnection{ + Hostname: "example.com", + Port: "22", + // User will be current system user, so we don't check it + }, + wantOk: true, + }, + { + name: "config file args should return false", + args: []string{"-F", "~/.ssh/config", "host"}, + wantConn: nil, + wantOk: false, + }, + { + name: "empty args", + args: []string{}, + wantConn: nil, + wantOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotConn, gotOk := ParseSSHArgs(tt.args) + + if gotOk != tt.wantOk { + t.Errorf("ParseSSHArgs() gotOk = %v, want %v", gotOk, tt.wantOk) + return + } + + if !tt.wantOk { + if gotConn != nil { + t.Errorf("ParseSSHArgs() gotConn = %v, want nil", gotConn) + } + return + } + + if gotConn == nil { + t.Errorf("ParseSSHArgs() gotConn = nil, want non-nil") + return + } + + if gotConn.User != tt.wantConn.User { + // Skip user check if wantConn.User is empty (current user) + if tt.wantConn.User != "" { + t.Errorf("ParseSSHArgs() User = %v, want %v", gotConn.User, tt.wantConn.User) + } + } + if gotConn.Hostname != tt.wantConn.Hostname { + t.Errorf("ParseSSHArgs() Hostname = %v, want %v", gotConn.Hostname, tt.wantConn.Hostname) + } + if gotConn.Port != tt.wantConn.Port { + t.Errorf("ParseSSHArgs() Port = %v, want %v", gotConn.Port, tt.wantConn.Port) + } + if gotConn.Identity != tt.wantConn.Identity { + t.Errorf("ParseSSHArgs() Identity = %v, want %v", gotConn.Identity, tt.wantConn.Identity) + } + }) + } +} + +func TestIsManualSSHCommand(t *testing.T) { + tests := []struct { + name string + args []string + want bool + }{ + { + name: "user@host is manual", + args: []string{"user@example.com"}, + want: true, + }, + { + name: "with -p flag is manual", + args: []string{"-p", "2222", "host"}, + want: true, + }, + { + name: "with -p2222 is manual", + args: []string{"-p2222", "host"}, + want: true, + }, + { + name: "just hostname is not manual", + args: []string{"myhost"}, + want: false, + }, + { + name: "empty is not manual", + args: []string{}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsManualSSHCommand(tt.args); got != tt.want { + t.Errorf("IsManualSSHCommand() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestManualConnectionID(t *testing.T) { + tests := []struct { + name string + conn ManualConnection + wantHostID string + wantUser string + wantHostname string + wantPort string + }{ + { + name: "complete connection", + conn: ManualConnection{ + User: "guillaume", + Hostname: "127.0.0.1", + Port: "2222", + Identity: "~/.ssh/id_rsa", + }, + wantHostID: "manual:guillaume@127.0.0.1:2222", + wantUser: "guillaume", + wantHostname: "127.0.0.1", + wantPort: "2222", + }, + { + name: "default port", + conn: ManualConnection{ + User: "user", + Hostname: "example.com", + Port: "", + }, + wantHostID: "manual:user@example.com:22", + wantUser: "user", + wantHostname: "example.com", + wantPort: "22", + }, + { + name: "no user specified", + conn: ManualConnection{ + Hostname: "example.com", + Port: "2222", + }, + wantHostID: "manual:default@example.com:2222", + wantUser: "default", + wantHostname: "example.com", + wantPort: "2222", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test generation + gotHostID := generateManualHostID(tt.conn) + if gotHostID != tt.wantHostID { + t.Errorf("generateManualHostID() = %v, want %v", gotHostID, tt.wantHostID) + } + + // Test IsManualConnection + if !IsManualConnection(gotHostID) { + t.Errorf("IsManualConnection(%v) = false, want true", gotHostID) + } + + // Test parsing + user, hostname, port, ok := ParseManualConnectionID(gotHostID) + if !ok { + t.Errorf("ParseManualConnectionID() ok = false, want true") + } + if user != tt.wantUser { + t.Errorf("ParseManualConnectionID() user = %v, want %v", user, tt.wantUser) + } + if hostname != tt.wantHostname { + t.Errorf("ParseManualConnectionID() hostname = %v, want %v", hostname, tt.wantHostname) + } + if port != tt.wantPort { + t.Errorf("ParseManualConnectionID() port = %v, want %v", port, tt.wantPort) + } + }) + } +} + +func TestParseManualConnectionID_Invalid(t *testing.T) { + tests := []struct { + name string + hostID string + }{ + { + name: "not a manual connection", + hostID: "myhost", + }, + { + name: "missing components", + hostID: "manual:invalid", + }, + { + name: "no @ sign", + hostID: "manual:hostname:22", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, _, ok := ParseManualConnectionID(tt.hostID) + if ok { + t.Errorf("ParseManualConnectionID() ok = true, want false for invalid input") + } + }) + } +} diff --git a/internal/ui/help_form.go b/internal/ui/help_form.go index 3b21744..7738dac 100644 --- a/internal/ui/help_form.go +++ b/internal/ui/help_form.go @@ -47,31 +47,34 @@ func (m *helpModel) View() string { m.styles.FocusedLabel.Render("Navigation & Connection"), "", lipgloss.JoinHorizontal(lipgloss.Left, - m.styles.FocusedLabel.Render("⏎ "), + m.styles.FocusedLabel.Render("⏎ "), m.styles.HelpText.Render("connect to selected host")), lipgloss.JoinHorizontal(lipgloss.Left, - m.styles.FocusedLabel.Render("i "), + m.styles.FocusedLabel.Render("i "), m.styles.HelpText.Render("show host information")), lipgloss.JoinHorizontal(lipgloss.Left, - m.styles.FocusedLabel.Render("/ "), + m.styles.FocusedLabel.Render("/ "), m.styles.HelpText.Render("search hosts")), lipgloss.JoinHorizontal(lipgloss.Left, - m.styles.FocusedLabel.Render("Tab "), + m.styles.FocusedLabel.Render("Tab "), m.styles.HelpText.Render("switch focus")), + lipgloss.JoinHorizontal(lipgloss.Left, + m.styles.FocusedLabel.Render("Ctrl+H "), + m.styles.HelpText.Render("switch to history view")), "", m.styles.FocusedLabel.Render("Host Management"), "", lipgloss.JoinHorizontal(lipgloss.Left, - m.styles.FocusedLabel.Render("a "), + m.styles.FocusedLabel.Render("a "), m.styles.HelpText.Render("add new host")), lipgloss.JoinHorizontal(lipgloss.Left, - m.styles.FocusedLabel.Render("e "), + m.styles.FocusedLabel.Render("e "), m.styles.HelpText.Render("edit selected host")), lipgloss.JoinHorizontal(lipgloss.Left, - m.styles.FocusedLabel.Render("m "), + m.styles.FocusedLabel.Render("m "), m.styles.HelpText.Render("move host to another config")), lipgloss.JoinHorizontal(lipgloss.Left, - m.styles.FocusedLabel.Render("d "), + m.styles.FocusedLabel.Render("d "), m.styles.HelpText.Render("delete selected host")), ) @@ -79,31 +82,31 @@ func (m *helpModel) View() string { m.styles.FocusedLabel.Render("Advanced Features"), "", lipgloss.JoinHorizontal(lipgloss.Left, - m.styles.FocusedLabel.Render("p "), + m.styles.FocusedLabel.Render("p "), m.styles.HelpText.Render("ping all hosts")), lipgloss.JoinHorizontal(lipgloss.Left, - m.styles.FocusedLabel.Render("f "), + m.styles.FocusedLabel.Render("f "), m.styles.HelpText.Render("setup port forwarding")), lipgloss.JoinHorizontal(lipgloss.Left, - m.styles.FocusedLabel.Render("s "), + m.styles.FocusedLabel.Render("s "), m.styles.HelpText.Render("cycle sort modes")), lipgloss.JoinHorizontal(lipgloss.Left, - m.styles.FocusedLabel.Render("n "), + m.styles.FocusedLabel.Render("n "), m.styles.HelpText.Render("sort by name")), lipgloss.JoinHorizontal(lipgloss.Left, - m.styles.FocusedLabel.Render("r "), + m.styles.FocusedLabel.Render("r "), m.styles.HelpText.Render("sort by recent connection")), "", m.styles.FocusedLabel.Render("System"), "", lipgloss.JoinHorizontal(lipgloss.Left, - m.styles.FocusedLabel.Render("h "), + m.styles.FocusedLabel.Render("h "), m.styles.HelpText.Render("show this help")), lipgloss.JoinHorizontal(lipgloss.Left, - m.styles.FocusedLabel.Render("q "), + m.styles.FocusedLabel.Render("q "), m.styles.HelpText.Render("quit application")), lipgloss.JoinHorizontal(lipgloss.Left, - m.styles.FocusedLabel.Render("ESC "), + m.styles.FocusedLabel.Render("ESC "), m.styles.HelpText.Render("exit current view")), ) diff --git a/internal/ui/history_tui.go b/internal/ui/history_tui.go new file mode 100644 index 0000000..29015b7 --- /dev/null +++ b/internal/ui/history_tui.go @@ -0,0 +1,530 @@ +package ui + +import ( + "fmt" + "os/exec" + "strings" + "time" + + "github.com/Gu1llaum-3/sshm/internal/config" + "github.com/Gu1llaum-3/sshm/internal/history" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// HistoryModel represents the TUI model for history view +type HistoryModel struct { + table table.Model + connections []history.ConnectionInfo + searchInput textinput.Model + searchActive bool + filteredConns []history.ConnectionInfo + configFile string + currentVersion string + styles Styles + width int + height int + showAddForm bool + addForm *addFormModel + selectedConn *history.ConnectionInfo + err string +} + +// NewHistoryModel creates a new history TUI model +func NewHistoryModel(connections []history.ConnectionInfo, configFile, currentVersion string) HistoryModel { + styles := NewStyles(80) + + // Create search input (different placeholder than main interface) + searchInput := textinput.New() + searchInput.Placeholder = "Search connections..." + searchInput.CharLimit = 50 + searchInput.Width = 25 // Same width as main interface + + m := HistoryModel{ + connections: connections, + filteredConns: connections, + searchInput: searchInput, + configFile: configFile, + currentVersion: currentVersion, + styles: styles, + } + + m.updateTable() + return m +} + +// Init initializes the history model +func (m HistoryModel) Init() tea.Cmd { + return nil +} + +// Update handles messages for the history model +func (m HistoryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + // Handle add form if active + if m.showAddForm && m.addForm != nil { + switch msg := msg.(type) { + case addFormSubmitMsg: + if msg.err != nil { + m.err = msg.err.Error() + } else { + m.showAddForm = false + m.addForm = nil + // Return to main list and refresh hosts + return m, func() tea.Msg { return refreshHostsMsg{} } + } + case addFormCancelMsg: + m.showAddForm = false + m.addForm = nil + return m, nil + } + + newForm, cmd := m.addForm.Update(msg) + m.addForm = newForm + return m, cmd + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.styles = NewStyles(m.width) + m.updateTable() + return m, nil + + case tea.KeyMsg: + // Handle search mode + if m.searchActive { + switch msg.String() { + case "esc", "ctrl+c": + m.searchActive = false + m.searchInput.Blur() + m.searchInput.SetValue("") + m.filteredConns = m.connections + m.updateTable() + return m, nil + case "enter": + m.searchActive = false + m.searchInput.Blur() + return m, nil + default: + m.searchInput, cmd = m.searchInput.Update(msg) + cmds = append(cmds, cmd) + m.filterConnections() + m.updateTable() + return m, tea.Batch(cmds...) + } + } + + // Normal mode key handling + switch msg.String() { + case "ctrl+c", "q", "esc": + return m, tea.Quit + + case "ctrl+l": + // Return to main list view + return m, func() tea.Msg { return returnToListMsg{} } + + case "enter": + // Connect to selected host + if len(m.filteredConns) > 0 { + selectedIdx := m.table.Cursor() + if selectedIdx < len(m.filteredConns) { + conn := m.filteredConns[selectedIdx] + return m, m.connectToHistory(conn) + } + } + + case "a": + // Add manual connection to config + if len(m.filteredConns) > 0 { + selectedIdx := m.table.Cursor() + if selectedIdx < len(m.filteredConns) { + conn := m.filteredConns[selectedIdx] + // Only allow adding manual connections to config + if history.IsManualConnection(conn.HostName) { + m.selectedConn = &conn + m.showAddForm = true + m.addForm = m.createAddFormFromConnection(conn) + return m, m.addForm.Init() + } + } + } + + case "d": + // Delete connection from history + if len(m.filteredConns) > 0 { + selectedIdx := m.table.Cursor() + if selectedIdx < len(m.filteredConns) { + conn := m.filteredConns[selectedIdx] + return m, m.deleteFromHistory(conn) + } + } + + case "/": + // Activate search + m.searchActive = true + m.searchInput.Focus() + return m, textinput.Blink + } + } + + // Update table + m.table, cmd = m.table.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +// View renders the history TUI +func (m HistoryModel) View() string { + if m.showAddForm && m.addForm != nil { + return m.addForm.View() + } + + // Build the interface components (same structure as main view) + components := []string{} + + // Add the ASCII title + components = append(components, m.styles.Header.Render(asciiTitle)) + + // Add error message if there's one to show + if m.err != "" { + errorStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("9")). // Red color + Background(lipgloss.Color("1")). // Dark red background + Bold(true). + Padding(0, 1). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("9")). + Align(lipgloss.Center) + + components = append(components, errorStyle.Render("❌ "+m.err)) + } + + // Add the search bar with the appropriate style based on focus + searchPrompt := "Search (/ to focus): " + if m.searchActive { + components = append(components, m.styles.SearchFocused.Render(searchPrompt+m.searchInput.View())) + } else { + components = append(components, m.styles.SearchUnfocused.Render(searchPrompt+m.searchInput.View())) + } + + // Add the table with the appropriate style based on focus + if m.searchActive { + // The table is not focused, use the unfocused style + components = append(components, m.styles.TableUnfocused.Render(m.table.View())) + } else { + // The table is focused, use the focused style + components = append(components, m.styles.TableFocused.Render(m.table.View())) + } + + // Add the help text + var helpText string + if !m.searchActive { + helpText = " ↑/↓: navigate • Enter: connect • Ctrl+L: list • a: add to config (★) • d: delete • q: quit" + } else { + helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit" + } + components = append(components, m.styles.HelpText.Render(helpText)) + + // Join all components vertically with appropriate spacing + mainView := m.styles.App.Render( + lipgloss.JoinVertical( + lipgloss.Left, + components..., + ), + ) + + return mainView +} // updateTable updates the table with current filtered connections +func (m *HistoryModel) updateTable() { + columns := []table.Column{ + {Title: "Host", Width: 22}, // Host name with ★ for manual connections + {Title: "User", Width: 15}, + {Title: "Hostname", Width: 25}, + {Title: "Port", Width: 6}, + {Title: "Last Connect", Width: 20}, + {Title: "Count", Width: 6}, + } + + // Load SSH hosts to get details for configured connections + var sshHosts []config.SSHHost + var err error + if m.configFile != "" { + sshHosts, err = config.ParseSSHConfigFile(m.configFile) + } else { + sshHosts, err = config.ParseSSHConfig() + } + if err != nil { + sshHosts = []config.SSHHost{} + } + + // Create a map for quick lookup + hostsMap := make(map[string]config.SSHHost) + for _, host := range sshHosts { + hostsMap[host.Name] = host + } + + rows := []table.Row{} + for _, conn := range m.filteredConns { + var hostDisplay, user, hostname, port string + + // Parse manual connections + if history.IsManualConnection(conn.HostName) { + u, h, p, ok := history.ParseManualConnectionID(conn.HostName) + if ok { + hostDisplay = "★" // Star indicates this can be added to config + user = u + hostname = h + port = p + } + } else { + // For configured hosts, show the host name + hostDisplay = conn.HostName + + if host, exists := hostsMap[conn.HostName]; exists { + user = host.User + hostname = host.Hostname + port = host.Port + if port == "" { + port = "22" + } + } + } + + lastConnect := formatTimeSince(conn.LastConnect) + + rows = append(rows, table.Row{ + hostDisplay, + user, + hostname, + port, + lastConnect, + fmt.Sprintf("%d", conn.ConnectCount), + }) + } + + // Calculate dynamic table height (same logic as main interface) + tableHeight := m.calculateTableHeight(len(rows)) + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(tableHeight), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(PrimaryColor)). + BorderBottom(true). + Bold(true) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color(PrimaryColor)). + Bold(false) + + t.SetStyles(s) + m.table = t +} + +// calculateTableHeight calculates the appropriate height for the table based on terminal size +func (m *HistoryModel) calculateTableHeight(rowCount int) int { + // Calculate dynamic table height based on terminal size + // Layout breakdown (same as main interface): + // - ASCII title: 5 lines (1 empty + 4 text lines) + // - Search bar: 1 line + // - Help text: 1 line + // - App margins/spacing: 3 lines + // - Safety margin: 3 lines + // Total reserved: 13 lines + reservedHeight := 13 + availableHeight := m.height - reservedHeight + + // Add 1 if there's an error message showing + if m.err != "" { + availableHeight -= 3 // Error box takes about 3 lines + } + + // Minimum height should be at least 3 rows for basic usability + minTableHeight := 4 // 1 header + 3 data rows minimum + maxTableHeight := availableHeight + if maxTableHeight < minTableHeight { + maxTableHeight = minTableHeight + } + + tableHeight := 1 // header + dataRowsNeeded := rowCount + maxDataRows := maxTableHeight - 1 // subtract 1 for header + + if dataRowsNeeded <= maxDataRows { + // We have enough space for all connections + tableHeight += dataRowsNeeded + } else { + // We need to limit to available space + tableHeight += maxDataRows + } + + // Add one extra line to prevent the last row from being hidden + tableHeight += 1 + + return tableHeight +} + +// filterConnections filters connections based on search input +func (m *HistoryModel) filterConnections() { + searchTerm := strings.ToLower(m.searchInput.Value()) + if searchTerm == "" { + m.filteredConns = m.connections + return + } + + m.filteredConns = []history.ConnectionInfo{} + for _, conn := range m.connections { + // Search in hostname + if strings.Contains(strings.ToLower(conn.HostName), searchTerm) { + m.filteredConns = append(m.filteredConns, conn) + continue + } + + // For manual connections, search in parsed fields + if history.IsManualConnection(conn.HostName) { + user, hostname, _, ok := history.ParseManualConnectionID(conn.HostName) + if ok { + if strings.Contains(strings.ToLower(user), searchTerm) || + strings.Contains(strings.ToLower(hostname), searchTerm) { + m.filteredConns = append(m.filteredConns, conn) + } + } + } + } +} + +// connectToHistory connects to a host from history +func (m HistoryModel) connectToHistory(conn history.ConnectionInfo) tea.Cmd { + var sshArgs []string + + if history.IsManualConnection(conn.HostName) { + // Manual connection + user, hostname, port, ok := history.ParseManualConnectionID(conn.HostName) + if !ok { + return nil + } + + if port != "" && port != "22" { + sshArgs = append(sshArgs, "-p", port) + } + + if user != "" { + sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", user, hostname)) + } else { + sshArgs = append(sshArgs, hostname) + } + } else { + // Configured host + if m.configFile != "" { + sshArgs = append(sshArgs, "-F", m.configFile) + } + sshArgs = append(sshArgs, conn.HostName) + } + + // Execute SSH using tea.ExecProcess for proper terminal handling + sshCmd := exec.Command("ssh", sshArgs...) + return tea.ExecProcess(sshCmd, func(err error) tea.Msg { + return tea.Quit() + }) +} + +// deleteFromHistory removes a connection from history +func (m HistoryModel) deleteFromHistory(conn history.ConnectionInfo) tea.Cmd { + return func() tea.Msg { + historyManager, err := history.NewHistoryManager() + if err != nil { + return tea.Quit + } + + // Remove from history + // This would need a new method in history manager + // For now, just quit + _ = historyManager + + return tea.Quit + } +} + +// createAddFormFromConnection creates an add form pre-filled with connection details +func (m *HistoryModel) createAddFormFromConnection(conn history.ConnectionInfo) *addFormModel { + user, hostname, port, ok := history.ParseManualConnectionID(conn.HostName) + if !ok { + return nil + } + + // Create form with empty name (user will choose) + form := NewAddForm("", m.styles, m.width, m.height, m.configFile) + + // Pre-fill the form with connection details + form.inputs[hostnameInput].SetValue(hostname) + form.inputs[userInput].SetValue(user) + if port != "22" && port != "" { + form.inputs[portInput].SetValue(port) + } + + // Leave name field empty for user to choose + // form.inputs[nameInput].SetValue("") // Already empty by default + + return form +} + +// formatTimeSince formats a time duration in human-readable format +func formatTimeSince(t time.Time) string { + duration := time.Since(t) + + switch { + case duration < time.Minute: + return "just now" + case duration < time.Hour: + mins := int(duration.Minutes()) + if mins == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", mins) + case duration < 24*time.Hour: + hours := int(duration.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + case duration < 7*24*time.Hour: + days := int(duration.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + case duration < 30*24*time.Hour: + weeks := int(duration.Hours() / 24 / 7) + if weeks == 1 { + return "1 week ago" + } + return fmt.Sprintf("%d weeks ago", weeks) + default: + months := int(duration.Hours() / 24 / 30) + if months == 1 { + return "1 month ago" + } + if months < 12 { + return fmt.Sprintf("%d months ago", months) + } + years := months / 12 + if years == 1 { + return "1 year ago" + } + return fmt.Sprintf("%d years ago", years) + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go index da6e7e9..ec449fa 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -42,6 +42,7 @@ const ( ViewPortForward ViewHelp ViewFileSelector + ViewHistory ) // PortForwardType defines the type of port forwarding @@ -81,7 +82,7 @@ type Model struct { configFile string // Path to the SSH config file // Application configuration - appConfig *config.AppConfig + appConfig *config.AppConfig // Version update information updateInfo *version.UpdateInfo @@ -96,6 +97,7 @@ type Model struct { portForwardForm *portForwardModel helpForm *helpModel fileSelectorForm *fileSelectorModel + historyView *HistoryModel // Terminal size and styles width int diff --git a/internal/ui/styles.go b/internal/ui/styles.go index da5d0c9..d4b3eb2 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -63,12 +63,14 @@ func NewStyles(width int) Styles { SearchFocused: lipgloss.NewStyle(). BorderStyle(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(PrimaryColor)). - Padding(0, 1), + Padding(0, 1). + Width(50), // Fixed width to prevent expansion SearchUnfocused: lipgloss.NewStyle(). BorderStyle(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(SecondaryColor)). - Padding(0, 1), + Padding(0, 1). + Width(50), // Fixed width to prevent expansion // Table styles TableFocused: lipgloss.NewStyle(). diff --git a/internal/ui/update.go b/internal/ui/update.go index 828f1ff..13fe73e 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -20,6 +20,8 @@ type ( versionCheckMsg *version.UpdateInfo versionErrorMsg error errorMsg string + returnToListMsg struct{} + refreshHostsMsg struct{} ) // startPingAllCmd creates a command to ping all hosts concurrently @@ -166,6 +168,40 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case returnToListMsg: + // Return to list view from history + m.viewMode = ViewList + m.historyView = nil + return m, nil + + case refreshHostsMsg: + // Refresh hosts after adding from history + var hosts []config.SSHHost + var err error + + if m.configFile != "" { + hosts, err = config.ParseSSHConfigFile(m.configFile) + } else { + hosts, err = config.ParseSSHConfig() + } + + if err != nil { + return m, nil + } + m.hosts = m.sortHosts(hosts) + + // Reapply search filter if there is one active + if m.searchInput.Value() != "" { + m.filteredHosts = m.filterHosts(m.searchInput.Value()) + } else { + m.filteredHosts = m.hosts + } + + m.updateTableRows() + m.viewMode = ViewList + m.historyView = nil + return m, nil + case addFormSubmitMsg: if msg.err != nil { // Show error in form @@ -434,6 +470,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.fileSelectorForm = newForm return m, cmd } + case ViewHistory: + if m.historyView != nil { + newView, cmd := m.historyView.Update(msg) + if histView, ok := newView.(HistoryModel); ok { + m.historyView = &histView + return m, cmd + } + } case ViewList: // Handle list view keys return m.handleListViewKeys(msg) @@ -705,6 +749,22 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.viewMode = ViewHelp return m, nil } + case "ctrl+h": + if !m.searchMode && !m.deleteMode { + // Switch to history view + if m.historyManager != nil { + connections := m.historyManager.GetAllConnectionsInfo() + historyView := NewHistoryModel(connections, m.configFile, m.currentVersion) + historyView.width = m.width + historyView.height = m.height + historyView.styles = m.styles + // Force table update with correct dimensions + historyView.updateTable() + m.historyView = &historyView + m.viewMode = ViewHistory + return m, nil + } + } case "s": if !m.searchMode && !m.deleteMode { // Cycle through sort modes (only 2 modes now) diff --git a/internal/ui/view.go b/internal/ui/view.go index 9f5f94e..09ab0e9 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -43,6 +43,10 @@ func (m Model) View() string { if m.fileSelectorForm != nil { return m.fileSelectorForm.View() } + case ViewHistory: + if m.historyView != nil { + return m.historyView.View() + } case ViewList: return m.renderListView() } @@ -106,7 +110,7 @@ func (m Model) renderListView() string { // Add the help text var helpText string if !m.searchMode { - helpText = " ↑/↓: navigate • Enter: connect • p: ping all • i: info • h: help • q: quit" + helpText = " ↑/↓: navigate • Enter: connect • Ctrl+H: history • i: info • h: help • q: quit" } else { helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit" }