mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2026-01-27 03:04:21 +01:00
Compare commits
1 Commits
1.5.1
...
feature/tu
| Author | SHA1 | Date | |
|---|---|---|---|
| 146d04c9b7 |
16
.github/workflows/build.yml
vendored
16
.github/workflows/build.yml
vendored
@@ -69,9 +69,9 @@ jobs:
|
|||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
VERSION=${GITHUB_REF#refs/tags/}
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||||
go build -ldflags="-s -w -X github.com/Gu1llaum-3/sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }}.exe .
|
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }}.exe .
|
||||||
else
|
else
|
||||||
go build -ldflags="-s -w -X github.com/Gu1llaum-3/sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }} .
|
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }} .
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Create archive
|
- name: Create archive
|
||||||
@@ -113,22 +113,12 @@ jobs:
|
|||||||
find ./artifacts -name "*.zip" -exec cp {} ./release/ \;
|
find ./artifacts -name "*.zip" -exec cp {} ./release/ \;
|
||||||
ls -la ./release/
|
ls -la ./release/
|
||||||
|
|
||||||
- name: Check if pre-release
|
|
||||||
id: check_prerelease
|
|
||||||
run: |
|
|
||||||
if [[ "${GITHUB_REF#refs/tags/}" == *"-beta"* ]] || [[ "${GITHUB_REF#refs/tags/}" == *"-alpha"* ]] || [[ "${GITHUB_REF#refs/tags/}" == *"-rc"* ]]; then
|
|
||||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
files: ./release/*
|
files: ./release/*
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }}
|
prerelease: false
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
name: ${{ github.ref_name }}${{ steps.check_prerelease.outputs.is_prerelease == 'true' && ' (Pre-release)' || '' }}
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
73
README.md
73
README.md
@@ -34,7 +34,6 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
|
|||||||
- **🔍 Smart Search** - Find hosts quickly with built-in filtering and search
|
- **🔍 Smart Search** - Find hosts quickly with built-in filtering and search
|
||||||
- **🔒 Secure** - Works directly with your existing `~/.ssh/config` file
|
- **🔒 Secure** - Works directly with your existing `~/.ssh/config` file
|
||||||
- **📁 Custom Config Support** - Use any SSH configuration file with the `-c` flag
|
- **📁 Custom Config Support** - Use any SSH configuration file with the `-c` flag
|
||||||
- **📂 SSH Include Support** - Full support for SSH Include directives to organize configurations across multiple files
|
|
||||||
- **⚙️ SSH Options Support** - Add any SSH configuration option through intuitive forms
|
- **⚙️ SSH Options Support** - Add any SSH configuration option through intuitive forms
|
||||||
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
|
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
|
||||||
|
|
||||||
@@ -243,29 +242,6 @@ sshm --version
|
|||||||
sshm --help
|
sshm --help
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backup Configuration
|
|
||||||
|
|
||||||
SSHM automatically creates backups of your SSH configuration files before making any changes to ensure your configurations are safe.
|
|
||||||
|
|
||||||
**Backup Location:**
|
|
||||||
- **Unix/Linux/macOS**: `~/.config/sshm/backups/` (or `$XDG_CONFIG_HOME/sshm/backups/` if set)
|
|
||||||
- **Windows**: `%APPDATA%\sshm\backups\` (fallback: `%USERPROFILE%\.config\sshm\backups\`)
|
|
||||||
|
|
||||||
**Key Features:**
|
|
||||||
- Automatic backup before any modification
|
|
||||||
- One backup per file (overwrites previous backup)
|
|
||||||
- Stored separately to avoid SSH Include conflicts
|
|
||||||
- Easy manual recovery if needed
|
|
||||||
|
|
||||||
**Quick Recovery:**
|
|
||||||
```bash
|
|
||||||
# Unix/Linux/macOS
|
|
||||||
cp ~/.config/sshm/backups/config.backup ~/.ssh/config
|
|
||||||
|
|
||||||
# Windows
|
|
||||||
copy "%APPDATA%\sshm\backups\config.backup" "%USERPROFILE%\.ssh\config"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration File Options
|
### Configuration File Options
|
||||||
|
|
||||||
By default, SSHM uses the standard SSH configuration file at `~/.ssh/config`. You can specify a different configuration file using the `-c` flag:
|
By default, SSHM uses the standard SSH configuration file at `~/.ssh/config`. You can specify a different configuration file using the `-c` flag:
|
||||||
@@ -296,55 +272,7 @@ sshm edit hostname -c /path/to/custom/ssh_config
|
|||||||
|
|
||||||
SSHM works directly with your standard SSH configuration file (`~/.ssh/config`). It adds special comment tags for enhanced functionality while maintaining full compatibility with standard SSH tools.
|
SSHM works directly with your standard SSH configuration file (`~/.ssh/config`). It adds special comment tags for enhanced functionality while maintaining full compatibility with standard SSH tools.
|
||||||
|
|
||||||
### SSH Include Support
|
|
||||||
|
|
||||||
SSHM fully supports SSH Include directives, allowing you to organize your SSH configurations across multiple files. This is particularly useful for managing large numbers of hosts or organizing configurations by environment, project, or team.
|
|
||||||
|
|
||||||
**Include Examples:**
|
|
||||||
```ssh
|
|
||||||
# Main ~/.ssh/config file
|
|
||||||
Host personal-server
|
|
||||||
HostName personal.example.com
|
|
||||||
User myuser
|
|
||||||
|
|
||||||
# Include work-related configurations
|
|
||||||
Include work-servers.conf
|
|
||||||
|
|
||||||
# Include all configurations from a directory
|
|
||||||
Include projects/*
|
|
||||||
|
|
||||||
# Include with relative paths
|
|
||||||
Include ~/.ssh/configs/production.conf
|
|
||||||
```
|
|
||||||
|
|
||||||
**Organization Examples:**
|
|
||||||
|
|
||||||
*work-servers.conf:*
|
|
||||||
```ssh
|
|
||||||
# Tags: work, production
|
|
||||||
Host prod-web-01
|
|
||||||
HostName 10.0.1.10
|
|
||||||
User deploy
|
|
||||||
ProxyJump bastion.company.com
|
|
||||||
|
|
||||||
# Tags: work, staging
|
|
||||||
Host staging-api
|
|
||||||
HostName staging-api.company.com
|
|
||||||
User developer
|
|
||||||
```
|
|
||||||
|
|
||||||
*projects/client-alpha.conf:*
|
|
||||||
```ssh
|
|
||||||
# Tags: client, development
|
|
||||||
Host client-alpha-dev
|
|
||||||
HostName dev.client-alpha.com
|
|
||||||
User admin
|
|
||||||
Port 2222
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example configuration:**
|
**Example configuration:**
|
||||||
Include ~/.ssh/conf.d/*
|
|
||||||
|
|
||||||
```ssh
|
```ssh
|
||||||
# Tags: production, web, frontend
|
# Tags: production, web, frontend
|
||||||
Host web-prod-01
|
Host web-prod-01
|
||||||
@@ -524,7 +452,6 @@ 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
|
|
||||||
- The Go community for building such fantastic tools
|
- The Go community for building such fantastic tools
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/Gu1llaum-3/sshm/internal/ui"
|
"sshm/internal/ui"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/Gu1llaum-3/sshm/internal/ui"
|
"sshm/internal/ui"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
"sshm/internal/config"
|
||||||
"github.com/Gu1llaum-3/sshm/internal/ui"
|
"sshm/internal/ui"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
"sshm/internal/config"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
9
go.mod
9
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module github.com/Gu1llaum-3/sshm
|
module sshm
|
||||||
|
|
||||||
go 1.23.1
|
go 1.23.1
|
||||||
|
|
||||||
@@ -7,7 +7,6 @@ require (
|
|||||||
github.com/charmbracelet/bubbletea v1.3.6
|
github.com/charmbracelet/bubbletea v1.3.6
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
golang.org/x/crypto v0.41.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -29,7 +28,7 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
golang.org/x/sync v0.15.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/text v0.3.8 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
16
go.sum
16
go.sum
@@ -49,19 +49,15 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
|||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
|
||||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
|
||||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
|
||||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -13,15 +13,14 @@ import (
|
|||||||
|
|
||||||
// SSHHost represents an SSH host configuration
|
// SSHHost represents an SSH host configuration
|
||||||
type SSHHost struct {
|
type SSHHost struct {
|
||||||
Name string
|
Name string
|
||||||
Hostname string
|
Hostname string
|
||||||
User string
|
User string
|
||||||
Port string
|
Port string
|
||||||
Identity string
|
Identity string
|
||||||
ProxyJump string
|
ProxyJump string
|
||||||
Options string
|
Options string
|
||||||
Tags []string
|
Tags []string
|
||||||
SourceFile string // Path to the config file where this host is defined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultSSHConfigPath returns the default SSH config path for the current platform
|
// GetDefaultSSHConfigPath returns the default SSH config path for the current platform
|
||||||
@@ -40,36 +39,6 @@ func GetDefaultSSHConfigPath() (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSSHMBackupDir returns the SSHM backup directory
|
|
||||||
func GetSSHMBackupDir() (string, error) {
|
|
||||||
homeDir, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var configDir string
|
|
||||||
switch runtime.GOOS {
|
|
||||||
case "windows":
|
|
||||||
// Use %APPDATA%/sshm on Windows
|
|
||||||
appData := os.Getenv("APPDATA")
|
|
||||||
if appData != "" {
|
|
||||||
configDir = filepath.Join(appData, "sshm")
|
|
||||||
} else {
|
|
||||||
configDir = filepath.Join(homeDir, ".config", "sshm")
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// Use XDG Base Directory specification
|
|
||||||
xdgConfigDir := os.Getenv("XDG_CONFIG_HOME")
|
|
||||||
if xdgConfigDir != "" {
|
|
||||||
configDir = filepath.Join(xdgConfigDir, "sshm")
|
|
||||||
} else {
|
|
||||||
configDir = filepath.Join(homeDir, ".config", "sshm")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return filepath.Join(configDir, "backups"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSSHDirectory returns the .ssh directory path
|
// GetSSHDirectory returns the .ssh directory path
|
||||||
func GetSSHDirectory() (string, error) {
|
func GetSSHDirectory() (string, error) {
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
@@ -97,23 +66,9 @@ func ensureSSHDirectory() error {
|
|||||||
// configMutex protects SSH config file operations from race conditions
|
// configMutex protects SSH config file operations from race conditions
|
||||||
var configMutex sync.Mutex
|
var configMutex sync.Mutex
|
||||||
|
|
||||||
// backupConfig creates a backup of the SSH config file in ~/.config/sshm/backups/
|
// backupConfig creates a backup of the SSH config file
|
||||||
func backupConfig(configPath string) error {
|
func backupConfig(configPath string) error {
|
||||||
// Get backup directory and ensure it exists
|
backupPath := configPath + ".backup"
|
||||||
backupDir, err := GetSSHMBackupDir()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get backup directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create backup directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create simple backup filename (overwrites previous backup)
|
|
||||||
filename := filepath.Base(configPath)
|
|
||||||
backupPath := filepath.Join(backupDir, filename+".backup")
|
|
||||||
|
|
||||||
// Copy file
|
|
||||||
src, err := os.Open(configPath)
|
src, err := os.Open(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -126,12 +81,8 @@ func backupConfig(configPath string) error {
|
|||||||
}
|
}
|
||||||
defer dst.Close()
|
defer dst.Close()
|
||||||
|
|
||||||
if _, err = io.Copy(dst, src); err != nil {
|
_, err = io.Copy(dst, src)
|
||||||
return err
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
// Set appropriate permissions
|
|
||||||
return os.Chmod(backupPath, 0600)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseSSHConfig parses the SSH config file and returns the list of hosts
|
// ParseSSHConfig parses the SSH config file and returns the list of hosts
|
||||||
@@ -258,10 +209,9 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
|
|||||||
}
|
}
|
||||||
// Create new host
|
// Create new host
|
||||||
currentHost = &SSHHost{
|
currentHost = &SSHHost{
|
||||||
Name: value,
|
Name: value,
|
||||||
Port: "22", // Default port
|
Port: "22", // Default port
|
||||||
Tags: pendingTags, // Assign pending tags to this host
|
Tags: pendingTags, // Assign pending tags to this host
|
||||||
SourceFile: absPath, // Track which file this host comes from
|
|
||||||
}
|
}
|
||||||
// Clear pending tags for next host
|
// Clear pending tags for next host
|
||||||
pendingTags = nil
|
pendingTags = nil
|
||||||
@@ -336,16 +286,6 @@ func processIncludeDirective(pattern string, baseConfigPath string, processedFil
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip backup files created by sshm (*.backup)
|
|
||||||
if strings.HasSuffix(match, ".backup") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip markdown files (*.md)
|
|
||||||
if strings.HasSuffix(match, ".md") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively parse the included file
|
// Recursively parse the included file
|
||||||
hosts, err := parseSSHConfigFileWithProcessedFiles(match, processedFiles)
|
hosts, err := parseSSHConfigFileWithProcessedFiles(match, processedFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -589,7 +529,11 @@ func GetSSHHostFromFile(hostName string, configPath string) (*SSHHost, error) {
|
|||||||
|
|
||||||
// UpdateSSHHost updates an existing SSH host configuration
|
// UpdateSSHHost updates an existing SSH host configuration
|
||||||
func UpdateSSHHost(oldName string, newHost SSHHost) error {
|
func UpdateSSHHost(oldName string, newHost SSHHost) error {
|
||||||
return UpdateSSHHostV2(oldName, newHost)
|
configPath, err := GetDefaultSSHConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return UpdateSSHHostInFile(oldName, newHost, configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSSHHostInFile updates an existing SSH host configuration in a specific file
|
// UpdateSSHHostInFile updates an existing SSH host configuration in a specific file
|
||||||
@@ -744,7 +688,11 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
|
|||||||
|
|
||||||
// DeleteSSHHost removes an SSH host configuration from the config file
|
// DeleteSSHHost removes an SSH host configuration from the config file
|
||||||
func DeleteSSHHost(hostName string) error {
|
func DeleteSSHHost(hostName string) error {
|
||||||
return DeleteSSHHostV2(hostName)
|
configPath, err := GetDefaultSSHConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return DeleteSSHHostFromFile(hostName, configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteSSHHostFromFile deletes an SSH host from a specific config file
|
// DeleteSSHHostFromFile deletes an SSH host from a specific config file
|
||||||
@@ -828,115 +776,3 @@ func DeleteSSHHostFromFile(hostName, configPath string) error {
|
|||||||
newContent := strings.Join(newLines, "\n")
|
newContent := strings.Join(newLines, "\n")
|
||||||
return os.WriteFile(configPath, []byte(newContent), 0600)
|
return os.WriteFile(configPath, []byte(newContent), 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindHostInAllConfigs finds a host in all configuration files and returns the host with its source file
|
|
||||||
func FindHostInAllConfigs(hostName string) (*SSHHost, error) {
|
|
||||||
hosts, err := ParseSSHConfig()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, host := range hosts {
|
|
||||||
if host.Name == hostName {
|
|
||||||
return &host, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("host '%s' not found in any configuration file", hostName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllConfigFiles returns all SSH config files (main + included files)
|
|
||||||
func GetAllConfigFiles() ([]string, error) {
|
|
||||||
configPath, err := GetDefaultSSHConfigPath()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
processedFiles := make(map[string]bool)
|
|
||||||
_, _ = parseSSHConfigFileWithProcessedFiles(configPath, processedFiles)
|
|
||||||
|
|
||||||
files := make([]string, 0, len(processedFiles))
|
|
||||||
for file := range processedFiles {
|
|
||||||
files = append(files, file)
|
|
||||||
}
|
|
||||||
|
|
||||||
return files, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllConfigFilesFromBase returns all SSH config files starting from a specific base config file
|
|
||||||
func GetAllConfigFilesFromBase(baseConfigPath string) ([]string, error) {
|
|
||||||
if baseConfigPath == "" {
|
|
||||||
// Fallback to default behavior
|
|
||||||
return GetAllConfigFiles()
|
|
||||||
}
|
|
||||||
|
|
||||||
processedFiles := make(map[string]bool)
|
|
||||||
_, _ = parseSSHConfigFileWithProcessedFiles(baseConfigPath, processedFiles)
|
|
||||||
|
|
||||||
files := make([]string, 0, len(processedFiles))
|
|
||||||
for file := range processedFiles {
|
|
||||||
files = append(files, file)
|
|
||||||
}
|
|
||||||
|
|
||||||
return files, nil
|
|
||||||
} // UpdateSSHHostV2 updates an existing SSH host configuration, searching in all config files
|
|
||||||
func UpdateSSHHostV2(oldName string, newHost SSHHost) error {
|
|
||||||
// Find the host to determine which file it's in
|
|
||||||
existingHost, err := FindHostInAllConfigs(oldName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the host in its source file
|
|
||||||
newHost.SourceFile = existingHost.SourceFile
|
|
||||||
return UpdateSSHHostInFile(oldName, newHost, existingHost.SourceFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteSSHHostV2 removes an SSH host configuration, searching in all config files
|
|
||||||
func DeleteSSHHostV2(hostName string) error {
|
|
||||||
// Find the host to determine which file it's in
|
|
||||||
existingHost, err := FindHostInAllConfigs(hostName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the host from its source file
|
|
||||||
return DeleteSSHHostFromFile(hostName, existingHost.SourceFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddSSHHostWithFileSelection adds a new SSH host to a user-specified config file
|
|
||||||
func AddSSHHostWithFileSelection(host SSHHost, targetFile string) error {
|
|
||||||
if targetFile == "" {
|
|
||||||
// Use default file if none specified
|
|
||||||
return AddSSHHost(host)
|
|
||||||
}
|
|
||||||
return AddSSHHostToFile(host, targetFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetIncludedConfigFiles returns a list of config files that can be used for adding hosts
|
|
||||||
func GetIncludedConfigFiles() ([]string, error) {
|
|
||||||
allFiles, err := GetAllConfigFiles()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out files that don't exist or can't be written to
|
|
||||||
var writableFiles []string
|
|
||||||
mainConfig, err := GetDefaultSSHConfigPath()
|
|
||||||
if err == nil {
|
|
||||||
writableFiles = append(writableFiles, mainConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, file := range allFiles {
|
|
||||||
if file == mainConfig {
|
|
||||||
continue // Already added
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if file exists and is writable
|
|
||||||
if info, err := os.Stat(file); err == nil && !info.IsDir() {
|
|
||||||
writableFiles = append(writableFiles, file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return writableFiles, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func TestEnsureSSHDirectory(t *testing.T) {
|
|||||||
func TestParseSSHConfigWithInclude(t *testing.T) {
|
func TestParseSSHConfigWithInclude(t *testing.T) {
|
||||||
// Create temporary directory for test files
|
// Create temporary directory for test files
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
// Create main config file
|
// Create main config file
|
||||||
mainConfig := filepath.Join(tempDir, "config")
|
mainConfig := filepath.Join(tempDir, "config")
|
||||||
mainConfigContent := `Host main-host
|
mainConfigContent := `Host main-host
|
||||||
@@ -90,7 +90,7 @@ Host another-host
|
|||||||
HostName another.example.com
|
HostName another.example.com
|
||||||
User anotheruser
|
User anotheruser
|
||||||
`
|
`
|
||||||
|
|
||||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create main config: %v", err)
|
t.Fatalf("Failed to create main config: %v", err)
|
||||||
@@ -103,7 +103,7 @@ Host another-host
|
|||||||
User includeduser
|
User includeduser
|
||||||
Port 2222
|
Port 2222
|
||||||
`
|
`
|
||||||
|
|
||||||
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
|
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create included config: %v", err)
|
t.Fatalf("Failed to create included config: %v", err)
|
||||||
@@ -122,7 +122,7 @@ Host another-host
|
|||||||
User subuser
|
User subuser
|
||||||
IdentityFile ~/.ssh/sub_key
|
IdentityFile ~/.ssh/sub_key
|
||||||
`
|
`
|
||||||
|
|
||||||
err = os.WriteFile(subConfig, []byte(subConfigContent), 0600)
|
err = os.WriteFile(subConfig, []byte(subConfigContent), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create sub config: %v", err)
|
t.Fatalf("Failed to create sub config: %v", err)
|
||||||
@@ -158,30 +158,18 @@ Host another-host
|
|||||||
if host.Hostname != "example.com" || host.User != "mainuser" {
|
if host.Hostname != "example.com" || host.User != "mainuser" {
|
||||||
t.Errorf("main-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
t.Errorf("main-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||||
}
|
}
|
||||||
if host.SourceFile != mainConfig {
|
|
||||||
t.Errorf("main-host SourceFile incorrect: expected=%s, got=%s", mainConfig, host.SourceFile)
|
|
||||||
}
|
|
||||||
case "included-host":
|
case "included-host":
|
||||||
if host.Hostname != "included.example.com" || host.User != "includeduser" || host.Port != "2222" {
|
if host.Hostname != "included.example.com" || host.User != "includeduser" || host.Port != "2222" {
|
||||||
t.Errorf("included-host properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port)
|
t.Errorf("included-host properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port)
|
||||||
}
|
}
|
||||||
if host.SourceFile != includedConfig {
|
|
||||||
t.Errorf("included-host SourceFile incorrect: expected=%s, got=%s", includedConfig, host.SourceFile)
|
|
||||||
}
|
|
||||||
case "sub-host":
|
case "sub-host":
|
||||||
if host.Hostname != "sub.example.com" || host.User != "subuser" || host.Identity != "~/.ssh/sub_key" {
|
if host.Hostname != "sub.example.com" || host.User != "subuser" || host.Identity != "~/.ssh/sub_key" {
|
||||||
t.Errorf("sub-host properties incorrect: hostname=%s, user=%s, identity=%s", host.Hostname, host.User, host.Identity)
|
t.Errorf("sub-host properties incorrect: hostname=%s, user=%s, identity=%s", host.Hostname, host.User, host.Identity)
|
||||||
}
|
}
|
||||||
if host.SourceFile != subConfig {
|
|
||||||
t.Errorf("sub-host SourceFile incorrect: expected=%s, got=%s", subConfig, host.SourceFile)
|
|
||||||
}
|
|
||||||
case "another-host":
|
case "another-host":
|
||||||
if host.Hostname != "another.example.com" || host.User != "anotheruser" {
|
if host.Hostname != "another.example.com" || host.User != "anotheruser" {
|
||||||
t.Errorf("another-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
t.Errorf("another-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||||
}
|
}
|
||||||
if host.SourceFile != mainConfig {
|
|
||||||
t.Errorf("another-host SourceFile incorrect: expected=%s, got=%s", mainConfig, host.SourceFile)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +186,7 @@ Host another-host
|
|||||||
func TestParseSSHConfigWithCircularInclude(t *testing.T) {
|
func TestParseSSHConfigWithCircularInclude(t *testing.T) {
|
||||||
// Create temporary directory for test files
|
// Create temporary directory for test files
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
// Create config1 that includes config2
|
// Create config1 that includes config2
|
||||||
config1 := filepath.Join(tempDir, "config1")
|
config1 := filepath.Join(tempDir, "config1")
|
||||||
config1Content := `Host host1
|
config1Content := `Host host1
|
||||||
@@ -206,7 +194,7 @@ func TestParseSSHConfigWithCircularInclude(t *testing.T) {
|
|||||||
|
|
||||||
Include config2
|
Include config2
|
||||||
`
|
`
|
||||||
|
|
||||||
err := os.WriteFile(config1, []byte(config1Content), 0600)
|
err := os.WriteFile(config1, []byte(config1Content), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create config1: %v", err)
|
t.Fatalf("Failed to create config1: %v", err)
|
||||||
@@ -219,7 +207,7 @@ Include config2
|
|||||||
|
|
||||||
Include config1
|
Include config1
|
||||||
`
|
`
|
||||||
|
|
||||||
err = os.WriteFile(config2, []byte(config2Content), 0600)
|
err = os.WriteFile(config2, []byte(config2Content), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create config2: %v", err)
|
t.Fatalf("Failed to create config2: %v", err)
|
||||||
@@ -259,7 +247,7 @@ Include config1
|
|||||||
func TestParseSSHConfigWithNonExistentInclude(t *testing.T) {
|
func TestParseSSHConfigWithNonExistentInclude(t *testing.T) {
|
||||||
// Create temporary directory for test files
|
// Create temporary directory for test files
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
// Create main config file with non-existent include
|
// Create main config file with non-existent include
|
||||||
mainConfig := filepath.Join(tempDir, "config")
|
mainConfig := filepath.Join(tempDir, "config")
|
||||||
mainConfigContent := `Host main-host
|
mainConfigContent := `Host main-host
|
||||||
@@ -270,7 +258,7 @@ Include non-existent-file.conf
|
|||||||
Host another-host
|
Host another-host
|
||||||
HostName another.example.com
|
HostName another.example.com
|
||||||
`
|
`
|
||||||
|
|
||||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create main config: %v", err)
|
t.Fatalf("Failed to create main config: %v", err)
|
||||||
@@ -300,7 +288,7 @@ Host another-host
|
|||||||
func TestParseSSHConfigWithWildcardHosts(t *testing.T) {
|
func TestParseSSHConfigWithWildcardHosts(t *testing.T) {
|
||||||
// Create temporary directory for test files
|
// Create temporary directory for test files
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
// Create config file with wildcard hosts
|
// Create config file with wildcard hosts
|
||||||
configFile := filepath.Join(tempDir, "config")
|
configFile := filepath.Join(tempDir, "config")
|
||||||
configContent := `# Wildcard patterns should be ignored
|
configContent := `# Wildcard patterns should be ignored
|
||||||
@@ -323,7 +311,7 @@ Host another-real-server
|
|||||||
HostName another.example.com
|
HostName another.example.com
|
||||||
User anotheruser
|
User anotheruser
|
||||||
`
|
`
|
||||||
|
|
||||||
err := os.WriteFile(configFile, []byte(configContent), 0600)
|
err := os.WriteFile(configFile, []byte(configContent), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create config: %v", err)
|
t.Fatalf("Failed to create config: %v", err)
|
||||||
@@ -377,439 +365,3 @@ Host another-real-server
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSSHConfigExcludesBackupFiles(t *testing.T) {
|
|
||||||
// Create temporary directory for test files
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
|
|
||||||
// Create main config file with include pattern
|
|
||||||
mainConfig := filepath.Join(tempDir, "config")
|
|
||||||
mainConfigContent := `Host main-host
|
|
||||||
HostName example.com
|
|
||||||
|
|
||||||
Include *.conf
|
|
||||||
`
|
|
||||||
|
|
||||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create main config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a regular config file
|
|
||||||
regularConfig := filepath.Join(tempDir, "regular.conf")
|
|
||||||
regularConfigContent := `Host regular-host
|
|
||||||
HostName regular.example.com
|
|
||||||
`
|
|
||||||
|
|
||||||
err = os.WriteFile(regularConfig, []byte(regularConfigContent), 0600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create regular config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a backup file that should be excluded
|
|
||||||
backupConfig := filepath.Join(tempDir, "regular.conf.backup")
|
|
||||||
backupConfigContent := `Host backup-host
|
|
||||||
HostName backup.example.com
|
|
||||||
`
|
|
||||||
|
|
||||||
err = os.WriteFile(backupConfig, []byte(backupConfigContent), 0600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create backup config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the config file
|
|
||||||
hosts, err := ParseSSHConfigFile(mainConfig)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should only get main-host and regular-host, not backup-host
|
|
||||||
expectedHosts := map[string]bool{
|
|
||||||
"main-host": false,
|
|
||||||
"regular-host": false,
|
|
||||||
}
|
|
||||||
|
|
||||||
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 _, host := range hosts {
|
|
||||||
if _, expected := expectedHosts[host.Name]; !expected {
|
|
||||||
t.Errorf("Unexpected host found: %s (backup files should be excluded)", host.Name)
|
|
||||||
} else {
|
|
||||||
expectedHosts[host.Name] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that backup-host was not included
|
|
||||||
for _, host := range hosts {
|
|
||||||
if host.Name == "backup-host" {
|
|
||||||
t.Error("backup-host should not be included (backup files should be excluded)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBackupConfigToSSHMDirectory(t *testing.T) {
|
|
||||||
// Create temporary directory for test files
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
|
|
||||||
// Override the home directory for this test
|
|
||||||
originalHome := os.Getenv("HOME")
|
|
||||||
if originalHome == "" {
|
|
||||||
originalHome = os.Getenv("USERPROFILE") // Windows
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set test home directory
|
|
||||||
os.Setenv("HOME", tempDir)
|
|
||||||
defer os.Setenv("HOME", originalHome)
|
|
||||||
|
|
||||||
// Create a test SSH config file
|
|
||||||
sshDir := filepath.Join(tempDir, ".ssh")
|
|
||||||
err := os.MkdirAll(sshDir, 0700)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create .ssh directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
configPath := filepath.Join(sshDir, "config")
|
|
||||||
configContent := `Host test-host
|
|
||||||
HostName test.example.com
|
|
||||||
User testuser
|
|
||||||
`
|
|
||||||
|
|
||||||
err = os.WriteFile(configPath, []byte(configContent), 0600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create config file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test backup creation
|
|
||||||
err = backupConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("backupConfig() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify backup directory was created
|
|
||||||
backupDir, err := GetSSHMBackupDir()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSSHMBackupDir() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
|
||||||
t.Errorf("Backup directory was not created: %s", backupDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify backup file was created
|
|
||||||
files, err := os.ReadDir(backupDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read backup directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(files) != 1 {
|
|
||||||
t.Errorf("Expected 1 backup file, got %d", len(files))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(files) > 0 {
|
|
||||||
backupFile := files[0]
|
|
||||||
expectedName := "config.backup"
|
|
||||||
if backupFile.Name() != expectedName {
|
|
||||||
t.Errorf("Backup file has unexpected name: got %s, want %s", backupFile.Name(), expectedName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify backup content
|
|
||||||
backupContent, err := os.ReadFile(filepath.Join(backupDir, backupFile.Name()))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read backup file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(backupContent) != configContent {
|
|
||||||
t.Errorf("Backup content doesn't match original")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that subsequent backups overwrite the previous one
|
|
||||||
newConfigContent := `Host test-host-updated
|
|
||||||
HostName updated.example.com
|
|
||||||
User updateduser
|
|
||||||
`
|
|
||||||
|
|
||||||
err = os.WriteFile(configPath, []byte(newConfigContent), 0600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to update config file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create second backup
|
|
||||||
err = backupConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Second backupConfig() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify still only one backup file exists
|
|
||||||
files, err = os.ReadDir(backupDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read backup directory after second backup: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(files) != 1 {
|
|
||||||
t.Errorf("Expected still 1 backup file after overwrite, got %d", len(files))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify backup content was updated
|
|
||||||
if len(files) > 0 {
|
|
||||||
backupContent, err := os.ReadFile(filepath.Join(backupDir, files[0].Name()))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read updated backup file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(backupContent) != newConfigContent {
|
|
||||||
t.Errorf("Updated backup content doesn't match new config content")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindHostInAllConfigs(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 included.conf
|
|
||||||
`
|
|
||||||
|
|
||||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create main config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create included config file
|
|
||||||
includedConfig := filepath.Join(tempDir, "included.conf")
|
|
||||||
includedConfigContent := `Host included-host
|
|
||||||
HostName included.example.com
|
|
||||||
User includeduser
|
|
||||||
`
|
|
||||||
|
|
||||||
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create included config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test finding host from main config
|
|
||||||
host, err := GetSSHHostFromFile("main-host", mainConfig)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSSHHostFromFile() error = %v", err)
|
|
||||||
}
|
|
||||||
if host.Name != "main-host" || host.Hostname != "example.com" {
|
|
||||||
t.Errorf("main-host not found correctly: name=%s, hostname=%s", host.Name, host.Hostname)
|
|
||||||
}
|
|
||||||
if host.SourceFile != mainConfig {
|
|
||||||
t.Errorf("main-host SourceFile incorrect: expected=%s, got=%s", mainConfig, host.SourceFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test finding host from included config
|
|
||||||
// Note: This tests the full parsing with includes
|
|
||||||
hosts, err := ParseSSHConfigFile(mainConfig)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var includedHost *SSHHost
|
|
||||||
for _, h := range hosts {
|
|
||||||
if h.Name == "included-host" {
|
|
||||||
includedHost = &h
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if includedHost == nil {
|
|
||||||
t.Fatal("included-host not found")
|
|
||||||
}
|
|
||||||
if includedHost.Hostname != "included.example.com" || includedHost.User != "includeduser" {
|
|
||||||
t.Errorf("included-host properties incorrect: hostname=%s, user=%s", includedHost.Hostname, includedHost.User)
|
|
||||||
}
|
|
||||||
if includedHost.SourceFile != includedConfig {
|
|
||||||
t.Errorf("included-host SourceFile incorrect: expected=%s, got=%s", includedConfig, includedHost.SourceFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetAllConfigFiles(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 included.conf
|
|
||||||
Include subdir/*.conf
|
|
||||||
`
|
|
||||||
|
|
||||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create main config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create included config file
|
|
||||||
includedConfig := filepath.Join(tempDir, "included.conf")
|
|
||||||
err = os.WriteFile(includedConfig, []byte("Host included-host\n HostName included.example.com\n"), 0600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create included config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create subdirectory with config files
|
|
||||||
subDir := filepath.Join(tempDir, "subdir")
|
|
||||||
err = os.MkdirAll(subDir, 0700)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create subdir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
subConfig := filepath.Join(subDir, "sub.conf")
|
|
||||||
err = os.WriteFile(subConfig, []byte("Host sub-host\n HostName sub.example.com\n"), 0600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create sub config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse to populate the processed files map
|
|
||||||
_, err = ParseSSHConfigFile(mainConfig)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: GetAllConfigFiles() uses a fresh parse, so we test it indirectly
|
|
||||||
// by checking that all files are found during parsing
|
|
||||||
hosts, err := ParseSSHConfigFile(mainConfig)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that hosts from all files are found
|
|
||||||
sourceFiles := make(map[string]bool)
|
|
||||||
for _, host := range hosts {
|
|
||||||
sourceFiles[host.SourceFile] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedFiles := []string{mainConfig, includedConfig, subConfig}
|
|
||||||
for _, expectedFile := range expectedFiles {
|
|
||||||
if !sourceFiles[expectedFile] {
|
|
||||||
t.Errorf("Expected config file not found in SourceFile: %s", expectedFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetAllConfigFilesFromBase(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 included.conf
|
|
||||||
`
|
|
||||||
|
|
||||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create main config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create included config file
|
|
||||||
includedConfig := filepath.Join(tempDir, "included.conf")
|
|
||||||
includedConfigContent := `Host included-host
|
|
||||||
HostName included.example.com
|
|
||||||
|
|
||||||
Include subdir/*.conf
|
|
||||||
`
|
|
||||||
|
|
||||||
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create included config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create subdirectory with config files
|
|
||||||
subDir := filepath.Join(tempDir, "subdir")
|
|
||||||
err = os.MkdirAll(subDir, 0700)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create subdir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
subConfig := filepath.Join(subDir, "sub.conf")
|
|
||||||
err = os.WriteFile(subConfig, []byte("Host sub-host\n HostName sub.example.com\n"), 0600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create sub config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create an isolated config file that should not be included
|
|
||||||
isolatedConfig := filepath.Join(tempDir, "isolated.conf")
|
|
||||||
err = os.WriteFile(isolatedConfig, []byte("Host isolated-host\n HostName isolated.example.com\n"), 0600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create isolated config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test GetAllConfigFilesFromBase with main config as base
|
|
||||||
files, err := GetAllConfigFilesFromBase(mainConfig)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAllConfigFilesFromBase() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should find main config, included config, and sub config, but not isolated config
|
|
||||||
expectedFiles := map[string]bool{
|
|
||||||
mainConfig: false,
|
|
||||||
includedConfig: false,
|
|
||||||
subConfig: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(files) != len(expectedFiles) {
|
|
||||||
t.Errorf("Expected %d config files, got %d", len(expectedFiles), len(files))
|
|
||||||
for i, file := range files {
|
|
||||||
t.Logf("Found file %d: %s", i+1, file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
if _, expected := expectedFiles[file]; expected {
|
|
||||||
expectedFiles[file] = true
|
|
||||||
} else if file == isolatedConfig {
|
|
||||||
t.Errorf("Isolated config file should not be included: %s", file)
|
|
||||||
} else {
|
|
||||||
t.Logf("Unexpected file found: %s", file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that all expected files were found
|
|
||||||
for file, found := range expectedFiles {
|
|
||||||
if !found {
|
|
||||||
t.Errorf("Expected config file not found: %s", file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test GetAllConfigFilesFromBase with isolated config as base (should only return itself)
|
|
||||||
isolatedFiles, err := GetAllConfigFilesFromBase(isolatedConfig)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAllConfigFilesFromBase() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(isolatedFiles) != 1 || isolatedFiles[0] != isolatedConfig {
|
|
||||||
t.Errorf("Expected only isolated config file, got: %v", isolatedFiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test with empty base config file path (should fallback to default behavior)
|
|
||||||
defaultFiles, err := GetAllConfigFilesFromBase("")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAllConfigFilesFromBase('') error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should behave like GetAllConfigFiles()
|
|
||||||
allFiles, err := GetAllConfigFiles()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAllConfigFiles() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(defaultFiles) != len(allFiles) {
|
|
||||||
t.Errorf("GetAllConfigFilesFromBase('') should behave like GetAllConfigFiles(). Got %d vs %d files", len(defaultFiles), len(allFiles))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
package connectivity
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net"
|
|
||||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PingStatus represents the connectivity status of an SSH host
|
|
||||||
type PingStatus int
|
|
||||||
|
|
||||||
const (
|
|
||||||
StatusUnknown PingStatus = iota
|
|
||||||
StatusConnecting
|
|
||||||
StatusOnline
|
|
||||||
StatusOffline
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s PingStatus) String() string {
|
|
||||||
switch s {
|
|
||||||
case StatusUnknown:
|
|
||||||
return "unknown"
|
|
||||||
case StatusConnecting:
|
|
||||||
return "connecting"
|
|
||||||
case StatusOnline:
|
|
||||||
return "online"
|
|
||||||
case StatusOffline:
|
|
||||||
return "offline"
|
|
||||||
}
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
// HostPingResult represents the result of pinging a host
|
|
||||||
type HostPingResult struct {
|
|
||||||
HostName string
|
|
||||||
Status PingStatus
|
|
||||||
Error error
|
|
||||||
Duration time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// PingManager manages SSH connectivity checks for multiple hosts
|
|
||||||
type PingManager struct {
|
|
||||||
results map[string]*HostPingResult
|
|
||||||
mutex sync.RWMutex
|
|
||||||
timeout time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPingManager creates a new ping manager with the specified timeout
|
|
||||||
func NewPingManager(timeout time.Duration) *PingManager {
|
|
||||||
return &PingManager{
|
|
||||||
results: make(map[string]*HostPingResult),
|
|
||||||
timeout: timeout,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStatus returns the current status for a host
|
|
||||||
func (pm *PingManager) GetStatus(hostName string) PingStatus {
|
|
||||||
pm.mutex.RLock()
|
|
||||||
defer pm.mutex.RUnlock()
|
|
||||||
|
|
||||||
if result, exists := pm.results[hostName]; exists {
|
|
||||||
return result.Status
|
|
||||||
}
|
|
||||||
return StatusUnknown
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetResult returns the complete result for a host
|
|
||||||
func (pm *PingManager) GetResult(hostName string) (*HostPingResult, bool) {
|
|
||||||
pm.mutex.RLock()
|
|
||||||
defer pm.mutex.RUnlock()
|
|
||||||
|
|
||||||
result, exists := pm.results[hostName]
|
|
||||||
return result, exists
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateStatus updates the status for a host
|
|
||||||
func (pm *PingManager) updateStatus(hostName string, status PingStatus, err error, duration time.Duration) {
|
|
||||||
pm.mutex.Lock()
|
|
||||||
defer pm.mutex.Unlock()
|
|
||||||
|
|
||||||
pm.results[hostName] = &HostPingResult{
|
|
||||||
HostName: hostName,
|
|
||||||
Status: status,
|
|
||||||
Error: err,
|
|
||||||
Duration: duration,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PingHost performs an SSH connectivity check for a single host
|
|
||||||
func (pm *PingManager) PingHost(ctx context.Context, host config.SSHHost) *HostPingResult {
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
// Mark as connecting
|
|
||||||
pm.updateStatus(host.Name, StatusConnecting, nil, 0)
|
|
||||||
|
|
||||||
// Determine the actual hostname and port
|
|
||||||
hostname := host.Hostname
|
|
||||||
if hostname == "" {
|
|
||||||
hostname = host.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
port := host.Port
|
|
||||||
if port == "" {
|
|
||||||
port = "22"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create context with timeout
|
|
||||||
pingCtx, cancel := context.WithTimeout(ctx, pm.timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Try to establish a TCP connection first (faster than SSH handshake)
|
|
||||||
dialer := &net.Dialer{}
|
|
||||||
conn, err := dialer.DialContext(pingCtx, "tcp", net.JoinHostPort(hostname, port))
|
|
||||||
if err != nil {
|
|
||||||
duration := time.Since(start)
|
|
||||||
pm.updateStatus(host.Name, StatusOffline, err, duration)
|
|
||||||
return &HostPingResult{
|
|
||||||
HostName: host.Name,
|
|
||||||
Status: StatusOffline,
|
|
||||||
Error: err,
|
|
||||||
Duration: duration,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
// If TCP connection succeeds, try SSH handshake
|
|
||||||
sshConfig := &ssh.ClientConfig{
|
|
||||||
User: host.User,
|
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // For ping purposes only
|
|
||||||
Timeout: time.Second * 2, // Short timeout for handshake
|
|
||||||
}
|
|
||||||
|
|
||||||
// We don't need to authenticate, just check if SSH is responding
|
|
||||||
sshConn, _, _, err := ssh.NewClientConn(conn, net.JoinHostPort(hostname, port), sshConfig)
|
|
||||||
if sshConn != nil {
|
|
||||||
sshConn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
duration := time.Since(start)
|
|
||||||
|
|
||||||
// Even if SSH handshake fails, if we got a TCP connection, consider it online
|
|
||||||
// This handles cases where authentication fails but the host is reachable
|
|
||||||
status := StatusOnline
|
|
||||||
if err != nil && isConnectionError(err) {
|
|
||||||
status = StatusOffline
|
|
||||||
}
|
|
||||||
|
|
||||||
pm.updateStatus(host.Name, status, err, duration)
|
|
||||||
return &HostPingResult{
|
|
||||||
HostName: host.Name,
|
|
||||||
Status: status,
|
|
||||||
Error: err,
|
|
||||||
Duration: duration,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PingAllHosts pings all hosts concurrently and returns a channel of results
|
|
||||||
func (pm *PingManager) PingAllHosts(ctx context.Context, hosts []config.SSHHost) <-chan *HostPingResult {
|
|
||||||
resultChan := make(chan *HostPingResult, len(hosts))
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
for _, host := range hosts {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(h config.SSHHost) {
|
|
||||||
defer wg.Done()
|
|
||||||
result := pm.PingHost(ctx, h)
|
|
||||||
select {
|
|
||||||
case resultChan <- result:
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}(host)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close the channel when all goroutines are done
|
|
||||||
go func() {
|
|
||||||
wg.Wait()
|
|
||||||
close(resultChan)
|
|
||||||
}()
|
|
||||||
|
|
||||||
return resultChan
|
|
||||||
}
|
|
||||||
|
|
||||||
// isConnectionError determines if an error is a connection-related error
|
|
||||||
func isConnectionError(err error) bool {
|
|
||||||
if err == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
errStr := err.Error()
|
|
||||||
connectionErrors := []string{
|
|
||||||
"connection refused",
|
|
||||||
"no route to host",
|
|
||||||
"network is unreachable",
|
|
||||||
"timeout",
|
|
||||||
"connection timed out",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, connErr := range connectionErrors {
|
|
||||||
if strings.Contains(strings.ToLower(errStr), connErr) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
"sshm/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConnectionHistory represents the history of SSH connections
|
// ConnectionHistory represents the history of SSH connections
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/user"
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
"sshm/internal/config"
|
||||||
"github.com/Gu1llaum-3/sshm/internal/validation"
|
"sshm/internal/validation"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
"sshm/internal/config"
|
||||||
"github.com/Gu1llaum-3/sshm/internal/validation"
|
"sshm/internal/validation"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type editFormModel struct {
|
type editFormModel struct {
|
||||||
@@ -17,7 +16,6 @@ type editFormModel struct {
|
|||||||
success bool
|
success bool
|
||||||
styles Styles
|
styles Styles
|
||||||
originalName string
|
originalName string
|
||||||
host *config.SSHHost // Store the original host with SourceFile
|
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
configFile string
|
configFile string
|
||||||
@@ -104,7 +102,6 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
|
|||||||
inputs: inputs,
|
inputs: inputs,
|
||||||
focused: nameInput,
|
focused: nameInput,
|
||||||
originalName: hostName,
|
originalName: hostName,
|
||||||
host: host,
|
|
||||||
configFile: configFile,
|
configFile: configFile,
|
||||||
styles: styles,
|
styles: styles,
|
||||||
width: width,
|
width: width,
|
||||||
@@ -204,24 +201,6 @@ func (m *editFormModel) View() string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
b.WriteString(m.styles.FormTitle.Render("Edit SSH Host Configuration"))
|
b.WriteString(m.styles.FormTitle.Render("Edit SSH Host Configuration"))
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
// Show source file information
|
|
||||||
if m.host != nil && m.host.SourceFile != "" {
|
|
||||||
b.WriteString("\n") // Ligne d'espace avant Config file
|
|
||||||
|
|
||||||
// Style for "Config file:" label in primary color
|
|
||||||
labelStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("#00ADD8")). // Primary color
|
|
||||||
Bold(true)
|
|
||||||
|
|
||||||
// Style for the file path in white
|
|
||||||
pathStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("#FFFFFF"))
|
|
||||||
|
|
||||||
configInfo := labelStyle.Render("Config file: ") + pathStyle.Render(formatConfigFile(m.host.SourceFile))
|
|
||||||
b.WriteString(configInfo)
|
|
||||||
}
|
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
fields := []string{
|
fields := []string{
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fileSelectorModel struct {
|
|
||||||
files []string // Chemins absolus des fichiers
|
|
||||||
displayNames []string // Noms d'affichage conviviaux
|
|
||||||
selected int
|
|
||||||
styles Styles
|
|
||||||
width int
|
|
||||||
height int
|
|
||||||
title string
|
|
||||||
}
|
|
||||||
|
|
||||||
type fileSelectorMsg struct {
|
|
||||||
selectedFile string
|
|
||||||
cancelled bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFileSelector creates a new file selector for choosing config files
|
|
||||||
func NewFileSelector(title string, styles Styles, width, height int) (*fileSelectorModel, error) {
|
|
||||||
files, err := config.GetAllConfigFiles()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return newFileSelectorFromFiles(title, styles, width, height, files)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFileSelectorFromBase creates a new file selector starting from a specific base config file
|
|
||||||
func NewFileSelectorFromBase(title string, styles Styles, width, height int, baseConfigFile string) (*fileSelectorModel, error) {
|
|
||||||
var files []string
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if baseConfigFile != "" {
|
|
||||||
files, err = config.GetAllConfigFilesFromBase(baseConfigFile)
|
|
||||||
} else {
|
|
||||||
files, err = config.GetAllConfigFiles()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return newFileSelectorFromFiles(title, styles, width, height, files)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newFileSelectorFromFiles creates a file selector from a list of files
|
|
||||||
func newFileSelectorFromFiles(title string, styles Styles, width, height int, files []string) (*fileSelectorModel, error) {
|
|
||||||
|
|
||||||
// Convert absolute paths to more user-friendly names
|
|
||||||
var displayNames []string
|
|
||||||
homeDir, _ := config.GetSSHDirectory()
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
// Check if it's the main config file
|
|
||||||
mainConfig, _ := config.GetDefaultSSHConfigPath()
|
|
||||||
if file == mainConfig {
|
|
||||||
displayNames = append(displayNames, "Main SSH Config (~/.ssh/config)")
|
|
||||||
} else {
|
|
||||||
// Try to make path relative to home/.ssh/
|
|
||||||
if strings.HasPrefix(file, homeDir) {
|
|
||||||
relPath, err := filepath.Rel(homeDir, file)
|
|
||||||
if err == nil {
|
|
||||||
displayNames = append(displayNames, fmt.Sprintf("~/.ssh/%s", relPath))
|
|
||||||
} else {
|
|
||||||
displayNames = append(displayNames, file)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
displayNames = append(displayNames, file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &fileSelectorModel{
|
|
||||||
files: files,
|
|
||||||
displayNames: displayNames,
|
|
||||||
selected: 0,
|
|
||||||
styles: styles,
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
title: title,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *fileSelectorModel) Init() tea.Cmd {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *fileSelectorModel) Update(msg tea.Msg) (*fileSelectorModel, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
m.width = msg.Width
|
|
||||||
m.height = msg.Height
|
|
||||||
m.styles = NewStyles(m.width)
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch msg.String() {
|
|
||||||
case "ctrl+c", "esc":
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
return fileSelectorMsg{cancelled: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "enter":
|
|
||||||
selectedFile := ""
|
|
||||||
if m.selected < len(m.files) {
|
|
||||||
selectedFile = m.files[m.selected]
|
|
||||||
}
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
return fileSelectorMsg{selectedFile: selectedFile}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "up", "k":
|
|
||||||
if m.selected > 0 {
|
|
||||||
m.selected--
|
|
||||||
}
|
|
||||||
|
|
||||||
case "down", "j":
|
|
||||||
if m.selected < len(m.files)-1 {
|
|
||||||
m.selected++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *fileSelectorModel) View() string {
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
b.WriteString(m.styles.FormTitle.Render(m.title))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
|
|
||||||
if len(m.files) == 0 {
|
|
||||||
b.WriteString(m.styles.Error.Render("No SSH config files found."))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
b.WriteString(m.styles.FormHelp.Render("Esc: cancel"))
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, displayName := range m.displayNames {
|
|
||||||
if i == m.selected {
|
|
||||||
b.WriteString(m.styles.Selected.Render(fmt.Sprintf("▶ %s", displayName)))
|
|
||||||
} else {
|
|
||||||
b.WriteString(fmt.Sprintf(" %s", displayName))
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(m.styles.FormHelp.Render("↑/↓: navigate • Enter: select • Esc: cancel"))
|
|
||||||
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
@@ -62,10 +62,6 @@ func (m *helpModel) View() string {
|
|||||||
" ",
|
" ",
|
||||||
m.styles.HelpText.Render("switch focus"),
|
m.styles.HelpText.Render("switch focus"),
|
||||||
" ",
|
" ",
|
||||||
m.styles.FocusedLabel.Render("p"),
|
|
||||||
" ",
|
|
||||||
m.styles.HelpText.Render("ping all"),
|
|
||||||
" ",
|
|
||||||
m.styles.FocusedLabel.Render("f"),
|
m.styles.FocusedLabel.Render("f"),
|
||||||
" ",
|
" ",
|
||||||
m.styles.HelpText.Render("port forward"),
|
m.styles.HelpText.Render("port forward"),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
"sshm/internal/config"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -91,7 +91,6 @@ func (m *infoFormModel) View() string {
|
|||||||
value string
|
value string
|
||||||
}{
|
}{
|
||||||
{"Host Name", m.host.Name},
|
{"Host Name", m.host.Name},
|
||||||
{"Config File", formatConfigFile(m.host.SourceFile)},
|
|
||||||
{"Hostname/IP", m.host.Hostname},
|
{"Hostname/IP", m.host.Hostname},
|
||||||
{"User", formatOptionalValue(m.host.User)},
|
{"User", formatOptionalValue(m.host.User)},
|
||||||
{"Port", formatOptionalValue(m.host.Port)},
|
{"Port", formatOptionalValue(m.host.Port)},
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
"sshm/internal/config"
|
||||||
"github.com/Gu1llaum-3/sshm/internal/connectivity"
|
"sshm/internal/history"
|
||||||
"github.com/Gu1llaum-3/sshm/internal/history"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/table"
|
"github.com/charmbracelet/bubbles/table"
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
@@ -39,7 +38,6 @@ const (
|
|||||||
ViewInfo
|
ViewInfo
|
||||||
ViewPortForward
|
ViewPortForward
|
||||||
ViewHelp
|
ViewHelp
|
||||||
ViewFileSelector
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// PortForwardType defines the type of port forwarding
|
// PortForwardType defines the type of port forwarding
|
||||||
@@ -74,18 +72,16 @@ type Model struct {
|
|||||||
deleteMode bool
|
deleteMode bool
|
||||||
deleteHost string
|
deleteHost string
|
||||||
historyManager *history.HistoryManager
|
historyManager *history.HistoryManager
|
||||||
pingManager *connectivity.PingManager
|
|
||||||
sortMode SortMode
|
sortMode SortMode
|
||||||
configFile string // Path to the SSH config file
|
configFile string // Path to the SSH config file
|
||||||
|
|
||||||
// View management
|
// View management
|
||||||
viewMode ViewMode
|
viewMode ViewMode
|
||||||
addForm *addFormModel
|
addForm *addFormModel
|
||||||
editForm *editFormModel
|
editForm *editFormModel
|
||||||
infoForm *infoFormModel
|
infoForm *infoFormModel
|
||||||
portForwardForm *portForwardModel
|
portForwardForm *portForwardModel
|
||||||
helpForm *helpModel
|
helpForm *helpModel
|
||||||
fileSelectorForm *fileSelectorModel
|
|
||||||
|
|
||||||
// Terminal size and styles
|
// Terminal size and styles
|
||||||
width int
|
width int
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
"sshm/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sortHosts sorts hosts according to the current sort mode
|
// sortHosts sorts hosts according to the current sort mode
|
||||||
|
|||||||
@@ -3,266 +3,12 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
"sshm/internal/config"
|
||||||
"github.com/Gu1llaum-3/sshm/internal/history"
|
"sshm/internal/history"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/table"
|
"github.com/charmbracelet/bubbles/table"
|
||||||
)
|
)
|
||||||
|
|
||||||
// calculateDynamicColumnWidths calculates optimal column widths based on terminal width
|
|
||||||
// and content length, ensuring all content fits when possible
|
|
||||||
func (m *Model) calculateDynamicColumnWidths(hosts []config.SSHHost) (int, int, int, int) {
|
|
||||||
if m.width <= 0 {
|
|
||||||
// Fallback to static widths if terminal width is not available
|
|
||||||
return calculateNameColumnWidth(hosts), 25, calculateTagsColumnWidth(hosts), calculateLastLoginColumnWidth(hosts, m.historyManager)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate content lengths
|
|
||||||
maxNameLength := 8 // Minimum for "Name" header + status indicator
|
|
||||||
maxHostnameLength := 8 // Minimum for "Hostname" header
|
|
||||||
maxTagsLength := 8 // Minimum for "Tags" header
|
|
||||||
maxLastLoginLength := 12 // Minimum for "Last Login" header
|
|
||||||
|
|
||||||
for _, host := range hosts {
|
|
||||||
// Name column includes status indicator (2 chars) + space (1 char) + name
|
|
||||||
nameLength := 3 + len(host.Name)
|
|
||||||
if nameLength > maxNameLength {
|
|
||||||
maxNameLength = nameLength
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(host.Hostname) > maxHostnameLength {
|
|
||||||
maxHostnameLength = len(host.Hostname)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate tags string length
|
|
||||||
var tagsStr string
|
|
||||||
if len(host.Tags) > 0 {
|
|
||||||
var formattedTags []string
|
|
||||||
for _, tag := range host.Tags {
|
|
||||||
formattedTags = append(formattedTags, "#"+tag)
|
|
||||||
}
|
|
||||||
tagsStr = strings.Join(formattedTags, " ")
|
|
||||||
}
|
|
||||||
if len(tagsStr) > maxTagsLength {
|
|
||||||
maxTagsLength = len(tagsStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate last login length
|
|
||||||
if m.historyManager != nil {
|
|
||||||
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
|
|
||||||
timeStr := formatTimeAgo(lastConnect)
|
|
||||||
if len(timeStr) > maxLastLoginLength {
|
|
||||||
maxLastLoginLength = len(timeStr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add padding to each column
|
|
||||||
maxNameLength += 2
|
|
||||||
maxHostnameLength += 2
|
|
||||||
maxTagsLength += 2
|
|
||||||
maxLastLoginLength += 2
|
|
||||||
|
|
||||||
// Calculate available width (minus borders and separators)
|
|
||||||
// Table has borders (2 chars) + column separators (3 chars between 4 columns)
|
|
||||||
availableWidth := m.width - 5
|
|
||||||
|
|
||||||
totalNeededWidth := maxNameLength + maxHostnameLength + maxTagsLength + maxLastLoginLength
|
|
||||||
|
|
||||||
if totalNeededWidth <= availableWidth {
|
|
||||||
// Everything fits perfectly
|
|
||||||
return maxNameLength, maxHostnameLength, maxTagsLength, maxLastLoginLength
|
|
||||||
}
|
|
||||||
|
|
||||||
// Need to adjust widths - prioritize columns by importance
|
|
||||||
// Priority: Name > Hostname > Last Login > Tags
|
|
||||||
|
|
||||||
// Calculate minimum widths
|
|
||||||
minNameWidth := 15 // Enough for status + short name
|
|
||||||
minHostnameWidth := 15
|
|
||||||
minLastLoginWidth := 12
|
|
||||||
minTagsWidth := 10
|
|
||||||
|
|
||||||
remainingWidth := availableWidth
|
|
||||||
|
|
||||||
// Allocate minimum widths first
|
|
||||||
nameWidth := minNameWidth
|
|
||||||
hostnameWidth := minHostnameWidth
|
|
||||||
lastLoginWidth := minLastLoginWidth
|
|
||||||
tagsWidth := minTagsWidth
|
|
||||||
|
|
||||||
remainingWidth -= (nameWidth + hostnameWidth + lastLoginWidth + tagsWidth)
|
|
||||||
|
|
||||||
// Distribute remaining space proportionally
|
|
||||||
if remainingWidth > 0 {
|
|
||||||
// Calculate how much each column wants beyond minimum
|
|
||||||
nameWant := maxNameLength - minNameWidth
|
|
||||||
hostnameWant := maxHostnameLength - minHostnameWidth
|
|
||||||
lastLoginWant := maxLastLoginLength - minLastLoginWidth
|
|
||||||
tagsWant := maxTagsLength - minTagsWidth
|
|
||||||
|
|
||||||
totalWant := nameWant + hostnameWant + lastLoginWant + tagsWant
|
|
||||||
|
|
||||||
if totalWant > 0 {
|
|
||||||
// Distribute proportionally
|
|
||||||
nameExtra := (nameWant * remainingWidth) / totalWant
|
|
||||||
hostnameExtra := (hostnameWant * remainingWidth) / totalWant
|
|
||||||
lastLoginExtra := (lastLoginWant * remainingWidth) / totalWant
|
|
||||||
tagsExtra := remainingWidth - nameExtra - hostnameExtra - lastLoginExtra
|
|
||||||
|
|
||||||
nameWidth += nameExtra
|
|
||||||
hostnameWidth += hostnameExtra
|
|
||||||
lastLoginWidth += lastLoginExtra
|
|
||||||
tagsWidth += tagsExtra
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nameWidth, hostnameWidth, tagsWidth, lastLoginWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateTableRows updates the table with filtered hosts
|
|
||||||
func (m *Model) updateTableRows() {
|
|
||||||
var rows []table.Row
|
|
||||||
hostsToShow := m.filteredHosts
|
|
||||||
if hostsToShow == nil {
|
|
||||||
hostsToShow = m.hosts
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, host := range hostsToShow {
|
|
||||||
// Get ping status indicator
|
|
||||||
statusIndicator := m.getPingStatusIndicator(host.Name)
|
|
||||||
|
|
||||||
// Format tags for display
|
|
||||||
var tagsStr string
|
|
||||||
if len(host.Tags) > 0 {
|
|
||||||
// Add the # prefix to each tag and join them with spaces
|
|
||||||
var formattedTags []string
|
|
||||||
for _, tag := range host.Tags {
|
|
||||||
formattedTags = append(formattedTags, "#"+tag)
|
|
||||||
}
|
|
||||||
tagsStr = strings.Join(formattedTags, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format last login information
|
|
||||||
var lastLoginStr string
|
|
||||||
if m.historyManager != nil {
|
|
||||||
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
|
|
||||||
lastLoginStr = formatTimeAgo(lastConnect)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rows = append(rows, table.Row{
|
|
||||||
statusIndicator + " " + host.Name,
|
|
||||||
host.Hostname,
|
|
||||||
// host.User, // Commented to save space
|
|
||||||
// host.Port, // Commented to save space
|
|
||||||
tagsStr,
|
|
||||||
lastLoginStr,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
m.table.SetRows(rows)
|
|
||||||
|
|
||||||
// Update table height and columns based on current terminal size
|
|
||||||
m.updateTableHeight()
|
|
||||||
m.updateTableColumns()
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateTableHeight dynamically adjusts table height based on terminal size
|
|
||||||
func (m *Model) updateTableHeight() {
|
|
||||||
if !m.ready {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate dynamic table height based on terminal size
|
|
||||||
// Layout breakdown:
|
|
||||||
// - ASCII title: 5 lines (1 empty + 4 text lines)
|
|
||||||
// - Search bar: 1 line
|
|
||||||
// - Help text: 1 line
|
|
||||||
// - App margins/spacing: 3 lines
|
|
||||||
// - Safety margin: 3 lines (to ensure UI elements are always visible)
|
|
||||||
// Total reserved: 13 lines minimum to preserve essential UI elements
|
|
||||||
reservedHeight := 13
|
|
||||||
availableHeight := m.height - reservedHeight
|
|
||||||
hostCount := len(m.table.Rows())
|
|
||||||
|
|
||||||
// Minimum height should be at least 3 rows for basic usability
|
|
||||||
// Even in very small terminals, we want to show at least header + 2 hosts
|
|
||||||
minTableHeight := 4 // 1 header + 3 data rows minimum
|
|
||||||
maxTableHeight := availableHeight
|
|
||||||
if maxTableHeight < minTableHeight {
|
|
||||||
maxTableHeight = minTableHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
tableHeight := 1 // header
|
|
||||||
dataRowsNeeded := hostCount
|
|
||||||
maxDataRows := maxTableHeight - 1 // subtract 1 for header
|
|
||||||
|
|
||||||
if dataRowsNeeded <= maxDataRows {
|
|
||||||
// We have enough space for all hosts
|
|
||||||
tableHeight += dataRowsNeeded
|
|
||||||
} else {
|
|
||||||
// We need to limit to available space
|
|
||||||
tableHeight += maxDataRows
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add one extra line to prevent the last host from being hidden
|
|
||||||
// This compensates for table rendering quirks in bubble tea
|
|
||||||
tableHeight += 1
|
|
||||||
|
|
||||||
// Update table height
|
|
||||||
m.table.SetHeight(tableHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateTableColumns dynamically adjusts table column widths based on terminal size
|
|
||||||
func (m *Model) updateTableColumns() {
|
|
||||||
if !m.ready {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hostsToShow := m.filteredHosts
|
|
||||||
if hostsToShow == nil {
|
|
||||||
hostsToShow = m.hosts
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use dynamic column width calculation
|
|
||||||
nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(hostsToShow)
|
|
||||||
|
|
||||||
// Create new columns with updated widths and sort indicators
|
|
||||||
nameTitle := "Name"
|
|
||||||
lastLoginTitle := "Last Login"
|
|
||||||
|
|
||||||
// Add sort indicators based on current sort mode
|
|
||||||
switch m.sortMode {
|
|
||||||
case SortByName:
|
|
||||||
nameTitle += " ↓"
|
|
||||||
case SortByLastUsed:
|
|
||||||
lastLoginTitle += " ↓"
|
|
||||||
}
|
|
||||||
|
|
||||||
columns := []table.Column{
|
|
||||||
{Title: nameTitle, Width: nameWidth},
|
|
||||||
{Title: "Hostname", Width: hostnameWidth},
|
|
||||||
// {Title: "User", Width: userWidth}, // Commented to save space
|
|
||||||
// {Title: "Port", Width: portWidth}, // Commented to save space
|
|
||||||
{Title: "Tags", Width: tagsWidth},
|
|
||||||
{Title: lastLoginTitle, Width: lastLoginWidth},
|
|
||||||
}
|
|
||||||
|
|
||||||
m.table.SetColumns(columns)
|
|
||||||
}
|
|
||||||
|
|
||||||
// max returns the maximum of two integers
|
|
||||||
func max(a, b int) int {
|
|
||||||
if a > b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy functions for compatibility
|
|
||||||
|
|
||||||
// calculateNameColumnWidth calculates the optimal width for the Name column
|
// calculateNameColumnWidth calculates the optimal width for the Name column
|
||||||
// based on the longest hostname, with a minimum of 8 and maximum of 40 characters
|
// based on the longest hostname, with a minimum of 8 and maximum of 40 characters
|
||||||
func calculateNameColumnWidth(hosts []config.SSHHost) int {
|
func calculateNameColumnWidth(hosts []config.SSHHost) int {
|
||||||
@@ -344,3 +90,168 @@ func calculateLastLoginColumnWidth(hosts []config.SSHHost, historyManager *histo
|
|||||||
|
|
||||||
return maxLength
|
return maxLength
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateTableRows updates the table with filtered hosts
|
||||||
|
func (m *Model) updateTableRows() {
|
||||||
|
var rows []table.Row
|
||||||
|
hostsToShow := m.filteredHosts
|
||||||
|
if hostsToShow == nil {
|
||||||
|
hostsToShow = m.hosts
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range hostsToShow {
|
||||||
|
// Format tags for display
|
||||||
|
var tagsStr string
|
||||||
|
if len(host.Tags) > 0 {
|
||||||
|
// Add the # prefix to each tag and join them with spaces
|
||||||
|
var formattedTags []string
|
||||||
|
for _, tag := range host.Tags {
|
||||||
|
formattedTags = append(formattedTags, "#"+tag)
|
||||||
|
}
|
||||||
|
tagsStr = strings.Join(formattedTags, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format last login information
|
||||||
|
var lastLoginStr string
|
||||||
|
if m.historyManager != nil {
|
||||||
|
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
|
||||||
|
lastLoginStr = formatTimeAgo(lastConnect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = append(rows, table.Row{
|
||||||
|
host.Name,
|
||||||
|
host.Hostname,
|
||||||
|
// host.User, // Commented to save space
|
||||||
|
// host.Port, // Commented to save space
|
||||||
|
tagsStr,
|
||||||
|
lastLoginStr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
m.table.SetRows(rows)
|
||||||
|
|
||||||
|
// Update table height and columns based on current terminal size
|
||||||
|
m.updateTableHeight()
|
||||||
|
m.updateTableColumns()
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTableHeight dynamically adjusts table height based on terminal size
|
||||||
|
func (m *Model) updateTableHeight() {
|
||||||
|
if !m.ready {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate dynamic table height based on terminal size
|
||||||
|
// Layout breakdown:
|
||||||
|
// - ASCII title: 5 lines (1 empty + 4 text lines)
|
||||||
|
// - Search bar: 1 line
|
||||||
|
// - Help text: 1 line
|
||||||
|
// - App margins/spacing: 3 lines
|
||||||
|
// - Safety margin: 3 lines (to ensure UI elements are always visible)
|
||||||
|
// Total reserved: 12 lines minimum to preserve essential UI elements
|
||||||
|
reservedHeight := 12
|
||||||
|
availableHeight := m.height - reservedHeight
|
||||||
|
hostCount := len(m.table.Rows())
|
||||||
|
|
||||||
|
// Minimum height should be at least 3 rows for basic usability
|
||||||
|
// Even in very small terminals, we want to show at least header + 2 hosts
|
||||||
|
minTableHeight := 4 // 1 header + 3 data rows minimum
|
||||||
|
maxTableHeight := availableHeight
|
||||||
|
if maxTableHeight < minTableHeight {
|
||||||
|
maxTableHeight = minTableHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
tableHeight := 1 // header
|
||||||
|
dataRowsNeeded := hostCount
|
||||||
|
maxDataRows := maxTableHeight - 1 // subtract 1 for header
|
||||||
|
|
||||||
|
if dataRowsNeeded <= maxDataRows {
|
||||||
|
// We have enough space for all hosts
|
||||||
|
tableHeight += dataRowsNeeded
|
||||||
|
} else {
|
||||||
|
// We need to limit to available space
|
||||||
|
tableHeight += maxDataRows
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update table height
|
||||||
|
m.table.SetHeight(tableHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTableColumns dynamically adjusts table column widths based on terminal size
|
||||||
|
func (m *Model) updateTableColumns() {
|
||||||
|
if !m.ready {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hostsToShow := m.filteredHosts
|
||||||
|
if hostsToShow == nil {
|
||||||
|
hostsToShow = m.hosts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate base column widths
|
||||||
|
nameWidth := calculateNameColumnWidth(hostsToShow)
|
||||||
|
tagsWidth := calculateTagsColumnWidth(hostsToShow)
|
||||||
|
lastLoginWidth := calculateLastLoginColumnWidth(hostsToShow, m.historyManager)
|
||||||
|
|
||||||
|
// Fixed column widths
|
||||||
|
hostnameWidth := 25
|
||||||
|
// userWidth := 12 // Commented to save space
|
||||||
|
// portWidth := 6 // Commented to save space
|
||||||
|
|
||||||
|
// Calculate total width needed for all columns
|
||||||
|
totalFixedWidth := hostnameWidth // + userWidth + portWidth // Commented columns
|
||||||
|
totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth
|
||||||
|
totalWidth := totalFixedWidth + totalVariableWidth
|
||||||
|
|
||||||
|
// Available width (accounting for table borders and padding)
|
||||||
|
availableWidth := m.width - 4 // 4 chars for borders and padding
|
||||||
|
|
||||||
|
// If the table is too wide, scale down the variable columns proportionally
|
||||||
|
if totalWidth > availableWidth {
|
||||||
|
excessWidth := totalWidth - availableWidth
|
||||||
|
variableColumnsWidth := totalVariableWidth
|
||||||
|
|
||||||
|
if variableColumnsWidth > 0 {
|
||||||
|
// Reduce variable columns proportionally
|
||||||
|
nameReduction := (excessWidth * nameWidth) / variableColumnsWidth
|
||||||
|
tagsReduction := (excessWidth * tagsWidth) / variableColumnsWidth
|
||||||
|
lastLoginReduction := excessWidth - nameReduction - tagsReduction
|
||||||
|
|
||||||
|
nameWidth = max(8, nameWidth-nameReduction)
|
||||||
|
tagsWidth = max(8, tagsWidth-tagsReduction)
|
||||||
|
lastLoginWidth = max(10, lastLoginWidth-lastLoginReduction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new columns with updated widths and sort indicators
|
||||||
|
nameTitle := "Name"
|
||||||
|
lastLoginTitle := "Last Login"
|
||||||
|
|
||||||
|
// Add sort indicators based on current sort mode
|
||||||
|
switch m.sortMode {
|
||||||
|
case SortByName:
|
||||||
|
nameTitle += " ↓"
|
||||||
|
case SortByLastUsed:
|
||||||
|
lastLoginTitle += " ↓"
|
||||||
|
}
|
||||||
|
|
||||||
|
columns := []table.Column{
|
||||||
|
{Title: nameTitle, Width: nameWidth},
|
||||||
|
{Title: "Hostname", Width: hostnameWidth},
|
||||||
|
// {Title: "User", Width: userWidth}, // Commented to save space
|
||||||
|
// {Title: "Port", Width: portWidth}, // Commented to save space
|
||||||
|
{Title: "Tags", Width: tagsWidth},
|
||||||
|
{Title: lastLoginTitle, Width: lastLoginWidth},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.table.SetColumns(columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// max returns the maximum of two integers
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
"sshm/internal/config"
|
||||||
"github.com/Gu1llaum-3/sshm/internal/connectivity"
|
"sshm/internal/history"
|
||||||
"github.com/Gu1llaum-3/sshm/internal/history"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/table"
|
"github.com/charmbracelet/bubbles/table"
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
@@ -28,14 +26,10 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
|||||||
// Create initial styles (will be updated on first WindowSizeMsg)
|
// Create initial styles (will be updated on first WindowSizeMsg)
|
||||||
styles := NewStyles(80) // Default width
|
styles := NewStyles(80) // Default width
|
||||||
|
|
||||||
// Initialize ping manager with 5 second timeout
|
|
||||||
pingManager := connectivity.NewPingManager(5 * time.Second)
|
|
||||||
|
|
||||||
// Create the model with default sorting by name
|
// Create the model with default sorting by name
|
||||||
m := Model{
|
m := Model{
|
||||||
hosts: hosts,
|
hosts: hosts,
|
||||||
historyManager: historyManager,
|
historyManager: historyManager,
|
||||||
pingManager: pingManager,
|
|
||||||
sortMode: SortByName,
|
sortMode: SortByName,
|
||||||
configFile: configFile,
|
configFile: configFile,
|
||||||
styles: styles,
|
styles: styles,
|
||||||
@@ -52,15 +46,21 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
|||||||
ti := textinput.New()
|
ti := textinput.New()
|
||||||
ti.Placeholder = "Search hosts or tags..."
|
ti.Placeholder = "Search hosts or tags..."
|
||||||
ti.CharLimit = 50
|
ti.CharLimit = 50
|
||||||
ti.Width = 25
|
ti.Width = 50
|
||||||
|
|
||||||
// Use dynamic column width calculation (will fallback to static if width not available)
|
// Calculate optimal width for the Name column
|
||||||
nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(sortedHosts)
|
nameWidth := calculateNameColumnWidth(sortedHosts)
|
||||||
|
|
||||||
|
// Calculate optimal width for the Tags column
|
||||||
|
tagsWidth := calculateTagsColumnWidth(sortedHosts)
|
||||||
|
|
||||||
|
// Calculate optimal width for the Last Login column
|
||||||
|
lastLoginWidth := calculateLastLoginColumnWidth(sortedHosts, historyManager)
|
||||||
|
|
||||||
// Create table columns
|
// Create table columns
|
||||||
columns := []table.Column{
|
columns := []table.Column{
|
||||||
{Title: "Name", Width: nameWidth},
|
{Title: "Name", Width: nameWidth},
|
||||||
{Title: "Hostname", Width: hostnameWidth},
|
{Title: "Hostname", Width: 25},
|
||||||
// {Title: "User", Width: 12}, // Commented to save space
|
// {Title: "User", Width: 12}, // Commented to save space
|
||||||
// {Title: "Port", Width: 6}, // Commented to save space
|
// {Title: "Port", Width: 6}, // Commented to save space
|
||||||
{Title: "Tags", Width: tagsWidth},
|
{Title: "Tags", Width: tagsWidth},
|
||||||
@@ -70,9 +70,6 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
|||||||
// Convert hosts to table rows
|
// Convert hosts to table rows
|
||||||
var rows []table.Row
|
var rows []table.Row
|
||||||
for _, host := range sortedHosts {
|
for _, host := range sortedHosts {
|
||||||
// Get ping status indicator
|
|
||||||
statusIndicator := m.getPingStatusIndicator(host.Name)
|
|
||||||
|
|
||||||
// Format tags for display
|
// Format tags for display
|
||||||
var tagsStr string
|
var tagsStr string
|
||||||
if len(host.Tags) > 0 {
|
if len(host.Tags) > 0 {
|
||||||
@@ -93,7 +90,7 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows = append(rows, table.Row{
|
rows = append(rows, table.Row{
|
||||||
statusIndicator + " " + host.Name,
|
host.Name,
|
||||||
host.Hostname,
|
host.Hostname,
|
||||||
// host.User, // Commented to save space
|
// host.User, // Commented to save space
|
||||||
// host.Port, // Commented to save space
|
// host.Port, // Commented to save space
|
||||||
|
|||||||
@@ -1,59 +1,20 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/Gu1llaum-3/sshm/internal/config"
|
"sshm/internal/config"
|
||||||
"github.com/Gu1llaum-3/sshm/internal/connectivity"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Messages for SSH ping functionality
|
|
||||||
type (
|
|
||||||
pingResultMsg *connectivity.HostPingResult
|
|
||||||
)
|
|
||||||
|
|
||||||
// startPingAllCmd creates a command to ping all hosts concurrently
|
|
||||||
func (m Model) startPingAllCmd() tea.Cmd {
|
|
||||||
if m.pingManager == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return tea.Batch(
|
|
||||||
// Create individual ping commands for each host
|
|
||||||
func() tea.Cmd {
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
for _, host := range m.hosts {
|
|
||||||
cmds = append(cmds, pingSingleHostCmd(m.pingManager, host))
|
|
||||||
}
|
|
||||||
return tea.Batch(cmds...)
|
|
||||||
}(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// listenForPingResultsCmd is no longer needed since we use individual ping commands
|
|
||||||
|
|
||||||
// pingSingleHostCmd creates a command to ping a single host
|
|
||||||
func pingSingleHostCmd(pingManager *connectivity.PingManager, host config.SSHHost) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
result := pingManager.PingHost(ctx, host)
|
|
||||||
return pingResultMsg(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init initializes the model
|
// Init initializes the model
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
return tea.Batch(
|
return tea.Batch(
|
||||||
textinput.Blink,
|
textinput.Blink,
|
||||||
// Ping is now optional - use 'p' key to start ping
|
// Ajoute ici d'autres tea.Cmd si tu veux charger des données, démarrer un spinner, etc.
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,19 +61,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.helpForm.height = m.height
|
m.helpForm.height = m.height
|
||||||
m.helpForm.styles = m.styles
|
m.helpForm.styles = m.styles
|
||||||
}
|
}
|
||||||
if m.fileSelectorForm != nil {
|
|
||||||
m.fileSelectorForm.width = m.width
|
|
||||||
m.fileSelectorForm.height = m.height
|
|
||||||
m.fileSelectorForm.styles = m.styles
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case pingResultMsg:
|
|
||||||
// Handle ping result - update table display
|
|
||||||
if msg != nil {
|
|
||||||
// Update the table to reflect the new ping status
|
|
||||||
m.updateTableRows()
|
|
||||||
}
|
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case addFormSubmitMsg:
|
case addFormSubmitMsg:
|
||||||
@@ -210,21 +158,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.table.Focus()
|
m.table.Focus()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case fileSelectorMsg:
|
|
||||||
if msg.cancelled {
|
|
||||||
// Cancel: return to list view
|
|
||||||
m.viewMode = ViewList
|
|
||||||
m.fileSelectorForm = nil
|
|
||||||
m.table.Focus()
|
|
||||||
return m, nil
|
|
||||||
} else {
|
|
||||||
// File selected: proceed to add form with selected file
|
|
||||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, msg.selectedFile)
|
|
||||||
m.viewMode = ViewAdd
|
|
||||||
m.fileSelectorForm = nil
|
|
||||||
return m, textinput.Blink
|
|
||||||
}
|
|
||||||
|
|
||||||
case infoFormEditMsg:
|
case infoFormEditMsg:
|
||||||
// Switch from info to edit mode
|
// Switch from info to edit mode
|
||||||
editForm, err := NewEditForm(msg.hostName, m.styles, m.width, m.height, m.configFile)
|
editForm, err := NewEditForm(msg.hostName, m.styles, m.width, m.height, m.configFile)
|
||||||
@@ -324,13 +257,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.helpForm = newForm
|
m.helpForm = newForm
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
case ViewFileSelector:
|
|
||||||
if m.fileSelectorForm != nil {
|
|
||||||
var newForm *fileSelectorModel
|
|
||||||
newForm, cmd = m.fileSelectorForm.Update(msg)
|
|
||||||
m.fileSelectorForm = newForm
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
case ViewList:
|
case ViewList:
|
||||||
// Handle list view keys
|
// Handle list view keys
|
||||||
return m.handleListViewKeys(msg)
|
return m.handleListViewKeys(msg)
|
||||||
@@ -443,7 +369,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
// Connect to the selected host
|
// Connect to the selected host
|
||||||
selected := m.table.SelectedRow()
|
selected := m.table.SelectedRow()
|
||||||
if len(selected) > 0 {
|
if len(selected) > 0 {
|
||||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
hostName := selected[0] // The hostname is in the first column
|
||||||
|
|
||||||
// Record the connection in history
|
// Record the connection in history
|
||||||
if m.historyManager != nil {
|
if m.historyManager != nil {
|
||||||
@@ -472,7 +398,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
// Edit the selected host
|
// Edit the selected host
|
||||||
selected := m.table.SelectedRow()
|
selected := m.table.SelectedRow()
|
||||||
if len(selected) > 0 {
|
if len(selected) > 0 {
|
||||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
hostName := selected[0] // The hostname is in the first column
|
||||||
editForm, err := NewEditForm(hostName, m.styles, m.width, m.height, m.configFile)
|
editForm, err := NewEditForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Handle error - could show in UI
|
// Handle error - could show in UI
|
||||||
@@ -488,7 +414,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
// Show info for the selected host
|
// Show info for the selected host
|
||||||
selected := m.table.SelectedRow()
|
selected := m.table.SelectedRow()
|
||||||
if len(selected) > 0 {
|
if len(selected) > 0 {
|
||||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
hostName := selected[0] // The hostname is in the first column
|
||||||
infoForm, err := NewInfoForm(hostName, m.styles, m.width, m.height, m.configFile)
|
infoForm, err := NewInfoForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Handle error - could show in UI
|
// Handle error - could show in UI
|
||||||
@@ -501,40 +427,9 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
case "a":
|
case "a":
|
||||||
if !m.searchMode && !m.deleteMode {
|
if !m.searchMode && !m.deleteMode {
|
||||||
// Check if there are multiple config files starting from the current base config
|
// Add a new host
|
||||||
var configFiles []string
|
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile)
|
||||||
var err error
|
m.viewMode = ViewAdd
|
||||||
|
|
||||||
if m.configFile != "" {
|
|
||||||
// Use the specified config file as base
|
|
||||||
configFiles, err = config.GetAllConfigFilesFromBase(m.configFile)
|
|
||||||
} else {
|
|
||||||
// Use the default config file as base
|
|
||||||
configFiles, err = config.GetAllConfigFiles()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil || len(configFiles) <= 1 {
|
|
||||||
// Only one config file (or error), go directly to add form
|
|
||||||
var configFile string
|
|
||||||
if len(configFiles) == 1 {
|
|
||||||
configFile = configFiles[0]
|
|
||||||
} else {
|
|
||||||
configFile = m.configFile
|
|
||||||
}
|
|
||||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, configFile)
|
|
||||||
m.viewMode = ViewAdd
|
|
||||||
} else {
|
|
||||||
// Multiple config files, show file selector
|
|
||||||
fileSelectorForm, err := NewFileSelectorFromBase("Select config file to add host to:", m.styles, m.width, m.height, m.configFile)
|
|
||||||
if err != nil {
|
|
||||||
// Fallback to default behavior if file selector fails
|
|
||||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile)
|
|
||||||
m.viewMode = ViewAdd
|
|
||||||
} else {
|
|
||||||
m.fileSelectorForm = fileSelectorForm
|
|
||||||
m.viewMode = ViewFileSelector
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, textinput.Blink
|
return m, textinput.Blink
|
||||||
}
|
}
|
||||||
case "d":
|
case "d":
|
||||||
@@ -542,24 +437,19 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
// Delete the selected host
|
// Delete the selected host
|
||||||
selected := m.table.SelectedRow()
|
selected := m.table.SelectedRow()
|
||||||
if len(selected) > 0 {
|
if len(selected) > 0 {
|
||||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
hostName := selected[0] // The hostname is in the first column
|
||||||
m.deleteMode = true
|
m.deleteMode = true
|
||||||
m.deleteHost = hostName
|
m.deleteHost = hostName
|
||||||
m.table.Blur()
|
m.table.Blur()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "p":
|
|
||||||
if !m.searchMode && !m.deleteMode {
|
|
||||||
// Ping all hosts
|
|
||||||
return m, m.startPingAllCmd()
|
|
||||||
}
|
|
||||||
case "f":
|
case "f":
|
||||||
if !m.searchMode && !m.deleteMode {
|
if !m.searchMode && !m.deleteMode {
|
||||||
// Port forwarding for the selected host
|
// Port forwarding for the selected host
|
||||||
selected := m.table.SelectedRow()
|
selected := m.table.SelectedRow()
|
||||||
if len(selected) > 0 {
|
if len(selected) > 0 {
|
||||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
hostName := selected[0] // The hostname is in the first column
|
||||||
m.portForwardForm = NewPortForwardForm(hostName, m.styles, m.width, m.height, m.configFile)
|
m.portForwardForm = NewPortForwardForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||||
m.viewMode = ViewPortForward
|
m.viewMode = ViewPortForward
|
||||||
return m, textinput.Blink
|
return m, textinput.Blink
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/Gu1llaum-3/sshm/internal/connectivity"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,49 +55,3 @@ func formatTimeAgo(t time.Time) string {
|
|||||||
return fmt.Sprintf("%d years ago", years)
|
return fmt.Sprintf("%d years ago", years)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatConfigFile formats a config file path for display
|
|
||||||
func formatConfigFile(filePath string) string {
|
|
||||||
if filePath == "" {
|
|
||||||
return "Unknown"
|
|
||||||
}
|
|
||||||
// Show just the filename and parent directory for readability
|
|
||||||
parts := strings.Split(filePath, "/")
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
return fmt.Sprintf(".../%s/%s", parts[len(parts)-2], parts[len(parts)-1])
|
|
||||||
}
|
|
||||||
return filePath
|
|
||||||
}
|
|
||||||
|
|
||||||
// getPingStatusIndicator returns a colored circle indicator based on ping status
|
|
||||||
func (m *Model) getPingStatusIndicator(hostName string) string {
|
|
||||||
if m.pingManager == nil {
|
|
||||||
return "⚫" // Gray circle for unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
status := m.pingManager.GetStatus(hostName)
|
|
||||||
switch status {
|
|
||||||
case connectivity.StatusOnline:
|
|
||||||
return "🟢" // Green circle for online
|
|
||||||
case connectivity.StatusOffline:
|
|
||||||
return "🔴" // Red circle for offline
|
|
||||||
case connectivity.StatusConnecting:
|
|
||||||
return "🟡" // Yellow circle for connecting
|
|
||||||
default:
|
|
||||||
return "⚫" // Gray circle for unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractHostNameFromTableRow extracts the host name from the first column,
|
|
||||||
// removing the ping status indicator
|
|
||||||
func extractHostNameFromTableRow(firstColumn string) string {
|
|
||||||
// The first column format is: "🟢 hostname" or "⚫ hostname" etc.
|
|
||||||
// We need to remove the emoji and space to get just the hostname
|
|
||||||
parts := strings.Fields(firstColumn)
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
// Return everything after the first part (the emoji)
|
|
||||||
return strings.Join(parts[1:], " ")
|
|
||||||
}
|
|
||||||
// Fallback: if there's no space, return the whole string
|
|
||||||
return firstColumn
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -35,10 +35,6 @@ func (m Model) View() string {
|
|||||||
if m.helpForm != nil {
|
if m.helpForm != nil {
|
||||||
return m.helpForm.View()
|
return m.helpForm.View()
|
||||||
}
|
}
|
||||||
case ViewFileSelector:
|
|
||||||
if m.fileSelectorForm != nil {
|
|
||||||
return m.fileSelectorForm.View()
|
|
||||||
}
|
|
||||||
case ViewList:
|
case ViewList:
|
||||||
return m.renderListView()
|
return m.renderListView()
|
||||||
}
|
}
|
||||||
@@ -74,7 +70,7 @@ func (m Model) renderListView() string {
|
|||||||
// Add the help text
|
// Add the help text
|
||||||
var helpText string
|
var helpText string
|
||||||
if !m.searchMode {
|
if !m.searchMode {
|
||||||
helpText = " ↑/↓: navigate • Enter: connect • p: ping all • i: info • h: help • q: quit"
|
helpText = " ↑/↓: navigate • Enter: connect • i: info • h: help • q: quit"
|
||||||
} else {
|
} else {
|
||||||
helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit"
|
helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user