mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2026-01-27 03:04:21 +01:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b67f5abbbc | |||
| b587defabc | |||
| 22586484c7 | |||
| 420db56ff5 | |||
|
|
7600eaaa9b | ||
|
|
e0dd32993a | ||
| 1cea3795e4 | |||
| 2ade315ddc | |||
| 2deec405f7 | |||
| 21c5d41977 | |||
| 20bc506e36 |
30
.github/workflows/build.yml
vendored
30
.github/workflows/build.yml
vendored
@@ -34,6 +34,14 @@ jobs:
|
|||||||
- goos: darwin
|
- goos: darwin
|
||||||
goarch: arm64
|
goarch: arm64
|
||||||
suffix: darwin-arm64
|
suffix: darwin-arm64
|
||||||
|
# Windows AMD64
|
||||||
|
- goos: windows
|
||||||
|
goarch: amd64
|
||||||
|
suffix: windows-amd64
|
||||||
|
# Windows ARM64
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm64
|
||||||
|
suffix: windows-arm64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -60,19 +68,30 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
VERSION=${GITHUB_REF#refs/tags/}
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }} .
|
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||||
|
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }}.exe .
|
||||||
|
else
|
||||||
|
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }} .
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Create tarball
|
- name: Create archive
|
||||||
run: |
|
run: |
|
||||||
cd dist
|
cd dist
|
||||||
tar -czf sshm-${{ matrix.suffix }}.tar.gz sshm-${{ matrix.suffix }}
|
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||||
rm sshm-${{ matrix.suffix }}
|
zip sshm-${{ matrix.suffix }}.zip sshm-${{ matrix.suffix }}.exe
|
||||||
|
rm sshm-${{ matrix.suffix }}.exe
|
||||||
|
else
|
||||||
|
tar -czf sshm-${{ matrix.suffix }}.tar.gz sshm-${{ matrix.suffix }}
|
||||||
|
rm sshm-${{ matrix.suffix }}
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: sshm-${{ matrix.suffix }}
|
name: sshm-${{ matrix.suffix }}
|
||||||
path: dist/sshm-${{ matrix.suffix }}.tar.gz
|
path: |
|
||||||
|
dist/sshm-${{ matrix.suffix }}.tar.gz
|
||||||
|
dist/sshm-${{ matrix.suffix }}.zip
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: Create Release
|
name: Create Release
|
||||||
@@ -91,6 +110,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p release
|
mkdir -p release
|
||||||
find ./artifacts -name "*.tar.gz" -exec cp {} ./release/ \;
|
find ./artifacts -name "*.tar.gz" -exec cp {} ./release/ \;
|
||||||
|
find ./artifacts -name "*.zip" -exec cp {} ./release/ \;
|
||||||
ls -la ./release/
|
ls -la ./release/
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
|
|||||||
175
README.md
175
README.md
@@ -9,7 +9,7 @@
|
|||||||
[](https://golang.org/)
|
[](https://golang.org/)
|
||||||
[](https://github.com/Gu1llaum-3/sshm/releases)
|
[](https://github.com/Gu1llaum-3/sshm/releases)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/Gu1llaum-3/sshm/releases)
|
[](https://github.com/Gu1llaum-3/sshm/releases)
|
||||||
|
|
||||||
> **A modern, interactive SSH Manager for your terminal** 🔥
|
> **A modern, interactive SSH Manager for your terminal** 🔥
|
||||||
|
|
||||||
@@ -28,11 +28,13 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
|
|||||||
### 🎯 **Core Features**
|
### 🎯 **Core Features**
|
||||||
- **🎨 Beautiful TUI Interface** - Navigate your SSH hosts with an elegant, interactive terminal UI
|
- **🎨 Beautiful TUI Interface** - Navigate your SSH hosts with an elegant, interactive terminal UI
|
||||||
- **⚡ Quick Connect** - Connect to any host instantly
|
- **⚡ Quick Connect** - Connect to any host instantly
|
||||||
- **📝 Easy Management** - Add, edit, and manage SSH configurations seamlessly
|
- **🔄 Port Forwarding** - Easy setup for Local, Remote, and Dynamic (SOCKS) forwarding
|
||||||
|
- **📝Easy Management** - Add, edit, and manage SSH configurations seamlessly
|
||||||
- **🏷️ Tag Support** - Organize your hosts with custom tags for better categorization
|
- **🏷️ Tag Support** - Organize your hosts with custom tags for better categorization
|
||||||
- **🔍 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
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
|
|||||||
- **Add new SSH hosts** with interactive forms
|
- **Add new SSH hosts** with interactive forms
|
||||||
- **Edit existing configurations** in-place
|
- **Edit existing configurations** in-place
|
||||||
- **Delete hosts** with confirmation prompts
|
- **Delete hosts** with confirmation prompts
|
||||||
|
- **Port forwarding setup** with intuitive interface for Local (-L), Remote (-R), and Dynamic (-D) forwarding
|
||||||
- **Backup configurations** automatically before changes
|
- **Backup configurations** automatically before changes
|
||||||
- **Validate settings** to prevent configuration errors
|
- **Validate settings** to prevent configuration errors
|
||||||
- **ProxyJump support** for secure connection tunneling through bastion hosts
|
- **ProxyJump support** for secure connection tunneling through bastion hosts
|
||||||
@@ -49,19 +52,26 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
|
|||||||
### 🎮 **User Experience**
|
### 🎮 **User Experience**
|
||||||
- **Zero configuration** - Works out of the box with your existing SSH setup
|
- **Zero configuration** - Works out of the box with your existing SSH setup
|
||||||
- **Keyboard shortcuts** for power users
|
- **Keyboard shortcuts** for power users
|
||||||
- **Cross-platform** - Supports Linux and macOS (Intel & Apple Silicon)
|
- **Cross-platform** - Supports Linux, macOS (Intel & Apple Silicon), and Windows
|
||||||
- **Lightweight** - Single binary with no dependencies
|
- **Lightweight** - Single binary with no dependencies
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
**One-line install (Recommended):**
|
**Unix/Linux/macOS (One-line install):**
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh | bash
|
curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Windows (PowerShell):**
|
||||||
|
```powershell
|
||||||
|
irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex
|
||||||
|
```
|
||||||
|
|
||||||
**Alternative methods:**
|
**Alternative methods:**
|
||||||
|
|
||||||
|
*Linux/macOS:*
|
||||||
```bash
|
```bash
|
||||||
# Download specific release
|
# Download specific release
|
||||||
wget https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-amd64.tar.gz
|
wget https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-amd64.tar.gz
|
||||||
@@ -71,6 +81,14 @@ tar -xzf sshm-linux-amd64.tar.gz
|
|||||||
sudo mv sshm-linux-amd64 /usr/local/bin/sshm
|
sudo mv sshm-linux-amd64 /usr/local/bin/sshm
|
||||||
```
|
```
|
||||||
|
|
||||||
|
*Windows:*
|
||||||
|
```powershell
|
||||||
|
# Download and extract
|
||||||
|
Invoke-WebRequest -Uri "https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-windows-amd64.zip" -OutFile "sshm-windows-amd64.zip"
|
||||||
|
Expand-Archive sshm-windows-amd64.zip -DestinationPath C:\tools\
|
||||||
|
# Add C:\tools to your PATH environment variable
|
||||||
|
```
|
||||||
|
|
||||||
## 📖 Usage
|
## 📖 Usage
|
||||||
|
|
||||||
### Interactive Mode
|
### Interactive Mode
|
||||||
@@ -87,6 +105,7 @@ sshm
|
|||||||
- `a` - Add new host
|
- `a` - Add new host
|
||||||
- `e` - Edit selected host
|
- `e` - Edit selected host
|
||||||
- `d` - Delete selected host
|
- `d` - Delete selected host
|
||||||
|
- `f` - Port forwarding setup
|
||||||
- `q` - Quit
|
- `q` - Quit
|
||||||
- `/` - Search/filter hosts
|
- `/` - Search/filter hosts
|
||||||
|
|
||||||
@@ -107,6 +126,90 @@ The interactive forms will guide you through configuration:
|
|||||||
- **SSH Options** - Additional SSH options in `-o` format (e.g., `-o Compression=yes -o ServerAliveInterval=60`)
|
- **SSH Options** - Additional SSH options in `-o` format (e.g., `-o Compression=yes -o ServerAliveInterval=60`)
|
||||||
- **Tags** - Comma-separated tags for organization
|
- **Tags** - Comma-separated tags for organization
|
||||||
|
|
||||||
|
### Port Forwarding
|
||||||
|
|
||||||
|
SSHM provides an intuitive interface for setting up SSH port forwarding. Press `f` while selecting a host to open the port forwarding setup:
|
||||||
|
|
||||||
|
**Forward Types:**
|
||||||
|
- **Local (-L)** - Forward a local port to a remote host/port through the SSH connection
|
||||||
|
- Example: Access a remote database on `localhost:5432` via local port `15432`
|
||||||
|
- Use case: `ssh -L 15432:localhost:5432 server` → Database accessible on `localhost:15432`
|
||||||
|
|
||||||
|
- **Remote (-R)** - Forward a remote port back to a local host/port
|
||||||
|
- Example: Expose local web server on remote host's port `8080`
|
||||||
|
- Use case: `ssh -R 8080:localhost:3000 server` → Local app accessible from remote host's port 8080
|
||||||
|
- ⚠️ **Requirements for external access:**
|
||||||
|
- **SSH Server Config**: Add `GatewayPorts yes` to `/etc/ssh/sshd_config` and restart SSH service
|
||||||
|
- **Firewall**: Open the remote port in the server's firewall (`ufw allow 8080` or equivalent)
|
||||||
|
- **Port Availability**: Ensure the remote port is not already in use
|
||||||
|
- **Bind Address**: Use `0.0.0.0` for external access, `127.0.0.1` for local-only
|
||||||
|
|
||||||
|
- **Dynamic (-D)** - Create a SOCKS proxy for secure browsing
|
||||||
|
- Example: Route web traffic through the SSH connection
|
||||||
|
- Use case: `ssh -D 1080 server` → Configure browser to use `localhost:1080` as SOCKS proxy
|
||||||
|
- ⚠️ **Configuration requirements:**
|
||||||
|
- **Browser Setup**: Configure SOCKS v5 proxy in browser settings
|
||||||
|
- **DNS**: Enable "Proxy DNS when using SOCKS v5" for full privacy
|
||||||
|
- **Applications**: Only SOCKS-aware applications will use the proxy
|
||||||
|
- **Bind Address**: Use `127.0.0.1` for security (local access only)
|
||||||
|
|
||||||
|
**Port Forwarding Interface:**
|
||||||
|
- Choose forward type with ←/→ arrow keys
|
||||||
|
- Configure ports and addresses with guided forms
|
||||||
|
- Optional bind address configuration (defaults to 127.0.0.1)
|
||||||
|
- Real-time validation of port numbers and addresses
|
||||||
|
- Connect automatically with configured forwarding options
|
||||||
|
|
||||||
|
**Troubleshooting Port Forwarding:**
|
||||||
|
|
||||||
|
*Remote Forwarding Issues:*
|
||||||
|
```bash
|
||||||
|
# Error: "remote port forwarding failed for listen port X"
|
||||||
|
# Solutions:
|
||||||
|
1. Check if port is already in use: ssh server "netstat -tln | grep :X"
|
||||||
|
2. Use a different port that's available
|
||||||
|
3. Enable GatewayPorts in SSH config for external access
|
||||||
|
```
|
||||||
|
|
||||||
|
*SSH Server Configuration for Remote Forwarding:*
|
||||||
|
```bash
|
||||||
|
# Edit SSH daemon config on the server:
|
||||||
|
sudo nano /etc/ssh/sshd_config
|
||||||
|
|
||||||
|
# Add or uncomment:
|
||||||
|
GatewayPorts yes
|
||||||
|
|
||||||
|
# Restart SSH service:
|
||||||
|
sudo systemctl restart sshd # Ubuntu/Debian/CentOS 7+
|
||||||
|
# OR
|
||||||
|
sudo service ssh restart # Older systems
|
||||||
|
```
|
||||||
|
|
||||||
|
*Firewall Configuration:*
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian (UFW):
|
||||||
|
sudo ufw allow [port_number]
|
||||||
|
|
||||||
|
# CentOS/RHEL/Rocky (firewalld):
|
||||||
|
sudo firewall-cmd --add-port=[port_number]/tcp --permanent
|
||||||
|
sudo firewall-cmd --reload
|
||||||
|
|
||||||
|
# Check if port is accessible:
|
||||||
|
telnet [server_ip] [port_number]
|
||||||
|
```
|
||||||
|
|
||||||
|
*Dynamic Forwarding (SOCKS) Browser Setup:*
|
||||||
|
```
|
||||||
|
Firefox: about:preferences → Network Settings
|
||||||
|
- Manual proxy configuration
|
||||||
|
- SOCKS Host: localhost, Port: [your_port]
|
||||||
|
- SOCKS v5: ✓
|
||||||
|
- Proxy DNS when using SOCKS v5: ✓
|
||||||
|
|
||||||
|
Chrome: Launch with proxy
|
||||||
|
chrome --proxy-server="socks5://localhost:[your_port]"
|
||||||
|
```
|
||||||
|
|
||||||
### CLI Usage
|
### CLI Usage
|
||||||
|
|
||||||
SSHM provides both command-line operations and an interactive TUI interface:
|
SSHM provides both command-line operations and an interactive TUI interface:
|
||||||
@@ -153,11 +256,72 @@ sshm add hostname -c /path/to/custom/ssh_config
|
|||||||
sshm edit hostname -c /path/to/custom/ssh_config
|
sshm edit hostname -c /path/to/custom/ssh_config
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Platform-Specific Notes
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
- SSHM works with the built-in OpenSSH client (Windows 10/11)
|
||||||
|
- Configuration file location: `%USERPROFILE%\.ssh\config`
|
||||||
|
- Compatible with WSL SSH configurations
|
||||||
|
- Supports the same SSH options as Unix systems
|
||||||
|
|
||||||
|
**Unix/Linux/macOS:**
|
||||||
|
- Standard SSH configuration file: `~/.ssh/config`
|
||||||
|
- Full compatibility with OpenSSH features
|
||||||
|
- Preserves file permissions automatically
|
||||||
|
|
||||||
## 🏗️ Configuration
|
## 🏗️ Configuration
|
||||||
|
|
||||||
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
|
||||||
@@ -314,6 +478,8 @@ Automated releases are built for multiple platforms:
|
|||||||
| Linux | ARM64 | [sshm-linux-arm64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-arm64.tar.gz) |
|
| Linux | ARM64 | [sshm-linux-arm64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-arm64.tar.gz) |
|
||||||
| macOS | Intel | [sshm-darwin-amd64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-darwin-amd64.tar.gz) |
|
| macOS | Intel | [sshm-darwin-amd64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-darwin-amd64.tar.gz) |
|
||||||
| macOS | Apple Silicon | [sshm-darwin-arm64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-darwin-arm64.tar.gz) |
|
| macOS | Apple Silicon | [sshm-darwin-arm64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-darwin-arm64.tar.gz) |
|
||||||
|
| Windows | AMD64 | [sshm-windows-amd64.zip](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-windows-amd64.zip) |
|
||||||
|
| Windows | ARM64 | [sshm-windows-arm64.zip](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-windows-arm64.zip) |
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
@@ -335,6 +501,7 @@ 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -12,8 +12,28 @@ curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh
|
|||||||
|
|
||||||
**Note:** When using the pipe method, the installer will automatically proceed with installation if SSHM is already installed.
|
**Note:** When using the pipe method, the installer will automatically proceed with installation if SSHM is already installed.
|
||||||
|
|
||||||
|
## Windows Installation
|
||||||
|
|
||||||
|
### Quick Install (Recommended)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex
|
||||||
|
```
|
||||||
|
|
||||||
### Install Options
|
### Install Options
|
||||||
|
|
||||||
|
**Force install without prompts:**
|
||||||
|
```powershell
|
||||||
|
iex "& { $(irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1) } -Force"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Custom installation directory:**
|
||||||
|
```powershell
|
||||||
|
iex "& { $(irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1) } -InstallDir 'C:\tools'"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unix/Linux/macOS Advanced Options
|
||||||
|
|
||||||
**Force install without prompts:**
|
**Force install without prompts:**
|
||||||
```bash
|
```bash
|
||||||
FORCE_INSTALL=true bash -c "$(curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh)"
|
FORCE_INSTALL=true bash -c "$(curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh)"
|
||||||
|
|||||||
@@ -74,10 +74,10 @@ downloadBinary() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Find the extracted binary
|
# Check if the expected binary exists (no find needed)
|
||||||
EXTRACTED_BINARY=$(find . -name "sshm-${OS}-${ARCH}" -type f)
|
EXTRACTED_BINARY="./sshm-${OS}-${ARCH}"
|
||||||
if [ -z "$EXTRACTED_BINARY" ]; then
|
if [ ! -f "$EXTRACTED_BINARY" ]; then
|
||||||
printf "${RED}Could not find extracted binary${NC}\n"
|
printf "${RED}Could not find extracted binary: $EXTRACTED_BINARY${NC}\n"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -88,17 +88,37 @@ downloadBinary() {
|
|||||||
install() {
|
install() {
|
||||||
printf "${YELLOW}Installing SSHM...${NC}\n"
|
printf "${YELLOW}Installing SSHM...${NC}\n"
|
||||||
|
|
||||||
|
# Backup old version if it exists to prevent interference during installation
|
||||||
|
OLD_BACKUP=""
|
||||||
|
if [ -f "$EXECUTABLE_PATH" ]; then
|
||||||
|
OLD_BACKUP="$EXECUTABLE_PATH.backup.$$"
|
||||||
|
runAsRoot mv "$EXECUTABLE_PATH" "$OLD_BACKUP"
|
||||||
|
fi
|
||||||
|
|
||||||
chmod +x "sshm-tmp"
|
chmod +x "sshm-tmp"
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
printf "${RED}Failed to set permissions${NC}\n"
|
printf "${RED}Failed to set permissions${NC}\n"
|
||||||
|
# Restore backup if installation fails
|
||||||
|
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
|
||||||
|
runAsRoot mv "$OLD_BACKUP" "$EXECUTABLE_PATH"
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
runAsRoot mv "sshm-tmp" "$EXECUTABLE_PATH"
|
runAsRoot mv "sshm-tmp" "$EXECUTABLE_PATH"
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
printf "${RED}Failed to install binary${NC}\n"
|
printf "${RED}Failed to install binary${NC}\n"
|
||||||
|
# Restore backup if installation fails
|
||||||
|
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
|
||||||
|
runAsRoot mv "$OLD_BACKUP" "$EXECUTABLE_PATH"
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Clean up backup if installation succeeded
|
||||||
|
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
|
||||||
|
runAsRoot rm -f "$OLD_BACKUP"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
@@ -161,7 +181,8 @@ main() {
|
|||||||
# Show version
|
# Show version
|
||||||
printf "${YELLOW}Verifying installation...${NC}\n"
|
printf "${YELLOW}Verifying installation...${NC}\n"
|
||||||
if command -v sshm >/dev/null 2>&1; then
|
if command -v sshm >/dev/null 2>&1; then
|
||||||
sshm --version
|
# Use the full path to ensure we're using the newly installed version
|
||||||
|
"$EXECUTABLE_PATH" --version 2>/dev/null || echo "Version check failed, but installation completed"
|
||||||
else
|
else
|
||||||
printf "${RED}Warning: 'sshm' command not found in PATH. You may need to restart your terminal or add $INSTALL_DIR to your PATH.${NC}\n"
|
printf "${RED}Warning: 'sshm' command not found in PATH. You may need to restart your terminal or add $INSTALL_DIR to your PATH.${NC}\n"
|
||||||
fi
|
fi
|
||||||
|
|||||||
135
install/windows.ps1
Normal file
135
install/windows.ps1
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# SSHM Windows Installation Script
|
||||||
|
# Usage:
|
||||||
|
# Online: irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex
|
||||||
|
# Local: .\install\windows.ps1 -LocalBinary ".\sshm.exe"
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$InstallDir = "$env:LOCALAPPDATA\sshm",
|
||||||
|
[switch]$Force = $false,
|
||||||
|
[string]$LocalBinary = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
function Write-ColorOutput($ForegroundColor) {
|
||||||
|
$fc = $host.UI.RawUI.ForegroundColor
|
||||||
|
$host.UI.RawUI.ForegroundColor = $ForegroundColor
|
||||||
|
if ($args) {
|
||||||
|
Write-Output $args
|
||||||
|
}
|
||||||
|
$host.UI.RawUI.ForegroundColor = $fc
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Info { Write-ColorOutput Green $args }
|
||||||
|
function Write-Warning { Write-ColorOutput Yellow $args }
|
||||||
|
function Write-Error { Write-ColorOutput Red $args }
|
||||||
|
|
||||||
|
Write-Info "🚀 Installing SSHM - SSH Manager"
|
||||||
|
Write-Info ""
|
||||||
|
|
||||||
|
# Check if SSHM is already installed
|
||||||
|
$existingSSHM = Get-Command sshm -ErrorAction SilentlyContinue
|
||||||
|
if ($existingSSHM -and -not $Force) {
|
||||||
|
$currentVersion = & sshm --version 2>$null | Select-String "version" | ForEach-Object { $_.ToString().Split()[-1] }
|
||||||
|
Write-Warning "SSHM is already installed (version: $currentVersion)"
|
||||||
|
$response = Read-Host "Do you want to continue with the installation? (y/N)"
|
||||||
|
if ($response -ne "y" -and $response -ne "Y") {
|
||||||
|
Write-Info "Installation cancelled."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect architecture
|
||||||
|
$arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "386" }
|
||||||
|
Write-Info "Detected platform: Windows ($arch)"
|
||||||
|
|
||||||
|
# Check if using local binary
|
||||||
|
if ($LocalBinary -ne "") {
|
||||||
|
if (-not (Test-Path $LocalBinary)) {
|
||||||
|
Write-Error "Local binary not found: $LocalBinary"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Info "Using local binary: $LocalBinary"
|
||||||
|
$targetPath = "$InstallDir\sshm.exe"
|
||||||
|
|
||||||
|
# Create installation directory
|
||||||
|
if (-not (Test-Path $InstallDir)) {
|
||||||
|
Write-Info "Creating installation directory: $InstallDir"
|
||||||
|
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Copy local binary
|
||||||
|
Write-Info "Installing binary to: $targetPath"
|
||||||
|
Copy-Item -Path $LocalBinary -Destination $targetPath -Force
|
||||||
|
|
||||||
|
} else {
|
||||||
|
# Online installation
|
||||||
|
Write-Info "Starting online installation..."
|
||||||
|
|
||||||
|
# Get latest version
|
||||||
|
Write-Info "Fetching latest version..."
|
||||||
|
try {
|
||||||
|
$latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest"
|
||||||
|
$latestVersion = $latestRelease.tag_name
|
||||||
|
Write-Info "Target version: $latestVersion"
|
||||||
|
} catch {
|
||||||
|
Write-Error "Failed to fetch version information"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Download binary
|
||||||
|
$fileName = "sshm-windows-$arch.zip"
|
||||||
|
$downloadUrl = "https://github.com/Gu1llaum-3/sshm/releases/download/$latestVersion/$fileName"
|
||||||
|
$tempFile = "$env:TEMP\$fileName"
|
||||||
|
|
||||||
|
Write-Info "Downloading $fileName..."
|
||||||
|
try {
|
||||||
|
Invoke-WebRequest -Uri $downloadUrl -OutFile $tempFile
|
||||||
|
} catch {
|
||||||
|
Write-Error "Download failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create installation directory
|
||||||
|
if (-not (Test-Path $InstallDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract archive
|
||||||
|
Write-Info "Extracting..."
|
||||||
|
try {
|
||||||
|
Expand-Archive -Path $tempFile -DestinationPath $env:TEMP -Force
|
||||||
|
$extractedBinary = "$env:TEMP\sshm-windows-$arch.exe"
|
||||||
|
$targetPath = "$InstallDir\sshm.exe"
|
||||||
|
|
||||||
|
Move-Item -Path $extractedBinary -Destination $targetPath -Force
|
||||||
|
} catch {
|
||||||
|
Write-Error "Extraction failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
Remove-Item $tempFile -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check PATH
|
||||||
|
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
||||||
|
if ($userPath -notlike "*$InstallDir*") {
|
||||||
|
Write-Warning "The directory $InstallDir is not in your PATH."
|
||||||
|
Write-Info "Adding to user PATH..."
|
||||||
|
[Environment]::SetEnvironmentVariable("Path", "$userPath;$InstallDir", "User")
|
||||||
|
Write-Info "Please restart your terminal to use the 'sshm' command."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Info ""
|
||||||
|
Write-Info "✅ SSHM successfully installed to: $targetPath"
|
||||||
|
Write-Info "You can now use the 'sshm' command!"
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
if (Test-Path $targetPath) {
|
||||||
|
Write-Info ""
|
||||||
|
Write-Info "Verifying installation..."
|
||||||
|
& $targetPath --version
|
||||||
|
}
|
||||||
11
internal/config/permissions_unix.go
Normal file
11
internal/config/permissions_unix.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// SetSecureFilePermissions configures secure permissions on Unix systems
|
||||||
|
func SetSecureFilePermissions(filepath string) error {
|
||||||
|
// Set file permissions to 0600 (owner read/write only)
|
||||||
|
return os.Chmod(filepath, 0600)
|
||||||
|
}
|
||||||
24
internal/config/permissions_windows.go
Normal file
24
internal/config/permissions_windows.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetSecureFilePermissions configures secure permissions on Windows
|
||||||
|
func SetSecureFilePermissions(filepath string) error {
|
||||||
|
// On Windows, file permissions work differently
|
||||||
|
// We ensure the file is not read-only and has basic permissions
|
||||||
|
info, err := os.Stat(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the file is not read-only
|
||||||
|
if info.Mode()&os.ModeType == 0 {
|
||||||
|
return os.Chmod(filepath, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -22,6 +23,46 @@ type SSHHost struct {
|
|||||||
Tags []string
|
Tags []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDefaultSSHConfigPath returns the default SSH config path for the current platform
|
||||||
|
func GetDefaultSSHConfigPath() (string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
return filepath.Join(homeDir, ".ssh", "config"), nil
|
||||||
|
default:
|
||||||
|
// Linux, macOS, etc.
|
||||||
|
return filepath.Join(homeDir, ".ssh", "config"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSSHDirectory returns the .ssh directory path
|
||||||
|
func GetSSHDirectory() (string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(homeDir, ".ssh"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSSHDirectory creates the .ssh directory with appropriate permissions
|
||||||
|
func ensureSSHDirectory() error {
|
||||||
|
sshDir, err := GetSSHDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(sshDir); os.IsNotExist(err) {
|
||||||
|
// 0700 provides owner-only access across platforms
|
||||||
|
return os.MkdirAll(sshDir, 0700)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|
||||||
@@ -46,32 +87,54 @@ func backupConfig(configPath string) error {
|
|||||||
|
|
||||||
// ParseSSHConfig parses the SSH config file and returns the list of hosts
|
// ParseSSHConfig parses the SSH config file and returns the list of hosts
|
||||||
func ParseSSHConfig() ([]SSHHost, error) {
|
func ParseSSHConfig() ([]SSHHost, error) {
|
||||||
homeDir, err := os.UserHomeDir()
|
configPath, err := GetDefaultSSHConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
|
||||||
return ParseSSHConfigFile(configPath)
|
return ParseSSHConfigFile(configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseSSHConfigFile parses a specific SSH config file and returns the list of hosts
|
// ParseSSHConfigFile parses a specific SSH config file and returns the list of hosts
|
||||||
func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
||||||
|
return parseSSHConfigFileWithProcessedFiles(configPath, make(map[string]bool))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSSHConfigFileWithProcessedFiles parses SSH config with include support
|
||||||
|
func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[string]bool) ([]SSHHost, error) {
|
||||||
|
// Resolve absolute path to prevent infinite recursion
|
||||||
|
absPath, err := filepath.Abs(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve absolute path for %s: %w", configPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for circular includes
|
||||||
|
if processedFiles[absPath] {
|
||||||
|
return []SSHHost{}, nil // Skip already processed files silently
|
||||||
|
}
|
||||||
|
processedFiles[absPath] = true
|
||||||
|
|
||||||
// Check if the file exists, otherwise create it (and the parent directory if needed)
|
// Check if the file exists, otherwise create it (and the parent directory if needed)
|
||||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
dir := filepath.Dir(configPath)
|
// Only create the main config file, not included files
|
||||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
if absPath == getMainConfigPath() {
|
||||||
err = os.MkdirAll(dir, 0700)
|
// Ensure .ssh directory exists with proper permissions
|
||||||
if err != nil {
|
if err := ensureSSHDirectory(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create .ssh directory: %w", err)
|
return nil, fmt.Errorf("failed to create .ssh directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
file, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create SSH config file: %w", err)
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
// Set secure permissions on the config file
|
||||||
|
if err := SetSecureFilePermissions(configPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set secure permissions: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
file, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY, 0600)
|
|
||||||
if err != nil {
|
// File doesn't exist, return empty host list
|
||||||
return nil, fmt.Errorf("failed to create SSH config file: %w", err)
|
|
||||||
}
|
|
||||||
file.Close()
|
|
||||||
// File created, return empty host list
|
|
||||||
return []SSHHost{}, nil
|
return []SSHHost{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,11 +188,25 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
|||||||
value := strings.Join(parts[1:], " ")
|
value := strings.Join(parts[1:], " ")
|
||||||
|
|
||||||
switch key {
|
switch key {
|
||||||
|
case "include":
|
||||||
|
// Handle Include directive
|
||||||
|
includeHosts, err := processIncludeDirective(value, configPath, processedFiles)
|
||||||
|
if err != nil {
|
||||||
|
// Don't fail the entire parse if include fails, just skip it
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hosts = append(hosts, includeHosts...)
|
||||||
case "host":
|
case "host":
|
||||||
// New host, save previous one if it exists
|
// New host, save previous one if it exists
|
||||||
if currentHost != nil {
|
if currentHost != nil {
|
||||||
hosts = append(hosts, *currentHost)
|
hosts = append(hosts, *currentHost)
|
||||||
}
|
}
|
||||||
|
// Skip hosts with wildcards (*, ?) as they are typically patterns, not actual hosts
|
||||||
|
if strings.ContainsAny(value, "*?") {
|
||||||
|
currentHost = nil
|
||||||
|
pendingTags = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Create new host
|
// Create new host
|
||||||
currentHost = &SSHHost{
|
currentHost = &SSHHost{
|
||||||
Name: value,
|
Name: value,
|
||||||
@@ -179,13 +256,61 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
|||||||
return hosts, scanner.Err()
|
return hosts, scanner.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// processIncludeDirective processes an Include directive and returns hosts from included files
|
||||||
|
func processIncludeDirective(pattern string, baseConfigPath string, processedFiles map[string]bool) ([]SSHHost, error) {
|
||||||
|
// Expand tilde to home directory
|
||||||
|
if strings.HasPrefix(pattern, "~") {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||||
|
}
|
||||||
|
pattern = filepath.Join(homeDir, pattern[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pattern is not absolute, make it relative to the base config directory
|
||||||
|
if !filepath.IsAbs(pattern) {
|
||||||
|
baseDir := filepath.Dir(baseConfigPath)
|
||||||
|
pattern = filepath.Join(baseDir, pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use glob to find matching files
|
||||||
|
matches, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to glob pattern %s: %w", pattern, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var allHosts []SSHHost
|
||||||
|
for _, match := range matches {
|
||||||
|
// Skip directories
|
||||||
|
if info, err := os.Stat(match); err == nil && info.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively parse the included file
|
||||||
|
hosts, err := parseSSHConfigFileWithProcessedFiles(match, processedFiles)
|
||||||
|
if err != nil {
|
||||||
|
// Skip files that can't be parsed rather than failing completely
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allHosts = append(allHosts, hosts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return allHosts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMainConfigPath returns the main SSH config path for comparison
|
||||||
|
func getMainConfigPath() string {
|
||||||
|
configPath, _ := GetDefaultSSHConfigPath()
|
||||||
|
absPath, _ := filepath.Abs(configPath)
|
||||||
|
return absPath
|
||||||
|
}
|
||||||
|
|
||||||
// AddSSHHost adds a new SSH host to the config file
|
// AddSSHHost adds a new SSH host to the config file
|
||||||
func AddSSHHost(host SSHHost) error {
|
func AddSSHHost(host SSHHost) error {
|
||||||
homeDir, err := os.UserHomeDir()
|
configPath, err := GetDefaultSSHConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
|
||||||
return AddSSHHostToFile(host, configPath)
|
return AddSSHHostToFile(host, configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,11 +529,10 @@ 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 {
|
||||||
homeDir, err := os.UserHomeDir()
|
configPath, err := GetDefaultSSHConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
|
||||||
return UpdateSSHHostInFile(oldName, newHost, configPath)
|
return UpdateSSHHostInFile(oldName, newHost, configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,11 +688,10 @@ 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 {
|
||||||
homeDir, err := os.UserHomeDir()
|
configPath, err := GetDefaultSSHConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
|
||||||
return DeleteSSHHostFromFile(hostName, configPath)
|
return DeleteSSHHostFromFile(hostName, configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
367
internal/config/ssh_test.go
Normal file
367
internal/config/ssh_test.go
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetDefaultSSHConfigPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
goos string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"Linux", "linux", ".ssh/config"},
|
||||||
|
{"macOS", "darwin", ".ssh/config"},
|
||||||
|
{"Windows", "windows", ".ssh/config"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Save original GOOS
|
||||||
|
originalGOOS := runtime.GOOS
|
||||||
|
defer func() {
|
||||||
|
// Note: We can't actually change runtime.GOOS at runtime
|
||||||
|
// This test verifies the function logic with the current OS
|
||||||
|
_ = originalGOOS
|
||||||
|
}()
|
||||||
|
|
||||||
|
configPath, err := GetDefaultSSHConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDefaultSSHConfigPath() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(configPath, tt.expected) {
|
||||||
|
t.Errorf("Expected path to end with %q, got %q", tt.expected, configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the path uses the correct separator for current OS
|
||||||
|
expectedSeparator := string(filepath.Separator)
|
||||||
|
if !strings.Contains(configPath, expectedSeparator) && len(configPath) > len(tt.expected) {
|
||||||
|
t.Errorf("Path should use OS-specific separator %q, got %q", expectedSeparator, configPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSSHDirectory(t *testing.T) {
|
||||||
|
sshDir, err := GetSSHDirectory()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSSHDirectory() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(sshDir, ".ssh") {
|
||||||
|
t.Errorf("Expected directory to end with .ssh, got %q", sshDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the path uses the correct separator for current OS
|
||||||
|
expectedSeparator := string(filepath.Separator)
|
||||||
|
if !strings.Contains(sshDir, expectedSeparator) && len(sshDir) > 4 {
|
||||||
|
t.Errorf("Path should use OS-specific separator %q, got %q", expectedSeparator, sshDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureSSHDirectory(t *testing.T) {
|
||||||
|
// This test just ensures the function doesn't panic
|
||||||
|
// and returns without error when .ssh directory already exists
|
||||||
|
err := ensureSSHDirectory()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ensureSSHDirectory() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSSHConfigWithInclude(t *testing.T) {
|
||||||
|
// Create temporary directory for test files
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create main config file
|
||||||
|
mainConfig := filepath.Join(tempDir, "config")
|
||||||
|
mainConfigContent := `Host main-host
|
||||||
|
HostName example.com
|
||||||
|
User mainuser
|
||||||
|
|
||||||
|
Include included.conf
|
||||||
|
Include subdir/*
|
||||||
|
|
||||||
|
Host another-host
|
||||||
|
HostName another.example.com
|
||||||
|
User anotheruser
|
||||||
|
`
|
||||||
|
|
||||||
|
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create main config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create included file
|
||||||
|
includedConfig := filepath.Join(tempDir, "included.conf")
|
||||||
|
includedConfigContent := `Host included-host
|
||||||
|
HostName included.example.com
|
||||||
|
User includeduser
|
||||||
|
Port 2222
|
||||||
|
`
|
||||||
|
|
||||||
|
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create included config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create subdirectory with another config file
|
||||||
|
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")
|
||||||
|
subConfigContent := `Host sub-host
|
||||||
|
HostName sub.example.com
|
||||||
|
User subuser
|
||||||
|
IdentityFile ~/.ssh/sub_key
|
||||||
|
`
|
||||||
|
|
||||||
|
err = os.WriteFile(subConfig, []byte(subConfigContent), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create sub config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the main config file
|
||||||
|
hosts, err := ParseSSHConfigFile(mainConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that we got all expected hosts
|
||||||
|
expectedHosts := map[string]struct{}{
|
||||||
|
"main-host": {},
|
||||||
|
"included-host": {},
|
||||||
|
"sub-host": {},
|
||||||
|
"another-host": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hosts) != len(expectedHosts) {
|
||||||
|
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
if _, exists := expectedHosts[host.Name]; !exists {
|
||||||
|
t.Errorf("Unexpected host found: %s", host.Name)
|
||||||
|
}
|
||||||
|
delete(expectedHosts, host.Name)
|
||||||
|
|
||||||
|
// Validate specific host properties
|
||||||
|
switch host.Name {
|
||||||
|
case "main-host":
|
||||||
|
if host.Hostname != "example.com" || host.User != "mainuser" {
|
||||||
|
t.Errorf("main-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||||
|
}
|
||||||
|
case "included-host":
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
case "sub-host":
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
case "another-host":
|
||||||
|
if host.Hostname != "another.example.com" || host.User != "anotheruser" {
|
||||||
|
t.Errorf("another-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that all expected hosts were found
|
||||||
|
if len(expectedHosts) > 0 {
|
||||||
|
var missing []string
|
||||||
|
for host := range expectedHosts {
|
||||||
|
missing = append(missing, host)
|
||||||
|
}
|
||||||
|
t.Errorf("Missing hosts: %v", missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSSHConfigWithCircularInclude(t *testing.T) {
|
||||||
|
// Create temporary directory for test files
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create config1 that includes config2
|
||||||
|
config1 := filepath.Join(tempDir, "config1")
|
||||||
|
config1Content := `Host host1
|
||||||
|
HostName example1.com
|
||||||
|
|
||||||
|
Include config2
|
||||||
|
`
|
||||||
|
|
||||||
|
err := os.WriteFile(config1, []byte(config1Content), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create config1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config2 that includes config1 (circular)
|
||||||
|
config2 := filepath.Join(tempDir, "config2")
|
||||||
|
config2Content := `Host host2
|
||||||
|
HostName example2.com
|
||||||
|
|
||||||
|
Include config1
|
||||||
|
`
|
||||||
|
|
||||||
|
err = os.WriteFile(config2, []byte(config2Content), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create config2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the config file - should not cause infinite recursion
|
||||||
|
hosts, err := ParseSSHConfigFile(config1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should get both hosts exactly once
|
||||||
|
expectedHosts := map[string]bool{
|
||||||
|
"host1": false,
|
||||||
|
"host2": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
if _, exists := expectedHosts[host.Name]; !exists {
|
||||||
|
t.Errorf("Unexpected host found: %s", host.Name)
|
||||||
|
} else {
|
||||||
|
if expectedHosts[host.Name] {
|
||||||
|
t.Errorf("Host %s found multiple times", host.Name)
|
||||||
|
}
|
||||||
|
expectedHosts[host.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all hosts were found
|
||||||
|
for hostName, found := range expectedHosts {
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Host %s not found", hostName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSSHConfigWithNonExistentInclude(t *testing.T) {
|
||||||
|
// Create temporary directory for test files
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create main config file with non-existent include
|
||||||
|
mainConfig := filepath.Join(tempDir, "config")
|
||||||
|
mainConfigContent := `Host main-host
|
||||||
|
HostName example.com
|
||||||
|
|
||||||
|
Include non-existent-file.conf
|
||||||
|
|
||||||
|
Host another-host
|
||||||
|
HostName another.example.com
|
||||||
|
`
|
||||||
|
|
||||||
|
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create main config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse should succeed and ignore the non-existent include
|
||||||
|
hosts, err := ParseSSHConfigFile(mainConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should get the hosts that exist, ignoring the failed include
|
||||||
|
if len(hosts) != 2 {
|
||||||
|
t.Errorf("Expected 2 hosts, got %d", len(hosts))
|
||||||
|
}
|
||||||
|
|
||||||
|
hostNames := make(map[string]bool)
|
||||||
|
for _, host := range hosts {
|
||||||
|
hostNames[host.Name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hostNames["main-host"] || !hostNames["another-host"] {
|
||||||
|
t.Errorf("Expected main-host and another-host, got: %v", hostNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSSHConfigWithWildcardHosts(t *testing.T) {
|
||||||
|
// Create temporary directory for test files
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create config file with wildcard hosts
|
||||||
|
configFile := filepath.Join(tempDir, "config")
|
||||||
|
configContent := `# Wildcard patterns should be ignored
|
||||||
|
Host *.example.com
|
||||||
|
User defaultuser
|
||||||
|
IdentityFile ~/.ssh/id_rsa
|
||||||
|
|
||||||
|
Host server-*
|
||||||
|
Port 2222
|
||||||
|
|
||||||
|
Host *
|
||||||
|
ServerAliveInterval 60
|
||||||
|
|
||||||
|
# Real hosts should be included
|
||||||
|
Host real-server
|
||||||
|
HostName real.example.com
|
||||||
|
User realuser
|
||||||
|
|
||||||
|
Host another-real-server
|
||||||
|
HostName another.example.com
|
||||||
|
User anotheruser
|
||||||
|
`
|
||||||
|
|
||||||
|
err := os.WriteFile(configFile, []byte(configContent), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the config file
|
||||||
|
hosts, err := ParseSSHConfigFile(configFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should only get real hosts, not wildcard patterns
|
||||||
|
expectedHosts := map[string]bool{
|
||||||
|
"real-server": false,
|
||||||
|
"another-real-server": 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", host.Name)
|
||||||
|
} else {
|
||||||
|
expectedHosts[host.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that all expected hosts were found
|
||||||
|
for hostName, found := range expectedHosts {
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Expected host %s not found", hostName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify host properties
|
||||||
|
for _, host := range hosts {
|
||||||
|
switch host.Name {
|
||||||
|
case "real-server":
|
||||||
|
if host.Hostname != "real.example.com" || host.User != "realuser" {
|
||||||
|
t.Errorf("real-server properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||||
|
}
|
||||||
|
case "another-real-server":
|
||||||
|
if host.Hostname != "another.example.com" || host.User != "anotheruser" {
|
||||||
|
t.Errorf("another-real-server properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
internal/ui/help_form.go
Normal file
109
internal/ui/help_form.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type helpModel struct {
|
||||||
|
styles Styles
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpCloseMsg is sent when the help window is closed
|
||||||
|
type helpCloseMsg struct{}
|
||||||
|
|
||||||
|
// NewHelpForm creates a new help form model
|
||||||
|
func NewHelpForm(styles Styles, width, height int) *helpModel {
|
||||||
|
return &helpModel{
|
||||||
|
styles: styles,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *helpModel) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *helpModel) Update(msg tea.Msg) (*helpModel, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "q", "h", "enter", "ctrl+c":
|
||||||
|
return m, func() tea.Msg { return helpCloseMsg{} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *helpModel) View() string {
|
||||||
|
// Title
|
||||||
|
title := m.styles.Header.Render("📖 SSHM - Help & Commands")
|
||||||
|
|
||||||
|
// Create horizontal sections with compact layout
|
||||||
|
line1 := lipgloss.JoinHorizontal(lipgloss.Center,
|
||||||
|
m.styles.FocusedLabel.Render("🧭 ↑/↓/j/k"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("navigate"),
|
||||||
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("⏎"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("connect"),
|
||||||
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("a/e/d"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("add/edit/delete"),
|
||||||
|
)
|
||||||
|
|
||||||
|
line2 := lipgloss.JoinHorizontal(lipgloss.Center,
|
||||||
|
m.styles.FocusedLabel.Render("Tab"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("switch focus"),
|
||||||
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("f"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("port forward"),
|
||||||
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("s/r/n"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("sort modes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
line3 := lipgloss.JoinHorizontal(lipgloss.Center,
|
||||||
|
m.styles.FocusedLabel.Render("/"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("search"),
|
||||||
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("h"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("help"),
|
||||||
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("q/ESC"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("quit"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create the main content
|
||||||
|
content := lipgloss.JoinVertical(lipgloss.Center,
|
||||||
|
title,
|
||||||
|
"",
|
||||||
|
line1,
|
||||||
|
"",
|
||||||
|
line2,
|
||||||
|
"",
|
||||||
|
line3,
|
||||||
|
"",
|
||||||
|
m.styles.HelpText.Render("Press ESC, h, q or Enter to close"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Center the help window
|
||||||
|
return lipgloss.Place(
|
||||||
|
m.width,
|
||||||
|
m.height,
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
m.styles.FormContainer.Render(content),
|
||||||
|
)
|
||||||
|
}
|
||||||
227
internal/ui/info_form.go
Normal file
227
internal/ui/info_form.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sshm/internal/config"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type infoFormModel struct {
|
||||||
|
host *config.SSHHost
|
||||||
|
styles Styles
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
configFile string
|
||||||
|
hostName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages for communication with parent model
|
||||||
|
type infoFormEditMsg struct {
|
||||||
|
hostName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type infoFormCancelMsg struct{}
|
||||||
|
|
||||||
|
// NewInfoForm creates a new info form model for displaying host details in read-only mode
|
||||||
|
func NewInfoForm(hostName string, styles Styles, width, height int, configFile string) (*infoFormModel, error) {
|
||||||
|
// Get the existing host configuration
|
||||||
|
var host *config.SSHHost
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if configFile != "" {
|
||||||
|
host, err = config.GetSSHHostFromFile(hostName, configFile)
|
||||||
|
} else {
|
||||||
|
host, err = config.GetSSHHost(hostName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &infoFormModel{
|
||||||
|
host: host,
|
||||||
|
hostName: hostName,
|
||||||
|
configFile: configFile,
|
||||||
|
styles: styles,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *infoFormModel) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *infoFormModel) Update(msg tea.Msg) (*infoFormModel, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
m.styles = NewStyles(m.width)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "ctrl+c", "esc", "q":
|
||||||
|
return m, func() tea.Msg { return infoFormCancelMsg{} }
|
||||||
|
|
||||||
|
case "e", "enter":
|
||||||
|
// Switch to edit mode
|
||||||
|
return m, func() tea.Msg { return infoFormEditMsg{hostName: m.hostName} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *infoFormModel) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// Title
|
||||||
|
title := fmt.Sprintf("SSH Host Information: %s", m.host.Name)
|
||||||
|
b.WriteString(m.styles.FormTitle.Render(title))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Create info sections with consistent formatting
|
||||||
|
sections := []struct {
|
||||||
|
label string
|
||||||
|
value string
|
||||||
|
}{
|
||||||
|
{"Host Name", m.host.Name},
|
||||||
|
{"Hostname/IP", m.host.Hostname},
|
||||||
|
{"User", formatOptionalValue(m.host.User)},
|
||||||
|
{"Port", formatOptionalValue(m.host.Port)},
|
||||||
|
{"Identity File", formatOptionalValue(m.host.Identity)},
|
||||||
|
{"ProxyJump", formatOptionalValue(m.host.ProxyJump)},
|
||||||
|
{"SSH Options", formatSSHOptions(m.host.Options)},
|
||||||
|
{"Tags", formatTags(m.host.Tags)},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render each section
|
||||||
|
for _, section := range sections {
|
||||||
|
// Label style
|
||||||
|
labelStyle := lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("39")). // Bright blue
|
||||||
|
Width(15).
|
||||||
|
AlignHorizontal(lipgloss.Right)
|
||||||
|
|
||||||
|
// Value style
|
||||||
|
valueStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("255")) // White
|
||||||
|
|
||||||
|
// If value is empty or default, use a muted style
|
||||||
|
if section.value == "Not set" || section.value == "22" && section.label == "Port" {
|
||||||
|
valueStyle = valueStyle.Foreground(lipgloss.Color("243")) // Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
line := lipgloss.JoinHorizontal(
|
||||||
|
lipgloss.Top,
|
||||||
|
labelStyle.Render(section.label+":"),
|
||||||
|
" ",
|
||||||
|
valueStyle.Render(section.value),
|
||||||
|
)
|
||||||
|
b.WriteString(line)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Action instructions
|
||||||
|
helpStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("243")).
|
||||||
|
Italic(true)
|
||||||
|
|
||||||
|
b.WriteString(helpStyle.Render("Actions:"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
actionStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("120")). // Green
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(actionStyle.Render("e/Enter"))
|
||||||
|
b.WriteString(helpStyle.Render(" - Switch to edit mode"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(actionStyle.Render("q/Esc"))
|
||||||
|
b.WriteString(helpStyle.Render(" - Return to host list"))
|
||||||
|
|
||||||
|
// Wrap in a border for better visual separation
|
||||||
|
content := b.String()
|
||||||
|
|
||||||
|
borderStyle := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("39")).
|
||||||
|
Padding(1).
|
||||||
|
Margin(1)
|
||||||
|
|
||||||
|
// Center the info window
|
||||||
|
return lipgloss.Place(
|
||||||
|
m.width,
|
||||||
|
m.height,
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
borderStyle.Render(content),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for formatting values
|
||||||
|
|
||||||
|
func formatOptionalValue(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return "Not set"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSSHOptions(options string) string {
|
||||||
|
if options == "" {
|
||||||
|
return "Not set"
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTags(tags []string) string {
|
||||||
|
if len(tags) == 0 {
|
||||||
|
return "Not set"
|
||||||
|
}
|
||||||
|
return strings.Join(tags, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone wrapper for info form (for testing or standalone use)
|
||||||
|
type standaloneInfoForm struct {
|
||||||
|
*infoFormModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m standaloneInfoForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.(type) {
|
||||||
|
case infoFormCancelMsg:
|
||||||
|
return m, tea.Quit
|
||||||
|
case infoFormEditMsg:
|
||||||
|
// For standalone mode, just quit - parent should handle edit transition
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
|
||||||
|
newForm, cmd := m.infoFormModel.Update(msg)
|
||||||
|
m.infoFormModel = newForm
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunInfoForm provides a standalone info form for testing
|
||||||
|
func RunInfoForm(hostName string, configFile string) error {
|
||||||
|
styles := NewStyles(80)
|
||||||
|
infoForm, err := NewInfoForm(hostName, styles, 80, 24, configFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m := standaloneInfoForm{infoForm}
|
||||||
|
|
||||||
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
|
_, err = p.Run()
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -35,8 +35,33 @@ const (
|
|||||||
ViewList ViewMode = iota
|
ViewList ViewMode = iota
|
||||||
ViewAdd
|
ViewAdd
|
||||||
ViewEdit
|
ViewEdit
|
||||||
|
ViewInfo
|
||||||
|
ViewPortForward
|
||||||
|
ViewHelp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PortForwardType defines the type of port forwarding
|
||||||
|
type PortForwardType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
LocalForward PortForwardType = iota
|
||||||
|
RemoteForward
|
||||||
|
DynamicForward
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p PortForwardType) String() string {
|
||||||
|
switch p {
|
||||||
|
case LocalForward:
|
||||||
|
return "Local (-L)"
|
||||||
|
case RemoteForward:
|
||||||
|
return "Remote (-R)"
|
||||||
|
case DynamicForward:
|
||||||
|
return "Dynamic (-D)"
|
||||||
|
default:
|
||||||
|
return "Local (-L)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Model represents the state of the user interface
|
// Model represents the state of the user interface
|
||||||
type Model struct {
|
type Model struct {
|
||||||
table table.Model
|
table table.Model
|
||||||
@@ -51,9 +76,12 @@ type Model struct {
|
|||||||
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
|
||||||
|
portForwardForm *portForwardModel
|
||||||
|
helpForm *helpModel
|
||||||
|
|
||||||
// Terminal size and styles
|
// Terminal size and styles
|
||||||
width int
|
width int
|
||||||
|
|||||||
490
internal/ui/port_forward_form.go
Normal file
490
internal/ui/port_forward_form.go
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Input field indices for port forward form
|
||||||
|
const (
|
||||||
|
pfTypeInput = iota
|
||||||
|
pfLocalPortInput
|
||||||
|
pfRemoteHostInput
|
||||||
|
pfRemotePortInput
|
||||||
|
pfBindAddressInput
|
||||||
|
)
|
||||||
|
|
||||||
|
type portForwardModel struct {
|
||||||
|
inputs []textinput.Model
|
||||||
|
focused int
|
||||||
|
forwardType PortForwardType
|
||||||
|
hostName string
|
||||||
|
err string
|
||||||
|
styles Styles
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
configFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
// portForwardSubmitMsg is sent when the port forward form is submitted
|
||||||
|
type portForwardSubmitMsg struct {
|
||||||
|
err error
|
||||||
|
sshArgs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// portForwardCancelMsg is sent when the port forward form is cancelled
|
||||||
|
type portForwardCancelMsg struct{}
|
||||||
|
|
||||||
|
// NewPortForwardForm creates a new port forward form model
|
||||||
|
func NewPortForwardForm(hostName string, styles Styles, width, height int, configFile string) *portForwardModel {
|
||||||
|
inputs := make([]textinput.Model, 5)
|
||||||
|
|
||||||
|
// Forward type input (display only, controlled by arrow keys)
|
||||||
|
inputs[pfTypeInput] = textinput.New()
|
||||||
|
inputs[pfTypeInput].Placeholder = "Use ←/→ to change forward type"
|
||||||
|
inputs[pfTypeInput].Focus()
|
||||||
|
inputs[pfTypeInput].Width = 40
|
||||||
|
inputs[pfTypeInput].SetValue("Local (-L)")
|
||||||
|
|
||||||
|
// Local port input
|
||||||
|
inputs[pfLocalPortInput] = textinput.New()
|
||||||
|
inputs[pfLocalPortInput].Placeholder = "8080"
|
||||||
|
inputs[pfLocalPortInput].CharLimit = 5
|
||||||
|
inputs[pfLocalPortInput].Width = 20
|
||||||
|
|
||||||
|
// Remote host input
|
||||||
|
inputs[pfRemoteHostInput] = textinput.New()
|
||||||
|
inputs[pfRemoteHostInput].Placeholder = "localhost"
|
||||||
|
inputs[pfRemoteHostInput].CharLimit = 100
|
||||||
|
inputs[pfRemoteHostInput].Width = 30
|
||||||
|
inputs[pfRemoteHostInput].SetValue("localhost")
|
||||||
|
|
||||||
|
// Remote port input
|
||||||
|
inputs[pfRemotePortInput] = textinput.New()
|
||||||
|
inputs[pfRemotePortInput].Placeholder = "80"
|
||||||
|
inputs[pfRemotePortInput].CharLimit = 5
|
||||||
|
inputs[pfRemotePortInput].Width = 20
|
||||||
|
|
||||||
|
// Bind address input (optional)
|
||||||
|
inputs[pfBindAddressInput] = textinput.New()
|
||||||
|
inputs[pfBindAddressInput].Placeholder = "127.0.0.1 (optional)"
|
||||||
|
inputs[pfBindAddressInput].CharLimit = 50
|
||||||
|
inputs[pfBindAddressInput].Width = 30
|
||||||
|
|
||||||
|
pf := &portForwardModel{
|
||||||
|
inputs: inputs,
|
||||||
|
focused: 0,
|
||||||
|
forwardType: LocalForward,
|
||||||
|
hostName: hostName,
|
||||||
|
styles: styles,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
configFile: configFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize input visibility
|
||||||
|
pf.updateInputVisibility()
|
||||||
|
|
||||||
|
return pf
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *portForwardModel) Init() tea.Cmd {
|
||||||
|
return textinput.Blink
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *portForwardModel) Update(msg tea.Msg) (*portForwardModel, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "ctrl+c":
|
||||||
|
return m, func() tea.Msg { return portForwardCancelMsg{} }
|
||||||
|
|
||||||
|
case "enter":
|
||||||
|
nextField := m.getNextValidField(m.focused)
|
||||||
|
if nextField != -1 {
|
||||||
|
// Move to next valid input
|
||||||
|
m.inputs[m.focused].Blur()
|
||||||
|
m.focused = nextField
|
||||||
|
m.inputs[m.focused].Focus()
|
||||||
|
return m, textinput.Blink
|
||||||
|
} else {
|
||||||
|
// Submit form
|
||||||
|
return m, m.submitForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
case "shift+tab", "up":
|
||||||
|
prevField := m.getPrevValidField(m.focused)
|
||||||
|
if prevField != -1 {
|
||||||
|
m.inputs[m.focused].Blur()
|
||||||
|
m.focused = prevField
|
||||||
|
m.inputs[m.focused].Focus()
|
||||||
|
return m, textinput.Blink
|
||||||
|
}
|
||||||
|
|
||||||
|
case "tab", "down":
|
||||||
|
nextField := m.getNextValidField(m.focused)
|
||||||
|
if nextField != -1 {
|
||||||
|
m.inputs[m.focused].Blur()
|
||||||
|
m.focused = nextField
|
||||||
|
m.inputs[m.focused].Focus()
|
||||||
|
return m, textinput.Blink
|
||||||
|
}
|
||||||
|
|
||||||
|
case "left", "right":
|
||||||
|
if m.focused == pfTypeInput {
|
||||||
|
// Change forward type
|
||||||
|
if msg.String() == "left" {
|
||||||
|
if m.forwardType > 0 {
|
||||||
|
m.forwardType--
|
||||||
|
} else {
|
||||||
|
m.forwardType = DynamicForward
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if m.forwardType < DynamicForward {
|
||||||
|
m.forwardType++
|
||||||
|
} else {
|
||||||
|
m.forwardType = LocalForward
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.inputs[pfTypeInput].SetValue(m.forwardType.String())
|
||||||
|
m.updateInputVisibility()
|
||||||
|
|
||||||
|
// Ensure focused field is valid for the new type
|
||||||
|
validFields := m.getValidFields()
|
||||||
|
validFocus := false
|
||||||
|
for _, field := range validFields {
|
||||||
|
if field == m.focused {
|
||||||
|
validFocus = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !validFocus && len(validFields) > 0 {
|
||||||
|
m.inputs[m.focused].Blur()
|
||||||
|
m.focused = validFields[0]
|
||||||
|
m.inputs[m.focused].Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the focused input
|
||||||
|
m.inputs[m.focused], cmd = m.inputs[m.focused].Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *portForwardModel) updateInputVisibility() {
|
||||||
|
// Reset all inputs visibility
|
||||||
|
for i := range m.inputs {
|
||||||
|
if i != pfTypeInput {
|
||||||
|
m.inputs[i].Placeholder = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch m.forwardType {
|
||||||
|
case LocalForward:
|
||||||
|
m.inputs[pfLocalPortInput].Placeholder = "Local port (e.g., 8080)"
|
||||||
|
m.inputs[pfRemoteHostInput].Placeholder = "Remote host (e.g., localhost)"
|
||||||
|
m.inputs[pfRemotePortInput].Placeholder = "Remote port (e.g., 80)"
|
||||||
|
m.inputs[pfBindAddressInput].Placeholder = "Bind address (optional, default: 127.0.0.1)"
|
||||||
|
case RemoteForward:
|
||||||
|
m.inputs[pfLocalPortInput].Placeholder = "Remote port (e.g., 8080)"
|
||||||
|
m.inputs[pfRemoteHostInput].Placeholder = "Local host (e.g., localhost)"
|
||||||
|
m.inputs[pfRemotePortInput].Placeholder = "Local port (e.g., 80)"
|
||||||
|
m.inputs[pfBindAddressInput].Placeholder = "Bind address (optional)"
|
||||||
|
case DynamicForward:
|
||||||
|
m.inputs[pfLocalPortInput].Placeholder = "SOCKS port (e.g., 1080)"
|
||||||
|
m.inputs[pfRemoteHostInput].Placeholder = ""
|
||||||
|
m.inputs[pfRemotePortInput].Placeholder = ""
|
||||||
|
m.inputs[pfBindAddressInput].Placeholder = "Bind address (optional, default: 127.0.0.1)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *portForwardModel) View() string {
|
||||||
|
var sections []string
|
||||||
|
|
||||||
|
// Title
|
||||||
|
title := m.styles.Header.Render("🔗 Port Forwarding Setup")
|
||||||
|
sections = append(sections, title)
|
||||||
|
|
||||||
|
// Host info
|
||||||
|
hostInfo := fmt.Sprintf("Host: %s", m.hostName)
|
||||||
|
sections = append(sections, m.styles.HelpText.Render(hostInfo))
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
if m.err != "" {
|
||||||
|
sections = append(sections, m.styles.Error.Render("Error: "+m.err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
var fields []string
|
||||||
|
|
||||||
|
// Forward type
|
||||||
|
typeLabel := "Forward Type:"
|
||||||
|
if m.focused == pfTypeInput {
|
||||||
|
typeLabel = m.styles.FocusedLabel.Render(typeLabel)
|
||||||
|
} else {
|
||||||
|
typeLabel = m.styles.Label.Render(typeLabel)
|
||||||
|
}
|
||||||
|
fields = append(fields, typeLabel)
|
||||||
|
fields = append(fields, m.inputs[pfTypeInput].View())
|
||||||
|
fields = append(fields, m.styles.HelpText.Render("Use ←/→ to change type"))
|
||||||
|
|
||||||
|
switch m.forwardType {
|
||||||
|
case LocalForward:
|
||||||
|
fields = append(fields, "")
|
||||||
|
fields = append(fields, m.styles.HelpText.Render("Local forwarding: ssh -L [bind_address:]local_port:remote_host:remote_port"))
|
||||||
|
fields = append(fields, "")
|
||||||
|
|
||||||
|
// Local port
|
||||||
|
localPortLabel := "Local Port:"
|
||||||
|
if m.focused == pfLocalPortInput {
|
||||||
|
localPortLabel = m.styles.FocusedLabel.Render(localPortLabel)
|
||||||
|
} else {
|
||||||
|
localPortLabel = m.styles.Label.Render(localPortLabel)
|
||||||
|
}
|
||||||
|
fields = append(fields, localPortLabel)
|
||||||
|
fields = append(fields, m.inputs[pfLocalPortInput].View())
|
||||||
|
|
||||||
|
// Remote host
|
||||||
|
remoteHostLabel := "Remote Host:"
|
||||||
|
if m.focused == pfRemoteHostInput {
|
||||||
|
remoteHostLabel = m.styles.FocusedLabel.Render(remoteHostLabel)
|
||||||
|
} else {
|
||||||
|
remoteHostLabel = m.styles.Label.Render(remoteHostLabel)
|
||||||
|
}
|
||||||
|
fields = append(fields, remoteHostLabel)
|
||||||
|
fields = append(fields, m.inputs[pfRemoteHostInput].View())
|
||||||
|
|
||||||
|
// Remote port
|
||||||
|
remotePortLabel := "Remote Port:"
|
||||||
|
if m.focused == pfRemotePortInput {
|
||||||
|
remotePortLabel = m.styles.FocusedLabel.Render(remotePortLabel)
|
||||||
|
} else {
|
||||||
|
remotePortLabel = m.styles.Label.Render(remotePortLabel)
|
||||||
|
}
|
||||||
|
fields = append(fields, remotePortLabel)
|
||||||
|
fields = append(fields, m.inputs[pfRemotePortInput].View())
|
||||||
|
|
||||||
|
case RemoteForward:
|
||||||
|
fields = append(fields, "")
|
||||||
|
fields = append(fields, m.styles.HelpText.Render("Remote forwarding: ssh -R [bind_address:]remote_port:local_host:local_port"))
|
||||||
|
fields = append(fields, "")
|
||||||
|
|
||||||
|
// Remote port
|
||||||
|
remotePortLabel := "Remote Port:"
|
||||||
|
if m.focused == pfLocalPortInput {
|
||||||
|
remotePortLabel = m.styles.FocusedLabel.Render(remotePortLabel)
|
||||||
|
} else {
|
||||||
|
remotePortLabel = m.styles.Label.Render(remotePortLabel)
|
||||||
|
}
|
||||||
|
fields = append(fields, remotePortLabel)
|
||||||
|
fields = append(fields, m.inputs[pfLocalPortInput].View())
|
||||||
|
|
||||||
|
// Local host
|
||||||
|
localHostLabel := "Local Host:"
|
||||||
|
if m.focused == pfRemoteHostInput {
|
||||||
|
localHostLabel = m.styles.FocusedLabel.Render(localHostLabel)
|
||||||
|
} else {
|
||||||
|
localHostLabel = m.styles.Label.Render(localHostLabel)
|
||||||
|
}
|
||||||
|
fields = append(fields, localHostLabel)
|
||||||
|
fields = append(fields, m.inputs[pfRemoteHostInput].View())
|
||||||
|
|
||||||
|
// Local port
|
||||||
|
localPortLabel := "Local Port:"
|
||||||
|
if m.focused == pfRemotePortInput {
|
||||||
|
localPortLabel = m.styles.FocusedLabel.Render(localPortLabel)
|
||||||
|
} else {
|
||||||
|
localPortLabel = m.styles.Label.Render(localPortLabel)
|
||||||
|
}
|
||||||
|
fields = append(fields, localPortLabel)
|
||||||
|
fields = append(fields, m.inputs[pfRemotePortInput].View())
|
||||||
|
|
||||||
|
case DynamicForward:
|
||||||
|
fields = append(fields, "")
|
||||||
|
fields = append(fields, m.styles.HelpText.Render("Dynamic forwarding (SOCKS proxy): ssh -D [bind_address:]port"))
|
||||||
|
fields = append(fields, "")
|
||||||
|
|
||||||
|
// SOCKS port
|
||||||
|
socksPortLabel := "SOCKS Port:"
|
||||||
|
if m.focused == pfLocalPortInput {
|
||||||
|
socksPortLabel = m.styles.FocusedLabel.Render(socksPortLabel)
|
||||||
|
} else {
|
||||||
|
socksPortLabel = m.styles.Label.Render(socksPortLabel)
|
||||||
|
}
|
||||||
|
fields = append(fields, socksPortLabel)
|
||||||
|
fields = append(fields, m.inputs[pfLocalPortInput].View())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind address (for all types)
|
||||||
|
fields = append(fields, "")
|
||||||
|
bindLabel := "Bind Address (optional):"
|
||||||
|
if m.focused == pfBindAddressInput {
|
||||||
|
bindLabel = m.styles.FocusedLabel.Render(bindLabel)
|
||||||
|
} else {
|
||||||
|
bindLabel = m.styles.Label.Render(bindLabel)
|
||||||
|
}
|
||||||
|
fields = append(fields, bindLabel)
|
||||||
|
fields = append(fields, m.inputs[pfBindAddressInput].View())
|
||||||
|
|
||||||
|
// Join form fields
|
||||||
|
formContent := lipgloss.JoinVertical(lipgloss.Left, fields...)
|
||||||
|
sections = append(sections, formContent)
|
||||||
|
|
||||||
|
// Help text
|
||||||
|
helpText := " Tab/↓: next field • Shift+Tab/↑: previous field • Enter: connect • Esc: cancel"
|
||||||
|
sections = append(sections, m.styles.HelpText.Render(helpText))
|
||||||
|
|
||||||
|
// Join all sections
|
||||||
|
content := lipgloss.JoinVertical(lipgloss.Left, sections...)
|
||||||
|
|
||||||
|
// Center the form
|
||||||
|
return lipgloss.Place(
|
||||||
|
m.width,
|
||||||
|
m.height,
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
m.styles.FormContainer.Render(content),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *portForwardModel) submitForm() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
// Validate inputs
|
||||||
|
localPort := strings.TrimSpace(m.inputs[pfLocalPortInput].Value())
|
||||||
|
if localPort == "" {
|
||||||
|
return portForwardSubmitMsg{err: fmt.Errorf("port is required"), sshArgs: nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate port number
|
||||||
|
if _, err := strconv.Atoi(localPort); err != nil {
|
||||||
|
return portForwardSubmitMsg{err: fmt.Errorf("invalid port number"), sshArgs: nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build SSH command with port forwarding
|
||||||
|
var sshArgs []string
|
||||||
|
|
||||||
|
// Add config file if specified
|
||||||
|
if m.configFile != "" {
|
||||||
|
sshArgs = append(sshArgs, "-F", m.configFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add forwarding arguments
|
||||||
|
bindAddress := strings.TrimSpace(m.inputs[pfBindAddressInput].Value())
|
||||||
|
|
||||||
|
switch m.forwardType {
|
||||||
|
case LocalForward:
|
||||||
|
remoteHost := strings.TrimSpace(m.inputs[pfRemoteHostInput].Value())
|
||||||
|
remotePort := strings.TrimSpace(m.inputs[pfRemotePortInput].Value())
|
||||||
|
|
||||||
|
if remoteHost == "" {
|
||||||
|
remoteHost = "localhost"
|
||||||
|
}
|
||||||
|
if remotePort == "" {
|
||||||
|
return portForwardSubmitMsg{err: fmt.Errorf("remote port is required for local forwarding"), sshArgs: nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate remote port
|
||||||
|
if _, err := strconv.Atoi(remotePort); err != nil {
|
||||||
|
return portForwardSubmitMsg{err: fmt.Errorf("invalid remote port number"), sshArgs: nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build -L argument
|
||||||
|
var forwardArg string
|
||||||
|
if bindAddress != "" {
|
||||||
|
forwardArg = fmt.Sprintf("%s:%s:%s:%s", bindAddress, localPort, remoteHost, remotePort)
|
||||||
|
} else {
|
||||||
|
forwardArg = fmt.Sprintf("%s:%s:%s", localPort, remoteHost, remotePort)
|
||||||
|
}
|
||||||
|
sshArgs = append(sshArgs, "-L", forwardArg)
|
||||||
|
|
||||||
|
case RemoteForward:
|
||||||
|
localHost := strings.TrimSpace(m.inputs[pfRemoteHostInput].Value())
|
||||||
|
localPortStr := strings.TrimSpace(m.inputs[pfRemotePortInput].Value())
|
||||||
|
|
||||||
|
if localHost == "" {
|
||||||
|
localHost = "localhost"
|
||||||
|
}
|
||||||
|
if localPortStr == "" {
|
||||||
|
return portForwardSubmitMsg{err: fmt.Errorf("local port is required for remote forwarding"), sshArgs: nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate local port
|
||||||
|
if _, err := strconv.Atoi(localPortStr); err != nil {
|
||||||
|
return portForwardSubmitMsg{err: fmt.Errorf("invalid local port number"), sshArgs: nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build -R argument (note: localPort is actually the remote port in this context)
|
||||||
|
var forwardArg string
|
||||||
|
if bindAddress != "" {
|
||||||
|
forwardArg = fmt.Sprintf("%s:%s:%s:%s", bindAddress, localPort, localHost, localPortStr)
|
||||||
|
} else {
|
||||||
|
forwardArg = fmt.Sprintf("%s:%s:%s", localPort, localHost, localPortStr)
|
||||||
|
}
|
||||||
|
sshArgs = append(sshArgs, "-R", forwardArg)
|
||||||
|
|
||||||
|
case DynamicForward:
|
||||||
|
// Build -D argument
|
||||||
|
var forwardArg string
|
||||||
|
if bindAddress != "" {
|
||||||
|
forwardArg = fmt.Sprintf("%s:%s", bindAddress, localPort)
|
||||||
|
} else {
|
||||||
|
forwardArg = localPort
|
||||||
|
}
|
||||||
|
sshArgs = append(sshArgs, "-D", forwardArg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add hostname
|
||||||
|
sshArgs = append(sshArgs, m.hostName)
|
||||||
|
|
||||||
|
// Return success with the SSH command to execute
|
||||||
|
return portForwardSubmitMsg{err: nil, sshArgs: sshArgs}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getValidFields returns the list of valid field indices for the current forward type
|
||||||
|
func (m *portForwardModel) getValidFields() []int {
|
||||||
|
switch m.forwardType {
|
||||||
|
case LocalForward:
|
||||||
|
return []int{pfTypeInput, pfLocalPortInput, pfRemoteHostInput, pfRemotePortInput, pfBindAddressInput}
|
||||||
|
case RemoteForward:
|
||||||
|
return []int{pfTypeInput, pfLocalPortInput, pfRemoteHostInput, pfRemotePortInput, pfBindAddressInput}
|
||||||
|
case DynamicForward:
|
||||||
|
return []int{pfTypeInput, pfLocalPortInput, pfBindAddressInput}
|
||||||
|
default:
|
||||||
|
return []int{pfTypeInput, pfLocalPortInput, pfRemoteHostInput, pfRemotePortInput, pfBindAddressInput}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNextValidField returns the next valid field index, or -1 if none
|
||||||
|
func (m *portForwardModel) getNextValidField(currentField int) int {
|
||||||
|
validFields := m.getValidFields()
|
||||||
|
|
||||||
|
for i, field := range validFields {
|
||||||
|
if field == currentField && i < len(validFields)-1 {
|
||||||
|
return validFields[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPrevValidField returns the previous valid field index, or -1 if none
|
||||||
|
func (m *portForwardModel) getPrevValidField(currentField int) int {
|
||||||
|
validFields := m.getValidFields()
|
||||||
|
|
||||||
|
for i, field := range validFields {
|
||||||
|
if field == currentField && i > 0 {
|
||||||
|
return validFields[i-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -36,9 +36,13 @@ type Styles struct {
|
|||||||
Error lipgloss.Style
|
Error lipgloss.Style
|
||||||
|
|
||||||
// Form styles (for add/edit forms)
|
// Form styles (for add/edit forms)
|
||||||
FormTitle lipgloss.Style
|
FormTitle lipgloss.Style
|
||||||
FormField lipgloss.Style
|
FormField lipgloss.Style
|
||||||
FormHelp lipgloss.Style
|
FormHelp lipgloss.Style
|
||||||
|
FormContainer lipgloss.Style
|
||||||
|
Label lipgloss.Style
|
||||||
|
FocusedLabel lipgloss.Style
|
||||||
|
HelpSection lipgloss.Style
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStyles creates a new Styles struct with the given terminal width
|
// NewStyles creates a new Styles struct with the given terminal width
|
||||||
@@ -85,8 +89,7 @@ func NewStyles(width int) Styles {
|
|||||||
Foreground(lipgloss.Color(SecondaryColor)),
|
Foreground(lipgloss.Color(SecondaryColor)),
|
||||||
|
|
||||||
HelpText: lipgloss.NewStyle().
|
HelpText: lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color(SecondaryColor)).
|
Foreground(lipgloss.Color(SecondaryColor)),
|
||||||
MarginTop(1),
|
|
||||||
|
|
||||||
// Error style
|
// Error style
|
||||||
Error: lipgloss.NewStyle().
|
Error: lipgloss.NewStyle().
|
||||||
@@ -105,6 +108,20 @@ func NewStyles(width int) Styles {
|
|||||||
|
|
||||||
FormHelp: lipgloss.NewStyle().
|
FormHelp: lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("#626262")),
|
Foreground(lipgloss.Color("#626262")),
|
||||||
|
|
||||||
|
FormContainer: lipgloss.NewStyle().
|
||||||
|
BorderStyle(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color(PrimaryColor)).
|
||||||
|
Padding(1, 2),
|
||||||
|
|
||||||
|
Label: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(SecondaryColor)),
|
||||||
|
|
||||||
|
FocusedLabel: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(PrimaryColor)),
|
||||||
|
|
||||||
|
HelpSection: lipgloss.NewStyle().
|
||||||
|
Padding(0, 2),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ func (m *Model) updateTableRows() {
|
|||||||
rows = append(rows, table.Row{
|
rows = append(rows, table.Row{
|
||||||
host.Name,
|
host.Name,
|
||||||
host.Hostname,
|
host.Hostname,
|
||||||
host.User,
|
// host.User, // Commented to save space
|
||||||
host.Port,
|
// host.Port, // Commented to save space
|
||||||
tagsStr,
|
tagsStr,
|
||||||
lastLoginStr,
|
lastLoginStr,
|
||||||
})
|
})
|
||||||
@@ -146,16 +146,17 @@ func (m *Model) updateTableHeight() {
|
|||||||
// Layout breakdown:
|
// Layout breakdown:
|
||||||
// - ASCII title: 5 lines (1 empty + 4 text lines)
|
// - ASCII title: 5 lines (1 empty + 4 text lines)
|
||||||
// - Search bar: 1 line
|
// - Search bar: 1 line
|
||||||
// - Sort info: 1 line
|
// - Help text: 1 line
|
||||||
// - Help text: 2 lines (multi-line text)
|
// - App margins/spacing: 3 lines
|
||||||
// - App margins/spacing: 2 lines
|
// - Safety margin: 3 lines (to ensure UI elements are always visible)
|
||||||
// Total reserved: 11 lines, mais réduisons à 7 pour forcer plus d'espace
|
// Total reserved: 13 lines minimum to preserve essential UI elements
|
||||||
reservedHeight := 7 // Réduction agressive pour tester
|
reservedHeight := 13
|
||||||
availableHeight := m.height - reservedHeight
|
availableHeight := m.height - reservedHeight
|
||||||
hostCount := len(m.table.Rows())
|
hostCount := len(m.table.Rows())
|
||||||
|
|
||||||
// Minimum height should be at least 5 rows for usability
|
// Minimum height should be at least 3 rows for basic usability
|
||||||
minTableHeight := 6 // 1 header + 5 data rows
|
// Even in very small terminals, we want to show at least header + 2 hosts
|
||||||
|
minTableHeight := 4 // 1 header + 3 data rows minimum
|
||||||
maxTableHeight := availableHeight
|
maxTableHeight := availableHeight
|
||||||
if maxTableHeight < minTableHeight {
|
if maxTableHeight < minTableHeight {
|
||||||
maxTableHeight = minTableHeight
|
maxTableHeight = minTableHeight
|
||||||
@@ -173,7 +174,8 @@ func (m *Model) updateTableHeight() {
|
|||||||
tableHeight += maxDataRows
|
tableHeight += maxDataRows
|
||||||
}
|
}
|
||||||
|
|
||||||
// FORCE: Ajoutons une ligne supplémentaire pour résoudre le problème
|
// Add one extra line to prevent the last host from being hidden
|
||||||
|
// This compensates for table rendering quirks in bubble tea
|
||||||
tableHeight += 1
|
tableHeight += 1
|
||||||
|
|
||||||
// Update table height
|
// Update table height
|
||||||
@@ -198,11 +200,11 @@ func (m *Model) updateTableColumns() {
|
|||||||
|
|
||||||
// Fixed column widths
|
// Fixed column widths
|
||||||
hostnameWidth := 25
|
hostnameWidth := 25
|
||||||
userWidth := 12
|
// userWidth := 12 // Commented to save space
|
||||||
portWidth := 6
|
// portWidth := 6 // Commented to save space
|
||||||
|
|
||||||
// Calculate total width needed for all columns
|
// Calculate total width needed for all columns
|
||||||
totalFixedWidth := hostnameWidth + userWidth + portWidth
|
totalFixedWidth := hostnameWidth // + userWidth + portWidth // Commented columns
|
||||||
totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth
|
totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth
|
||||||
totalWidth := totalFixedWidth + totalVariableWidth
|
totalWidth := totalFixedWidth + totalVariableWidth
|
||||||
|
|
||||||
@@ -226,14 +228,25 @@ func (m *Model) updateTableColumns() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new columns with updated widths
|
// 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{
|
columns := []table.Column{
|
||||||
{Title: "Name", Width: nameWidth},
|
{Title: nameTitle, Width: nameWidth},
|
||||||
{Title: "Hostname", Width: hostnameWidth},
|
{Title: "Hostname", Width: hostnameWidth},
|
||||||
{Title: "User", Width: userWidth},
|
// {Title: "User", Width: userWidth}, // Commented to save space
|
||||||
{Title: "Port", Width: portWidth},
|
// {Title: "Port", Width: portWidth}, // Commented to save space
|
||||||
{Title: "Tags", Width: tagsWidth},
|
{Title: "Tags", Width: tagsWidth},
|
||||||
{Title: "Last Login", Width: lastLoginWidth},
|
{Title: lastLoginTitle, Width: lastLoginWidth},
|
||||||
}
|
}
|
||||||
|
|
||||||
m.table.SetColumns(columns)
|
m.table.SetColumns(columns)
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
|||||||
columns := []table.Column{
|
columns := []table.Column{
|
||||||
{Title: "Name", Width: nameWidth},
|
{Title: "Name", Width: nameWidth},
|
||||||
{Title: "Hostname", Width: 25},
|
{Title: "Hostname", Width: 25},
|
||||||
{Title: "User", Width: 12},
|
// {Title: "User", Width: 12}, // Commented to save space
|
||||||
{Title: "Port", Width: 6},
|
// {Title: "Port", Width: 6}, // Commented to save space
|
||||||
{Title: "Tags", Width: tagsWidth},
|
{Title: "Tags", Width: tagsWidth},
|
||||||
{Title: "Last Login", Width: lastLoginWidth},
|
{Title: "Last Login", Width: lastLoginWidth},
|
||||||
}
|
}
|
||||||
@@ -92,8 +92,8 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
|||||||
rows = append(rows, table.Row{
|
rows = append(rows, table.Row{
|
||||||
host.Name,
|
host.Name,
|
||||||
host.Hostname,
|
host.Hostname,
|
||||||
host.User,
|
// host.User, // Commented to save space
|
||||||
host.Port,
|
// host.Port, // Commented to save space
|
||||||
tagsStr,
|
tagsStr,
|
||||||
lastLoginStr,
|
lastLoginStr,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,6 +46,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.editForm.height = m.height
|
m.editForm.height = m.height
|
||||||
m.editForm.styles = m.styles
|
m.editForm.styles = m.styles
|
||||||
}
|
}
|
||||||
|
if m.infoForm != nil {
|
||||||
|
m.infoForm.width = m.width
|
||||||
|
m.infoForm.height = m.height
|
||||||
|
m.infoForm.styles = m.styles
|
||||||
|
}
|
||||||
|
if m.portForwardForm != nil {
|
||||||
|
m.portForwardForm.width = m.width
|
||||||
|
m.portForwardForm.height = m.height
|
||||||
|
m.portForwardForm.styles = m.styles
|
||||||
|
}
|
||||||
|
if m.helpForm != nil {
|
||||||
|
m.helpForm.width = m.width
|
||||||
|
m.helpForm.height = m.height
|
||||||
|
m.helpForm.styles = m.styles
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case addFormSubmitMsg:
|
case addFormSubmitMsg:
|
||||||
@@ -136,6 +151,74 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.table.Focus()
|
m.table.Focus()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case infoFormCancelMsg:
|
||||||
|
// Cancel: return to list view
|
||||||
|
m.viewMode = ViewList
|
||||||
|
m.infoForm = nil
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case infoFormEditMsg:
|
||||||
|
// Switch from info to edit mode
|
||||||
|
editForm, err := NewEditForm(msg.hostName, m.styles, m.width, m.height, m.configFile)
|
||||||
|
if err != nil {
|
||||||
|
// Handle error - could show in UI, for now just go back to list
|
||||||
|
m.viewMode = ViewList
|
||||||
|
m.infoForm = nil
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.editForm = editForm
|
||||||
|
m.infoForm = nil
|
||||||
|
m.viewMode = ViewEdit
|
||||||
|
return m, textinput.Blink
|
||||||
|
|
||||||
|
case portForwardSubmitMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
// Show error in form
|
||||||
|
if m.portForwardForm != nil {
|
||||||
|
m.portForwardForm.err = msg.err.Error()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
} else {
|
||||||
|
// Success: execute SSH command with port forwarding
|
||||||
|
if len(msg.sshArgs) > 0 {
|
||||||
|
sshCmd := exec.Command("ssh", msg.sshArgs...)
|
||||||
|
|
||||||
|
// Record the connection in history
|
||||||
|
if m.historyManager != nil && m.portForwardForm != nil {
|
||||||
|
err := m.historyManager.RecordConnection(m.portForwardForm.hostName)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: Could not record connection history: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, tea.ExecProcess(sshCmd, func(err error) tea.Msg {
|
||||||
|
return tea.Quit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no SSH args, just return to list view
|
||||||
|
m.viewMode = ViewList
|
||||||
|
m.portForwardForm = nil
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case portForwardCancelMsg:
|
||||||
|
// Cancel: return to list view
|
||||||
|
m.viewMode = ViewList
|
||||||
|
m.portForwardForm = nil
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case helpCloseMsg:
|
||||||
|
// Close help: return to list view
|
||||||
|
m.viewMode = ViewList
|
||||||
|
m.helpForm = nil
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
// Handle view-specific key presses
|
// Handle view-specific key presses
|
||||||
switch m.viewMode {
|
switch m.viewMode {
|
||||||
@@ -153,6 +236,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.editForm = newForm
|
m.editForm = newForm
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
case ViewInfo:
|
||||||
|
if m.infoForm != nil {
|
||||||
|
var newForm *infoFormModel
|
||||||
|
newForm, cmd = m.infoForm.Update(msg)
|
||||||
|
m.infoForm = newForm
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
case ViewPortForward:
|
||||||
|
if m.portForwardForm != nil {
|
||||||
|
var newForm *portForwardModel
|
||||||
|
newForm, cmd = m.portForwardForm.Update(msg)
|
||||||
|
m.portForwardForm = newForm
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
case ViewHelp:
|
||||||
|
if m.helpForm != nil {
|
||||||
|
var newForm *helpModel
|
||||||
|
newForm, cmd = m.helpForm.Update(msg)
|
||||||
|
m.helpForm = newForm
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
case ViewList:
|
case ViewList:
|
||||||
// Handle list view keys
|
// Handle list view keys
|
||||||
return m.handleListViewKeys(msg)
|
return m.handleListViewKeys(msg)
|
||||||
@@ -305,6 +409,22 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, textinput.Blink
|
return m, textinput.Blink
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "i":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Show info for the selected host
|
||||||
|
selected := m.table.SelectedRow()
|
||||||
|
if len(selected) > 0 {
|
||||||
|
hostName := selected[0] // The hostname is in the first column
|
||||||
|
infoForm, err := NewInfoForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||||
|
if err != nil {
|
||||||
|
// Handle error - could show in UI
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.infoForm = infoForm
|
||||||
|
m.viewMode = ViewInfo
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
case "a":
|
case "a":
|
||||||
if !m.searchMode && !m.deleteMode {
|
if !m.searchMode && !m.deleteMode {
|
||||||
// Add a new host
|
// Add a new host
|
||||||
@@ -324,6 +444,24 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "f":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Port forwarding for the selected host
|
||||||
|
selected := m.table.SelectedRow()
|
||||||
|
if len(selected) > 0 {
|
||||||
|
hostName := selected[0] // The hostname is in the first column
|
||||||
|
m.portForwardForm = NewPortForwardForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||||
|
m.viewMode = ViewPortForward
|
||||||
|
return m, textinput.Blink
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "h":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Show help
|
||||||
|
m.helpForm = NewHelpForm(m.styles, m.width, m.height)
|
||||||
|
m.viewMode = ViewHelp
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
case "s":
|
case "s":
|
||||||
if !m.searchMode && !m.deleteMode {
|
if !m.searchMode && !m.deleteMode {
|
||||||
// Cycle through sort modes (only 2 modes now)
|
// Cycle through sort modes (only 2 modes now)
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ func (m Model) View() string {
|
|||||||
if m.editForm != nil {
|
if m.editForm != nil {
|
||||||
return m.editForm.View()
|
return m.editForm.View()
|
||||||
}
|
}
|
||||||
|
case ViewInfo:
|
||||||
|
if m.infoForm != nil {
|
||||||
|
return m.infoForm.View()
|
||||||
|
}
|
||||||
|
case ViewPortForward:
|
||||||
|
if m.portForwardForm != nil {
|
||||||
|
return m.portForwardForm.View()
|
||||||
|
}
|
||||||
|
case ViewHelp:
|
||||||
|
if m.helpForm != nil {
|
||||||
|
return m.helpForm.View()
|
||||||
|
}
|
||||||
case ViewList:
|
case ViewList:
|
||||||
return m.renderListView()
|
return m.renderListView()
|
||||||
}
|
}
|
||||||
@@ -46,10 +58,6 @@ func (m Model) renderListView() string {
|
|||||||
components = append(components, m.styles.SearchUnfocused.Render(searchPrompt+m.searchInput.View()))
|
components = append(components, m.styles.SearchUnfocused.Render(searchPrompt+m.searchInput.View()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the sort mode indicator
|
|
||||||
sortInfo := fmt.Sprintf(" Sort: %s", m.sortMode.String())
|
|
||||||
components = append(components, m.styles.SortInfo.Render(sortInfo))
|
|
||||||
|
|
||||||
// Add the table with the appropriate style based on focus
|
// Add the table with the appropriate style based on focus
|
||||||
if m.searchMode {
|
if m.searchMode {
|
||||||
// The table is not focused, use the unfocused style
|
// The table is not focused, use the unfocused style
|
||||||
@@ -62,9 +70,9 @@ 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 = " Use ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • Tab to switch\n Sort: (s)witch • (r)ecent • (n)ame • q/ESC to quit"
|
helpText = " ↑/↓: navigate • Enter: connect • i: info • h: help • q: quit"
|
||||||
} else {
|
} else {
|
||||||
helpText = " Type to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit"
|
helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit"
|
||||||
}
|
}
|
||||||
components = append(components, m.styles.HelpText.Render(helpText))
|
components = append(components, m.styles.HelpText.Render(helpText))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user