5 Commits

Author SHA1 Message Date
98aa2b6579 cleanup: delete unused Model fields 2025-09-02 14:14:26 +02:00
5dca755b11 refactor(ui): split TUI logic into multiple files and improve styling 2025-09-02 13:17:20 +02:00
1d50e7cb47 fix: reformat code 2025-09-02 09:21:00 +02:00
01f2b4e6be feat: add SSH connection history with TUI sorting by last login 2025-09-02 09:17:34 +02:00
534b7d9a6c feat: improve search UX with persistent search bar and better navigation
- Always-visible search bar with focus indicators
- Tab navigation between search and table
- ASCII title header
- Simplified keyboard shortcuts (/, Tab, ESC)
- Fixed search validation workflow
- Enhanced visual feedback and accessibility
2025-09-01 14:36:09 +02:00
11 changed files with 1357 additions and 494 deletions

208
internal/history/history.go Normal file
View File

@@ -0,0 +1,208 @@
package history
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"time"
"sshm/internal/config"
)
// ConnectionHistory represents the history of SSH connections
type ConnectionHistory struct {
Connections map[string]ConnectionInfo `json:"connections"`
}
// ConnectionInfo stores information about a specific connection
type ConnectionInfo struct {
HostName string `json:"host_name"`
LastConnect time.Time `json:"last_connect"`
ConnectCount int `json:"connect_count"`
}
// HistoryManager manages the connection history
type HistoryManager struct {
historyPath string
history *ConnectionHistory
}
// NewHistoryManager creates a new history manager
func NewHistoryManager() (*HistoryManager, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
historyPath := filepath.Join(homeDir, ".ssh", "sshm_history.json")
hm := &HistoryManager{
historyPath: historyPath,
history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)},
}
// Load existing history if it exists
err = hm.loadHistory()
if err != nil {
// If file doesn't exist, that's okay - we'll create it when needed
if !os.IsNotExist(err) {
return nil, err
}
}
return hm, nil
}
// loadHistory loads the connection history from the JSON file
func (hm *HistoryManager) loadHistory() error {
data, err := os.ReadFile(hm.historyPath)
if err != nil {
return err
}
return json.Unmarshal(data, hm.history)
}
// saveHistory saves the connection history to the JSON file
func (hm *HistoryManager) saveHistory() error {
// Ensure the directory exists
dir := filepath.Dir(hm.historyPath)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
data, err := json.MarshalIndent(hm.history, "", " ")
if err != nil {
return err
}
return os.WriteFile(hm.historyPath, data, 0600)
}
// RecordConnection records a new connection for the specified host
func (hm *HistoryManager) RecordConnection(hostName string) error {
now := time.Now()
if conn, exists := hm.history.Connections[hostName]; exists {
// Update existing connection
conn.LastConnect = now
conn.ConnectCount++
hm.history.Connections[hostName] = conn
} else {
// Create new connection record
hm.history.Connections[hostName] = ConnectionInfo{
HostName: hostName,
LastConnect: now,
ConnectCount: 1,
}
}
return hm.saveHistory()
}
// GetLastConnectionTime returns the last connection time for a host
func (hm *HistoryManager) GetLastConnectionTime(hostName string) (time.Time, bool) {
if conn, exists := hm.history.Connections[hostName]; exists {
return conn.LastConnect, true
}
return time.Time{}, false
}
// GetConnectionCount returns the total number of connections for a host
func (hm *HistoryManager) GetConnectionCount(hostName string) int {
if conn, exists := hm.history.Connections[hostName]; exists {
return conn.ConnectCount
}
return 0
}
// SortHostsByLastUsed sorts hosts by their last connection time (most recent first)
func (hm *HistoryManager) SortHostsByLastUsed(hosts []config.SSHHost) []config.SSHHost {
sorted := make([]config.SSHHost, len(hosts))
copy(sorted, hosts)
sort.Slice(sorted, func(i, j int) bool {
timeI, existsI := hm.GetLastConnectionTime(sorted[i].Name)
timeJ, existsJ := hm.GetLastConnectionTime(sorted[j].Name)
// If both have history, sort by most recent first
if existsI && existsJ {
return timeI.After(timeJ)
}
// Hosts with history come before hosts without history
if existsI && !existsJ {
return true
}
if !existsI && existsJ {
return false
}
// If neither has history, sort alphabetically
return sorted[i].Name < sorted[j].Name
})
return sorted
}
// SortHostsByMostUsed sorts hosts by their connection count (most used first)
func (hm *HistoryManager) SortHostsByMostUsed(hosts []config.SSHHost) []config.SSHHost {
sorted := make([]config.SSHHost, len(hosts))
copy(sorted, hosts)
sort.Slice(sorted, func(i, j int) bool {
countI := hm.GetConnectionCount(sorted[i].Name)
countJ := hm.GetConnectionCount(sorted[j].Name)
// If counts are different, sort by count (highest first)
if countI != countJ {
return countI > countJ
}
// If counts are equal, sort by most recent
timeI, existsI := hm.GetLastConnectionTime(sorted[i].Name)
timeJ, existsJ := hm.GetLastConnectionTime(sorted[j].Name)
if existsI && existsJ {
return timeI.After(timeJ)
}
// If neither has history, sort alphabetically
return sorted[i].Name < sorted[j].Name
})
return sorted
}
// CleanupOldEntries removes connection history for hosts that no longer exist
func (hm *HistoryManager) CleanupOldEntries(currentHosts []config.SSHHost) error {
// Create a set of current host names
currentHostNames := make(map[string]bool)
for _, host := range currentHosts {
currentHostNames[host.Name] = true
}
// Remove entries for hosts that no longer exist
for hostName := range hm.history.Connections {
if !currentHostNames[hostName] {
delete(hm.history.Connections, hostName)
}
}
return hm.saveHistory()
}
// GetAllConnectionsInfo returns all connection information sorted by last connection time
func (hm *HistoryManager) GetAllConnectionsInfo() []ConnectionInfo {
var connections []ConnectionInfo
for _, conn := range hm.history.Connections {
connections = append(connections, conn)
}
sort.Slice(connections, func(i, j int) bool {
return connections[i].LastConnect.After(connections[j].LastConnect)
})
return connections
}

View File

@@ -10,44 +10,20 @@ import (
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var (
titleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFDF5")).
Background(lipgloss.Color("#25A065")).
Padding(0, 1)
fieldStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575"))
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
helpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#626262"))
)
type addFormModel struct {
inputs []textinput.Model
focused int
err string
styles Styles
success bool
width int
height int
}
const (
nameInput = iota
hostnameInput
userInput
portInput
identityInput
proxyJumpInput
optionsInput
tagsInput
)
func RunAddForm(hostname string) error {
// NewAddForm creates a new add form model
func NewAddForm(hostname string, styles Styles, width, height int) *addFormModel {
// Get current user for default
currentUser, _ := user.Current()
defaultUser := "root"
@@ -123,28 +99,52 @@ func RunAddForm(hostname string) error {
inputs[tagsInput].CharLimit = 200
inputs[tagsInput].Width = 50
m := addFormModel{
return &addFormModel{
inputs: inputs,
focused: nameInput,
styles: styles,
width: width,
height: height,
}
p := tea.NewProgram(&m, tea.WithAltScreen())
_, err := p.Run()
return err
}
const (
nameInput = iota
hostnameInput
userInput
portInput
identityInput
proxyJumpInput
optionsInput
tagsInput
)
// Messages for communication with parent model
type addFormSubmitMsg struct {
hostname string
err error
}
type addFormCancelMsg struct{}
func (m *addFormModel) Init() tea.Cmd {
return textinput.Blink
}
func (m *addFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *addFormModel) Update(msg tea.Msg) (*addFormModel, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.styles = NewStyles(m.width)
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
return m, func() tea.Msg { return addFormCancelMsg{} }
case "ctrl+enter":
// Allow submission from any field with Ctrl+Enter
@@ -182,14 +182,15 @@ func (m *addFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
case submitResult:
case addFormSubmitMsg:
if msg.err != nil {
m.err = msg.err.Error()
} else {
m.success = true
m.err = ""
return m, tea.Quit
// Don't quit here, let parent handle the success
}
return m, nil
}
// Update inputs
@@ -209,7 +210,7 @@ func (m *addFormModel) View() string {
var b strings.Builder
b.WriteString(titleStyle.Render("Add SSH Host Configuration"))
b.WriteString(m.styles.FormTitle.Render("Add SSH Host Configuration"))
b.WriteString("\n\n")
fields := []string{
@@ -224,27 +225,57 @@ func (m *addFormModel) View() string {
}
for i, field := range fields {
b.WriteString(fieldStyle.Render(field))
b.WriteString(m.styles.FormField.Render(field))
b.WriteString("\n")
b.WriteString(m.inputs[i].View())
b.WriteString("\n\n")
}
if m.err != "" {
b.WriteString(errorStyle.Render("Error: " + m.err))
b.WriteString(m.styles.Error.Render("Error: " + m.err))
b.WriteString("\n\n")
}
b.WriteString(helpStyle.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
b.WriteString("\n")
b.WriteString(helpStyle.Render("* Required fields"))
b.WriteString(m.styles.FormHelp.Render("* Required fields"))
return b.String()
}
type submitResult struct {
hostname string
err error
// Standalone wrapper for add form
type standaloneAddForm struct {
*addFormModel
}
func (m standaloneAddForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case addFormSubmitMsg:
if msg.err != nil {
m.addFormModel.err = msg.err.Error()
} else {
m.addFormModel.success = true
return m, tea.Quit
}
return m, nil
case addFormCancelMsg:
return m, tea.Quit
}
newForm, cmd := m.addFormModel.Update(msg)
m.addFormModel = newForm
return m, cmd
}
// RunAddForm provides backward compatibility for standalone add form
func RunAddForm(hostname string) error {
styles := NewStyles(80)
addForm := NewAddForm(hostname, styles, 80, 24)
m := standaloneAddForm{addForm}
p := tea.NewProgram(m, tea.WithAltScreen())
_, err := p.Run()
return err
}
func (m *addFormModel) submitForm() tea.Cmd {
@@ -269,7 +300,7 @@ func (m *addFormModel) submitForm() tea.Cmd {
// Validate all fields
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
return submitResult{err: err}
return addFormSubmitMsg{err: err}
}
tagsStr := strings.TrimSpace(m.inputs[tagsInput].Value())
@@ -297,6 +328,6 @@ func (m *addFormModel) submitForm() tea.Cmd {
// Add to config
err := config.AddSSHHost(host)
return submitResult{hostname: name, err: err}
return addFormSubmitMsg{hostname: name, err: err}
}
}

View File

@@ -7,23 +7,6 @@ import (
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var (
titleStyleEdit = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFDF5")).
Background(lipgloss.Color("#25A065")).
Padding(0, 1)
fieldStyleEdit = lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575"))
errorStyleEdit = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
helpStyleEdit = lipgloss.NewStyle().
Foreground(lipgloss.Color("#626262"))
)
type editFormModel struct {
@@ -31,14 +14,18 @@ type editFormModel struct {
focused int
err string
success bool
styles Styles
originalName string
width int
height int
}
func RunEditForm(hostName string) error {
// NewEditForm creates a new edit form model
func NewEditForm(hostName string, styles Styles, width, height int) (*editFormModel, error) {
// Get the existing host configuration
host, err := config.GetSSHHost(hostName)
if err != nil {
return err
return nil, err
}
inputs := make([]textinput.Model, 8)
@@ -102,30 +89,42 @@ func RunEditForm(hostName string) error {
inputs[tagsInput].SetValue(strings.Join(host.Tags, ", "))
}
m := editFormModel{
return &editFormModel{
inputs: inputs,
focused: nameInput,
originalName: hostName,
}
// Open in separate window like add form
p := tea.NewProgram(&m, tea.WithAltScreen())
_, err = p.Run()
return err
styles: styles,
width: width,
height: height,
}, nil
}
// Messages for communication with parent model
type editFormSubmitMsg struct {
hostname string
err error
}
type editFormCancelMsg struct{}
func (m *editFormModel) Init() tea.Cmd {
return textinput.Blink
}
func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *editFormModel) Update(msg tea.Msg) (*editFormModel, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.styles = NewStyles(m.width)
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
return m, func() tea.Msg { return editFormCancelMsg{} }
case "ctrl+enter":
// Allow submission from any field with Ctrl+Enter
@@ -163,14 +162,15 @@ func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
case editResult:
case editFormSubmitMsg:
if msg.err != nil {
m.err = msg.err.Error()
} else {
m.success = true
m.err = ""
return m, tea.Quit
// Don't quit here, let parent handle the success
}
return m, nil
}
// Update inputs
@@ -190,7 +190,7 @@ func (m *editFormModel) View() string {
var b strings.Builder
b.WriteString(titleStyleEdit.Render("Edit SSH Host Configuration"))
b.WriteString(m.styles.FormTitle.Render("Edit SSH Host Configuration"))
b.WriteString("\n\n")
fields := []string{
@@ -205,27 +205,60 @@ func (m *editFormModel) View() string {
}
for i, field := range fields {
b.WriteString(fieldStyleEdit.Render(field))
b.WriteString(m.styles.FormField.Render(field))
b.WriteString("\n")
b.WriteString(m.inputs[i].View())
b.WriteString("\n\n")
}
if m.err != "" {
b.WriteString(errorStyleEdit.Render("Error: " + m.err))
b.WriteString(m.styles.Error.Render("Error: " + m.err))
b.WriteString("\n\n")
}
b.WriteString(helpStyleEdit.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
b.WriteString("\n")
b.WriteString(helpStyleEdit.Render("* Required fields"))
b.WriteString(m.styles.FormHelp.Render("* Required fields"))
return b.String()
}
type editResult struct {
hostname string
err error
// Standalone wrapper for edit form
type standaloneEditForm struct {
*editFormModel
}
func (m standaloneEditForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case editFormSubmitMsg:
if msg.err != nil {
m.editFormModel.err = msg.err.Error()
} else {
m.editFormModel.success = true
return m, tea.Quit
}
return m, nil
case editFormCancelMsg:
return m, tea.Quit
}
newForm, cmd := m.editFormModel.Update(msg)
m.editFormModel = newForm
return m, cmd
}
// RunEditForm provides backward compatibility for standalone edit form
func RunEditForm(hostName string) error {
styles := NewStyles(80)
editForm, err := NewEditForm(hostName, styles, 80, 24)
if err != nil {
return err
}
m := standaloneEditForm{editForm}
p := tea.NewProgram(m, tea.WithAltScreen())
_, err = p.Run()
return err
}
func (m *editFormModel) submitEditForm() tea.Cmd {
@@ -247,7 +280,7 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
// Validate all fields
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
return editResult{err: err}
return editFormSubmitMsg{err: err}
}
// Parse tags
@@ -276,6 +309,6 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
// Update the configuration
err := config.UpdateSSHHost(m.originalName, host)
return editResult{hostname: name, err: err}
return editFormSubmitMsg{hostname: name, err: err}
}
}

86
internal/ui/model.go Normal file
View File

@@ -0,0 +1,86 @@
package ui
import (
"sshm/internal/config"
"sshm/internal/history"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/lipgloss"
)
// SortMode defines the available sorting modes
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)"
}
}
// ViewMode defines the current view state
type ViewMode int
const (
ViewList ViewMode = iota
ViewAdd
ViewEdit
)
// Model represents the state of the user interface
type Model struct {
table table.Model
searchInput textinput.Model
hosts []config.SSHHost
filteredHosts []config.SSHHost
searchMode bool
deleteMode bool
deleteHost string
historyManager *history.HistoryManager
sortMode SortMode
// View management
viewMode ViewMode
addForm *addFormModel
editForm *editFormModel
// Terminal size and styles
width int
height int
styles Styles
ready bool
}
// updateTableStyles updates the table header border color based on focus state
func (m *Model) updateTableStyles() {
s := table.DefaultStyles()
s.Selected = m.styles.Selected
if m.searchMode {
// When in search mode, use secondary color for table header
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color(SecondaryColor)).
BorderBottom(true).
Bold(false)
} else {
// When table is focused, use primary color for table header
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color(PrimaryColor)).
BorderBottom(true).
Bold(false)
}
m.table.SetStyles(s)
}

71
internal/ui/sort.go Normal file
View File

@@ -0,0 +1,71 @@
package ui
import (
"sort"
"strings"
"sshm/internal/config"
)
// sortHosts sorts hosts according to 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
}
// filterHosts filters hosts according to the 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 the hostname
if strings.Contains(strings.ToLower(host.Name), query) {
filtered = append(filtered, host)
continue
}
// Check the hostname
if strings.Contains(strings.ToLower(host.Hostname), query) {
filtered = append(filtered, host)
continue
}
// Check the tags
for _, tag := range host.Tags {
if strings.Contains(strings.ToLower(tag), query) {
filtered = append(filtered, host)
break
}
}
}
}
return m.sortHosts(filtered)
}

117
internal/ui/styles.go Normal file
View File

@@ -0,0 +1,117 @@
package ui
import "github.com/charmbracelet/lipgloss"
// Theme colors
var (
// Primary interface color - easily modifiable
PrimaryColor = "#00ADD8" // Official Go logo blue color
// Secondary colors
SecondaryColor = "240" // Gray
ErrorColor = "1" // Red
SuccessColor = "36" // Green (for reference if needed)
)
// Styles struct centralizes all lipgloss styles
type Styles struct {
// Layout
App lipgloss.Style
Header lipgloss.Style
// Search styles
SearchFocused lipgloss.Style
SearchUnfocused lipgloss.Style
// Table styles
TableFocused lipgloss.Style
TableUnfocused lipgloss.Style
Selected lipgloss.Style
// Info and help styles
SortInfo lipgloss.Style
HelpText lipgloss.Style
// Error and confirmation styles
Error lipgloss.Style
// Form styles (for add/edit forms)
FormTitle lipgloss.Style
FormField lipgloss.Style
FormHelp lipgloss.Style
}
// NewStyles creates a new Styles struct with the given terminal width
func NewStyles(width int) Styles {
return Styles{
// Main app container
App: lipgloss.NewStyle().
Padding(1),
// Header style
Header: lipgloss.NewStyle().
Foreground(lipgloss.Color(PrimaryColor)).
Bold(true).
Align(lipgloss.Center),
// Search styles
SearchFocused: lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(PrimaryColor)).
Padding(0, 1),
SearchUnfocused: lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(SecondaryColor)).
Padding(0, 1),
// Table styles
TableFocused: lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color(PrimaryColor)),
TableUnfocused: lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color(SecondaryColor)),
// Style for selected items
Selected: lipgloss.NewStyle().
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color(PrimaryColor)).
Bold(false),
// Info styles
SortInfo: lipgloss.NewStyle().
Foreground(lipgloss.Color(SecondaryColor)),
HelpText: lipgloss.NewStyle().
Foreground(lipgloss.Color(SecondaryColor)).
MarginTop(1),
// Error style
Error: lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(ErrorColor)).
Padding(1, 2),
// Form styles
FormTitle: lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFDF5")).
Background(lipgloss.Color(PrimaryColor)).
Padding(0, 1),
FormField: lipgloss.NewStyle().
Foreground(lipgloss.Color(PrimaryColor)),
FormHelp: lipgloss.NewStyle().
Foreground(lipgloss.Color("#626262")),
}
}
// Application ASCII title
const asciiTitle = `
_____ _____ __ __ _____
| __| __| | | |
|__ |__ | | | | |
|_____|_____|__|__|_|_|_|
`

133
internal/ui/table.go Normal file
View File

@@ -0,0 +1,133 @@
package ui
import (
"strings"
"sshm/internal/config"
"sshm/internal/history"
"github.com/charmbracelet/bubbles/table"
)
// calculateNameColumnWidth calculates the optimal width for the Name column
// based on the longest hostname, 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
// Limit the maximum width to avoid extremely large columns
if maxLength > 40 {
maxLength = 40
}
return maxLength
}
// calculateTagsColumnWidth calculates the optimal width for the Tags column
// based on the longest tag 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 as they appear in the table
var tagsStr string
if len(host.Tags) > 0 {
// Add the # prefix to each tag and join them 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
// Limit the maximum width to avoid extremely large columns
if maxLength > 40 {
maxLength = 40
}
return maxLength
}
// 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
// Limit the maximum width to avoid extremely large columns
if maxLength > 20 {
maxLength = 20
}
return maxLength
}
// 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 the # prefix to each tag and join them with spaces
var formattedTags []string
for _, tag := range host.Tags {
formattedTags = append(formattedTags, "#"+tag)
}
tagsStr = strings.Join(formattedTags, " ")
}
// Format last login information
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)
}

View File

@@ -2,11 +2,10 @@ package ui
import (
"fmt"
"os/exec"
"sort"
"strings"
"sshm/internal/config"
"sshm/internal/history"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
@@ -14,258 +13,35 @@ import (
"github.com/charmbracelet/lipgloss"
)
var baseStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240"))
var searchStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("36")).
Padding(0, 1)
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
}
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.searchMode {
// Exit search mode
m.searchMode = false
m.searchInput.Blur()
m.table.Focus()
return m, nil
}
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 "enter":
if m.searchMode {
// Exit search mode and focus table
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
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
}
}
}
}
// Update components based on mode
if m.searchMode {
m.searchInput, cmd = m.searchInput.Update(msg)
// Filter hosts when search input changes
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = 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 search bar
searchPrompt := "Search (/ to search, ESC to exit search): "
if m.searchMode {
view.WriteString(searchStyle.Render(searchPrompt+m.searchInput.View()) + "\n\n")
}
// Add table
view.WriteString(baseStyle.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 • (q)uit")
} else {
view.WriteString("\nType to filter hosts by name or tag • Enter to select • ESC to exit search")
}
return view.String()
}
// 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 50 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 > 50 {
maxLength = 50
}
return maxLength
}
// NewModel creates a new TUI model with the given SSH hosts
func NewModel(hosts []config.SSHHost) Model {
// Sort hosts alphabetically by name
sortedHosts := sortHostsByName(hosts)
// Initialize the history manager
historyManager, err := history.NewHistoryManager()
if err != nil {
// Log the error but continue without the history functionality
fmt.Printf("Warning: Could not initialize history manager: %v\n", err)
historyManager = nil
}
// Create search input
// Create initial styles (will be updated on first WindowSizeMsg)
styles := NewStyles(80) // Default width
// Create the model with default sorting by name
m := Model{
hosts: hosts,
historyManager: historyManager,
sortMode: SortByName,
styles: styles,
width: 80,
height: 24,
ready: false,
viewMode: ViewList,
}
// Sort hosts according to the default sort mode
sortedHosts := m.sortHosts(hosts)
// Create the search input
ti := textinput.New()
ti.Placeholder = "Search hosts or tags..."
ti.CharLimit = 50
@@ -277,6 +53,9 @@ func NewModel(hosts []config.SSHHost) Model {
// 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},
@@ -284,6 +63,7 @@ func NewModel(hosts []config.SSHHost) Model {
{Title: "User", Width: 12},
{Title: "Port", Width: 6},
{Title: "Tags", Width: tagsWidth},
{Title: "Last Login", Width: lastLoginWidth},
}
// Convert hosts to table rows
@@ -292,7 +72,7 @@ func NewModel(hosts []config.SSHHost) Model {
// Format tags for display
var tagsStr string
if len(host.Tags) > 0 {
// Add # prefix to each tag and join with spaces
// Add the # prefix to each tag and join them with spaces
var formattedTags []string
for _, tag := range host.Tags {
formattedTags = append(formattedTags, "#"+tag)
@@ -300,16 +80,25 @@ func NewModel(hosts []config.SSHHost) Model {
tagsStr = strings.Join(formattedTags, " ")
}
// Format last login information
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)
// Determine table height: 1 (header) + number of hosts (max 10)
hostCount := len(rows)
tableHeight := 1 // header
if hostCount < 10 {
@@ -318,7 +107,7 @@ func NewModel(hosts []config.SSHHost) Model {
tableHeight += 10
}
// Create table
// Create the table
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
@@ -330,167 +119,34 @@ func NewModel(hosts []config.SSHHost) Model {
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderForeground(lipgloss.Color(SecondaryColor)).
BorderBottom(true).
Bold(false)
s.Selected = s.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
s.Selected = m.styles.Selected
t.SetStyles(s)
return Model{
table: t,
searchInput: ti,
hosts: sortedHosts,
filteredHosts: sortedHosts,
searchMode: false,
}
// Update the model with the table and other properties
m.table = t
m.searchInput = ti
m.filteredHosts = sortedHosts
// Initialize table styles based on initial focus state
m.updateTableStyles()
return m
}
// RunInteractiveMode starts the interactive TUI
// RunInteractiveMode starts the interactive TUI interface
func RunInteractiveMode(hosts []config.SSHHost) error {
for {
m := NewModel(hosts)
m := NewModel(hosts)
// Start the application in terminal (without alt screen)
p := tea.NewProgram(m)
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
}
// Clear screen before returning to TUI
fmt.Print("\033[2J\033[H")
// 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
}
// Clear screen before returning to TUI
fmt.Print("\033[2J\033[H")
// 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
// Start the application in alt screen mode for clean output
p := tea.NewProgram(m, tea.WithAltScreen())
_, err := p.Run()
if err != nil {
return fmt.Errorf("error running TUI: %w", err)
}
return nil
}
// filterHosts filters hosts based on search query (name or tags)
func (m Model) filterHosts(query string) []config.SSHHost {
if query == "" {
return sortHostsByName(m.hosts)
}
query = strings.ToLower(query)
var filtered []config.SSHHost
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 sortHostsByName(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
}
// Sort hosts alphabetically by name
sortedHosts := sortHostsByName(hostsToShow)
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, " ")
}
rows = append(rows, table.Row{
host.Name,
host.Hostname,
host.User,
host.Port,
tagsStr,
})
}
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()
}

324
internal/ui/update.go Normal file
View File

@@ -0,0 +1,324 @@
package ui
import (
"fmt"
"os/exec"
"sshm/internal/config"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
// Init initializes the model
func (m Model) Init() tea.Cmd {
return tea.Batch(
textinput.Blink,
// Ajoute ici d'autres tea.Cmd si tu veux charger des données, démarrer un spinner, etc.
)
}
// Update handles model updates
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
// Handle different message types
switch msg := msg.(type) {
case tea.WindowSizeMsg:
// Update terminal size and recalculate styles
m.width = msg.Width
m.height = msg.Height
m.styles = NewStyles(m.width)
m.ready = true
// Update sub-forms if they exist
if m.addForm != nil {
m.addForm.width = m.width
m.addForm.height = m.height
m.addForm.styles = m.styles
}
if m.editForm != nil {
m.editForm.width = m.width
m.editForm.height = m.height
m.editForm.styles = m.styles
}
return m, nil
case addFormSubmitMsg:
if msg.err != nil {
// Show error in form
if m.addForm != nil {
m.addForm.err = msg.err.Error()
}
return m, nil
} else {
// Success: refresh hosts and return to list view
hosts, err := config.ParseSSHConfig()
if err != nil {
return m, tea.Quit
}
m.hosts = m.sortHosts(hosts)
m.filteredHosts = m.hosts
m.updateTableRows()
m.viewMode = ViewList
m.addForm = nil
m.table.Focus()
return m, nil
}
case addFormCancelMsg:
// Cancel: return to list view
m.viewMode = ViewList
m.addForm = nil
m.table.Focus()
return m, nil
case editFormSubmitMsg:
if msg.err != nil {
// Show error in form
if m.editForm != nil {
m.editForm.err = msg.err.Error()
}
return m, nil
} else {
// Success: refresh hosts and return to list view
hosts, err := config.ParseSSHConfig()
if err != nil {
return m, tea.Quit
}
m.hosts = m.sortHosts(hosts)
m.filteredHosts = m.hosts
m.updateTableRows()
m.viewMode = ViewList
m.editForm = nil
m.table.Focus()
return m, nil
}
case editFormCancelMsg:
// Cancel: return to list view
m.viewMode = ViewList
m.editForm = nil
m.table.Focus()
return m, nil
case tea.KeyMsg:
// Handle view-specific key presses
switch m.viewMode {
case ViewAdd:
if m.addForm != nil {
var newForm *addFormModel
newForm, cmd = m.addForm.Update(msg)
m.addForm = newForm
return m, cmd
}
case ViewEdit:
if m.editForm != nil {
var newForm *editFormModel
newForm, cmd = m.editForm.Update(msg)
m.editForm = newForm
return m, cmd
}
case ViewList:
// Handle list view keys
return m.handleListViewKeys(msg)
}
}
return m, cmd
}
func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
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.updateTableStyles()
m.table.Blur()
m.searchInput.Focus()
return m, textinput.Blink
}
case "tab":
if !m.deleteMode {
// Switch focus between search input and table
if m.searchMode {
// Switch from search to table
m.searchMode = false
m.updateTableStyles()
m.searchInput.Blur()
m.table.Focus()
} else {
// Switch from table to search
m.searchMode = true
m.updateTableStyles()
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.updateTableStyles()
m.searchInput.Blur()
m.table.Focus()
return m, nil
} else if m.deleteMode {
// Confirm deletion
err := config.DeleteSSHHost(m.deleteHost)
if err != nil {
// Could display an error message here
m.deleteMode = false
m.deleteHost = ""
m.table.Focus()
return m, nil
}
// Refresh the hosts list
hosts, err := config.ParseSSHConfig()
if err != nil {
// Could display an error message here
m.deleteMode = false
m.deleteHost = ""
m.table.Focus()
return m, nil
}
m.hosts = m.sortHosts(hosts)
m.filteredHosts = m.hosts
m.updateTableRows()
m.deleteMode = false
m.deleteHost = ""
m.table.Focus()
return m, nil
} else {
// Connect to the selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := selected[0] // The hostname is in the first column
// Record the connection in history
if m.historyManager != nil {
err := m.historyManager.RecordConnection(hostName)
if err != nil {
// Log the error but don't prevent the 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 the selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := selected[0] // The hostname is in the first column
editForm, err := NewEditForm(hostName, m.styles, m.width, m.height)
if err != nil {
// Handle error - could show in UI
return m, nil
}
m.editForm = editForm
m.viewMode = ViewEdit
return m, textinput.Blink
}
}
case "a":
if !m.searchMode && !m.deleteMode {
// Add a new host
m.addForm = NewAddForm("", m.styles, m.width, m.height)
m.viewMode = ViewAdd
return m, textinput.Blink
}
case "d":
if !m.searchMode && !m.deleteMode {
// Delete the selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := selected[0] // The hostname 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 the current filter with the 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 the current filter with the 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 the current filter with the 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 the appropriate component based on mode
if m.searchMode {
oldValue := m.searchInput.Value()
m.searchInput, cmd = m.searchInput.Update(msg)
// Update filtered hosts only if the search value has 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
}

57
internal/ui/utils.go Normal file
View File

@@ -0,0 +1,57 @@
package ui
import (
"fmt"
"time"
)
// formatTimeAgo formats a time into a readable "X 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)
}
}

147
internal/ui/view.go Normal file
View File

@@ -0,0 +1,147 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// View renders the complete user interface
func (m Model) View() string {
if !m.ready {
return "Loading..."
}
// Handle different view modes
switch m.viewMode {
case ViewAdd:
if m.addForm != nil {
return m.addForm.View()
}
case ViewEdit:
if m.editForm != nil {
return m.editForm.View()
}
case ViewList:
return m.renderListView()
}
return m.renderListView()
}
// renderListView renders the main list interface
func (m Model) renderListView() string {
// Build the interface components
components := []string{}
// Add the ASCII title
components = append(components, m.styles.Header.Render(asciiTitle))
// Add the search bar with the appropriate style based on focus
searchPrompt := "Search (/ to focus, Tab to switch): "
if m.searchMode {
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 sort mode indicator
sortInfo := fmt.Sprintf(" Sort: %s", m.sortMode.String())
components = append(components, m.styles.SortInfo.Render(sortInfo))
// Add the table with the appropriate style based on focus
if m.searchMode {
// 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 with the primary color
components = append(components, m.styles.TableFocused.Render(m.table.View()))
}
// Add the help text
var helpText string
if !m.searchMode {
helpText = " Use ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • Tab to switch\n Sort: (s)witch • (r)ecent • (n)ame • q/ESC to quit"
} else {
helpText = " Type to filter hosts • Enter to validate search • Tab to switch to table • ESC to 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...,
),
)
// If in delete mode, overlay the confirmation dialog
if m.deleteMode {
// Combine the main view with the confirmation dialog overlay
confirmation := m.renderDeleteConfirmation()
// Center the confirmation dialog on the screen
centeredConfirmation := lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
confirmation,
)
return centeredConfirmation
}
return mainView
}
// renderDeleteConfirmation renders a clean delete confirmation dialog
func (m Model) renderDeleteConfirmation() string {
// Remove emojis (uncertain width depending on terminal) to stabilize the frame
title := "DELETE SSH HOST"
question := fmt.Sprintf("Are you sure you want to delete host '%s'?", m.deleteHost)
action := "This action cannot be undone."
help := "Enter: confirm • Esc: cancel"
// Individual styles (do not affect width via internal centering)
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("196"))
questionStyle := lipgloss.NewStyle()
actionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
lines := []string{
titleStyle.Render(title),
"",
questionStyle.Render(question),
"",
actionStyle.Render(action),
"",
helpStyle.Render(help),
}
// Compute the real maximum width (ANSI-safe via lipgloss.Width)
maxw := 0
for _, ln := range lines {
w := lipgloss.Width(ln)
if w > maxw {
maxw = w
}
}
// Minimal width for aesthetics
if maxw < 40 {
maxw = 40
}
// Build the raw text block (without centering) then apply the container style
raw := strings.Join(lines, "\n")
// Container style: wider horizontal padding, stable border
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("196")).
PaddingTop(1).PaddingBottom(1).PaddingLeft(2).PaddingRight(2).
Width(maxw + 4) // +4 = internal margin (2 spaces of left/right padding)
return box.Render(raw)
}