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
This commit is contained in:
Gu1llaum-3 2025-10-15 19:22:04 +02:00
parent 825c534ebe
commit 167e4c0a09
11 changed files with 1224 additions and 21 deletions

View File

@ -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.

View File

@ -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

View File

@ -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
}

View File

@ -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 <port> or -p<port>
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 <identity>
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
}

View File

@ -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")
}
})
}
}

View File

@ -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")),
)

530
internal/ui/history_tui.go Normal file
View File

@ -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)
}
}

View File

@ -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

View File

@ -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().

View File

@ -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)

View File

@ -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"
}