Compare commits

...

30 Commits
1.5.2 ... main

Author SHA1 Message Date
825c534ebe feat(ui): add tabbed forms with height validation
- Implement General/Advanced tabs for add/edit forms
- Add terminal height detection with user-friendly warnings
- Add Ctrl+J/K tab navigation and SSH RemoteCommand/RequestTTY fields
2025-10-13 21:55:08 +02:00
c1457af73a feat: add support for SSH RemoteCommand and RequestTTY in host configuration and TUI forms
- Allow users to specify a RemoteCommand to execute on SSH connection, both via TUI and config file
- Add RequestTTY option (yes, no, force, auto) to host configuration and forms
- Update config parsing and writing to handle new fields
- Improve TUI forms to support editing and adding these options
- Fix edit form standalone mode to allow proper quit/save via keyboard shortcuts
2025-10-12 20:25:20 +02:00
12d97270f0 feat: reorganize release notes 2025-10-10 22:43:06 +02:00
6ba82b1c97 feat: filter non-SSH files from config parsing
- Skip README, .git, and documentation files during SSH config parsing
- Add QuickHostExists for fast host validation without full parsing
- Prevent errors when Include * encounters non-config files
2025-10-10 21:47:13 +02:00
42e87b6827 feat: add ARM v6/v7 support and version selection to install script
- Support ARM v6/v7 architectures for Raspberry Pi
- Add SSHM_VERSION env var for specific version installation
- Add beta/pre-release version support
- Add version validation and --help flag
- Fix architecture mapping for GoReleaser binaries
2025-10-10 21:22:56 +02:00
d686d97f8c fix: SSH identity file paths with spaces and edit form navigation
- Quote IdentityFile paths containing spaces to prevent SSH config errors
- Fix edit form ESC/Ctrl+C to return to main view instead of quitting
- Improve edit form navigation consistency with add form
- Fix focus management when adding host fields with Ctrl+A
2025-10-09 22:04:36 +02:00
8d5f59fab2 feat: add multi-host block support for SSH config management
- Support "Host server1 server2 server3" syntax in SSH configurations
- Add multi-host editing UI with separate host name inputs
- Implement multi-host block update and deletion operations
- Add comprehensive test coverage
- Maintain backward compatibility with single-host configs
2025-10-09 20:46:10 +02:00
049998c235 docs: update README.md and remove CONFIG.md 2025-10-04 17:02:21 +02:00
Guillaume Archambault
5986659048
Merge pull request #11 from qingfengzxr/main
feat: add configurable key bindings with ESC quit disable option
2025-10-04 17:00:56 +02:00
Guillaume Archambault
abbda54125
Merge pull request #12 from ldreux/support-subsearch
feat: support multiple words search
2025-10-04 16:24:06 +02:00
Loïc Dreux
986017a552 feat: support multiple words search 2025-10-01 12:07:05 +02:00
zxr
120cd6c009 feat: add configurable key bindings with ESC quit disable option
- Add unified application configuration system with JSON config file
- Implement configurable quit keys (default: "q", "ctrl+c")
- Add disable_esc_quit option for vim users to prevent accidental exits
- Auto-create default config file at ~/.config/sshm/config.json
- Maintain backward compatibility (ESC quit enabled by default)
- Include comprehensive tests and documentation
2025-09-29 11:05:26 +08:00
3d746ec49a doc: update installation instructions for Homebrew 2025-09-17 15:24:17 +02:00
f31fe9dacf fix: update Windows install script for GoReleaser format
- Change from sshm-windows-amd64.zip to sshm_Windows_x86_64.zip
- Update architecture mapping (amd64 -> x86_64, 386 -> i386)
- Fix extracted binary name (now just 'sshm.exe')
- Update migration documentation
2025-09-17 14:44:50 +02:00
7b15db1f34 fix: update install script for GoReleaser binary format
- Change from sshm-darwin-arm64.tar.gz to sshm_Darwin_arm64.tar.gz
- Update architecture mapping (amd64 -> x86_64)
- Update OS mapping (darwin -> Darwin)
- Fix extracted binary name (now just 'sshm' instead of platform suffix)
2025-09-17 14:32:31 +02:00
55f3359287 fix: remove discussion_category_name (discussions not enabled) 2025-09-17 14:27:39 +02:00
4efec57a8a fix: make ValidateIdentityFile test robust for CI environments
- Remove assumption that ~/.ssh/id_rsa always exists
- Test tilde path expansion without asserting file existence
2025-09-17 14:20:08 +02:00
0975ae2fe2 git commit -m "feat: add GoReleaser for automated releases and Homebrew integration
- Replace manual GitHub Actions workflow
- Add automated Formula updates to homebrew-sshm tap
- Support pre-releases with auto-detection
- Standardize cross-platform binary distribution"
2025-09-17 14:14:49 +02:00
ed6ea2939a fix(ci): correct version injection in build workflow
- Change -X flag from cmd.version to cmd.AppVersion
- This fixes version display showing 'dev' instead of actual version
- Binaries will now display correct version when built via GitHub Actions
2025-09-13 12:20:41 +02:00
45eccabc23 fix: replace Ctrl+Enter with Ctrl+S in forms to avoid terminal conflicts 2025-09-13 11:53:45 +02:00
2425695992 docs: update README 2025-09-13 11:53:45 +02:00
306f38e862 feat: show error when move requires includes but none found 2025-09-13 11:53:45 +02:00
3c627a5d21 feat: add port forwarding history persistence 2025-09-13 11:53:45 +02:00
71bf8ea2bb feat: add direct host connection via sshm <host> with history tracking 2025-09-13 11:53:45 +02:00
8c6f3b01ef feat: centralize history storage in config directory
Automatically migrates existing ~/.ssh/sshm_history.json to platform-appropriate config location
2025-09-13 11:53:45 +02:00
aa6be1d92d fix(cmd): export variables for test accessibility
Export rootCmd->RootCmd and appVersion->AppVersion to fix test compilation errors. Update all references across cmd package and tests.
2025-09-13 11:53:45 +02:00
9bb44da18b build: strip 'v' prefix from version tag for binary
- Remove the 'v' prefix from the Git tag before injecting the version into the built binary
- Ensures the version string in the CLI does not include a leading 'v' (e.g. '1.2.3' instead of 'v1.2.3')
2025-09-13 11:53:45 +02:00
77b2b8fd22 feat: add move command to relocate SSH hosts between config files
- Add 'move' command with interactive file selector
- Implement atomic host moving between SSH config files
- Support for configs with include directives
- Add comprehensive error handling and validation
- Update help screen with improved two-column layout
2025-09-13 11:53:45 +02:00
5c832ce26f feat: add automatic version update checking and notifications
- Add internal/version module for GitHub release checking
- Integrate async version check in Bubble Tea UI
- Display update notification in main interface
- Add version check to --version/-v command output
- Include comprehensive version comparison and error handling
- Add unit tests for version parsing and comparison logic
2025-09-13 11:53:45 +02:00
ef075e74cf test: add comprehensive test suite and fix failing tests
- Fix history tests with proper test isolation using temp files
- Fix CMD tests with proper string contains and simplified assertions
- Add missing test utilities and helper functions
- Improve test coverage across all packages
- Remove flaky tests and replace with robust alternatives"
2025-09-10 08:15:46 +02:00
39 changed files with 5821 additions and 718 deletions

View File

@ -1,134 +0,0 @@
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 github.com/Gu1llaum-3/sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }}.exe .
else
go build -ldflags="-s -w -X github.com/Gu1llaum-3/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: Check if pre-release
id: check_prerelease
run: |
if [[ "${GITHUB_REF#refs/tags/}" == *"-beta"* ]] || [[ "${GITHUB_REF#refs/tags/}" == *"-alpha"* ]] || [[ "${GITHUB_REF#refs/tags/}" == *"-rc"* ]]; then
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: ./release/*
draft: false
prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }}
generate_release_notes: true
name: ${{ github.ref_name }}${{ steps.check_prerelease.outputs.is_prerelease == 'true' && ' (Pre-release)' || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

39
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,39 @@
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 }}

155
.goreleaser.yaml Normal file
View File

@ -0,0 +1,155 @@
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 }}"

44
Makefile Normal file
View File

@ -0,0 +1,44 @@
.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

222
README.md
View File

@ -25,40 +25,39 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
## ✨ Features
### 🎯 **Core Features**
### 🚀 **Core Capabilities**
- **🎨 Beautiful TUI Interface** - Navigate your SSH hosts with an elegant, interactive terminal UI
- **⚡ 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
- **⚡ 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
- **🏷️ 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
### 🛠️ **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
- **🔄 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
## 🚀 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
@ -105,10 +104,17 @@ 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)
@ -158,6 +164,7 @@ 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:**
@ -218,9 +225,15 @@ 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
@ -236,13 +249,42 @@ sshm edit my-server
# Edit host with custom SSH config file
sshm edit my-server -c /path/to/custom/ssh_config
# Show version information
# 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)
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.
@ -257,6 +299,10 @@ SSHM automatically creates backups of your SSH configuration files before making
- 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
@ -277,8 +323,96 @@ 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:**
@ -419,6 +553,34 @@ 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
@ -449,20 +611,29 @@ 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
│ │ ├── 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
│ ├── 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
│ │ ├── table.go # Host list table component with status indicators
│ │ ├── 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
@ -490,6 +661,7 @@ 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
@ -525,6 +697,8 @@ 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,6 +2,7 @@ package cmd
import (
"fmt"
"github.com/Gu1llaum-3/sshm/internal/ui"
"github.com/spf13/cobra"
@ -26,5 +27,5 @@ var addCmd = &cobra.Command{
}
func init() {
rootCmd.AddCommand(addCmd)
RootCmd.AddCommand(addCmd)
}

88
cmd/add_test.go Normal file
View File

@ -0,0 +1,88 @@
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,6 +2,7 @@ package cmd
import (
"fmt"
"github.com/Gu1llaum-3/sshm/internal/ui"
"github.com/spf13/cobra"
@ -23,5 +24,5 @@ var editCmd = &cobra.Command{
}
func init() {
rootCmd.AddCommand(editCmd)
RootCmd.AddCommand(editCmd)
}

70
cmd/edit_test.go Normal file
View File

@ -0,0 +1,70 @@
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")
}
}

28
cmd/move.go Normal file
View File

@ -0,0 +1,28 @@
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,45 +1,57 @@
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"
"github.com/spf13/cobra"
)
// version will be set at build time via -ldflags
var version = "dev"
// AppVersion will be set at build time via -ldflags
var AppVersion = "dev"
// configFile holds the path to the SSH config file
var configFile string
var rootCmd = &cobra.Command{
Use: "sshm",
// RootCmd is the base command when called without any subcommands
var RootCmd = &cobra.Command{
Use: "sshm [host]",
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 direct operations.
You can also use sshm in CLI mode for other operations like adding, editing, or searching hosts.
Hosts are read from your ~/.ssh/config file by default.`,
Version: version,
Run: func(cmd *cobra.Command, args []string) {
Version: AppVersion,
Args: cobra.ArbitraryArgs,
SilenceUsage: true,
SilenceErrors: true, // We'll handle errors ourselves
RunE: func(cmd *cobra.Command, args []string) error {
// If no arguments provided, run interactive mode
if len(args) == 0 {
runInteractiveMode()
return
return nil
}
// If a host name is provided, connect directly
hostName := args[0]
connectToHost(hostName)
return nil
},
}
@ -85,33 +97,24 @@ func runInteractiveMode() {
}
// Run the interactive TUI
if err := ui.RunInteractiveMode(hosts, configFile); err != nil {
if err := ui.RunInteractiveMode(hosts, configFile, AppVersion); err != nil {
log.Fatalf("Error running interactive mode: %v", err)
}
}
func connectToHost(hostName string) {
// Parse SSH configurations to verify host exists
var hosts []config.SSHHost
// Quick check if host exists without full parsing (optimized for connection)
var hostFound bool
var err error
if configFile != "" {
hosts, err = config.ParseSSHConfigFile(configFile)
hostFound, err = config.QuickHostExistsInFile(hostName, configFile)
} else {
hosts, err = config.ParseSSHConfig()
hostFound, err = config.QuickHostExists(hostName)
}
if err != nil {
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
}
log.Fatalf("Error checking SSH config: %v", err)
}
if !hostFound {
@ -120,25 +123,94 @@ func connectToHost(hostName string) {
os.Exit(1)
}
// Connect to the host
fmt.Printf("Connecting to %s...\n", hostName)
// Build the SSH command with the appropriate config file
var sshCmd []string
if configFile != "" {
sshCmd = []string{"ssh", "-F", configFile, hostName}
// 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 {
sshCmd = []string{"ssh", hostName}
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)
}
}
// 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, " "))
// Build and execute the SSH command
fmt.Printf("Connecting to %s...\n", hostName)
var sshCmd *exec.Cmd
var args []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"
}
if updateInfo != nil && updateInfo.Available {
versionText += fmt.Sprintf("\n🚀 Update available: %s → %s (%s)",
updateInfo.CurrentVer,
updateInfo.LatestVer,
updateInfo.ReleaseURL)
}
return versionText + "\n"
}
// Execute adds all child commands to the root command and sets flags appropriately.
func Execute() {
if err := rootCmd.Execute(); err != nil {
// 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
}
}
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
@ -146,5 +218,8 @@ func Execute() {
func init() {
// Add the config file flag
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)")
RootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)")
// Set custom version template with update check
RootCmd.SetVersionTemplate(getVersionWithUpdateCheck())
}

145
cmd/root_test.go Normal file
View File

@ -0,0 +1,145 @@
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

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

120
cmd/search_test.go Normal file
View File

@ -0,0 +1,120 @@
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
}

View File

@ -7,6 +7,7 @@ USE_SUDO="false"
OS=""
ARCH=""
FORCE_INSTALL="${FORCE_INSTALL:-false}"
SSHM_VERSION="${SSHM_VERSION:-latest}"
RED='\033[0;31m'
PURPLE='\033[0;35m'
@ -14,13 +15,27 @@ 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="arm64" ;;
armv7*) ARCH="arm64" ;;
armv6*) ARCH="armv6" ;;
armv7*) ARCH="armv7" ;;
aarch64*) ARCH="arm64" ;;
arm64) ARCH="arm64" ;;
esac
@ -46,17 +61,48 @@ runAsRoot() {
}
getLatestVersion() {
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
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"
fi
printf "${GREEN}Latest version: $LATEST_VERSION${NC}\n"
printf "${GREEN}Installing version: $LATEST_VERSION${NC}\n"
}
downloadBinary() {
GITHUB_FILE="sshm-${OS}-${ARCH}.tar.gz"
# 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_URL="https://github.com/Gu1llaum-3/sshm/releases/download/$LATEST_VERSION/$GITHUB_FILE"
printf "${YELLOW}Downloading $GITHUB_FILE...${NC}\n"
@ -74,8 +120,8 @@ downloadBinary() {
exit 1
fi
# Check if the expected binary exists (no find needed)
EXTRACTED_BINARY="./sshm-${OS}-${ARCH}"
# 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"
exit 1
@ -158,18 +204,24 @@ checkExisting() {
}
main() {
printf "${PURPLE}Installing SSHM - SSH Connection Manager${NC}\n\n"
# Check for help argument
if [ "$1" = "-h" ] || [ "$1" = "--help" ] || [ "$1" = "help" ]; then
usage
exit 0
fi
# Check if already installed
checkExisting
printf "${PURPLE}Installing SSHM - SSH Connection Manager${NC}\n\n"
# Set up system detection
setSystem
printf "${GREEN}Detected system: $OS ($ARCH)${NC}\n"
# Get latest version
# Get and validate version FIRST (this can fail early)
getLatestVersion
# Check if already installed (this might prompt user)
checkExisting
# Download and install
downloadBinary
install

View File

@ -80,7 +80,11 @@ if ($LocalBinary -ne "") {
}
# Download binary
$fileName = "sshm-windows-$arch.zip"
# 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"
$downloadUrl = "https://github.com/Gu1llaum-3/sshm/releases/download/$latestVersion/$fileName"
$tempFile = "$env:TEMP\$fileName"
@ -101,7 +105,8 @@ if ($LocalBinary -ne "") {
Write-Info "Extracting..."
try {
Expand-Archive -Path $tempFile -DestinationPath $env:TEMP -Force
$extractedBinary = "$env:TEMP\sshm-windows-$arch.exe"
# GoReleaser extracts the binary as just "sshm.exe", not with platform suffix
$extractedBinary = "$env:TEMP\sshm.exe"
$targetPath = "$InstallDir\sshm.exe"
Move-Item -Path $extractedBinary -Destination $targetPath -Force

View File

@ -0,0 +1,146 @@
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

@ -0,0 +1,181 @@
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

View File

@ -813,3 +813,884 @@ Include subdir/*.conf
t.Errorf("GetAllConfigFilesFromBase('') should behave like GetAllConfigFiles(). Got %d vs %d files", len(defaultFiles), len(allFiles))
}
}
func TestHostExistsInSpecificFile(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create main config file
mainConfig := filepath.Join(tempDir, "config")
mainConfigContent := `Host main-host
HostName example.com
User mainuser
Include included.conf
Host another-host
HostName another.example.com
User anotheruser
`
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create main config: %v", err)
}
// Create included config file
includedConfig := filepath.Join(tempDir, "included.conf")
includedConfigContent := `Host included-host
HostName included.example.com
User includeduser
`
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create included config: %v", err)
}
// Test that host exists in main config file (should ignore includes)
exists, err := HostExistsInSpecificFile("main-host", mainConfig)
if err != nil {
t.Fatalf("HostExistsInSpecificFile() error = %v", err)
}
if !exists {
t.Error("main-host should exist in main config file")
}
// Test that host from included file does NOT exist in main config file
exists, err = HostExistsInSpecificFile("included-host", mainConfig)
if err != nil {
t.Fatalf("HostExistsInSpecificFile() error = %v", err)
}
if exists {
t.Error("included-host should NOT exist in main config file (should ignore includes)")
}
// Test that host exists in included config file
exists, err = HostExistsInSpecificFile("included-host", includedConfig)
if err != nil {
t.Fatalf("HostExistsInSpecificFile() error = %v", err)
}
if !exists {
t.Error("included-host should exist in included config file")
}
// Test non-existent host
exists, err = HostExistsInSpecificFile("non-existent", mainConfig)
if err != nil {
t.Fatalf("HostExistsInSpecificFile() error = %v", err)
}
if exists {
t.Error("non-existent host should not exist")
}
// Test with non-existent file
exists, err = HostExistsInSpecificFile("any-host", "/non/existent/file")
if err != nil {
t.Fatalf("HostExistsInSpecificFile() should not return error for non-existent file: %v", err)
}
if exists {
t.Error("non-existent file should not contain any hosts")
}
}
func TestGetConfigFilesExcludingCurrent(t *testing.T) {
// This test verifies the function works when SSH config is properly set up
// Since GetConfigFilesExcludingCurrent depends on FindHostInAllConfigs which uses the default SSH config,
// we'll test the function more directly by creating a temporary SSH config setup
// Skip this test if we can't access SSH config directory
_, err := GetSSHDirectory()
if err != nil {
t.Skipf("Skipping test: cannot get SSH directory: %v", err)
}
// Check if SSH config exists
defaultConfigPath, err := GetDefaultSSHConfigPath()
if err != nil {
t.Skipf("Skipping test: cannot get default SSH config path: %v", err)
}
if _, err := os.Stat(defaultConfigPath); os.IsNotExist(err) {
t.Skipf("Skipping test: SSH config file does not exist at %s", defaultConfigPath)
}
// Test that the function returns something for a hypothetical host
// We can't guarantee specific hosts exist, so we test the function doesn't crash
_, err = GetConfigFilesExcludingCurrent("test-host-that-probably-does-not-exist", defaultConfigPath)
if err == nil {
t.Log("GetConfigFilesExcludingCurrent() succeeded for non-existent host (expected)")
} else if strings.Contains(err.Error(), "not found") {
t.Log("GetConfigFilesExcludingCurrent() correctly reported host not found")
} else {
t.Fatalf("GetConfigFilesExcludingCurrent() unexpected error = %v", err)
}
// Test with valid SSH config directory
if err == nil {
t.Log("GetConfigFilesExcludingCurrent() function is working correctly")
}
}
func TestMoveHostToFile(t *testing.T) {
// This test verifies the MoveHostToFile function works when SSH config is properly set up
// Since MoveHostToFile depends on FindHostInAllConfigs which uses the default SSH config,
// we'll test the error handling and basic function behavior
// Check if SSH config exists
defaultConfigPath, err := GetDefaultSSHConfigPath()
if err != nil {
t.Skipf("Skipping test: cannot get default SSH config path: %v", err)
}
if _, err := os.Stat(defaultConfigPath); os.IsNotExist(err) {
t.Skipf("Skipping test: SSH config file does not exist at %s", defaultConfigPath)
}
// Create a temporary destination config file
tempDir := t.TempDir()
destConfig := filepath.Join(tempDir, "dest.conf")
destConfigContent := `Host dest-host
HostName dest.example.com
User destuser
`
err = os.WriteFile(destConfig, []byte(destConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create dest config: %v", err)
}
// Test moving non-existent host (should return error)
err = MoveHostToFile("non-existent-host-12345", destConfig)
if err == nil {
t.Error("MoveHostToFile() should return error for non-existent host")
} else if !strings.Contains(err.Error(), "not found") {
t.Errorf("Expected 'not found' error, got: %v", err)
}
// Test moving to non-existent file (should return error)
err = MoveHostToFile("any-host", "/non/existent/file")
if err == nil {
t.Error("MoveHostToFile() should return error for non-existent destination file")
}
// Verify that the HostExistsInSpecificFile function works correctly
// This is a component that MoveHostToFile uses
exists, err := HostExistsInSpecificFile("dest-host", destConfig)
if err != nil {
t.Fatalf("HostExistsInSpecificFile() error = %v", err)
}
if !exists {
t.Error("dest-host should exist in destination config file")
}
// Test that the component functions work for the move operation
t.Log("MoveHostToFile() error handling works correctly")
}
func TestParseSSHConfigWithMultipleHostsOnSameLine(t *testing.T) {
tempDir := t.TempDir()
configFile := filepath.Join(tempDir, "config")
configContent := `# Test multiple hosts on same line
Host local1 local2
HostName ::1
User myuser
Host root-server
User root
HostName root.example.com
Host web1 web2 web3
HostName ::1
User webuser
Port 8080
Host single-host
HostName single.example.com
User singleuser
`
err := os.WriteFile(configFile, []byte(configContent), 0600)
if err != nil {
t.Fatalf("Failed to create config: %v", err)
}
hosts, err := ParseSSHConfigFile(configFile)
if err != nil {
t.Fatalf("ParseSSHConfigFile() error = %v", err)
}
// Should get 7 hosts: local1, local2, root-server, web1, web2, web3, single-host
expectedHosts := map[string]struct{}{
"local1": {},
"local2": {},
"root-server": {},
"web1": {},
"web2": {},
"web3": {},
"single-host": {},
}
if len(hosts) != len(expectedHosts) {
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
for _, host := range hosts {
t.Logf("Found host: %s", host.Name)
}
}
hostMap := make(map[string]SSHHost)
for _, host := range hosts {
hostMap[host.Name] = host
}
for expectedHostName := range expectedHosts {
if _, found := hostMap[expectedHostName]; !found {
t.Errorf("Expected host %s not found", expectedHostName)
}
}
// Verify properties based on host name
if host, found := hostMap["local1"]; found {
if host.Hostname != "::1" || host.User != "myuser" {
t.Errorf("local1 properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
}
}
if host, found := hostMap["local2"]; found {
if host.Hostname != "::1" || host.User != "myuser" {
t.Errorf("local2 properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
}
}
if host, found := hostMap["web1"]; found {
if host.Hostname != "::1" || host.User != "webuser" || host.Port != "8080" {
t.Errorf("web1 properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port)
}
}
if host, found := hostMap["web2"]; found {
if host.Hostname != "::1" || host.User != "webuser" || host.Port != "8080" {
t.Errorf("web2 properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port)
}
}
if host, found := hostMap["web3"]; found {
if host.Hostname != "::1" || host.User != "webuser" || host.Port != "8080" {
t.Errorf("web3 properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port)
}
}
if host, found := hostMap["root-server"]; found {
if host.User != "root" || host.Hostname != "root.example.com" {
t.Errorf("root-server properties incorrect: user=%s, hostname=%s", host.User, host.Hostname)
}
}
}
func TestUpdateSSHHostInFileWithMultiHost(t *testing.T) {
tempDir := t.TempDir()
configFile := filepath.Join(tempDir, "config")
configContent := `# Test config with multi-host
Host web1 web2 web3
HostName webserver.example.com
User webuser
Port 2222
Host database
HostName db.example.com
User dbuser
`
err := os.WriteFile(configFile, []byte(configContent), 0600)
if err != nil {
t.Fatalf("Failed to create config: %v", err)
}
// Update web2 in the multi-host line
newHost := SSHHost{
Name: "web2-updated",
Hostname: "newweb.example.com",
User: "newuser",
Port: "22",
}
err = UpdateSSHHostInFile("web2", newHost, configFile)
if err != nil {
t.Fatalf("UpdateSSHHostInFile() error = %v", err)
}
// Parse the updated config
hosts, err := ParseSSHConfigFile(configFile)
if err != nil {
t.Fatalf("ParseSSHConfigFile() error = %v", err)
}
// Should have: web1, web3, web2-updated, database
expectedHosts := []string{"web1", "web3", "web2-updated", "database"}
hostMap := make(map[string]SSHHost)
for _, host := range hosts {
hostMap[host.Name] = host
}
if len(hosts) != len(expectedHosts) {
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
for _, host := range hosts {
t.Logf("Found host: %s", host.Name)
}
}
for _, expectedHostName := range expectedHosts {
if _, found := hostMap[expectedHostName]; !found {
t.Errorf("Expected host %s not found", expectedHostName)
}
}
// Verify web1 and web3 still have original properties
if host, found := hostMap["web1"]; found {
if host.Hostname != "webserver.example.com" || host.User != "webuser" || host.Port != "2222" {
t.Errorf("web1 properties changed: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port)
}
}
if host, found := hostMap["web3"]; found {
if host.Hostname != "webserver.example.com" || host.User != "webuser" || host.Port != "2222" {
t.Errorf("web3 properties changed: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port)
}
}
// Verify web2-updated has new properties
if host, found := hostMap["web2-updated"]; found {
if host.Hostname != "newweb.example.com" || host.User != "newuser" || host.Port != "22" {
t.Errorf("web2-updated properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port)
}
}
// Verify database is unchanged
if host, found := hostMap["database"]; found {
if host.Hostname != "db.example.com" || host.User != "dbuser" {
t.Errorf("database properties changed: hostname=%s, user=%s", host.Hostname, host.User)
}
}
}
func TestIsPartOfMultiHostDeclaration(t *testing.T) {
tempDir := t.TempDir()
configFile := filepath.Join(tempDir, "config")
configContent := `Host single
HostName single.example.com
Host multi1 multi2 multi3
HostName multi.example.com
Host another
HostName another.example.com
`
err := os.WriteFile(configFile, []byte(configContent), 0600)
if err != nil {
t.Fatalf("Failed to create config: %v", err)
}
tests := []struct {
hostName string
expectedMulti bool
expectedHosts []string
}{
{"single", false, []string{"single"}},
{"multi1", true, []string{"multi1", "multi2", "multi3"}},
{"multi2", true, []string{"multi1", "multi2", "multi3"}},
{"multi3", true, []string{"multi1", "multi2", "multi3"}},
{"another", false, []string{"another"}},
{"nonexistent", false, nil},
}
for _, tt := range tests {
t.Run(tt.hostName, func(t *testing.T) {
isMulti, hostNames, err := IsPartOfMultiHostDeclaration(tt.hostName, configFile)
if err != nil {
t.Fatalf("IsPartOfMultiHostDeclaration() error = %v", err)
}
if isMulti != tt.expectedMulti {
t.Errorf("Expected isMulti=%v, got %v", tt.expectedMulti, isMulti)
}
if tt.expectedHosts == nil && hostNames != nil {
t.Errorf("Expected hostNames to be nil, got %v", hostNames)
} else if tt.expectedHosts != nil {
if len(hostNames) != len(tt.expectedHosts) {
t.Errorf("Expected %d hostNames, got %d", len(tt.expectedHosts), len(hostNames))
} else {
for i, expectedHost := range tt.expectedHosts {
if i < len(hostNames) && hostNames[i] != expectedHost {
t.Errorf("Expected hostNames[%d]=%s, got %s", i, expectedHost, hostNames[i])
}
}
}
}
})
}
}
func TestDeleteSSHHostFromFileWithMultiHost(t *testing.T) {
tempDir := t.TempDir()
configFile := filepath.Join(tempDir, "config")
configContent := `# Test config with multi-host deletion
Host web1 web2 web3
HostName webserver.example.com
User webuser
Port 2222
Host database
HostName db.example.com
User dbuser
# Tags: production, critical
Host app1 app2
HostName appserver.example.com
User appuser
`
err := os.WriteFile(configFile, []byte(configContent), 0600)
if err != nil {
t.Fatalf("Failed to create config: %v", err)
}
// Test 1: Delete one host from multi-host block (should keep others)
err = DeleteSSHHostFromFile("web2", configFile)
if err != nil {
t.Fatalf("DeleteSSHHostFromFile() error = %v", err)
}
// Parse the updated config
hosts, err := ParseSSHConfigFile(configFile)
if err != nil {
t.Fatalf("ParseSSHConfigFile() error = %v", err)
}
// Should have: web1, web3, database, app1, app2 (web2 removed)
expectedHosts := []string{"web1", "web3", "database", "app1", "app2"}
hostMap := make(map[string]SSHHost)
for _, host := range hosts {
hostMap[host.Name] = host
}
if len(hosts) != len(expectedHosts) {
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
for _, host := range hosts {
t.Logf("Found host: %s", host.Name)
}
}
for _, expectedHostName := range expectedHosts {
if _, found := hostMap[expectedHostName]; !found {
t.Errorf("Expected host %s not found", expectedHostName)
}
}
// Verify web2 is not present
if _, found := hostMap["web2"]; found {
t.Error("web2 should have been deleted")
}
// Verify web1 and web3 still have original properties
if host, found := hostMap["web1"]; found {
if host.Hostname != "webserver.example.com" || host.User != "webuser" || host.Port != "2222" {
t.Errorf("web1 properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port)
}
}
if host, found := hostMap["web3"]; found {
if host.Hostname != "webserver.example.com" || host.User != "webuser" || host.Port != "2222" {
t.Errorf("web3 properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port)
}
}
// Test 2: Delete one host from multi-host block with tags
err = DeleteSSHHostFromFile("app1", configFile)
if err != nil {
t.Fatalf("DeleteSSHHostFromFile() error = %v", err)
}
// Parse again
hosts, err = ParseSSHConfigFile(configFile)
if err != nil {
t.Fatalf("ParseSSHConfigFile() error = %v", err)
}
// Should have: web1, web3, database, app2 (app1 removed)
expectedHosts = []string{"web1", "web3", "database", "app2"}
hostMap = make(map[string]SSHHost)
for _, host := range hosts {
hostMap[host.Name] = host
}
if len(hosts) != len(expectedHosts) {
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
for _, host := range hosts {
t.Logf("Found host: %s", host.Name)
}
}
// Verify app2 still has tags
if host, found := hostMap["app2"]; found {
if !contains(host.Tags, "production") || !contains(host.Tags, "critical") {
t.Errorf("app2 tags incorrect: %v", host.Tags)
}
}
}
func TestUpdateMultiHostBlock(t *testing.T) {
tempDir := t.TempDir()
configFile := filepath.Join(tempDir, "config")
configContent := `# Test config for multi-host block update
Host server1 server2 server3
HostName cluster.example.com
User clusteruser
Port 2222
Host single
HostName single.example.com
User singleuser
`
err := os.WriteFile(configFile, []byte(configContent), 0600)
if err != nil {
t.Fatalf("Failed to create config: %v", err)
}
// Update the multi-host block
originalHosts := []string{"server1", "server2", "server3"}
newHosts := []string{"server1", "server4", "server5"} // Remove server2, server3 and add server4, server5
commonProperties := SSHHost{
Hostname: "newcluster.example.com",
User: "newuser",
Port: "22",
Tags: []string{"updated", "cluster"},
}
err = UpdateMultiHostBlock(originalHosts, newHosts, commonProperties, configFile)
if err != nil {
t.Fatalf("UpdateMultiHostBlock() error = %v", err)
}
// Parse the updated config
hosts, err := ParseSSHConfigFile(configFile)
if err != nil {
t.Fatalf("ParseSSHConfigFile() error = %v", err)
}
// Should have: server1, server4, server5, single
expectedHosts := []string{"server1", "server4", "server5", "single"}
hostMap := make(map[string]SSHHost)
for _, host := range hosts {
hostMap[host.Name] = host
}
if len(hosts) != len(expectedHosts) {
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
for _, host := range hosts {
t.Logf("Found host: %s", host.Name)
}
}
// Verify new hosts have updated properties
for _, hostName := range []string{"server1", "server4", "server5"} {
if host, found := hostMap[hostName]; found {
if host.Hostname != "newcluster.example.com" || host.User != "newuser" || host.Port != "22" {
t.Errorf("%s properties incorrect: hostname=%s, user=%s, port=%s",
hostName, host.Hostname, host.User, host.Port)
}
if !contains(host.Tags, "updated") || !contains(host.Tags, "cluster") {
t.Errorf("%s tags incorrect: %v", hostName, host.Tags)
}
} else {
t.Errorf("Expected host %s not found", hostName)
}
}
// Verify single host is unchanged
if host, found := hostMap["single"]; found {
if host.Hostname != "single.example.com" || host.User != "singleuser" {
t.Errorf("single host properties changed: hostname=%s, user=%s", host.Hostname, host.User)
}
}
// Verify old hosts are gone
for _, oldHost := range []string{"server2", "server3"} {
if _, found := hostMap[oldHost]; found {
t.Errorf("Old host %s should have been removed", oldHost)
}
}
}
// Helper function to check if slice contains a string
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// Helper function to create temporary config files for testing
func createTempConfigFile(content string) (string, error) {
tempFile, err := os.CreateTemp("", "ssh_config_test_*.conf")
if err != nil {
return "", err
}
defer tempFile.Close()
_, err = tempFile.WriteString(content)
if err != nil {
os.Remove(tempFile.Name())
return "", err
}
return tempFile.Name(), nil
}
func TestFormatSSHConfigValue(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "simple path without spaces",
input: "/home/user/.ssh/id_rsa",
expected: "/home/user/.ssh/id_rsa",
},
{
name: "path with spaces",
input: "/home/user/My Documents/ssh key",
expected: "\"/home/user/My Documents/ssh key\"",
},
{
name: "Windows path with spaces",
input: `G:\My Drive\7 - Tech\9 - SSH Keys\Server_WF.opk`,
expected: `"G:\My Drive\7 - Tech\9 - SSH Keys\Server_WF.opk"`,
},
{
name: "path with quotes but no spaces",
input: `/home/user/key"with"quotes`,
expected: `/home/user/key"with"quotes`,
},
{
name: "path with spaces and quotes",
input: `/home/user/key "with" quotes`,
expected: `"/home/user/key "with" quotes"`,
},
{
name: "empty path",
input: "",
expected: "",
},
{
name: "path with single space at end",
input: "/home/user/key ",
expected: "\"/home/user/key \"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatSSHConfigValue(tt.input)
if result != tt.expected {
t.Errorf("formatSSHConfigValue(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestAddSSHHostWithSpacesInPath(t *testing.T) {
// Create temporary config file
configFile, err := createTempConfigFile(`Host existing
HostName existing.com
`)
if err != nil {
t.Fatalf("Failed to create config file: %v", err)
}
defer os.Remove(configFile)
// Test adding host with path containing spaces
host := SSHHost{
Name: "test-spaces",
Hostname: "test.com",
User: "testuser",
Identity: "/path/with spaces/key file",
}
err = AddSSHHostToFile(host, configFile)
if err != nil {
t.Fatalf("AddSSHHostToFile failed: %v", err)
}
// Read the file and verify quotes are added
content, err := os.ReadFile(configFile)
if err != nil {
t.Fatalf("Failed to read config file: %v", err)
}
contentStr := string(content)
expectedIdentityLine := ` IdentityFile "/path/with spaces/key file"`
if !strings.Contains(contentStr, expectedIdentityLine) {
t.Errorf("Expected identity file line with quotes not found.\nContent:\n%s\nExpected line: %s", contentStr, expectedIdentityLine)
}
}
func TestIsNonSSHConfigFile(t *testing.T) {
tests := []struct {
fileName string
expected bool
}{
// Should be excluded
{"README", true},
{"README.txt", true},
{"README.md", true},
{"script.sh", true},
{"data.json", true},
{"notes.txt", true},
{".gitignore", true},
{"backup.bak", true},
{"old.orig", true},
{"log.log", true},
{"temp.tmp", true},
{"archive.zip", true},
{"image.jpg", true},
{"python.py", true},
{"golang.go", true},
{"config.yaml", true},
{"config.yml", true},
{"config.toml", true},
// Should NOT be excluded (valid SSH config files)
{"config", false},
{"servers.conf", false},
{"production", false},
{"staging", false},
{"hosts", false},
{"ssh_config", false},
{"work-servers", false},
}
for _, test := range tests {
// Create a temporary file for content testing
tempDir := t.TempDir()
filePath := filepath.Join(tempDir, test.fileName)
// Write appropriate content based on expected result
var content string
if test.expected {
// Write non-SSH content for files that should be excluded
content = "# This is not an SSH config file\nSome random content"
} else {
// Write SSH-like content for files that should be included
content = "Host example\n HostName example.com\n User testuser"
}
err := os.WriteFile(filePath, []byte(content), 0600)
if err != nil {
t.Fatalf("Failed to create test file %s: %v", test.fileName, err)
}
result := isNonSSHConfigFile(filePath)
if result != test.expected {
t.Errorf("isNonSSHConfigFile(%q) = %v, want %v", test.fileName, result, test.expected)
}
}
}
func TestQuickHostExists(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create main config file
mainConfig := filepath.Join(tempDir, "config")
mainConfigContent := `Host main-host
HostName example.com
Include config.d/*
Host another-host
HostName another.example.com
`
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create main config: %v", err)
}
// Create config.d directory
configDir := filepath.Join(tempDir, "config.d")
err = os.MkdirAll(configDir, 0700)
if err != nil {
t.Fatalf("Failed to create config.d: %v", err)
}
// Create valid SSH config file in config.d
validConfig := filepath.Join(configDir, "servers.conf")
validConfigContent := `Host included-host
HostName included.example.com
User includeduser
Host production-server
HostName prod.example.com
User produser
`
err = os.WriteFile(validConfig, []byte(validConfigContent), 0600)
if err != nil {
t.Fatalf("Failed to create valid config: %v", err)
}
// Create files that should be excluded (README, etc.)
excludedFiles := map[string]string{
"README": "# This is a README file\nDocumentation goes here",
"README.md": "# SSH Configuration\nThis directory contains...",
"script.sh": "#!/bin/bash\necho 'hello world'",
"data.json": `{"key": "value"}`,
}
for fileName, content := range excludedFiles {
filePath := filepath.Join(configDir, fileName)
err = os.WriteFile(filePath, []byte(content), 0600)
if err != nil {
t.Fatalf("Failed to create %s: %v", fileName, err)
}
}
// Test hosts that should be found
existingHosts := []string{"main-host", "another-host", "included-host", "production-server"}
for _, hostName := range existingHosts {
found, err := QuickHostExistsInFile(hostName, mainConfig)
if err != nil {
t.Errorf("QuickHostExistsInFile(%q) error = %v", hostName, err)
}
if !found {
t.Errorf("QuickHostExistsInFile(%q) = false, want true", hostName)
}
}
// Test hosts that should NOT be found
nonExistingHosts := []string{"nonexistent-host", "fake-server", "unknown"}
for _, hostName := range nonExistingHosts {
found, err := QuickHostExistsInFile(hostName, mainConfig)
if err != nil {
t.Errorf("QuickHostExistsInFile(%q) error = %v", hostName, err)
}
if found {
t.Errorf("QuickHostExistsInFile(%q) = true, want false", hostName)
}
}
}

View File

@ -0,0 +1,144 @@
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

@ -15,11 +15,21 @@ 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"`
HostName string `json:"host_name"`
LastConnect time.Time `json:"last_connect"`
ConnectCount int `json:"connect_count"`
PortForwarding *PortForwardConfig `json:"port_forwarding,omitempty"`
}
// HistoryManager manages the connection history
@ -30,12 +40,23 @@ type HistoryManager struct {
// NewHistoryManager creates a new history manager
func NewHistoryManager() (*HistoryManager, error) {
homeDir, err := os.UserHomeDir()
configDir, err := config.GetSSHMConfigDir()
if err != nil {
return nil, err
}
historyPath := filepath.Join(homeDir, ".ssh", "sshm_history.json")
// 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
}
hm := &HistoryManager{
historyPath: historyPath,
@ -54,6 +75,46 @@ 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)
@ -206,3 +267,42 @@ 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

@ -0,0 +1,164 @@
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

@ -0,0 +1,183 @@
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,12 +1,14 @@
package ui
import (
"fmt"
"os"
"os/user"
"path/filepath"
"strings"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/validation"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
@ -15,6 +17,7 @@ import (
type addFormModel struct {
inputs []textinput.Model
focused int
currentTab int // 0 = General, 1 = Advanced
err string
styles Styles
success bool
@ -46,7 +49,7 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
}
}
inputs := make([]textinput.Model, 8)
inputs := make([]textinput.Model, 10) // Increased from 9 to 10 for RequestTTY
// Name input
inputs[nameInput] = textinput.New()
@ -100,9 +103,22 @@ 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,
@ -110,6 +126,11 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
}
}
const (
tabGeneral = iota
tabAdvanced
)
const (
nameInput = iota
hostnameInput
@ -117,8 +138,11 @@ const (
portInput
identityInput
proxyJumpInput
optionsInput
tagsInput
// Advanced tab inputs
optionsInput
remoteCommandInput
requestTTYInput
)
// Messages for communication with parent model
@ -148,40 +172,24 @@ func (m *addFormModel) Update(msg tea.Msg) (*addFormModel, tea.Cmd) {
case "ctrl+c", "esc":
return m, func() tea.Msg { return addFormCancelMsg{} }
case "ctrl+enter":
// Allow submission from any field with Ctrl+Enter
case "ctrl+s":
// Allow submission from any field with Ctrl+S (Save)
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":
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...)
return m, m.handleNavigation(msg.String())
}
case addFormSubmitMsg:
@ -205,32 +213,104 @@ 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")
fields := []string{
"Host Name *",
"Hostname/IP *",
"User",
"Port",
"Identity File",
"ProxyJump",
"SSH Options",
"Tags (comma-separated)",
}
// Render tabs
b.WriteString(m.renderTabs())
b.WriteString("\n\n")
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")
// Render current tab content
switch m.currentTab {
case tabGeneral:
b.WriteString(m.renderGeneralTab())
case tabAdvanced:
b.WriteString(m.renderAdvancedTab())
}
if m.err != "" {
@ -238,13 +318,133 @@ func (m *addFormModel) View() string {
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"))
// 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("\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
@ -290,6 +490,8 @@ 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 == "" {
@ -318,14 +520,16 @@ 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),
Tags: tags,
Name: name,
Hostname: hostname,
User: user,
Port: port,
Identity: identity,
ProxyJump: proxyJump,
Options: config.ParseSSHOptionsFromCommand(options),
RemoteCommand: remoteCommand,
RequestTTY: requestTTY,
Tags: tags,
}
// Add to config

View File

@ -1,29 +1,46 @@
package ui
import (
"fmt"
"strings"
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/validation"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type editFormModel struct {
inputs []textinput.Model
focused int
err string
success bool
styles Styles
originalName string
host *config.SSHHost // Store the original host with SourceFile
width int
height int
configFile string
const (
focusAreaHosts = iota
focusAreaProperties
)
type editFormSubmitMsg struct {
hostname string
err error
}
// NewEditForm creates a new edit form model
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
}
// NewEditForm creates a new edit form model that supports both single and multi-host editing
func NewEditForm(hostName string, styles Styles, width, height int, configFile string) (*editFormModel, error) {
// Get the existing host configuration
var host *config.SSHHost
@ -39,207 +56,482 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
return nil, err
}
inputs := make([]textinput.Model, 8)
// Check if this host is part of a multi-host declaration
var actualConfigFile string
var hostNames []string
var isMulti bool
// 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[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[userInput] = textinput.New()
inputs[userInput].Placeholder = "root"
inputs[userInput].CharLimit = 50
inputs[userInput].Width = 30
inputs[userInput].SetValue(host.User)
// Port input
inputs[portInput] = textinput.New()
inputs[portInput].Placeholder = "22"
inputs[portInput].CharLimit = 5
inputs[portInput].Width = 30
inputs[portInput].SetValue(host.Port)
// Identity input
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[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)
// 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[tagsInput] = textinput.New()
inputs[tagsInput].Placeholder = "production, web, database"
inputs[tagsInput].CharLimit = 200
inputs[tagsInput].Width = 50
if len(host.Tags) > 0 {
inputs[tagsInput].SetValue(strings.Join(host.Tags, ", "))
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
// 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)
// User input
inputs[1] = textinput.New()
inputs[1].Placeholder = "root"
inputs[1].CharLimit = 50
inputs[1].Width = 30
inputs[1].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)
// 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)
// ProxyJump input
inputs[4] = textinput.New()
inputs[4].Placeholder = "jump-server"
inputs[4].CharLimit = 100
inputs[4].Width = 30
inputs[4].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))
}
// Tags input
inputs[6] = textinput.New()
inputs[6].Placeholder = "production, web, database"
inputs[6].CharLimit = 200
inputs[6].Width = 50
if len(host.Tags) > 0 {
inputs[6].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{
inputs: inputs,
focused: nameInput,
originalName: hostName,
host: host,
configFile: configFile,
styles: styles,
width: width,
height: height,
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,
}, 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
}
func (m *editFormModel) Update(msg tea.Msg) (*editFormModel, tea.Cmd) {
// 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) {
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+enter":
// Allow submission from any field with Ctrl+Enter
case "ctrl+s":
// Allow submission from any field with Ctrl+S (Save)
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":
s := msg.String()
return m, m.handleEditNavigation(msg.String())
// Handle form submission
if s == "enter" && m.focused == len(m.inputs)-1 {
return m, m.submitEditForm()
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()
}
// 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 {
m.success = true
m.err = ""
// Don't quit here, let parent handle the success
// Success: let the wrapper handle this
// In TUI mode, this will be handled by the parent
// In standalone mode, the wrapper will quit
}
return m, nil
}
// Update inputs
cmd := make([]tea.Cmd, len(m.inputs))
for i := range m.inputs {
m.inputs[i], cmd[i] = m.inputs[i].Update(msg)
// 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, cmd...)
cmds = append(cmds, hostCmd...)
// Update property inputs
propCmd := make([]tea.Cmd, len(m.inputs))
for i := range m.inputs {
m.inputs[i], propCmd[i] = m.inputs[i].Update(msg)
}
cmds = append(cmds, propCmd...)
return m, tea.Batch(cmds...)
}
func (m *editFormModel) 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("Edit SSH Host Configuration"))
b.WriteString("\n")
if m.err != "" {
b.WriteString(m.styles.Error.Render("Error: " + m.err))
b.WriteString("\n\n")
}
b.WriteString(m.styles.Header.Render("Edit SSH Host"))
b.WriteString("\n\n")
// Show source file information
if m.host != nil && m.host.SourceFile != "" {
b.WriteString("\n") // Ligne d'espace avant Config file
// Style for "Config file:" label in primary color
labelStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00ADD8")). // Primary color
Bold(true)
// Style for the file path in white
pathStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
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")
fields := []string{
"Host Name *",
"Hostname/IP *",
"User",
"Port",
"Identity File",
"ProxyJump",
"SSH Options",
"Tags (comma-separated)",
// 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)))
b.WriteString("\n")
b.WriteString(hostInput.View())
b.WriteString("\n\n")
}
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")
// 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 != "" {
@ -247,9 +539,87 @@ func (m *editFormModel) View() string {
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"))
// 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")
}
return b.String()
}
@ -264,29 +634,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 {
m.editFormModel.success = true
// Success: quit the program
return m, tea.Quit
}
return m, nil
case editFormCancelMsg:
return m, tea.Quit
}
newForm, cmd := m.editFormModel.Update(msg)
m.editFormModel = newForm
m.editFormModel = newForm.(*editFormModel)
return m, cmd
}
// RunEditForm provides backward compatibility for standalone edit form
// RunEditForm runs the edit form as a standalone program
func RunEditForm(hostName string, configFile string) error {
styles := NewStyles(80)
styles := NewStyles(80) // Default width
editForm, err := NewEditForm(hostName, styles, 80, 24, configFile)
if err != nil {
return err
}
m := standaloneEditForm{editForm}
m := standaloneEditForm{editForm}
p := tea.NewProgram(m, tea.WithAltScreen())
_, err = p.Run()
return err
@ -294,28 +664,48 @@ func RunEditForm(hostName string, configFile string) error {
func (m *editFormModel) submitEditForm() tea.Cmd {
return func() tea.Msg {
// 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())
// 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
// Set defaults
if port == "" {
port = "22"
}
// Do not auto-fill identity with placeholder if left empty; keep it empty so it's optional
// Validate all fields
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
return editFormSubmitMsg{err: err}
// 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}
}
}
// Parse tags
tagsStr := strings.TrimSpace(m.inputs[tagsInput].Value())
tagsStr := strings.TrimSpace(m.inputs[6].Value()) // tagsInput
var tags []string
if tagsStr != "" {
for _, tag := range strings.Split(tagsStr, ",") {
@ -326,25 +716,33 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
}
}
// 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,
// 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,
}
// Update the configuration
var err error
if m.configFile != "" {
err = config.UpdateSSHHostInFile(m.originalName, host, m.configFile)
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)
}
} else {
err = config.UpdateSSHHost(m.originalName, host)
// Multi-host editing or conversion from single to multi
err = config.UpdateMultiHostBlock(m.originalHosts, hostNames, commonHost, m.actualConfigFile)
}
return editFormSubmitMsg{hostname: name, err: err}
return editFormSubmitMsg{hostname: hostNames[0], err: err}
}
}

View File

@ -40,64 +40,85 @@ func (m *helpModel) Update(msg tea.Msg) (*helpModel, tea.Cmd) {
func (m *helpModel) View() string {
// Title
title := m.styles.Header.Render("📖 SSHM - Help & Commands")
title := m.styles.Header.Render("📖 SSHM - Commands")
// Create horizontal sections with compact layout
line1 := lipgloss.JoinHorizontal(lipgloss.Center,
m.styles.FocusedLabel.Render("🧭 ↑/↓/j/k"),
" ",
m.styles.HelpText.Render("navigate"),
" ",
m.styles.FocusedLabel.Render("⏎"),
" ",
m.styles.HelpText.Render("connect"),
" ",
m.styles.FocusedLabel.Render("a/e/d"),
" ",
m.styles.HelpText.Render("add/edit/delete"),
// 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")),
)
line2 := lipgloss.JoinHorizontal(lipgloss.Center,
m.styles.FocusedLabel.Render("Tab"),
" ",
m.styles.HelpText.Render("switch focus"),
" ",
m.styles.FocusedLabel.Render("p"),
" ",
m.styles.HelpText.Render("ping all"),
" ",
m.styles.FocusedLabel.Render("f"),
" ",
m.styles.HelpText.Render("port forward"),
" ",
m.styles.FocusedLabel.Render("s/r/n"),
" ",
m.styles.HelpText.Render("sort modes"),
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")),
)
line3 := lipgloss.JoinHorizontal(lipgloss.Center,
m.styles.FocusedLabel.Render("/"),
" ",
m.styles.HelpText.Render("search"),
" ",
m.styles.FocusedLabel.Render("h"),
" ",
m.styles.HelpText.Render("help"),
" ",
m.styles.FocusedLabel.Render("q/ESC"),
" ",
m.styles.HelpText.Render("quit"),
// 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,
"",
line1,
"",
line2,
"",
line3,
columns,
"",
m.styles.HelpText.Render("Press ESC, h, q or Enter to close"),
)

View File

@ -4,6 +4,7 @@ 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"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
@ -36,6 +37,7 @@ const (
ViewList ViewMode = iota
ViewAdd
ViewEdit
ViewMove
ViewInfo
ViewPortForward
ViewHelp
@ -78,10 +80,18 @@ type Model struct {
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
@ -92,6 +102,10 @@ type Model struct {
height int
styles Styles
ready bool
// Error handling
errorMessage string
showingError bool
}
// updateTableStyles updates the table header border color based on focus state

188
internal/ui/move_form.go Normal file
View File

@ -0,0 +1,188 @@
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,6 +5,7 @@ import (
"strconv"
"strings"
"github.com/Gu1llaum-3/sshm/internal/history"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@ -20,15 +21,16 @@ const (
)
type portForwardModel struct {
inputs []textinput.Model
focused int
forwardType PortForwardType
hostName string
err string
styles Styles
width int
height int
configFile string
inputs []textinput.Model
focused int
forwardType PortForwardType
hostName string
err string
styles Styles
width int
height int
configFile string
historyManager *history.HistoryManager
}
// portForwardSubmitMsg is sent when the port forward form is submitted
@ -41,7 +43,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) *portForwardModel {
func NewPortForwardForm(hostName string, styles Styles, width, height int, configFile string, historyManager *history.HistoryManager) *portForwardModel {
inputs := make([]textinput.Model, 5)
// Forward type input (display only, controlled by arrow keys)
@ -49,7 +51,6 @@ 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()
@ -77,16 +78,20 @@ 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,
inputs: inputs,
focused: 0,
forwardType: LocalForward,
hostName: hostName,
styles: styles,
width: width,
height: height,
configFile: configFile,
historyManager: historyManager,
}
// Load previous port forwarding configuration if available
pf.loadPreviousConfig()
// Initialize input visibility
pf.updateInputVisibility()
@ -370,6 +375,11 @@ 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
@ -379,13 +389,10 @@ func (m *portForwardModel) submitForm() tea.Cmd {
}
// Add forwarding arguments
bindAddress := strings.TrimSpace(m.inputs[pfBindAddressInput].Value())
var forwardTypeStr string
switch m.forwardType {
case LocalForward:
remoteHost := strings.TrimSpace(m.inputs[pfRemoteHostInput].Value())
remotePort := strings.TrimSpace(m.inputs[pfRemotePortInput].Value())
forwardTypeStr = "local"
if remoteHost == "" {
remoteHost = "localhost"
}
@ -408,31 +415,30 @@ func (m *portForwardModel) submitForm() tea.Cmd {
sshArgs = append(sshArgs, "-L", forwardArg)
case RemoteForward:
localHost := strings.TrimSpace(m.inputs[pfRemoteHostInput].Value())
localPortStr := strings.TrimSpace(m.inputs[pfRemotePortInput].Value())
if localHost == "" {
localHost = "localhost"
forwardTypeStr = "remote"
if remoteHost == "" {
remoteHost = "localhost"
}
if localPortStr == "" {
if remotePort == "" {
return portForwardSubmitMsg{err: fmt.Errorf("local port is required for remote forwarding"), sshArgs: nil}
}
// Validate local port
if _, err := strconv.Atoi(localPortStr); err != nil {
if _, err := strconv.Atoi(remotePort); err != nil {
return portForwardSubmitMsg{err: fmt.Errorf("invalid local port number"), sshArgs: nil}
}
// Build -R argument (note: localPort is actually the remote port in this context)
var forwardArg string
if bindAddress != "" {
forwardArg = fmt.Sprintf("%s:%s:%s:%s", bindAddress, localPort, localHost, localPortStr)
forwardArg = fmt.Sprintf("%s:%s:%s:%s", bindAddress, localPort, remoteHost, remotePort)
} else {
forwardArg = fmt.Sprintf("%s:%s:%s", localPort, localHost, localPortStr)
forwardArg = fmt.Sprintf("%s:%s:%s", localPort, remoteHost, remotePort)
}
sshArgs = append(sshArgs, "-R", forwardArg)
case DynamicForward:
forwardTypeStr = "dynamic"
// Build -D argument
var forwardArg string
if bindAddress != "" {
@ -443,6 +449,21 @@ 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)
@ -488,3 +509,47 @@ 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

@ -37,35 +37,64 @@ 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 query == "" {
if word == "" {
filtered = m.hosts
} else {
query = strings.ToLower(query)
word = strings.ToLower(word)
for _, host := range m.hosts {
// Check the hostname
if strings.Contains(strings.ToLower(host.Name), query) {
if strings.Contains(strings.ToLower(host.Name), word) {
filtered = append(filtered, host)
continue
}
// Check the hostname
if strings.Contains(strings.ToLower(host.Hostname), query) {
if strings.Contains(strings.ToLower(host.Hostname), word) {
filtered = append(filtered, host)
continue
}
// Check the user
if strings.Contains(strings.ToLower(host.User), query) {
if strings.Contains(strings.ToLower(host.User), word) {
filtered = append(filtered, host)
continue
}
// Check the tags
for _, tag := range host.Tags {
if strings.Contains(strings.ToLower(tag), query) {
if strings.Contains(strings.ToLower(tag), word) {
filtered = append(filtered, host)
break
}

View File

@ -33,7 +33,8 @@ type Styles struct {
HelpText lipgloss.Style
// Error and confirmation styles
Error lipgloss.Style
Error lipgloss.Style
ErrorText lipgloss.Style
// Form styles (for add/edit forms)
FormTitle lipgloss.Style
@ -97,6 +98,11 @@ 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")).

View File

@ -178,12 +178,13 @@ func (m *Model) updateTableHeight() {
// 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: 13 lines minimum to preserve essential UI elements
reservedHeight := 13
// Total reserved: 14 lines minimum to preserve essential UI elements
reservedHeight := 14
availableHeight := m.height - reservedHeight
hostCount := len(m.table.Rows())

View File

@ -16,7 +16,16 @@ import (
)
// NewModel creates a new TUI model with the given SSH hosts
func NewModel(hosts []config.SSHHost, configFile string) Model {
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
}
// Initialize the history manager
historyManager, err := history.NewHistoryManager()
if err != nil {
@ -38,6 +47,8 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
pingManager: pingManager,
sortMode: SortByName,
configFile: configFile,
currentVersion: currentVersion,
appConfig: appConfig,
styles: styles,
width: 80,
height: 24,
@ -136,8 +147,8 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
}
// RunInteractiveMode starts the interactive TUI interface
func RunInteractiveMode(hosts []config.SSHHost, configFile string) error {
m := NewModel(hosts, configFile)
func RunInteractiveMode(hosts []config.SSHHost, configFile, currentVersion string) error {
m := NewModel(hosts, configFile, currentVersion)
// Start the application in alt screen mode for clean output
p := tea.NewProgram(m, tea.WithAltScreen())

View File

@ -8,14 +8,18 @@ import (
"github.com/Gu1llaum-3/sshm/internal/config"
"github.com/Gu1llaum-3/sshm/internal/connectivity"
"github.com/Gu1llaum-3/sshm/internal/version"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
// Messages for SSH ping functionality
// Messages for SSH ping functionality and version checking
type (
pingResultMsg *connectivity.HostPingResult
pingResultMsg *connectivity.HostPingResult
versionCheckMsg *version.UpdateInfo
versionErrorMsg error
errorMsg string
)
// startPingAllCmd creates a command to ping all hosts concurrently
@ -49,12 +53,33 @@ func pingSingleHostCmd(pingManager *connectivity.PingManager, host config.SSHHos
}
}
// 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 {
return tea.Batch(
textinput.Blink,
// Ping is now optional - use 'p' key to start ping
)
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...)
}
// Update handles model updates
@ -85,6 +110,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
@ -115,6 +145,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
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:
if msg.err != nil {
// Show error in form
@ -203,6 +254,51 @@ 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
@ -298,9 +394,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case ViewEdit:
if m.editForm != nil {
var newForm *editFormModel
newForm, cmd = m.editForm.Update(msg)
m.editForm = newForm
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:
@ -342,8 +445,9 @@ 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 msg.String() {
switch key {
case "esc", "ctrl+c":
if m.deleteMode {
// Exit delete mode
@ -352,10 +456,16 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.table.Focus()
return m, nil
}
return m, tea.Quit
// Use configurable key bindings for quit
if m.appConfig != nil && m.appConfig.KeyBindings.ShouldQuitOnKey(key) {
return m, tea.Quit
}
case "q":
if !m.searchMode && !m.deleteMode {
return m, tea.Quit
// Use configurable key bindings for quit
if m.appConfig != nil && m.appConfig.KeyBindings.ShouldQuitOnKey(key) {
return m, tea.Quit
}
}
case "/", "ctrl+f":
if !m.searchMode && !m.deleteMode {
@ -485,6 +595,27 @@ 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
@ -562,7 +693,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
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.portForwardForm = NewPortForwardForm(hostName, m.styles, m.width, m.height, m.configFile, m.historyManager)
m.viewMode = ViewPortForward
return m, textinput.Blink
}

View File

@ -23,6 +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()
@ -54,6 +58,34 @@ 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 {
@ -157,3 +189,30 @@ 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

@ -0,0 +1,187 @@
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)
}
})
}
}

145
internal/version/version.go Normal file
View File

@ -0,0 +1,145 @@
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

@ -0,0 +1,56 @@
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)
}
}
}