2 Commits

Author SHA1 Message Date
cooper
8604a8f365 Merge b43a67eb1a into 3d746ec49a 2025-09-30 03:32:03 +00:00
zxr
b43a67eb1a feat: auto ping after sshm turn on. 2025-09-30 11:27:32 +08:00
11 changed files with 413 additions and 2079 deletions

View File

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

66
CONFIG.md Normal file
View File

@@ -0,0 +1,66 @@
# 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,34 +553,6 @@ This will be automatically converted to:
StrictHostKeyChecking no
```
### Custom Key Bindings
SSHM supports customizable key bindings through a configuration file. This is particularly useful for users who want to modify the default quit behavior.
**Configuration File Location:**
- **Linux/macOS**: `~/.config/sshm/config.json`
- **Windows**: `%APPDATA%\sshm\config.json`
**Example Configuration:**
```json
{
"key_bindings": {
"quit_keys": ["q", "ctrl+c"],
"disable_esc_quit": true
}
}
```
**Available Options:**
- **quit_keys**: Array of keys that will quit the application. Default: `["q", "ctrl+c"]`
- **disable_esc_quit**: Boolean flag to disable ESC key from quitting the application. Default: `false`
**For Vim Users:**
If you frequently press ESC accidentally causing the application to quit, set `disable_esc_quit` to `true`. This will disable ESC as a quit key while preserving all other functionality.
**Default Configuration:**
If no configuration file exists, SSHM will automatically create one with default settings that maintain backward compatibility.
## 🛠️ Development
### Prerequisites
@@ -697,8 +669,6 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- [Charm](https://charm.sh/) for the amazing TUI libraries
- [Cobra](https://cobra.dev/) for the excellent CLI framework
- [@yimeng](https://github.com/yimeng) for contributing SSH Include directive support
- [@ldreux](https://github.com/ldreux) for contributing multi-word search functionality
- [@qingfengzxr](https://github.com/qingfengzxr) for contributing custom key bindings support
- The Go community for building such fantastic tools
---

View File

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

View File

@@ -7,7 +7,6 @@ USE_SUDO="false"
OS=""
ARCH=""
FORCE_INSTALL="${FORCE_INSTALL:-false}"
SSHM_VERSION="${SSHM_VERSION:-latest}"
RED='\033[0;31m'
PURPLE='\033[0;35m'
@@ -15,27 +14,13 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
usage() {
printf "${PURPLE}SSHM Installation Script${NC}\n\n"
printf "Usage:\n"
printf " Default (latest stable): ${GREEN}bash install.sh${NC}\n"
printf " Specific version: ${GREEN}SSHM_VERSION=v1.8.0 bash install.sh${NC}\n"
printf " Beta/pre-release: ${GREEN}SSHM_VERSION=v1.8.1-beta bash install.sh${NC}\n"
printf " Force install: ${GREEN}FORCE_INSTALL=true bash install.sh${NC}\n"
printf " Custom install directory: ${GREEN}INSTALL_DIR=/opt/bin bash install.sh${NC}\n\n"
printf "Environment variables:\n"
printf " SSHM_VERSION - Version to install (default: latest)\n"
printf " FORCE_INSTALL - Skip confirmation prompts (default: false)\n"
printf " INSTALL_DIR - Installation directory (default: /usr/local/bin)\n\n"
}
setSystem() {
ARCH=$(uname -m)
case $ARCH in
i386|i686) ARCH="amd64" ;;
x86_64) ARCH="amd64";;
armv6*) ARCH="armv6" ;;
armv7*) ARCH="armv7" ;;
armv6*) ARCH="arm64" ;;
armv7*) ARCH="arm64" ;;
aarch64*) ARCH="arm64" ;;
arm64) ARCH="arm64" ;;
esac
@@ -61,25 +46,13 @@ runAsRoot() {
}
getLatestVersion() {
if [ "$SSHM_VERSION" = "latest" ]; then
printf "${YELLOW}Fetching latest stable version...${NC}\n"
LATEST_VERSION=$(curl -s https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$LATEST_VERSION" ]; then
printf "${RED}Failed to fetch latest version${NC}\n"
exit 1
fi
else
printf "${YELLOW}Using specified version: $SSHM_VERSION${NC}\n"
# Validate that the specified version exists
RELEASE_CHECK=$(curl -s "https://api.github.com/repos/Gu1llaum-3/sshm/releases/tags/$SSHM_VERSION" | grep '"tag_name":')
if [ -z "$RELEASE_CHECK" ]; then
printf "${RED}Version $SSHM_VERSION not found. Available versions:${NC}\n"
curl -s https://api.github.com/repos/Gu1llaum-3/sshm/releases | grep '"tag_name":' | head -10 | sed -E 's/.*"([^"]+)".*/ - \1/'
exit 1
fi
LATEST_VERSION="$SSHM_VERSION"
printf "${YELLOW}Fetching latest version...${NC}\n"
LATEST_VERSION=$(curl -s https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$LATEST_VERSION" ]; then
printf "${RED}Failed to fetch latest version${NC}\n"
exit 1
fi
printf "${GREEN}Installing version: $LATEST_VERSION${NC}\n"
printf "${GREEN}Latest version: $LATEST_VERSION${NC}\n"
}
downloadBinary() {
@@ -97,11 +70,10 @@ downloadBinary() {
"amd64") GORELEASER_ARCH="x86_64" ;;
"arm64") GORELEASER_ARCH="arm64" ;;
"386") GORELEASER_ARCH="i386" ;;
"armv6") GORELEASER_ARCH="armv6" ;;
"armv7") GORELEASER_ARCH="armv7" ;;
"arm") GORELEASER_ARCH="armv6" ;;
esac
# GoReleaser format: sshm_Linux_armv7.tar.gz
# GoReleaser format: sshm_Darwin_arm64.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"
@@ -204,24 +176,18 @@ checkExisting() {
}
main() {
# Check for help argument
if [ "$1" = "-h" ] || [ "$1" = "--help" ] || [ "$1" = "help" ]; then
usage
exit 0
fi
printf "${PURPLE}Installing SSHM - SSH Connection Manager${NC}\n\n"
# Check if already installed
checkExisting
# Set up system detection
setSystem
printf "${GREEN}Detected system: $OS ($ARCH)${NC}\n"
# Get and validate version FIRST (this can fail early)
# Get latest version
getLatestVersion
# Check if already installed (this might prompt user)
checkExisting
# Download and install
downloadBinary
install

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -152,6 +152,12 @@ func RunInteractiveMode(hosts []config.SSHHost, configFile, currentVersion strin
// Start the application in alt screen mode for clean output
p := tea.NewProgram(m, tea.WithAltScreen())
// Send initial command to start auto-ping when the program starts
go func() {
p.Send(autoPingMsg{})
}()
_, err := p.Run()
if err != nil {
return fmt.Errorf("error running TUI: %w", err)

View File

@@ -20,6 +20,7 @@ type (
versionCheckMsg *version.UpdateInfo
versionErrorMsg error
errorMsg string
autoPingMsg struct{}
)
// startPingAllCmd creates a command to ping all hosts concurrently
@@ -145,6 +146,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case autoPingMsg:
// Handle auto-ping on startup - start pinging all hosts
return m, m.startPingAllCmd()
case versionCheckMsg:
// Handle version check result
if msg != nil {
@@ -394,9 +399,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case ViewEdit:
if m.editForm != nil {
var updatedModel tea.Model
updatedModel, cmd = m.editForm.Update(msg)
m.editForm = updatedModel.(*editFormModel)
var newForm *editFormModel
newForm, cmd = m.editForm.Update(msg)
m.editForm = newForm
return m, cmd
}
case ViewMove: