From 1cea3795e48b59145f4c97c3f69a0cfad3e09d4e Mon Sep 17 00:00:00 2001 From: Gu1llaum-3 Date: Thu, 4 Sep 2025 16:47:07 +0200 Subject: [PATCH] feat: refactor TUI with read-only info view and optimized layout - Add new 'i' command for read-only host information display - Implement info view with option to switch to edit mode (e/Enter) - Hide User and Port columns to optimize table space usage - Improve table height calculation for better host visibility - Add proper message handling for info view navigation - Interface optimization --- internal/ui/help_form.go | 109 +++++++++++++++++++ internal/ui/info_form.go | 227 +++++++++++++++++++++++++++++++++++++++ internal/ui/model.go | 4 + internal/ui/styles.go | 10 +- internal/ui/table.go | 71 ++++++------ internal/ui/tui.go | 8 +- internal/ui/update.go | 76 +++++++++++++ internal/ui/view.go | 16 +-- 8 files changed, 469 insertions(+), 52 deletions(-) create mode 100644 internal/ui/help_form.go create mode 100644 internal/ui/info_form.go diff --git a/internal/ui/help_form.go b/internal/ui/help_form.go new file mode 100644 index 0000000..699e183 --- /dev/null +++ b/internal/ui/help_form.go @@ -0,0 +1,109 @@ +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type helpModel struct { + styles Styles + width int + height int +} + +// helpCloseMsg is sent when the help window is closed +type helpCloseMsg struct{} + +// NewHelpForm creates a new help form model +func NewHelpForm(styles Styles, width, height int) *helpModel { + return &helpModel{ + styles: styles, + width: width, + height: height, + } +} + +func (m *helpModel) Init() tea.Cmd { + return nil +} + +func (m *helpModel) Update(msg tea.Msg) (*helpModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc", "q", "h", "enter", "ctrl+c": + return m, func() tea.Msg { return helpCloseMsg{} } + } + } + return m, nil +} + +func (m *helpModel) View() string { + // Title + title := m.styles.Header.Render("📖 SSHM - Help & Commands") + + // Create horizontal sections with compact layout + line1 := lipgloss.JoinHorizontal(lipgloss.Center, + m.styles.FocusedLabel.Render("🧭 ↑/↓/j/k"), + " ", + m.styles.HelpText.Render("navigate"), + " ", + m.styles.FocusedLabel.Render("⏎"), + " ", + m.styles.HelpText.Render("connect"), + " ", + m.styles.FocusedLabel.Render("a/e/d"), + " ", + m.styles.HelpText.Render("add/edit/delete"), + ) + + line2 := lipgloss.JoinHorizontal(lipgloss.Center, + m.styles.FocusedLabel.Render("Tab"), + " ", + m.styles.HelpText.Render("switch focus"), + " ", + m.styles.FocusedLabel.Render("f"), + " ", + m.styles.HelpText.Render("port forward"), + " ", + m.styles.FocusedLabel.Render("s/r/n"), + " ", + m.styles.HelpText.Render("sort modes"), + ) + + line3 := lipgloss.JoinHorizontal(lipgloss.Center, + m.styles.FocusedLabel.Render("/"), + " ", + m.styles.HelpText.Render("search"), + " ", + m.styles.FocusedLabel.Render("h"), + " ", + m.styles.HelpText.Render("help"), + " ", + m.styles.FocusedLabel.Render("q/ESC"), + " ", + m.styles.HelpText.Render("quit"), + ) + + // Create the main content + content := lipgloss.JoinVertical(lipgloss.Center, + title, + "", + line1, + "", + line2, + "", + line3, + "", + m.styles.HelpText.Render("Press ESC, h, q or Enter to close"), + ) + + // Center the help window + return lipgloss.Place( + m.width, + m.height, + lipgloss.Center, + lipgloss.Center, + m.styles.FormContainer.Render(content), + ) +} diff --git a/internal/ui/info_form.go b/internal/ui/info_form.go new file mode 100644 index 0000000..c4f09db --- /dev/null +++ b/internal/ui/info_form.go @@ -0,0 +1,227 @@ +package ui + +import ( + "fmt" + "sshm/internal/config" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type infoFormModel struct { + host *config.SSHHost + styles Styles + width int + height int + configFile string + hostName string +} + +// Messages for communication with parent model +type infoFormEditMsg struct { + hostName string +} + +type infoFormCancelMsg struct{} + +// NewInfoForm creates a new info form model for displaying host details in read-only mode +func NewInfoForm(hostName string, styles Styles, width, height int, configFile string) (*infoFormModel, error) { + // Get the existing host configuration + var host *config.SSHHost + var err error + + if configFile != "" { + host, err = config.GetSSHHostFromFile(hostName, configFile) + } else { + host, err = config.GetSSHHost(hostName) + } + + if err != nil { + return nil, err + } + + return &infoFormModel{ + host: host, + hostName: hostName, + configFile: configFile, + styles: styles, + width: width, + height: height, + }, nil +} + +func (m *infoFormModel) Init() tea.Cmd { + return nil +} + +func (m *infoFormModel) Update(msg tea.Msg) (*infoFormModel, 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", "q": + return m, func() tea.Msg { return infoFormCancelMsg{} } + + case "e", "enter": + // Switch to edit mode + return m, func() tea.Msg { return infoFormEditMsg{hostName: m.hostName} } + } + } + + return m, nil +} + +func (m *infoFormModel) View() string { + var b strings.Builder + + // Title + title := fmt.Sprintf("SSH Host Information: %s", m.host.Name) + b.WriteString(m.styles.FormTitle.Render(title)) + b.WriteString("\n\n") + + // Create info sections with consistent formatting + sections := []struct { + label string + value string + }{ + {"Host Name", m.host.Name}, + {"Hostname/IP", m.host.Hostname}, + {"User", formatOptionalValue(m.host.User)}, + {"Port", formatOptionalValue(m.host.Port)}, + {"Identity File", formatOptionalValue(m.host.Identity)}, + {"ProxyJump", formatOptionalValue(m.host.ProxyJump)}, + {"SSH Options", formatSSHOptions(m.host.Options)}, + {"Tags", formatTags(m.host.Tags)}, + } + + // Render each section + for _, section := range sections { + // Label style + labelStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("39")). // Bright blue + Width(15). + AlignHorizontal(lipgloss.Right) + + // Value style + valueStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("255")) // White + + // If value is empty or default, use a muted style + if section.value == "Not set" || section.value == "22" && section.label == "Port" { + valueStyle = valueStyle.Foreground(lipgloss.Color("243")) // Gray + } + + line := lipgloss.JoinHorizontal( + lipgloss.Top, + labelStyle.Render(section.label+":"), + " ", + valueStyle.Render(section.value), + ) + b.WriteString(line) + b.WriteString("\n") + } + + b.WriteString("\n") + + // Action instructions + helpStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("243")). + Italic(true) + + b.WriteString(helpStyle.Render("Actions:")) + b.WriteString("\n") + + actionStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("120")). // Green + Bold(true) + + b.WriteString(" ") + b.WriteString(actionStyle.Render("e/Enter")) + b.WriteString(helpStyle.Render(" - Switch to edit mode")) + b.WriteString("\n") + + b.WriteString(" ") + b.WriteString(actionStyle.Render("q/Esc")) + b.WriteString(helpStyle.Render(" - Return to host list")) + + // Wrap in a border for better visual separation + content := b.String() + + borderStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("39")). + Padding(1). + Margin(1) + + // Center the info window + return lipgloss.Place( + m.width, + m.height, + lipgloss.Center, + lipgloss.Center, + borderStyle.Render(content), + ) +} + +// Helper functions for formatting values + +func formatOptionalValue(value string) string { + if value == "" { + return "Not set" + } + return value +} + +func formatSSHOptions(options string) string { + if options == "" { + return "Not set" + } + return options +} + +func formatTags(tags []string) string { + if len(tags) == 0 { + return "Not set" + } + return strings.Join(tags, ", ") +} + +// Standalone wrapper for info form (for testing or standalone use) +type standaloneInfoForm struct { + *infoFormModel +} + +func (m standaloneInfoForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg.(type) { + case infoFormCancelMsg: + return m, tea.Quit + case infoFormEditMsg: + // For standalone mode, just quit - parent should handle edit transition + return m, tea.Quit + } + + newForm, cmd := m.infoFormModel.Update(msg) + m.infoFormModel = newForm + return m, cmd +} + +// RunInfoForm provides a standalone info form for testing +func RunInfoForm(hostName string, configFile string) error { + styles := NewStyles(80) + infoForm, err := NewInfoForm(hostName, styles, 80, 24, configFile) + if err != nil { + return err + } + m := standaloneInfoForm{infoForm} + + p := tea.NewProgram(m, tea.WithAltScreen()) + _, err = p.Run() + return err +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 8a9efe4..aba5735 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -35,7 +35,9 @@ const ( ViewList ViewMode = iota ViewAdd ViewEdit + ViewInfo ViewPortForward + ViewHelp ) // PortForwardType defines the type of port forwarding @@ -77,7 +79,9 @@ type Model struct { viewMode ViewMode addForm *addFormModel editForm *editFormModel + infoForm *infoFormModel portForwardForm *portForwardModel + helpForm *helpModel // Terminal size and styles width int diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 78989d6..4fe41aa 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -42,6 +42,7 @@ type Styles struct { FormContainer lipgloss.Style Label lipgloss.Style FocusedLabel lipgloss.Style + HelpSection lipgloss.Style } // NewStyles creates a new Styles struct with the given terminal width @@ -88,8 +89,7 @@ func NewStyles(width int) Styles { Foreground(lipgloss.Color(SecondaryColor)), HelpText: lipgloss.NewStyle(). - Foreground(lipgloss.Color(SecondaryColor)). - MarginTop(1), + Foreground(lipgloss.Color(SecondaryColor)), // Error style Error: lipgloss.NewStyle(). @@ -118,8 +118,10 @@ func NewStyles(width int) Styles { Foreground(lipgloss.Color(SecondaryColor)), FocusedLabel: lipgloss.NewStyle(). - Foreground(lipgloss.Color(PrimaryColor)). - Bold(true), + Foreground(lipgloss.Color(PrimaryColor)), + + HelpSection: lipgloss.NewStyle(). + Padding(0, 2), } } diff --git a/internal/ui/table.go b/internal/ui/table.go index dd2ca7b..9ec609c 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -122,8 +122,8 @@ func (m *Model) updateTableRows() { rows = append(rows, table.Row{ host.Name, host.Hostname, - host.User, - host.Port, + // host.User, // Commented to save space + // host.Port, // Commented to save space tagsStr, lastLoginStr, }) @@ -142,40 +142,24 @@ func (m *Model) updateTableHeight() { return } - // Calculate dynamic table height based on terminal size - // Layout breakdown: - // - ASCII title: 5 lines (1 empty + 4 text lines) - // - Search bar: 1 line - // - Sort info: 1 line - // - Help text: 2 lines (multi-line text) - // - App margins/spacing: 2 lines - // Total reserved: 11 lines, mais réduisons à 7 pour forcer plus d'espace - reservedHeight := 7 // Réduction agressive pour tester - availableHeight := m.height - reservedHeight hostCount := len(m.table.Rows()) - // Minimum height should be at least 5 rows for usability - minTableHeight := 6 // 1 header + 5 data rows - maxTableHeight := availableHeight - if maxTableHeight < minTableHeight { - maxTableHeight = minTableHeight + // Calculate exactly what we need: + // 1 line for header + actual number of host rows + 1 extra line for better UX + tableHeight := 1 + hostCount + 1 + + // Set a reasonable maximum based on terminal height + // Leave space for: title (5) + search (1) + help (1) + margins (2) = 9 lines + // But be less conservative, use 7 lines instead of 9 + maxPossibleHeight := m.height - 7 + if maxPossibleHeight < 4 { + maxPossibleHeight = 4 // Minimum: header + 3 rows } - tableHeight := 1 // header - dataRowsNeeded := hostCount - maxDataRows := maxTableHeight - 1 // subtract 1 for header - - if dataRowsNeeded <= maxDataRows { - // We have enough space for all hosts - tableHeight += dataRowsNeeded - } else { - // We need to limit to available space - tableHeight += maxDataRows + if tableHeight > maxPossibleHeight { + tableHeight = maxPossibleHeight } - // FORCE: Ajoutons une ligne supplémentaire pour résoudre le problème - tableHeight += 1 - // Update table height m.table.SetHeight(tableHeight) } @@ -198,11 +182,11 @@ func (m *Model) updateTableColumns() { // Fixed column widths hostnameWidth := 25 - userWidth := 12 - portWidth := 6 + // userWidth := 12 // Commented to save space + // portWidth := 6 // Commented to save space // Calculate total width needed for all columns - totalFixedWidth := hostnameWidth + userWidth + portWidth + totalFixedWidth := hostnameWidth // + userWidth + portWidth // Commented columns totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth totalWidth := totalFixedWidth + totalVariableWidth @@ -226,14 +210,25 @@ func (m *Model) updateTableColumns() { } } - // Create new columns with updated widths + // Create new columns with updated widths and sort indicators + nameTitle := "Name" + lastLoginTitle := "Last Login" + + // Add sort indicators based on current sort mode + switch m.sortMode { + case SortByName: + nameTitle += " ↓" + case SortByLastUsed: + lastLoginTitle += " ↓" + } + columns := []table.Column{ - {Title: "Name", Width: nameWidth}, + {Title: nameTitle, Width: nameWidth}, {Title: "Hostname", Width: hostnameWidth}, - {Title: "User", Width: userWidth}, - {Title: "Port", Width: portWidth}, + // {Title: "User", Width: userWidth}, // Commented to save space + // {Title: "Port", Width: portWidth}, // Commented to save space {Title: "Tags", Width: tagsWidth}, - {Title: "Last Login", Width: lastLoginWidth}, + {Title: lastLoginTitle, Width: lastLoginWidth}, } m.table.SetColumns(columns) diff --git a/internal/ui/tui.go b/internal/ui/tui.go index 761d356..fe64a0e 100644 --- a/internal/ui/tui.go +++ b/internal/ui/tui.go @@ -61,8 +61,8 @@ func NewModel(hosts []config.SSHHost, configFile string) Model { columns := []table.Column{ {Title: "Name", Width: nameWidth}, {Title: "Hostname", Width: 25}, - {Title: "User", Width: 12}, - {Title: "Port", Width: 6}, + // {Title: "User", Width: 12}, // Commented to save space + // {Title: "Port", Width: 6}, // Commented to save space {Title: "Tags", Width: tagsWidth}, {Title: "Last Login", Width: lastLoginWidth}, } @@ -92,8 +92,8 @@ func NewModel(hosts []config.SSHHost, configFile string) Model { rows = append(rows, table.Row{ host.Name, host.Hostname, - host.User, - host.Port, + // host.User, // Commented to save space + // host.Port, // Commented to save space tagsStr, lastLoginStr, }) diff --git a/internal/ui/update.go b/internal/ui/update.go index f0b207f..c65b384 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -46,11 +46,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.editForm.height = m.height m.editForm.styles = m.styles } + if m.infoForm != nil { + m.infoForm.width = m.width + m.infoForm.height = m.height + m.infoForm.styles = m.styles + } if m.portForwardForm != nil { m.portForwardForm.width = m.width m.portForwardForm.height = m.height m.portForwardForm.styles = m.styles } + if m.helpForm != nil { + m.helpForm.width = m.width + m.helpForm.height = m.height + m.helpForm.styles = m.styles + } return m, nil case addFormSubmitMsg: @@ -141,6 +151,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.table.Focus() return m, nil + case infoFormCancelMsg: + // Cancel: return to list view + m.viewMode = ViewList + m.infoForm = nil + m.table.Focus() + return m, nil + + case infoFormEditMsg: + // Switch from info to edit mode + editForm, err := NewEditForm(msg.hostName, m.styles, m.width, m.height, m.configFile) + if err != nil { + // Handle error - could show in UI, for now just go back to list + m.viewMode = ViewList + m.infoForm = nil + m.table.Focus() + return m, nil + } + m.editForm = editForm + m.infoForm = nil + m.viewMode = ViewEdit + return m, textinput.Blink + case portForwardSubmitMsg: if msg.err != nil { // Show error in form @@ -180,6 +212,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.table.Focus() return m, nil + case helpCloseMsg: + // Close help: return to list view + m.viewMode = ViewList + m.helpForm = nil + m.table.Focus() + return m, nil + case tea.KeyMsg: // Handle view-specific key presses switch m.viewMode { @@ -197,6 +236,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.editForm = newForm return m, cmd } + case ViewInfo: + if m.infoForm != nil { + var newForm *infoFormModel + newForm, cmd = m.infoForm.Update(msg) + m.infoForm = newForm + return m, cmd + } case ViewPortForward: if m.portForwardForm != nil { var newForm *portForwardModel @@ -204,6 +250,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.portForwardForm = newForm return m, cmd } + case ViewHelp: + if m.helpForm != nil { + var newForm *helpModel + newForm, cmd = m.helpForm.Update(msg) + m.helpForm = newForm + return m, cmd + } case ViewList: // Handle list view keys return m.handleListViewKeys(msg) @@ -356,6 +409,22 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, textinput.Blink } } + case "i": + if !m.searchMode && !m.deleteMode { + // Show info for the selected host + selected := m.table.SelectedRow() + if len(selected) > 0 { + hostName := selected[0] // The hostname is in the first column + infoForm, err := NewInfoForm(hostName, m.styles, m.width, m.height, m.configFile) + if err != nil { + // Handle error - could show in UI + return m, nil + } + m.infoForm = infoForm + m.viewMode = ViewInfo + return m, nil + } + } case "a": if !m.searchMode && !m.deleteMode { // Add a new host @@ -386,6 +455,13 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, textinput.Blink } } + case "h": + if !m.searchMode && !m.deleteMode { + // Show help + m.helpForm = NewHelpForm(m.styles, m.width, m.height) + m.viewMode = ViewHelp + return m, nil + } case "s": if !m.searchMode && !m.deleteMode { // Cycle through sort modes (only 2 modes now) diff --git a/internal/ui/view.go b/internal/ui/view.go index 826960d..40bea97 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -23,10 +23,18 @@ func (m Model) View() string { if m.editForm != nil { return m.editForm.View() } + case ViewInfo: + if m.infoForm != nil { + return m.infoForm.View() + } case ViewPortForward: if m.portForwardForm != nil { return m.portForwardForm.View() } + case ViewHelp: + if m.helpForm != nil { + return m.helpForm.View() + } case ViewList: return m.renderListView() } @@ -50,10 +58,6 @@ func (m Model) renderListView() string { 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 @@ -66,9 +70,9 @@ func (m Model) renderListView() string { // Add the help text var helpText string if !m.searchMode { - helpText = " Use ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • (f)orward • / to search • Tab to switch\n Sort: (s)witch • (r)ecent • (n)ame • q/ESC to quit" + helpText = " ↑/↓: navigate • Enter: connect • i: info • h: help • q: quit" } else { - helpText = " Type to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit" + helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit" } components = append(components, m.styles.HelpText.Render(helpText))