mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2025-10-19 01:17:20 +02:00
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
531 lines
14 KiB
Go
531 lines
14 KiB
Go
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)
|
|
}
|
|
}
|