feat: improve search UX with persistent search bar and better navigation

- Always-visible search bar with focus indicators
- Tab navigation between search and table
- ASCII title header
- Simplified keyboard shortcuts (/, Tab, ESC)
- Fixed search validation workflow
- Enhanced visual feedback and accessibility
This commit is contained in:
Gu1llaum-3 2025-09-01 14:36:09 +02:00
parent fad2585d5e
commit 534b7d9a6c

View File

@ -14,15 +14,28 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
var baseStyle = lipgloss.NewStyle(). var searchStyleFocused = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240"))
var searchStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()). BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("36")). BorderForeground(lipgloss.Color("36")).
Padding(0, 1) 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 Model struct { type Model struct {
table table.Model table table.Model
searchInput textinput.Model searchInput textinput.Model
@ -47,13 +60,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "esc", "ctrl+c": case "esc", "ctrl+c":
if m.searchMode {
// Exit search mode
m.searchMode = false
m.searchInput.Blur()
m.table.Focus()
return m, nil
}
if m.deleteMode { if m.deleteMode {
// Exit delete mode // Exit delete mode
m.deleteMode = false m.deleteMode = false
@ -74,9 +80,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.searchInput.Focus() m.searchInput.Focus()
return m, textinput.Blink 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": case "enter":
if m.searchMode { if m.searchMode {
// Exit search mode and focus table // Validate search and return to table mode to allow commands
m.searchMode = false m.searchMode = false
m.searchInput.Blur() m.searchInput.Blur()
m.table.Focus() m.table.Focus()
@ -153,17 +176,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Update components based on mode // Update components based on mode
if m.searchMode { if m.searchMode {
m.searchInput, cmd = m.searchInput.Update(msg) m.searchInput, cmd = m.searchInput.Update(msg)
// Filter hosts when search input changes
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.hosts
}
m.updateTableRows()
} else { } else {
m.table, cmd = m.table.Update(msg) m.table, cmd = m.table.Update(msg)
} }
// Always filter hosts when search input changes (regardless of mode)
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.hosts
}
m.updateTableRows()
return m, cmd return m, cmd
} }
@ -174,20 +198,37 @@ func (m Model) View() string {
var view strings.Builder var view strings.Builder
// Add search bar // Add ASCII title
searchPrompt := "Search (/ to search, ESC to exit search): " 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 { if m.searchMode {
view.WriteString(searchStyle.Render(searchPrompt+m.searchInput.View()) + "\n\n") view.WriteString(searchStyleFocused.Render(searchPrompt+m.searchInput.View()) + "\n\n")
} else {
view.WriteString(searchStyleUnfocused.Render(searchPrompt+m.searchInput.View()) + "\n\n")
} }
// Add table // Add table with appropriate style based on focus
view.WriteString(baseStyle.Render(m.table.View())) 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 // Add help text
if !m.searchMode { if !m.searchMode {
view.WriteString("\nUse ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • (q)uit") view.WriteString("\nUse ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • Tab to switch • q/ESC to quit")
} else { } else {
view.WriteString("\nType to filter hosts by name or tag • Enter to select • ESC to exit search") view.WriteString("\nType to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit")
} }
return view.String() return view.String()
@ -353,8 +394,8 @@ func RunInteractiveMode(hosts []config.SSHHost) error {
for { for {
m := NewModel(hosts) m := NewModel(hosts)
// Start the application in terminal (without alt screen) // Start the application in alt screen mode for clean exit
p := tea.NewProgram(m) p := tea.NewProgram(m, tea.WithAltScreen())
finalModel, err := p.Run() finalModel, err := p.Run()
if err != nil { if err != nil {
return fmt.Errorf("error running TUI: %w", err) return fmt.Errorf("error running TUI: %w", err)
@ -370,9 +411,6 @@ func RunInteractiveMode(hosts []config.SSHHost) error {
continue continue
} }
// Clear screen before returning to TUI
fmt.Print("\033[2J\033[H")
// Refresh the hosts list after editing // Refresh the hosts list after editing
refreshedHosts, err := config.ParseSSHConfig() refreshedHosts, err := config.ParseSSHConfig()
if err != nil { if err != nil {
@ -390,9 +428,6 @@ func RunInteractiveMode(hosts []config.SSHHost) error {
continue continue
} }
// Clear screen before returning to TUI
fmt.Print("\033[2J\033[H")
// Refresh the hosts list after adding // Refresh the hosts list after adding
refreshedHosts, err := config.ParseSSHConfig() refreshedHosts, err := config.ParseSSHConfig()
if err != nil { if err != nil {