7 Commits

Author SHA1 Message Date
b67f5abbbc docs: update README features 2025-09-05 12:46:16 +02:00
b587defabc fix: improve TUI layout responsiveness for large host lists 2025-09-05 12:35:02 +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
11 changed files with 909 additions and 47 deletions

View File

@@ -34,6 +34,7 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
- **🔍 Smart Search** - Find hosts quickly with built-in filtering and search - **🔍 Smart Search** - Find hosts quickly with built-in filtering and search
- **🔒 Secure** - Works directly with your existing `~/.ssh/config` file - **🔒 Secure** - Works directly with your existing `~/.ssh/config` file
- **📁 Custom Config Support** - Use any SSH configuration file with the `-c` flag - **📁 Custom Config Support** - Use any SSH configuration file with the `-c` flag
- **📂 SSH Include Support** - Full support for SSH Include directives to organize configurations across multiple files
- **⚙️ SSH Options Support** - Add any SSH configuration option through intuitive forms - **⚙️ SSH Options Support** - Add any SSH configuration option through intuitive forms
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats - **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
@@ -272,7 +273,55 @@ sshm edit hostname -c /path/to/custom/ssh_config
SSHM works directly with your standard SSH configuration file (`~/.ssh/config`). It adds special comment tags for enhanced functionality while maintaining full compatibility with standard SSH tools. SSHM works directly with your standard SSH configuration file (`~/.ssh/config`). It adds special comment tags for enhanced functionality while maintaining full compatibility with standard SSH tools.
### SSH Include Support
SSHM fully supports SSH Include directives, allowing you to organize your SSH configurations across multiple files. This is particularly useful for managing large numbers of hosts or organizing configurations by environment, project, or team.
**Include Examples:**
```ssh
# Main ~/.ssh/config file
Host personal-server
HostName personal.example.com
User myuser
# Include work-related configurations
Include work-servers.conf
# Include all configurations from a directory
Include projects/*
# Include with relative paths
Include ~/.ssh/configs/production.conf
```
**Organization Examples:**
*work-servers.conf:*
```ssh
# Tags: work, production
Host prod-web-01
HostName 10.0.1.10
User deploy
ProxyJump bastion.company.com
# Tags: work, staging
Host staging-api
HostName staging-api.company.com
User developer
```
*projects/client-alpha.conf:*
```ssh
# Tags: client, development
Host client-alpha-dev
HostName dev.client-alpha.com
User admin
Port 2222
```
**Example configuration:** **Example configuration:**
Include ~/.ssh/conf.d/*
```ssh ```ssh
# Tags: production, web, frontend # Tags: production, web, frontend
Host web-prod-01 Host web-prod-01
@@ -452,6 +501,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- [Charm](https://charm.sh/) for the amazing TUI libraries - [Charm](https://charm.sh/) for the amazing TUI libraries
- [Cobra](https://cobra.dev/) for the excellent CLI framework - [Cobra](https://cobra.dev/) for the excellent CLI framework
- [@yimeng](https://github.com/yimeng) for contributing SSH Include directive support
- The Go community for building such fantastic tools - The Go community for building such fantastic tools
--- ---

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: 13 lines minimum to preserve essential UI elements
reservedHeight := 7 // Réduction agressive pour tester reservedHeight := 13
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,7 +174,8 @@ func (m *Model) updateTableHeight() {
tableHeight += maxDataRows tableHeight += maxDataRows
} }
// FORCE: Ajoutons une ligne supplémentaire pour résoudre le problème // Add one extra line to prevent the last host from being hidden
// This compensates for table rendering quirks in bubble tea
tableHeight += 1 tableHeight += 1
// Update table height // Update table height
@@ -198,11 +200,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 +228,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))