sshm/internal/ui/tui.go
2025-09-02 09:21:00 +02:00

744 lines
19 KiB
Go

package ui
import (
"fmt"
"os/exec"
"sort"
"strings"
"time"
"sshm/internal/config"
"sshm/internal/history"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var searchStyleFocused = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("36")).
Padding(0, 1)
var searchStyleUnfocused = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240")).
Padding(0, 1)
var headerStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("36")).
Bold(true).
Align(lipgloss.Center)
const asciiTitle = `
_____ _____ _ _ _____
| __| __| | | |
|__ |__ | | | | |
|_____|_____|__|__|_|_|_|
`
type SortMode int
const (
SortByName SortMode = iota
SortByLastUsed
)
func (s SortMode) String() string {
switch s {
case SortByName:
return "Name (A-Z)"
case SortByLastUsed:
return "Last Login"
default:
return "Name (A-Z)"
}
}
type Model struct {
table table.Model
searchInput textinput.Model
hosts []config.SSHHost
filteredHosts []config.SSHHost
searchMode bool
deleteMode bool
deleteHost string
exitAction string
exitHostName string
historyManager *history.HistoryManager
sortMode SortMode
}
func (m Model) Init() tea.Cmd {
return textinput.Blink
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
// Handle key messages
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc", "ctrl+c":
if m.deleteMode {
// Exit delete mode
m.deleteMode = false
m.deleteHost = ""
m.table.Focus()
return m, nil
}
return m, tea.Quit
case "q":
if !m.searchMode && !m.deleteMode {
return m, tea.Quit
}
case "/", "ctrl+f":
if !m.searchMode && !m.deleteMode {
// Enter search mode
m.searchMode = true
m.table.Blur()
m.searchInput.Focus()
return m, textinput.Blink
}
case "tab":
if !m.deleteMode {
// Toggle focus between search input and table
if m.searchMode {
// Switch from search to table
m.searchMode = false
m.searchInput.Blur()
m.table.Focus()
} else {
// Switch from table to search
m.searchMode = true
m.table.Blur()
m.searchInput.Focus()
return m, textinput.Blink
}
return m, nil
}
case "enter":
if m.searchMode {
// Validate search and return to table mode to allow commands
m.searchMode = false
m.searchInput.Blur()
m.table.Focus()
return m, nil
} else if m.deleteMode {
// Confirm deletion
err := config.DeleteSSHHost(m.deleteHost)
if err != nil {
// Could show error message here
m.deleteMode = false
m.deleteHost = ""
m.table.Focus()
return m, nil
}
// Refresh the host list
hosts, err := config.ParseSSHConfig()
if err != nil {
// Could show error message here
m.deleteMode = false
m.deleteHost = ""
m.table.Focus()
return m, nil
}
m.hosts = sortHostsByName(hosts)
m.filteredHosts = m.hosts
m.updateTableRows()
m.deleteMode = false
m.deleteHost = ""
m.table.Focus()
return m, nil
} else {
// Connect to selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := selected[0] // Host name is in the first column
// Record the connection in history
if m.historyManager != nil {
err := m.historyManager.RecordConnection(hostName)
if err != nil {
// Log error but don't prevent connection
fmt.Printf("Warning: Could not record connection history: %v\n", err)
}
}
return m, tea.ExecProcess(exec.Command("ssh", hostName), func(err error) tea.Msg {
return tea.Quit()
})
}
}
case "e":
if !m.searchMode && !m.deleteMode {
// Edit selected host using dedicated edit form
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := selected[0] // Host name is in the first column
// Store the edit action and exit
m.exitAction = "edit"
m.exitHostName = hostName
return m, tea.Quit
}
}
case "a":
if !m.searchMode && !m.deleteMode {
// Add new host using dedicated add form
m.exitAction = "add"
return m, tea.Quit
}
case "d":
if !m.searchMode && !m.deleteMode {
// Delete selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := selected[0] // Host name is in the first column
m.deleteMode = true
m.deleteHost = hostName
m.table.Blur()
return m, nil
}
}
case "s":
if !m.searchMode && !m.deleteMode {
// Cycle through sort modes (only 2 modes now)
m.sortMode = (m.sortMode + 1) % 2
// Re-apply current filter with new sort mode
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.sortHosts(m.hosts)
}
m.updateTableRows()
return m, nil
}
case "r":
if !m.searchMode && !m.deleteMode {
// Switch to sort by recent (last used)
m.sortMode = SortByLastUsed
// Re-apply current filter with new sort mode
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.sortHosts(m.hosts)
}
m.updateTableRows()
return m, nil
}
case "n":
if !m.searchMode && !m.deleteMode {
// Switch to sort by name
m.sortMode = SortByName
// Re-apply current filter with new sort mode
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.sortHosts(m.hosts)
}
m.updateTableRows()
return m, nil
}
}
}
// Update components based on mode
if m.searchMode {
oldValue := m.searchInput.Value()
m.searchInput, cmd = m.searchInput.Update(msg)
// Only update filtered hosts if search value changed
if m.searchInput.Value() != oldValue {
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.sortHosts(m.hosts)
}
m.updateTableRows()
}
} else {
m.table, cmd = m.table.Update(msg)
}
return m, cmd
}
func (m Model) View() string {
if m.deleteMode {
return m.renderDeleteConfirmation()
}
var view strings.Builder
// Add ASCII title
view.WriteString(headerStyle.Render(asciiTitle) + "\n")
// Add search bar (always visible) with appropriate style based on focus
searchPrompt := "Search (/ to focus, Tab to switch): "
if m.searchMode {
view.WriteString(searchStyleFocused.Render(searchPrompt+m.searchInput.View()) + "\n")
} else {
view.WriteString(searchStyleUnfocused.Render(searchPrompt+m.searchInput.View()) + "\n")
}
// Add sort mode indicator
sortInfo := fmt.Sprintf("Sort: %s", m.sortMode.String())
view.WriteString(lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Render(sortInfo) + "\n\n")
// Add table with appropriate style based on focus
if m.searchMode {
// Table is not focused, use gray border
tableStyle := lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240"))
view.WriteString(tableStyle.Render(m.table.View()))
} else {
// Table is focused, use green border
tableStyle := lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("36"))
view.WriteString(tableStyle.Render(m.table.View()))
}
// Add help text
if !m.searchMode {
view.WriteString("\nUse ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • Tab to switch")
view.WriteString("\nSort: (s)witch • (r)ecent • (n)ame • q/ESC to quit")
} else {
view.WriteString("\nType to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit")
}
return view.String()
}
// sortHosts sorts hosts based on the current sort mode
func (m Model) sortHosts(hosts []config.SSHHost) []config.SSHHost {
if m.historyManager == nil {
return sortHostsByName(hosts)
}
switch m.sortMode {
case SortByLastUsed:
return m.historyManager.SortHostsByLastUsed(hosts)
case SortByName:
fallthrough
default:
return sortHostsByName(hosts)
}
}
// sortHostsByName sorts a slice of SSH hosts alphabetically by name
func sortHostsByName(hosts []config.SSHHost) []config.SSHHost {
sorted := make([]config.SSHHost, len(hosts))
copy(sorted, hosts)
sort.Slice(sorted, func(i, j int) bool {
return strings.ToLower(sorted[i].Name) < strings.ToLower(sorted[j].Name)
})
return sorted
}
// calculateNameColumnWidth calculates the optimal width for the Name column
// based on the longest host name, with a minimum of 8 and maximum of 40 characters
func calculateNameColumnWidth(hosts []config.SSHHost) int {
maxLength := 8 // Minimum width to accommodate the "Name" header
for _, host := range hosts {
if len(host.Name) > maxLength {
maxLength = len(host.Name)
}
}
// Add some padding (2 characters) for better visual spacing
maxLength += 2
// Cap the maximum width to avoid extremely wide columns
if maxLength > 40 {
maxLength = 40
}
return maxLength
}
// calculateTagsColumnWidth calculates the optimal width for the Tags column
// based on the longest tags string, with a minimum of 8 and maximum of 40 characters
func calculateTagsColumnWidth(hosts []config.SSHHost) int {
maxLength := 8 // Minimum width to accommodate the "Tags" header
for _, host := range hosts {
// Format tags exactly the same way they appear in the table
var tagsStr string
if len(host.Tags) > 0 {
// Add # prefix to each tag and join with spaces
var formattedTags []string
for _, tag := range host.Tags {
formattedTags = append(formattedTags, "#"+tag)
}
tagsStr = strings.Join(formattedTags, " ")
}
if len(tagsStr) > maxLength {
maxLength = len(tagsStr)
}
}
// Add some padding (2 characters) for better visual spacing
maxLength += 2
// Cap the maximum width to avoid extremely wide columns
if maxLength > 40 {
maxLength = 40
}
return maxLength
}
// formatTimeAgo formats a time into a human-readable "time ago" string
func formatTimeAgo(t time.Time) string {
now := time.Now()
duration := now.Sub(t)
switch {
case duration < time.Minute:
seconds := int(duration.Seconds())
if seconds <= 1 {
return "1 second ago"
}
return fmt.Sprintf("%d seconds ago", seconds)
case duration < time.Hour:
minutes := int(duration.Minutes())
if minutes == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", minutes)
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)
case duration < 365*24*time.Hour:
months := int(duration.Hours() / (24 * 30))
if months == 1 {
return "1 month ago"
}
return fmt.Sprintf("%d months ago", months)
default:
years := int(duration.Hours() / (24 * 365))
if years == 1 {
return "1 year ago"
}
return fmt.Sprintf("%d years ago", years)
}
}
// calculateLastLoginColumnWidth calculates the optimal width for the Last Login column
// based on the longest time format, with a minimum of 12 and maximum of 20 characters
func calculateLastLoginColumnWidth(hosts []config.SSHHost, historyManager *history.HistoryManager) int {
maxLength := 12 // Minimum width to accommodate the "Last Login" header
if historyManager != nil {
for _, host := range hosts {
if lastConnect, exists := historyManager.GetLastConnectionTime(host.Name); exists {
timeStr := formatTimeAgo(lastConnect)
if len(timeStr) > maxLength {
maxLength = len(timeStr)
}
}
}
}
// Add some padding (2 characters) for better visual spacing
maxLength += 2
// Cap the maximum width to avoid extremely wide columns
if maxLength > 20 {
maxLength = 20
}
return maxLength
}
// calculateInfoColumnWidth calculates the optimal width for the combined Info column
// based on the longest combined tags and history string, with a minimum of 12 and maximum of 60 characters
// enterEditMode initializes edit mode for a specific host
// NewModel creates a new TUI model with the given SSH hosts
func NewModel(hosts []config.SSHHost) Model {
// Initialize history manager
historyManager, err := history.NewHistoryManager()
if err != nil {
// Log error but continue without history functionality
fmt.Printf("Warning: Could not initialize history manager: %v\n", err)
historyManager = nil
}
// Create the model with default sorting by name
m := Model{
hosts: hosts,
historyManager: historyManager,
sortMode: SortByName,
}
// Sort hosts based on default sort mode
sortedHosts := m.sortHosts(hosts)
// Create search input
ti := textinput.New()
ti.Placeholder = "Search hosts or tags..."
ti.CharLimit = 50
ti.Width = 50
// Calculate optimal width for the Name column
nameWidth := calculateNameColumnWidth(sortedHosts)
// Calculate optimal width for the Tags column
tagsWidth := calculateTagsColumnWidth(sortedHosts)
// Calculate optimal width for the Last Login column
lastLoginWidth := calculateLastLoginColumnWidth(sortedHosts, historyManager)
// Create table columns
columns := []table.Column{
{Title: "Name", Width: nameWidth},
{Title: "Hostname", Width: 25},
{Title: "User", Width: 12},
{Title: "Port", Width: 6},
{Title: "Tags", Width: tagsWidth},
{Title: "Last Login", Width: lastLoginWidth},
}
// Convert hosts to table rows
var rows []table.Row
for _, host := range sortedHosts {
// Format tags for display
var tagsStr string
if len(host.Tags) > 0 {
// Add # prefix to each tag and join with spaces
var formattedTags []string
for _, tag := range host.Tags {
formattedTags = append(formattedTags, "#"+tag)
}
tagsStr = strings.Join(formattedTags, " ")
}
// Format last login info
var lastLoginStr string
if historyManager != nil {
if lastConnect, exists := historyManager.GetLastConnectionTime(host.Name); exists {
lastLoginStr = formatTimeAgo(lastConnect)
}
}
rows = append(rows, table.Row{
host.Name,
host.Hostname,
host.User,
host.Port,
tagsStr,
lastLoginStr,
})
}
// Déterminer la hauteur du tableau : 1 (header) + nombre de hosts (max 10)
hostCount := len(rows)
tableHeight := 1 // header
if hostCount < 10 {
tableHeight += hostCount
} else {
tableHeight += 10
}
// Create table
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(tableHeight),
)
// Style the table
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(false)
s.Selected = s.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
t.SetStyles(s)
// Update the model with the table and other properties
m.table = t
m.searchInput = ti
m.filteredHosts = sortedHosts
return m
}
// RunInteractiveMode starts the interactive TUI
func RunInteractiveMode(hosts []config.SSHHost) error {
for {
m := NewModel(hosts)
// Start the application in alt screen mode for clean exit
p := tea.NewProgram(m, tea.WithAltScreen())
finalModel, err := p.Run()
if err != nil {
return fmt.Errorf("error running TUI: %w", err)
}
// Check if the final model indicates an action
if model, ok := finalModel.(Model); ok {
if model.exitAction == "edit" && model.exitHostName != "" {
// Launch the dedicated edit form (opens in separate window)
if err := RunEditForm(model.exitHostName); err != nil {
fmt.Printf("Error editing host: %v\n", err)
// Continue the loop to return to the main interface
continue
}
// Refresh the hosts list after editing
refreshedHosts, err := config.ParseSSHConfig()
if err != nil {
return fmt.Errorf("error refreshing hosts after edit: %w", err)
}
hosts = refreshedHosts
// Continue the loop to return to the main interface
continue
} else if model.exitAction == "add" {
// Launch the dedicated add form (opens in separate window)
if err := RunAddForm(""); err != nil {
fmt.Printf("Error adding host: %v\n", err)
// Continue the loop to return to the main interface
continue
}
// Refresh the hosts list after adding
refreshedHosts, err := config.ParseSSHConfig()
if err != nil {
return fmt.Errorf("error refreshing hosts after add: %w", err)
}
hosts = refreshedHosts
// Continue the loop to return to the main interface
continue
}
}
// If no special command, exit normally
break
}
return nil
}
// filterHosts filters hosts based on search query (name or tags)
func (m Model) filterHosts(query string) []config.SSHHost {
var filtered []config.SSHHost
if query == "" {
filtered = m.hosts
} else {
query = strings.ToLower(query)
for _, host := range m.hosts {
// Check host name
if strings.Contains(strings.ToLower(host.Name), query) {
filtered = append(filtered, host)
continue
}
// Check hostname
if strings.Contains(strings.ToLower(host.Hostname), query) {
filtered = append(filtered, host)
continue
}
// Check tags
for _, tag := range host.Tags {
if strings.Contains(strings.ToLower(tag), query) {
filtered = append(filtered, host)
break
}
}
}
}
return m.sortHosts(filtered)
}
// updateTableRows updates the table with filtered hosts
func (m *Model) updateTableRows() {
var rows []table.Row
hostsToShow := m.filteredHosts
if hostsToShow == nil {
hostsToShow = m.hosts
}
for _, host := range hostsToShow {
// Format tags for display
var tagsStr string
if len(host.Tags) > 0 {
// Add # prefix to each tag and join with spaces
var formattedTags []string
for _, tag := range host.Tags {
formattedTags = append(formattedTags, "#"+tag)
}
tagsStr = strings.Join(formattedTags, " ")
}
// Format last login info
var lastLoginStr string
if m.historyManager != nil {
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
lastLoginStr = formatTimeAgo(lastConnect)
}
}
rows = append(rows, table.Row{
host.Name,
host.Hostname,
host.User,
host.Port,
tagsStr,
lastLoginStr,
})
}
m.table.SetRows(rows)
}
// enterEditMode initializes edit mode for a specific host
// renderDeleteConfirmation renders the delete confirmation dialog
func (m Model) renderDeleteConfirmation() string {
var view strings.Builder
view.WriteString(lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("1")). // Red border
Padding(1, 2).
Render(fmt.Sprintf("⚠️ Delete SSH Host\n\nAre you sure you want to delete host '%s'?\n\nThis action cannot be undone.\n\nPress Enter to confirm or Esc to cancel", m.deleteHost)))
return view.String()
}