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

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