mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2025-10-21 02:17:20 +02:00
Compare commits
46 Commits
Author | SHA1 | Date | |
---|---|---|---|
825c534ebe | |||
c1457af73a | |||
12d97270f0 | |||
6ba82b1c97 | |||
42e87b6827 | |||
d686d97f8c | |||
8d5f59fab2 | |||
049998c235 | |||
|
5986659048 | ||
|
abbda54125 | ||
|
986017a552 | ||
|
120cd6c009 | ||
3d746ec49a | |||
f31fe9dacf | |||
7b15db1f34 | |||
55f3359287 | |||
4efec57a8a | |||
0975ae2fe2 | |||
ed6ea2939a | |||
45eccabc23 | |||
2425695992 | |||
306f38e862 | |||
3c627a5d21 | |||
71bf8ea2bb | |||
8c6f3b01ef | |||
aa6be1d92d | |||
9bb44da18b | |||
77b2b8fd22 | |||
5c832ce26f | |||
ef075e74cf | |||
9bb5d18f8e | |||
44ffa0c31d | |||
edf61049fc | |||
67987e6242 | |||
e1efef4680 | |||
42387eb1fa | |||
6577002e2b | |||
be3dcaa1cd | |||
b67f5abbbc | |||
b587defabc | |||
22586484c7 | |||
420db56ff5 | |||
|
7600eaaa9b | ||
|
e0dd32993a | ||
1cea3795e4 | |||
2ade315ddc |
124
.github/workflows/build.yml
vendored
124
.github/workflows/build.yml
vendored
@ -1,124 +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 sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }}.exe .
|
||||
else
|
||||
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }} .
|
||||
fi
|
||||
|
||||
- name: Create archive
|
||||
run: |
|
||||
cd dist
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
zip sshm-${{ matrix.suffix }}.zip sshm-${{ matrix.suffix }}.exe
|
||||
rm sshm-${{ matrix.suffix }}.exe
|
||||
else
|
||||
tar -czf sshm-${{ matrix.suffix }}.tar.gz sshm-${{ matrix.suffix }}
|
||||
rm sshm-${{ matrix.suffix }}
|
||||
fi
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sshm-${{ matrix.suffix }}
|
||||
path: |
|
||||
dist/sshm-${{ matrix.suffix }}.tar.gz
|
||||
dist/sshm-${{ matrix.suffix }}.zip
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Prepare release assets
|
||||
run: |
|
||||
mkdir -p release
|
||||
find ./artifacts -name "*.tar.gz" -exec cp {} ./release/ \;
|
||||
find ./artifacts -name "*.zip" -exec cp {} ./release/ \;
|
||||
ls -la ./release/
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ./release/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
39
.github/workflows/release.yml
vendored
Normal file
39
.github/workflows/release.yml
vendored
Normal 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
155
.goreleaser.yaml
Normal 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
44
Makefile
Normal 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
|
295
README.md
295
README.md
@ -25,39 +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
|
||||
@ -104,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)
|
||||
@ -157,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:**
|
||||
@ -217,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
|
||||
|
||||
@ -235,13 +249,69 @@ 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.
|
||||
|
||||
**Backup Location:**
|
||||
- **Unix/Linux/macOS**: `~/.config/sshm/backups/` (or `$XDG_CONFIG_HOME/sshm/backups/` if set)
|
||||
- **Windows**: `%APPDATA%\sshm\backups\` (fallback: `%USERPROFILE%\.config\sshm\backups\`)
|
||||
|
||||
**Key Features:**
|
||||
- Automatic backup before any modification
|
||||
- One backup per file (overwrites previous backup)
|
||||
- Stored separately to avoid SSH Include conflicts
|
||||
- Easy manual recovery if needed
|
||||
|
||||
**Additional Storage:**
|
||||
- **Connection History**: Stored in the same config directory for persistent tracking
|
||||
- **Port Forwarding History**: Saved configurations for quick reuse of common forwarding setups
|
||||
|
||||
**Quick Recovery:**
|
||||
```bash
|
||||
# Unix/Linux/macOS
|
||||
cp ~/.config/sshm/backups/config.backup ~/.ssh/config
|
||||
|
||||
# Windows
|
||||
copy "%APPDATA%\sshm\backups\config.backup" "%USERPROFILE%\.ssh\config"
|
||||
```
|
||||
|
||||
### Configuration File Options
|
||||
|
||||
By default, SSHM uses the standard SSH configuration file at `~/.ssh/config`. You can specify a different configuration file using the `-c` flag:
|
||||
@ -253,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:**
|
||||
@ -272,7 +430,55 @@ sshm edit hostname -c /path/to/custom/ssh_config
|
||||
|
||||
SSHM works directly with your standard SSH configuration file (`~/.ssh/config`). It adds special comment tags for enhanced functionality while maintaining full compatibility with standard SSH tools.
|
||||
|
||||
### SSH Include Support
|
||||
|
||||
SSHM fully supports SSH Include directives, allowing you to organize your SSH configurations across multiple files. This is particularly useful for managing large numbers of hosts or organizing configurations by environment, project, or team.
|
||||
|
||||
**Include Examples:**
|
||||
```ssh
|
||||
# Main ~/.ssh/config file
|
||||
Host personal-server
|
||||
HostName personal.example.com
|
||||
User myuser
|
||||
|
||||
# Include work-related configurations
|
||||
Include work-servers.conf
|
||||
|
||||
# Include all configurations from a directory
|
||||
Include projects/*
|
||||
|
||||
# Include with relative paths
|
||||
Include ~/.ssh/configs/production.conf
|
||||
```
|
||||
|
||||
**Organization Examples:**
|
||||
|
||||
*work-servers.conf:*
|
||||
```ssh
|
||||
# Tags: work, production
|
||||
Host prod-web-01
|
||||
HostName 10.0.1.10
|
||||
User deploy
|
||||
ProxyJump bastion.company.com
|
||||
|
||||
# Tags: work, staging
|
||||
Host staging-api
|
||||
HostName staging-api.company.com
|
||||
User developer
|
||||
```
|
||||
|
||||
*projects/client-alpha.conf:*
|
||||
```ssh
|
||||
# Tags: client, development
|
||||
Host client-alpha-dev
|
||||
HostName dev.client-alpha.com
|
||||
User admin
|
||||
Port 2222
|
||||
```
|
||||
|
||||
**Example configuration:**
|
||||
Include ~/.ssh/conf.d/*
|
||||
|
||||
```ssh
|
||||
# Tags: production, web, frontend
|
||||
Host web-prod-01
|
||||
@ -347,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
|
||||
@ -377,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
|
||||
@ -418,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
|
||||
|
||||
@ -452,6 +696,9 @@ 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
|
||||
|
||||
---
|
||||
|
@ -2,7 +2,8 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sshm/internal/ui"
|
||||
|
||||
"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
88
cmd/add_test.go
Normal 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
|
||||
}
|
@ -2,7 +2,8 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sshm/internal/ui"
|
||||
|
||||
"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
70
cmd/edit_test.go
Normal 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
28
cmd/move.go
Normal 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)
|
||||
}
|
151
cmd/root.go
151
cmd/root.go
@ -1,45 +1,57 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/ui"
|
||||
"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
145
cmd/root_test.go
Normal 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)
|
||||
}
|
||||
}
|
@ -5,7 +5,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@ -235,7 +235,7 @@ func escapeJSON(s string) string {
|
||||
|
||||
func init() {
|
||||
// Add search command to root
|
||||
rootCmd.AddCommand(searchCmd)
|
||||
RootCmd.AddCommand(searchCmd)
|
||||
|
||||
// Add flags
|
||||
searchCmd.Flags().StringVarP(&outputFormat, "format", "f", "table", "Output format (table, json, simple)")
|
||||
|
120
cmd/search_test.go
Normal file
120
cmd/search_test.go
Normal 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
|
||||
}
|
9
go.mod
9
go.mod
@ -1,4 +1,4 @@
|
||||
module sshm
|
||||
module github.com/Gu1llaum-3/sshm
|
||||
|
||||
go 1.23.1
|
||||
|
||||
@ -7,6 +7,7 @@ require (
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
golang.org/x/crypto v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
@ -28,7 +29,7 @@ require (
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
)
|
||||
|
16
go.sum
16
go.sum
@ -49,15 +49,19 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
109
install/unix.sh
109
install/unix.sh
@ -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,10 +120,10 @@ downloadBinary() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find the extracted binary
|
||||
EXTRACTED_BINARY=$(find . -name "sshm-${OS}-${ARCH}" -type f)
|
||||
if [ -z "$EXTRACTED_BINARY" ]; then
|
||||
printf "${RED}Could not find extracted binary${NC}\n"
|
||||
# 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
|
||||
fi
|
||||
|
||||
@ -88,17 +134,37 @@ downloadBinary() {
|
||||
install() {
|
||||
printf "${YELLOW}Installing SSHM...${NC}\n"
|
||||
|
||||
# Backup old version if it exists to prevent interference during installation
|
||||
OLD_BACKUP=""
|
||||
if [ -f "$EXECUTABLE_PATH" ]; then
|
||||
OLD_BACKUP="$EXECUTABLE_PATH.backup.$$"
|
||||
runAsRoot mv "$EXECUTABLE_PATH" "$OLD_BACKUP"
|
||||
fi
|
||||
|
||||
chmod +x "sshm-tmp"
|
||||
if [ $? -ne 0 ]; then
|
||||
printf "${RED}Failed to set permissions${NC}\n"
|
||||
# Restore backup if installation fails
|
||||
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
|
||||
runAsRoot mv "$OLD_BACKUP" "$EXECUTABLE_PATH"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
runAsRoot mv "sshm-tmp" "$EXECUTABLE_PATH"
|
||||
if [ $? -ne 0 ]; then
|
||||
printf "${RED}Failed to install binary${NC}\n"
|
||||
# Restore backup if installation fails
|
||||
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
|
||||
runAsRoot mv "$OLD_BACKUP" "$EXECUTABLE_PATH"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up backup if installation succeeded
|
||||
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
|
||||
runAsRoot rm -f "$OLD_BACKUP"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
@ -138,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
|
||||
@ -161,7 +233,8 @@ main() {
|
||||
# Show version
|
||||
printf "${YELLOW}Verifying installation...${NC}\n"
|
||||
if command -v sshm >/dev/null 2>&1; then
|
||||
sshm --version
|
||||
# Use the full path to ensure we're using the newly installed version
|
||||
"$EXECUTABLE_PATH" --version 2>/dev/null || echo "Version check failed, but installation completed"
|
||||
else
|
||||
printf "${RED}Warning: 'sshm' command not found in PATH. You may need to restart your terminal or add $INSTALL_DIR to your PATH.${NC}\n"
|
||||
fi
|
||||
|
@ -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
|
||||
|
146
internal/config/keybindings.go
Normal file
146
internal/config/keybindings.go
Normal 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
|
||||
}
|
181
internal/config/keybindings_test.go
Normal file
181
internal/config/keybindings_test.go
Normal 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
File diff suppressed because it is too large
Load Diff
212
internal/connectivity/ping.go
Normal file
212
internal/connectivity/ping.go
Normal file
@ -0,0 +1,212 @@
|
||||
package connectivity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// PingStatus represents the connectivity status of an SSH host
|
||||
type PingStatus int
|
||||
|
||||
const (
|
||||
StatusUnknown PingStatus = iota
|
||||
StatusConnecting
|
||||
StatusOnline
|
||||
StatusOffline
|
||||
)
|
||||
|
||||
func (s PingStatus) String() string {
|
||||
switch s {
|
||||
case StatusUnknown:
|
||||
return "unknown"
|
||||
case StatusConnecting:
|
||||
return "connecting"
|
||||
case StatusOnline:
|
||||
return "online"
|
||||
case StatusOffline:
|
||||
return "offline"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// HostPingResult represents the result of pinging a host
|
||||
type HostPingResult struct {
|
||||
HostName string
|
||||
Status PingStatus
|
||||
Error error
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// PingManager manages SSH connectivity checks for multiple hosts
|
||||
type PingManager struct {
|
||||
results map[string]*HostPingResult
|
||||
mutex sync.RWMutex
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewPingManager creates a new ping manager with the specified timeout
|
||||
func NewPingManager(timeout time.Duration) *PingManager {
|
||||
return &PingManager{
|
||||
results: make(map[string]*HostPingResult),
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatus returns the current status for a host
|
||||
func (pm *PingManager) GetStatus(hostName string) PingStatus {
|
||||
pm.mutex.RLock()
|
||||
defer pm.mutex.RUnlock()
|
||||
|
||||
if result, exists := pm.results[hostName]; exists {
|
||||
return result.Status
|
||||
}
|
||||
return StatusUnknown
|
||||
}
|
||||
|
||||
// GetResult returns the complete result for a host
|
||||
func (pm *PingManager) GetResult(hostName string) (*HostPingResult, bool) {
|
||||
pm.mutex.RLock()
|
||||
defer pm.mutex.RUnlock()
|
||||
|
||||
result, exists := pm.results[hostName]
|
||||
return result, exists
|
||||
}
|
||||
|
||||
// updateStatus updates the status for a host
|
||||
func (pm *PingManager) updateStatus(hostName string, status PingStatus, err error, duration time.Duration) {
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
|
||||
pm.results[hostName] = &HostPingResult{
|
||||
HostName: hostName,
|
||||
Status: status,
|
||||
Error: err,
|
||||
Duration: duration,
|
||||
}
|
||||
}
|
||||
|
||||
// PingHost performs an SSH connectivity check for a single host
|
||||
func (pm *PingManager) PingHost(ctx context.Context, host config.SSHHost) *HostPingResult {
|
||||
start := time.Now()
|
||||
|
||||
// Mark as connecting
|
||||
pm.updateStatus(host.Name, StatusConnecting, nil, 0)
|
||||
|
||||
// Determine the actual hostname and port
|
||||
hostname := host.Hostname
|
||||
if hostname == "" {
|
||||
hostname = host.Name
|
||||
}
|
||||
|
||||
port := host.Port
|
||||
if port == "" {
|
||||
port = "22"
|
||||
}
|
||||
|
||||
// Create context with timeout
|
||||
pingCtx, cancel := context.WithTimeout(ctx, pm.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Try to establish a TCP connection first (faster than SSH handshake)
|
||||
dialer := &net.Dialer{}
|
||||
conn, err := dialer.DialContext(pingCtx, "tcp", net.JoinHostPort(hostname, port))
|
||||
if err != nil {
|
||||
duration := time.Since(start)
|
||||
pm.updateStatus(host.Name, StatusOffline, err, duration)
|
||||
return &HostPingResult{
|
||||
HostName: host.Name,
|
||||
Status: StatusOffline,
|
||||
Error: err,
|
||||
Duration: duration,
|
||||
}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// If TCP connection succeeds, try SSH handshake
|
||||
sshConfig := &ssh.ClientConfig{
|
||||
User: host.User,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // For ping purposes only
|
||||
Timeout: time.Second * 2, // Short timeout for handshake
|
||||
}
|
||||
|
||||
// We don't need to authenticate, just check if SSH is responding
|
||||
sshConn, _, _, err := ssh.NewClientConn(conn, net.JoinHostPort(hostname, port), sshConfig)
|
||||
if sshConn != nil {
|
||||
sshConn.Close()
|
||||
}
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
// Even if SSH handshake fails, if we got a TCP connection, consider it online
|
||||
// This handles cases where authentication fails but the host is reachable
|
||||
status := StatusOnline
|
||||
if err != nil && isConnectionError(err) {
|
||||
status = StatusOffline
|
||||
}
|
||||
|
||||
pm.updateStatus(host.Name, status, err, duration)
|
||||
return &HostPingResult{
|
||||
HostName: host.Name,
|
||||
Status: status,
|
||||
Error: err,
|
||||
Duration: duration,
|
||||
}
|
||||
}
|
||||
|
||||
// PingAllHosts pings all hosts concurrently and returns a channel of results
|
||||
func (pm *PingManager) PingAllHosts(ctx context.Context, hosts []config.SSHHost) <-chan *HostPingResult {
|
||||
resultChan := make(chan *HostPingResult, len(hosts))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, host := range hosts {
|
||||
wg.Add(1)
|
||||
go func(h config.SSHHost) {
|
||||
defer wg.Done()
|
||||
result := pm.PingHost(ctx, h)
|
||||
select {
|
||||
case resultChan <- result:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}(host)
|
||||
}
|
||||
|
||||
// Close the channel when all goroutines are done
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resultChan)
|
||||
}()
|
||||
|
||||
return resultChan
|
||||
}
|
||||
|
||||
// isConnectionError determines if an error is a connection-related error
|
||||
func isConnectionError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
connectionErrors := []string{
|
||||
"connection refused",
|
||||
"no route to host",
|
||||
"network is unreachable",
|
||||
"timeout",
|
||||
"connection timed out",
|
||||
}
|
||||
|
||||
for _, connErr := range connectionErrors {
|
||||
if strings.Contains(strings.ToLower(errStr), connErr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
144
internal/connectivity/ping_test.go
Normal file
144
internal/connectivity/ping_test.go
Normal 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")
|
||||
}
|
||||
}
|
@ -7,7 +7,7 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
)
|
||||
|
||||
// ConnectionHistory represents the history of SSH connections
|
||||
@ -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
|
||||
}
|
||||
|
164
internal/history/history_test.go
Normal file
164
internal/history/history_test.go
Normal 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")
|
||||
}
|
||||
}
|
183
internal/history/port_forward_test.go
Normal file
183
internal/history/port_forward_test.go
Normal 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)
|
||||
}
|
||||
}
|
@ -1,13 +1,15 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/validation"
|
||||
"strings"
|
||||
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/validation"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
@ -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
|
||||
|
@ -1,27 +1,46 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/validation"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/validation"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type editFormModel struct {
|
||||
inputs []textinput.Model
|
||||
focused int
|
||||
err string
|
||||
success bool
|
||||
styles Styles
|
||||
originalName string
|
||||
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
|
||||
@ -37,198 +56,570 @@ 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,
|
||||
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\n")
|
||||
|
||||
fields := []string{
|
||||
"Host Name *",
|
||||
"Hostname/IP *",
|
||||
"User",
|
||||
"Port",
|
||||
"Identity File",
|
||||
"ProxyJump",
|
||||
"SSH Options",
|
||||
"Tags (comma-separated)",
|
||||
if m.err != "" {
|
||||
b.WriteString(m.styles.Error.Render("Error: " + m.err))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
for i, field := range fields {
|
||||
b.WriteString(m.styles.FormField.Render(field))
|
||||
b.WriteString(m.styles.Header.Render("Edit SSH Host"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if m.host != nil && m.host.SourceFile != "" {
|
||||
labelStyle := m.styles.FormField
|
||||
pathStyle := m.styles.FormField
|
||||
configInfo := labelStyle.Render("Config file: ") + pathStyle.Render(formatConfigFile(m.host.SourceFile))
|
||||
b.WriteString(configInfo)
|
||||
}
|
||||
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Host Names Section
|
||||
b.WriteString(m.styles.FormTitle.Render("Host Names"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
for i, hostInput := range m.hostInputs {
|
||||
hostStyle := m.styles.FormField
|
||||
if m.focusArea == focusAreaHosts && m.focused == i {
|
||||
hostStyle = m.styles.FocusedLabel
|
||||
}
|
||||
b.WriteString(hostStyle.Render(fmt.Sprintf("Host Name %d *", i+1)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.inputs[i].View())
|
||||
b.WriteString(hostInput.View())
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Properties Section
|
||||
b.WriteString(m.styles.FormTitle.Render("Common Properties"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Render tabs for properties
|
||||
b.WriteString(m.renderEditTabs())
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Render current tab content
|
||||
switch m.currentTab {
|
||||
case 0: // General
|
||||
b.WriteString(m.renderEditGeneralTab())
|
||||
case 1: // Advanced
|
||||
b.WriteString(m.renderEditAdvancedTab())
|
||||
}
|
||||
|
||||
if m.err != "" {
|
||||
b.WriteString(m.styles.Error.Render("Error: " + m.err))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@ -243,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
|
||||
@ -273,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, ",") {
|
||||
@ -305,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}
|
||||
}
|
||||
}
|
||||
|
162
internal/ui/file_selector.go
Normal file
162
internal/ui/file_selector.go
Normal file
@ -0,0 +1,162 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type fileSelectorModel struct {
|
||||
files []string // Chemins absolus des fichiers
|
||||
displayNames []string // Noms d'affichage conviviaux
|
||||
selected int
|
||||
styles Styles
|
||||
width int
|
||||
height int
|
||||
title string
|
||||
}
|
||||
|
||||
type fileSelectorMsg struct {
|
||||
selectedFile string
|
||||
cancelled bool
|
||||
}
|
||||
|
||||
// NewFileSelector creates a new file selector for choosing config files
|
||||
func NewFileSelector(title string, styles Styles, width, height int) (*fileSelectorModel, error) {
|
||||
files, err := config.GetAllConfigFiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newFileSelectorFromFiles(title, styles, width, height, files)
|
||||
}
|
||||
|
||||
// NewFileSelectorFromBase creates a new file selector starting from a specific base config file
|
||||
func NewFileSelectorFromBase(title string, styles Styles, width, height int, baseConfigFile string) (*fileSelectorModel, error) {
|
||||
var files []string
|
||||
var err error
|
||||
|
||||
if baseConfigFile != "" {
|
||||
files, err = config.GetAllConfigFilesFromBase(baseConfigFile)
|
||||
} else {
|
||||
files, err = config.GetAllConfigFiles()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newFileSelectorFromFiles(title, styles, width, height, files)
|
||||
}
|
||||
|
||||
// newFileSelectorFromFiles creates a file selector from a list of files
|
||||
func newFileSelectorFromFiles(title string, styles Styles, width, height int, files []string) (*fileSelectorModel, error) {
|
||||
|
||||
// Convert absolute paths to more user-friendly names
|
||||
var displayNames []string
|
||||
homeDir, _ := config.GetSSHDirectory()
|
||||
|
||||
for _, file := range files {
|
||||
// Check if it's the main config file
|
||||
mainConfig, _ := config.GetDefaultSSHConfigPath()
|
||||
if file == mainConfig {
|
||||
displayNames = append(displayNames, "Main SSH Config (~/.ssh/config)")
|
||||
} else {
|
||||
// Try to make path relative to home/.ssh/
|
||||
if strings.HasPrefix(file, homeDir) {
|
||||
relPath, err := filepath.Rel(homeDir, file)
|
||||
if err == nil {
|
||||
displayNames = append(displayNames, fmt.Sprintf("~/.ssh/%s", relPath))
|
||||
} else {
|
||||
displayNames = append(displayNames, file)
|
||||
}
|
||||
} else {
|
||||
displayNames = append(displayNames, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &fileSelectorModel{
|
||||
files: files,
|
||||
displayNames: displayNames,
|
||||
selected: 0,
|
||||
styles: styles,
|
||||
width: width,
|
||||
height: height,
|
||||
title: title,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *fileSelectorModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *fileSelectorModel) Update(msg tea.Msg) (*fileSelectorModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.styles = NewStyles(m.width)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, func() tea.Msg {
|
||||
return fileSelectorMsg{cancelled: true}
|
||||
}
|
||||
|
||||
case "enter":
|
||||
selectedFile := ""
|
||||
if m.selected < len(m.files) {
|
||||
selectedFile = m.files[m.selected]
|
||||
}
|
||||
return m, func() tea.Msg {
|
||||
return fileSelectorMsg{selectedFile: selectedFile}
|
||||
}
|
||||
|
||||
case "up", "k":
|
||||
if m.selected > 0 {
|
||||
m.selected--
|
||||
}
|
||||
|
||||
case "down", "j":
|
||||
if m.selected < len(m.files)-1 {
|
||||
m.selected++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *fileSelectorModel) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(m.styles.FormTitle.Render(m.title))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if len(m.files) == 0 {
|
||||
b.WriteString(m.styles.Error.Render("No SSH config files found."))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(m.styles.FormHelp.Render("Esc: cancel"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
for i, displayName := range m.displayNames {
|
||||
if i == m.selected {
|
||||
b.WriteString(m.styles.Selected.Render(fmt.Sprintf("▶ %s", displayName)))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf(" %s", displayName))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.styles.FormHelp.Render("↑/↓: navigate • Enter: select • Esc: cancel"))
|
||||
|
||||
return b.String()
|
||||
}
|
134
internal/ui/help_form.go
Normal file
134
internal/ui/help_form.go
Normal file
@ -0,0 +1,134 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type helpModel struct {
|
||||
styles Styles
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
// helpCloseMsg is sent when the help window is closed
|
||||
type helpCloseMsg struct{}
|
||||
|
||||
// NewHelpForm creates a new help form model
|
||||
func NewHelpForm(styles Styles, width, height int) *helpModel {
|
||||
return &helpModel{
|
||||
styles: styles,
|
||||
width: width,
|
||||
height: height,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *helpModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *helpModel) Update(msg tea.Msg) (*helpModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc", "q", "h", "enter", "ctrl+c":
|
||||
return m, func() tea.Msg { return helpCloseMsg{} }
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *helpModel) View() string {
|
||||
// Title
|
||||
title := m.styles.Header.Render("📖 SSHM - Commands")
|
||||
|
||||
// Create two columns of commands for better visual organization
|
||||
leftColumn := lipgloss.JoinVertical(lipgloss.Left,
|
||||
m.styles.FocusedLabel.Render("Navigation & Connection"),
|
||||
"",
|
||||
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
m.styles.FocusedLabel.Render("⏎ "),
|
||||
m.styles.HelpText.Render("connect to selected host")),
|
||||
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
m.styles.FocusedLabel.Render("i "),
|
||||
m.styles.HelpText.Render("show host information")),
|
||||
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
m.styles.FocusedLabel.Render("/ "),
|
||||
m.styles.HelpText.Render("search hosts")),
|
||||
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
m.styles.FocusedLabel.Render("Tab "),
|
||||
m.styles.HelpText.Render("switch focus")),
|
||||
"",
|
||||
m.styles.FocusedLabel.Render("Host Management"),
|
||||
"",
|
||||
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
m.styles.FocusedLabel.Render("a "),
|
||||
m.styles.HelpText.Render("add new host")),
|
||||
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
m.styles.FocusedLabel.Render("e "),
|
||||
m.styles.HelpText.Render("edit selected host")),
|
||||
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
m.styles.FocusedLabel.Render("m "),
|
||||
m.styles.HelpText.Render("move host to another config")),
|
||||
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
m.styles.FocusedLabel.Render("d "),
|
||||
m.styles.HelpText.Render("delete selected host")),
|
||||
)
|
||||
|
||||
rightColumn := lipgloss.JoinVertical(lipgloss.Left,
|
||||
m.styles.FocusedLabel.Render("Advanced Features"),
|
||||
"",
|
||||
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
m.styles.FocusedLabel.Render("p "),
|
||||
m.styles.HelpText.Render("ping all hosts")),
|
||||
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
m.styles.FocusedLabel.Render("f "),
|
||||
m.styles.HelpText.Render("setup port forwarding")),
|
||||
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
m.styles.FocusedLabel.Render("s "),
|
||||
m.styles.HelpText.Render("cycle sort modes")),
|
||||
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
m.styles.FocusedLabel.Render("n "),
|
||||
m.styles.HelpText.Render("sort by name")),
|
||||
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
m.styles.FocusedLabel.Render("r "),
|
||||
m.styles.HelpText.Render("sort by recent connection")),
|
||||
"",
|
||||
m.styles.FocusedLabel.Render("System"),
|
||||
"",
|
||||
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
m.styles.FocusedLabel.Render("h "),
|
||||
m.styles.HelpText.Render("show this help")),
|
||||
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
m.styles.FocusedLabel.Render("q "),
|
||||
m.styles.HelpText.Render("quit application")),
|
||||
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
m.styles.FocusedLabel.Render("ESC "),
|
||||
m.styles.HelpText.Render("exit current view")),
|
||||
)
|
||||
|
||||
// Join the two columns side by side
|
||||
columns := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
leftColumn,
|
||||
" ", // spacing between columns
|
||||
rightColumn,
|
||||
)
|
||||
|
||||
// Create the main content
|
||||
content := lipgloss.JoinVertical(lipgloss.Center,
|
||||
title,
|
||||
"",
|
||||
columns,
|
||||
"",
|
||||
m.styles.HelpText.Render("Press ESC, h, q or Enter to close"),
|
||||
)
|
||||
|
||||
// Center the help window
|
||||
return lipgloss.Place(
|
||||
m.width,
|
||||
m.height,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
m.styles.FormContainer.Render(content),
|
||||
)
|
||||
}
|
228
internal/ui/info_form.go
Normal file
228
internal/ui/info_form.go
Normal file
@ -0,0 +1,228 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type infoFormModel struct {
|
||||
host *config.SSHHost
|
||||
styles Styles
|
||||
width int
|
||||
height int
|
||||
configFile string
|
||||
hostName string
|
||||
}
|
||||
|
||||
// Messages for communication with parent model
|
||||
type infoFormEditMsg struct {
|
||||
hostName string
|
||||
}
|
||||
|
||||
type infoFormCancelMsg struct{}
|
||||
|
||||
// NewInfoForm creates a new info form model for displaying host details in read-only mode
|
||||
func NewInfoForm(hostName string, styles Styles, width, height int, configFile string) (*infoFormModel, error) {
|
||||
// Get the existing host configuration
|
||||
var host *config.SSHHost
|
||||
var err error
|
||||
|
||||
if configFile != "" {
|
||||
host, err = config.GetSSHHostFromFile(hostName, configFile)
|
||||
} else {
|
||||
host, err = config.GetSSHHost(hostName)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &infoFormModel{
|
||||
host: host,
|
||||
hostName: hostName,
|
||||
configFile: configFile,
|
||||
styles: styles,
|
||||
width: width,
|
||||
height: height,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *infoFormModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *infoFormModel) Update(msg tea.Msg) (*infoFormModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.styles = NewStyles(m.width)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc", "q":
|
||||
return m, func() tea.Msg { return infoFormCancelMsg{} }
|
||||
|
||||
case "e", "enter":
|
||||
// Switch to edit mode
|
||||
return m, func() tea.Msg { return infoFormEditMsg{hostName: m.hostName} }
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *infoFormModel) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
// Title
|
||||
title := fmt.Sprintf("SSH Host Information: %s", m.host.Name)
|
||||
b.WriteString(m.styles.FormTitle.Render(title))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Create info sections with consistent formatting
|
||||
sections := []struct {
|
||||
label string
|
||||
value string
|
||||
}{
|
||||
{"Host Name", m.host.Name},
|
||||
{"Config File", formatConfigFile(m.host.SourceFile)},
|
||||
{"Hostname/IP", m.host.Hostname},
|
||||
{"User", formatOptionalValue(m.host.User)},
|
||||
{"Port", formatOptionalValue(m.host.Port)},
|
||||
{"Identity File", formatOptionalValue(m.host.Identity)},
|
||||
{"ProxyJump", formatOptionalValue(m.host.ProxyJump)},
|
||||
{"SSH Options", formatSSHOptions(m.host.Options)},
|
||||
{"Tags", formatTags(m.host.Tags)},
|
||||
}
|
||||
|
||||
// Render each section
|
||||
for _, section := range sections {
|
||||
// Label style
|
||||
labelStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("39")). // Bright blue
|
||||
Width(15).
|
||||
AlignHorizontal(lipgloss.Right)
|
||||
|
||||
// Value style
|
||||
valueStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("255")) // White
|
||||
|
||||
// If value is empty or default, use a muted style
|
||||
if section.value == "Not set" || section.value == "22" && section.label == "Port" {
|
||||
valueStyle = valueStyle.Foreground(lipgloss.Color("243")) // Gray
|
||||
}
|
||||
|
||||
line := lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
labelStyle.Render(section.label+":"),
|
||||
" ",
|
||||
valueStyle.Render(section.value),
|
||||
)
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
|
||||
// Action instructions
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("243")).
|
||||
Italic(true)
|
||||
|
||||
b.WriteString(helpStyle.Render("Actions:"))
|
||||
b.WriteString("\n")
|
||||
|
||||
actionStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("120")). // Green
|
||||
Bold(true)
|
||||
|
||||
b.WriteString(" ")
|
||||
b.WriteString(actionStyle.Render("e/Enter"))
|
||||
b.WriteString(helpStyle.Render(" - Switch to edit mode"))
|
||||
b.WriteString("\n")
|
||||
|
||||
b.WriteString(" ")
|
||||
b.WriteString(actionStyle.Render("q/Esc"))
|
||||
b.WriteString(helpStyle.Render(" - Return to host list"))
|
||||
|
||||
// Wrap in a border for better visual separation
|
||||
content := b.String()
|
||||
|
||||
borderStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("39")).
|
||||
Padding(1).
|
||||
Margin(1)
|
||||
|
||||
// Center the info window
|
||||
return lipgloss.Place(
|
||||
m.width,
|
||||
m.height,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
borderStyle.Render(content),
|
||||
)
|
||||
}
|
||||
|
||||
// Helper functions for formatting values
|
||||
|
||||
func formatOptionalValue(value string) string {
|
||||
if value == "" {
|
||||
return "Not set"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func formatSSHOptions(options string) string {
|
||||
if options == "" {
|
||||
return "Not set"
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func formatTags(tags []string) string {
|
||||
if len(tags) == 0 {
|
||||
return "Not set"
|
||||
}
|
||||
return strings.Join(tags, ", ")
|
||||
}
|
||||
|
||||
// Standalone wrapper for info form (for testing or standalone use)
|
||||
type standaloneInfoForm struct {
|
||||
*infoFormModel
|
||||
}
|
||||
|
||||
func (m standaloneInfoForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg.(type) {
|
||||
case infoFormCancelMsg:
|
||||
return m, tea.Quit
|
||||
case infoFormEditMsg:
|
||||
// For standalone mode, just quit - parent should handle edit transition
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
newForm, cmd := m.infoFormModel.Update(msg)
|
||||
m.infoFormModel = newForm
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// RunInfoForm provides a standalone info form for testing
|
||||
func RunInfoForm(hostName string, configFile string) error {
|
||||
styles := NewStyles(80)
|
||||
infoForm, err := NewInfoForm(hostName, styles, 80, 24, configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m := standaloneInfoForm{infoForm}
|
||||
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
_, err = p.Run()
|
||||
return err
|
||||
}
|
@ -1,8 +1,10 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/history"
|
||||
"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"
|
||||
@ -35,7 +37,11 @@ const (
|
||||
ViewList ViewMode = iota
|
||||
ViewAdd
|
||||
ViewEdit
|
||||
ViewMove
|
||||
ViewInfo
|
||||
ViewPortForward
|
||||
ViewHelp
|
||||
ViewFileSelector
|
||||
)
|
||||
|
||||
// PortForwardType defines the type of port forwarding
|
||||
@ -70,20 +76,36 @@ type Model struct {
|
||||
deleteMode bool
|
||||
deleteHost string
|
||||
historyManager *history.HistoryManager
|
||||
pingManager *connectivity.PingManager
|
||||
sortMode SortMode
|
||||
configFile string // Path to the SSH config file
|
||||
|
||||
// Application configuration
|
||||
appConfig *config.AppConfig
|
||||
|
||||
// Version update information
|
||||
updateInfo *version.UpdateInfo
|
||||
currentVersion string
|
||||
|
||||
// View management
|
||||
viewMode ViewMode
|
||||
addForm *addFormModel
|
||||
editForm *editFormModel
|
||||
portForwardForm *portForwardModel
|
||||
viewMode ViewMode
|
||||
addForm *addFormModel
|
||||
editForm *editFormModel
|
||||
moveForm *moveFormModel
|
||||
infoForm *infoFormModel
|
||||
portForwardForm *portForwardModel
|
||||
helpForm *helpModel
|
||||
fileSelectorForm *fileSelectorModel
|
||||
|
||||
// Terminal size and styles
|
||||
width int
|
||||
height int
|
||||
styles Styles
|
||||
ready bool
|
||||
|
||||
// Error handling
|
||||
errorMessage string
|
||||
showingError bool
|
||||
}
|
||||
|
||||
// updateTableStyles updates the table header border color based on focus state
|
||||
|
188
internal/ui/move_form.go
Normal file
188
internal/ui/move_form.go
Normal 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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
305
internal/ui/search_test.go
Normal file
305
internal/ui/search_test.go
Normal file
@ -0,0 +1,305 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// createTestModel creates a model with test data for testing
|
||||
func createTestModel() Model {
|
||||
hosts := []config.SSHHost{
|
||||
{Name: "server1", Hostname: "server1.example.com", User: "user1"},
|
||||
{Name: "server2", Hostname: "server2.example.com", User: "user2"},
|
||||
{Name: "server3", Hostname: "server3.example.com", User: "user3"},
|
||||
{Name: "web-server", Hostname: "web.example.com", User: "webuser"},
|
||||
{Name: "db-server", Hostname: "db.example.com", User: "dbuser"},
|
||||
}
|
||||
|
||||
m := Model{
|
||||
hosts: hosts,
|
||||
filteredHosts: hosts,
|
||||
searchInput: textinput.New(),
|
||||
table: table.New(),
|
||||
searchMode: false,
|
||||
ready: true,
|
||||
width: 80,
|
||||
height: 24,
|
||||
styles: NewStyles(80),
|
||||
}
|
||||
|
||||
// Initialize table with test data
|
||||
m.updateTableColumns()
|
||||
m.updateTableRows()
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func TestSearchModeToggle(t *testing.T) {
|
||||
m := createTestModel()
|
||||
|
||||
// Initially should not be in search mode
|
||||
if m.searchMode {
|
||||
t.Error("Model should not start in search mode")
|
||||
}
|
||||
|
||||
// Simulate pressing "/" to enter search mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
// Should now be in search mode
|
||||
if !m.searchMode {
|
||||
t.Error("Model should be in search mode after pressing '/'")
|
||||
}
|
||||
|
||||
// The search input should be focused
|
||||
if !m.searchInput.Focused() {
|
||||
t.Error("Search input should be focused in search mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchFiltering(t *testing.T) {
|
||||
m := createTestModel()
|
||||
|
||||
// Enter search mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
// Type "server" in search
|
||||
for _, char := range "server" {
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
}
|
||||
|
||||
// Should filter to only hosts containing "server"
|
||||
expectedHosts := []string{"server1", "server2", "server3", "web-server", "db-server"}
|
||||
if len(m.filteredHosts) != len(expectedHosts) {
|
||||
t.Errorf("Expected %d filtered hosts, got %d", len(expectedHosts), len(m.filteredHosts))
|
||||
}
|
||||
|
||||
// Check that all filtered hosts contain "server"
|
||||
for _, host := range m.filteredHosts {
|
||||
found := false
|
||||
for _, expected := range expectedHosts {
|
||||
if host.Name == expected {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Unexpected host in filtered results: %s", host.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchFilteringSpecific(t *testing.T) {
|
||||
m := createTestModel()
|
||||
|
||||
// Enter search mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
// Type "web" in search
|
||||
for _, char := range "web" {
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
}
|
||||
|
||||
// Should filter to only hosts containing "web"
|
||||
if len(m.filteredHosts) != 1 {
|
||||
t.Errorf("Expected 1 filtered host, got %d", len(m.filteredHosts))
|
||||
}
|
||||
|
||||
if len(m.filteredHosts) > 0 && m.filteredHosts[0].Name != "web-server" {
|
||||
t.Errorf("Expected 'web-server', got '%s'", m.filteredHosts[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchClearReturnToOriginal(t *testing.T) {
|
||||
m := createTestModel()
|
||||
originalHostCount := len(m.hosts)
|
||||
|
||||
// Enter search mode and type something
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
// Type "web" in search
|
||||
for _, char := range "web" {
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
}
|
||||
|
||||
// Should have filtered results
|
||||
if len(m.filteredHosts) >= originalHostCount {
|
||||
t.Error("Search should have filtered down the results")
|
||||
}
|
||||
|
||||
// Clear the search by simulating backspace
|
||||
for i := 0; i < 3; i++ { // "web" is 3 characters
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
}
|
||||
|
||||
// Should return to all hosts
|
||||
if len(m.filteredHosts) != originalHostCount {
|
||||
t.Errorf("Expected %d hosts after clearing search, got %d", originalHostCount, len(m.filteredHosts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorPositionAfterFiltering(t *testing.T) {
|
||||
m := createTestModel()
|
||||
|
||||
// Move cursor down to position 2 (third item)
|
||||
m.table.SetCursor(2)
|
||||
initialCursor := m.table.Cursor()
|
||||
|
||||
if initialCursor != 2 {
|
||||
t.Errorf("Expected cursor at position 2, got %d", initialCursor)
|
||||
}
|
||||
|
||||
// Enter search mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
// Type "web" - this will filter to only 1 result
|
||||
for _, char := range "web" {
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
}
|
||||
|
||||
// Cursor should be reset to 0 since filtered results has only 1 item
|
||||
// and cursor position 2 would be out of bounds
|
||||
if len(m.filteredHosts) == 1 && m.table.Cursor() != 0 {
|
||||
t.Errorf("Expected cursor to be reset to 0 when filtered results are smaller, got %d", m.table.Cursor())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTabSwitchBetweenSearchAndTable(t *testing.T) {
|
||||
m := createTestModel()
|
||||
|
||||
// Enter search mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
if !m.searchMode {
|
||||
t.Error("Should be in search mode")
|
||||
}
|
||||
|
||||
// Press Tab to switch to table
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyTab}
|
||||
newModel, _ = m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
if m.searchMode {
|
||||
t.Error("Should not be in search mode after Tab")
|
||||
}
|
||||
|
||||
// Press Tab again to switch back to search
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyTab}
|
||||
newModel, _ = m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
if !m.searchMode {
|
||||
t.Error("Should be in search mode after second Tab")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterExitsSearchMode(t *testing.T) {
|
||||
m := createTestModel()
|
||||
|
||||
// Enter search mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
if !m.searchMode {
|
||||
t.Error("Should be in search mode")
|
||||
}
|
||||
|
||||
// Press Enter to exit search mode
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyEnter}
|
||||
newModel, _ = m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
if m.searchMode {
|
||||
t.Error("Should not be in search mode after Enter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchModeDoesNotTriggerOnEmptyInput(t *testing.T) {
|
||||
m := createTestModel()
|
||||
originalHostCount := len(m.hosts)
|
||||
|
||||
// Enter search mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
// At this point, filteredHosts should still be the same as the original hosts
|
||||
// because entering search mode should not trigger filtering with empty input
|
||||
if len(m.filteredHosts) != originalHostCount {
|
||||
t.Errorf("Expected %d hosts when entering search mode, got %d", originalHostCount, len(m.filteredHosts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchByHostname(t *testing.T) {
|
||||
m := createTestModel()
|
||||
|
||||
// Enter search mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
// Search by hostname part "example.com"
|
||||
searchTerm := "example.com"
|
||||
for _, char := range searchTerm {
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
}
|
||||
|
||||
// All hosts should match since they all have "example.com" in hostname
|
||||
if len(m.filteredHosts) != len(m.hosts) {
|
||||
t.Errorf("Expected all %d hosts to match hostname search, got %d", len(m.hosts), len(m.filteredHosts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchByUser(t *testing.T) {
|
||||
m := createTestModel()
|
||||
|
||||
// Enter search mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
|
||||
// Search by user "user1"
|
||||
searchTerm := "user1"
|
||||
for _, char := range searchTerm {
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{char}}
|
||||
newModel, _ := m.Update(keyMsg)
|
||||
m = newModel.(Model)
|
||||
}
|
||||
|
||||
// Only server1 should match
|
||||
if len(m.filteredHosts) != 1 {
|
||||
t.Errorf("Expected 1 host to match user search, got %d", len(m.filteredHosts))
|
||||
}
|
||||
|
||||
if len(m.filteredHosts) > 0 && m.filteredHosts[0].Name != "server1" {
|
||||
t.Errorf("Expected 'server1' to match user search, got '%s'", m.filteredHosts[0].Name)
|
||||
}
|
||||
}
|
@ -4,7 +4,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
)
|
||||
|
||||
// sortHosts sorts hosts according to the current sort mode
|
||||
@ -37,29 +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), 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
|
||||
}
|
||||
|
@ -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
|
||||
@ -42,6 +43,7 @@ type Styles struct {
|
||||
FormContainer lipgloss.Style
|
||||
Label lipgloss.Style
|
||||
FocusedLabel lipgloss.Style
|
||||
HelpSection lipgloss.Style
|
||||
}
|
||||
|
||||
// NewStyles creates a new Styles struct with the given terminal width
|
||||
@ -88,8 +90,7 @@ func NewStyles(width int) Styles {
|
||||
Foreground(lipgloss.Color(SecondaryColor)),
|
||||
|
||||
HelpText: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(SecondaryColor)).
|
||||
MarginTop(1),
|
||||
Foreground(lipgloss.Color(SecondaryColor)),
|
||||
|
||||
// Error style
|
||||
Error: lipgloss.NewStyle().
|
||||
@ -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")).
|
||||
@ -118,8 +124,10 @@ func NewStyles(width int) Styles {
|
||||
Foreground(lipgloss.Color(SecondaryColor)),
|
||||
|
||||
FocusedLabel: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(PrimaryColor)).
|
||||
Bold(true),
|
||||
Foreground(lipgloss.Color(PrimaryColor)),
|
||||
|
||||
HelpSection: lipgloss.NewStyle().
|
||||
Padding(0, 2),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,12 +3,267 @@ package ui
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/history"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/history"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
)
|
||||
|
||||
// calculateDynamicColumnWidths calculates optimal column widths based on terminal width
|
||||
// and content length, ensuring all content fits when possible
|
||||
func (m *Model) calculateDynamicColumnWidths(hosts []config.SSHHost) (int, int, int, int) {
|
||||
if m.width <= 0 {
|
||||
// Fallback to static widths if terminal width is not available
|
||||
return calculateNameColumnWidth(hosts), 25, calculateTagsColumnWidth(hosts), calculateLastLoginColumnWidth(hosts, m.historyManager)
|
||||
}
|
||||
|
||||
// Calculate content lengths
|
||||
maxNameLength := 8 // Minimum for "Name" header + status indicator
|
||||
maxHostnameLength := 8 // Minimum for "Hostname" header
|
||||
maxTagsLength := 8 // Minimum for "Tags" header
|
||||
maxLastLoginLength := 12 // Minimum for "Last Login" header
|
||||
|
||||
for _, host := range hosts {
|
||||
// Name column includes status indicator (2 chars) + space (1 char) + name
|
||||
nameLength := 3 + len(host.Name)
|
||||
if nameLength > maxNameLength {
|
||||
maxNameLength = nameLength
|
||||
}
|
||||
|
||||
if len(host.Hostname) > maxHostnameLength {
|
||||
maxHostnameLength = len(host.Hostname)
|
||||
}
|
||||
|
||||
// Calculate tags string length
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
var formattedTags []string
|
||||
for _, tag := range host.Tags {
|
||||
formattedTags = append(formattedTags, "#"+tag)
|
||||
}
|
||||
tagsStr = strings.Join(formattedTags, " ")
|
||||
}
|
||||
if len(tagsStr) > maxTagsLength {
|
||||
maxTagsLength = len(tagsStr)
|
||||
}
|
||||
|
||||
// Calculate last login length
|
||||
if m.historyManager != nil {
|
||||
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
|
||||
timeStr := formatTimeAgo(lastConnect)
|
||||
if len(timeStr) > maxLastLoginLength {
|
||||
maxLastLoginLength = len(timeStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add padding to each column
|
||||
maxNameLength += 2
|
||||
maxHostnameLength += 2
|
||||
maxTagsLength += 2
|
||||
maxLastLoginLength += 2
|
||||
|
||||
// Calculate available width (minus borders and separators)
|
||||
// Table has borders (2 chars) + column separators (3 chars between 4 columns)
|
||||
availableWidth := m.width - 5
|
||||
|
||||
totalNeededWidth := maxNameLength + maxHostnameLength + maxTagsLength + maxLastLoginLength
|
||||
|
||||
if totalNeededWidth <= availableWidth {
|
||||
// Everything fits perfectly
|
||||
return maxNameLength, maxHostnameLength, maxTagsLength, maxLastLoginLength
|
||||
}
|
||||
|
||||
// Need to adjust widths - prioritize columns by importance
|
||||
// Priority: Name > Hostname > Last Login > Tags
|
||||
|
||||
// Calculate minimum widths
|
||||
minNameWidth := 15 // Enough for status + short name
|
||||
minHostnameWidth := 15
|
||||
minLastLoginWidth := 12
|
||||
minTagsWidth := 10
|
||||
|
||||
remainingWidth := availableWidth
|
||||
|
||||
// Allocate minimum widths first
|
||||
nameWidth := minNameWidth
|
||||
hostnameWidth := minHostnameWidth
|
||||
lastLoginWidth := minLastLoginWidth
|
||||
tagsWidth := minTagsWidth
|
||||
|
||||
remainingWidth -= (nameWidth + hostnameWidth + lastLoginWidth + tagsWidth)
|
||||
|
||||
// Distribute remaining space proportionally
|
||||
if remainingWidth > 0 {
|
||||
// Calculate how much each column wants beyond minimum
|
||||
nameWant := maxNameLength - minNameWidth
|
||||
hostnameWant := maxHostnameLength - minHostnameWidth
|
||||
lastLoginWant := maxLastLoginLength - minLastLoginWidth
|
||||
tagsWant := maxTagsLength - minTagsWidth
|
||||
|
||||
totalWant := nameWant + hostnameWant + lastLoginWant + tagsWant
|
||||
|
||||
if totalWant > 0 {
|
||||
// Distribute proportionally
|
||||
nameExtra := (nameWant * remainingWidth) / totalWant
|
||||
hostnameExtra := (hostnameWant * remainingWidth) / totalWant
|
||||
lastLoginExtra := (lastLoginWant * remainingWidth) / totalWant
|
||||
tagsExtra := remainingWidth - nameExtra - hostnameExtra - lastLoginExtra
|
||||
|
||||
nameWidth += nameExtra
|
||||
hostnameWidth += hostnameExtra
|
||||
lastLoginWidth += lastLoginExtra
|
||||
tagsWidth += tagsExtra
|
||||
}
|
||||
}
|
||||
|
||||
return nameWidth, hostnameWidth, tagsWidth, lastLoginWidth
|
||||
}
|
||||
|
||||
// updateTableRows updates the table with filtered hosts
|
||||
func (m *Model) updateTableRows() {
|
||||
var rows []table.Row
|
||||
hostsToShow := m.filteredHosts
|
||||
if hostsToShow == nil {
|
||||
hostsToShow = m.hosts
|
||||
}
|
||||
|
||||
for _, host := range hostsToShow {
|
||||
// Get ping status indicator
|
||||
statusIndicator := m.getPingStatusIndicator(host.Name)
|
||||
|
||||
// Format tags for display
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
// Add the # prefix to each tag and join them with spaces
|
||||
var formattedTags []string
|
||||
for _, tag := range host.Tags {
|
||||
formattedTags = append(formattedTags, "#"+tag)
|
||||
}
|
||||
tagsStr = strings.Join(formattedTags, " ")
|
||||
}
|
||||
|
||||
// Format last login information
|
||||
var lastLoginStr string
|
||||
if m.historyManager != nil {
|
||||
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
|
||||
lastLoginStr = formatTimeAgo(lastConnect)
|
||||
}
|
||||
}
|
||||
|
||||
rows = append(rows, table.Row{
|
||||
statusIndicator + " " + host.Name,
|
||||
host.Hostname,
|
||||
// host.User, // Commented to save space
|
||||
// host.Port, // Commented to save space
|
||||
tagsStr,
|
||||
lastLoginStr,
|
||||
})
|
||||
}
|
||||
|
||||
m.table.SetRows(rows)
|
||||
|
||||
// Update table height and columns based on current terminal size
|
||||
m.updateTableHeight()
|
||||
m.updateTableColumns()
|
||||
}
|
||||
|
||||
// updateTableHeight dynamically adjusts table height based on terminal size
|
||||
func (m *Model) updateTableHeight() {
|
||||
if !m.ready {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate dynamic table height based on terminal size
|
||||
// Layout breakdown:
|
||||
// - ASCII title: 5 lines (1 empty + 4 text lines)
|
||||
// - Update banner : 1 line (if present)
|
||||
// - Search bar: 1 line
|
||||
// - Help text: 1 line
|
||||
// - App margins/spacing: 3 lines
|
||||
// - Safety margin: 3 lines (to ensure UI elements are always visible)
|
||||
// Total reserved: 14 lines minimum to preserve essential UI elements
|
||||
reservedHeight := 14
|
||||
availableHeight := m.height - reservedHeight
|
||||
hostCount := len(m.table.Rows())
|
||||
|
||||
// Minimum height should be at least 3 rows for basic usability
|
||||
// Even in very small terminals, we want to show at least header + 2 hosts
|
||||
minTableHeight := 4 // 1 header + 3 data rows minimum
|
||||
maxTableHeight := availableHeight
|
||||
if maxTableHeight < minTableHeight {
|
||||
maxTableHeight = minTableHeight
|
||||
}
|
||||
|
||||
tableHeight := 1 // header
|
||||
dataRowsNeeded := hostCount
|
||||
maxDataRows := maxTableHeight - 1 // subtract 1 for header
|
||||
|
||||
if dataRowsNeeded <= maxDataRows {
|
||||
// We have enough space for all hosts
|
||||
tableHeight += dataRowsNeeded
|
||||
} else {
|
||||
// We need to limit to available space
|
||||
tableHeight += maxDataRows
|
||||
}
|
||||
|
||||
// Add one extra line to prevent the last host from being hidden
|
||||
// This compensates for table rendering quirks in bubble tea
|
||||
tableHeight += 1
|
||||
|
||||
// Update table height
|
||||
m.table.SetHeight(tableHeight)
|
||||
}
|
||||
|
||||
// updateTableColumns dynamically adjusts table column widths based on terminal size
|
||||
func (m *Model) updateTableColumns() {
|
||||
if !m.ready {
|
||||
return
|
||||
}
|
||||
|
||||
hostsToShow := m.filteredHosts
|
||||
if hostsToShow == nil {
|
||||
hostsToShow = m.hosts
|
||||
}
|
||||
|
||||
// Use dynamic column width calculation
|
||||
nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(hostsToShow)
|
||||
|
||||
// Create new columns with updated widths and sort indicators
|
||||
nameTitle := "Name"
|
||||
lastLoginTitle := "Last Login"
|
||||
|
||||
// Add sort indicators based on current sort mode
|
||||
switch m.sortMode {
|
||||
case SortByName:
|
||||
nameTitle += " ↓"
|
||||
case SortByLastUsed:
|
||||
lastLoginTitle += " ↓"
|
||||
}
|
||||
|
||||
columns := []table.Column{
|
||||
{Title: nameTitle, Width: nameWidth},
|
||||
{Title: "Hostname", Width: hostnameWidth},
|
||||
// {Title: "User", Width: userWidth}, // Commented to save space
|
||||
// {Title: "Port", Width: portWidth}, // Commented to save space
|
||||
{Title: "Tags", Width: tagsWidth},
|
||||
{Title: lastLoginTitle, Width: lastLoginWidth},
|
||||
}
|
||||
|
||||
m.table.SetColumns(columns)
|
||||
}
|
||||
|
||||
// max returns the maximum of two integers
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Legacy functions for compatibility
|
||||
|
||||
// calculateNameColumnWidth calculates the optimal width for the Name column
|
||||
// based on the longest hostname, with a minimum of 8 and maximum of 40 characters
|
||||
func calculateNameColumnWidth(hosts []config.SSHHost) int {
|
||||
@ -90,159 +345,3 @@ func calculateLastLoginColumnWidth(hosts []config.SSHHost, historyManager *histo
|
||||
|
||||
return maxLength
|
||||
}
|
||||
|
||||
// updateTableRows updates the table with filtered hosts
|
||||
func (m *Model) updateTableRows() {
|
||||
var rows []table.Row
|
||||
hostsToShow := m.filteredHosts
|
||||
if hostsToShow == nil {
|
||||
hostsToShow = m.hosts
|
||||
}
|
||||
|
||||
for _, host := range hostsToShow {
|
||||
// Format tags for display
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
// Add the # prefix to each tag and join them with spaces
|
||||
var formattedTags []string
|
||||
for _, tag := range host.Tags {
|
||||
formattedTags = append(formattedTags, "#"+tag)
|
||||
}
|
||||
tagsStr = strings.Join(formattedTags, " ")
|
||||
}
|
||||
|
||||
// Format last login information
|
||||
var lastLoginStr string
|
||||
if m.historyManager != nil {
|
||||
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
|
||||
lastLoginStr = formatTimeAgo(lastConnect)
|
||||
}
|
||||
}
|
||||
|
||||
rows = append(rows, table.Row{
|
||||
host.Name,
|
||||
host.Hostname,
|
||||
host.User,
|
||||
host.Port,
|
||||
tagsStr,
|
||||
lastLoginStr,
|
||||
})
|
||||
}
|
||||
|
||||
m.table.SetRows(rows)
|
||||
|
||||
// Update table height and columns based on current terminal size
|
||||
m.updateTableHeight()
|
||||
m.updateTableColumns()
|
||||
}
|
||||
|
||||
// updateTableHeight dynamically adjusts table height based on terminal size
|
||||
func (m *Model) updateTableHeight() {
|
||||
if !m.ready {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate dynamic table height based on terminal size
|
||||
// Layout breakdown:
|
||||
// - ASCII title: 5 lines (1 empty + 4 text lines)
|
||||
// - Search bar: 1 line
|
||||
// - Sort info: 1 line
|
||||
// - Help text: 2 lines (multi-line text)
|
||||
// - App margins/spacing: 2 lines
|
||||
// Total reserved: 11 lines, mais réduisons à 7 pour forcer plus d'espace
|
||||
reservedHeight := 7 // Réduction agressive pour tester
|
||||
availableHeight := m.height - reservedHeight
|
||||
hostCount := len(m.table.Rows())
|
||||
|
||||
// Minimum height should be at least 5 rows for usability
|
||||
minTableHeight := 6 // 1 header + 5 data rows
|
||||
maxTableHeight := availableHeight
|
||||
if maxTableHeight < minTableHeight {
|
||||
maxTableHeight = minTableHeight
|
||||
}
|
||||
|
||||
tableHeight := 1 // header
|
||||
dataRowsNeeded := hostCount
|
||||
maxDataRows := maxTableHeight - 1 // subtract 1 for header
|
||||
|
||||
if dataRowsNeeded <= maxDataRows {
|
||||
// We have enough space for all hosts
|
||||
tableHeight += dataRowsNeeded
|
||||
} else {
|
||||
// We need to limit to available space
|
||||
tableHeight += maxDataRows
|
||||
}
|
||||
|
||||
// FORCE: Ajoutons une ligne supplémentaire pour résoudre le problème
|
||||
tableHeight += 1
|
||||
|
||||
// Update table height
|
||||
m.table.SetHeight(tableHeight)
|
||||
}
|
||||
|
||||
// updateTableColumns dynamically adjusts table column widths based on terminal size
|
||||
func (m *Model) updateTableColumns() {
|
||||
if !m.ready {
|
||||
return
|
||||
}
|
||||
|
||||
hostsToShow := m.filteredHosts
|
||||
if hostsToShow == nil {
|
||||
hostsToShow = m.hosts
|
||||
}
|
||||
|
||||
// Calculate base column widths
|
||||
nameWidth := calculateNameColumnWidth(hostsToShow)
|
||||
tagsWidth := calculateTagsColumnWidth(hostsToShow)
|
||||
lastLoginWidth := calculateLastLoginColumnWidth(hostsToShow, m.historyManager)
|
||||
|
||||
// Fixed column widths
|
||||
hostnameWidth := 25
|
||||
userWidth := 12
|
||||
portWidth := 6
|
||||
|
||||
// Calculate total width needed for all columns
|
||||
totalFixedWidth := hostnameWidth + userWidth + portWidth
|
||||
totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth
|
||||
totalWidth := totalFixedWidth + totalVariableWidth
|
||||
|
||||
// Available width (accounting for table borders and padding)
|
||||
availableWidth := m.width - 4 // 4 chars for borders and padding
|
||||
|
||||
// If the table is too wide, scale down the variable columns proportionally
|
||||
if totalWidth > availableWidth {
|
||||
excessWidth := totalWidth - availableWidth
|
||||
variableColumnsWidth := totalVariableWidth
|
||||
|
||||
if variableColumnsWidth > 0 {
|
||||
// Reduce variable columns proportionally
|
||||
nameReduction := (excessWidth * nameWidth) / variableColumnsWidth
|
||||
tagsReduction := (excessWidth * tagsWidth) / variableColumnsWidth
|
||||
lastLoginReduction := excessWidth - nameReduction - tagsReduction
|
||||
|
||||
nameWidth = max(8, nameWidth-nameReduction)
|
||||
tagsWidth = max(8, tagsWidth-tagsReduction)
|
||||
lastLoginWidth = max(10, lastLoginWidth-lastLoginReduction)
|
||||
}
|
||||
}
|
||||
|
||||
// Create new columns with updated widths
|
||||
columns := []table.Column{
|
||||
{Title: "Name", Width: nameWidth},
|
||||
{Title: "Hostname", Width: hostnameWidth},
|
||||
{Title: "User", Width: userWidth},
|
||||
{Title: "Port", Width: portWidth},
|
||||
{Title: "Tags", Width: tagsWidth},
|
||||
{Title: "Last Login", Width: lastLoginWidth},
|
||||
}
|
||||
|
||||
m.table.SetColumns(columns)
|
||||
}
|
||||
|
||||
// max returns the maximum of two integers
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
@ -3,9 +3,11 @@ package ui
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/history"
|
||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
||||
"github.com/Gu1llaum-3/sshm/internal/connectivity"
|
||||
"github.com/Gu1llaum-3/sshm/internal/history"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
@ -14,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 {
|
||||
@ -26,12 +37,18 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
// Create initial styles (will be updated on first WindowSizeMsg)
|
||||
styles := NewStyles(80) // Default width
|
||||
|
||||
// Initialize ping manager with 5 second timeout
|
||||
pingManager := connectivity.NewPingManager(5 * time.Second)
|
||||
|
||||
// Create the model with default sorting by name
|
||||
m := Model{
|
||||
hosts: hosts,
|
||||
historyManager: historyManager,
|
||||
pingManager: pingManager,
|
||||
sortMode: SortByName,
|
||||
configFile: configFile,
|
||||
currentVersion: currentVersion,
|
||||
appConfig: appConfig,
|
||||
styles: styles,
|
||||
width: 80,
|
||||
height: 24,
|
||||
@ -46,23 +63,17 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Search hosts or tags..."
|
||||
ti.CharLimit = 50
|
||||
ti.Width = 50
|
||||
ti.Width = 25
|
||||
|
||||
// Calculate optimal width for the Name column
|
||||
nameWidth := calculateNameColumnWidth(sortedHosts)
|
||||
|
||||
// Calculate optimal width for the Tags column
|
||||
tagsWidth := calculateTagsColumnWidth(sortedHosts)
|
||||
|
||||
// Calculate optimal width for the Last Login column
|
||||
lastLoginWidth := calculateLastLoginColumnWidth(sortedHosts, historyManager)
|
||||
// Use dynamic column width calculation (will fallback to static if width not available)
|
||||
nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(sortedHosts)
|
||||
|
||||
// Create table columns
|
||||
columns := []table.Column{
|
||||
{Title: "Name", Width: nameWidth},
|
||||
{Title: "Hostname", Width: 25},
|
||||
{Title: "User", Width: 12},
|
||||
{Title: "Port", Width: 6},
|
||||
{Title: "Hostname", Width: hostnameWidth},
|
||||
// {Title: "User", Width: 12}, // Commented to save space
|
||||
// {Title: "Port", Width: 6}, // Commented to save space
|
||||
{Title: "Tags", Width: tagsWidth},
|
||||
{Title: "Last Login", Width: lastLoginWidth},
|
||||
}
|
||||
@ -70,6 +81,9 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
// Convert hosts to table rows
|
||||
var rows []table.Row
|
||||
for _, host := range sortedHosts {
|
||||
// Get ping status indicator
|
||||
statusIndicator := m.getPingStatusIndicator(host.Name)
|
||||
|
||||
// Format tags for display
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
@ -90,10 +104,10 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
}
|
||||
|
||||
rows = append(rows, table.Row{
|
||||
host.Name,
|
||||
statusIndicator + " " + host.Name,
|
||||
host.Hostname,
|
||||
host.User,
|
||||
host.Port,
|
||||
// host.User, // Commented to save space
|
||||
// host.Port, // Commented to save space
|
||||
tagsStr,
|
||||
lastLoginStr,
|
||||
})
|
||||
@ -133,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())
|
||||
|
@ -1,21 +1,85 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"sshm/internal/config"
|
||||
"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 and version checking
|
||||
type (
|
||||
pingResultMsg *connectivity.HostPingResult
|
||||
versionCheckMsg *version.UpdateInfo
|
||||
versionErrorMsg error
|
||||
errorMsg string
|
||||
)
|
||||
|
||||
// startPingAllCmd creates a command to ping all hosts concurrently
|
||||
func (m Model) startPingAllCmd() tea.Cmd {
|
||||
if m.pingManager == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return tea.Batch(
|
||||
// Create individual ping commands for each host
|
||||
func() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
for _, host := range m.hosts {
|
||||
cmds = append(cmds, pingSingleHostCmd(m.pingManager, host))
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}(),
|
||||
)
|
||||
}
|
||||
|
||||
// listenForPingResultsCmd is no longer needed since we use individual ping commands
|
||||
|
||||
// pingSingleHostCmd creates a command to ping a single host
|
||||
func pingSingleHostCmd(pingManager *connectivity.PingManager, host config.SSHHost) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result := pingManager.PingHost(ctx, host)
|
||||
return pingResultMsg(result)
|
||||
}
|
||||
}
|
||||
|
||||
// checkVersionCmd creates a command to check for version updates
|
||||
func checkVersionCmd(currentVersion string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
updateInfo, err := version.CheckForUpdates(ctx, currentVersion)
|
||||
if err != nil {
|
||||
return versionErrorMsg(err)
|
||||
}
|
||||
return versionCheckMsg(updateInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the model
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
textinput.Blink,
|
||||
// Ajoute ici d'autres tea.Cmd si tu veux charger des données, démarrer un spinner, etc.
|
||||
)
|
||||
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
|
||||
@ -46,11 +110,60 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.editForm.height = m.height
|
||||
m.editForm.styles = m.styles
|
||||
}
|
||||
if m.moveForm != nil {
|
||||
m.moveForm.width = m.width
|
||||
m.moveForm.height = m.height
|
||||
m.moveForm.styles = m.styles
|
||||
}
|
||||
if m.infoForm != nil {
|
||||
m.infoForm.width = m.width
|
||||
m.infoForm.height = m.height
|
||||
m.infoForm.styles = m.styles
|
||||
}
|
||||
if m.portForwardForm != nil {
|
||||
m.portForwardForm.width = m.width
|
||||
m.portForwardForm.height = m.height
|
||||
m.portForwardForm.styles = m.styles
|
||||
}
|
||||
if m.helpForm != nil {
|
||||
m.helpForm.width = m.width
|
||||
m.helpForm.height = m.height
|
||||
m.helpForm.styles = m.styles
|
||||
}
|
||||
if m.fileSelectorForm != nil {
|
||||
m.fileSelectorForm.width = m.width
|
||||
m.fileSelectorForm.height = m.height
|
||||
m.fileSelectorForm.styles = m.styles
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case pingResultMsg:
|
||||
// Handle ping result - update table display
|
||||
if msg != nil {
|
||||
// Update the table to reflect the new ping status
|
||||
m.updateTableRows()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case versionCheckMsg:
|
||||
// Handle version check result
|
||||
if msg != nil {
|
||||
m.updateInfo = msg
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case versionErrorMsg:
|
||||
// Handle version check error (silently - not critical)
|
||||
// We don't want to show error messages for version checks
|
||||
// as it might disrupt the user experience
|
||||
return m, nil
|
||||
|
||||
case errorMsg:
|
||||
// Handle general error messages
|
||||
if string(msg) == "clear" {
|
||||
m.showingError = false
|
||||
m.errorMessage = ""
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case addFormSubmitMsg:
|
||||
@ -141,6 +254,88 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
|
||||
case moveFormSubmitMsg:
|
||||
if msg.err != nil {
|
||||
// En cas d'erreur, on pourrait afficher une notification ou retourner à la liste
|
||||
// Pour l'instant, on retourne simplement à la liste
|
||||
m.viewMode = ViewList
|
||||
m.moveForm = nil
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
} else {
|
||||
// Success: refresh hosts and return to list view
|
||||
var hosts []config.SSHHost
|
||||
var err error
|
||||
|
||||
if m.configFile != "" {
|
||||
hosts, err = config.ParseSSHConfigFile(m.configFile)
|
||||
} else {
|
||||
hosts, err = config.ParseSSHConfig()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.hosts = m.sortHosts(hosts)
|
||||
|
||||
// Reapply search filter if there is one active
|
||||
if m.searchInput.Value() != "" {
|
||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||
} else {
|
||||
m.filteredHosts = m.hosts
|
||||
}
|
||||
|
||||
m.updateTableRows()
|
||||
m.viewMode = ViewList
|
||||
m.moveForm = nil
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case moveFormCancelMsg:
|
||||
// Cancel: return to list view
|
||||
m.viewMode = ViewList
|
||||
m.moveForm = nil
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
|
||||
case infoFormCancelMsg:
|
||||
// Cancel: return to list view
|
||||
m.viewMode = ViewList
|
||||
m.infoForm = nil
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
|
||||
case fileSelectorMsg:
|
||||
if msg.cancelled {
|
||||
// Cancel: return to list view
|
||||
m.viewMode = ViewList
|
||||
m.fileSelectorForm = nil
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
} else {
|
||||
// File selected: proceed to add form with selected file
|
||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, msg.selectedFile)
|
||||
m.viewMode = ViewAdd
|
||||
m.fileSelectorForm = nil
|
||||
return m, textinput.Blink
|
||||
}
|
||||
|
||||
case infoFormEditMsg:
|
||||
// Switch from info to edit mode
|
||||
editForm, err := NewEditForm(msg.hostName, m.styles, m.width, m.height, m.configFile)
|
||||
if err != nil {
|
||||
// Handle error - could show in UI, for now just go back to list
|
||||
m.viewMode = ViewList
|
||||
m.infoForm = nil
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
m.editForm = editForm
|
||||
m.infoForm = nil
|
||||
m.viewMode = ViewEdit
|
||||
return m, textinput.Blink
|
||||
|
||||
case portForwardSubmitMsg:
|
||||
if msg.err != nil {
|
||||
// Show error in form
|
||||
@ -180,6 +375,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
|
||||
case helpCloseMsg:
|
||||
// Close help: return to list view
|
||||
m.viewMode = ViewList
|
||||
m.helpForm = nil
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
// Handle view-specific key presses
|
||||
switch m.viewMode {
|
||||
@ -192,9 +394,23 @@ 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:
|
||||
if m.infoForm != nil {
|
||||
var newForm *infoFormModel
|
||||
newForm, cmd = m.infoForm.Update(msg)
|
||||
m.infoForm = newForm
|
||||
return m, cmd
|
||||
}
|
||||
case ViewPortForward:
|
||||
@ -204,6 +420,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.portForwardForm = newForm
|
||||
return m, cmd
|
||||
}
|
||||
case ViewHelp:
|
||||
if m.helpForm != nil {
|
||||
var newForm *helpModel
|
||||
newForm, cmd = m.helpForm.Update(msg)
|
||||
m.helpForm = newForm
|
||||
return m, cmd
|
||||
}
|
||||
case ViewFileSelector:
|
||||
if m.fileSelectorForm != nil {
|
||||
var newForm *fileSelectorModel
|
||||
newForm, cmd = m.fileSelectorForm.Update(msg)
|
||||
m.fileSelectorForm = newForm
|
||||
return m, cmd
|
||||
}
|
||||
case ViewList:
|
||||
// Handle list view keys
|
||||
return m.handleListViewKeys(msg)
|
||||
@ -215,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
|
||||
@ -225,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 {
|
||||
@ -237,6 +474,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.updateTableStyles()
|
||||
m.table.Blur()
|
||||
m.searchInput.Focus()
|
||||
// Don't trigger filtering when entering search mode - wait for user input
|
||||
return m, textinput.Blink
|
||||
}
|
||||
case "tab":
|
||||
@ -254,6 +492,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.updateTableStyles()
|
||||
m.table.Blur()
|
||||
m.searchInput.Focus()
|
||||
// Don't trigger filtering when switching to search mode
|
||||
return m, textinput.Blink
|
||||
}
|
||||
return m, nil
|
||||
@ -316,7 +555,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Connect to the selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // The hostname is in the first column
|
||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
||||
|
||||
// Record the connection in history
|
||||
if m.historyManager != nil {
|
||||
@ -345,7 +584,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Edit the selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // The hostname is in the first column
|
||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
||||
editForm, err := NewEditForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||
if err != nil {
|
||||
// Handle error - could show in UI
|
||||
@ -356,11 +595,79 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, textinput.Blink
|
||||
}
|
||||
}
|
||||
case "m":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Move the selected host to another config file
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
||||
moveForm, err := NewMoveForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||
if err != nil {
|
||||
// Show error message to user
|
||||
m.errorMessage = err.Error()
|
||||
m.showingError = true
|
||||
return m, func() tea.Msg {
|
||||
time.Sleep(3 * time.Second) // Show error for 3 seconds
|
||||
return errorMsg("clear")
|
||||
}
|
||||
}
|
||||
m.moveForm = moveForm
|
||||
m.viewMode = ViewMove
|
||||
return m, textinput.Blink
|
||||
}
|
||||
}
|
||||
case "i":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Show info for the selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
||||
infoForm, err := NewInfoForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||
if err != nil {
|
||||
// Handle error - could show in UI
|
||||
return m, nil
|
||||
}
|
||||
m.infoForm = infoForm
|
||||
m.viewMode = ViewInfo
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
case "a":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Add a new host
|
||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile)
|
||||
m.viewMode = ViewAdd
|
||||
// Check if there are multiple config files starting from the current base config
|
||||
var configFiles []string
|
||||
var err error
|
||||
|
||||
if m.configFile != "" {
|
||||
// Use the specified config file as base
|
||||
configFiles, err = config.GetAllConfigFilesFromBase(m.configFile)
|
||||
} else {
|
||||
// Use the default config file as base
|
||||
configFiles, err = config.GetAllConfigFiles()
|
||||
}
|
||||
|
||||
if err != nil || len(configFiles) <= 1 {
|
||||
// Only one config file (or error), go directly to add form
|
||||
var configFile string
|
||||
if len(configFiles) == 1 {
|
||||
configFile = configFiles[0]
|
||||
} else {
|
||||
configFile = m.configFile
|
||||
}
|
||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, configFile)
|
||||
m.viewMode = ViewAdd
|
||||
} else {
|
||||
// Multiple config files, show file selector
|
||||
fileSelectorForm, err := NewFileSelectorFromBase("Select config file to add host to:", m.styles, m.width, m.height, m.configFile)
|
||||
if err != nil {
|
||||
// Fallback to default behavior if file selector fails
|
||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile)
|
||||
m.viewMode = ViewAdd
|
||||
} else {
|
||||
m.fileSelectorForm = fileSelectorForm
|
||||
m.viewMode = ViewFileSelector
|
||||
}
|
||||
}
|
||||
return m, textinput.Blink
|
||||
}
|
||||
case "d":
|
||||
@ -368,24 +675,36 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Delete the selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // The hostname is in the first column
|
||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
||||
m.deleteMode = true
|
||||
m.deleteHost = hostName
|
||||
m.table.Blur()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
case "p":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Ping all hosts
|
||||
return m, m.startPingAllCmd()
|
||||
}
|
||||
case "f":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Port forwarding for the selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // The hostname is in the first column
|
||||
m.portForwardForm = NewPortForwardForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
||||
m.portForwardForm = NewPortForwardForm(hostName, m.styles, m.width, m.height, m.configFile, m.historyManager)
|
||||
m.viewMode = ViewPortForward
|
||||
return m, textinput.Blink
|
||||
}
|
||||
}
|
||||
case "h":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Show help
|
||||
m.helpForm = NewHelpForm(m.styles, m.width, m.height)
|
||||
m.viewMode = ViewHelp
|
||||
return m, nil
|
||||
}
|
||||
case "s":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Cycle through sort modes (only 2 modes now)
|
||||
@ -433,12 +752,17 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||
// Update filtered hosts only if the search value has changed
|
||||
if m.searchInput.Value() != oldValue {
|
||||
currentCursor := m.table.Cursor()
|
||||
if m.searchInput.Value() != "" {
|
||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||
} else {
|
||||
m.filteredHosts = m.sortHosts(m.hosts)
|
||||
}
|
||||
m.updateTableRows()
|
||||
// If the current cursor position is beyond the filtered results, reset to 0
|
||||
if currentCursor >= len(m.filteredHosts) && len(m.filteredHosts) > 0 {
|
||||
m.table.SetCursor(0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
m.table, cmd = m.table.Update(msg)
|
||||
|
@ -2,6 +2,8 @@ package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/Gu1llaum-3/sshm/internal/connectivity"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -55,3 +57,49 @@ func formatTimeAgo(t time.Time) string {
|
||||
return fmt.Sprintf("%d years ago", years)
|
||||
}
|
||||
}
|
||||
|
||||
// formatConfigFile formats a config file path for display
|
||||
func formatConfigFile(filePath string) string {
|
||||
if filePath == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
// Show just the filename and parent directory for readability
|
||||
parts := strings.Split(filePath, "/")
|
||||
if len(parts) >= 2 {
|
||||
return fmt.Sprintf(".../%s/%s", parts[len(parts)-2], parts[len(parts)-1])
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
// getPingStatusIndicator returns a colored circle indicator based on ping status
|
||||
func (m *Model) getPingStatusIndicator(hostName string) string {
|
||||
if m.pingManager == nil {
|
||||
return "⚫" // Gray circle for unknown
|
||||
}
|
||||
|
||||
status := m.pingManager.GetStatus(hostName)
|
||||
switch status {
|
||||
case connectivity.StatusOnline:
|
||||
return "🟢" // Green circle for online
|
||||
case connectivity.StatusOffline:
|
||||
return "🔴" // Red circle for offline
|
||||
case connectivity.StatusConnecting:
|
||||
return "🟡" // Yellow circle for connecting
|
||||
default:
|
||||
return "⚫" // Gray circle for unknown
|
||||
}
|
||||
}
|
||||
|
||||
// extractHostNameFromTableRow extracts the host name from the first column,
|
||||
// removing the ping status indicator
|
||||
func extractHostNameFromTableRow(firstColumn string) string {
|
||||
// The first column format is: "🟢 hostname" or "⚫ hostname" etc.
|
||||
// We need to remove the emoji and space to get just the hostname
|
||||
parts := strings.Fields(firstColumn)
|
||||
if len(parts) >= 2 {
|
||||
// Return everything after the first part (the emoji)
|
||||
return strings.Join(parts[1:], " ")
|
||||
}
|
||||
// Fallback: if there's no space, return the whole string
|
||||
return firstColumn
|
||||
}
|
||||
|
@ -23,10 +23,26 @@ func (m Model) View() string {
|
||||
if m.editForm != nil {
|
||||
return m.editForm.View()
|
||||
}
|
||||
case ViewMove:
|
||||
if m.moveForm != nil {
|
||||
return m.moveForm.View()
|
||||
}
|
||||
case ViewInfo:
|
||||
if m.infoForm != nil {
|
||||
return m.infoForm.View()
|
||||
}
|
||||
case ViewPortForward:
|
||||
if m.portForwardForm != nil {
|
||||
return m.portForwardForm.View()
|
||||
}
|
||||
case ViewHelp:
|
||||
if m.helpForm != nil {
|
||||
return m.helpForm.View()
|
||||
}
|
||||
case ViewFileSelector:
|
||||
if m.fileSelectorForm != nil {
|
||||
return m.fileSelectorForm.View()
|
||||
}
|
||||
case ViewList:
|
||||
return m.renderListView()
|
||||
}
|
||||
@ -42,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 {
|
||||
@ -50,10 +94,6 @@ func (m Model) renderListView() string {
|
||||
components = append(components, m.styles.SearchUnfocused.Render(searchPrompt+m.searchInput.View()))
|
||||
}
|
||||
|
||||
// Add the sort mode indicator
|
||||
sortInfo := fmt.Sprintf(" Sort: %s", m.sortMode.String())
|
||||
components = append(components, m.styles.SortInfo.Render(sortInfo))
|
||||
|
||||
// Add the table with the appropriate style based on focus
|
||||
if m.searchMode {
|
||||
// The table is not focused, use the unfocused style
|
||||
@ -66,9 +106,9 @@ func (m Model) renderListView() string {
|
||||
// Add the help text
|
||||
var helpText string
|
||||
if !m.searchMode {
|
||||
helpText = " Use ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • (f)orward • / to search • Tab to switch\n Sort: (s)witch • (r)ecent • (n)ame • q/ESC to quit"
|
||||
helpText = " ↑/↓: navigate • Enter: connect • p: ping all • i: info • h: help • q: quit"
|
||||
} else {
|
||||
helpText = " Type to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit"
|
||||
helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit"
|
||||
}
|
||||
components = append(components, m.styles.HelpText.Render(helpText))
|
||||
|
||||
@ -149,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)
|
||||
}
|
||||
|
187
internal/validation/ssh_test.go
Normal file
187
internal/validation/ssh_test.go
Normal 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
145
internal/version/version.go
Normal 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
|
||||
}
|
56
internal/version/version_test.go
Normal file
56
internal/version/version_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user