7 Commits

17 changed files with 1287 additions and 58 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/}
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
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

196
README.md
View File

@@ -9,14 +9,18 @@
[![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** 🔥
SSHM is a beautiful command-line tool that transforms how you manage and connect to your SSH hosts. Built with Go and featuring an intuitive TUI interface, it makes SSH connection management effortless and enjoyable.
<p align="center">
<img src="images/sshm.gif" alt="Demo SSHM Terminal" width="600" />
<a href="images/sshm.gif" target="_blank">
<img src="images/sshm.gif" alt="Demo SSHM Terminal" width="800" />
</a>
<br>
<em>🖱️ Click on the image to view in full size</em>
</p>
## ✨ Features
@@ -24,10 +28,12 @@ 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
- **📁 Custom Config Support** - Use any SSH configuration file with the `-c` flag
- **⚙️ SSH Options Support** - Add any SSH configuration option through intuitive forms
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
@@ -35,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
@@ -44,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
@@ -66,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
@@ -82,9 +104,18 @@ sshm
- `a` - Add new host
- `e` - Edit selected host
- `d` - Delete selected host
- `f` - Port forwarding setup
- `q` - Quit
- `/` - Search/filter hosts
**Sorting & Filtering:**
- `s` - Switch between sorting modes (name ↔ last login)
- `n` - Sort by **name** (alphabetical)
- `r` - Sort by **recent** (last login time)
- `Tab` - Cycle between filtering modes
- Filter by **name** (default) - Search through host names
- Filter by **last login** - Sort and filter by most recently used connections
The interactive forms will guide you through configuration:
- **Hostname/IP** - Server address
- **Username** - SSH user
@@ -94,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:
@@ -102,15 +217,24 @@ SSHM provides both command-line operations and an interactive TUI interface:
# Launch interactive TUI mode for browsing and connecting to hosts
sshm
# Launch TUI with custom SSH config file
sshm -c /path/to/custom/ssh_config
# Add a new host using interactive form
sshm add
# Add a new host with pre-filled hostname
sshm add hostname
# Add a new host with custom SSH config file
sshm add hostname -c /path/to/custom/ssh_config
# Edit an existing host configuration
sshm edit my-server
# Edit host with custom SSH config file
sshm edit my-server -c /path/to/custom/ssh_config
# Show version information
sshm --version
@@ -118,6 +242,32 @@ sshm --version
sshm --help
```
### Configuration File Options
By default, SSHM uses the standard SSH configuration file at `~/.ssh/config`. You can specify a different configuration file using the `-c` flag:
```bash
# Use custom config file in TUI mode
sshm -c /path/to/custom/ssh_config
# Use custom config file with commands
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.
@@ -222,24 +372,44 @@ go build -o sshm .
```
sshm/
├── main.go # Application entry point
├── cmd/ # CLI commands (Cobra)
│ ├── root.go # Root command and interactive mode
│ ├── add.go # Add host command
── edit.go # Edit host command
── edit.go # Edit host command
│ └── search.go # Search command
├── internal/
│ ├── config/ # SSH configuration management
│ │ └── ssh.go # Config parsing and manipulation
│ ├── ui/ # Terminal UI components
│ │ ── tui.go # Main TUI interface
│ ├── add_form.go # Add host form
│ │ ── edit_form.go# Edit host form
│ ├── history/ # Connection history tracking
│ │ ── history.go # History management and last login tracking
├── ui/ # Terminal UI components (Bubble Tea)
│ │ ── tui.go # Main TUI interface and program setup
│ │ ├── model.go # Core TUI model and state
│ │ ├── update.go # Message handling and state updates
│ │ ├── view.go # UI rendering and layout
│ │ ├── table.go # Host list table component
│ │ ├── add_form.go # Add host form interface
│ │ ├── edit_form.go# Edit host form interface
│ │ ├── styles.go # Lip Gloss styling definitions
│ │ ├── sort.go # Sorting and filtering logic
│ │ └── utils.go # UI utility functions
│ └── validation/ # Input validation
│ └── ssh.go # SSH config validation
├── images/ # Documentation assets
│ ├── logo.png # Project logo
│ └── sshm.gif # Demo animation
├── install/ # Installation scripts
│ ├── unix.sh # Unix/Linux/macOS installer
│ └── README.md # Installation guide
── .github/workflows/ # CI/CD pipelines
── build.yml # Multi-platform builds
── .github/ # GitHub configuration
── copilot-instructions.md # Development guidelines
│ └── workflows/ # CI/CD pipelines
│ └── build.yml # Multi-platform builds
├── go.mod # Go module definition
├── go.sum # Go module checksums
├── LICENSE # MIT license
└── README.md # Project documentation
```
### Dependencies
@@ -259,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

@@ -21,9 +21,14 @@ var configFile string
var rootCmd = &cobra.Command{
Use: "sshm",
Short: "SSH Manager - A modern SSH connection manager",
Long: `SSH Manager (sshm) is a modern command-line tool for managing SSH connections.
It provides an interactive interface to browse and connect to your SSH hosts
configured in your ~/.ssh/config file.`,
Long: `SSHM is a modern SSH manager for your terminal.
Main usage:
Running 'sshm' (without arguments) opens the interactive TUI window to browse, search, and connect to your SSH hosts graphically.
You can also use sshm in CLI mode for direct operations.
Hosts are read from your ~/.ssh/config file by default.`,
Version: version,
Run: func(cmd *cobra.Command, args []string) {
// If no arguments provided, run interactive mode

Binary file not shown.

Before

Width:  |  Height:  |  Size: 615 KiB

After

Width:  |  Height:  |  Size: 797 KiB

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)"

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,12 +87,10 @@ 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)
}
@@ -59,18 +98,22 @@ func ParseSSHConfig() ([]SSHHost, error) {
func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
// 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 {
// 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 created, return empty host list
return []SSHHost{}, nil
}
@@ -181,11 +224,10 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
// 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 +446,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 +605,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)
}

View File

@@ -0,0 +1,73 @@
package config
import (
"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)
}
}

View File

@@ -35,8 +35,31 @@ const (
ViewList ViewMode = iota
ViewAdd
ViewEdit
ViewPortForward
)
// 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
@@ -54,6 +77,7 @@ type Model struct {
viewMode ViewMode
addForm *addFormModel
editForm *editFormModel
portForwardForm *portForwardModel
// 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

@@ -39,6 +39,9 @@ type Styles struct {
FormTitle lipgloss.Style
FormField lipgloss.Style
FormHelp lipgloss.Style
FormContainer lipgloss.Style
Label lipgloss.Style
FocusedLabel lipgloss.Style
}
// NewStyles creates a new Styles struct with the given terminal width
@@ -105,6 +108,18 @@ 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)).
Bold(true),
}
}

View File

@@ -130,4 +130,119 @@ func (m *Model) updateTableRows() {
}
m.table.SetRows(rows)
// Update table height and columns based on current terminal size
m.updateTableHeight()
m.updateTableColumns()
}
// updateTableHeight dynamically adjusts table height based on terminal size
func (m *Model) updateTableHeight() {
if !m.ready {
return
}
// Calculate dynamic table height based on terminal size
// Layout breakdown:
// - ASCII title: 5 lines (1 empty + 4 text lines)
// - Search bar: 1 line
// - Sort info: 1 line
// - Help text: 2 lines (multi-line text)
// - App margins/spacing: 2 lines
// Total reserved: 11 lines, mais réduisons à 7 pour forcer plus d'espace
reservedHeight := 7 // Réduction agressive pour tester
availableHeight := m.height - reservedHeight
hostCount := len(m.table.Rows())
// Minimum height should be at least 5 rows for usability
minTableHeight := 6 // 1 header + 5 data rows
maxTableHeight := availableHeight
if maxTableHeight < minTableHeight {
maxTableHeight = minTableHeight
}
tableHeight := 1 // header
dataRowsNeeded := hostCount
maxDataRows := maxTableHeight - 1 // subtract 1 for header
if dataRowsNeeded <= maxDataRows {
// We have enough space for all hosts
tableHeight += dataRowsNeeded
} else {
// We need to limit to available space
tableHeight += maxDataRows
}
// FORCE: Ajoutons une ligne supplémentaire pour résoudre le problème
tableHeight += 1
// Update table height
m.table.SetHeight(tableHeight)
}
// updateTableColumns dynamically adjusts table column widths based on terminal size
func (m *Model) updateTableColumns() {
if !m.ready {
return
}
hostsToShow := m.filteredHosts
if hostsToShow == nil {
hostsToShow = m.hosts
}
// Calculate base column widths
nameWidth := calculateNameColumnWidth(hostsToShow)
tagsWidth := calculateTagsColumnWidth(hostsToShow)
lastLoginWidth := calculateLastLoginColumnWidth(hostsToShow, m.historyManager)
// Fixed column widths
hostnameWidth := 25
userWidth := 12
portWidth := 6
// Calculate total width needed for all columns
totalFixedWidth := hostnameWidth + userWidth + portWidth
totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth
totalWidth := totalFixedWidth + totalVariableWidth
// Available width (accounting for table borders and padding)
availableWidth := m.width - 4 // 4 chars for borders and padding
// If the table is too wide, scale down the variable columns proportionally
if totalWidth > availableWidth {
excessWidth := totalWidth - availableWidth
variableColumnsWidth := totalVariableWidth
if variableColumnsWidth > 0 {
// Reduce variable columns proportionally
nameReduction := (excessWidth * nameWidth) / variableColumnsWidth
tagsReduction := (excessWidth * tagsWidth) / variableColumnsWidth
lastLoginReduction := excessWidth - nameReduction - tagsReduction
nameWidth = max(8, nameWidth-nameReduction)
tagsWidth = max(8, tagsWidth-tagsReduction)
lastLoginWidth = max(10, lastLoginWidth-lastLoginReduction)
}
}
// Create new columns with updated widths
columns := []table.Column{
{Title: "Name", Width: nameWidth},
{Title: "Hostname", Width: hostnameWidth},
{Title: "User", Width: userWidth},
{Title: "Port", Width: portWidth},
{Title: "Tags", Width: tagsWidth},
{Title: "Last Login", Width: lastLoginWidth},
}
m.table.SetColumns(columns)
}
// max returns the maximum of two integers
func max(a, b int) int {
if a > b {
return a
}
return b
}

View File

@@ -99,21 +99,12 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
})
}
// Determine table height: 1 (header) + number of hosts (max 10)
hostCount := len(rows)
tableHeight := 1 // header
if hostCount < 10 {
tableHeight += hostCount
} else {
tableHeight += 10
}
// Create the table
// Create the table with initial height (will be updated on first WindowSizeMsg)
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(tableHeight),
table.WithHeight(10), // Initial height, will be recalculated dynamically
)
// Style the table
@@ -135,6 +126,9 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
// Initialize table styles based on initial focus state
m.updateTableStyles()
// The table height will be properly set on the first WindowSizeMsg
// when m.ready becomes true and actual terminal dimensions are known
return m
}

View File

@@ -31,6 +31,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.styles = NewStyles(m.width)
m.ready = true
// Update table height and columns based on new window size
m.updateTableHeight()
m.updateTableColumns()
// Update sub-forms if they exist
if m.addForm != nil {
m.addForm.width = m.width
@@ -42,6 +46,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.editForm.height = m.height
m.editForm.styles = m.styles
}
if m.portForwardForm != nil {
m.portForwardForm.width = m.width
m.portForwardForm.height = m.height
m.portForwardForm.styles = m.styles
}
return m, nil
case addFormSubmitMsg:
@@ -66,7 +75,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit
}
m.hosts = m.sortHosts(hosts)
// Reapply search filter if there is one active
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.hosts
}
m.updateTableRows()
m.viewMode = ViewList
m.addForm = nil
@@ -103,7 +119,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit
}
m.hosts = m.sortHosts(hosts)
// Reapply search filter if there is one active
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.hosts
}
m.updateTableRows()
m.viewMode = ViewList
m.editForm = nil
@@ -118,6 +141,45 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.table.Focus()
return m, nil
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 tea.KeyMsg:
// Handle view-specific key presses
switch m.viewMode {
@@ -135,6 +197,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.editForm = 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 ViewList:
// Handle list view keys
return m.handleListViewKeys(msg)
@@ -230,7 +299,14 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
m.hosts = m.sortHosts(hosts)
// Reapply search filter if there is one active
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.hosts
}
m.updateTableRows()
m.deleteMode = false
m.deleteHost = ""
@@ -299,6 +375,17 @@ 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 "s":
if !m.searchMode && !m.deleteMode {
// Cycle through sort modes (only 2 modes now)

View File

@@ -23,6 +23,10 @@ func (m Model) View() string {
if m.editForm != nil {
return m.editForm.View()
}
case ViewPortForward:
if m.portForwardForm != nil {
return m.portForwardForm.View()
}
case ViewList:
return m.renderListView()
}
@@ -39,7 +43,7 @@ func (m Model) renderListView() string {
components = append(components, m.styles.Header.Render(asciiTitle))
// Add the search bar with the appropriate style based on focus
searchPrompt := "Search (/ to focus, Tab to switch): "
searchPrompt := "Search (/ to focus): "
if m.searchMode {
components = append(components, m.styles.SearchFocused.Render(searchPrompt+m.searchInput.View()))
} else {
@@ -62,7 +66,7 @@ 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 = " 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"
} else {
helpText = " Type to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit"
}