10 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
2ade315ddc fix: resolve macOS permission errors during SSHM installation
- Replace recursive find with direct file path check to avoid scanning protected directories
- Backup old binary during installation to prevent interference from old version
- Use full path for version verification to ensure new binary is tested
- Fixes 'Operation not permitted' errors on macOS during installation process
2025-09-04 12:30:18 +02:00
2deec405f7 feat: add port forwarding support 2025-09-04 10:34:54 +02:00
21c5d41977 feat: add local binary support to Windows installer 2025-09-03 18:12:01 +02:00
20bc506e36 feat: add Windows platform support 2025-09-03 17:48:58 +02:00
18 changed files with 1935 additions and 71 deletions

View File

@@ -34,6 +34,14 @@ jobs:
- goos: darwin
goarch: arm64
suffix: darwin-arm64
# Windows AMD64
- goos: windows
goarch: amd64
suffix: windows-amd64
# Windows ARM64
- goos: windows
goarch: arm64
suffix: windows-arm64
steps:
- name: Checkout code
@@ -60,19 +68,30 @@ jobs:
run: |
mkdir -p dist
VERSION=${GITHUB_REF#refs/tags/}
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }} .
if [ "${{ matrix.goos }}" = "windows" ]; then
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }}.exe .
else
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }} .
fi
- name: Create tarball
- name: Create archive
run: |
cd dist
tar -czf sshm-${{ matrix.suffix }}.tar.gz sshm-${{ matrix.suffix }}
rm sshm-${{ matrix.suffix }}
if [ "${{ matrix.goos }}" = "windows" ]; then
zip sshm-${{ matrix.suffix }}.zip sshm-${{ matrix.suffix }}.exe
rm sshm-${{ matrix.suffix }}.exe
else
tar -czf sshm-${{ matrix.suffix }}.tar.gz sshm-${{ matrix.suffix }}
rm sshm-${{ matrix.suffix }}
fi
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: sshm-${{ matrix.suffix }}
path: dist/sshm-${{ matrix.suffix }}.tar.gz
path: |
dist/sshm-${{ matrix.suffix }}.tar.gz
dist/sshm-${{ matrix.suffix }}.zip
release:
name: Create Release
@@ -91,6 +110,7 @@ jobs:
run: |
mkdir -p release
find ./artifacts -name "*.tar.gz" -exec cp {} ./release/ \;
find ./artifacts -name "*.zip" -exec cp {} ./release/ \;
ls -la ./release/
- name: Create Release

125
README.md
View File

@@ -9,7 +9,7 @@
[![Go](https://img.shields.io/badge/Go-1.23+-00ADD8?style=for-the-badge&logo=go)](https://golang.org/)
[![Release](https://img.shields.io/github/v/release/Gu1llaum-3/sshm?style=for-the-badge)](https://github.com/Gu1llaum-3/sshm/releases)
[![License](https://img.shields.io/github/license/Gu1llaum-3/sshm?style=for-the-badge)](LICENSE)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS-lightgrey?style=for-the-badge)](https://github.com/Gu1llaum-3/sshm/releases)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-lightgrey?style=for-the-badge)](https://github.com/Gu1llaum-3/sshm/releases)
> **A modern, interactive SSH Manager for your terminal** 🔥
@@ -28,7 +28,8 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
### 🎯 **Core Features**
- **🎨 Beautiful TUI Interface** - Navigate your SSH hosts with an elegant, interactive terminal UI
- **⚡ Quick Connect** - Connect to any host instantly
- **📝 Easy Management** - Add, edit, and manage SSH configurations seamlessly
- **🔄 Port Forwarding** - Easy setup for Local, Remote, and Dynamic (SOCKS) forwarding
- **📝Easy Management** - Add, edit, and manage SSH configurations seamlessly
- **🏷️ Tag Support** - Organize your hosts with custom tags for better categorization
- **🔍 Smart Search** - Find hosts quickly with built-in filtering and search
- **🔒 Secure** - Works directly with your existing `~/.ssh/config` file
@@ -40,6 +41,7 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
- **Add new SSH hosts** with interactive forms
- **Edit existing configurations** in-place
- **Delete hosts** with confirmation prompts
- **Port forwarding setup** with intuitive interface for Local (-L), Remote (-R), and Dynamic (-D) forwarding
- **Backup configurations** automatically before changes
- **Validate settings** to prevent configuration errors
- **ProxyJump support** for secure connection tunneling through bastion hosts
@@ -49,19 +51,26 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
### 🎮 **User Experience**
- **Zero configuration** - Works out of the box with your existing SSH setup
- **Keyboard shortcuts** for power users
- **Cross-platform** - Supports Linux and macOS (Intel & Apple Silicon)
- **Cross-platform** - Supports Linux, macOS (Intel & Apple Silicon), and Windows
- **Lightweight** - Single binary with no dependencies
## 🚀 Quick Start
### Installation
**One-line install (Recommended):**
**Unix/Linux/macOS (One-line install):**
```bash
curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh | bash
```
**Windows (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex
```
**Alternative methods:**
*Linux/macOS:*
```bash
# Download specific release
wget https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-amd64.tar.gz
@@ -71,6 +80,14 @@ tar -xzf sshm-linux-amd64.tar.gz
sudo mv sshm-linux-amd64 /usr/local/bin/sshm
```
*Windows:*
```powershell
# Download and extract
Invoke-WebRequest -Uri "https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-windows-amd64.zip" -OutFile "sshm-windows-amd64.zip"
Expand-Archive sshm-windows-amd64.zip -DestinationPath C:\tools\
# Add C:\tools to your PATH environment variable
```
## 📖 Usage
### Interactive Mode
@@ -87,6 +104,7 @@ sshm
- `a` - Add new host
- `e` - Edit selected host
- `d` - Delete selected host
- `f` - Port forwarding setup
- `q` - Quit
- `/` - Search/filter hosts
@@ -107,6 +125,90 @@ The interactive forms will guide you through configuration:
- **SSH Options** - Additional SSH options in `-o` format (e.g., `-o Compression=yes -o ServerAliveInterval=60`)
- **Tags** - Comma-separated tags for organization
### Port Forwarding
SSHM provides an intuitive interface for setting up SSH port forwarding. Press `f` while selecting a host to open the port forwarding setup:
**Forward Types:**
- **Local (-L)** - Forward a local port to a remote host/port through the SSH connection
- Example: Access a remote database on `localhost:5432` via local port `15432`
- Use case: `ssh -L 15432:localhost:5432 server` → Database accessible on `localhost:15432`
- **Remote (-R)** - Forward a remote port back to a local host/port
- Example: Expose local web server on remote host's port `8080`
- Use case: `ssh -R 8080:localhost:3000 server` → Local app accessible from remote host's port 8080
- ⚠️ **Requirements for external access:**
- **SSH Server Config**: Add `GatewayPorts yes` to `/etc/ssh/sshd_config` and restart SSH service
- **Firewall**: Open the remote port in the server's firewall (`ufw allow 8080` or equivalent)
- **Port Availability**: Ensure the remote port is not already in use
- **Bind Address**: Use `0.0.0.0` for external access, `127.0.0.1` for local-only
- **Dynamic (-D)** - Create a SOCKS proxy for secure browsing
- Example: Route web traffic through the SSH connection
- Use case: `ssh -D 1080 server` → Configure browser to use `localhost:1080` as SOCKS proxy
- ⚠️ **Configuration requirements:**
- **Browser Setup**: Configure SOCKS v5 proxy in browser settings
- **DNS**: Enable "Proxy DNS when using SOCKS v5" for full privacy
- **Applications**: Only SOCKS-aware applications will use the proxy
- **Bind Address**: Use `127.0.0.1` for security (local access only)
**Port Forwarding Interface:**
- Choose forward type with ←/→ arrow keys
- Configure ports and addresses with guided forms
- Optional bind address configuration (defaults to 127.0.0.1)
- Real-time validation of port numbers and addresses
- Connect automatically with configured forwarding options
**Troubleshooting Port Forwarding:**
*Remote Forwarding Issues:*
```bash
# Error: "remote port forwarding failed for listen port X"
# Solutions:
1. Check if port is already in use: ssh server "netstat -tln | grep :X"
2. Use a different port that's available
3. Enable GatewayPorts in SSH config for external access
```
*SSH Server Configuration for Remote Forwarding:*
```bash
# Edit SSH daemon config on the server:
sudo nano /etc/ssh/sshd_config
# Add or uncomment:
GatewayPorts yes
# Restart SSH service:
sudo systemctl restart sshd # Ubuntu/Debian/CentOS 7+
# OR
sudo service ssh restart # Older systems
```
*Firewall Configuration:*
```bash
# Ubuntu/Debian (UFW):
sudo ufw allow [port_number]
# CentOS/RHEL/Rocky (firewalld):
sudo firewall-cmd --add-port=[port_number]/tcp --permanent
sudo firewall-cmd --reload
# Check if port is accessible:
telnet [server_ip] [port_number]
```
*Dynamic Forwarding (SOCKS) Browser Setup:*
```
Firefox: about:preferences → Network Settings
- Manual proxy configuration
- SOCKS Host: localhost, Port: [your_port]
- SOCKS v5: ✓
- Proxy DNS when using SOCKS v5: ✓
Chrome: Launch with proxy
chrome --proxy-server="socks5://localhost:[your_port]"
```
### CLI Usage
SSHM provides both command-line operations and an interactive TUI interface:
@@ -153,6 +255,19 @@ sshm add hostname -c /path/to/custom/ssh_config
sshm edit hostname -c /path/to/custom/ssh_config
```
### Platform-Specific Notes
**Windows:**
- SSHM works with the built-in OpenSSH client (Windows 10/11)
- Configuration file location: `%USERPROFILE%\.ssh\config`
- Compatible with WSL SSH configurations
- Supports the same SSH options as Unix systems
**Unix/Linux/macOS:**
- Standard SSH configuration file: `~/.ssh/config`
- Full compatibility with OpenSSH features
- Preserves file permissions automatically
## 🏗️ Configuration
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.
@@ -314,6 +429,8 @@ Automated releases are built for multiple platforms:
| Linux | ARM64 | [sshm-linux-arm64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-arm64.tar.gz) |
| macOS | Intel | [sshm-darwin-amd64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-darwin-amd64.tar.gz) |
| macOS | Apple Silicon | [sshm-darwin-arm64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-darwin-arm64.tar.gz) |
| Windows | AMD64 | [sshm-windows-amd64.zip](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-windows-amd64.zip) |
| Windows | ARM64 | [sshm-windows-arm64.zip](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-windows-arm64.zip) |
## 🤝 Contributing

View File

@@ -12,8 +12,28 @@ curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh
**Note:** When using the pipe method, the installer will automatically proceed with installation if SSHM is already installed.
## Windows Installation
### Quick Install (Recommended)
```powershell
irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex
```
### Install Options
**Force install without prompts:**
```powershell
iex "& { $(irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1) } -Force"
```
**Custom installation directory:**
```powershell
iex "& { $(irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1) } -InstallDir 'C:\tools'"
```
## Unix/Linux/macOS Advanced Options
**Force install without prompts:**
```bash
FORCE_INSTALL=true bash -c "$(curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh)"

View File

@@ -74,10 +74,10 @@ downloadBinary() {
exit 1
fi
# Find the extracted binary
EXTRACTED_BINARY=$(find . -name "sshm-${OS}-${ARCH}" -type f)
if [ -z "$EXTRACTED_BINARY" ]; then
printf "${RED}Could not find extracted binary${NC}\n"
# Check if the expected binary exists (no find needed)
EXTRACTED_BINARY="./sshm-${OS}-${ARCH}"
if [ ! -f "$EXTRACTED_BINARY" ]; then
printf "${RED}Could not find extracted binary: $EXTRACTED_BINARY${NC}\n"
exit 1
fi
@@ -88,17 +88,37 @@ downloadBinary() {
install() {
printf "${YELLOW}Installing SSHM...${NC}\n"
# Backup old version if it exists to prevent interference during installation
OLD_BACKUP=""
if [ -f "$EXECUTABLE_PATH" ]; then
OLD_BACKUP="$EXECUTABLE_PATH.backup.$$"
runAsRoot mv "$EXECUTABLE_PATH" "$OLD_BACKUP"
fi
chmod +x "sshm-tmp"
if [ $? -ne 0 ]; then
printf "${RED}Failed to set permissions${NC}\n"
# Restore backup if installation fails
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
runAsRoot mv "$OLD_BACKUP" "$EXECUTABLE_PATH"
fi
exit 1
fi
runAsRoot mv "sshm-tmp" "$EXECUTABLE_PATH"
if [ $? -ne 0 ]; then
printf "${RED}Failed to install binary${NC}\n"
# Restore backup if installation fails
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
runAsRoot mv "$OLD_BACKUP" "$EXECUTABLE_PATH"
fi
exit 1
fi
# Clean up backup if installation succeeded
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
runAsRoot rm -f "$OLD_BACKUP"
fi
}
cleanup() {
@@ -161,7 +181,8 @@ main() {
# Show version
printf "${YELLOW}Verifying installation...${NC}\n"
if command -v sshm >/dev/null 2>&1; then
sshm --version
# Use the full path to ensure we're using the newly installed version
"$EXECUTABLE_PATH" --version 2>/dev/null || echo "Version check failed, but installation completed"
else
printf "${RED}Warning: 'sshm' command not found in PATH. You may need to restart your terminal or add $INSTALL_DIR to your PATH.${NC}\n"
fi

135
install/windows.ps1 Normal file
View File

@@ -0,0 +1,135 @@
# SSHM Windows Installation Script
# Usage:
# Online: irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex
# Local: .\install\windows.ps1 -LocalBinary ".\sshm.exe"
param(
[string]$InstallDir = "$env:LOCALAPPDATA\sshm",
[switch]$Force = $false,
[string]$LocalBinary = ""
)
$ErrorActionPreference = "Stop"
# Colors for output
function Write-ColorOutput($ForegroundColor) {
$fc = $host.UI.RawUI.ForegroundColor
$host.UI.RawUI.ForegroundColor = $ForegroundColor
if ($args) {
Write-Output $args
}
$host.UI.RawUI.ForegroundColor = $fc
}
function Write-Info { Write-ColorOutput Green $args }
function Write-Warning { Write-ColorOutput Yellow $args }
function Write-Error { Write-ColorOutput Red $args }
Write-Info "🚀 Installing SSHM - SSH Manager"
Write-Info ""
# Check if SSHM is already installed
$existingSSHM = Get-Command sshm -ErrorAction SilentlyContinue
if ($existingSSHM -and -not $Force) {
$currentVersion = & sshm --version 2>$null | Select-String "version" | ForEach-Object { $_.ToString().Split()[-1] }
Write-Warning "SSHM is already installed (version: $currentVersion)"
$response = Read-Host "Do you want to continue with the installation? (y/N)"
if ($response -ne "y" -and $response -ne "Y") {
Write-Info "Installation cancelled."
exit 0
}
}
# Detect architecture
$arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "386" }
Write-Info "Detected platform: Windows ($arch)"
# Check if using local binary
if ($LocalBinary -ne "") {
if (-not (Test-Path $LocalBinary)) {
Write-Error "Local binary not found: $LocalBinary"
exit 1
}
Write-Info "Using local binary: $LocalBinary"
$targetPath = "$InstallDir\sshm.exe"
# Create installation directory
if (-not (Test-Path $InstallDir)) {
Write-Info "Creating installation directory: $InstallDir"
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
}
# Copy local binary
Write-Info "Installing binary to: $targetPath"
Copy-Item -Path $LocalBinary -Destination $targetPath -Force
} else {
# Online installation
Write-Info "Starting online installation..."
# Get latest version
Write-Info "Fetching latest version..."
try {
$latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest"
$latestVersion = $latestRelease.tag_name
Write-Info "Target version: $latestVersion"
} catch {
Write-Error "Failed to fetch version information"
exit 1
}
# Download binary
$fileName = "sshm-windows-$arch.zip"
$downloadUrl = "https://github.com/Gu1llaum-3/sshm/releases/download/$latestVersion/$fileName"
$tempFile = "$env:TEMP\$fileName"
Write-Info "Downloading $fileName..."
try {
Invoke-WebRequest -Uri $downloadUrl -OutFile $tempFile
} catch {
Write-Error "Download failed"
exit 1
}
# Create installation directory
if (-not (Test-Path $InstallDir)) {
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
}
# Extract archive
Write-Info "Extracting..."
try {
Expand-Archive -Path $tempFile -DestinationPath $env:TEMP -Force
$extractedBinary = "$env:TEMP\sshm-windows-$arch.exe"
$targetPath = "$InstallDir\sshm.exe"
Move-Item -Path $extractedBinary -Destination $targetPath -Force
} catch {
Write-Error "Extraction failed"
exit 1
}
# Clean up
Remove-Item $tempFile -Force
}
# Check PATH
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($userPath -notlike "*$InstallDir*") {
Write-Warning "The directory $InstallDir is not in your PATH."
Write-Info "Adding to user PATH..."
[Environment]::SetEnvironmentVariable("Path", "$userPath;$InstallDir", "User")
Write-Info "Please restart your terminal to use the 'sshm' command."
}
Write-Info ""
Write-Info "✅ SSHM successfully installed to: $targetPath"
Write-Info "You can now use the 'sshm' command!"
# Verify installation
if (Test-Path $targetPath) {
Write-Info ""
Write-Info "Verifying installation..."
& $targetPath --version
}

View File

@@ -0,0 +1,11 @@
//go:build !windows
package config
import "os"
// SetSecureFilePermissions configures secure permissions on Unix systems
func SetSecureFilePermissions(filepath string) error {
// Set file permissions to 0600 (owner read/write only)
return os.Chmod(filepath, 0600)
}

View File

@@ -0,0 +1,24 @@
//go:build windows
package config
import (
"os"
)
// SetSecureFilePermissions configures secure permissions on Windows
func SetSecureFilePermissions(filepath string) error {
// On Windows, file permissions work differently
// We ensure the file is not read-only and has basic permissions
info, err := os.Stat(filepath)
if err != nil {
return err
}
// Ensure the file is not read-only
if info.Mode()&os.ModeType == 0 {
return os.Chmod(filepath, 0600)
}
return nil
}

View File

@@ -6,6 +6,7 @@ import (
"io"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
)
@@ -22,6 +23,46 @@ type SSHHost struct {
Tags []string
}
// GetDefaultSSHConfigPath returns the default SSH config path for the current platform
func GetDefaultSSHConfigPath() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
switch runtime.GOOS {
case "windows":
return filepath.Join(homeDir, ".ssh", "config"), nil
default:
// Linux, macOS, etc.
return filepath.Join(homeDir, ".ssh", "config"), nil
}
}
// GetSSHDirectory returns the .ssh directory path
func GetSSHDirectory() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(homeDir, ".ssh"), nil
}
// ensureSSHDirectory creates the .ssh directory with appropriate permissions
func ensureSSHDirectory() error {
sshDir, err := GetSSHDirectory()
if err != nil {
return err
}
if _, err := os.Stat(sshDir); os.IsNotExist(err) {
// 0700 provides owner-only access across platforms
return os.MkdirAll(sshDir, 0700)
}
return nil
}
// configMutex protects SSH config file operations from race conditions
var configMutex sync.Mutex
@@ -46,32 +87,54 @@ func backupConfig(configPath string) error {
// ParseSSHConfig parses the SSH config file and returns the list of hosts
func ParseSSHConfig() ([]SSHHost, error) {
homeDir, err := os.UserHomeDir()
configPath, err := GetDefaultSSHConfigPath()
if err != nil {
return nil, err
}
configPath := filepath.Join(homeDir, ".ssh", "config")
return ParseSSHConfigFile(configPath)
}
// ParseSSHConfigFile parses a specific SSH config file and returns the list of hosts
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)
if _, err := os.Stat(configPath); os.IsNotExist(err) {
dir := filepath.Dir(configPath)
if _, err := os.Stat(dir); os.IsNotExist(err) {
err = os.MkdirAll(dir, 0700)
if err != nil {
// Only create the main config file, not included files
if absPath == getMainConfigPath() {
// 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)
if err != nil {
return nil, fmt.Errorf("failed to create SSH config file: %w", err)
}
file.Close()
// File created, return empty host list
// File doesn't exist, return empty host list
return []SSHHost{}, nil
}
@@ -125,11 +188,25 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
value := strings.Join(parts[1:], " ")
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":
// New host, save previous one if it exists
if currentHost != nil {
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
currentHost = &SSHHost{
Name: value,
@@ -179,13 +256,61 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
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
func AddSSHHost(host SSHHost) error {
homeDir, err := os.UserHomeDir()
configPath, err := GetDefaultSSHConfigPath()
if err != nil {
return err
}
configPath := filepath.Join(homeDir, ".ssh", "config")
return AddSSHHostToFile(host, configPath)
}
@@ -404,11 +529,10 @@ func GetSSHHostFromFile(hostName string, configPath string) (*SSHHost, error) {
// UpdateSSHHost updates an existing SSH host configuration
func UpdateSSHHost(oldName string, newHost SSHHost) error {
homeDir, err := os.UserHomeDir()
configPath, err := GetDefaultSSHConfigPath()
if err != nil {
return err
}
configPath := filepath.Join(homeDir, ".ssh", "config")
return UpdateSSHHostInFile(oldName, newHost, configPath)
}
@@ -564,11 +688,10 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
// DeleteSSHHost removes an SSH host configuration from the config file
func DeleteSSHHost(hostName string) error {
homeDir, err := os.UserHomeDir()
configPath, err := GetDefaultSSHConfigPath()
if err != nil {
return err
}
configPath := filepath.Join(homeDir, ".ssh", "config")
return DeleteSSHHostFromFile(hostName, configPath)
}

367
internal/config/ssh_test.go Normal file
View File

@@ -0,0 +1,367 @@
package config
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestGetDefaultSSHConfigPath(t *testing.T) {
tests := []struct {
name string
goos string
expected string
}{
{"Linux", "linux", ".ssh/config"},
{"macOS", "darwin", ".ssh/config"},
{"Windows", "windows", ".ssh/config"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Save original GOOS
originalGOOS := runtime.GOOS
defer func() {
// Note: We can't actually change runtime.GOOS at runtime
// This test verifies the function logic with the current OS
_ = originalGOOS
}()
configPath, err := GetDefaultSSHConfigPath()
if err != nil {
t.Fatalf("GetDefaultSSHConfigPath() error = %v", err)
}
if !strings.HasSuffix(configPath, tt.expected) {
t.Errorf("Expected path to end with %q, got %q", tt.expected, configPath)
}
// Verify the path uses the correct separator for current OS
expectedSeparator := string(filepath.Separator)
if !strings.Contains(configPath, expectedSeparator) && len(configPath) > len(tt.expected) {
t.Errorf("Path should use OS-specific separator %q, got %q", expectedSeparator, configPath)
}
})
}
}
func TestGetSSHDirectory(t *testing.T) {
sshDir, err := GetSSHDirectory()
if err != nil {
t.Fatalf("GetSSHDirectory() error = %v", err)
}
if !strings.HasSuffix(sshDir, ".ssh") {
t.Errorf("Expected directory to end with .ssh, got %q", sshDir)
}
// Verify the path uses the correct separator for current OS
expectedSeparator := string(filepath.Separator)
if !strings.Contains(sshDir, expectedSeparator) && len(sshDir) > 4 {
t.Errorf("Path should use OS-specific separator %q, got %q", expectedSeparator, sshDir)
}
}
func TestEnsureSSHDirectory(t *testing.T) {
// This test just ensures the function doesn't panic
// and returns without error when .ssh directory already exists
err := ensureSSHDirectory()
if err != nil {
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,8 +35,33 @@ const (
ViewList ViewMode = iota
ViewAdd
ViewEdit
ViewInfo
ViewPortForward
ViewHelp
)
// PortForwardType defines the type of port forwarding
type PortForwardType int
const (
LocalForward PortForwardType = iota
RemoteForward
DynamicForward
)
func (p PortForwardType) String() string {
switch p {
case LocalForward:
return "Local (-L)"
case RemoteForward:
return "Remote (-R)"
case DynamicForward:
return "Dynamic (-D)"
default:
return "Local (-L)"
}
}
// Model represents the state of the user interface
type Model struct {
table table.Model
@@ -51,9 +76,12 @@ type Model struct {
configFile string // Path to the SSH config file
// View management
viewMode ViewMode
addForm *addFormModel
editForm *editFormModel
viewMode ViewMode
addForm *addFormModel
editForm *editFormModel
infoForm *infoFormModel
portForwardForm *portForwardModel
helpForm *helpModel
// Terminal size and styles
width int

View File

@@ -0,0 +1,490 @@
package ui
import (
"fmt"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Input field indices for port forward form
const (
pfTypeInput = iota
pfLocalPortInput
pfRemoteHostInput
pfRemotePortInput
pfBindAddressInput
)
type portForwardModel struct {
inputs []textinput.Model
focused int
forwardType PortForwardType
hostName string
err string
styles Styles
width int
height int
configFile string
}
// portForwardSubmitMsg is sent when the port forward form is submitted
type portForwardSubmitMsg struct {
err error
sshArgs []string
}
// portForwardCancelMsg is sent when the port forward form is cancelled
type portForwardCancelMsg struct{}
// NewPortForwardForm creates a new port forward form model
func NewPortForwardForm(hostName string, styles Styles, width, height int, configFile string) *portForwardModel {
inputs := make([]textinput.Model, 5)
// Forward type input (display only, controlled by arrow keys)
inputs[pfTypeInput] = textinput.New()
inputs[pfTypeInput].Placeholder = "Use ←/→ to change forward type"
inputs[pfTypeInput].Focus()
inputs[pfTypeInput].Width = 40
inputs[pfTypeInput].SetValue("Local (-L)")
// Local port input
inputs[pfLocalPortInput] = textinput.New()
inputs[pfLocalPortInput].Placeholder = "8080"
inputs[pfLocalPortInput].CharLimit = 5
inputs[pfLocalPortInput].Width = 20
// Remote host input
inputs[pfRemoteHostInput] = textinput.New()
inputs[pfRemoteHostInput].Placeholder = "localhost"
inputs[pfRemoteHostInput].CharLimit = 100
inputs[pfRemoteHostInput].Width = 30
inputs[pfRemoteHostInput].SetValue("localhost")
// Remote port input
inputs[pfRemotePortInput] = textinput.New()
inputs[pfRemotePortInput].Placeholder = "80"
inputs[pfRemotePortInput].CharLimit = 5
inputs[pfRemotePortInput].Width = 20
// Bind address input (optional)
inputs[pfBindAddressInput] = textinput.New()
inputs[pfBindAddressInput].Placeholder = "127.0.0.1 (optional)"
inputs[pfBindAddressInput].CharLimit = 50
inputs[pfBindAddressInput].Width = 30
pf := &portForwardModel{
inputs: inputs,
focused: 0,
forwardType: LocalForward,
hostName: hostName,
styles: styles,
width: width,
height: height,
configFile: configFile,
}
// Initialize input visibility
pf.updateInputVisibility()
return pf
}
func (m *portForwardModel) Init() tea.Cmd {
return textinput.Blink
}
func (m *portForwardModel) Update(msg tea.Msg) (*portForwardModel, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc", "ctrl+c":
return m, func() tea.Msg { return portForwardCancelMsg{} }
case "enter":
nextField := m.getNextValidField(m.focused)
if nextField != -1 {
// Move to next valid input
m.inputs[m.focused].Blur()
m.focused = nextField
m.inputs[m.focused].Focus()
return m, textinput.Blink
} else {
// Submit form
return m, m.submitForm()
}
case "shift+tab", "up":
prevField := m.getPrevValidField(m.focused)
if prevField != -1 {
m.inputs[m.focused].Blur()
m.focused = prevField
m.inputs[m.focused].Focus()
return m, textinput.Blink
}
case "tab", "down":
nextField := m.getNextValidField(m.focused)
if nextField != -1 {
m.inputs[m.focused].Blur()
m.focused = nextField
m.inputs[m.focused].Focus()
return m, textinput.Blink
}
case "left", "right":
if m.focused == pfTypeInput {
// Change forward type
if msg.String() == "left" {
if m.forwardType > 0 {
m.forwardType--
} else {
m.forwardType = DynamicForward
}
} else {
if m.forwardType < DynamicForward {
m.forwardType++
} else {
m.forwardType = LocalForward
}
}
m.inputs[pfTypeInput].SetValue(m.forwardType.String())
m.updateInputVisibility()
// Ensure focused field is valid for the new type
validFields := m.getValidFields()
validFocus := false
for _, field := range validFields {
if field == m.focused {
validFocus = true
break
}
}
if !validFocus && len(validFields) > 0 {
m.inputs[m.focused].Blur()
m.focused = validFields[0]
m.inputs[m.focused].Focus()
}
return m, nil
}
}
}
// Update the focused input
m.inputs[m.focused], cmd = m.inputs[m.focused].Update(msg)
return m, cmd
}
func (m *portForwardModel) updateInputVisibility() {
// Reset all inputs visibility
for i := range m.inputs {
if i != pfTypeInput {
m.inputs[i].Placeholder = ""
}
}
switch m.forwardType {
case LocalForward:
m.inputs[pfLocalPortInput].Placeholder = "Local port (e.g., 8080)"
m.inputs[pfRemoteHostInput].Placeholder = "Remote host (e.g., localhost)"
m.inputs[pfRemotePortInput].Placeholder = "Remote port (e.g., 80)"
m.inputs[pfBindAddressInput].Placeholder = "Bind address (optional, default: 127.0.0.1)"
case RemoteForward:
m.inputs[pfLocalPortInput].Placeholder = "Remote port (e.g., 8080)"
m.inputs[pfRemoteHostInput].Placeholder = "Local host (e.g., localhost)"
m.inputs[pfRemotePortInput].Placeholder = "Local port (e.g., 80)"
m.inputs[pfBindAddressInput].Placeholder = "Bind address (optional)"
case DynamicForward:
m.inputs[pfLocalPortInput].Placeholder = "SOCKS port (e.g., 1080)"
m.inputs[pfRemoteHostInput].Placeholder = ""
m.inputs[pfRemotePortInput].Placeholder = ""
m.inputs[pfBindAddressInput].Placeholder = "Bind address (optional, default: 127.0.0.1)"
}
}
func (m *portForwardModel) View() string {
var sections []string
// Title
title := m.styles.Header.Render("🔗 Port Forwarding Setup")
sections = append(sections, title)
// Host info
hostInfo := fmt.Sprintf("Host: %s", m.hostName)
sections = append(sections, m.styles.HelpText.Render(hostInfo))
// Error message
if m.err != "" {
sections = append(sections, m.styles.Error.Render("Error: "+m.err))
}
// Form fields
var fields []string
// Forward type
typeLabel := "Forward Type:"
if m.focused == pfTypeInput {
typeLabel = m.styles.FocusedLabel.Render(typeLabel)
} else {
typeLabel = m.styles.Label.Render(typeLabel)
}
fields = append(fields, typeLabel)
fields = append(fields, m.inputs[pfTypeInput].View())
fields = append(fields, m.styles.HelpText.Render("Use ←/→ to change type"))
switch m.forwardType {
case LocalForward:
fields = append(fields, "")
fields = append(fields, m.styles.HelpText.Render("Local forwarding: ssh -L [bind_address:]local_port:remote_host:remote_port"))
fields = append(fields, "")
// Local port
localPortLabel := "Local Port:"
if m.focused == pfLocalPortInput {
localPortLabel = m.styles.FocusedLabel.Render(localPortLabel)
} else {
localPortLabel = m.styles.Label.Render(localPortLabel)
}
fields = append(fields, localPortLabel)
fields = append(fields, m.inputs[pfLocalPortInput].View())
// Remote host
remoteHostLabel := "Remote Host:"
if m.focused == pfRemoteHostInput {
remoteHostLabel = m.styles.FocusedLabel.Render(remoteHostLabel)
} else {
remoteHostLabel = m.styles.Label.Render(remoteHostLabel)
}
fields = append(fields, remoteHostLabel)
fields = append(fields, m.inputs[pfRemoteHostInput].View())
// Remote port
remotePortLabel := "Remote Port:"
if m.focused == pfRemotePortInput {
remotePortLabel = m.styles.FocusedLabel.Render(remotePortLabel)
} else {
remotePortLabel = m.styles.Label.Render(remotePortLabel)
}
fields = append(fields, remotePortLabel)
fields = append(fields, m.inputs[pfRemotePortInput].View())
case RemoteForward:
fields = append(fields, "")
fields = append(fields, m.styles.HelpText.Render("Remote forwarding: ssh -R [bind_address:]remote_port:local_host:local_port"))
fields = append(fields, "")
// Remote port
remotePortLabel := "Remote Port:"
if m.focused == pfLocalPortInput {
remotePortLabel = m.styles.FocusedLabel.Render(remotePortLabel)
} else {
remotePortLabel = m.styles.Label.Render(remotePortLabel)
}
fields = append(fields, remotePortLabel)
fields = append(fields, m.inputs[pfLocalPortInput].View())
// Local host
localHostLabel := "Local Host:"
if m.focused == pfRemoteHostInput {
localHostLabel = m.styles.FocusedLabel.Render(localHostLabel)
} else {
localHostLabel = m.styles.Label.Render(localHostLabel)
}
fields = append(fields, localHostLabel)
fields = append(fields, m.inputs[pfRemoteHostInput].View())
// Local port
localPortLabel := "Local Port:"
if m.focused == pfRemotePortInput {
localPortLabel = m.styles.FocusedLabel.Render(localPortLabel)
} else {
localPortLabel = m.styles.Label.Render(localPortLabel)
}
fields = append(fields, localPortLabel)
fields = append(fields, m.inputs[pfRemotePortInput].View())
case DynamicForward:
fields = append(fields, "")
fields = append(fields, m.styles.HelpText.Render("Dynamic forwarding (SOCKS proxy): ssh -D [bind_address:]port"))
fields = append(fields, "")
// SOCKS port
socksPortLabel := "SOCKS Port:"
if m.focused == pfLocalPortInput {
socksPortLabel = m.styles.FocusedLabel.Render(socksPortLabel)
} else {
socksPortLabel = m.styles.Label.Render(socksPortLabel)
}
fields = append(fields, socksPortLabel)
fields = append(fields, m.inputs[pfLocalPortInput].View())
}
// Bind address (for all types)
fields = append(fields, "")
bindLabel := "Bind Address (optional):"
if m.focused == pfBindAddressInput {
bindLabel = m.styles.FocusedLabel.Render(bindLabel)
} else {
bindLabel = m.styles.Label.Render(bindLabel)
}
fields = append(fields, bindLabel)
fields = append(fields, m.inputs[pfBindAddressInput].View())
// Join form fields
formContent := lipgloss.JoinVertical(lipgloss.Left, fields...)
sections = append(sections, formContent)
// Help text
helpText := " Tab/↓: next field • Shift+Tab/↑: previous field • Enter: connect • Esc: cancel"
sections = append(sections, m.styles.HelpText.Render(helpText))
// Join all sections
content := lipgloss.JoinVertical(lipgloss.Left, sections...)
// Center the form
return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
m.styles.FormContainer.Render(content),
)
}
func (m *portForwardModel) submitForm() tea.Cmd {
return func() tea.Msg {
// Validate inputs
localPort := strings.TrimSpace(m.inputs[pfLocalPortInput].Value())
if localPort == "" {
return portForwardSubmitMsg{err: fmt.Errorf("port is required"), sshArgs: nil}
}
// Validate port number
if _, err := strconv.Atoi(localPort); err != nil {
return portForwardSubmitMsg{err: fmt.Errorf("invalid port number"), sshArgs: nil}
}
// Build SSH command with port forwarding
var sshArgs []string
// Add config file if specified
if m.configFile != "" {
sshArgs = append(sshArgs, "-F", m.configFile)
}
// Add forwarding arguments
bindAddress := strings.TrimSpace(m.inputs[pfBindAddressInput].Value())
switch m.forwardType {
case LocalForward:
remoteHost := strings.TrimSpace(m.inputs[pfRemoteHostInput].Value())
remotePort := strings.TrimSpace(m.inputs[pfRemotePortInput].Value())
if remoteHost == "" {
remoteHost = "localhost"
}
if remotePort == "" {
return portForwardSubmitMsg{err: fmt.Errorf("remote port is required for local forwarding"), sshArgs: nil}
}
// Validate remote port
if _, err := strconv.Atoi(remotePort); err != nil {
return portForwardSubmitMsg{err: fmt.Errorf("invalid remote port number"), sshArgs: nil}
}
// Build -L argument
var forwardArg string
if bindAddress != "" {
forwardArg = fmt.Sprintf("%s:%s:%s:%s", bindAddress, localPort, remoteHost, remotePort)
} else {
forwardArg = fmt.Sprintf("%s:%s:%s", localPort, remoteHost, remotePort)
}
sshArgs = append(sshArgs, "-L", forwardArg)
case RemoteForward:
localHost := strings.TrimSpace(m.inputs[pfRemoteHostInput].Value())
localPortStr := strings.TrimSpace(m.inputs[pfRemotePortInput].Value())
if localHost == "" {
localHost = "localhost"
}
if localPortStr == "" {
return portForwardSubmitMsg{err: fmt.Errorf("local port is required for remote forwarding"), sshArgs: nil}
}
// Validate local port
if _, err := strconv.Atoi(localPortStr); err != nil {
return portForwardSubmitMsg{err: fmt.Errorf("invalid local port number"), sshArgs: nil}
}
// Build -R argument (note: localPort is actually the remote port in this context)
var forwardArg string
if bindAddress != "" {
forwardArg = fmt.Sprintf("%s:%s:%s:%s", bindAddress, localPort, localHost, localPortStr)
} else {
forwardArg = fmt.Sprintf("%s:%s:%s", localPort, localHost, localPortStr)
}
sshArgs = append(sshArgs, "-R", forwardArg)
case DynamicForward:
// Build -D argument
var forwardArg string
if bindAddress != "" {
forwardArg = fmt.Sprintf("%s:%s", bindAddress, localPort)
} else {
forwardArg = localPort
}
sshArgs = append(sshArgs, "-D", forwardArg)
}
// Add hostname
sshArgs = append(sshArgs, m.hostName)
// Return success with the SSH command to execute
return portForwardSubmitMsg{err: nil, sshArgs: sshArgs}
}
}
// getValidFields returns the list of valid field indices for the current forward type
func (m *portForwardModel) getValidFields() []int {
switch m.forwardType {
case LocalForward:
return []int{pfTypeInput, pfLocalPortInput, pfRemoteHostInput, pfRemotePortInput, pfBindAddressInput}
case RemoteForward:
return []int{pfTypeInput, pfLocalPortInput, pfRemoteHostInput, pfRemotePortInput, pfBindAddressInput}
case DynamicForward:
return []int{pfTypeInput, pfLocalPortInput, pfBindAddressInput}
default:
return []int{pfTypeInput, pfLocalPortInput, pfRemoteHostInput, pfRemotePortInput, pfBindAddressInput}
}
}
// getNextValidField returns the next valid field index, or -1 if none
func (m *portForwardModel) getNextValidField(currentField int) int {
validFields := m.getValidFields()
for i, field := range validFields {
if field == currentField && i < len(validFields)-1 {
return validFields[i+1]
}
}
return -1
}
// getPrevValidField returns the previous valid field index, or -1 if none
func (m *portForwardModel) getPrevValidField(currentField int) int {
validFields := m.getValidFields()
for i, field := range validFields {
if field == currentField && i > 0 {
return validFields[i-1]
}
}
return -1
}

View File

@@ -36,9 +36,13 @@ type Styles struct {
Error lipgloss.Style
// Form styles (for add/edit forms)
FormTitle lipgloss.Style
FormField lipgloss.Style
FormHelp lipgloss.Style
FormTitle lipgloss.Style
FormField lipgloss.Style
FormHelp lipgloss.Style
FormContainer lipgloss.Style
Label lipgloss.Style
FocusedLabel lipgloss.Style
HelpSection lipgloss.Style
}
// NewStyles creates a new Styles struct with the given terminal width
@@ -85,8 +89,7 @@ func NewStyles(width int) Styles {
Foreground(lipgloss.Color(SecondaryColor)),
HelpText: lipgloss.NewStyle().
Foreground(lipgloss.Color(SecondaryColor)).
MarginTop(1),
Foreground(lipgloss.Color(SecondaryColor)),
// Error style
Error: lipgloss.NewStyle().
@@ -105,6 +108,20 @@ func NewStyles(width int) Styles {
FormHelp: lipgloss.NewStyle().
Foreground(lipgloss.Color("#626262")),
FormContainer: lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(PrimaryColor)).
Padding(1, 2),
Label: lipgloss.NewStyle().
Foreground(lipgloss.Color(SecondaryColor)),
FocusedLabel: lipgloss.NewStyle().
Foreground(lipgloss.Color(PrimaryColor)),
HelpSection: lipgloss.NewStyle().
Padding(0, 2),
}
}

View File

@@ -122,8 +122,8 @@ func (m *Model) updateTableRows() {
rows = append(rows, table.Row{
host.Name,
host.Hostname,
host.User,
host.Port,
// host.User, // Commented to save space
// host.Port, // Commented to save space
tagsStr,
lastLoginStr,
})
@@ -146,16 +146,17 @@ func (m *Model) updateTableHeight() {
// 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
// - Help text: 1 line
// - App margins/spacing: 3 lines
// - Safety margin: 3 lines (to ensure UI elements are always visible)
// Total reserved: 12 lines minimum to preserve essential UI elements
reservedHeight := 12
availableHeight := m.height - reservedHeight
hostCount := len(m.table.Rows())
// Minimum height should be at least 5 rows for usability
minTableHeight := 6 // 1 header + 5 data rows
// Minimum height should be at least 3 rows for basic usability
// Even in very small terminals, we want to show at least header + 2 hosts
minTableHeight := 4 // 1 header + 3 data rows minimum
maxTableHeight := availableHeight
if maxTableHeight < minTableHeight {
maxTableHeight = minTableHeight
@@ -173,9 +174,6 @@ func (m *Model) updateTableHeight() {
tableHeight += maxDataRows
}
// FORCE: Ajoutons une ligne supplémentaire pour résoudre le problème
tableHeight += 1
// Update table height
m.table.SetHeight(tableHeight)
}
@@ -198,11 +196,11 @@ func (m *Model) updateTableColumns() {
// Fixed column widths
hostnameWidth := 25
userWidth := 12
portWidth := 6
// userWidth := 12 // Commented to save space
// portWidth := 6 // Commented to save space
// Calculate total width needed for all columns
totalFixedWidth := hostnameWidth + userWidth + portWidth
totalFixedWidth := hostnameWidth // + userWidth + portWidth // Commented columns
totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth
totalWidth := totalFixedWidth + totalVariableWidth
@@ -226,14 +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{
{Title: "Name", Width: nameWidth},
{Title: nameTitle, Width: nameWidth},
{Title: "Hostname", Width: hostnameWidth},
{Title: "User", Width: userWidth},
{Title: "Port", Width: portWidth},
// {Title: "User", Width: userWidth}, // Commented to save space
// {Title: "Port", Width: portWidth}, // Commented to save space
{Title: "Tags", Width: tagsWidth},
{Title: "Last Login", Width: lastLoginWidth},
{Title: lastLoginTitle, Width: lastLoginWidth},
}
m.table.SetColumns(columns)

View File

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

View File

@@ -46,6 +46,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.editForm.height = m.height
m.editForm.styles = m.styles
}
if m.infoForm != nil {
m.infoForm.width = m.width
m.infoForm.height = m.height
m.infoForm.styles = m.styles
}
if m.portForwardForm != nil {
m.portForwardForm.width = m.width
m.portForwardForm.height = m.height
m.portForwardForm.styles = m.styles
}
if m.helpForm != nil {
m.helpForm.width = m.width
m.helpForm.height = m.height
m.helpForm.styles = m.styles
}
return m, nil
case addFormSubmitMsg:
@@ -136,6 +151,74 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.table.Focus()
return m, nil
case infoFormCancelMsg:
// Cancel: return to list view
m.viewMode = ViewList
m.infoForm = nil
m.table.Focus()
return m, nil
case infoFormEditMsg:
// Switch from info to edit mode
editForm, err := NewEditForm(msg.hostName, m.styles, m.width, m.height, m.configFile)
if err != nil {
// Handle error - could show in UI, for now just go back to list
m.viewMode = ViewList
m.infoForm = nil
m.table.Focus()
return m, nil
}
m.editForm = editForm
m.infoForm = nil
m.viewMode = ViewEdit
return m, textinput.Blink
case portForwardSubmitMsg:
if msg.err != nil {
// Show error in form
if m.portForwardForm != nil {
m.portForwardForm.err = msg.err.Error()
}
return m, nil
} else {
// Success: execute SSH command with port forwarding
if len(msg.sshArgs) > 0 {
sshCmd := exec.Command("ssh", msg.sshArgs...)
// Record the connection in history
if m.historyManager != nil && m.portForwardForm != nil {
err := m.historyManager.RecordConnection(m.portForwardForm.hostName)
if err != nil {
fmt.Printf("Warning: Could not record connection history: %v\n", err)
}
}
return m, tea.ExecProcess(sshCmd, func(err error) tea.Msg {
return tea.Quit()
})
}
// If no SSH args, just return to list view
m.viewMode = ViewList
m.portForwardForm = nil
m.table.Focus()
return m, nil
}
case portForwardCancelMsg:
// Cancel: return to list view
m.viewMode = ViewList
m.portForwardForm = nil
m.table.Focus()
return m, nil
case helpCloseMsg:
// Close help: return to list view
m.viewMode = ViewList
m.helpForm = nil
m.table.Focus()
return m, nil
case tea.KeyMsg:
// Handle view-specific key presses
switch m.viewMode {
@@ -153,6 +236,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.editForm = newForm
return m, cmd
}
case ViewInfo:
if m.infoForm != nil {
var newForm *infoFormModel
newForm, cmd = m.infoForm.Update(msg)
m.infoForm = newForm
return m, cmd
}
case ViewPortForward:
if m.portForwardForm != nil {
var newForm *portForwardModel
newForm, cmd = m.portForwardForm.Update(msg)
m.portForwardForm = newForm
return m, cmd
}
case ViewHelp:
if m.helpForm != nil {
var newForm *helpModel
newForm, cmd = m.helpForm.Update(msg)
m.helpForm = newForm
return m, cmd
}
case ViewList:
// Handle list view keys
return m.handleListViewKeys(msg)
@@ -305,6 +409,22 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, textinput.Blink
}
}
case "i":
if !m.searchMode && !m.deleteMode {
// Show info for the selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := selected[0] // The hostname is in the first column
infoForm, err := NewInfoForm(hostName, m.styles, m.width, m.height, m.configFile)
if err != nil {
// Handle error - could show in UI
return m, nil
}
m.infoForm = infoForm
m.viewMode = ViewInfo
return m, nil
}
}
case "a":
if !m.searchMode && !m.deleteMode {
// Add a new host
@@ -324,6 +444,24 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
}
case "f":
if !m.searchMode && !m.deleteMode {
// Port forwarding for the selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := selected[0] // The hostname is in the first column
m.portForwardForm = NewPortForwardForm(hostName, m.styles, m.width, m.height, m.configFile)
m.viewMode = ViewPortForward
return m, textinput.Blink
}
}
case "h":
if !m.searchMode && !m.deleteMode {
// Show help
m.helpForm = NewHelpForm(m.styles, m.width, m.height)
m.viewMode = ViewHelp
return m, nil
}
case "s":
if !m.searchMode && !m.deleteMode {
// Cycle through sort modes (only 2 modes now)

View File

@@ -23,6 +23,18 @@ func (m Model) View() string {
if m.editForm != nil {
return m.editForm.View()
}
case ViewInfo:
if m.infoForm != nil {
return m.infoForm.View()
}
case ViewPortForward:
if m.portForwardForm != nil {
return m.portForwardForm.View()
}
case ViewHelp:
if m.helpForm != nil {
return m.helpForm.View()
}
case ViewList:
return m.renderListView()
}
@@ -46,10 +58,6 @@ func (m Model) renderListView() string {
components = append(components, m.styles.SearchUnfocused.Render(searchPrompt+m.searchInput.View()))
}
// Add the sort mode indicator
sortInfo := fmt.Sprintf(" Sort: %s", m.sortMode.String())
components = append(components, m.styles.SortInfo.Render(sortInfo))
// Add the table with the appropriate style based on focus
if m.searchMode {
// The table is not focused, use the unfocused style
@@ -62,9 +70,9 @@ func (m Model) renderListView() string {
// Add the help text
var helpText string
if !m.searchMode {
helpText = " Use ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • Tab to switch\n Sort: (s)witch • (r)ecent • (n)ame • q/ESC to quit"
helpText = " ↑/↓: navigate • Enter: connect • i: info • h: help • q: quit"
} else {
helpText = " Type to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit"
helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit"
}
components = append(components, m.styles.HelpText.Render(helpText))