6 Commits

Author SHA1 Message Date
146d04c9b7 fix: improve TUI layout responsiveness for large host lists 2025-09-05 12:27:46 +02:00
22586484c7 Merge branch 'main' into feature/tui-refactor 2025-09-05 12:17:30 +02:00
420db56ff5 fix: improve table height calculation for better UI responsiveness 2025-09-05 12:08:52 +02:00
Guillaume Archambault
7600eaaa9b Merge pull request #3 from yimeng/main
This commit adds comprehensive support for SSH Include directives, 
allowing users to organize SSH configurations across multiple files.

Features:
- Support for Include directives with glob patterns (e.g., Include config.d/*)
- Circular include detection to prevent infinite recursion
- Graceful handling of missing include files
- Wildcard host filtering (ignores Host * patterns)
- Tilde expansion for home directory paths
- Relative path resolution from config file directory

The implementation maintains backward compatibility and includes 
comprehensive test coverage for all edge cases.

Thanks to @yimeng for this excellent contribution!
2025-09-05 11:53:04 +02:00
yimeng
e0dd32993a add ssh config include 2025-09-05 12:43:34 +08:00
1cea3795e4 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
2025-09-04 16:47:07 +02:00
10 changed files with 857 additions and 49 deletions

View File

@@ -96,25 +96,45 @@ func ParseSSHConfig() ([]SSHHost, error) {
// ParseSSHConfigFile parses a specific SSH config file and returns the list of hosts // ParseSSHConfigFile parses a specific SSH config file and returns the list of hosts
func ParseSSHConfigFile(configPath string) ([]SSHHost, error) { func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
return parseSSHConfigFileWithProcessedFiles(configPath, make(map[string]bool))
}
// parseSSHConfigFileWithProcessedFiles parses SSH config with include support
func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[string]bool) ([]SSHHost, error) {
// Resolve absolute path to prevent infinite recursion
absPath, err := filepath.Abs(configPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve absolute path for %s: %w", configPath, err)
}
// Check for circular includes
if processedFiles[absPath] {
return []SSHHost{}, nil // Skip already processed files silently
}
processedFiles[absPath] = true
// Check if the file exists, otherwise create it (and the parent directory if needed) // Check if the file exists, otherwise create it (and the parent directory if needed)
if _, err := os.Stat(configPath); os.IsNotExist(err) { if _, err := os.Stat(configPath); os.IsNotExist(err) {
// Ensure .ssh directory exists with proper permissions // Only create the main config file, not included files
if err := ensureSSHDirectory(); err != nil { if absPath == getMainConfigPath() {
return nil, fmt.Errorf("failed to create .ssh directory: %w", err) // Ensure .ssh directory exists with proper permissions
if err := ensureSSHDirectory(); err != nil {
return nil, fmt.Errorf("failed to create .ssh directory: %w", err)
}
file, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return nil, fmt.Errorf("failed to create SSH config file: %w", err)
}
file.Close()
// Set secure permissions on the config file
if err := SetSecureFilePermissions(configPath); err != nil {
return nil, fmt.Errorf("failed to set secure permissions: %w", err)
}
} }
file, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY, 0600) // File doesn't exist, return empty host list
if err != nil {
return nil, fmt.Errorf("failed to create SSH config file: %w", err)
}
file.Close()
// Set secure permissions on the config file
if err := SetSecureFilePermissions(configPath); err != nil {
return nil, fmt.Errorf("failed to set secure permissions: %w", err)
}
// File created, return empty host list
return []SSHHost{}, nil return []SSHHost{}, nil
} }
@@ -168,11 +188,25 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
value := strings.Join(parts[1:], " ") value := strings.Join(parts[1:], " ")
switch key { switch key {
case "include":
// Handle Include directive
includeHosts, err := processIncludeDirective(value, configPath, processedFiles)
if err != nil {
// Don't fail the entire parse if include fails, just skip it
continue
}
hosts = append(hosts, includeHosts...)
case "host": case "host":
// New host, save previous one if it exists // New host, save previous one if it exists
if currentHost != nil { if currentHost != nil {
hosts = append(hosts, *currentHost) hosts = append(hosts, *currentHost)
} }
// Skip hosts with wildcards (*, ?) as they are typically patterns, not actual hosts
if strings.ContainsAny(value, "*?") {
currentHost = nil
pendingTags = nil
continue
}
// Create new host // Create new host
currentHost = &SSHHost{ currentHost = &SSHHost{
Name: value, Name: value,
@@ -222,6 +256,55 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
return hosts, scanner.Err() return hosts, scanner.Err()
} }
// processIncludeDirective processes an Include directive and returns hosts from included files
func processIncludeDirective(pattern string, baseConfigPath string, processedFiles map[string]bool) ([]SSHHost, error) {
// Expand tilde to home directory
if strings.HasPrefix(pattern, "~") {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
pattern = filepath.Join(homeDir, pattern[1:])
}
// If pattern is not absolute, make it relative to the base config directory
if !filepath.IsAbs(pattern) {
baseDir := filepath.Dir(baseConfigPath)
pattern = filepath.Join(baseDir, pattern)
}
// Use glob to find matching files
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("failed to glob pattern %s: %w", pattern, err)
}
var allHosts []SSHHost
for _, match := range matches {
// Skip directories
if info, err := os.Stat(match); err == nil && info.IsDir() {
continue
}
// Recursively parse the included file
hosts, err := parseSSHConfigFileWithProcessedFiles(match, processedFiles)
if err != nil {
// Skip files that can't be parsed rather than failing completely
continue
}
allHosts = append(allHosts, hosts...)
}
return allHosts, nil
}
// getMainConfigPath returns the main SSH config path for comparison
func getMainConfigPath() string {
configPath, _ := GetDefaultSSHConfigPath()
absPath, _ := filepath.Abs(configPath)
return absPath
}
// AddSSHHost adds a new SSH host to the config file // AddSSHHost adds a new SSH host to the config file
func AddSSHHost(host SSHHost) error { func AddSSHHost(host SSHHost) error {
configPath, err := GetDefaultSSHConfigPath() configPath, err := GetDefaultSSHConfigPath()

View File

@@ -1,6 +1,7 @@
package config package config
import ( import (
"os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
@@ -71,3 +72,296 @@ func TestEnsureSSHDirectory(t *testing.T) {
t.Fatalf("ensureSSHDirectory() error = %v", err) t.Fatalf("ensureSSHDirectory() error = %v", err)
} }
} }
func TestParseSSHConfigWithInclude(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create main config file
mainConfig := filepath.Join(tempDir, "config")
mainConfigContent := `Host main-host
HostName example.com
User mainuser
Include included.conf
Include subdir/*
Host another-host
HostName another.example.com
User anotheruser
`
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create main config: %v", err)
}
// Create included file
includedConfig := filepath.Join(tempDir, "included.conf")
includedConfigContent := `Host included-host
HostName included.example.com
User includeduser
Port 2222
`
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create included config: %v", err)
}
// Create subdirectory with another config file
subDir := filepath.Join(tempDir, "subdir")
err = os.MkdirAll(subDir, 0700)
if err != nil {
t.Fatalf("Failed to create subdir: %v", err)
}
subConfig := filepath.Join(subDir, "sub.conf")
subConfigContent := `Host sub-host
HostName sub.example.com
User subuser
IdentityFile ~/.ssh/sub_key
`
err = os.WriteFile(subConfig, []byte(subConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create sub config: %v", err)
}
// Parse the main config file
hosts, err := ParseSSHConfigFile(mainConfig)
if err != nil {
t.Fatalf("ParseSSHConfigFile() error = %v", err)
}
// Check that we got all expected hosts
expectedHosts := map[string]struct{}{
"main-host": {},
"included-host": {},
"sub-host": {},
"another-host": {},
}
if len(hosts) != len(expectedHosts) {
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
}
for _, host := range hosts {
if _, exists := expectedHosts[host.Name]; !exists {
t.Errorf("Unexpected host found: %s", host.Name)
}
delete(expectedHosts, host.Name)
// Validate specific host properties
switch host.Name {
case "main-host":
if host.Hostname != "example.com" || host.User != "mainuser" {
t.Errorf("main-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
}
case "included-host":
if host.Hostname != "included.example.com" || host.User != "includeduser" || host.Port != "2222" {
t.Errorf("included-host properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port)
}
case "sub-host":
if host.Hostname != "sub.example.com" || host.User != "subuser" || host.Identity != "~/.ssh/sub_key" {
t.Errorf("sub-host properties incorrect: hostname=%s, user=%s, identity=%s", host.Hostname, host.User, host.Identity)
}
case "another-host":
if host.Hostname != "another.example.com" || host.User != "anotheruser" {
t.Errorf("another-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
}
}
}
// Check that all expected hosts were found
if len(expectedHosts) > 0 {
var missing []string
for host := range expectedHosts {
missing = append(missing, host)
}
t.Errorf("Missing hosts: %v", missing)
}
}
func TestParseSSHConfigWithCircularInclude(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create config1 that includes config2
config1 := filepath.Join(tempDir, "config1")
config1Content := `Host host1
HostName example1.com
Include config2
`
err := os.WriteFile(config1, []byte(config1Content), 0600)
if err != nil {
t.Fatalf("Failed to create config1: %v", err)
}
// Create config2 that includes config1 (circular)
config2 := filepath.Join(tempDir, "config2")
config2Content := `Host host2
HostName example2.com
Include config1
`
err = os.WriteFile(config2, []byte(config2Content), 0600)
if err != nil {
t.Fatalf("Failed to create config2: %v", err)
}
// Parse the config file - should not cause infinite recursion
hosts, err := ParseSSHConfigFile(config1)
if err != nil {
t.Fatalf("ParseSSHConfigFile() error = %v", err)
}
// Should get both hosts exactly once
expectedHosts := map[string]bool{
"host1": false,
"host2": false,
}
for _, host := range hosts {
if _, exists := expectedHosts[host.Name]; !exists {
t.Errorf("Unexpected host found: %s", host.Name)
} else {
if expectedHosts[host.Name] {
t.Errorf("Host %s found multiple times", host.Name)
}
expectedHosts[host.Name] = true
}
}
// Check all hosts were found
for hostName, found := range expectedHosts {
if !found {
t.Errorf("Host %s not found", hostName)
}
}
}
func TestParseSSHConfigWithNonExistentInclude(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create main config file with non-existent include
mainConfig := filepath.Join(tempDir, "config")
mainConfigContent := `Host main-host
HostName example.com
Include non-existent-file.conf
Host another-host
HostName another.example.com
`
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create main config: %v", err)
}
// Parse should succeed and ignore the non-existent include
hosts, err := ParseSSHConfigFile(mainConfig)
if err != nil {
t.Fatalf("ParseSSHConfigFile() error = %v", err)
}
// Should get the hosts that exist, ignoring the failed include
if len(hosts) != 2 {
t.Errorf("Expected 2 hosts, got %d", len(hosts))
}
hostNames := make(map[string]bool)
for _, host := range hosts {
hostNames[host.Name] = true
}
if !hostNames["main-host"] || !hostNames["another-host"] {
t.Errorf("Expected main-host and another-host, got: %v", hostNames)
}
}
func TestParseSSHConfigWithWildcardHosts(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create config file with wildcard hosts
configFile := filepath.Join(tempDir, "config")
configContent := `# Wildcard patterns should be ignored
Host *.example.com
User defaultuser
IdentityFile ~/.ssh/id_rsa
Host server-*
Port 2222
Host *
ServerAliveInterval 60
# Real hosts should be included
Host real-server
HostName real.example.com
User realuser
Host another-real-server
HostName another.example.com
User anotheruser
`
err := os.WriteFile(configFile, []byte(configContent), 0600)
if err != nil {
t.Fatalf("Failed to create config: %v", err)
}
// Parse the config file
hosts, err := ParseSSHConfigFile(configFile)
if err != nil {
t.Fatalf("ParseSSHConfigFile() error = %v", err)
}
// Should only get real hosts, not wildcard patterns
expectedHosts := map[string]bool{
"real-server": false,
"another-real-server": false,
}
if len(hosts) != len(expectedHosts) {
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
for _, host := range hosts {
t.Logf("Found host: %s", host.Name)
}
}
for _, host := range hosts {
if _, expected := expectedHosts[host.Name]; !expected {
t.Errorf("Unexpected host found: %s", host.Name)
} else {
expectedHosts[host.Name] = true
}
}
// Check that all expected hosts were found
for hostName, found := range expectedHosts {
if !found {
t.Errorf("Expected host %s not found", hostName)
}
}
// Verify host properties
for _, host := range hosts {
switch host.Name {
case "real-server":
if host.Hostname != "real.example.com" || host.User != "realuser" {
t.Errorf("real-server properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
}
case "another-real-server":
if host.Hostname != "another.example.com" || host.User != "anotheruser" {
t.Errorf("another-real-server properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
}
}
}
}

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,
}) })
@@ -146,16 +146,17 @@ func (m *Model) updateTableHeight() {
// Layout breakdown: // Layout breakdown:
// - ASCII title: 5 lines (1 empty + 4 text lines) // - ASCII title: 5 lines (1 empty + 4 text lines)
// - Search bar: 1 line // - Search bar: 1 line
// - Sort info: 1 line // - Help text: 1 line
// - Help text: 2 lines (multi-line text) // - App margins/spacing: 3 lines
// - App margins/spacing: 2 lines // - Safety margin: 3 lines (to ensure UI elements are always visible)
// Total reserved: 11 lines, mais réduisons à 7 pour forcer plus d'espace // Total reserved: 12 lines minimum to preserve essential UI elements
reservedHeight := 7 // Réduction agressive pour tester reservedHeight := 12
availableHeight := m.height - reservedHeight availableHeight := m.height - reservedHeight
hostCount := len(m.table.Rows()) hostCount := len(m.table.Rows())
// Minimum height should be at least 5 rows for usability // Minimum height should be at least 3 rows for basic usability
minTableHeight := 6 // 1 header + 5 data rows // Even in very small terminals, we want to show at least header + 2 hosts
minTableHeight := 4 // 1 header + 3 data rows minimum
maxTableHeight := availableHeight maxTableHeight := availableHeight
if maxTableHeight < minTableHeight { if maxTableHeight < minTableHeight {
maxTableHeight = minTableHeight maxTableHeight = minTableHeight
@@ -173,9 +174,6 @@ func (m *Model) updateTableHeight() {
tableHeight += maxDataRows 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 +196,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 +224,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))