From 5dca755b11aef6e74f2e50cea85651d9ea62fb88 Mon Sep 17 00:00:00 2001 From: Gu1llaum-3 Date: Tue, 2 Sep 2025 13:17:20 +0200 Subject: [PATCH] refactor(ui): split TUI logic into multiple files and improve styling --- internal/ui/add_form.go | 127 +++++--- internal/ui/edit_form.go | 113 ++++--- internal/ui/model.go | 89 ++++++ internal/ui/sort.go | 71 +++++ internal/ui/styles.go | 117 +++++++ internal/ui/table.go | 133 ++++++++ internal/ui/tui.go | 649 ++------------------------------------- internal/ui/update.go | 324 +++++++++++++++++++ internal/ui/utils.go | 57 ++++ internal/ui/view.go | 147 +++++++++ 10 files changed, 1119 insertions(+), 708 deletions(-) create mode 100644 internal/ui/model.go create mode 100644 internal/ui/sort.go create mode 100644 internal/ui/styles.go create mode 100644 internal/ui/table.go create mode 100644 internal/ui/update.go create mode 100644 internal/ui/utils.go create mode 100644 internal/ui/view.go diff --git a/internal/ui/add_form.go b/internal/ui/add_form.go index 0c7f126..89caea0 100644 --- a/internal/ui/add_form.go +++ b/internal/ui/add_form.go @@ -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} } } diff --git a/internal/ui/edit_form.go b/internal/ui/edit_form.go index a463b13..0716c4a 100644 --- a/internal/ui/edit_form.go +++ b/internal/ui/edit_form.go @@ -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} } } diff --git a/internal/ui/model.go b/internal/ui/model.go new file mode 100644 index 0000000..c717d39 --- /dev/null +++ b/internal/ui/model.go @@ -0,0 +1,89 @@ +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 + exitAction string + exitHostName string + historyManager *history.HistoryManager + sortMode SortMode + + // View management + viewMode ViewMode + addForm *addFormModel + editForm *editFormModel + previousView ViewMode + + // 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) +} diff --git a/internal/ui/sort.go b/internal/ui/sort.go new file mode 100644 index 0000000..1a4314d --- /dev/null +++ b/internal/ui/sort.go @@ -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) +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go new file mode 100644 index 0000000..5e888c9 --- /dev/null +++ b/internal/ui/styles.go @@ -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 = ` + _____ _____ __ __ _____ +| __| __| | | | +|__ |__ | | | | | +|_____|_____|__|__|_|_|_| +` diff --git a/internal/ui/table.go b/internal/ui/table.go new file mode 100644 index 0000000..f1c22bb --- /dev/null +++ b/internal/ui/table.go @@ -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) +} diff --git a/internal/ui/tui.go b/internal/ui/tui.go index 0b46585..874e734 100644 --- a/internal/ui/tui.go +++ b/internal/ui/tui.go @@ -2,10 +2,7 @@ package ui import ( "fmt" - "os/exec" - "sort" "strings" - "time" "sshm/internal/config" "sshm/internal/history" @@ -16,490 +13,35 @@ import ( "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 + // Initialize the history manager historyManager, err := history.NewHistoryManager() if err != nil { - // Log error but continue without history functionality + // Log the error but continue without the history functionality fmt.Printf("Warning: Could not initialize history manager: %v\n", err) historyManager = nil } + // 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 based on default sort mode + // Sort hosts according to the default sort mode sortedHosts := m.sortHosts(hosts) - // Create search input + // Create the search input ti := textinput.New() ti.Placeholder = "Search hosts or tags..." ti.CharLimit = 50 @@ -530,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) @@ -538,7 +80,7 @@ func NewModel(hosts []config.SSHHost) Model { tagsStr = strings.Join(formattedTags, " ") } - // Format last login info + // Format last login information var lastLoginStr string if historyManager != nil { if lastConnect, exists := historyManager.GetLastConnectionTime(host.Name); exists { @@ -556,7 +98,7 @@ func NewModel(hosts []config.SSHHost) Model { }) } - // 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 { @@ -565,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), @@ -577,13 +119,11 @@ 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) // Update the model with the table and other properties @@ -591,153 +131,22 @@ func NewModel(hosts []config.SSHHost) Model { 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 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 + // 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 { - 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() -} diff --git a/internal/ui/update.go b/internal/ui/update.go new file mode 100644 index 0000000..996eb07 --- /dev/null +++ b/internal/ui/update.go @@ -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 +} diff --git a/internal/ui/utils.go b/internal/ui/utils.go new file mode 100644 index 0000000..61f21bf --- /dev/null +++ b/internal/ui/utils.go @@ -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) + } +} diff --git a/internal/ui/view.go b/internal/ui/view.go new file mode 100644 index 0000000..19bec4a --- /dev/null +++ b/internal/ui/view.go @@ -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) +}