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
This commit is contained in:
Gu1llaum-3 2025-09-04 16:47:07 +02:00
parent 2ade315ddc
commit 1cea3795e4
8 changed files with 469 additions and 52 deletions

109
internal/ui/help_form.go Normal file
View File

@ -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),
)
}

227
internal/ui/info_form.go Normal file
View File

@ -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
}

View File

@ -35,7 +35,9 @@ const (
ViewList ViewMode = iota ViewList ViewMode = iota
ViewAdd ViewAdd
ViewEdit ViewEdit
ViewInfo
ViewPortForward ViewPortForward
ViewHelp
) )
// PortForwardType defines the type of port forwarding // PortForwardType defines the type of port forwarding
@ -77,7 +79,9 @@ type Model struct {
viewMode ViewMode viewMode ViewMode
addForm *addFormModel addForm *addFormModel
editForm *editFormModel editForm *editFormModel
infoForm *infoFormModel
portForwardForm *portForwardModel portForwardForm *portForwardModel
helpForm *helpModel
// Terminal size and styles // Terminal size and styles
width int width int

View File

@ -42,6 +42,7 @@ type Styles struct {
FormContainer lipgloss.Style FormContainer lipgloss.Style
Label lipgloss.Style Label lipgloss.Style
FocusedLabel lipgloss.Style FocusedLabel lipgloss.Style
HelpSection lipgloss.Style
} }
// NewStyles creates a new Styles struct with the given terminal width // NewStyles creates a new Styles struct with the given terminal width
@ -88,8 +89,7 @@ func NewStyles(width int) Styles {
Foreground(lipgloss.Color(SecondaryColor)), Foreground(lipgloss.Color(SecondaryColor)),
HelpText: lipgloss.NewStyle(). HelpText: lipgloss.NewStyle().
Foreground(lipgloss.Color(SecondaryColor)). Foreground(lipgloss.Color(SecondaryColor)),
MarginTop(1),
// Error style // Error style
Error: lipgloss.NewStyle(). Error: lipgloss.NewStyle().
@ -118,8 +118,10 @@ func NewStyles(width int) Styles {
Foreground(lipgloss.Color(SecondaryColor)), Foreground(lipgloss.Color(SecondaryColor)),
FocusedLabel: lipgloss.NewStyle(). FocusedLabel: lipgloss.NewStyle().
Foreground(lipgloss.Color(PrimaryColor)). Foreground(lipgloss.Color(PrimaryColor)),
Bold(true),
HelpSection: lipgloss.NewStyle().
Padding(0, 2),
} }
} }

View File

@ -122,8 +122,8 @@ func (m *Model) updateTableRows() {
rows = append(rows, table.Row{ rows = append(rows, table.Row{
host.Name, host.Name,
host.Hostname, host.Hostname,
host.User, // host.User, // Commented to save space
host.Port, // host.Port, // Commented to save space
tagsStr, tagsStr,
lastLoginStr, lastLoginStr,
}) })
@ -142,40 +142,24 @@ func (m *Model) updateTableHeight() {
return 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()) hostCount := len(m.table.Rows())
// Minimum height should be at least 5 rows for usability // Calculate exactly what we need:
minTableHeight := 6 // 1 header + 5 data rows // 1 line for header + actual number of host rows + 1 extra line for better UX
maxTableHeight := availableHeight tableHeight := 1 + hostCount + 1
if maxTableHeight < minTableHeight {
maxTableHeight = minTableHeight // 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 if tableHeight > maxPossibleHeight {
dataRowsNeeded := hostCount tableHeight = maxPossibleHeight
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
} }
// FORCE: Ajoutons une ligne supplémentaire pour résoudre le problème
tableHeight += 1
// Update table height // Update table height
m.table.SetHeight(tableHeight) m.table.SetHeight(tableHeight)
} }
@ -198,11 +182,11 @@ func (m *Model) updateTableColumns() {
// Fixed column widths // Fixed column widths
hostnameWidth := 25 hostnameWidth := 25
userWidth := 12 // userWidth := 12 // Commented to save space
portWidth := 6 // portWidth := 6 // Commented to save space
// Calculate total width needed for all columns // Calculate total width needed for all columns
totalFixedWidth := hostnameWidth + userWidth + portWidth totalFixedWidth := hostnameWidth // + userWidth + portWidth // Commented columns
totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth
totalWidth := totalFixedWidth + totalVariableWidth 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{ columns := []table.Column{
{Title: "Name", Width: nameWidth}, {Title: nameTitle, Width: nameWidth},
{Title: "Hostname", Width: hostnameWidth}, {Title: "Hostname", Width: hostnameWidth},
{Title: "User", Width: userWidth}, // {Title: "User", Width: userWidth}, // Commented to save space
{Title: "Port", Width: portWidth}, // {Title: "Port", Width: portWidth}, // Commented to save space
{Title: "Tags", Width: tagsWidth}, {Title: "Tags", Width: tagsWidth},
{Title: "Last Login", Width: lastLoginWidth}, {Title: lastLoginTitle, Width: lastLoginWidth},
} }
m.table.SetColumns(columns) m.table.SetColumns(columns)

View File

@ -61,8 +61,8 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
columns := []table.Column{ columns := []table.Column{
{Title: "Name", Width: nameWidth}, {Title: "Name", Width: nameWidth},
{Title: "Hostname", Width: 25}, {Title: "Hostname", Width: 25},
{Title: "User", Width: 12}, // {Title: "User", Width: 12}, // Commented to save space
{Title: "Port", Width: 6}, // {Title: "Port", Width: 6}, // Commented to save space
{Title: "Tags", Width: tagsWidth}, {Title: "Tags", Width: tagsWidth},
{Title: "Last Login", Width: lastLoginWidth}, {Title: "Last Login", Width: lastLoginWidth},
} }
@ -92,8 +92,8 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
rows = append(rows, table.Row{ rows = append(rows, table.Row{
host.Name, host.Name,
host.Hostname, host.Hostname,
host.User, // host.User, // Commented to save space
host.Port, // host.Port, // Commented to save space
tagsStr, tagsStr,
lastLoginStr, lastLoginStr,
}) })

View File

@ -46,11 +46,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.editForm.height = m.height m.editForm.height = m.height
m.editForm.styles = m.styles 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 { if m.portForwardForm != nil {
m.portForwardForm.width = m.width m.portForwardForm.width = m.width
m.portForwardForm.height = m.height m.portForwardForm.height = m.height
m.portForwardForm.styles = m.styles 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 return m, nil
case addFormSubmitMsg: case addFormSubmitMsg:
@ -141,6 +151,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.table.Focus() m.table.Focus()
return m, nil 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: case portForwardSubmitMsg:
if msg.err != nil { if msg.err != nil {
// Show error in form // Show error in form
@ -180,6 +212,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.table.Focus() m.table.Focus()
return m, nil 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: case tea.KeyMsg:
// Handle view-specific key presses // Handle view-specific key presses
switch m.viewMode { switch m.viewMode {
@ -197,6 +236,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.editForm = newForm m.editForm = newForm
return m, cmd 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: case ViewPortForward:
if m.portForwardForm != nil { if m.portForwardForm != nil {
var newForm *portForwardModel var newForm *portForwardModel
@ -204,6 +250,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.portForwardForm = newForm m.portForwardForm = newForm
return m, cmd 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: case ViewList:
// Handle list view keys // Handle list view keys
return m.handleListViewKeys(msg) return m.handleListViewKeys(msg)
@ -356,6 +409,22 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, textinput.Blink 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": case "a":
if !m.searchMode && !m.deleteMode { if !m.searchMode && !m.deleteMode {
// Add a new host // Add a new host
@ -386,6 +455,13 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, textinput.Blink 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": case "s":
if !m.searchMode && !m.deleteMode { if !m.searchMode && !m.deleteMode {
// Cycle through sort modes (only 2 modes now) // Cycle through sort modes (only 2 modes now)

View File

@ -23,10 +23,18 @@ func (m Model) View() string {
if m.editForm != nil { if m.editForm != nil {
return m.editForm.View() return m.editForm.View()
} }
case ViewInfo:
if m.infoForm != nil {
return m.infoForm.View()
}
case ViewPortForward: case ViewPortForward:
if m.portForwardForm != nil { if m.portForwardForm != nil {
return m.portForwardForm.View() return m.portForwardForm.View()
} }
case ViewHelp:
if m.helpForm != nil {
return m.helpForm.View()
}
case ViewList: case ViewList:
return m.renderListView() return m.renderListView()
} }
@ -50,10 +58,6 @@ func (m Model) renderListView() string {
components = append(components, m.styles.SearchUnfocused.Render(searchPrompt+m.searchInput.View())) 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 // Add the table with the appropriate style based on focus
if m.searchMode { if m.searchMode {
// The table is not focused, use the unfocused style // The table is not focused, use the unfocused style
@ -66,9 +70,9 @@ func (m Model) renderListView() string {
// Add the help text // Add the help text
var helpText string var helpText string
if !m.searchMode { 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 { } 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)) components = append(components, m.styles.HelpText.Render(helpText))