9 Commits

Author SHA1 Message Date
2e84ac002e feat: reorganize release notes 2025-10-10 22:02:00 +02:00
6ba82b1c97 feat: filter non-SSH files from config parsing
- Skip README, .git, and documentation files during SSH config parsing
- Add QuickHostExists for fast host validation without full parsing
- Prevent errors when Include * encounters non-config files
2025-10-10 21:47:13 +02:00
42e87b6827 feat: add ARM v6/v7 support and version selection to install script
- Support ARM v6/v7 architectures for Raspberry Pi
- Add SSHM_VERSION env var for specific version installation
- Add beta/pre-release version support
- Add version validation and --help flag
- Fix architecture mapping for GoReleaser binaries
2025-10-10 21:22:56 +02:00
d686d97f8c fix: SSH identity file paths with spaces and edit form navigation
- Quote IdentityFile paths containing spaces to prevent SSH config errors
- Fix edit form ESC/Ctrl+C to return to main view instead of quitting
- Improve edit form navigation consistency with add form
- Fix focus management when adding host fields with Ctrl+A
2025-10-09 22:04:36 +02:00
8d5f59fab2 feat: add multi-host block support for SSH config management
- Support "Host server1 server2 server3" syntax in SSH configurations
- Add multi-host editing UI with separate host name inputs
- Implement multi-host block update and deletion operations
- Add comprehensive test coverage
- Maintain backward compatibility with single-host configs
2025-10-09 20:46:10 +02:00
049998c235 docs: update README.md and remove CONFIG.md 2025-10-04 17:02:21 +02:00
Guillaume Archambault
5986659048 Merge pull request #11 from qingfengzxr/main
feat: add configurable key bindings with ESC quit disable option
2025-10-04 17:00:56 +02:00
Guillaume Archambault
abbda54125 Merge pull request #12 from ldreux/support-subsearch
feat: support multiple words search
2025-10-04 16:24:06 +02:00
Loïc Dreux
986017a552 feat: support multiple words search 2025-10-01 12:07:05 +02:00
10 changed files with 2081 additions and 404 deletions

View File

@@ -19,6 +19,9 @@ builds:
- arm64 - arm64
- "386" - "386"
- arm - arm
goarm:
- "6"
- "7"
ignore: ignore:
# Skip ARM for Windows (not commonly used) # Skip ARM for Windows (not commonly used)
- goos: windows - goos: windows
@@ -104,20 +107,31 @@ release:
header: | header: |
## SSHM {{.Version}} ## SSHM {{.Version}}
Thank you for downloading SSHM! Thank you for downloading SSHM!
### Installation footer: |
## Installation
**Homebrew (macOS/Linux):** ### Homebrew (macOS/Linux)
```bash ```bash
brew tap Gu1llaum-3/sshm brew tap Gu1llaum-3/sshm
brew install sshm brew install sshm
``` ```
**Manual Installation:** ### Installation Script (Recommended)
Download the appropriate binary for your platform from the assets below. **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.
footer: |
## Full Changelog ## Full Changelog
See all changes at https://github.com/Gu1llaum-3/sshm/compare/{{.PreviousTag}}...{{.Tag}} See all changes at https://github.com/Gu1llaum-3/sshm/compare/{{.PreviousTag}}...{{.Tag}}

View File

@@ -1,66 +0,0 @@
# SSHM Configuration
SSHM supports configurable key bindings through a configuration file located at:
- Linux/macOS: `~/.config/sshm/config.json`
- Windows: `%APPDATA%\sshm\config.json`
## Configuration Options
### Key Bindings
The key bindings section allows you to customize how you exit the application.
#### Example Configuration
```json
{
"key_bindings": {
"quit_keys": ["q", "ctrl+c"],
"disable_esc_quit": true
}
}
```
#### 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're a vim user and frequently press ESC accidentally causing the application to quit, set `disable_esc_quit` to `true`:
```json
{
"key_bindings": {
"quit_keys": ["q", "ctrl+c"],
"disable_esc_quit": true
}
}
```
With this configuration:
- ESC will no longer quit the application
- You can still quit using 'q' or Ctrl+C
- All other functionality remains the same
## Default Configuration
If no configuration file exists, SSHM will create one with these defaults:
```json
{
"key_bindings": {
"quit_keys": ["q", "ctrl+c"],
"disable_esc_quit": false
}
}
```
This ensures backward compatibility - ESC will continue to work as a quit key by default.
## Configuration Location
The configuration file will be automatically created when you first run SSHM. You can manually edit it to customize the key bindings to your preference.
If you encounter any issues with the configuration file, you can delete it and SSHM will recreate it with default settings on the next run.

View File

@@ -553,6 +553,34 @@ This will be automatically converted to:
StrictHostKeyChecking no 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 ## 🛠️ Development
### Prerequisites ### Prerequisites
@@ -669,6 +697,8 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- [Charm](https://charm.sh/) for the amazing TUI libraries - [Charm](https://charm.sh/) for the amazing TUI libraries
- [Cobra](https://cobra.dev/) for the excellent CLI framework - [Cobra](https://cobra.dev/) for the excellent CLI framework
- [@yimeng](https://github.com/yimeng) for contributing SSH Include directive support - [@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 - The Go community for building such fantastic tools
--- ---

View File

@@ -103,27 +103,18 @@ func runInteractiveMode() {
} }
func connectToHost(hostName string) { func connectToHost(hostName string) {
// Parse SSH configurations to verify host exists // Quick check if host exists without full parsing (optimized for connection)
var hosts []config.SSHHost var hostFound bool
var err error var err error
if configFile != "" { if configFile != "" {
hosts, err = config.ParseSSHConfigFile(configFile) hostFound, err = config.QuickHostExistsInFile(hostName, configFile)
} else { } else {
hosts, err = config.ParseSSHConfig() hostFound, err = config.QuickHostExists(hostName)
} }
if err != nil { if err != nil {
log.Fatalf("Error reading SSH config file: %v", err) log.Fatalf("Error checking SSH config: %v", err)
}
// Check if host exists
var hostFound bool
for _, host := range hosts {
if host.Name == hostName {
hostFound = true
break
}
} }
if !hostFound { if !hostFound {

View File

@@ -7,6 +7,7 @@ USE_SUDO="false"
OS="" OS=""
ARCH="" ARCH=""
FORCE_INSTALL="${FORCE_INSTALL:-false}" FORCE_INSTALL="${FORCE_INSTALL:-false}"
SSHM_VERSION="${SSHM_VERSION:-latest}"
RED='\033[0;31m' RED='\033[0;31m'
PURPLE='\033[0;35m' PURPLE='\033[0;35m'
@@ -14,13 +15,27 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
NC='\033[0m' 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() { setSystem() {
ARCH=$(uname -m) ARCH=$(uname -m)
case $ARCH in case $ARCH in
i386|i686) ARCH="amd64" ;; i386|i686) ARCH="amd64" ;;
x86_64) ARCH="amd64";; x86_64) ARCH="amd64";;
armv6*) ARCH="arm64" ;; armv6*) ARCH="armv6" ;;
armv7*) ARCH="arm64" ;; armv7*) ARCH="armv7" ;;
aarch64*) ARCH="arm64" ;; aarch64*) ARCH="arm64" ;;
arm64) ARCH="arm64" ;; arm64) ARCH="arm64" ;;
esac esac
@@ -46,13 +61,25 @@ runAsRoot() {
} }
getLatestVersion() { getLatestVersion() {
printf "${YELLOW}Fetching latest version...${NC}\n" if [ "$SSHM_VERSION" = "latest" ]; then
LATEST_VERSION=$(curl -s https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') printf "${YELLOW}Fetching latest stable version...${NC}\n"
if [ -z "$LATEST_VERSION" ]; then LATEST_VERSION=$(curl -s https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
printf "${RED}Failed to fetch latest version${NC}\n" if [ -z "$LATEST_VERSION" ]; then
exit 1 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 fi
printf "${GREEN}Latest version: $LATEST_VERSION${NC}\n" printf "${GREEN}Installing version: $LATEST_VERSION${NC}\n"
} }
downloadBinary() { downloadBinary() {
@@ -70,10 +97,11 @@ downloadBinary() {
"amd64") GORELEASER_ARCH="x86_64" ;; "amd64") GORELEASER_ARCH="x86_64" ;;
"arm64") GORELEASER_ARCH="arm64" ;; "arm64") GORELEASER_ARCH="arm64" ;;
"386") GORELEASER_ARCH="i386" ;; "386") GORELEASER_ARCH="i386" ;;
"arm") GORELEASER_ARCH="armv6" ;; "armv6") GORELEASER_ARCH="armv6" ;;
"armv7") GORELEASER_ARCH="armv7" ;;
esac esac
# GoReleaser format: sshm_Darwin_arm64.tar.gz # GoReleaser format: sshm_Linux_armv7.tar.gz
GITHUB_FILE="sshm_${GORELEASER_OS}_${GORELEASER_ARCH}.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" GITHUB_URL="https://github.com/Gu1llaum-3/sshm/releases/download/$LATEST_VERSION/$GITHUB_FILE"
@@ -176,18 +204,24 @@ checkExisting() {
} }
main() { 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 printf "${PURPLE}Installing SSHM - SSH Connection Manager${NC}\n\n"
checkExisting
# Set up system detection # Set up system detection
setSystem setSystem
printf "${GREEN}Detected system: $OS ($ARCH)${NC}\n" printf "${GREEN}Detected system: $OS ($ARCH)${NC}\n"
# Get latest version # Get and validate version FIRST (this can fail early)
getLatestVersion getLatestVersion
# Check if already installed (this might prompt user)
checkExisting
# Download and install # Download and install
downloadBinary downloadBinary
install install

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,6 +1,7 @@
package ui package ui
import ( import (
"fmt"
"strings" "strings"
"github.com/Gu1llaum-3/sshm/internal/config" "github.com/Gu1llaum-3/sshm/internal/config"
@@ -11,20 +12,36 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
type editFormModel struct { const (
inputs []textinput.Model focusAreaHosts = iota
focused int focusAreaProperties
err string )
success bool
styles Styles type editFormSubmitMsg struct {
originalName string hostname string
host *config.SSHHost // Store the original host with SourceFile err error
width int
height int
configFile string
} }
// 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
err string
success bool
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) { func NewEditForm(hostName string, styles Styles, width, height int, configFile string) (*editFormModel, error) {
// Get the existing host configuration // Get the existing host configuration
var host *config.SSHHost var host *config.SSHHost
@@ -40,104 +57,197 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
return nil, err 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 if configFile != "" {
inputs[nameInput] = textinput.New() actualConfigFile = configFile
inputs[nameInput].Placeholder = "server-name" } else {
inputs[nameInput].Focus() actualConfigFile = host.SourceFile
inputs[nameInput].CharLimit = 50 }
inputs[nameInput].Width = 30
inputs[nameInput].SetValue(host.Name) 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, 7) // Reduced from 8 since we removed nameInput
// Hostname input // Hostname input
inputs[hostnameInput] = textinput.New() inputs[0] = textinput.New()
inputs[hostnameInput].Placeholder = "192.168.1.100 or example.com" inputs[0].Placeholder = "192.168.1.100 or example.com"
inputs[hostnameInput].CharLimit = 100 inputs[0].CharLimit = 100
inputs[hostnameInput].Width = 30 inputs[0].Width = 30
inputs[hostnameInput].SetValue(host.Hostname) inputs[0].SetValue(host.Hostname)
// User input // User input
inputs[userInput] = textinput.New() inputs[1] = textinput.New()
inputs[userInput].Placeholder = "root" inputs[1].Placeholder = "root"
inputs[userInput].CharLimit = 50 inputs[1].CharLimit = 50
inputs[userInput].Width = 30 inputs[1].Width = 30
inputs[userInput].SetValue(host.User) inputs[1].SetValue(host.User)
// Port input // Port input
inputs[portInput] = textinput.New() inputs[2] = textinput.New()
inputs[portInput].Placeholder = "22" inputs[2].Placeholder = "22"
inputs[portInput].CharLimit = 5 inputs[2].CharLimit = 5
inputs[portInput].Width = 30 inputs[2].Width = 30
inputs[portInput].SetValue(host.Port) inputs[2].SetValue(host.Port)
// Identity input // Identity input
inputs[identityInput] = textinput.New() inputs[3] = textinput.New()
inputs[identityInput].Placeholder = "~/.ssh/id_rsa" inputs[3].Placeholder = "~/.ssh/id_rsa"
inputs[identityInput].CharLimit = 200 inputs[3].CharLimit = 200
inputs[identityInput].Width = 50 inputs[3].Width = 50
inputs[identityInput].SetValue(host.Identity) inputs[3].SetValue(host.Identity)
// ProxyJump input // ProxyJump input
inputs[proxyJumpInput] = textinput.New() inputs[4] = textinput.New()
inputs[proxyJumpInput].Placeholder = "user@jump-host:port or existing-host-name" inputs[4].Placeholder = "jump-server"
inputs[proxyJumpInput].CharLimit = 200 inputs[4].CharLimit = 100
inputs[proxyJumpInput].Width = 50 inputs[4].Width = 30
inputs[proxyJumpInput].SetValue(host.ProxyJump) inputs[4].SetValue(host.ProxyJump)
// SSH Options input // Options input
inputs[optionsInput] = textinput.New() inputs[5] = textinput.New()
inputs[optionsInput].Placeholder = "-o Compression=yes -o ServerAliveInterval=60" inputs[5].Placeholder = "-o StrictHostKeyChecking=no"
inputs[optionsInput].CharLimit = 500 inputs[5].CharLimit = 200
inputs[optionsInput].Width = 70 inputs[5].Width = 50
inputs[optionsInput].SetValue(config.FormatSSHOptionsForCommand(host.Options)) if host.Options != "" {
inputs[5].SetValue(config.FormatSSHOptionsForCommand(host.Options))
}
// Tags input // Tags input
inputs[tagsInput] = textinput.New() inputs[6] = textinput.New()
inputs[tagsInput].Placeholder = "production, web, database" inputs[6].Placeholder = "production, web, database"
inputs[tagsInput].CharLimit = 200 inputs[6].CharLimit = 200
inputs[tagsInput].Width = 50 inputs[6].Width = 50
if len(host.Tags) > 0 { if len(host.Tags) > 0 {
inputs[tagsInput].SetValue(strings.Join(host.Tags, ", ")) inputs[6].SetValue(strings.Join(host.Tags, ", "))
} }
return &editFormModel{ return &editFormModel{
inputs: inputs, hostInputs: hostInputs,
focused: nameInput, inputs: inputs,
originalName: hostName, focusArea: focusAreaHosts, // Start with hosts focused for multi-host editing
host: host, focused: 0,
configFile: configFile, originalName: hostName,
styles: styles, originalHosts: hostNames,
width: width, host: host,
height: height, configFile: configFile,
actualConfigFile: actualConfigFile,
styles: styles,
width: width,
height: height,
}, nil }, nil
} }
// Messages for communication with parent model
type editFormSubmitMsg struct {
hostname string
err error
}
type editFormCancelMsg struct{}
func (m *editFormModel) Init() tea.Cmd { func (m *editFormModel) Init() tea.Cmd {
return textinput.Blink 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
}
func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
m.styles = NewStyles(m.width)
return m, nil
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "ctrl+c", "esc": case "ctrl+c", "esc":
m.err = ""
m.success = false
return m, func() tea.Msg { return editFormCancelMsg{} } return m, func() tea.Msg { return editFormCancelMsg{} }
case "ctrl+s": case "ctrl+s":
@@ -148,96 +258,141 @@ func (m *editFormModel) Update(msg tea.Msg) (*editFormModel, tea.Cmd) {
s := msg.String() s := msg.String()
// Handle form submission // Handle form submission
if s == "enter" && m.focused == len(m.inputs)-1 { totalFields := len(m.hostInputs) + len(m.inputs)
currentGlobalIndex := m.focused
if m.focusArea == focusAreaProperties {
currentGlobalIndex = len(m.hostInputs) + m.focused
}
if s == "enter" && currentGlobalIndex == totalFields-1 {
return m, m.submitEditForm() return m, m.submitEditForm()
} }
// Cycle inputs // Cycle inputs
if s == "up" || s == "shift+tab" { if s == "up" || s == "shift+tab" {
m.focused-- currentGlobalIndex--
} else { } else {
m.focused++ currentGlobalIndex++
} }
if m.focused > len(m.inputs)-1 { if currentGlobalIndex >= totalFields {
m.focused = 0 currentGlobalIndex = 0
} else if m.focused < 0 { } else if currentGlobalIndex < 0 {
m.focused = len(m.inputs) - 1 currentGlobalIndex = totalFields - 1
} }
for i := range m.inputs { // Update focus area and focused index based on global index
if i == m.focused { if currentGlobalIndex < len(m.hostInputs) {
cmds = append(cmds, m.inputs[i].Focus()) m.focusArea = focusAreaHosts
continue m.focused = currentGlobalIndex
} } else {
m.inputs[i].Blur() m.focusArea = focusAreaProperties
m.focused = currentGlobalIndex - len(m.hostInputs)
} }
return m, tea.Batch(cmds...) return m, m.updateFocus()
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()
}
} }
case editFormSubmitMsg: case editFormSubmitMsg:
if msg.err != nil { if msg.err != nil {
m.err = msg.err.Error() m.err = msg.err.Error()
m.success = false
} else { } else {
m.success = true m.success = true
m.err = "" m.err = ""
// Don't quit here, let parent handle the success
} }
return m, nil return m, nil
} }
// Update inputs // Update host inputs
cmd := make([]tea.Cmd, len(m.inputs)) hostCmd := make([]tea.Cmd, len(m.hostInputs))
for i := range m.inputs { for i := range m.hostInputs {
m.inputs[i], cmd[i] = m.inputs[i].Update(msg) 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...) return m, tea.Batch(cmds...)
} }
func (m *editFormModel) View() string { func (m *editFormModel) View() string {
if m.success {
return ""
}
var b strings.Builder var b strings.Builder
b.WriteString(m.styles.FormTitle.Render("Edit SSH Host Configuration")) if m.success {
b.WriteString("\n") b.WriteString(m.styles.FormField.Foreground(lipgloss.Color("#10B981")).Render("✓ Host updated successfully!"))
b.WriteString("\n\n")
b.WriteString(m.styles.FormHelp.Render("Press Ctrl+C or Esc to go back"))
return b.String()
}
if m.err != "" {
b.WriteString(m.styles.Error.Render("Error: " + m.err))
b.WriteString("\n\n")
}
b.WriteString(m.styles.Header.Render("Edit SSH Host"))
b.WriteString("\n\n")
// Show source file information
if m.host != nil && m.host.SourceFile != "" { if m.host != nil && m.host.SourceFile != "" {
b.WriteString("\n") // Ligne d'espace avant Config file labelStyle := m.styles.FormField
pathStyle := m.styles.FormField
// Style for "Config file:" label in primary color
labelStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00ADD8")). // Primary color
Bold(true)
// Style for the file path in white
pathStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
configInfo := labelStyle.Render("Config file: ") + pathStyle.Render(formatConfigFile(m.host.SourceFile)) configInfo := labelStyle.Render("Config file: ") + pathStyle.Render(formatConfigFile(m.host.SourceFile))
b.WriteString(configInfo) 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(hostInput.View())
b.WriteString("\n\n")
}
// Properties Section
b.WriteString(m.styles.FormTitle.Render("Common Properties"))
b.WriteString("\n\n") b.WriteString("\n\n")
fields := []string{ fields := []string{
"Host Name *",
"Hostname/IP *", "Hostname/IP *",
"User", "User",
"Port", "Port",
"Identity File", "Identity File",
"ProxyJump", "Proxy Jump",
"SSH Options", "SSH Options",
"Tags (comma-separated)", "Tags (comma-separated)",
} }
for i, field := range fields { for i, field := range fields {
b.WriteString(m.styles.FormField.Render(field)) fieldStyle := m.styles.FormField
if m.focusArea == focusAreaProperties && m.focused == i {
fieldStyle = m.styles.FocusedLabel
}
b.WriteString(fieldStyle.Render(field))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(m.inputs[i].View()) b.WriteString(m.inputs[i].View())
b.WriteString("\n\n") b.WriteString("\n\n")
@@ -248,75 +403,82 @@ func (m *editFormModel) View() string {
b.WriteString("\n\n") b.WriteString("\n\n")
} }
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+S: save • Ctrl+C/Esc: cancel")) // Show different help based on number of hosts
b.WriteString("\n") if len(m.hostInputs) > 1 {
b.WriteString(m.styles.FormHelp.Render("* Required fields")) b.WriteString(m.styles.FormHelp.Render("Tab/↑↓/Enter: navigate • Ctrl+A: add host • Ctrl+D: delete host"))
b.WriteString("\n")
} else {
b.WriteString(m.styles.FormHelp.Render("Tab/↑↓/Enter: navigate • 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() return b.String()
} }
// Standalone wrapper for edit form // RunEditForm runs the edit form as a standalone program
type standaloneEditForm struct {
*editFormModel
}
func (m standaloneEditForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case editFormSubmitMsg:
if msg.err != nil {
m.editFormModel.err = msg.err.Error()
} else {
m.editFormModel.success = true
return m, tea.Quit
}
return m, nil
case editFormCancelMsg:
return m, tea.Quit
}
newForm, cmd := m.editFormModel.Update(msg)
m.editFormModel = newForm
return m, cmd
}
// RunEditForm provides backward compatibility for standalone edit form
func RunEditForm(hostName string, configFile string) error { func RunEditForm(hostName string, configFile string) error {
styles := NewStyles(80) styles := NewStyles(80) // Default width
editForm, err := NewEditForm(hostName, styles, 80, 24, configFile) editForm, err := NewEditForm(hostName, styles, 80, 24, configFile)
if err != nil { if err != nil {
return err return err
} }
m := standaloneEditForm{editForm}
p := tea.NewProgram(m, tea.WithAltScreen()) p := tea.NewProgram(editForm, tea.WithAltScreen())
_, err = p.Run() _, err = p.Run()
return err if err != nil {
return err
}
if editForm.err != "" {
return fmt.Errorf(editForm.err)
}
return nil
} }
func (m *editFormModel) submitEditForm() tea.Cmd { func (m *editFormModel) submitEditForm() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
// Get values // Collect host names
name := strings.TrimSpace(m.inputs[nameInput].Value()) var hostNames []string
hostname := strings.TrimSpace(m.inputs[hostnameInput].Value()) for _, input := range m.hostInputs {
user := strings.TrimSpace(m.inputs[userInput].Value()) name := strings.TrimSpace(input.Value())
port := strings.TrimSpace(m.inputs[portInput].Value()) if name != "" {
identity := strings.TrimSpace(m.inputs[identityInput].Value()) hostNames = append(hostNames, name)
proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value()) }
options := strings.TrimSpace(m.inputs[optionsInput].Value()) }
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
// Set defaults // Set defaults
if port == "" { if port == "" {
port = "22" port = "22"
} }
// Do not auto-fill identity with placeholder if left empty; keep it empty so it's optional
// Validate all fields // Validate hostname
if err := validation.ValidateHost(name, hostname, port, identity); err != nil { if hostname == "" {
return editFormSubmitMsg{err: err} 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 // Parse tags
tagsStr := strings.TrimSpace(m.inputs[tagsInput].Value()) tagsStr := strings.TrimSpace(m.inputs[6].Value()) // tagsInput
var tags []string var tags []string
if tagsStr != "" { if tagsStr != "" {
for _, tag := range strings.Split(tagsStr, ",") { for _, tag := range strings.Split(tagsStr, ",") {
@@ -327,25 +489,31 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
} }
} }
// Create updated host configuration // Create the common host configuration
host := config.SSHHost{ commonHost := config.SSHHost{
Name: name,
Hostname: hostname, Hostname: hostname,
User: user, User: user,
Port: port, Port: port,
Identity: identity, Identity: identity,
ProxyJump: proxyJump, ProxyJump: proxyJump,
Options: config.ParseSSHOptionsFromCommand(options), Options: options,
Tags: tags, Tags: tags,
} }
// Update the configuration
var err error var err error
if m.configFile != "" { if len(hostNames) == 1 && len(m.originalHosts) == 1 {
err = config.UpdateSSHHostInFile(m.originalName, host, m.configFile) // 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 { } else {
err = config.UpdateSSHHost(m.originalName, host) // Multi-host editing or conversion from single to multi
err = config.UpdateMultiHostBlock(m.originalHosts, hostNames, commonHost, m.actualConfigFile)
} }
return editFormSubmitMsg{hostname: name, err: err}
return editFormSubmitMsg{hostname: hostNames[0], err: err}
} }
} }

View File

@@ -37,35 +37,64 @@ func sortHostsByName(hosts []config.SSHHost) []config.SSHHost {
// filterHosts filters hosts according to the search query (name or tags) // filterHosts filters hosts according to the search query (name or tags)
func (m Model) filterHosts(query string) []config.SSHHost { 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 var filtered []config.SSHHost
if query == "" { if word == "" {
filtered = m.hosts filtered = m.hosts
} else { } else {
query = strings.ToLower(query) word = strings.ToLower(word)
for _, host := range m.hosts { for _, host := range m.hosts {
// Check the hostname // Check the hostname
if strings.Contains(strings.ToLower(host.Name), query) { if strings.Contains(strings.ToLower(host.Name), word) {
filtered = append(filtered, host) filtered = append(filtered, host)
continue continue
} }
// Check the hostname // Check the hostname
if strings.Contains(strings.ToLower(host.Hostname), query) { if strings.Contains(strings.ToLower(host.Hostname), word) {
filtered = append(filtered, host) filtered = append(filtered, host)
continue continue
} }
// Check the user // Check the user
if strings.Contains(strings.ToLower(host.User), query) { if strings.Contains(strings.ToLower(host.User), word) {
filtered = append(filtered, host) filtered = append(filtered, host)
continue continue
} }
// Check the tags // Check the tags
for _, tag := range host.Tags { for _, tag := range host.Tags {
if strings.Contains(strings.ToLower(tag), query) { if strings.Contains(strings.ToLower(tag), word) {
filtered = append(filtered, host) filtered = append(filtered, host)
break break
} }

View File

@@ -394,9 +394,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case ViewEdit: case ViewEdit:
if m.editForm != nil { if m.editForm != nil {
var newForm *editFormModel var updatedModel tea.Model
newForm, cmd = m.editForm.Update(msg) updatedModel, cmd = m.editForm.Update(msg)
m.editForm = newForm m.editForm = updatedModel.(*editFormModel)
return m, cmd return m, cmd
} }
case ViewMove: case ViewMove: