Compare commits

..

No commits in common. "main" and "1.3.0" have entirely different histories.
main ... 1.3.0

47 changed files with 866 additions and 8474 deletions

124
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,124 @@
name: Build Binaries
on:
push:
tags:
- '*'
release:
types: [created]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
name: Build
runs-on: ubuntu-latest
strategy:
matrix:
include:
# Linux AMD64
- goos: linux
goarch: amd64
suffix: linux-amd64
# Linux ARM64
- goos: linux
goarch: arm64
suffix: linux-arm64
# macOS AMD64 (Intel)
- goos: darwin
goarch: amd64
suffix: darwin-amd64
# macOS ARM64 (Apple Silicon)
- 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
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
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 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
dist/sshm-${{ matrix.suffix }}.zip
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: ./artifacts
merge-multiple: true
- name: Prepare release assets
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
uses: softprops/action-gh-release@v2
with:
files: ./release/*
draft: false
prerelease: false
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,39 +0,0 @@
name: Release with GoReleaser
on:
push:
tags:
- '*'
permissions:
contents: write
# Required for Homebrew tap updates
issues: write
pull-requests: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Fetch full history for changelog generation
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache: true
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Token for updating Homebrew tap (create this secret in your repo settings)
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}

View File

@ -1,155 +0,0 @@
version: 2
project_name: sshm
before:
hooks:
- go mod tidy
- go test ./...
builds:
- id: sshm
main: ./main.go
binary: sshm
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm64
- "386"
- arm
goarm:
- "6"
- "7"
ignore:
# Skip ARM for Windows (not commonly used)
- goos: windows
goarch: arm
- goos: windows
goarch: arm64
env:
- CGO_ENABLED=0
ldflags:
- -s -w
- -X github.com/Gu1llaum-3/sshm/cmd.AppVersion={{.Version}}
flags:
- -trimpath
archives:
- id: sshm
formats: [ "tar.gz" ]
# Use zip for Windows
format_overrides:
- goos: windows
formats: [ "zip" ]
# Template for archive name
name_template: >-
{{ .ProjectName }}_ {{- title .Os }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }}
files:
- LICENSE
- README.md
checksum:
name_template: "checksums.txt"
algorithm: sha256
changelog:
use: github
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
- "^chore:"
- "^build:"
groups:
- title: Features
regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$'
order: 0
- title: Bug fixes
regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$'
order: 1
- title: Others
order: 999
# Homebrew tap configuration (Formula pour CLI)
brews:
- name: sshm
repository:
owner: Gu1llaum-3
name: homebrew-sshm
# Token with repo permissions for your homebrew-sshm repo
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
commit_author:
name: goreleaserbot
email: bot@goreleaser.com
commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}"
homepage: "https://github.com/Gu1llaum-3/sshm"
description: "A modern SSH connection manager for your terminal"
license: MIT
skip_upload: auto
# Test command to verify installation
test: |
system "#{bin}/sshm --version"
# Release configuration
release:
github:
owner: Gu1llaum-3
name: sshm
prerelease: auto
draft: false
replace_existing_draft: true
target_commitish: "{{ .Commit }}"
name_template: "{{.ProjectName}} {{.Version}}"
header: |
## SSHM {{.Version}}
Thank you for downloading SSHM!
footer: |
## Installation
### Homebrew (macOS/Linux)
```bash
brew tap Gu1llaum-3/sshm
brew install sshm
```
### Installation Script (Recommended)
**Unix/Linux/macOS:**
```bash
curl -sSL https://github.com/Gu1llaum-3/sshm/raw/main/install/unix.sh | bash
```
**Windows (PowerShell):**
```powershell
iwr -useb https://github.com/Gu1llaum-3/sshm/raw/main/install/windows.ps1 | iex
```
### Manual Installation
Download the appropriate binary for your platform from the assets above, extract it, and place it in your PATH.
## Full Changelog
See all changes at https://github.com/Gu1llaum-3/sshm/compare/{{.PreviousTag}}...{{.Tag}}
---
📖 **Documentation:** See the updated [README](https://github.com/Gu1llaum-3/sshm/blob/main/README.md)
🐛 **Issues:** Found a bug? Open an [issue](https://github.com/Gu1llaum-3/sshm/issues)
---
Released with ❤️ by [GoReleaser](https://github.com/goreleaser/goreleaser)
# Snapshot builds (for non-tag builds)
snapshot:
version_template: "{{ .Tag }}-snapshot-{{.ShortCommit}}"
# Metadata for package managers
metadata:
mod_timestamp: "{{ .CommitTimestamp }}"

View File

@ -1,44 +0,0 @@
.PHONY: build build-local test clean release snapshot
# Version can be overridden via environment variable or command line
VERSION ?= dev
# Go build flags
LDFLAGS := -s -w -X github.com/Gu1llaum-3/sshm/cmd.AppVersion=$(VERSION)
# Build with specific version
build:
@mkdir -p dist
go build -ldflags="$(LDFLAGS)" -o dist/sshm .
# Build with git version
build-local: VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
build-local: build
# Run tests
test:
go test ./...
# Clean build artifacts
clean:
rm -rf dist
# Release with GoReleaser (requires tag)
release:
@if [ -z "$(shell git tag --points-at HEAD)" ]; then \
echo "Error: No git tag found at current commit. Create a tag first with: git tag vX.Y.Z"; \
exit 1; \
fi
goreleaser release --clean
# Build snapshot (without tag)
snapshot:
goreleaser release --snapshot --clean
# Check GoReleaser config
release-check:
goreleaser check
# Run GoReleaser in dry-run mode
release-dry-run:
goreleaser release --snapshot --skip=publish --clean

295
README.md
View File

@ -25,39 +25,39 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
## ✨ Features
### 🚀 **Core Capabilities**
### 🎯 **Core Features**
- **🎨 Beautiful TUI Interface** - Navigate your SSH hosts with an elegant, interactive terminal UI
- **⚡ Quick Connect** - Connect to any host instantly through the TUI or the CLI with `sshm <host>`
- **🔄 Port Forwarding** - Easy setup for Local, Remote, and Dynamic (SOCKS) forwarding with history persistence
- **📝 Easy Management** - Add, edit, move, and manage SSH configurations seamlessly
- **⚡ Quick Connect** - Connect to any host instantly
- **🔄 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
- **📝 Real-time Status** - Live SSH connectivity indicators with asynchronous ping checks and color-coded status
- **🔔 Smart Updates** - Automatic version checking with update notifications
- **📈 Connection History** - Track your SSH connections with last login timestamps
### 🛠️ **Technical Features**
- **🔒 Secure** - Works directly with your existing `~/.ssh/config` file
- **📁 Custom Config Support** - Use any SSH configuration file with the `-c` flag
- **📂 SSH Include Support** - Full support for SSH Include directives to organize configurations across multiple files
- **⚙️ SSH Options Support** - Add any SSH configuration option through intuitive forms
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
- **🔄 Automatic Backups** - Backup configurations automatically before changes
- **✅ Validation** - Prevent configuration errors with built-in validation
- **🔗 ProxyJump Support** - Secure connection tunneling through bastion hosts
- **⌨️ Keyboard Shortcuts** - Power user navigation with vim-like shortcuts
- **🌐 Cross-platform** - Supports Linux, macOS (Intel & Apple Silicon), and Windows
- **⚡ Lightweight** - Single binary with no dependencies, zero configuration required
### 🛠️ **Management Operations**
- **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
- **SSH Options management** - Add any SSH option with automatic format conversion
- **Full SSH compatibility** - Maintains compatibility with standard SSH tools
### 🎮 **User Experience**
- **Zero configuration** - Works out of the box with your existing SSH setup
- **Keyboard shortcuts** for power users
- **Cross-platform** - Supports Linux, macOS (Intel & Apple Silicon), and Windows
- **Lightweight** - Single binary with no dependencies
## 🚀 Quick Start
### Installation
**Homebrew (Recommended for macOS):**
```bash
brew install Gu1llaum-3/sshm/sshm
```
**Unix/Linux/macOS (One-line install):**
```bash
curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh | bash
@ -104,17 +104,10 @@ sshm
- `a` - Add new host
- `e` - Edit selected host
- `d` - Delete selected host
- `m` - Move host to another config file (requires SSH Include directives)
- `f` - Port forwarding setup
- `q` - Quit
- `/` - Search/filter hosts
**Real-time Status Indicators:**
- 🟢 **Online** - Host is reachable via SSH
- 🟡 **Connecting** - Currently checking host connectivity
- 🔴 **Offline** - Host is unreachable or SSH connection failed
- ⚫ **Unknown** - Connectivity status not yet determined
**Sorting & Filtering:**
- `s` - Switch between sorting modes (name ↔ last login)
- `n` - Sort by **name** (alphabetical)
@ -164,7 +157,6 @@ SSHM provides an intuitive interface for setting up SSH port forwarding. Press `
- 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
- **Port forwarding history** - Save frequently used configurations for quick reuse
- Connect automatically with configured forwarding options
**Troubleshooting Port Forwarding:**
@ -225,15 +217,9 @@ SSHM provides both command-line operations and an interactive TUI interface:
# Launch interactive TUI mode for browsing and connecting to hosts
sshm
# Connect directly to a specific host (with history tracking)
sshm my-server
# Launch TUI with custom SSH config file
sshm -c /path/to/custom/ssh_config
# Connect directly with custom SSH config file
sshm my-server -c /path/to/custom/ssh_config
# Add a new host using interactive form
sshm add
@ -249,69 +235,13 @@ sshm edit my-server
# Edit host with custom SSH config file
sshm edit my-server -c /path/to/custom/ssh_config
# Move a host to another SSH config file (requires Include directives)
sshm move my-server
# Move host with custom SSH config file (requires Include directives)
sshm move my-server -c /path/to/custom/ssh_config
# Search for hosts (interactive filter)
sshm search
# Show version information (includes update check)
# Show version information
sshm --version
# Show help and available commands
sshm --help
```
### Direct Host Connection
SSHM supports direct connection to hosts via the command line, making it easy to integrate into your existing workflow:
```bash
# Connect directly to any configured host
sshm production-server
sshm db-staging
sshm web-01
# All direct connections are tracked in your history
# Use the TUI to see your most recently connected hosts
```
**Features of Direct Connection:**
- **Instant connection** - No TUI navigation required
- **History tracking** - All connections are recorded with timestamps
- **Error handling** - Clear messages if host doesn't exist or configuration issues
- **Config file support** - Works with custom config files using `-c` flag
### Backup Configuration
SSHM automatically creates backups of your SSH configuration files before making any changes to ensure your configurations are safe.
**Backup Location:**
- **Unix/Linux/macOS**: `~/.config/sshm/backups/` (or `$XDG_CONFIG_HOME/sshm/backups/` if set)
- **Windows**: `%APPDATA%\sshm\backups\` (fallback: `%USERPROFILE%\.config\sshm\backups\`)
**Key Features:**
- Automatic backup before any modification
- One backup per file (overwrites previous backup)
- Stored separately to avoid SSH Include conflicts
- Easy manual recovery if needed
**Additional Storage:**
- **Connection History**: Stored in the same config directory for persistent tracking
- **Port Forwarding History**: Saved configurations for quick reuse of common forwarding setups
**Quick Recovery:**
```bash
# Unix/Linux/macOS
cp ~/.config/sshm/backups/config.backup ~/.ssh/config
# Windows
copy "%APPDATA%\sshm\backups\config.backup" "%USERPROFILE%\.ssh\config"
```
### 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:
@ -323,96 +253,8 @@ 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
sshm move hostname -c /path/to/custom/ssh_config
```
### Advanced Features
#### Host Movement Between Config Files
SSHM provides a powerful `move` command to relocate SSH hosts between different configuration files. **This feature requires SSH Include directives to be present in your SSH configuration.**
```bash
# Move a host to another config file (requires Include directives)
sshm move my-server
# Move with custom config file (requires Include directives)
sshm move my-server -c /path/to/custom/ssh_config
```
**⚠️ Important Requirements:**
- **SSH Include directives must be present** in your SSH config file (either `~/.ssh/config` or the file specified with `-c`)
- The config file must contain `Include` statements referencing other SSH configuration files
- Without Include directives, the move command will display an error message
**Features:**
- **Interactive file selector** - Choose destination config file from Include directives
- **Include support** - Works seamlessly with SSH Include directives structure
- **Atomic operations** - Safe host movement with automatic backups
- **Validation** - Prevents conflicts and ensures configuration integrity
- **Error handling** - Clear messages when Include files are needed but not found
**Use Cases:**
- Reorganize hosts from main config to specialized include files
- Move development hosts to separate environment-specific configs
- Consolidate configurations for better organization
**Example Setup Required:**
Your main SSH config file must contain Include directives like:
```ssh
# ~/.ssh/config
Include ~/.ssh/config.d/*
Include work-servers.conf
Include projects/*.conf
Host personal-server
HostName personal.example.com
User myuser
```
#### Real-time Connectivity Status
SSHM features asynchronous SSH connectivity checking that provides visual indicators of host availability:
**Status Indicators:**
- 🟢 **Online** - SSH connection successful (shows response time)
- 🟡 **Connecting** - Currently testing connectivity
- 🔴 **Offline** - SSH connection failed or host unreachable
- ⚫ **Unknown** - Status not yet determined
**Features:**
- **Non-blocking checks** - Status updates happen in the background
- **Response time tracking** - See connection latency for online hosts
- **Automatic refresh** - Status indicators update continuously
- **Error details** - Detailed error information for failed connections
#### Automatic Update Checking
SSHM includes built-in version checking that notifies you of available updates:
**Features:**
- **Background checking** - Version check happens asynchronously
- **Release notifications** - Clear indicators when updates are available
- **Pre-release detection** - Identifies beta and development versions
- **GitHub integration** - Direct links to release pages
- **Non-intrusive** - Updates don't interrupt your workflow
**Update notifications appear:**
- In the main TUI interface as a subtle notification
- In the `sshm --version` command output
- Only when a newer stable version is available
#### Port Forwarding History
SSHM remembers your port forwarding configurations for easy reuse:
**Features:**
- **Automatic saving** - Successful forwarding setups are saved automatically
- **Quick reuse** - Previously used configurations appear as suggestions
- **Per-host history** - Forwarding history is tracked per SSH host
- **All forward types** - Supports Local (-L), Remote (-R), and Dynamic (-D) forwarding history
- **Persistent storage** - History survives application restarts
### Platform-Specific Notes
**Windows:**
@ -430,55 +272,7 @@ SSHM remembers your port forwarding configurations for easy reuse:
SSHM works directly with your standard SSH configuration file (`~/.ssh/config`). It adds special comment tags for enhanced functionality while maintaining full compatibility with standard SSH tools.
### SSH Include Support
SSHM fully supports SSH Include directives, allowing you to organize your SSH configurations across multiple files. This is particularly useful for managing large numbers of hosts or organizing configurations by environment, project, or team.
**Include Examples:**
```ssh
# Main ~/.ssh/config file
Host personal-server
HostName personal.example.com
User myuser
# Include work-related configurations
Include work-servers.conf
# Include all configurations from a directory
Include projects/*
# Include with relative paths
Include ~/.ssh/configs/production.conf
```
**Organization Examples:**
*work-servers.conf:*
```ssh
# Tags: work, production
Host prod-web-01
HostName 10.0.1.10
User deploy
ProxyJump bastion.company.com
# Tags: work, staging
Host staging-api
HostName staging-api.company.com
User developer
```
*projects/client-alpha.conf:*
```ssh
# Tags: client, development
Host client-alpha-dev
HostName dev.client-alpha.com
User admin
Port 2222
```
**Example configuration:**
Include ~/.ssh/conf.d/*
```ssh
# Tags: production, web, frontend
Host web-prod-01
@ -553,34 +347,6 @@ This will be automatically converted to:
StrictHostKeyChecking no
```
### Custom Key Bindings
SSHM supports customizable key bindings through a configuration file. This is particularly useful for users who want to modify the default quit behavior.
**Configuration File Location:**
- **Linux/macOS**: `~/.config/sshm/config.json`
- **Windows**: `%APPDATA%\sshm\config.json`
**Example Configuration:**
```json
{
"key_bindings": {
"quit_keys": ["q", "ctrl+c"],
"disable_esc_quit": true
}
}
```
**Available Options:**
- **quit_keys**: Array of keys that will quit the application. Default: `["q", "ctrl+c"]`
- **disable_esc_quit**: Boolean flag to disable ESC key from quitting the application. Default: `false`
**For Vim Users:**
If you frequently press ESC accidentally causing the application to quit, set `disable_esc_quit` to `true`. This will disable ESC as a quit key while preserving all other functionality.
**Default Configuration:**
If no configuration file exists, SSHM will automatically create one with default settings that maintain backward compatibility.
## 🛠️ Development
### Prerequisites
@ -611,29 +377,20 @@ sshm/
│ ├── root.go # Root command and interactive mode
│ ├── add.go # Add host command
│ ├── edit.go # Edit host command
│ ├── move.go # Move host command
│ └── search.go # Search command
├── internal/
│ ├── config/ # SSH configuration management
│ │ └── ssh.go # Config parsing and manipulation
│ ├── connectivity/ # SSH connectivity checking
│ │ └── ping.go # Asynchronous SSH ping functionality
│ ├── history/ # Connection history tracking
│ │ ├── history.go # History management and last login tracking
│ │ └── port_forward_test.go # Port forwarding history tests
│ ├── version/ # Version checking and updates
│ │ ├── version.go # GitHub release checking and version comparison
│ │ └── version_test.go # Version parsing and comparison tests
│ │ └── 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 with status indicators
│ │ ├── table.go # Host list table component
│ │ ├── add_form.go # Add host form interface
│ │ ├── edit_form.go# Edit host form interface
│ │ ├── move_form.go# Move host form interface
│ │ ├── port_forward_form.go # Port forwarding setup with history
│ │ ├── styles.go # Lip Gloss styling definitions
│ │ ├── sort.go # Sorting and filtering logic
│ │ └── utils.go # UI utility functions
@ -661,7 +418,6 @@ sshm/
- [Bubble Tea](https://github.com/charmbracelet/bubbletea) - TUI framework
- [Bubbles](https://github.com/charmbracelet/bubbles) - TUI components
- [Lipgloss](https://github.com/charmbracelet/lipgloss) - Styling
- [Go Crypto SSH](https://golang.org/x/crypto/ssh) - SSH connectivity checking
## 📦 Releases
@ -696,9 +452,6 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- [Charm](https://charm.sh/) for the amazing TUI libraries
- [Cobra](https://cobra.dev/) for the excellent CLI framework
- [@yimeng](https://github.com/yimeng) for contributing SSH Include directive support
- [@ldreux](https://github.com/ldreux) for contributing multi-word search functionality
- [@qingfengzxr](https://github.com/qingfengzxr) for contributing custom key bindings support
- The Go community for building such fantastic tools
---

View File

@ -2,8 +2,7 @@ package cmd
import (
"fmt"
"github.com/Gu1llaum-3/sshm/internal/ui"
"sshm/internal/ui"
"github.com/spf13/cobra"
)
@ -27,5 +26,5 @@ var addCmd = &cobra.Command{
}
func init() {
RootCmd.AddCommand(addCmd)
rootCmd.AddCommand(addCmd)
}

View File

@ -1,88 +0,0 @@
package cmd
import (
"bytes"
"testing"
"github.com/spf13/cobra"
)
func TestAddCommand(t *testing.T) {
// Test that the add command is properly configured
if addCmd.Use != "add [hostname]" {
t.Errorf("Expected Use 'add [hostname]', got '%s'", addCmd.Use)
}
if addCmd.Short != "Add a new SSH host configuration" {
t.Errorf("Expected Short description, got '%s'", addCmd.Short)
}
// Test that it accepts maximum 1 argument
err := addCmd.Args(addCmd, []string{"host1", "host2"})
if err == nil {
t.Error("Expected error for too many arguments")
}
// Test that it accepts 0 or 1 argument
err = addCmd.Args(addCmd, []string{})
if err != nil {
t.Errorf("Expected no error for 0 arguments, got %v", err)
}
err = addCmd.Args(addCmd, []string{"hostname"})
if err != nil {
t.Errorf("Expected no error for 1 argument, got %v", err)
}
}
func TestAddCommandRegistration(t *testing.T) {
// Check that add command is registered with root command
found := false
for _, cmd := range RootCmd.Commands() {
if cmd.Name() == "add" {
found = true
break
}
}
if !found {
t.Error("Add command not found in root command")
}
}
func TestAddCommandHelp(t *testing.T) {
// Test help output
cmd := &cobra.Command{}
cmd.AddCommand(addCmd)
buf := new(bytes.Buffer)
cmd.SetOut(buf)
cmd.SetArgs([]string{"add", "--help"})
// This should not return an error for help
err := cmd.Execute()
if err != nil {
t.Errorf("Expected no error for help command, got %v", err)
}
output := buf.String()
if !contains(output, "Add a new SSH host configuration") {
t.Error("Help output should contain command description")
}
}
// Helper function to check if string contains substring
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > len(substr) && (s[:len(substr)] == substr ||
s[len(s)-len(substr):] == substr ||
containsSubstring(s, substr))))
}
func containsSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@ -2,8 +2,7 @@ package cmd
import (
"fmt"
"github.com/Gu1llaum-3/sshm/internal/ui"
"sshm/internal/ui"
"github.com/spf13/cobra"
)
@ -24,5 +23,5 @@ var editCmd = &cobra.Command{
}
func init() {
RootCmd.AddCommand(editCmd)
rootCmd.AddCommand(editCmd)
}

View File

@ -1,70 +0,0 @@
package cmd
import (
"bytes"
"testing"
"github.com/spf13/cobra"
)
func TestEditCommand(t *testing.T) {
// Test that the edit command is properly configured
if editCmd.Use != "edit <hostname>" {
t.Errorf("Expected Use 'edit <hostname>', got '%s'", editCmd.Use)
}
if editCmd.Short != "Edit an existing SSH host configuration" {
t.Errorf("Expected Short description, got '%s'", editCmd.Short)
}
// Test that it requires exactly 1 argument
err := editCmd.Args(editCmd, []string{})
if err == nil {
t.Error("Expected error for no arguments")
}
err = editCmd.Args(editCmd, []string{"host1", "host2"})
if err == nil {
t.Error("Expected error for too many arguments")
}
err = editCmd.Args(editCmd, []string{"hostname"})
if err != nil {
t.Errorf("Expected no error for 1 argument, got %v", err)
}
}
func TestEditCommandRegistration(t *testing.T) {
// Check that edit command is registered with root command
found := false
for _, cmd := range RootCmd.Commands() {
if cmd.Name() == "edit" {
found = true
break
}
}
if !found {
t.Error("Edit command not found in root command")
}
}
func TestEditCommandHelp(t *testing.T) {
// Test help output
cmd := &cobra.Command{}
cmd.AddCommand(editCmd)
buf := new(bytes.Buffer)
cmd.SetOut(buf)
cmd.SetArgs([]string{"edit", "--help"})
// This should not return an error for help
err := cmd.Execute()
if err != nil {
t.Errorf("Expected no error for help command, got %v", err)
}
output := buf.String()
if !contains(output, "Edit an existing SSH host configuration") {
t.Error("Help output should contain command description")
}
}

View File

@ -1,28 +0,0 @@
package cmd
import (
"fmt"
"github.com/Gu1llaum-3/sshm/internal/ui"
"github.com/spf13/cobra"
)
var moveCmd = &cobra.Command{
Use: "move <hostname>",
Short: "Move an existing SSH host configuration to another config file",
Long: `Move an existing SSH host configuration to another config file with an interactive file selector.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
hostname := args[0]
err := ui.RunMoveForm(hostname, configFile)
if err != nil {
fmt.Printf("Error moving host: %v\n", err)
}
},
}
func init() {
RootCmd.AddCommand(moveCmd)
}

View File

@ -1,57 +1,45 @@
package cmd
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"strings"
"syscall"
"time"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/history"
"github.com/Gu1llaum-3/sshm/internal/ui"
"github.com/Gu1llaum-3/sshm/internal/version"
"sshm/internal/config"
"sshm/internal/ui"
"github.com/spf13/cobra"
)
// AppVersion will be set at build time via -ldflags
var AppVersion = "dev"
// version will be set at build time via -ldflags
var version = "dev"
// configFile holds the path to the SSH config file
var configFile string
// RootCmd is the base command when called without any subcommands
var RootCmd = &cobra.Command{
Use: "sshm [host]",
var rootCmd = &cobra.Command{
Use: "sshm",
Short: "SSH Manager - A modern SSH connection manager",
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.
Running 'sshm <host>' connects directly to the specified host and records the connection in your history.
You can also use sshm in CLI mode for other operations like adding, editing, or searching hosts.
You can also use sshm in CLI mode for direct operations.
Hosts are read from your ~/.ssh/config file by default.`,
Version: AppVersion,
Args: cobra.ArbitraryArgs,
SilenceUsage: true,
SilenceErrors: true, // We'll handle errors ourselves
RunE: func(cmd *cobra.Command, args []string) error {
Version: version,
Run: func(cmd *cobra.Command, args []string) {
// If no arguments provided, run interactive mode
if len(args) == 0 {
runInteractiveMode()
return nil
return
}
// If a host name is provided, connect directly
hostName := args[0]
connectToHost(hostName)
return nil
},
}
@ -97,24 +85,33 @@ func runInteractiveMode() {
}
// Run the interactive TUI
if err := ui.RunInteractiveMode(hosts, configFile, AppVersion); err != nil {
if err := ui.RunInteractiveMode(hosts, configFile); err != nil {
log.Fatalf("Error running interactive mode: %v", err)
}
}
func connectToHost(hostName string) {
// Quick check if host exists without full parsing (optimized for connection)
var hostFound bool
// Parse SSH configurations to verify host exists
var hosts []config.SSHHost
var err error
if configFile != "" {
hostFound, err = config.QuickHostExistsInFile(hostName, configFile)
hosts, err = config.ParseSSHConfigFile(configFile)
} else {
hostFound, err = config.QuickHostExists(hostName)
hosts, err = config.ParseSSHConfig()
}
if err != nil {
log.Fatalf("Error checking SSH config: %v", err)
log.Fatalf("Error reading SSH config file: %v", err)
}
// Check if host exists
var hostFound bool
for _, host := range hosts {
if host.Name == hostName {
hostFound = true
break
}
}
if !hostFound {
@ -123,94 +120,25 @@ func connectToHost(hostName string) {
os.Exit(1)
}
// Record the connection in history
historyManager, err := history.NewHistoryManager()
if err != nil {
// Log the error but don't prevent the connection
fmt.Printf("Warning: Could not initialize connection history: %v\n", err)
} else {
err = historyManager.RecordConnection(hostName)
if err != nil {
// Log the error but don't prevent the connection
fmt.Printf("Warning: Could not record connection history: %v\n", err)
}
}
// Build and execute the SSH command
// Connect to the host
fmt.Printf("Connecting to %s...\n", hostName)
var sshCmd *exec.Cmd
var args []string
// Build the SSH command with the appropriate config file
var sshCmd []string
if configFile != "" {
args = append(args, "-F", configFile)
}
args = append(args, hostName)
// Note: We don't add RemoteCommand here because if it's configured in SSH config,
// SSH will handle it automatically. Adding it as a command line argument would conflict.
sshCmd = exec.Command("ssh", args...)
// Set up the command to use the same stdin, stdout, and stderr as the parent process
sshCmd.Stdin = os.Stdin
sshCmd.Stdout = os.Stdout
sshCmd.Stderr = os.Stderr
// Execute the SSH command
err = sshCmd.Run()
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
// SSH command failed, exit with the same code
if status, ok := exitError.Sys().(syscall.WaitStatus); ok {
os.Exit(status.ExitStatus())
}
}
fmt.Printf("Error executing SSH command: %v\n", err)
os.Exit(1)
}
}
// getVersionWithUpdateCheck returns a custom version string with update check
func getVersionWithUpdateCheck() string {
versionText := fmt.Sprintf("sshm version %s", AppVersion)
// Check for updates
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
updateInfo, err := version.CheckForUpdates(ctx, AppVersion)
if err != nil {
// Return just version if check fails
return versionText + "\n"
sshCmd = []string{"ssh", "-F", configFile, hostName}
} else {
sshCmd = []string{"ssh", hostName}
}
if updateInfo != nil && updateInfo.Available {
versionText += fmt.Sprintf("\n🚀 Update available: %s → %s (%s)",
updateInfo.CurrentVer,
updateInfo.LatestVer,
updateInfo.ReleaseURL)
}
return versionText + "\n"
// Note: In a real implementation, you'd use exec.Command here
// For now, just print the command that would be executed
fmt.Printf("%s\n", strings.Join(sshCmd, " "))
}
// Execute adds all child commands to the root command and sets flags appropriately.
func Execute() {
// Custom error handling for unknown commands that might be host names
if err := RootCmd.Execute(); err != nil {
// Check if this is an "unknown command" error and the argument might be a host name
errStr := err.Error()
if strings.Contains(errStr, "unknown command") {
// Extract the command name from the error
parts := strings.Split(errStr, "\"")
if len(parts) >= 2 {
potentialHost := parts[1]
// Try to connect to this as a host
connectToHost(potentialHost)
return
}
}
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
@ -218,8 +146,5 @@ func Execute() {
func init() {
// Add the config file flag
RootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)")
// Set custom version template with update check
RootCmd.SetVersionTemplate(getVersionWithUpdateCheck())
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)")
}

View File

@ -1,145 +0,0 @@
package cmd
import (
"bytes"
"strings"
"testing"
)
func TestRootCommand(t *testing.T) {
// Test that the root command is properly configured
if RootCmd.Use != "sshm [host]" {
t.Errorf("Expected Use 'sshm [host]', got '%s'", RootCmd.Use)
}
if RootCmd.Short != "SSH Manager - A modern SSH connection manager" {
t.Errorf("Expected Short description, got '%s'", RootCmd.Short)
}
if RootCmd.Version != AppVersion {
t.Errorf("Expected Version '%s', got '%s'", AppVersion, RootCmd.Version)
}
}
func TestRootCommandFlags(t *testing.T) {
// Test that persistent flags are properly configured
flags := RootCmd.PersistentFlags()
// Check config flag
configFlag := flags.Lookup("config")
if configFlag == nil {
t.Error("Expected --config flag to be defined")
return
}
if configFlag.Shorthand != "c" {
t.Errorf("Expected config flag shorthand 'c', got '%s'", configFlag.Shorthand)
}
}
func TestRootCommandSubcommands(t *testing.T) {
// Test that all expected subcommands are registered
// Note: completion and help are automatically added by Cobra and may not always appear in Commands()
expectedCommands := []string{"add", "edit", "search"}
commands := RootCmd.Commands()
commandNames := make(map[string]bool)
for _, cmd := range commands {
commandNames[cmd.Name()] = true
}
for _, expected := range expectedCommands {
if !commandNames[expected] {
t.Errorf("Expected command '%s' not found", expected)
}
}
// Check that we have at least the core commands
if len(commandNames) < 3 {
t.Errorf("Expected at least 3 commands, got %d", len(commandNames))
}
}
func TestRootCommandHelp(t *testing.T) {
// Test help output
buf := new(bytes.Buffer)
RootCmd.SetOut(buf)
RootCmd.SetArgs([]string{"--help"})
// This should not return an error for help
err := RootCmd.Execute()
if err != nil {
t.Errorf("Expected no error for help command, got %v", err)
}
output := buf.String()
if !strings.Contains(output, "modern SSH manager") {
t.Error("Help output should contain command description")
}
if !strings.Contains(output, "Usage:") {
t.Error("Help output should contain usage section")
}
}
func TestRootCommandVersion(t *testing.T) {
// Test that version command executes without error
// Note: Cobra handles version output internally, so we just check for no error
RootCmd.SetArgs([]string{"--version"})
// This should not return an error for version
err := RootCmd.Execute()
if err != nil {
t.Errorf("Expected no error for version command, got %v", err)
}
// Reset args for other tests
RootCmd.SetArgs([]string{})
}
func TestExecuteFunction(t *testing.T) {
// Test that Execute function exists and can be called
// We can't easily test the actual execution without mocking,
// but we can test that the function exists
t.Log("Execute function exists and is accessible")
}
func TestConnectToHostFunction(t *testing.T) {
// Test that connectToHost function exists and can be called
// Note: We can't easily test the actual connection without a valid SSH config
// and without actually connecting to a host, but we can verify the function exists
t.Log("connectToHost function exists and is accessible")
// The function will handle errors internally (like host not found)
// We don't want to actually test the SSH connection in unit tests
}
func TestRunInteractiveModeFunction(t *testing.T) {
// Test that runInteractiveMode function exists
// We can't easily test the actual execution without mocking the UI,
// but we can verify the function signature
t.Log("runInteractiveMode function exists and is accessible")
}
func TestConfigFileVariable(t *testing.T) {
// Test that configFile variable is properly initialized
originalConfigFile := configFile
defer func() { configFile = originalConfigFile }()
// Set config file through flag
RootCmd.SetArgs([]string{"--config", "/tmp/test-config"})
RootCmd.ParseFlags([]string{"--config", "/tmp/test-config"})
// The configFile variable should be updated by the flag parsing
// Note: This test verifies the flag binding works
}
func TestVersionVariable(t *testing.T) {
// Test that version variable has a default value
if AppVersion == "" {
t.Error("AppVersion variable should have a default value")
}
// Test that version is set to "dev" by default
if AppVersion != "dev" {
t.Logf("AppVersion is set to '%s' (expected 'dev' for development)", AppVersion)
}
}

View File

@ -5,7 +5,7 @@ import (
"os"
"strings"
"github.com/Gu1llaum-3/sshm/internal/config"
"sshm/internal/config"
"github.com/spf13/cobra"
)
@ -235,7 +235,7 @@ func escapeJSON(s string) string {
func init() {
// Add search command to root
RootCmd.AddCommand(searchCmd)
rootCmd.AddCommand(searchCmd)
// Add flags
searchCmd.Flags().StringVarP(&outputFormat, "format", "f", "table", "Output format (table, json, simple)")

View File

@ -1,120 +0,0 @@
package cmd
import (
"strings"
"testing"
)
func TestSearchCommand(t *testing.T) {
// Test that the search command is properly configured
if searchCmd.Use != "search [query]" {
t.Errorf("Expected Use 'search [query]', got '%s'", searchCmd.Use)
}
if searchCmd.Short != "Search SSH hosts by name, hostname, or tags" {
t.Errorf("Expected Short description, got '%s'", searchCmd.Short)
}
// Test that it accepts maximum 1 argument
err := searchCmd.Args(searchCmd, []string{"query1", "query2"})
if err == nil {
t.Error("Expected error for too many arguments")
}
// Test that it accepts 0 or 1 argument
err = searchCmd.Args(searchCmd, []string{})
if err != nil {
t.Errorf("Expected no error for 0 arguments, got %v", err)
}
err = searchCmd.Args(searchCmd, []string{"query"})
if err != nil {
t.Errorf("Expected no error for 1 argument, got %v", err)
}
}
func TestSearchCommandRegistration(t *testing.T) {
// Check that search command is registered with root command
found := false
for _, cmd := range RootCmd.Commands() {
if cmd.Name() == "search" {
found = true
break
}
}
if !found {
t.Error("Search command not found in root command")
}
}
func TestSearchCommandFlags(t *testing.T) {
// Test that flags are properly configured
flags := searchCmd.Flags()
// Check format flag
formatFlag := flags.Lookup("format")
if formatFlag == nil {
t.Error("Expected --format flag to be defined")
}
// Check tags flag
tagsFlag := flags.Lookup("tags")
if tagsFlag == nil {
t.Error("Expected --tags flag to be defined")
}
// Check names flag
namesFlag := flags.Lookup("names")
if namesFlag == nil {
t.Error("Expected --names flag to be defined")
}
}
func TestSearchCommandHelp(t *testing.T) {
// Test that the command has the right help properties
// Instead of executing --help, just check the Long description
if searchCmd.Long == "" {
t.Error("Search command should have a Long description")
}
if !strings.Contains(searchCmd.Long, "Search") {
t.Error("Long description should contain information about searching")
}
}
func TestFormatOutput(t *testing.T) {
tests := []struct {
name string
format string
valid bool
}{
{"table format", "table", true},
{"json format", "json", true},
{"simple format", "simple", true},
{"invalid format", "invalid", false},
{"empty format", "", true}, // Should default to table
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
valid := isValidFormat(tt.format)
if valid != tt.valid {
t.Errorf("isValidFormat(%q) = %v, want %v", tt.format, valid, tt.valid)
}
})
}
}
// Helper function to validate format (this would be in the actual search.go)
func isValidFormat(format string) bool {
if format == "" {
return true // Default to table
}
validFormats := []string{"table", "json", "simple"}
for _, valid := range validFormats {
if format == valid {
return true
}
}
return false
}

9
go.mod
View File

@ -1,4 +1,4 @@
module github.com/Gu1llaum-3/sshm
module sshm
go 1.23.1
@ -7,7 +7,6 @@ require (
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/lipgloss v1.1.0
github.com/spf13/cobra v1.9.1
golang.org/x/crypto v0.41.0
)
require (
@ -29,7 +28,7 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

16
go.sum
View File

@ -49,19 +49,15 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -7,7 +7,6 @@ USE_SUDO="false"
OS=""
ARCH=""
FORCE_INSTALL="${FORCE_INSTALL:-false}"
SSHM_VERSION="${SSHM_VERSION:-latest}"
RED='\033[0;31m'
PURPLE='\033[0;35m'
@ -15,27 +14,13 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
usage() {
printf "${PURPLE}SSHM Installation Script${NC}\n\n"
printf "Usage:\n"
printf " Default (latest stable): ${GREEN}bash install.sh${NC}\n"
printf " Specific version: ${GREEN}SSHM_VERSION=v1.8.0 bash install.sh${NC}\n"
printf " Beta/pre-release: ${GREEN}SSHM_VERSION=v1.8.1-beta bash install.sh${NC}\n"
printf " Force install: ${GREEN}FORCE_INSTALL=true bash install.sh${NC}\n"
printf " Custom install directory: ${GREEN}INSTALL_DIR=/opt/bin bash install.sh${NC}\n\n"
printf "Environment variables:\n"
printf " SSHM_VERSION - Version to install (default: latest)\n"
printf " FORCE_INSTALL - Skip confirmation prompts (default: false)\n"
printf " INSTALL_DIR - Installation directory (default: /usr/local/bin)\n\n"
}
setSystem() {
ARCH=$(uname -m)
case $ARCH in
i386|i686) ARCH="amd64" ;;
x86_64) ARCH="amd64";;
armv6*) ARCH="armv6" ;;
armv7*) ARCH="armv7" ;;
armv6*) ARCH="arm64" ;;
armv7*) ARCH="arm64" ;;
aarch64*) ARCH="arm64" ;;
arm64) ARCH="arm64" ;;
esac
@ -61,48 +46,17 @@ runAsRoot() {
}
getLatestVersion() {
if [ "$SSHM_VERSION" = "latest" ]; then
printf "${YELLOW}Fetching latest stable version...${NC}\n"
LATEST_VERSION=$(curl -s https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$LATEST_VERSION" ]; then
printf "${RED}Failed to fetch latest version${NC}\n"
exit 1
fi
else
printf "${YELLOW}Using specified version: $SSHM_VERSION${NC}\n"
# Validate that the specified version exists
RELEASE_CHECK=$(curl -s "https://api.github.com/repos/Gu1llaum-3/sshm/releases/tags/$SSHM_VERSION" | grep '"tag_name":')
if [ -z "$RELEASE_CHECK" ]; then
printf "${RED}Version $SSHM_VERSION not found. Available versions:${NC}\n"
curl -s https://api.github.com/repos/Gu1llaum-3/sshm/releases | grep '"tag_name":' | head -10 | sed -E 's/.*"([^"]+)".*/ - \1/'
exit 1
fi
LATEST_VERSION="$SSHM_VERSION"
printf "${YELLOW}Fetching latest version...${NC}\n"
LATEST_VERSION=$(curl -s https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$LATEST_VERSION" ]; then
printf "${RED}Failed to fetch latest version${NC}\n"
exit 1
fi
printf "${GREEN}Installing version: $LATEST_VERSION${NC}\n"
printf "${GREEN}Latest version: $LATEST_VERSION${NC}\n"
}
downloadBinary() {
# Map OS names to match GoReleaser format
local GORELEASER_OS="$OS"
case $OS in
"darwin") GORELEASER_OS="Darwin" ;;
"linux") GORELEASER_OS="Linux" ;;
"windows") GORELEASER_OS="Windows" ;;
esac
# Map architecture names to match GoReleaser format
local GORELEASER_ARCH="$ARCH"
case $ARCH in
"amd64") GORELEASER_ARCH="x86_64" ;;
"arm64") GORELEASER_ARCH="arm64" ;;
"386") GORELEASER_ARCH="i386" ;;
"armv6") GORELEASER_ARCH="armv6" ;;
"armv7") GORELEASER_ARCH="armv7" ;;
esac
# GoReleaser format: sshm_Linux_armv7.tar.gz
GITHUB_FILE="sshm_${GORELEASER_OS}_${GORELEASER_ARCH}.tar.gz"
GITHUB_FILE="sshm-${OS}-${ARCH}.tar.gz"
GITHUB_URL="https://github.com/Gu1llaum-3/sshm/releases/download/$LATEST_VERSION/$GITHUB_FILE"
printf "${YELLOW}Downloading $GITHUB_FILE...${NC}\n"
@ -120,10 +74,10 @@ downloadBinary() {
exit 1
fi
# GoReleaser extracts the binary as just "sshm", not with the platform suffix
EXTRACTED_BINARY="./sshm"
if [ ! -f "$EXTRACTED_BINARY" ]; then
printf "${RED}Could not find extracted binary: $EXTRACTED_BINARY${NC}\n"
# 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"
exit 1
fi
@ -134,37 +88,17 @@ 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() {
@ -204,24 +138,18 @@ checkExisting() {
}
main() {
# Check for help argument
if [ "$1" = "-h" ] || [ "$1" = "--help" ] || [ "$1" = "help" ]; then
usage
exit 0
fi
printf "${PURPLE}Installing SSHM - SSH Connection Manager${NC}\n\n"
# Check if already installed
checkExisting
# Set up system detection
setSystem
printf "${GREEN}Detected system: $OS ($ARCH)${NC}\n"
# Get and validate version FIRST (this can fail early)
# Get latest version
getLatestVersion
# Check if already installed (this might prompt user)
checkExisting
# Download and install
downloadBinary
install
@ -233,8 +161,7 @@ main() {
# Show version
printf "${YELLOW}Verifying installation...${NC}\n"
if command -v sshm >/dev/null 2>&1; then
# 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"
sshm --version
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

View File

@ -80,11 +80,7 @@ if ($LocalBinary -ne "") {
}
# Download binary
# Map architecture to match GoReleaser format
$goreleaserArch = if ($arch -eq "amd64") { "x86_64" } else { "i386" }
# GoReleaser format: sshm_Windows_x86_64.zip
$fileName = "sshm_Windows_$goreleaserArch.zip"
$fileName = "sshm-windows-$arch.zip"
$downloadUrl = "https://github.com/Gu1llaum-3/sshm/releases/download/$latestVersion/$fileName"
$tempFile = "$env:TEMP\$fileName"
@ -105,8 +101,7 @@ if ($LocalBinary -ne "") {
Write-Info "Extracting..."
try {
Expand-Archive -Path $tempFile -DestinationPath $env:TEMP -Force
# GoReleaser extracts the binary as just "sshm.exe", not with platform suffix
$extractedBinary = "$env:TEMP\sshm.exe"
$extractedBinary = "$env:TEMP\sshm-windows-$arch.exe"
$targetPath = "$InstallDir\sshm.exe"
Move-Item -Path $extractedBinary -Destination $targetPath -Force

View File

@ -1,146 +0,0 @@
package config
import (
"encoding/json"
"errors"
"os"
"path/filepath"
)
// KeyBindings represents configurable key bindings for the application
type KeyBindings struct {
// Quit keys - keys that will quit the application
QuitKeys []string `json:"quit_keys"`
// DisableEscQuit - if true, ESC key won't quit the application (useful for vim users)
DisableEscQuit bool `json:"disable_esc_quit"`
}
// AppConfig represents the main application configuration
type AppConfig struct {
KeyBindings KeyBindings `json:"key_bindings"`
}
// GetDefaultKeyBindings returns the default key bindings configuration
func GetDefaultKeyBindings() KeyBindings {
return KeyBindings{
QuitKeys: []string{"q", "ctrl+c"}, // Default keeps current behavior minus ESC
DisableEscQuit: false, // Default to false for backward compatibility
}
}
// GetDefaultAppConfig returns the default application configuration
func GetDefaultAppConfig() AppConfig {
return AppConfig{
KeyBindings: GetDefaultKeyBindings(),
}
}
// GetAppConfigPath returns the path to the application config file
func GetAppConfigPath() (string, error) {
configDir, err := GetSSHMConfigDir()
if err != nil {
return "", err
}
return filepath.Join(configDir, "config.json"), nil
}
// LoadAppConfig loads the application configuration from file
// If the file doesn't exist, it returns the default configuration
func LoadAppConfig() (*AppConfig, error) {
configPath, err := GetAppConfigPath()
if err != nil {
return nil, err
}
// If config file doesn't exist, return default config and create the file
if _, err := os.Stat(configPath); os.IsNotExist(err) {
defaultConfig := GetDefaultAppConfig()
// Create config directory if it doesn't exist
configDir := filepath.Dir(configPath)
if err := os.MkdirAll(configDir, 0755); err != nil {
return nil, err
}
// Save default config to file
if err := SaveAppConfig(&defaultConfig); err != nil {
// If we can't save, just return the default config without erroring
// This allows the app to work even if config file can't be created
return &defaultConfig, nil
}
return &defaultConfig, nil
}
// Read existing config file
data, err := os.ReadFile(configPath)
if err != nil {
return nil, err
}
var config AppConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, err
}
// Validate and fill in missing fields with defaults
config = mergeWithDefaults(config)
return &config, nil
}
// SaveAppConfig saves the application configuration to file
func SaveAppConfig(config *AppConfig) error {
if config == nil {
return errors.New("config cannot be nil")
}
configPath, err := GetAppConfigPath()
if err != nil {
return err
}
// Create config directory if it doesn't exist
configDir := filepath.Dir(configPath)
if err := os.MkdirAll(configDir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
return os.WriteFile(configPath, data, 0644)
}
// mergeWithDefaults ensures all required fields are set with defaults if missing
func mergeWithDefaults(config AppConfig) AppConfig {
defaults := GetDefaultAppConfig()
// If QuitKeys is empty, use defaults
if len(config.KeyBindings.QuitKeys) == 0 {
config.KeyBindings.QuitKeys = defaults.KeyBindings.QuitKeys
}
return config
}
// ShouldQuitOnKey checks if the given key should trigger quit based on configuration
func (kb *KeyBindings) ShouldQuitOnKey(key string) bool {
// Special handling for ESC key
if key == "esc" {
return !kb.DisableEscQuit
}
// Check if key is in the quit keys list
for _, quitKey := range kb.QuitKeys {
if quitKey == key {
return true
}
}
return false
}

View File

@ -1,181 +0,0 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestDefaultKeyBindings(t *testing.T) {
kb := GetDefaultKeyBindings()
// Test default configuration
if kb.DisableEscQuit {
t.Error("Default configuration should allow ESC to quit (backward compatibility)")
}
// Test default quit keys
expectedQuitKeys := []string{"q", "ctrl+c"}
if len(kb.QuitKeys) != len(expectedQuitKeys) {
t.Errorf("Expected %d quit keys, got %d", len(expectedQuitKeys), len(kb.QuitKeys))
}
for i, expected := range expectedQuitKeys {
if i >= len(kb.QuitKeys) || kb.QuitKeys[i] != expected {
t.Errorf("Expected quit key %s, got %s", expected, kb.QuitKeys[i])
}
}
}
func TestShouldQuitOnKey(t *testing.T) {
tests := []struct {
name string
keyBindings KeyBindings
key string
expectedResult bool
}{
{
name: "Default config - ESC should quit",
keyBindings: KeyBindings{
QuitKeys: []string{"q", "ctrl+c"},
DisableEscQuit: false,
},
key: "esc",
expectedResult: true,
},
{
name: "Disabled ESC quit - ESC should not quit",
keyBindings: KeyBindings{
QuitKeys: []string{"q", "ctrl+c"},
DisableEscQuit: true,
},
key: "esc",
expectedResult: false,
},
{
name: "Q key should quit",
keyBindings: KeyBindings{
QuitKeys: []string{"q", "ctrl+c"},
DisableEscQuit: true,
},
key: "q",
expectedResult: true,
},
{
name: "Ctrl+C should quit",
keyBindings: KeyBindings{
QuitKeys: []string{"q", "ctrl+c"},
DisableEscQuit: true,
},
key: "ctrl+c",
expectedResult: true,
},
{
name: "Other keys should not quit",
keyBindings: KeyBindings{
QuitKeys: []string{"q", "ctrl+c"},
DisableEscQuit: true,
},
key: "enter",
expectedResult: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.keyBindings.ShouldQuitOnKey(tt.key)
if result != tt.expectedResult {
t.Errorf("ShouldQuitOnKey(%q) = %v, expected %v", tt.key, result, tt.expectedResult)
}
})
}
}
func TestAppConfigBasics(t *testing.T) {
// Test default config creation
defaultConfig := GetDefaultAppConfig()
if defaultConfig.KeyBindings.DisableEscQuit {
t.Error("Default configuration should allow ESC to quit")
}
expectedQuitKeys := []string{"q", "ctrl+c"}
if len(defaultConfig.KeyBindings.QuitKeys) != len(expectedQuitKeys) {
t.Errorf("Expected %d quit keys, got %d", len(expectedQuitKeys), len(defaultConfig.KeyBindings.QuitKeys))
}
}
func TestMergeWithDefaults(t *testing.T) {
// Test config with missing QuitKeys
incompleteConfig := AppConfig{
KeyBindings: KeyBindings{
DisableEscQuit: true,
// QuitKeys is missing
},
}
mergedConfig := mergeWithDefaults(incompleteConfig)
// Should preserve DisableEscQuit
if !mergedConfig.KeyBindings.DisableEscQuit {
t.Error("Should preserve DisableEscQuit as true")
}
// Should fill in default QuitKeys
expectedQuitKeys := []string{"q", "ctrl+c"}
if len(mergedConfig.KeyBindings.QuitKeys) != len(expectedQuitKeys) {
t.Errorf("Expected %d quit keys, got %d", len(expectedQuitKeys), len(mergedConfig.KeyBindings.QuitKeys))
}
}
func TestSaveAndLoadAppConfigIntegration(t *testing.T) {
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "sshm_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
// Create a custom config file directly in temp directory
configPath := filepath.Join(tempDir, "config.json")
customConfig := AppConfig{
KeyBindings: KeyBindings{
QuitKeys: []string{"q"},
DisableEscQuit: true,
},
}
// Save config directly to file
data, err := json.MarshalIndent(customConfig, "", " ")
if err != nil {
t.Fatalf("Failed to marshal config: %v", err)
}
err = os.WriteFile(configPath, data, 0644)
if err != nil {
t.Fatalf("Failed to write config file: %v", err)
}
// Read and unmarshal config
readData, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("Failed to read config file: %v", err)
}
var loadedConfig AppConfig
err = json.Unmarshal(readData, &loadedConfig)
if err != nil {
t.Fatalf("Failed to unmarshal config: %v", err)
}
// Verify the loaded config matches what we saved
if !loadedConfig.KeyBindings.DisableEscQuit {
t.Error("DisableEscQuit should be true")
}
if len(loadedConfig.KeyBindings.QuitKeys) != 1 || loadedConfig.KeyBindings.QuitKeys[0] != "q" {
t.Errorf("Expected quit keys to be ['q'], got %v", loadedConfig.KeyBindings.QuitKeys)
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,212 +0,0 @@
package connectivity
import (
"context"
"net"
"github.com/Gu1llaum-3/sshm/internal/config"
"strings"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
// PingStatus represents the connectivity status of an SSH host
type PingStatus int
const (
StatusUnknown PingStatus = iota
StatusConnecting
StatusOnline
StatusOffline
)
func (s PingStatus) String() string {
switch s {
case StatusUnknown:
return "unknown"
case StatusConnecting:
return "connecting"
case StatusOnline:
return "online"
case StatusOffline:
return "offline"
}
return "unknown"
}
// HostPingResult represents the result of pinging a host
type HostPingResult struct {
HostName string
Status PingStatus
Error error
Duration time.Duration
}
// PingManager manages SSH connectivity checks for multiple hosts
type PingManager struct {
results map[string]*HostPingResult
mutex sync.RWMutex
timeout time.Duration
}
// NewPingManager creates a new ping manager with the specified timeout
func NewPingManager(timeout time.Duration) *PingManager {
return &PingManager{
results: make(map[string]*HostPingResult),
timeout: timeout,
}
}
// GetStatus returns the current status for a host
func (pm *PingManager) GetStatus(hostName string) PingStatus {
pm.mutex.RLock()
defer pm.mutex.RUnlock()
if result, exists := pm.results[hostName]; exists {
return result.Status
}
return StatusUnknown
}
// GetResult returns the complete result for a host
func (pm *PingManager) GetResult(hostName string) (*HostPingResult, bool) {
pm.mutex.RLock()
defer pm.mutex.RUnlock()
result, exists := pm.results[hostName]
return result, exists
}
// updateStatus updates the status for a host
func (pm *PingManager) updateStatus(hostName string, status PingStatus, err error, duration time.Duration) {
pm.mutex.Lock()
defer pm.mutex.Unlock()
pm.results[hostName] = &HostPingResult{
HostName: hostName,
Status: status,
Error: err,
Duration: duration,
}
}
// PingHost performs an SSH connectivity check for a single host
func (pm *PingManager) PingHost(ctx context.Context, host config.SSHHost) *HostPingResult {
start := time.Now()
// Mark as connecting
pm.updateStatus(host.Name, StatusConnecting, nil, 0)
// Determine the actual hostname and port
hostname := host.Hostname
if hostname == "" {
hostname = host.Name
}
port := host.Port
if port == "" {
port = "22"
}
// Create context with timeout
pingCtx, cancel := context.WithTimeout(ctx, pm.timeout)
defer cancel()
// Try to establish a TCP connection first (faster than SSH handshake)
dialer := &net.Dialer{}
conn, err := dialer.DialContext(pingCtx, "tcp", net.JoinHostPort(hostname, port))
if err != nil {
duration := time.Since(start)
pm.updateStatus(host.Name, StatusOffline, err, duration)
return &HostPingResult{
HostName: host.Name,
Status: StatusOffline,
Error: err,
Duration: duration,
}
}
defer conn.Close()
// If TCP connection succeeds, try SSH handshake
sshConfig := &ssh.ClientConfig{
User: host.User,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // For ping purposes only
Timeout: time.Second * 2, // Short timeout for handshake
}
// We don't need to authenticate, just check if SSH is responding
sshConn, _, _, err := ssh.NewClientConn(conn, net.JoinHostPort(hostname, port), sshConfig)
if sshConn != nil {
sshConn.Close()
}
duration := time.Since(start)
// Even if SSH handshake fails, if we got a TCP connection, consider it online
// This handles cases where authentication fails but the host is reachable
status := StatusOnline
if err != nil && isConnectionError(err) {
status = StatusOffline
}
pm.updateStatus(host.Name, status, err, duration)
return &HostPingResult{
HostName: host.Name,
Status: status,
Error: err,
Duration: duration,
}
}
// PingAllHosts pings all hosts concurrently and returns a channel of results
func (pm *PingManager) PingAllHosts(ctx context.Context, hosts []config.SSHHost) <-chan *HostPingResult {
resultChan := make(chan *HostPingResult, len(hosts))
var wg sync.WaitGroup
for _, host := range hosts {
wg.Add(1)
go func(h config.SSHHost) {
defer wg.Done()
result := pm.PingHost(ctx, h)
select {
case resultChan <- result:
case <-ctx.Done():
return
}
}(host)
}
// Close the channel when all goroutines are done
go func() {
wg.Wait()
close(resultChan)
}()
return resultChan
}
// isConnectionError determines if an error is a connection-related error
func isConnectionError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
connectionErrors := []string{
"connection refused",
"no route to host",
"network is unreachable",
"timeout",
"connection timed out",
}
for _, connErr := range connectionErrors {
if strings.Contains(strings.ToLower(errStr), connErr) {
return true
}
}
return false
}

View File

@ -1,144 +0,0 @@
package connectivity
import (
"context"
"testing"
"time"
"github.com/Gu1llaum-3/sshm/internal/config"
)
func TestNewPingManager(t *testing.T) {
pm := NewPingManager(5 * time.Second)
if pm == nil {
t.Error("NewPingManager() returned nil")
}
if pm.results == nil {
t.Error("PingManager.results map not initialized")
}
}
func TestPingManager_PingHost(t *testing.T) {
pm := NewPingManager(1 * time.Second)
ctx := context.Background()
// Test ping method exists and doesn't panic
host := config.SSHHost{Name: "test", Hostname: "127.0.0.1", Port: "22"}
result := pm.PingHost(ctx, host)
if result == nil {
t.Error("Expected ping result to be returned")
}
// Test with invalid host
invalidHost := config.SSHHost{Name: "invalid", Hostname: "invalid.host.12345", Port: "22"}
result = pm.PingHost(ctx, invalidHost)
if result == nil {
t.Error("Expected ping result to be returned even for invalid host")
}
}
func TestPingManager_GetStatus(t *testing.T) {
pm := NewPingManager(1 * time.Second)
// Test unknown host
status := pm.GetStatus("unknown.host")
if status != StatusUnknown {
t.Errorf("Expected StatusUnknown for unknown host, got %v", status)
}
// Test after ping
ctx := context.Background()
host := config.SSHHost{Name: "test", Hostname: "127.0.0.1", Port: "22"}
pm.PingHost(ctx, host)
status = pm.GetStatus("test")
if status == StatusUnknown {
t.Error("Expected status to be set after ping")
}
}
func TestPingManager_PingMultipleHosts(t *testing.T) {
pm := NewPingManager(1 * time.Second)
hosts := []config.SSHHost{
{Name: "localhost", Hostname: "127.0.0.1", Port: "22"},
{Name: "invalid", Hostname: "invalid.host.12345", Port: "22"},
}
ctx := context.Background()
// Ping each host individually
for _, host := range hosts {
result := pm.PingHost(ctx, host)
if result == nil {
t.Errorf("Expected ping result for host %s", host.Name)
}
// Check that status was set
status := pm.GetStatus(host.Name)
if status == StatusUnknown {
t.Errorf("Expected status to be set for host %s after ping", host.Name)
}
}
}
func TestPingManager_GetResult(t *testing.T) {
pm := NewPingManager(1 * time.Second)
ctx := context.Background()
// Test getting result for unknown host
result, exists := pm.GetResult("unknown")
if exists || result != nil {
t.Error("Expected no result for unknown host")
}
// Test after ping
host := config.SSHHost{Name: "test", Hostname: "127.0.0.1", Port: "22"}
pm.PingHost(ctx, host)
result, exists = pm.GetResult("test")
if !exists || result == nil {
t.Error("Expected result to exist after ping")
}
if result.HostName != "test" {
t.Errorf("Expected hostname 'test', got '%s'", result.HostName)
}
}
func TestPingStatus_String(t *testing.T) {
tests := []struct {
status PingStatus
expected string
}{
{StatusUnknown, "unknown"},
{StatusConnecting, "connecting"},
{StatusOnline, "online"},
{StatusOffline, "offline"},
{PingStatus(999), "unknown"}, // Invalid status
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
if got := tt.status.String(); got != tt.expected {
t.Errorf("PingStatus.String() = %v, want %v", got, tt.expected)
}
})
}
}
func TestPingHost_Basic(t *testing.T) {
// Test that the ping functionality exists
pm := NewPingManager(1 * time.Second)
ctx := context.Background()
host := config.SSHHost{Name: "test", Hostname: "127.0.0.1", Port: "22"}
// Just ensure the function doesn't panic
result := pm.PingHost(ctx, host)
if result == nil {
t.Error("Expected ping result to be returned")
}
// Test that status is set
status := pm.GetStatus("test")
if status == StatusUnknown {
t.Error("Expected status to be set after ping attempt")
}
}

View File

@ -7,7 +7,7 @@ import (
"sort"
"time"
"github.com/Gu1llaum-3/sshm/internal/config"
"sshm/internal/config"
)
// ConnectionHistory represents the history of SSH connections
@ -15,21 +15,11 @@ type ConnectionHistory struct {
Connections map[string]ConnectionInfo `json:"connections"`
}
// PortForwardConfig stores port forwarding configuration
type PortForwardConfig struct {
Type string `json:"type"` // "local", "remote", "dynamic"
LocalPort string `json:"local_port"`
RemoteHost string `json:"remote_host"`
RemotePort string `json:"remote_port"`
BindAddress string `json:"bind_address"`
}
// ConnectionInfo stores information about a specific connection
type ConnectionInfo struct {
HostName string `json:"host_name"`
LastConnect time.Time `json:"last_connect"`
ConnectCount int `json:"connect_count"`
PortForwarding *PortForwardConfig `json:"port_forwarding,omitempty"`
HostName string `json:"host_name"`
LastConnect time.Time `json:"last_connect"`
ConnectCount int `json:"connect_count"`
}
// HistoryManager manages the connection history
@ -40,23 +30,12 @@ type HistoryManager struct {
// NewHistoryManager creates a new history manager
func NewHistoryManager() (*HistoryManager, error) {
configDir, err := config.GetSSHMConfigDir()
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
// Ensure config dir exists
if err := os.MkdirAll(configDir, 0755); err != nil {
return nil, err
}
historyPath := filepath.Join(configDir, "sshm_history.json")
// Migration: check if old history file exists and migrate it
if err := migrateOldHistoryFile(historyPath); err != nil {
// Don't fail if migration fails, just log it
// In a production environment, you might want to log this properly
}
historyPath := filepath.Join(homeDir, ".ssh", "sshm_history.json")
hm := &HistoryManager{
historyPath: historyPath,
@ -75,46 +54,6 @@ func NewHistoryManager() (*HistoryManager, error) {
return hm, nil
}
// migrateOldHistoryFile migrates the old history file from ~/.ssh to ~/.config/sshm
// TODO: Remove this migration logic in v2.0.0 (introduced in v1.6.0)
func migrateOldHistoryFile(newHistoryPath string) error {
// Check if new file already exists, skip migration
if _, err := os.Stat(newHistoryPath); err == nil {
return nil // New file exists, no migration needed
}
// Get old history file path - use same logic as SSH config location
sshDir, err := config.GetSSHDirectory()
if err != nil {
return err
}
oldHistoryPath := filepath.Join(sshDir, "sshm_history.json")
// Check if old file exists
if _, err := os.Stat(oldHistoryPath); os.IsNotExist(err) {
return nil // Old file doesn't exist, nothing to migrate
}
// Read old file
data, err := os.ReadFile(oldHistoryPath)
if err != nil {
return err
}
// Write to new location
if err := os.WriteFile(newHistoryPath, data, 0644); err != nil {
return err
}
// Remove old file only if write was successful
if err := os.Remove(oldHistoryPath); err != nil {
// Don't fail if we can't remove the old file
// The migration was successful even if cleanup failed
}
return nil
}
// loadHistory loads the connection history from the JSON file
func (hm *HistoryManager) loadHistory() error {
data, err := os.ReadFile(hm.historyPath)
@ -267,42 +206,3 @@ func (hm *HistoryManager) GetAllConnectionsInfo() []ConnectionInfo {
return connections
}
// RecordPortForwarding saves port forwarding configuration for a host
func (hm *HistoryManager) RecordPortForwarding(hostName, forwardType, localPort, remoteHost, remotePort, bindAddress string) error {
now := time.Now()
portForwardConfig := &PortForwardConfig{
Type: forwardType,
LocalPort: localPort,
RemoteHost: remoteHost,
RemotePort: remotePort,
BindAddress: bindAddress,
}
if conn, exists := hm.history.Connections[hostName]; exists {
// Update existing connection
conn.LastConnect = now
conn.ConnectCount++
conn.PortForwarding = portForwardConfig
hm.history.Connections[hostName] = conn
} else {
// Create new connection record
hm.history.Connections[hostName] = ConnectionInfo{
HostName: hostName,
LastConnect: now,
ConnectCount: 1,
PortForwarding: portForwardConfig,
}
}
return hm.saveHistory()
}
// GetPortForwardingConfig retrieves the last used port forwarding configuration for a host
func (hm *HistoryManager) GetPortForwardingConfig(hostName string) *PortForwardConfig {
if conn, exists := hm.history.Connections[hostName]; exists {
return conn.PortForwarding
}
return nil
}

View File

@ -1,164 +0,0 @@
package history
import (
"os"
"path/filepath"
"testing"
"time"
)
// createTestHistoryManager creates a history manager with a temporary file for testing
func createTestHistoryManager(t *testing.T) *HistoryManager {
// Create temporary directory
tempDir := t.TempDir()
historyPath := filepath.Join(tempDir, "test_sshm_history.json")
hm := &HistoryManager{
historyPath: historyPath,
history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)},
}
return hm
}
func TestNewHistoryManager(t *testing.T) {
hm, err := NewHistoryManager()
if err != nil {
t.Fatalf("NewHistoryManager() error = %v", err)
}
if hm == nil {
t.Fatal("NewHistoryManager() returned nil")
}
if hm.historyPath == "" {
t.Error("Expected historyPath to be set")
}
}
func TestHistoryManager_RecordConnection(t *testing.T) {
hm := createTestHistoryManager(t)
// Add a connection
err := hm.RecordConnection("testhost")
if err != nil {
t.Errorf("RecordConnection() error = %v", err)
}
// Check that the connection was added
lastUsed, exists := hm.GetLastConnectionTime("testhost")
if !exists || lastUsed.IsZero() {
t.Error("Expected connection to be recorded")
}
}
func TestHistoryManager_GetLastConnectionTime(t *testing.T) {
hm := createTestHistoryManager(t)
// Test with no connections
lastUsed, exists := hm.GetLastConnectionTime("nonexistent-testhost")
if exists || !lastUsed.IsZero() {
t.Error("Expected no connection for non-existent host")
}
// Add a connection
err := hm.RecordConnection("testhost")
if err != nil {
t.Errorf("RecordConnection() error = %v", err)
}
// Test with existing connection
lastUsed, exists = hm.GetLastConnectionTime("testhost")
if !exists || lastUsed.IsZero() {
t.Error("Expected non-zero time for existing host")
}
// Check that the time is recent (within last minute)
if time.Since(lastUsed) > time.Minute {
t.Error("Last used time seems too old")
}
}
func TestHistoryManager_GetConnectionCount(t *testing.T) {
hm := createTestHistoryManager(t)
// Add same host multiple times
for i := 0; i < 3; i++ {
err := hm.RecordConnection("testhost-count")
if err != nil {
t.Errorf("RecordConnection() error = %v", err)
}
time.Sleep(1 * time.Millisecond)
}
// Should have correct count
count := hm.GetConnectionCount("testhost-count")
if count != 3 {
t.Errorf("Expected connection count 3, got %d", count)
}
}
func TestMigrateOldHistoryFile(t *testing.T) {
// This test verifies that migration doesn't fail when called
// The actual migration logic will be tested in integration tests
tempDir := t.TempDir()
newHistoryPath := filepath.Join(tempDir, "sshm_history.json")
// Test that migration works when no old file exists (common case)
if err := migrateOldHistoryFile(newHistoryPath); err != nil {
t.Errorf("migrateOldHistoryFile() with no old file error = %v", err)
}
// Test that migration skips when new file already exists
if err := os.WriteFile(newHistoryPath, []byte(`{"connections":{}}`), 0644); err != nil {
t.Fatalf("Failed to write new history file: %v", err)
}
if err := migrateOldHistoryFile(newHistoryPath); err != nil {
t.Errorf("migrateOldHistoryFile() with existing new file error = %v", err)
}
// File should be unchanged
data, err := os.ReadFile(newHistoryPath)
if err != nil {
t.Errorf("Failed to read new file: %v", err)
}
if string(data) != `{"connections":{}}` {
t.Error("New file was modified when it shouldn't have been")
}
}
func TestMigrateOldHistoryFile_NoOldFile(t *testing.T) {
// Test migration when no old file exists
tempDir := t.TempDir()
newHistoryPath := filepath.Join(tempDir, "sshm_history.json")
// Should not return error when old file doesn't exist
if err := migrateOldHistoryFile(newHistoryPath); err != nil {
t.Errorf("migrateOldHistoryFile() with no old file error = %v", err)
}
}
func TestMigrateOldHistoryFile_NewFileExists(t *testing.T) {
// Test migration when new file already exists (should skip migration)
tempDir := t.TempDir()
newHistoryPath := filepath.Join(tempDir, "sshm_history.json")
// Create new file first
if err := os.WriteFile(newHistoryPath, []byte(`{"connections":{}}`), 0644); err != nil {
t.Fatalf("Failed to write new history file: %v", err)
}
// Migration should skip when new file exists
if err := migrateOldHistoryFile(newHistoryPath); err != nil {
t.Errorf("migrateOldHistoryFile() with existing new file error = %v", err)
}
// New file should be unchanged
data, err := os.ReadFile(newHistoryPath)
if err != nil {
t.Errorf("Failed to read new file: %v", err)
}
if string(data) != `{"connections":{}}` {
t.Error("New file was modified when it shouldn't have been")
}
}

View File

@ -1,183 +0,0 @@
package history
import (
"os"
"path/filepath"
"testing"
)
func TestPortForwardingHistory(t *testing.T) {
// Create temporary directory for testing
tempDir, err := os.MkdirTemp("", "sshm_test_*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create history manager with temp directory
historyPath := filepath.Join(tempDir, "test_history.json")
hm := &HistoryManager{
historyPath: historyPath,
history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)},
}
hostName := "test-server"
// Test recording port forwarding configuration
err = hm.RecordPortForwarding(hostName, "local", "8080", "localhost", "80", "127.0.0.1")
if err != nil {
t.Fatalf("Failed to record port forwarding: %v", err)
}
// Test retrieving port forwarding configuration
config := hm.GetPortForwardingConfig(hostName)
if config == nil {
t.Fatalf("Expected port forwarding config to exist")
}
// Verify the saved configuration
if config.Type != "local" {
t.Errorf("Expected Type 'local', got %s", config.Type)
}
if config.LocalPort != "8080" {
t.Errorf("Expected LocalPort '8080', got %s", config.LocalPort)
}
if config.RemoteHost != "localhost" {
t.Errorf("Expected RemoteHost 'localhost', got %s", config.RemoteHost)
}
if config.RemotePort != "80" {
t.Errorf("Expected RemotePort '80', got %s", config.RemotePort)
}
if config.BindAddress != "127.0.0.1" {
t.Errorf("Expected BindAddress '127.0.0.1', got %s", config.BindAddress)
}
// Test updating configuration with different values
err = hm.RecordPortForwarding(hostName, "remote", "3000", "app-server", "8000", "")
if err != nil {
t.Fatalf("Failed to record updated port forwarding: %v", err)
}
// Verify the updated configuration
config = hm.GetPortForwardingConfig(hostName)
if config == nil {
t.Fatalf("Expected port forwarding config to exist after update")
}
if config.Type != "remote" {
t.Errorf("Expected updated Type 'remote', got %s", config.Type)
}
if config.LocalPort != "3000" {
t.Errorf("Expected updated LocalPort '3000', got %s", config.LocalPort)
}
if config.RemoteHost != "app-server" {
t.Errorf("Expected updated RemoteHost 'app-server', got %s", config.RemoteHost)
}
if config.RemotePort != "8000" {
t.Errorf("Expected updated RemotePort '8000', got %s", config.RemotePort)
}
if config.BindAddress != "" {
t.Errorf("Expected updated BindAddress to be empty, got %s", config.BindAddress)
}
// Test dynamic forwarding
err = hm.RecordPortForwarding(hostName, "dynamic", "1080", "", "", "0.0.0.0")
if err != nil {
t.Fatalf("Failed to record dynamic port forwarding: %v", err)
}
config = hm.GetPortForwardingConfig(hostName)
if config == nil {
t.Fatalf("Expected port forwarding config to exist for dynamic forwarding")
}
if config.Type != "dynamic" {
t.Errorf("Expected Type 'dynamic', got %s", config.Type)
}
if config.LocalPort != "1080" {
t.Errorf("Expected LocalPort '1080', got %s", config.LocalPort)
}
if config.RemoteHost != "" {
t.Errorf("Expected RemoteHost to be empty for dynamic forwarding, got %s", config.RemoteHost)
}
if config.RemotePort != "" {
t.Errorf("Expected RemotePort to be empty for dynamic forwarding, got %s", config.RemotePort)
}
if config.BindAddress != "0.0.0.0" {
t.Errorf("Expected BindAddress '0.0.0.0', got %s", config.BindAddress)
}
}
func TestPortForwardingHistoryPersistence(t *testing.T) {
// Create temporary directory for testing
tempDir, err := os.MkdirTemp("", "sshm_test_*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
historyPath := filepath.Join(tempDir, "test_history.json")
// Create first history manager and record data
hm1 := &HistoryManager{
historyPath: historyPath,
history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)},
}
hostName := "persistent-server"
err = hm1.RecordPortForwarding(hostName, "local", "9090", "db-server", "5432", "")
if err != nil {
t.Fatalf("Failed to record port forwarding: %v", err)
}
// Create second history manager and load data
hm2 := &HistoryManager{
historyPath: historyPath,
history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)},
}
err = hm2.loadHistory()
if err != nil {
t.Fatalf("Failed to load history: %v", err)
}
// Verify the loaded configuration
config := hm2.GetPortForwardingConfig(hostName)
if config == nil {
t.Fatalf("Expected port forwarding config to be loaded from file")
}
if config.Type != "local" {
t.Errorf("Expected loaded Type 'local', got %s", config.Type)
}
if config.LocalPort != "9090" {
t.Errorf("Expected loaded LocalPort '9090', got %s", config.LocalPort)
}
if config.RemoteHost != "db-server" {
t.Errorf("Expected loaded RemoteHost 'db-server', got %s", config.RemoteHost)
}
if config.RemotePort != "5432" {
t.Errorf("Expected loaded RemotePort '5432', got %s", config.RemotePort)
}
}
func TestGetPortForwardingConfigNonExistent(t *testing.T) {
// Create temporary directory for testing
tempDir, err := os.MkdirTemp("", "sshm_test_*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
historyPath := filepath.Join(tempDir, "test_history.json")
hm := &HistoryManager{
historyPath: historyPath,
history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)},
}
// Test getting configuration for non-existent host
config := hm.GetPortForwardingConfig("non-existent-host")
if config != nil {
t.Errorf("Expected nil config for non-existent host, got %+v", config)
}
}

View File

@ -1,15 +1,13 @@
package ui
import (
"fmt"
"os"
"os/user"
"path/filepath"
"sshm/internal/config"
"sshm/internal/validation"
"strings"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/validation"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
@ -17,7 +15,6 @@ import (
type addFormModel struct {
inputs []textinput.Model
focused int
currentTab int // 0 = General, 1 = Advanced
err string
styles Styles
success bool
@ -49,7 +46,7 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
}
}
inputs := make([]textinput.Model, 10) // Increased from 9 to 10 for RequestTTY
inputs := make([]textinput.Model, 8)
// Name input
inputs[nameInput] = textinput.New()
@ -103,22 +100,9 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
inputs[tagsInput].CharLimit = 200
inputs[tagsInput].Width = 50
// Remote Command input
inputs[remoteCommandInput] = textinput.New()
inputs[remoteCommandInput].Placeholder = "ls -la, htop, bash"
inputs[remoteCommandInput].CharLimit = 300
inputs[remoteCommandInput].Width = 70
// RequestTTY input
inputs[requestTTYInput] = textinput.New()
inputs[requestTTYInput].Placeholder = "yes, no, force, auto"
inputs[requestTTYInput].CharLimit = 10
inputs[requestTTYInput].Width = 30
return &addFormModel{
inputs: inputs,
focused: nameInput,
currentTab: tabGeneral, // Start on General tab
styles: styles,
width: width,
height: height,
@ -126,11 +110,6 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
}
}
const (
tabGeneral = iota
tabAdvanced
)
const (
nameInput = iota
hostnameInput
@ -138,11 +117,8 @@ const (
portInput
identityInput
proxyJumpInput
tagsInput
// Advanced tab inputs
optionsInput
remoteCommandInput
requestTTYInput
tagsInput
)
// Messages for communication with parent model
@ -172,24 +148,40 @@ func (m *addFormModel) Update(msg tea.Msg) (*addFormModel, tea.Cmd) {
case "ctrl+c", "esc":
return m, func() tea.Msg { return addFormCancelMsg{} }
case "ctrl+s":
// Allow submission from any field with Ctrl+S (Save)
case "ctrl+enter":
// Allow submission from any field with Ctrl+Enter
return m, m.submitForm()
case "ctrl+j":
// Switch to next tab
m.currentTab = (m.currentTab + 1) % 2
m.focused = m.getFirstInputForTab(m.currentTab)
return m, m.updateFocus()
case "ctrl+k":
// Switch to previous tab
m.currentTab = (m.currentTab - 1 + 2) % 2
m.focused = m.getFirstInputForTab(m.currentTab)
return m, m.updateFocus()
case "tab", "shift+tab", "enter", "up", "down":
return m, m.handleNavigation(msg.String())
s := msg.String()
// Handle form submission
if s == "enter" && m.focused == len(m.inputs)-1 {
return m, m.submitForm()
}
// Cycle inputs
if s == "up" || s == "shift+tab" {
m.focused--
} else {
m.focused++
}
if m.focused > len(m.inputs)-1 {
m.focused = 0
} else if m.focused < 0 {
m.focused = len(m.inputs) - 1
}
for i := range m.inputs {
if i == m.focused {
cmds = append(cmds, m.inputs[i].Focus())
continue
}
m.inputs[i].Blur()
}
return m, tea.Batch(cmds...)
}
case addFormSubmitMsg:
@ -213,104 +205,32 @@ func (m *addFormModel) Update(msg tea.Msg) (*addFormModel, tea.Cmd) {
return m, tea.Batch(cmds...)
}
// getFirstInputForTab returns the first input index for a given tab
func (m *addFormModel) getFirstInputForTab(tab int) int {
switch tab {
case tabGeneral:
return nameInput
case tabAdvanced:
return optionsInput
default:
return nameInput
}
}
// getInputsForCurrentTab returns the input indices for the current tab
func (m *addFormModel) getInputsForCurrentTab() []int {
switch m.currentTab {
case tabGeneral:
return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, tagsInput}
case tabAdvanced:
return []int{optionsInput, remoteCommandInput, requestTTYInput}
default:
return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, tagsInput}
}
}
// updateFocus updates focus for inputs
func (m *addFormModel) updateFocus() tea.Cmd {
var cmds []tea.Cmd
for i := range m.inputs {
if i == m.focused {
cmds = append(cmds, m.inputs[i].Focus())
} else {
m.inputs[i].Blur()
}
}
return tea.Batch(cmds...)
}
// handleNavigation handles tab/arrow navigation within the current tab
func (m *addFormModel) handleNavigation(key string) tea.Cmd {
currentTabInputs := m.getInputsForCurrentTab()
// Find current position within the tab
currentPos := 0
for i, input := range currentTabInputs {
if input == m.focused {
currentPos = i
break
}
}
// Handle form submission on last field of Advanced tab
if key == "enter" && m.currentTab == tabAdvanced && currentPos == len(currentTabInputs)-1 {
return m.submitForm()
}
// Navigate within current tab
if key == "up" || key == "shift+tab" {
currentPos--
} else {
currentPos++
}
// Wrap around within current tab
if currentPos >= len(currentTabInputs) {
currentPos = 0
} else if currentPos < 0 {
currentPos = len(currentTabInputs) - 1
}
m.focused = currentTabInputs[currentPos]
return m.updateFocus()
}
func (m *addFormModel) View() string {
if m.success {
return ""
}
// Check if terminal height is sufficient
if !m.isHeightSufficient() {
return m.renderHeightWarning()
}
var b strings.Builder
b.WriteString(m.styles.FormTitle.Render("Add SSH Host Configuration"))
b.WriteString("\n\n")
// Render tabs
b.WriteString(m.renderTabs())
b.WriteString("\n\n")
fields := []string{
"Host Name *",
"Hostname/IP *",
"User",
"Port",
"Identity File",
"ProxyJump",
"SSH Options",
"Tags (comma-separated)",
}
// Render current tab content
switch m.currentTab {
case tabGeneral:
b.WriteString(m.renderGeneralTab())
case tabAdvanced:
b.WriteString(m.renderAdvancedTab())
for i, field := range fields {
b.WriteString(m.styles.FormField.Render(field))
b.WriteString("\n")
b.WriteString(m.inputs[i].View())
b.WriteString("\n\n")
}
if m.err != "" {
@ -318,133 +238,13 @@ func (m *addFormModel) View() string {
b.WriteString("\n\n")
}
// Help text
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Ctrl+J/K: switch tabs"))
b.WriteString("\n")
b.WriteString(m.styles.FormHelp.Render("Enter on last field: submit • Ctrl+S: save • Ctrl+C/Esc: cancel"))
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
b.WriteString("\n")
b.WriteString(m.styles.FormHelp.Render("* Required fields"))
return b.String()
}
// getMinimumHeight calculates the minimum height needed to display the form
func (m *addFormModel) getMinimumHeight() int {
// Title: 1 line + 2 newlines = 3
titleLines := 3
// Tabs: 1 line + 2 newlines = 3
tabLines := 3
// Fields in current tab
var fieldsCount int
if m.currentTab == tabGeneral {
fieldsCount = 7 // 7 fields in general tab
} else {
fieldsCount = 3 // 3 fields in advanced tab
}
// Each field: label (1) + input (1) + spacing (2) = 4 lines per field, but let's be more conservative
fieldsLines := fieldsCount * 3 // Reduced from 4 to 3
// Help text: 3 lines
helpLines := 3
// Error message space when needed: 2 lines
errorLines := 0 // Only count when there's actually an error
if m.err != "" {
errorLines = 2
}
return titleLines + tabLines + fieldsLines + helpLines + errorLines + 1 // +1 minimal safety margin
}
// isHeightSufficient checks if the current terminal height is sufficient
func (m *addFormModel) isHeightSufficient() bool {
return m.height >= m.getMinimumHeight()
}
// renderHeightWarning renders a warning message when height is insufficient
func (m *addFormModel) renderHeightWarning() string {
required := m.getMinimumHeight()
current := m.height
warning := m.styles.ErrorText.Render("⚠️ Terminal height is too small!")
details := m.styles.FormField.Render(fmt.Sprintf("Current: %d lines, Required: %d lines", current, required))
instruction := m.styles.FormHelp.Render("Please resize your terminal window and try again.")
instruction2 := m.styles.FormHelp.Render("Press Ctrl+C to cancel or resize terminal window.")
return warning + "\n\n" + details + "\n\n" + instruction + "\n" + instruction2
}
// renderTabs renders the tab headers
func (m *addFormModel) renderTabs() string {
var generalTab, advancedTab string
if m.currentTab == tabGeneral {
generalTab = m.styles.FocusedLabel.Render("[ General ]")
advancedTab = m.styles.FormField.Render(" Advanced ")
} else {
generalTab = m.styles.FormField.Render(" General ")
advancedTab = m.styles.FocusedLabel.Render("[ Advanced ]")
}
return generalTab + " " + advancedTab
}
// renderGeneralTab renders the general tab content
func (m *addFormModel) renderGeneralTab() string {
var b strings.Builder
fields := []struct {
index int
label string
}{
{nameInput, "Host Name *"},
{hostnameInput, "Hostname/IP *"},
{userInput, "User"},
{portInput, "Port"},
{identityInput, "Identity File"},
{proxyJumpInput, "ProxyJump"},
{tagsInput, "Tags (comma-separated)"},
}
for _, field := range fields {
fieldStyle := m.styles.FormField
if m.focused == field.index {
fieldStyle = m.styles.FocusedLabel
}
b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n")
b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n")
}
return b.String()
}
// renderAdvancedTab renders the advanced tab content
func (m *addFormModel) renderAdvancedTab() string {
var b strings.Builder
fields := []struct {
index int
label string
}{
{optionsInput, "SSH Options"},
{remoteCommandInput, "Remote Command"},
{requestTTYInput, "Request TTY"},
}
for _, field := range fields {
fieldStyle := m.styles.FormField
if m.focused == field.index {
fieldStyle = m.styles.FocusedLabel
}
b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n")
b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n")
}
return b.String()
}
// Standalone wrapper for add form
type standaloneAddForm struct {
*addFormModel
@ -490,8 +290,6 @@ func (m *addFormModel) submitForm() tea.Cmd {
identity := strings.TrimSpace(m.inputs[identityInput].Value())
proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value())
options := strings.TrimSpace(m.inputs[optionsInput].Value())
remoteCommand := strings.TrimSpace(m.inputs[remoteCommandInput].Value())
requestTTY := strings.TrimSpace(m.inputs[requestTTYInput].Value())
// Set defaults
if user == "" {
@ -520,16 +318,14 @@ func (m *addFormModel) submitForm() tea.Cmd {
// Create host configuration
host := config.SSHHost{
Name: name,
Hostname: hostname,
User: user,
Port: port,
Identity: identity,
ProxyJump: proxyJump,
Options: config.ParseSSHOptionsFromCommand(options),
RemoteCommand: remoteCommand,
RequestTTY: requestTTY,
Tags: tags,
Name: name,
Hostname: hostname,
User: user,
Port: port,
Identity: identity,
ProxyJump: proxyJump,
Options: config.ParseSSHOptionsFromCommand(options),
Tags: tags,
}
// Add to config

View File

@ -1,46 +1,27 @@
package ui
import (
"fmt"
"sshm/internal/config"
"sshm/internal/validation"
"strings"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/validation"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
const (
focusAreaHosts = iota
focusAreaProperties
)
type editFormSubmitMsg struct {
hostname string
err error
}
type editFormCancelMsg struct{}
type editFormModel struct {
hostInputs []textinput.Model // Support for multiple hosts
inputs []textinput.Model
focusArea int // 0=hosts, 1=properties
focused int
currentTab int // 0=General, 1=Advanced (only applies when focusArea == focusAreaProperties)
err string
styles Styles
originalName string
originalHosts []string // Store original host names for multi-host detection
host *config.SSHHost // Store the original host with SourceFile
configFile string // Configuration file path passed by user
actualConfigFile string // Actual config file to use (either configFile or host.SourceFile)
width int
height int
inputs []textinput.Model
focused int
err string
success bool
styles Styles
originalName string
width int
height int
configFile string
}
// NewEditForm creates a new edit form model that supports both single and multi-host editing
// NewEditForm creates a new edit form model
func NewEditForm(hostName string, styles Styles, width, height int, configFile string) (*editFormModel, error) {
// Get the existing host configuration
var host *config.SSHHost
@ -56,570 +37,198 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
return nil, err
}
// Check if this host is part of a multi-host declaration
var actualConfigFile string
var hostNames []string
var isMulti bool
inputs := make([]textinput.Model, 8)
if configFile != "" {
actualConfigFile = configFile
} else {
actualConfigFile = host.SourceFile
}
if actualConfigFile != "" {
isMulti, hostNames, err = config.IsPartOfMultiHostDeclaration(hostName, actualConfigFile)
if err != nil {
// If we can't determine multi-host status, treat as single host
isMulti = false
hostNames = []string{hostName}
}
}
if !isMulti {
hostNames = []string{hostName}
}
// Create host inputs
hostInputs := make([]textinput.Model, len(hostNames))
for i, name := range hostNames {
hostInputs[i] = textinput.New()
hostInputs[i].Placeholder = "host-name"
hostInputs[i].SetValue(name)
if i == 0 {
hostInputs[i].Focus()
}
}
inputs := make([]textinput.Model, 9) // Increased from 8 to 9 for RequestTTY
// Name input
inputs[nameInput] = textinput.New()
inputs[nameInput].Placeholder = "server-name"
inputs[nameInput].Focus()
inputs[nameInput].CharLimit = 50
inputs[nameInput].Width = 30
inputs[nameInput].SetValue(host.Name)
// Hostname input
inputs[0] = textinput.New()
inputs[0].Placeholder = "192.168.1.100 or example.com"
inputs[0].CharLimit = 100
inputs[0].Width = 30
inputs[0].SetValue(host.Hostname)
inputs[hostnameInput] = textinput.New()
inputs[hostnameInput].Placeholder = "192.168.1.100 or example.com"
inputs[hostnameInput].CharLimit = 100
inputs[hostnameInput].Width = 30
inputs[hostnameInput].SetValue(host.Hostname)
// User input
inputs[1] = textinput.New()
inputs[1].Placeholder = "root"
inputs[1].CharLimit = 50
inputs[1].Width = 30
inputs[1].SetValue(host.User)
inputs[userInput] = textinput.New()
inputs[userInput].Placeholder = "root"
inputs[userInput].CharLimit = 50
inputs[userInput].Width = 30
inputs[userInput].SetValue(host.User)
// Port input
inputs[2] = textinput.New()
inputs[2].Placeholder = "22"
inputs[2].CharLimit = 5
inputs[2].Width = 30
inputs[2].SetValue(host.Port)
inputs[portInput] = textinput.New()
inputs[portInput].Placeholder = "22"
inputs[portInput].CharLimit = 5
inputs[portInput].Width = 30
inputs[portInput].SetValue(host.Port)
// Identity input
inputs[3] = textinput.New()
inputs[3].Placeholder = "~/.ssh/id_rsa"
inputs[3].CharLimit = 200
inputs[3].Width = 50
inputs[3].SetValue(host.Identity)
inputs[identityInput] = textinput.New()
inputs[identityInput].Placeholder = "~/.ssh/id_rsa"
inputs[identityInput].CharLimit = 200
inputs[identityInput].Width = 50
inputs[identityInput].SetValue(host.Identity)
// ProxyJump input
inputs[4] = textinput.New()
inputs[4].Placeholder = "jump-server"
inputs[4].CharLimit = 100
inputs[4].Width = 30
inputs[4].SetValue(host.ProxyJump)
inputs[proxyJumpInput] = textinput.New()
inputs[proxyJumpInput].Placeholder = "user@jump-host:port or existing-host-name"
inputs[proxyJumpInput].CharLimit = 200
inputs[proxyJumpInput].Width = 50
inputs[proxyJumpInput].SetValue(host.ProxyJump)
// Options input
inputs[5] = textinput.New()
inputs[5].Placeholder = "-o StrictHostKeyChecking=no"
inputs[5].CharLimit = 200
inputs[5].Width = 50
if host.Options != "" {
inputs[5].SetValue(config.FormatSSHOptionsForCommand(host.Options))
}
// SSH Options input
inputs[optionsInput] = textinput.New()
inputs[optionsInput].Placeholder = "-o Compression=yes -o ServerAliveInterval=60"
inputs[optionsInput].CharLimit = 500
inputs[optionsInput].Width = 70
inputs[optionsInput].SetValue(config.FormatSSHOptionsForCommand(host.Options))
// Tags input
inputs[6] = textinput.New()
inputs[6].Placeholder = "production, web, database"
inputs[6].CharLimit = 200
inputs[6].Width = 50
inputs[tagsInput] = textinput.New()
inputs[tagsInput].Placeholder = "production, web, database"
inputs[tagsInput].CharLimit = 200
inputs[tagsInput].Width = 50
if len(host.Tags) > 0 {
inputs[6].SetValue(strings.Join(host.Tags, ", "))
inputs[tagsInput].SetValue(strings.Join(host.Tags, ", "))
}
// Remote Command input
inputs[7] = textinput.New()
inputs[7].Placeholder = "ls -la, htop, bash"
inputs[7].CharLimit = 300
inputs[7].Width = 70
inputs[7].SetValue(host.RemoteCommand)
// RequestTTY input
inputs[8] = textinput.New()
inputs[8].Placeholder = "yes, no, force, auto"
inputs[8].CharLimit = 10
inputs[8].Width = 30
inputs[8].SetValue(host.RequestTTY)
return &editFormModel{
hostInputs: hostInputs,
inputs: inputs,
focusArea: focusAreaHosts, // Start with hosts focused for multi-host editing
focused: 0,
currentTab: 0, // Start on General tab
originalName: hostName,
originalHosts: hostNames,
host: host,
configFile: configFile,
actualConfigFile: actualConfigFile,
styles: styles,
width: width,
height: height,
inputs: inputs,
focused: nameInput,
originalName: hostName,
configFile: configFile,
styles: styles,
width: width,
height: height,
}, nil
}
// Messages for communication with parent model
type editFormSubmitMsg struct {
hostname string
err error
}
type editFormCancelMsg struct{}
func (m *editFormModel) Init() tea.Cmd {
return textinput.Blink
}
// addHostInput adds a new empty host input
func (m *editFormModel) addHostInput() tea.Cmd {
newInput := textinput.New()
newInput.Placeholder = "host-name"
newInput.Focus()
// Unfocus current input regardless of which area we're in
if m.focusArea == focusAreaHosts && m.focused < len(m.hostInputs) {
m.hostInputs[m.focused].Blur()
} else if m.focusArea == focusAreaProperties && m.focused < len(m.inputs) {
m.inputs[m.focused].Blur()
}
m.hostInputs = append(m.hostInputs, newInput)
// Move focus to the new host input
m.focusArea = focusAreaHosts
m.focused = len(m.hostInputs) - 1
return textinput.Blink
}
// deleteHostInput removes the currently focused host input
func (m *editFormModel) deleteHostInput() tea.Cmd {
if len(m.hostInputs) <= 1 || m.focusArea != focusAreaHosts {
return nil // Can't delete if only one host or not in host area
}
// Remove the focused host input
m.hostInputs = append(m.hostInputs[:m.focused], m.hostInputs[m.focused+1:]...)
// Adjust focus
if m.focused >= len(m.hostInputs) {
m.focused = len(m.hostInputs) - 1
}
// Focus the new current input
if len(m.hostInputs) > 0 {
m.hostInputs[m.focused].Focus()
}
return nil
}
// updateFocus updates the focus state based on current area and index
func (m *editFormModel) updateFocus() tea.Cmd {
// Blur all inputs first
for i := range m.hostInputs {
m.hostInputs[i].Blur()
}
for i := range m.inputs {
m.inputs[i].Blur()
}
// Focus the appropriate input
if m.focusArea == focusAreaHosts {
if m.focused < len(m.hostInputs) {
m.hostInputs[m.focused].Focus()
}
} else {
if m.focused < len(m.inputs) {
m.inputs[m.focused].Focus()
}
}
return textinput.Blink
}
// getPropertiesForCurrentTab returns the property input indices for the current tab
func (m *editFormModel) getPropertiesForCurrentTab() []int {
switch m.currentTab {
case 0: // General
return []int{0, 1, 2, 3, 4, 6} // hostname, user, port, identity, proxyjump, tags
case 1: // Advanced
return []int{5, 7, 8} // options, remotecommand, requesttty
default:
return []int{0, 1, 2, 3, 4, 6}
}
}
// getFirstPropertyForTab returns the first property index for a given tab
func (m *editFormModel) getFirstPropertyForTab(tab int) int {
properties := []int{0, 1, 2, 3, 4, 6} // General tab
if tab == 1 {
properties = []int{5, 7, 8} // Advanced tab
}
if len(properties) > 0 {
return properties[0]
}
return 0
}
// handleEditNavigation handles navigation in the edit form with tab support
func (m *editFormModel) handleEditNavigation(key string) tea.Cmd {
if m.focusArea == focusAreaHosts {
// Navigate in hosts area
if key == "up" || key == "shift+tab" {
m.focused--
} else {
m.focused++
}
if m.focused >= len(m.hostInputs) {
// Move to properties area, keep current tab
m.focusArea = focusAreaProperties
// Keep the current tab instead of forcing it to 0
m.focused = m.getFirstPropertyForTab(m.currentTab)
} else if m.focused < 0 {
m.focused = len(m.hostInputs) - 1
}
} else {
// Navigate in properties area within current tab
currentTabProperties := m.getPropertiesForCurrentTab()
// Find current position within the tab
currentPos := 0
for i, prop := range currentTabProperties {
if prop == m.focused {
currentPos = i
break
}
}
// Handle form submission on last field of Advanced tab
if key == "enter" && m.currentTab == 1 && currentPos == len(currentTabProperties)-1 {
return m.submitEditForm()
}
// Navigate within current tab
if key == "up" || key == "shift+tab" {
currentPos--
} else {
currentPos++
}
// Handle transitions between areas and tabs
if currentPos >= len(currentTabProperties) {
// Move to next area/tab
if m.currentTab == 0 {
// Move to advanced tab
m.currentTab = 1
m.focused = m.getFirstPropertyForTab(1)
} else {
// Move back to hosts area
m.focusArea = focusAreaHosts
m.focused = 0
}
} else if currentPos < 0 {
// Move to previous area/tab
if m.currentTab == 1 {
// Move to general tab
m.currentTab = 0
properties := m.getPropertiesForCurrentTab()
m.focused = properties[len(properties)-1]
} else {
// Move to hosts area
m.focusArea = focusAreaHosts
m.focused = len(m.hostInputs) - 1
}
} else {
m.focused = currentTabProperties[currentPos]
}
}
return m.updateFocus()
}
// getMinimumHeight calculates the minimum height needed to display the edit form
func (m *editFormModel) getMinimumHeight() int {
// Title: 1 line + 2 newlines = 3
titleLines := 3
// Config file info: 1 line + 2 newlines = 3
configLines := 3
// Host Names section: title (1) + spacing (2) = 3
hostSectionLines := 3
// Host inputs: number of hosts * 3 lines each (reduced from 4)
hostLines := len(m.hostInputs) * 3
// Properties section: title (1) + spacing (2) = 3
propertiesSectionLines := 3
// Tabs: 1 line + 2 newlines = 3
tabLines := 3
// Fields in current tab
var fieldsCount int
if m.currentTab == 0 {
fieldsCount = 6 // 6 fields in general tab
} else {
fieldsCount = 3 // 3 fields in advanced tab
}
// Each field: reduced from 4 to 3 lines per field
fieldsLines := fieldsCount * 3
// Help text: 3 lines
helpLines := 3
// Error message space when needed: 2 lines
errorLines := 0 // Only count when there's actually an error
if m.err != "" {
errorLines = 2
}
return titleLines + configLines + hostSectionLines + hostLines + propertiesSectionLines + tabLines + fieldsLines + helpLines + errorLines + 1 // +1 minimal safety margin
}
// isHeightSufficient checks if the current terminal height is sufficient
func (m *editFormModel) isHeightSufficient() bool {
return m.height >= m.getMinimumHeight()
}
// renderHeightWarning renders a warning message when height is insufficient
func (m *editFormModel) renderHeightWarning() string {
required := m.getMinimumHeight()
current := m.height
warning := m.styles.ErrorText.Render("⚠️ Terminal height is too small!")
details := m.styles.FormField.Render(fmt.Sprintf("Current: %d lines, Required: %d lines", current, required))
instruction := m.styles.FormHelp.Render("Please resize your terminal window and try again.")
instruction2 := m.styles.FormHelp.Render("Press Ctrl+C to cancel or resize terminal window.")
return warning + "\n\n" + details + "\n\n" + instruction + "\n" + instruction2
}
func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *editFormModel) Update(msg tea.Msg) (*editFormModel, tea.Cmd) {
var cmds []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":
m.err = ""
return m, func() tea.Msg { return editFormCancelMsg{} }
case "ctrl+s":
// Allow submission from any field with Ctrl+S (Save)
case "ctrl+enter":
// Allow submission from any field with Ctrl+Enter
return m, m.submitEditForm()
case "ctrl+j":
// Switch to next tab
m.currentTab = (m.currentTab + 1) % 2
// If we're in hosts area, stay there. If in properties, go to the first field of the new tab
if m.focusArea == focusAreaProperties {
m.focused = m.getFirstPropertyForTab(m.currentTab)
}
return m, m.updateFocus()
case "ctrl+k":
// Switch to previous tab
m.currentTab = (m.currentTab - 1 + 2) % 2
// If we're in hosts area, stay there. If in properties, go to the first field of the new tab
if m.focusArea == focusAreaProperties {
m.focused = m.getFirstPropertyForTab(m.currentTab)
}
return m, m.updateFocus()
case "tab", "shift+tab", "enter", "up", "down":
return m, m.handleEditNavigation(msg.String())
s := msg.String()
case "ctrl+a":
// Add a new host input
return m, m.addHostInput()
case "ctrl+d":
// Delete the currently focused host (if more than one exists)
if m.focusArea == focusAreaHosts && len(m.hostInputs) > 1 {
return m, m.deleteHostInput()
// Handle form submission
if s == "enter" && m.focused == len(m.inputs)-1 {
return m, m.submitEditForm()
}
// Cycle inputs
if s == "up" || s == "shift+tab" {
m.focused--
} else {
m.focused++
}
if m.focused > len(m.inputs)-1 {
m.focused = 0
} else if m.focused < 0 {
m.focused = len(m.inputs) - 1
}
for i := range m.inputs {
if i == m.focused {
cmds = append(cmds, m.inputs[i].Focus())
continue
}
m.inputs[i].Blur()
}
return m, tea.Batch(cmds...)
}
case editFormSubmitMsg:
if msg.err != nil {
m.err = msg.err.Error()
} else {
// Success: let the wrapper handle this
// In TUI mode, this will be handled by the parent
// In standalone mode, the wrapper will quit
m.success = true
m.err = ""
// Don't quit here, let parent handle the success
}
return m, nil
}
// Update host inputs
hostCmd := make([]tea.Cmd, len(m.hostInputs))
for i := range m.hostInputs {
m.hostInputs[i], hostCmd[i] = m.hostInputs[i].Update(msg)
}
cmds = append(cmds, hostCmd...)
// Update property inputs
propCmd := make([]tea.Cmd, len(m.inputs))
// Update inputs
cmd := make([]tea.Cmd, len(m.inputs))
for i := range m.inputs {
m.inputs[i], propCmd[i] = m.inputs[i].Update(msg)
m.inputs[i], cmd[i] = m.inputs[i].Update(msg)
}
cmds = append(cmds, propCmd...)
cmds = append(cmds, cmd...)
return m, tea.Batch(cmds...)
}
func (m *editFormModel) View() string {
// Check if terminal height is sufficient
if !m.isHeightSufficient() {
return m.renderHeightWarning()
if m.success {
return ""
}
var b strings.Builder
if m.err != "" {
b.WriteString(m.styles.Error.Render("Error: " + m.err))
b.WriteString("\n\n")
b.WriteString(m.styles.FormTitle.Render("Edit SSH Host Configuration"))
b.WriteString("\n\n")
fields := []string{
"Host Name *",
"Hostname/IP *",
"User",
"Port",
"Identity File",
"ProxyJump",
"SSH Options",
"Tags (comma-separated)",
}
b.WriteString(m.styles.Header.Render("Edit SSH Host"))
b.WriteString("\n\n")
if m.host != nil && m.host.SourceFile != "" {
labelStyle := m.styles.FormField
pathStyle := m.styles.FormField
configInfo := labelStyle.Render("Config file: ") + pathStyle.Render(formatConfigFile(m.host.SourceFile))
b.WriteString(configInfo)
}
b.WriteString("\n\n")
// Host Names Section
b.WriteString(m.styles.FormTitle.Render("Host Names"))
b.WriteString("\n\n")
for i, hostInput := range m.hostInputs {
hostStyle := m.styles.FormField
if m.focusArea == focusAreaHosts && m.focused == i {
hostStyle = m.styles.FocusedLabel
}
b.WriteString(hostStyle.Render(fmt.Sprintf("Host Name %d *", i+1)))
for i, field := range fields {
b.WriteString(m.styles.FormField.Render(field))
b.WriteString("\n")
b.WriteString(hostInput.View())
b.WriteString(m.inputs[i].View())
b.WriteString("\n\n")
}
// Properties Section
b.WriteString(m.styles.FormTitle.Render("Common Properties"))
b.WriteString("\n\n")
// Render tabs for properties
b.WriteString(m.renderEditTabs())
b.WriteString("\n\n")
// Render current tab content
switch m.currentTab {
case 0: // General
b.WriteString(m.renderEditGeneralTab())
case 1: // Advanced
b.WriteString(m.renderEditAdvancedTab())
}
if m.err != "" {
b.WriteString(m.styles.Error.Render("Error: " + m.err))
b.WriteString("\n\n")
}
// Show different help based on number of hosts
if len(m.hostInputs) > 1 {
b.WriteString(m.styles.FormHelp.Render("Tab/↑↓/Enter: navigate • Ctrl+J/K: switch tabs • Ctrl+A: add host • Ctrl+D: delete host"))
b.WriteString("\n")
} else {
b.WriteString(m.styles.FormHelp.Render("Tab/↑↓/Enter: navigate • Ctrl+J/K: switch tabs • Ctrl+A: add host"))
b.WriteString("\n")
}
b.WriteString(m.styles.FormHelp.Render("Ctrl+S: save • Ctrl+C/Esc: cancel • * Required fields"))
return b.String()
}
// renderEditTabs renders the tab headers for properties
func (m *editFormModel) renderEditTabs() string {
var generalTab, advancedTab string
if m.currentTab == 0 {
generalTab = m.styles.FocusedLabel.Render("[ General ]")
advancedTab = m.styles.FormField.Render(" Advanced ")
} else {
generalTab = m.styles.FormField.Render(" General ")
advancedTab = m.styles.FocusedLabel.Render("[ Advanced ]")
}
return generalTab + " " + advancedTab
}
// renderEditGeneralTab renders the general tab content for properties
func (m *editFormModel) renderEditGeneralTab() string {
var b strings.Builder
fields := []struct {
index int
label string
}{
{0, "Hostname/IP *"},
{1, "User"},
{2, "Port"},
{3, "Identity File"},
{4, "Proxy Jump"},
{6, "Tags (comma-separated)"},
}
for _, field := range fields {
fieldStyle := m.styles.FormField
if m.focusArea == focusAreaProperties && m.focused == field.index {
fieldStyle = m.styles.FocusedLabel
}
b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n")
b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n")
}
return b.String()
}
// renderEditAdvancedTab renders the advanced tab content for properties
func (m *editFormModel) renderEditAdvancedTab() string {
var b strings.Builder
fields := []struct {
index int
label string
}{
{5, "SSH Options"},
{7, "Remote Command"},
{8, "Request TTY"},
}
for _, field := range fields {
fieldStyle := m.styles.FormField
if m.focusArea == focusAreaProperties && m.focused == field.index {
fieldStyle = m.styles.FocusedLabel
}
b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n")
b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n")
}
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
b.WriteString("\n")
b.WriteString(m.styles.FormHelp.Render("* Required fields"))
return b.String()
}
@ -634,29 +243,29 @@ func (m standaloneEditForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case editFormSubmitMsg:
if msg.err != nil {
m.editFormModel.err = msg.err.Error()
return m, nil
} else {
// Success: quit the program
m.editFormModel.success = true
return m, tea.Quit
}
return m, nil
case editFormCancelMsg:
return m, tea.Quit
}
newForm, cmd := m.editFormModel.Update(msg)
m.editFormModel = newForm.(*editFormModel)
m.editFormModel = newForm
return m, cmd
}
// RunEditForm runs the edit form as a standalone program
// RunEditForm provides backward compatibility for standalone edit form
func RunEditForm(hostName string, configFile string) error {
styles := NewStyles(80) // Default width
styles := NewStyles(80)
editForm, err := NewEditForm(hostName, styles, 80, 24, configFile)
if err != nil {
return err
}
m := standaloneEditForm{editForm}
p := tea.NewProgram(m, tea.WithAltScreen())
_, err = p.Run()
return err
@ -664,48 +273,28 @@ func RunEditForm(hostName string, configFile string) error {
func (m *editFormModel) submitEditForm() tea.Cmd {
return func() tea.Msg {
// Collect host names
var hostNames []string
for _, input := range m.hostInputs {
name := strings.TrimSpace(input.Value())
if name != "" {
hostNames = append(hostNames, name)
}
}
if len(hostNames) == 0 {
return editFormSubmitMsg{err: fmt.Errorf("at least one host name is required")}
}
// Get property values using direct indices
hostname := strings.TrimSpace(m.inputs[0].Value()) // hostnameInput
user := strings.TrimSpace(m.inputs[1].Value()) // userInput
port := strings.TrimSpace(m.inputs[2].Value()) // portInput
identity := strings.TrimSpace(m.inputs[3].Value()) // identityInput
proxyJump := strings.TrimSpace(m.inputs[4].Value()) // proxyJumpInput
options := strings.TrimSpace(m.inputs[5].Value()) // optionsInput
remoteCommand := strings.TrimSpace(m.inputs[7].Value()) // remoteCommandInput
requestTTY := strings.TrimSpace(m.inputs[8].Value()) // requestTTYInput
// Get values
name := strings.TrimSpace(m.inputs[nameInput].Value())
hostname := strings.TrimSpace(m.inputs[hostnameInput].Value())
user := strings.TrimSpace(m.inputs[userInput].Value())
port := strings.TrimSpace(m.inputs[portInput].Value())
identity := strings.TrimSpace(m.inputs[identityInput].Value())
proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value())
options := strings.TrimSpace(m.inputs[optionsInput].Value())
// Set defaults
if port == "" {
port = "22"
}
// Do not auto-fill identity with placeholder if left empty; keep it empty so it's optional
// Validate hostname
if hostname == "" {
return editFormSubmitMsg{err: fmt.Errorf("hostname is required")}
}
// Validate all host names
for _, hostName := range hostNames {
if err := validation.ValidateHost(hostName, hostname, port, identity); err != nil {
return editFormSubmitMsg{err: err}
}
// Validate all fields
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
return editFormSubmitMsg{err: err}
}
// Parse tags
tagsStr := strings.TrimSpace(m.inputs[6].Value()) // tagsInput
tagsStr := strings.TrimSpace(m.inputs[tagsInput].Value())
var tags []string
if tagsStr != "" {
for _, tag := range strings.Split(tagsStr, ",") {
@ -716,33 +305,25 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
}
}
// Create the common host configuration
commonHost := config.SSHHost{
Hostname: hostname,
User: user,
Port: port,
Identity: identity,
ProxyJump: proxyJump,
Options: options,
RemoteCommand: remoteCommand,
RequestTTY: requestTTY,
Tags: tags,
// Create updated host configuration
host := config.SSHHost{
Name: name,
Hostname: hostname,
User: user,
Port: port,
Identity: identity,
ProxyJump: proxyJump,
Options: config.ParseSSHOptionsFromCommand(options),
Tags: tags,
}
// Update the configuration
var err error
if len(hostNames) == 1 && len(m.originalHosts) == 1 {
// Single host editing
commonHost.Name = hostNames[0]
if m.actualConfigFile != "" {
err = config.UpdateSSHHostInFile(m.originalName, commonHost, m.actualConfigFile)
} else {
err = config.UpdateSSHHost(m.originalName, commonHost)
}
if m.configFile != "" {
err = config.UpdateSSHHostInFile(m.originalName, host, m.configFile)
} else {
// Multi-host editing or conversion from single to multi
err = config.UpdateMultiHostBlock(m.originalHosts, hostNames, commonHost, m.actualConfigFile)
err = config.UpdateSSHHost(m.originalName, host)
}
return editFormSubmitMsg{hostname: hostNames[0], err: err}
return editFormSubmitMsg{hostname: name, err: err}
}
}

View File

@ -1,162 +0,0 @@
package ui
import (
"fmt"
"path/filepath"
"github.com/Gu1llaum-3/sshm/internal/config"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type fileSelectorModel struct {
files []string // Chemins absolus des fichiers
displayNames []string // Noms d'affichage conviviaux
selected int
styles Styles
width int
height int
title string
}
type fileSelectorMsg struct {
selectedFile string
cancelled bool
}
// NewFileSelector creates a new file selector for choosing config files
func NewFileSelector(title string, styles Styles, width, height int) (*fileSelectorModel, error) {
files, err := config.GetAllConfigFiles()
if err != nil {
return nil, err
}
return newFileSelectorFromFiles(title, styles, width, height, files)
}
// NewFileSelectorFromBase creates a new file selector starting from a specific base config file
func NewFileSelectorFromBase(title string, styles Styles, width, height int, baseConfigFile string) (*fileSelectorModel, error) {
var files []string
var err error
if baseConfigFile != "" {
files, err = config.GetAllConfigFilesFromBase(baseConfigFile)
} else {
files, err = config.GetAllConfigFiles()
}
if err != nil {
return nil, err
}
return newFileSelectorFromFiles(title, styles, width, height, files)
}
// newFileSelectorFromFiles creates a file selector from a list of files
func newFileSelectorFromFiles(title string, styles Styles, width, height int, files []string) (*fileSelectorModel, error) {
// Convert absolute paths to more user-friendly names
var displayNames []string
homeDir, _ := config.GetSSHDirectory()
for _, file := range files {
// Check if it's the main config file
mainConfig, _ := config.GetDefaultSSHConfigPath()
if file == mainConfig {
displayNames = append(displayNames, "Main SSH Config (~/.ssh/config)")
} else {
// Try to make path relative to home/.ssh/
if strings.HasPrefix(file, homeDir) {
relPath, err := filepath.Rel(homeDir, file)
if err == nil {
displayNames = append(displayNames, fmt.Sprintf("~/.ssh/%s", relPath))
} else {
displayNames = append(displayNames, file)
}
} else {
displayNames = append(displayNames, file)
}
}
}
return &fileSelectorModel{
files: files,
displayNames: displayNames,
selected: 0,
styles: styles,
width: width,
height: height,
title: title,
}, nil
}
func (m *fileSelectorModel) Init() tea.Cmd {
return nil
}
func (m *fileSelectorModel) Update(msg tea.Msg) (*fileSelectorModel, 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":
return m, func() tea.Msg {
return fileSelectorMsg{cancelled: true}
}
case "enter":
selectedFile := ""
if m.selected < len(m.files) {
selectedFile = m.files[m.selected]
}
return m, func() tea.Msg {
return fileSelectorMsg{selectedFile: selectedFile}
}
case "up", "k":
if m.selected > 0 {
m.selected--
}
case "down", "j":
if m.selected < len(m.files)-1 {
m.selected++
}
}
}
return m, nil
}
func (m *fileSelectorModel) View() string {
var b strings.Builder
b.WriteString(m.styles.FormTitle.Render(m.title))
b.WriteString("\n\n")
if len(m.files) == 0 {
b.WriteString(m.styles.Error.Render("No SSH config files found."))
b.WriteString("\n\n")
b.WriteString(m.styles.FormHelp.Render("Esc: cancel"))
return b.String()
}
for i, displayName := range m.displayNames {
if i == m.selected {
b.WriteString(m.styles.Selected.Render(fmt.Sprintf("▶ %s", displayName)))
} else {
b.WriteString(fmt.Sprintf(" %s", displayName))
}
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(m.styles.FormHelp.Render("↑/↓: navigate • Enter: select • Esc: cancel"))
return b.String()
}

View File

@ -1,134 +0,0 @@
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 - Commands")
// Create two columns of commands for better visual organization
leftColumn := lipgloss.JoinVertical(lipgloss.Left,
m.styles.FocusedLabel.Render("Navigation & Connection"),
"",
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("⏎ "),
m.styles.HelpText.Render("connect to selected host")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("i "),
m.styles.HelpText.Render("show host information")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("/ "),
m.styles.HelpText.Render("search hosts")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("Tab "),
m.styles.HelpText.Render("switch focus")),
"",
m.styles.FocusedLabel.Render("Host Management"),
"",
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("a "),
m.styles.HelpText.Render("add new host")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("e "),
m.styles.HelpText.Render("edit selected host")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("m "),
m.styles.HelpText.Render("move host to another config")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("d "),
m.styles.HelpText.Render("delete selected host")),
)
rightColumn := lipgloss.JoinVertical(lipgloss.Left,
m.styles.FocusedLabel.Render("Advanced Features"),
"",
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("p "),
m.styles.HelpText.Render("ping all hosts")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("f "),
m.styles.HelpText.Render("setup port forwarding")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("s "),
m.styles.HelpText.Render("cycle sort modes")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("n "),
m.styles.HelpText.Render("sort by name")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("r "),
m.styles.HelpText.Render("sort by recent connection")),
"",
m.styles.FocusedLabel.Render("System"),
"",
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("h "),
m.styles.HelpText.Render("show this help")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("q "),
m.styles.HelpText.Render("quit application")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("ESC "),
m.styles.HelpText.Render("exit current view")),
)
// Join the two columns side by side
columns := lipgloss.JoinHorizontal(lipgloss.Top,
leftColumn,
" ", // spacing between columns
rightColumn,
)
// Create the main content
content := lipgloss.JoinVertical(lipgloss.Center,
title,
"",
columns,
"",
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),
)
}

View File

@ -1,228 +0,0 @@
package ui
import (
"fmt"
"github.com/Gu1llaum-3/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},
{"Config File", formatConfigFile(m.host.SourceFile)},
{"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

@ -1,10 +1,8 @@
package ui
import (
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/connectivity"
"github.com/Gu1llaum-3/sshm/internal/history"
"github.com/Gu1llaum-3/sshm/internal/version"
"sshm/internal/config"
"sshm/internal/history"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
@ -37,11 +35,7 @@ const (
ViewList ViewMode = iota
ViewAdd
ViewEdit
ViewMove
ViewInfo
ViewPortForward
ViewHelp
ViewFileSelector
)
// PortForwardType defines the type of port forwarding
@ -76,36 +70,20 @@ type Model struct {
deleteMode bool
deleteHost string
historyManager *history.HistoryManager
pingManager *connectivity.PingManager
sortMode SortMode
configFile string // Path to the SSH config file
// Application configuration
appConfig *config.AppConfig
// Version update information
updateInfo *version.UpdateInfo
currentVersion string
// View management
viewMode ViewMode
addForm *addFormModel
editForm *editFormModel
moveForm *moveFormModel
infoForm *infoFormModel
portForwardForm *portForwardModel
helpForm *helpModel
fileSelectorForm *fileSelectorModel
viewMode ViewMode
addForm *addFormModel
editForm *editFormModel
portForwardForm *portForwardModel
// Terminal size and styles
width int
height int
styles Styles
ready bool
// Error handling
errorMessage string
showingError bool
}
// updateTableStyles updates the table header border color based on focus state

View File

@ -1,188 +0,0 @@
package ui
import (
"fmt"
"github.com/Gu1llaum-3/sshm/internal/config"
tea "github.com/charmbracelet/bubbletea"
)
type moveFormModel struct {
fileSelector *fileSelectorModel
hostName string
configFile string
width int
height int
styles Styles
state moveFormState
}
type moveFormState int
const (
moveFormSelectingFile moveFormState = iota
moveFormProcessing
)
type moveFormSubmitMsg struct {
hostName string
targetFile string
err error
}
type moveFormCancelMsg struct{}
// NewMoveForm creates a new move form for moving a host to another config file
func NewMoveForm(hostName string, styles Styles, width, height int, configFile string) (*moveFormModel, error) {
// Get all config files except the one containing the current host
files, err := config.GetConfigFilesExcludingCurrent(hostName, configFile)
if err != nil {
return nil, fmt.Errorf("failed to get config files: %v", err)
}
if len(files) == 0 {
return nil, fmt.Errorf("no includes found in SSH config file - move operation requires multiple config files")
}
// Create a custom file selector for move operation
fileSelector, err := newFileSelectorFromFiles(
fmt.Sprintf("Select destination config file for host '%s':", hostName),
styles,
width,
height,
files,
)
if err != nil {
return nil, fmt.Errorf("failed to create file selector: %v", err)
}
return &moveFormModel{
fileSelector: fileSelector,
hostName: hostName,
configFile: configFile,
width: width,
height: height,
styles: styles,
state: moveFormSelectingFile,
}, nil
}
func (m *moveFormModel) Init() tea.Cmd {
return m.fileSelector.Init()
}
func (m *moveFormModel) Update(msg tea.Msg) (*moveFormModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.styles = NewStyles(m.width)
if m.fileSelector != nil {
m.fileSelector.width = m.width
m.fileSelector.height = m.height
m.fileSelector.styles = m.styles
}
return m, nil
case tea.KeyMsg:
switch m.state {
case moveFormSelectingFile:
switch msg.String() {
case "enter":
if m.fileSelector != nil && len(m.fileSelector.files) > 0 {
selectedFile := m.fileSelector.files[m.fileSelector.selected]
m.state = moveFormProcessing
return m, m.submitMove(selectedFile)
}
case "esc", "q":
return m, func() tea.Msg { return moveFormCancelMsg{} }
default:
// Forward other keys to file selector
if m.fileSelector != nil {
newFileSelector, cmd := m.fileSelector.Update(msg)
m.fileSelector = newFileSelector
return m, cmd
}
}
case moveFormProcessing:
// Dans cet état, on attend le résultat de l'opération
// Le résultat sera géré par le modèle principal
switch msg.String() {
case "esc", "q":
return m, func() tea.Msg { return moveFormCancelMsg{} }
}
}
}
return m, nil
}
func (m *moveFormModel) View() string {
switch m.state {
case moveFormSelectingFile:
if m.fileSelector != nil {
return m.fileSelector.View()
}
return "Loading..."
case moveFormProcessing:
return m.styles.FormTitle.Render("Moving host...") + "\n\n" +
m.styles.HelpText.Render(fmt.Sprintf("Moving host '%s' to selected config file...", m.hostName))
default:
return "Unknown state"
}
}
func (m *moveFormModel) submitMove(targetFile string) tea.Cmd {
return func() tea.Msg {
err := config.MoveHostToFile(m.hostName, targetFile)
return moveFormSubmitMsg{
hostName: m.hostName,
targetFile: targetFile,
err: err,
}
}
}
// Standalone move form for CLI usage
type standaloneMoveForm struct {
moveFormModel *moveFormModel
}
func (m standaloneMoveForm) Init() tea.Cmd {
return m.moveFormModel.Init()
}
func (m standaloneMoveForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case moveFormCancelMsg:
return m, tea.Quit
case moveFormSubmitMsg:
// En mode standalone, on quitte après le déplacement (succès ou erreur)
return m, tea.Quit
}
newForm, cmd := m.moveFormModel.Update(msg)
m.moveFormModel = newForm
return m, cmd
}
func (m standaloneMoveForm) View() string {
return m.moveFormModel.View()
}
// RunMoveForm provides backward compatibility for standalone move form
func RunMoveForm(hostName string, configFile string) error {
styles := NewStyles(80)
moveForm, err := NewMoveForm(hostName, styles, 80, 24, configFile)
if err != nil {
return err
}
m := standaloneMoveForm{moveForm}
p := tea.NewProgram(m, tea.WithAltScreen())
_, err = p.Run()
return err
}

View File

@ -5,7 +5,6 @@ import (
"strconv"
"strings"
"github.com/Gu1llaum-3/sshm/internal/history"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@ -21,16 +20,15 @@ const (
)
type portForwardModel struct {
inputs []textinput.Model
focused int
forwardType PortForwardType
hostName string
err string
styles Styles
width int
height int
configFile string
historyManager *history.HistoryManager
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
@ -43,7 +41,7 @@ type portForwardSubmitMsg struct {
type portForwardCancelMsg struct{}
// NewPortForwardForm creates a new port forward form model
func NewPortForwardForm(hostName string, styles Styles, width, height int, configFile string, historyManager *history.HistoryManager) *portForwardModel {
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)
@ -51,6 +49,7 @@ func NewPortForwardForm(hostName string, styles Styles, width, height int, confi
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()
@ -78,20 +77,16 @@ func NewPortForwardForm(hostName string, styles Styles, width, height int, confi
inputs[pfBindAddressInput].Width = 30
pf := &portForwardModel{
inputs: inputs,
focused: 0,
forwardType: LocalForward,
hostName: hostName,
styles: styles,
width: width,
height: height,
configFile: configFile,
historyManager: historyManager,
inputs: inputs,
focused: 0,
forwardType: LocalForward,
hostName: hostName,
styles: styles,
width: width,
height: height,
configFile: configFile,
}
// Load previous port forwarding configuration if available
pf.loadPreviousConfig()
// Initialize input visibility
pf.updateInputVisibility()
@ -375,11 +370,6 @@ func (m *portForwardModel) submitForm() tea.Cmd {
return portForwardSubmitMsg{err: fmt.Errorf("invalid port number"), sshArgs: nil}
}
// Get form values for saving to history
remoteHost := strings.TrimSpace(m.inputs[pfRemoteHostInput].Value())
remotePort := strings.TrimSpace(m.inputs[pfRemotePortInput].Value())
bindAddress := strings.TrimSpace(m.inputs[pfBindAddressInput].Value())
// Build SSH command with port forwarding
var sshArgs []string
@ -389,10 +379,13 @@ func (m *portForwardModel) submitForm() tea.Cmd {
}
// Add forwarding arguments
var forwardTypeStr string
bindAddress := strings.TrimSpace(m.inputs[pfBindAddressInput].Value())
switch m.forwardType {
case LocalForward:
forwardTypeStr = "local"
remoteHost := strings.TrimSpace(m.inputs[pfRemoteHostInput].Value())
remotePort := strings.TrimSpace(m.inputs[pfRemotePortInput].Value())
if remoteHost == "" {
remoteHost = "localhost"
}
@ -415,30 +408,31 @@ func (m *portForwardModel) submitForm() tea.Cmd {
sshArgs = append(sshArgs, "-L", forwardArg)
case RemoteForward:
forwardTypeStr = "remote"
if remoteHost == "" {
remoteHost = "localhost"
localHost := strings.TrimSpace(m.inputs[pfRemoteHostInput].Value())
localPortStr := strings.TrimSpace(m.inputs[pfRemotePortInput].Value())
if localHost == "" {
localHost = "localhost"
}
if remotePort == "" {
if localPortStr == "" {
return portForwardSubmitMsg{err: fmt.Errorf("local port is required for remote forwarding"), sshArgs: nil}
}
// Validate local port
if _, err := strconv.Atoi(remotePort); err != nil {
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, remoteHost, remotePort)
forwardArg = fmt.Sprintf("%s:%s:%s:%s", bindAddress, localPort, localHost, localPortStr)
} else {
forwardArg = fmt.Sprintf("%s:%s:%s", localPort, remoteHost, remotePort)
forwardArg = fmt.Sprintf("%s:%s:%s", localPort, localHost, localPortStr)
}
sshArgs = append(sshArgs, "-R", forwardArg)
case DynamicForward:
forwardTypeStr = "dynamic"
// Build -D argument
var forwardArg string
if bindAddress != "" {
@ -449,21 +443,6 @@ func (m *portForwardModel) submitForm() tea.Cmd {
sshArgs = append(sshArgs, "-D", forwardArg)
}
// Save port forwarding configuration to history
if m.historyManager != nil {
if err := m.historyManager.RecordPortForwarding(
m.hostName,
forwardTypeStr,
localPort,
remoteHost,
remotePort,
bindAddress,
); err != nil {
// Log the error but don't fail the connection
// In a production environment, you might want to handle this differently
}
}
// Add hostname
sshArgs = append(sshArgs, m.hostName)
@ -509,47 +488,3 @@ func (m *portForwardModel) getPrevValidField(currentField int) int {
}
return -1
}
// loadPreviousConfig loads the previous port forwarding configuration for this host
func (m *portForwardModel) loadPreviousConfig() {
if m.historyManager == nil {
m.inputs[pfTypeInput].SetValue("Local (-L)")
return
}
config := m.historyManager.GetPortForwardingConfig(m.hostName)
if config == nil {
m.inputs[pfTypeInput].SetValue("Local (-L)")
return
}
// Set forward type based on saved configuration
switch config.Type {
case "local":
m.forwardType = LocalForward
case "remote":
m.forwardType = RemoteForward
case "dynamic":
m.forwardType = DynamicForward
default:
m.forwardType = LocalForward
}
m.inputs[pfTypeInput].SetValue(m.forwardType.String())
// Set values from saved configuration
if config.LocalPort != "" {
m.inputs[pfLocalPortInput].SetValue(config.LocalPort)
}
if config.RemoteHost != "" {
m.inputs[pfRemoteHostInput].SetValue(config.RemoteHost)
} else if m.forwardType != DynamicForward {
// Default to localhost for local and remote forwarding if not set
m.inputs[pfRemoteHostInput].SetValue("localhost")
}
if config.RemotePort != "" {
m.inputs[pfRemotePortInput].SetValue(config.RemotePort)
}
if config.BindAddress != "" {
m.inputs[pfBindAddressInput].SetValue(config.BindAddress)
}
}

View File

@ -1,305 +0,0 @@
package ui
import (
"testing"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
// createTestModel creates a model with test data for testing
func createTestModel() Model {
hosts := []config.SSHHost{
{Name: "server1", Hostname: "server1.example.com", User: "user1"},
{Name: "server2", Hostname: "server2.example.com", User: "user2"},
{Name: "server3", Hostname: "server3.example.com", User: "user3"},
{Name: "web-server", Hostname: "web.example.com", User: "webuser"},
{Name: "db-server", Hostname: "db.example.com", User: "dbuser"},
}
m := Model{
hosts: hosts,
filteredHosts: hosts,
searchInput: textinput.New(),
table: table.New(),
searchMode: false,
ready: true,
width: 80,
height: 24,
styles: NewStyles(80),
}
// Initialize table with test data
m.updateTableColumns()
m.updateTableRows()
return m
}
func TestSearchModeToggle(t *testing.T) {
m := createTestModel()
// Initially should not be in search mode
if m.searchMode {
t.Error("Model should not start in search mode")
}
// Simulate pressing "/" to enter search mode
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
// Should now be in search mode
if !m.searchMode {
t.Error("Model should be in search mode after pressing '/'")
}
// The search input should be focused
if !m.searchInput.Focused() {
t.Error("Search input should be focused in search mode")
}
}
func TestSearchFiltering(t *testing.T) {
m := createTestModel()
// Enter search mode
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
// Type "server" in search
for _, char := range "server" {
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
}
// Should filter to only hosts containing "server"
expectedHosts := []string{"server1", "server2", "server3", "web-server", "db-server"}
if len(m.filteredHosts) != len(expectedHosts) {
t.Errorf("Expected %d filtered hosts, got %d", len(expectedHosts), len(m.filteredHosts))
}
// Check that all filtered hosts contain "server"
for _, host := range m.filteredHosts {
found := false
for _, expected := range expectedHosts {
if host.Name == expected {
found = true
break
}
}
if !found {
t.Errorf("Unexpected host in filtered results: %s", host.Name)
}
}
}
func TestSearchFilteringSpecific(t *testing.T) {
m := createTestModel()
// Enter search mode
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
// Type "web" in search
for _, char := range "web" {
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
}
// Should filter to only hosts containing "web"
if len(m.filteredHosts) != 1 {
t.Errorf("Expected 1 filtered host, got %d", len(m.filteredHosts))
}
if len(m.filteredHosts) > 0 && m.filteredHosts[0].Name != "web-server" {
t.Errorf("Expected 'web-server', got '%s'", m.filteredHosts[0].Name)
}
}
func TestSearchClearReturnToOriginal(t *testing.T) {
m := createTestModel()
originalHostCount := len(m.hosts)
// Enter search mode and type something
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
// Type "web" in search
for _, char := range "web" {
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
}
// Should have filtered results
if len(m.filteredHosts) >= originalHostCount {
t.Error("Search should have filtered down the results")
}
// Clear the search by simulating backspace
for i := 0; i < 3; i++ { // "web" is 3 characters
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
}
// Should return to all hosts
if len(m.filteredHosts) != originalHostCount {
t.Errorf("Expected %d hosts after clearing search, got %d", originalHostCount, len(m.filteredHosts))
}
}
func TestCursorPositionAfterFiltering(t *testing.T) {
m := createTestModel()
// Move cursor down to position 2 (third item)
m.table.SetCursor(2)
initialCursor := m.table.Cursor()
if initialCursor != 2 {
t.Errorf("Expected cursor at position 2, got %d", initialCursor)
}
// Enter search mode
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
// Type "web" - this will filter to only 1 result
for _, char := range "web" {
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
}
// Cursor should be reset to 0 since filtered results has only 1 item
// and cursor position 2 would be out of bounds
if len(m.filteredHosts) == 1 && m.table.Cursor() != 0 {
t.Errorf("Expected cursor to be reset to 0 when filtered results are smaller, got %d", m.table.Cursor())
}
}
func TestTabSwitchBetweenSearchAndTable(t *testing.T) {
m := createTestModel()
// Enter search mode
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
if !m.searchMode {
t.Error("Should be in search mode")
}
// Press Tab to switch to table
keyMsg = tea.KeyMsg{Type: tea.KeyTab}
newModel, _ = m.Update(keyMsg)
m = newModel.(Model)
if m.searchMode {
t.Error("Should not be in search mode after Tab")
}
// Press Tab again to switch back to search
keyMsg = tea.KeyMsg{Type: tea.KeyTab}
newModel, _ = m.Update(keyMsg)
m = newModel.(Model)
if !m.searchMode {
t.Error("Should be in search mode after second Tab")
}
}
func TestEnterExitsSearchMode(t *testing.T) {
m := createTestModel()
// Enter search mode
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
if !m.searchMode {
t.Error("Should be in search mode")
}
// Press Enter to exit search mode
keyMsg = tea.KeyMsg{Type: tea.KeyEnter}
newModel, _ = m.Update(keyMsg)
m = newModel.(Model)
if m.searchMode {
t.Error("Should not be in search mode after Enter")
}
}
func TestSearchModeDoesNotTriggerOnEmptyInput(t *testing.T) {
m := createTestModel()
originalHostCount := len(m.hosts)
// Enter search mode
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
// At this point, filteredHosts should still be the same as the original hosts
// because entering search mode should not trigger filtering with empty input
if len(m.filteredHosts) != originalHostCount {
t.Errorf("Expected %d hosts when entering search mode, got %d", originalHostCount, len(m.filteredHosts))
}
}
func TestSearchByHostname(t *testing.T) {
m := createTestModel()
// Enter search mode
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
// Search by hostname part "example.com"
searchTerm := "example.com"
for _, char := range searchTerm {
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
}
// All hosts should match since they all have "example.com" in hostname
if len(m.filteredHosts) != len(m.hosts) {
t.Errorf("Expected all %d hosts to match hostname search, got %d", len(m.hosts), len(m.filteredHosts))
}
}
func TestSearchByUser(t *testing.T) {
m := createTestModel()
// Enter search mode
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
// Search by user "user1"
searchTerm := "user1"
for _, char := range searchTerm {
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
newModel, _ := m.Update(keyMsg)
m = newModel.(Model)
}
// Only server1 should match
if len(m.filteredHosts) != 1 {
t.Errorf("Expected 1 host to match user search, got %d", len(m.filteredHosts))
}
if len(m.filteredHosts) > 0 && m.filteredHosts[0].Name != "server1" {
t.Errorf("Expected 'server1' to match user search, got '%s'", m.filteredHosts[0].Name)
}
}

View File

@ -4,7 +4,7 @@ import (
"sort"
"strings"
"github.com/Gu1llaum-3/sshm/internal/config"
"sshm/internal/config"
)
// sortHosts sorts hosts according to the current sort mode
@ -37,64 +37,29 @@ func sortHostsByName(hosts []config.SSHHost) []config.SSHHost {
// filterHosts filters hosts according to the search query (name or tags)
func (m Model) filterHosts(query string) []config.SSHHost {
subqueries := strings.Split(query, " ")
subqueriesLength := len(subqueries)
subfilteredHosts := make([][]config.SSHHost, subqueriesLength)
for i, subquery := range subqueries {
subfilteredHosts[i] = m.filterHostsByWord(subquery)
}
// return the intersection of search results
result := make([]config.SSHHost, 0)
tempMap := map[string]int{}
for _, hosts := range subfilteredHosts {
for _, host := range hosts {
if _, ok := tempMap[host.Name]; !ok {
tempMap[host.Name] = 1
} else {
tempMap[host.Name] = tempMap[host.Name] + 1
}
if tempMap[host.Name] == subqueriesLength {
result = append(result, host)
}
}
}
return result
}
// filterHostsByWord filters hosts according to a single word
func (m Model) filterHostsByWord(word string) []config.SSHHost {
var filtered []config.SSHHost
if word == "" {
if query == "" {
filtered = m.hosts
} else {
word = strings.ToLower(word)
query = strings.ToLower(query)
for _, host := range m.hosts {
// Check the hostname
if strings.Contains(strings.ToLower(host.Name), word) {
if strings.Contains(strings.ToLower(host.Name), query) {
filtered = append(filtered, host)
continue
}
// Check the hostname
if strings.Contains(strings.ToLower(host.Hostname), word) {
filtered = append(filtered, host)
continue
}
// Check the user
if strings.Contains(strings.ToLower(host.User), word) {
if strings.Contains(strings.ToLower(host.Hostname), query) {
filtered = append(filtered, host)
continue
}
// Check the tags
for _, tag := range host.Tags {
if strings.Contains(strings.ToLower(tag), word) {
if strings.Contains(strings.ToLower(tag), query) {
filtered = append(filtered, host)
break
}

View File

@ -33,8 +33,7 @@ type Styles struct {
HelpText lipgloss.Style
// Error and confirmation styles
Error lipgloss.Style
ErrorText lipgloss.Style
Error lipgloss.Style
// Form styles (for add/edit forms)
FormTitle lipgloss.Style
@ -43,7 +42,6 @@ type Styles struct {
FormContainer lipgloss.Style
Label lipgloss.Style
FocusedLabel lipgloss.Style
HelpSection lipgloss.Style
}
// NewStyles creates a new Styles struct with the given terminal width
@ -90,7 +88,8 @@ func NewStyles(width int) Styles {
Foreground(lipgloss.Color(SecondaryColor)),
HelpText: lipgloss.NewStyle().
Foreground(lipgloss.Color(SecondaryColor)),
Foreground(lipgloss.Color(SecondaryColor)).
MarginTop(1),
// Error style
Error: lipgloss.NewStyle().
@ -98,11 +97,6 @@ func NewStyles(width int) Styles {
BorderForeground(lipgloss.Color(ErrorColor)).
Padding(1, 2),
// Error text style (no border, just red text)
ErrorText: lipgloss.NewStyle().
Foreground(lipgloss.Color(ErrorColor)).
Bold(true),
// Form styles
FormTitle: lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFDF5")).
@ -124,10 +118,8 @@ func NewStyles(width int) Styles {
Foreground(lipgloss.Color(SecondaryColor)),
FocusedLabel: lipgloss.NewStyle().
Foreground(lipgloss.Color(PrimaryColor)),
HelpSection: lipgloss.NewStyle().
Padding(0, 2),
Foreground(lipgloss.Color(PrimaryColor)).
Bold(true),
}
}

View File

@ -3,267 +3,12 @@ package ui
import (
"strings"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/history"
"sshm/internal/config"
"sshm/internal/history"
"github.com/charmbracelet/bubbles/table"
)
// calculateDynamicColumnWidths calculates optimal column widths based on terminal width
// and content length, ensuring all content fits when possible
func (m *Model) calculateDynamicColumnWidths(hosts []config.SSHHost) (int, int, int, int) {
if m.width <= 0 {
// Fallback to static widths if terminal width is not available
return calculateNameColumnWidth(hosts), 25, calculateTagsColumnWidth(hosts), calculateLastLoginColumnWidth(hosts, m.historyManager)
}
// Calculate content lengths
maxNameLength := 8 // Minimum for "Name" header + status indicator
maxHostnameLength := 8 // Minimum for "Hostname" header
maxTagsLength := 8 // Minimum for "Tags" header
maxLastLoginLength := 12 // Minimum for "Last Login" header
for _, host := range hosts {
// Name column includes status indicator (2 chars) + space (1 char) + name
nameLength := 3 + len(host.Name)
if nameLength > maxNameLength {
maxNameLength = nameLength
}
if len(host.Hostname) > maxHostnameLength {
maxHostnameLength = len(host.Hostname)
}
// Calculate tags string length
var tagsStr string
if len(host.Tags) > 0 {
var formattedTags []string
for _, tag := range host.Tags {
formattedTags = append(formattedTags, "#"+tag)
}
tagsStr = strings.Join(formattedTags, " ")
}
if len(tagsStr) > maxTagsLength {
maxTagsLength = len(tagsStr)
}
// Calculate last login length
if m.historyManager != nil {
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
timeStr := formatTimeAgo(lastConnect)
if len(timeStr) > maxLastLoginLength {
maxLastLoginLength = len(timeStr)
}
}
}
}
// Add padding to each column
maxNameLength += 2
maxHostnameLength += 2
maxTagsLength += 2
maxLastLoginLength += 2
// Calculate available width (minus borders and separators)
// Table has borders (2 chars) + column separators (3 chars between 4 columns)
availableWidth := m.width - 5
totalNeededWidth := maxNameLength + maxHostnameLength + maxTagsLength + maxLastLoginLength
if totalNeededWidth <= availableWidth {
// Everything fits perfectly
return maxNameLength, maxHostnameLength, maxTagsLength, maxLastLoginLength
}
// Need to adjust widths - prioritize columns by importance
// Priority: Name > Hostname > Last Login > Tags
// Calculate minimum widths
minNameWidth := 15 // Enough for status + short name
minHostnameWidth := 15
minLastLoginWidth := 12
minTagsWidth := 10
remainingWidth := availableWidth
// Allocate minimum widths first
nameWidth := minNameWidth
hostnameWidth := minHostnameWidth
lastLoginWidth := minLastLoginWidth
tagsWidth := minTagsWidth
remainingWidth -= (nameWidth + hostnameWidth + lastLoginWidth + tagsWidth)
// Distribute remaining space proportionally
if remainingWidth > 0 {
// Calculate how much each column wants beyond minimum
nameWant := maxNameLength - minNameWidth
hostnameWant := maxHostnameLength - minHostnameWidth
lastLoginWant := maxLastLoginLength - minLastLoginWidth
tagsWant := maxTagsLength - minTagsWidth
totalWant := nameWant + hostnameWant + lastLoginWant + tagsWant
if totalWant > 0 {
// Distribute proportionally
nameExtra := (nameWant * remainingWidth) / totalWant
hostnameExtra := (hostnameWant * remainingWidth) / totalWant
lastLoginExtra := (lastLoginWant * remainingWidth) / totalWant
tagsExtra := remainingWidth - nameExtra - hostnameExtra - lastLoginExtra
nameWidth += nameExtra
hostnameWidth += hostnameExtra
lastLoginWidth += lastLoginExtra
tagsWidth += tagsExtra
}
}
return nameWidth, hostnameWidth, tagsWidth, lastLoginWidth
}
// updateTableRows updates the table with filtered hosts
func (m *Model) updateTableRows() {
var rows []table.Row
hostsToShow := m.filteredHosts
if hostsToShow == nil {
hostsToShow = m.hosts
}
for _, host := range hostsToShow {
// Get ping status indicator
statusIndicator := m.getPingStatusIndicator(host.Name)
// Format tags for display
var tagsStr string
if len(host.Tags) > 0 {
// Add the # prefix to each tag and join them with spaces
var formattedTags []string
for _, tag := range host.Tags {
formattedTags = append(formattedTags, "#"+tag)
}
tagsStr = strings.Join(formattedTags, " ")
}
// Format last login information
var lastLoginStr string
if m.historyManager != nil {
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
lastLoginStr = formatTimeAgo(lastConnect)
}
}
rows = append(rows, table.Row{
statusIndicator + " " + host.Name,
host.Hostname,
// host.User, // Commented to save space
// host.Port, // Commented to save space
tagsStr,
lastLoginStr,
})
}
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)
// - Update banner : 1 line (if present)
// - Search bar: 1 line
// - Help text: 1 line
// - App margins/spacing: 3 lines
// - Safety margin: 3 lines (to ensure UI elements are always visible)
// Total reserved: 14 lines minimum to preserve essential UI elements
reservedHeight := 14
availableHeight := m.height - reservedHeight
hostCount := len(m.table.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
}
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
}
// Add one extra line to prevent the last host from being hidden
// This compensates for table rendering quirks in bubble tea
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
}
// Use dynamic column width calculation
nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(hostsToShow)
// 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: nameTitle, Width: nameWidth},
{Title: "Hostname", Width: hostnameWidth},
// {Title: "User", Width: userWidth}, // Commented to save space
// {Title: "Port", Width: portWidth}, // Commented to save space
{Title: "Tags", Width: tagsWidth},
{Title: lastLoginTitle, 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
}
// Legacy functions for compatibility
// calculateNameColumnWidth calculates the optimal width for the Name column
// based on the longest hostname, with a minimum of 8 and maximum of 40 characters
func calculateNameColumnWidth(hosts []config.SSHHost) int {
@ -345,3 +90,159 @@ func calculateLastLoginColumnWidth(hosts []config.SSHHost, historyManager *histo
return maxLength
}
// updateTableRows updates the table with filtered hosts
func (m *Model) updateTableRows() {
var rows []table.Row
hostsToShow := m.filteredHosts
if hostsToShow == nil {
hostsToShow = m.hosts
}
for _, host := range hostsToShow {
// Format tags for display
var tagsStr string
if len(host.Tags) > 0 {
// Add the # prefix to each tag and join them with spaces
var formattedTags []string
for _, tag := range host.Tags {
formattedTags = append(formattedTags, "#"+tag)
}
tagsStr = strings.Join(formattedTags, " ")
}
// Format last login information
var lastLoginStr string
if m.historyManager != nil {
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
lastLoginStr = formatTimeAgo(lastConnect)
}
}
rows = append(rows, table.Row{
host.Name,
host.Hostname,
host.User,
host.Port,
tagsStr,
lastLoginStr,
})
}
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

@ -3,11 +3,9 @@ package ui
import (
"fmt"
"strings"
"time"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/connectivity"
"github.com/Gu1llaum-3/sshm/internal/history"
"sshm/internal/config"
"sshm/internal/history"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
@ -16,16 +14,7 @@ import (
)
// NewModel creates a new TUI model with the given SSH hosts
func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model {
// Load application configuration
appConfig, err := config.LoadAppConfig()
if err != nil {
// Log the error but continue with default configuration
fmt.Printf("Warning: Could not load application config: %v, using defaults\n", err)
defaultConfig := config.GetDefaultAppConfig()
appConfig = &defaultConfig
}
func NewModel(hosts []config.SSHHost, configFile string) Model {
// Initialize the history manager
historyManager, err := history.NewHistoryManager()
if err != nil {
@ -37,18 +26,12 @@ func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model {
// Create initial styles (will be updated on first WindowSizeMsg)
styles := NewStyles(80) // Default width
// Initialize ping manager with 5 second timeout
pingManager := connectivity.NewPingManager(5 * time.Second)
// Create the model with default sorting by name
m := Model{
hosts: hosts,
historyManager: historyManager,
pingManager: pingManager,
sortMode: SortByName,
configFile: configFile,
currentVersion: currentVersion,
appConfig: appConfig,
styles: styles,
width: 80,
height: 24,
@ -63,17 +46,23 @@ func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model {
ti := textinput.New()
ti.Placeholder = "Search hosts or tags..."
ti.CharLimit = 50
ti.Width = 25
ti.Width = 50
// Use dynamic column width calculation (will fallback to static if width not available)
nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(sortedHosts)
// Calculate optimal width for the Name column
nameWidth := calculateNameColumnWidth(sortedHosts)
// Calculate optimal width for the Tags column
tagsWidth := calculateTagsColumnWidth(sortedHosts)
// Calculate optimal width for the Last Login column
lastLoginWidth := calculateLastLoginColumnWidth(sortedHosts, historyManager)
// Create table columns
columns := []table.Column{
{Title: "Name", Width: nameWidth},
{Title: "Hostname", Width: hostnameWidth},
// {Title: "User", Width: 12}, // Commented to save space
// {Title: "Port", Width: 6}, // Commented to save space
{Title: "Hostname", Width: 25},
{Title: "User", Width: 12},
{Title: "Port", Width: 6},
{Title: "Tags", Width: tagsWidth},
{Title: "Last Login", Width: lastLoginWidth},
}
@ -81,9 +70,6 @@ func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model {
// Convert hosts to table rows
var rows []table.Row
for _, host := range sortedHosts {
// Get ping status indicator
statusIndicator := m.getPingStatusIndicator(host.Name)
// Format tags for display
var tagsStr string
if len(host.Tags) > 0 {
@ -104,10 +90,10 @@ func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model {
}
rows = append(rows, table.Row{
statusIndicator + " " + host.Name,
host.Name,
host.Hostname,
// host.User, // Commented to save space
// host.Port, // Commented to save space
host.User,
host.Port,
tagsStr,
lastLoginStr,
})
@ -147,8 +133,8 @@ func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model {
}
// RunInteractiveMode starts the interactive TUI interface
func RunInteractiveMode(hosts []config.SSHHost, configFile, currentVersion string) error {
m := NewModel(hosts, configFile, currentVersion)
func RunInteractiveMode(hosts []config.SSHHost, configFile string) error {
m := NewModel(hosts, configFile)
// Start the application in alt screen mode for clean output
p := tea.NewProgram(m, tea.WithAltScreen())

View File

@ -1,85 +1,21 @@
package ui
import (
"context"
"fmt"
"os/exec"
"time"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/connectivity"
"github.com/Gu1llaum-3/sshm/internal/version"
"sshm/internal/config"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
// Messages for SSH ping functionality and version checking
type (
pingResultMsg *connectivity.HostPingResult
versionCheckMsg *version.UpdateInfo
versionErrorMsg error
errorMsg string
)
// startPingAllCmd creates a command to ping all hosts concurrently
func (m Model) startPingAllCmd() tea.Cmd {
if m.pingManager == nil {
return nil
}
return tea.Batch(
// Create individual ping commands for each host
func() tea.Cmd {
var cmds []tea.Cmd
for _, host := range m.hosts {
cmds = append(cmds, pingSingleHostCmd(m.pingManager, host))
}
return tea.Batch(cmds...)
}(),
)
}
// listenForPingResultsCmd is no longer needed since we use individual ping commands
// pingSingleHostCmd creates a command to ping a single host
func pingSingleHostCmd(pingManager *connectivity.PingManager, host config.SSHHost) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
result := pingManager.PingHost(ctx, host)
return pingResultMsg(result)
}
}
// checkVersionCmd creates a command to check for version updates
func checkVersionCmd(currentVersion string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
updateInfo, err := version.CheckForUpdates(ctx, currentVersion)
if err != nil {
return versionErrorMsg(err)
}
return versionCheckMsg(updateInfo)
}
}
// Init initializes the model
func (m Model) Init() tea.Cmd {
var cmds []tea.Cmd
// Basic initialization commands
cmds = append(cmds, textinput.Blink)
// Check for version updates if we have a current version
if m.currentVersion != "" {
cmds = append(cmds, checkVersionCmd(m.currentVersion))
}
return tea.Batch(cmds...)
return tea.Batch(
textinput.Blink,
// Ajoute ici d'autres tea.Cmd si tu veux charger des données, démarrer un spinner, etc.
)
}
// Update handles model updates
@ -110,60 +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.moveForm != nil {
m.moveForm.width = m.width
m.moveForm.height = m.height
m.moveForm.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
}
if m.fileSelectorForm != nil {
m.fileSelectorForm.width = m.width
m.fileSelectorForm.height = m.height
m.fileSelectorForm.styles = m.styles
}
return m, nil
case pingResultMsg:
// Handle ping result - update table display
if msg != nil {
// Update the table to reflect the new ping status
m.updateTableRows()
}
return m, nil
case versionCheckMsg:
// Handle version check result
if msg != nil {
m.updateInfo = msg
}
return m, nil
case versionErrorMsg:
// Handle version check error (silently - not critical)
// We don't want to show error messages for version checks
// as it might disrupt the user experience
return m, nil
case errorMsg:
// Handle general error messages
if string(msg) == "clear" {
m.showingError = false
m.errorMessage = ""
}
return m, nil
case addFormSubmitMsg:
@ -254,88 +141,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.table.Focus()
return m, nil
case moveFormSubmitMsg:
if msg.err != nil {
// En cas d'erreur, on pourrait afficher une notification ou retourner à la liste
// Pour l'instant, on retourne simplement à la liste
m.viewMode = ViewList
m.moveForm = nil
m.table.Focus()
return m, nil
} else {
// Success: refresh hosts and return to list view
var hosts []config.SSHHost
var err error
if m.configFile != "" {
hosts, err = config.ParseSSHConfigFile(m.configFile)
} else {
hosts, err = config.ParseSSHConfig()
}
if err != nil {
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.moveForm = nil
m.table.Focus()
return m, nil
}
case moveFormCancelMsg:
// Cancel: return to list view
m.viewMode = ViewList
m.moveForm = nil
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 fileSelectorMsg:
if msg.cancelled {
// Cancel: return to list view
m.viewMode = ViewList
m.fileSelectorForm = nil
m.table.Focus()
return m, nil
} else {
// File selected: proceed to add form with selected file
m.addForm = NewAddForm("", m.styles, m.width, m.height, msg.selectedFile)
m.viewMode = ViewAdd
m.fileSelectorForm = nil
return m, textinput.Blink
}
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
@ -375,13 +180,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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 {
@ -394,23 +192,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case ViewEdit:
if m.editForm != nil {
var updatedModel tea.Model
updatedModel, cmd = m.editForm.Update(msg)
m.editForm = updatedModel.(*editFormModel)
return m, cmd
}
case ViewMove:
if m.moveForm != nil {
var newForm *moveFormModel
newForm, cmd = m.moveForm.Update(msg)
m.moveForm = newForm
return m, cmd
}
case ViewInfo:
if m.infoForm != nil {
var newForm *infoFormModel
newForm, cmd = m.infoForm.Update(msg)
m.infoForm = newForm
var newForm *editFormModel
newForm, cmd = m.editForm.Update(msg)
m.editForm = newForm
return m, cmd
}
case ViewPortForward:
@ -420,20 +204,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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 ViewFileSelector:
if m.fileSelectorForm != nil {
var newForm *fileSelectorModel
newForm, cmd = m.fileSelectorForm.Update(msg)
m.fileSelectorForm = newForm
return m, cmd
}
case ViewList:
// Handle list view keys
return m.handleListViewKeys(msg)
@ -445,9 +215,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
key := msg.String()
switch key {
switch msg.String() {
case "esc", "ctrl+c":
if m.deleteMode {
// Exit delete mode
@ -456,16 +225,10 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.table.Focus()
return m, nil
}
// Use configurable key bindings for quit
if m.appConfig != nil && m.appConfig.KeyBindings.ShouldQuitOnKey(key) {
return m, tea.Quit
}
return m, tea.Quit
case "q":
if !m.searchMode && !m.deleteMode {
// Use configurable key bindings for quit
if m.appConfig != nil && m.appConfig.KeyBindings.ShouldQuitOnKey(key) {
return m, tea.Quit
}
return m, tea.Quit
}
case "/", "ctrl+f":
if !m.searchMode && !m.deleteMode {
@ -474,7 +237,6 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.updateTableStyles()
m.table.Blur()
m.searchInput.Focus()
// Don't trigger filtering when entering search mode - wait for user input
return m, textinput.Blink
}
case "tab":
@ -492,7 +254,6 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.updateTableStyles()
m.table.Blur()
m.searchInput.Focus()
// Don't trigger filtering when switching to search mode
return m, textinput.Blink
}
return m, nil
@ -555,7 +316,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Connect to the selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
hostName := selected[0] // The hostname is in the first column
// Record the connection in history
if m.historyManager != nil {
@ -584,7 +345,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Edit the selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
hostName := selected[0] // The hostname is in the first column
editForm, err := NewEditForm(hostName, m.styles, m.width, m.height, m.configFile)
if err != nil {
// Handle error - could show in UI
@ -595,79 +356,11 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, textinput.Blink
}
}
case "m":
if !m.searchMode && !m.deleteMode {
// Move the selected host to another config file
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
moveForm, err := NewMoveForm(hostName, m.styles, m.width, m.height, m.configFile)
if err != nil {
// Show error message to user
m.errorMessage = err.Error()
m.showingError = true
return m, func() tea.Msg {
time.Sleep(3 * time.Second) // Show error for 3 seconds
return errorMsg("clear")
}
}
m.moveForm = moveForm
m.viewMode = ViewMove
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 := extractHostNameFromTableRow(selected[0]) // Extract hostname from 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 {
// Check if there are multiple config files starting from the current base config
var configFiles []string
var err error
if m.configFile != "" {
// Use the specified config file as base
configFiles, err = config.GetAllConfigFilesFromBase(m.configFile)
} else {
// Use the default config file as base
configFiles, err = config.GetAllConfigFiles()
}
if err != nil || len(configFiles) <= 1 {
// Only one config file (or error), go directly to add form
var configFile string
if len(configFiles) == 1 {
configFile = configFiles[0]
} else {
configFile = m.configFile
}
m.addForm = NewAddForm("", m.styles, m.width, m.height, configFile)
m.viewMode = ViewAdd
} else {
// Multiple config files, show file selector
fileSelectorForm, err := NewFileSelectorFromBase("Select config file to add host to:", m.styles, m.width, m.height, m.configFile)
if err != nil {
// Fallback to default behavior if file selector fails
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile)
m.viewMode = ViewAdd
} else {
m.fileSelectorForm = fileSelectorForm
m.viewMode = ViewFileSelector
}
}
// Add a new host
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile)
m.viewMode = ViewAdd
return m, textinput.Blink
}
case "d":
@ -675,36 +368,24 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Delete the selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
hostName := selected[0] // The hostname is in the first column
m.deleteMode = true
m.deleteHost = hostName
m.table.Blur()
return m, nil
}
}
case "p":
if !m.searchMode && !m.deleteMode {
// Ping all hosts
return m, m.startPingAllCmd()
}
case "f":
if !m.searchMode && !m.deleteMode {
// Port forwarding for the selected host
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
m.portForwardForm = NewPortForwardForm(hostName, m.styles, m.width, m.height, m.configFile, m.historyManager)
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)
@ -752,17 +433,12 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.searchInput, cmd = m.searchInput.Update(msg)
// Update filtered hosts only if the search value has changed
if m.searchInput.Value() != oldValue {
currentCursor := m.table.Cursor()
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.sortHosts(m.hosts)
}
m.updateTableRows()
// If the current cursor position is beyond the filtered results, reset to 0
if currentCursor >= len(m.filteredHosts) && len(m.filteredHosts) > 0 {
m.table.SetCursor(0)
}
}
} else {
m.table, cmd = m.table.Update(msg)

View File

@ -2,8 +2,6 @@ package ui
import (
"fmt"
"github.com/Gu1llaum-3/sshm/internal/connectivity"
"strings"
"time"
)
@ -57,49 +55,3 @@ func formatTimeAgo(t time.Time) string {
return fmt.Sprintf("%d years ago", years)
}
}
// formatConfigFile formats a config file path for display
func formatConfigFile(filePath string) string {
if filePath == "" {
return "Unknown"
}
// Show just the filename and parent directory for readability
parts := strings.Split(filePath, "/")
if len(parts) >= 2 {
return fmt.Sprintf(".../%s/%s", parts[len(parts)-2], parts[len(parts)-1])
}
return filePath
}
// getPingStatusIndicator returns a colored circle indicator based on ping status
func (m *Model) getPingStatusIndicator(hostName string) string {
if m.pingManager == nil {
return "⚫" // Gray circle for unknown
}
status := m.pingManager.GetStatus(hostName)
switch status {
case connectivity.StatusOnline:
return "🟢" // Green circle for online
case connectivity.StatusOffline:
return "🔴" // Red circle for offline
case connectivity.StatusConnecting:
return "🟡" // Yellow circle for connecting
default:
return "⚫" // Gray circle for unknown
}
}
// extractHostNameFromTableRow extracts the host name from the first column,
// removing the ping status indicator
func extractHostNameFromTableRow(firstColumn string) string {
// The first column format is: "🟢 hostname" or "⚫ hostname" etc.
// We need to remove the emoji and space to get just the hostname
parts := strings.Fields(firstColumn)
if len(parts) >= 2 {
// Return everything after the first part (the emoji)
return strings.Join(parts[1:], " ")
}
// Fallback: if there's no space, return the whole string
return firstColumn
}

View File

@ -23,26 +23,10 @@ func (m Model) View() string {
if m.editForm != nil {
return m.editForm.View()
}
case ViewMove:
if m.moveForm != nil {
return m.moveForm.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 ViewFileSelector:
if m.fileSelectorForm != nil {
return m.fileSelectorForm.View()
}
case ViewList:
return m.renderListView()
}
@ -58,34 +42,6 @@ func (m Model) renderListView() string {
// Add the ASCII title
components = append(components, m.styles.Header.Render(asciiTitle))
// Add update notification if available (between title and search)
if m.updateInfo != nil && m.updateInfo.Available {
updateText := fmt.Sprintf("🚀 Update available: %s → %s",
m.updateInfo.CurrentVer,
m.updateInfo.LatestVer)
updateStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("10")). // Green color
Bold(true).
Align(lipgloss.Center) // Center the notification
components = append(components, updateStyle.Render(updateText))
}
// Add error message if there's one to show
if m.showingError && m.errorMessage != "" {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("9")). // Red color
Background(lipgloss.Color("1")). // Dark red background
Bold(true).
Padding(0, 1).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("9")).
Align(lipgloss.Center)
components = append(components, errorStyle.Render("❌ "+m.errorMessage))
}
// Add the search bar with the appropriate style based on focus
searchPrompt := "Search (/ to focus): "
if m.searchMode {
@ -94,6 +50,10 @@ 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
@ -106,9 +66,9 @@ func (m Model) renderListView() string {
// Add the help text
var helpText string
if !m.searchMode {
helpText = " ↑/↓: navigate • Enter: connect • p: ping all • i: info • h: help • q: 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 • Enter: validate • Tab: switch • ESC: quit"
helpText = " Type to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit"
}
components = append(components, m.styles.HelpText.Render(helpText))
@ -189,30 +149,3 @@ func (m Model) renderDeleteConfirmation() string {
return box.Render(raw)
}
// renderUpdateNotification renders the update notification banner
func (m Model) renderUpdateNotification() string {
if m.updateInfo == nil || !m.updateInfo.Available {
return ""
}
// Create the notification message
message := fmt.Sprintf("🚀 Update available: %s → %s",
m.updateInfo.CurrentVer,
m.updateInfo.LatestVer)
// Add release URL if available
if m.updateInfo.ReleaseURL != "" {
message += fmt.Sprintf(" • View release: %s", m.updateInfo.ReleaseURL)
}
// Style the notification with a bright color to make it stand out
notificationStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00FF00")). // Bright green
Bold(true).
Padding(0, 1).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#00AA00")) // Darker green border
return notificationStyle.Render(message)
}

View File

@ -1,187 +0,0 @@
package validation
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestValidateHostname(t *testing.T) {
tests := []struct {
name string
hostname string
want bool
}{
{"valid hostname", "example.com", true},
{"valid IP", "192.168.1.1", true}, // IPs are valid hostnames too
{"valid subdomain", "sub.example.com", true},
{"valid single word", "localhost", true},
{"empty hostname", "", false},
{"hostname too long", strings.Repeat("a", 254), false},
{"hostname with space", "example .com", false},
{"hostname starting with dot", ".example.com", false},
{"hostname ending with dot", "example.com.", false},
{"hostname with hyphen", "my-server.com", true},
{"hostname starting with number", "1example.com", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidateHostname(tt.hostname); got != tt.want {
t.Errorf("ValidateHostname(%q) = %v, want %v", tt.hostname, got, tt.want)
}
})
}
}
func TestValidateIP(t *testing.T) {
tests := []struct {
name string
ip string
want bool
}{
{"valid IPv4", "192.168.1.1", true},
{"valid IPv6", "2001:db8::1", true},
{"invalid IP", "256.256.256.256", false},
{"empty IP", "", false},
{"hostname not IP", "example.com", false},
{"localhost", "127.0.0.1", true},
{"zero IP", "0.0.0.0", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidateIP(tt.ip); got != tt.want {
t.Errorf("ValidateIP(%q) = %v, want %v", tt.ip, got, tt.want)
}
})
}
}
func TestValidatePort(t *testing.T) {
tests := []struct {
name string
port string
want bool
}{
{"valid port 22", "22", true},
{"valid port 80", "80", true},
{"valid port 65535", "65535", true},
{"valid port 1", "1", true},
{"empty port", "", true}, // Empty defaults to 22
{"invalid port 0", "0", false},
{"invalid port 65536", "65536", false},
{"invalid port negative", "-1", false},
{"invalid port string", "abc", false},
{"invalid port with space", "22 ", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidatePort(tt.port); got != tt.want {
t.Errorf("ValidatePort(%q) = %v, want %v", tt.port, got, tt.want)
}
})
}
}
func TestValidateHostName(t *testing.T) {
tests := []struct {
name string
hostName string
want bool
}{
{"valid host name", "myserver", true},
{"valid host name with hyphen", "my-server", true},
{"valid host name with number", "server1", true},
{"empty host name", "", false},
{"host name too long", strings.Repeat("a", 51), false},
{"host name with space", "my server", false},
{"host name with tab", "my\tserver", false},
{"host name with newline", "my\nserver", false},
{"host name with hash", "my#server", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidateHostName(tt.hostName); got != tt.want {
t.Errorf("ValidateHostName(%q) = %v, want %v", tt.hostName, got, tt.want)
}
})
}
}
func TestValidateIdentityFile(t *testing.T) {
// Create a temporary file for testing
tmpDir := t.TempDir()
validFile := filepath.Join(tmpDir, "test_key")
if err := os.WriteFile(validFile, []byte("test"), 0600); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
path string
want bool
}{
{"empty path", "", true}, // Optional field
{"valid file", validFile, true},
{"non-existent file", "/path/to/nonexistent", false},
// Skip tilde path test in CI environments where ~/.ssh/id_rsa may not exist
// {"tilde path", "~/.ssh/id_rsa", true}, // Will pass if file exists
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidateIdentityFile(tt.path); got != tt.want {
t.Errorf("ValidateIdentityFile(%q) = %v, want %v", tt.path, got, tt.want)
}
})
}
// Test tilde path separately, but only if the file actually exists
t.Run("tilde path", func(t *testing.T) {
tildeFile := "~/.ssh/id_rsa"
// Just test that it doesn't crash, don't assume file exists
result := ValidateIdentityFile(tildeFile)
// Result can be true or false depending on file existence
_ = result // We just care that it doesn't panic
})
}
func TestValidateHost(t *testing.T) {
// Create a temporary file for identity testing
tmpDir := t.TempDir()
validIdentity := filepath.Join(tmpDir, "test_key")
if err := os.WriteFile(validIdentity, []byte("test"), 0600); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
hostName string
hostname string
port string
identity string
wantErr bool
}{
{"valid host", "myserver", "example.com", "22", "", false},
{"valid host with identity", "myserver", "192.168.1.1", "2222", validIdentity, false},
{"empty host name", "", "example.com", "22", "", true},
{"invalid host name", "my server", "example.com", "22", "", true},
{"empty hostname", "myserver", "", "22", "", true},
{"invalid hostname", "myserver", "invalid..hostname", "22", "", true},
{"invalid port", "myserver", "example.com", "99999", "", true},
{"invalid identity", "myserver", "example.com", "22", "/nonexistent", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateHost(tt.hostName, tt.hostname, tt.port, tt.identity)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateHost() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View File

@ -1,145 +0,0 @@
package version
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
// GitHubRelease represents a GitHub release response
type GitHubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
HTMLURL string `json:"html_url"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
}
// UpdateInfo contains information about available updates
type UpdateInfo struct {
Available bool
CurrentVer string
LatestVer string
ReleaseURL string
ReleaseName string
}
// parseVersion extracts version numbers from a version string (e.g., "v1.2.3" -> [1, 2, 3])
func parseVersion(version string) []int {
// Remove 'v' prefix if present
version = strings.TrimPrefix(version, "v")
parts := strings.Split(version, ".")
nums := make([]int, len(parts))
for i, part := range parts {
// Remove any non-numeric suffixes (e.g., "1-beta", "2-rc1")
numPart := strings.FieldsFunc(part, func(r rune) bool {
return r == '-' || r == '+' || r == '_'
})[0]
if num, err := strconv.Atoi(numPart); err == nil {
nums[i] = num
}
}
return nums
}
// compareVersions compares two version strings
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
func compareVersions(v1, v2 string) int {
nums1 := parseVersion(v1)
nums2 := parseVersion(v2)
// Pad with zeros to make lengths equal
maxLen := len(nums1)
if len(nums2) > maxLen {
maxLen = len(nums2)
}
for len(nums1) < maxLen {
nums1 = append(nums1, 0)
}
for len(nums2) < maxLen {
nums2 = append(nums2, 0)
}
// Compare each part
for i := 0; i < maxLen; i++ {
if nums1[i] < nums2[i] {
return -1
}
if nums1[i] > nums2[i] {
return 1
}
}
return 0
}
// CheckForUpdates checks GitHub for the latest release of sshm
func CheckForUpdates(ctx context.Context, currentVersion string) (*UpdateInfo, error) {
// Skip version check if current version is "dev"
if currentVersion == "dev" {
return &UpdateInfo{
Available: false,
CurrentVer: currentVersion,
}, nil
}
// Create HTTP client with timeout
client := &http.Client{
Timeout: 10 * time.Second,
}
// Create request with context
req, err := http.NewRequestWithContext(ctx, "GET",
"https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest", nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Set user agent
req.Header.Set("User-Agent", "sshm/"+currentVersion)
// Make the request
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch latest release: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
}
// Parse the response
var release GitHubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Skip pre-releases and drafts
if release.Prerelease || release.Draft {
return &UpdateInfo{
Available: false,
CurrentVer: currentVersion,
}, nil
}
// Compare versions
updateAvailable := compareVersions(currentVersion, release.TagName) < 0
return &UpdateInfo{
Available: updateAvailable,
CurrentVer: currentVersion,
LatestVer: release.TagName,
ReleaseURL: release.HTMLURL,
ReleaseName: release.Name,
}, nil
}

View File

@ -1,56 +0,0 @@
package version
import (
"testing"
)
func TestParseVersion(t *testing.T) {
tests := []struct {
version string
expected []int
}{
{"v1.2.3", []int{1, 2, 3}},
{"1.2.3", []int{1, 2, 3}},
{"v2.0.0", []int{2, 0, 0}},
{"1.2.3-beta", []int{1, 2, 3}},
{"1.2.3-rc1", []int{1, 2, 3}},
{"dev", []int{0}},
}
for _, test := range tests {
result := parseVersion(test.version)
if len(result) != len(test.expected) {
t.Errorf("parseVersion(%q) length = %d, want %d", test.version, len(result), len(test.expected))
continue
}
for i, v := range result {
if v != test.expected[i] {
t.Errorf("parseVersion(%q)[%d] = %d, want %d", test.version, i, v, test.expected[i])
break
}
}
}
}
func TestCompareVersions(t *testing.T) {
tests := []struct {
v1 string
v2 string
expected int
}{
{"v1.0.0", "v1.0.1", -1},
{"v1.0.1", "v1.0.0", 1},
{"v1.0.0", "v1.0.0", 0},
{"1.2.3", "1.2.4", -1},
{"2.0.0", "1.9.9", 1},
{"1.2.3-beta", "1.2.3", 0}, // Should ignore suffixes
{"1.2.3", "1.2.3-rc1", 0},
}
for _, test := range tests {
result := compareVersions(test.v1, test.v2)
if result != test.expected {
t.Errorf("compareVersions(%q, %q) = %d, want %d", test.v1, test.v2, result, test.expected)
}
}
}

View File

@ -1,6 +1,6 @@
package main
import "github.com/Gu1llaum-3/sshm/cmd"
import "sshm/cmd"
func main() {
cmd.Execute()