10 Commits

23 changed files with 2146 additions and 109 deletions

426
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,426 @@
# GitHub Copilot Instructions for Go + Bubble Tea (TUI)
These project-level instructions tell Copilot how to generate idiomatic, production-quality Go code using the Bubble Tea ecosystem. **Follow and prefer these rules over generic patterns.**
---
## 1) Project Scope & Goals
* Build terminal UIs with **[Bubble Tea](https://github.com/charmbracelet/bubbletea)** and **Bubbles** components.
* Use **Lip Gloss** for styling and **Huh**/**Bubbles forms** for prompts where useful.
* Favor **small, composable models** and **message-driven state**.
* Prioritize **maintainability, testability, and clear separation** of update vs. view.
---
## 2) Go Conventions to Prefer
* Go version: **1.22+**.
* Module: `go.mod` with minimal, pinned dependencies; use `go get -u` only deliberately.
* Code style: `gofmt`, `go vet`, `staticcheck` (when available), `golangci-lint`.
* Names: short, meaningful; exported symbols require GoDoc comments.
* Errors: return wrapped errors with `%w` and `errors.Is/As`. No panics for flow control.
* Concurrency: use `context.Context` and `errgroup` where applicable. Avoid goroutine leaks; cancel contexts in `Quit`/`Stop`.
* Testing: `*_test.go`, table-driven tests, golden tests for `View()` when helpful.
* Logging: prefer structured logs (e.g., `slog`) and keep logs separate from UI rendering.
---
## 3) Bubble Tea Architecture Rules
### 3.1 Model layout
```go
// Model holds all state needed to render and update.
type Model struct {
width, height int
ready bool
// Domain state
items []Item
cursor int
err error
// Child components
list list.Model
spinner spinner.Model
// Styles
styles Styles
}
```
**Guidelines**
* Keep **domain state** (data) separate from **UI components** (Bubbles models) and **styles**.
* Add a `Styles` struct to centralize Lip Gloss styles; initialize once.
* Track terminal size (`width`, `height`); re-calc layout on `tea.WindowSizeMsg`.
### 3.2 Init
* Return **batch** of startup commands for IO (e.g., loading data) and component inits.
* Never block in `Init`; do IO in `tea.Cmd`s.
```go
func (m Model) Init() tea.Cmd {
return tea.Batch(m.spinner.Tick, loadItemsCmd())
}
```
### 3.3 Update
* **Pure function** style: transform `Model` + `Msg``(Model, Cmd)`.
* Always handle `tea.WindowSizeMsg` to set `m.width`/`m.height` and recompute layout.
* Use **type-switched** message handling; push side effects into `tea.Cmd`s.
* Bubble components: call `Update(msg)` on children and **return their Cmd**.
```go
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.styles = NewStyles(m.width) // recompute if responsive
return m, nil
case errMsg:
m.err = msg
return m, nil
case itemsLoaded:
m.items = msg
return m, nil
}
// delegate to children last
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
```
### 3.4 View
* **Never** mutate state in `View()`.
* Compose layout with Lip Gloss; gracefully handle small terminals.
* Put errors and help at the bottom.
```go
func (m Model) View() string {
if !m.ready {
return m.styles.Loading.Render(m.spinner.View() + " Loading…")
}
main := lipgloss.JoinVertical(lipgloss.Left,
m.styles.Title.Render("My App"),
m.list.View(),
)
if m.err != nil {
main += "\n" + m.styles.Error.Render(m.err.Error())
}
return m.styles.App.Render(main)
}
```
### 3.5 Messages & Commands
* Define **typed messages** for domain events, not raw strings.
* Each async operation returns a **message type**; handle errors with a dedicated `errMsg`.
```go
type itemsLoaded []Item
type errMsg error
func loadItemsCmd() tea.Cmd {
return func() tea.Msg {
items, err := fetchItems()
if err != nil { return errMsg(err) }
return itemsLoaded(items)
}
}
```
### 3.6 Keys & Help
* Centralize keybindings and help text. Prefer `bubbles/key` + `bubbles/help`.
```go
type keyMap struct {
Up, Down, Select, Quit key.Binding
}
var keys = keyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
}
```
Handle keys in `Update` using `key.Matches(msg, keys.X)` and show a `help.Model` in the footer.
### 3.7 Submodels (Component Composition)
* For complex screens, create **submodels** with their own `(Model, Init, Update, View)` and wire them into a parent.
* Exchange messages via **custom Msg types** and/or **parent state**.
* Keep submodels **pure**; IO still goes through parent-level `tea.Cmd`s or via submodel commands returned to parent.
### 3.8 Program Options
* Start programs with `tea.NewProgram(m, tea.WithOutput(os.Stdout), tea.WithAltScreen())` when full-screen; avoid AltScreen for simple tools.
* Always handle **TTY absence** (e.g., piping); fall back to non-interactive.
---
## 4) Styling with Lip Gloss
* Maintain a single `Styles` struct with named styles.
* Compute widths once per resize; avoid per-cell Lip Gloss allocations in tight loops.
* Use `lipgloss.JoinHorizontal/Vertical` for layout; avoid manual spacing where possible.
```go
type Styles struct {
App, Title, Error, Loading lipgloss.Style
}
func NewStyles(width int) Styles {
return Styles{
App: lipgloss.NewStyle().Padding(1),
Title: lipgloss.NewStyle().Bold(true),
Error: lipgloss.NewStyle().Foreground(lipgloss.Color("9")),
Loading: lipgloss.NewStyle().Faint(true),
}
}
```
---
## 5) IO, Concurrency & Performance
* **Never** perform blocking IO in `Update` directly; always return a `tea.Cmd` that does the work.
* Use `context.Context` inside commands; respect cancellation when program exits.
* Be careful with **goroutine leaks**: ensure commands stop when model quits.
* Batch commands with `tea.Batch` to keep updates snappy.
* For large lists, prefer `bubbles/list` with virtualization; avoid generating huge strings per frame.
* Debounce high-frequency events (typing) with timer-based commands.
---
## 6) Error Handling & UX
* Represent recoverable errors in the UI; do not exit on first error.
* Use `errMsg` for async failures; show a concise, styled error line.
* For fatal initialization errors, return `tea.Quit` with an explanatory message printed once.
---
## 7) Keys, Shortcuts, and Accessibility
* Provide **discoverable shortcuts** via a footer help view.
* Offer Emacs-style alternatives where it makes sense (e.g., `ctrl+n/p`).
* Use consistent navigation patterns across screens.
---
## 8) Testing Strategy
* Unit test message handling with deterministic messages.
* Snapshot/golden-test `View()` output for known terminal sizes.
* Fuzz-test parsers/formatters used by the UI.
```go
func TestUpdate_Select(t *testing.T) {
m := newTestModel()
_, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if got, want := m.cursor, 1; got != want { t.Fatalf("cursor=%d want %d", got, want) }
}
```
---
## 9) Project Structure Template
```
cmd/
app/
main.go
internal/
tui/
model.go // root model, styles
update.go // Update + messages
view.go // View
keys.go // keymap/help
components/ // submodels
domain/ // business logic, pure Go
io/ // adapters (API, FS, net)
Makefile // lint, test, run targets
```
---
## 10) Scaffolding Snippets (Ask Copilot to use these)
### 10.1 Root main.go
```go
package main
import (
"context"
"log"
"os"
tea "github.com/charmbracelet/bubbletea"
)
func main() {
if !isTTY() { // optional: detect piping
log.Println("Non-interactive mode not implemented.")
return
}
p := tea.NewProgram(NewModel(), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
log.Fatalf("error: %v", err)
}
}
```
### 10.2 NewModel()
```go
func NewModel() Model {
s := NewStyles(0)
return Model{
list: newList(),
spinner: spinner.New(),
styles: s,
}
}
```
### 10.3 Custom messages
```go
type (
errMsg error
itemsLoaded []Item
)
```
### 10.4 Command helper
```go
func do(cmd func(context.Context) (tea.Msg, error)) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
msg, err := cmd(ctx)
if err != nil { return errMsg(err) }
return msg
}
}
```
---
## 11) Dependencies to Prefer
* `github.com/charmbracelet/bubbletea`
* `github.com/charmbracelet/bubbles`
* `github.com/charmbracelet/lipgloss`
* `golang.org/x/sync/errgroup` (for non-UI workloads)
* `log/slog` (Go 1.21+) for logging
Pin versions in `go.mod`. Avoid extra UI deps unless justified.
---
## 12) Copilot Prompting Rules (Important)
* When the user writes a new TUI screen, **scaffold** `(Model, Init, Update, View)` with:
* Window size handling
* Keymap/help wiring
* Styles struct and `NewStyles(width)`
* Commands for all IO
* Prefer **typed messages** and return **`tea.Cmd`**; do not perform blocking work in `Update`.
* Always update child bubble components via `child.Update(msg)` and collect cmds with `tea.Batch`.
* Generate **tests** for key message paths.
* Include **help footer** with keybindings.
* Keep `View()` pure and free of side effects.
**Bad**
* Doing HTTP/FS work directly in `Update`.
* Printing to stdout from `Update`/`View`.
* Storing `context.Context` in the model.
* Creating goroutines that outlive the program.
**Good**
* Commands that return typed messages.
* Centralized keymap + help.
* Single source of truth for styles.
* Small submodels and composition.
---
## 13) Security & Reliability
* Validate all external inputs; sanitize strings rendered into the terminal.
* Respect user locale and UTF-8; avoid slicing strings by bytes for widths (use `lipgloss.Width`).
* Handle small terminal sizes (min-width fallbacks).
* Ensure graceful shutdown; propagate quit via `tea.Quit` and cancel pending work.
---
## 14) Makefile Targets (suggested)
```
.PHONY: run test lint fmt tidy
run:; go run ./cmd/app
fmt:; go fmt ./...
lint:; golangci-lint run
test:; go test ./...
tidy:; go mod tidy
```
---
## 15) Example Key Handling Pattern
```go
case tea.KeyMsg:
switch {
case key.Matches(msg, keys.Quit):
return m, tea.Quit
case key.Matches(msg, keys.Up):
if m.cursor > 0 { m.cursor-- }
case key.Matches(msg, keys.Down):
if m.cursor < len(m.items)-1 { m.cursor++ }
}
```
---
## 16) Documentation & Comments
* Exported types/functions must have a sentence GoDoc.
* At the top of each file, include a short comment describing its responsibility.
* For non-obvious state transitions, include a brief state diagram in comments.
---
## 17) Acceptance Criteria for Generated Code
* Builds with `go build ./...`
* Passes `go vet` and `golangci-lint` (if configured)
* Has at least one table-driven test per major update path
* Handles window resize and quit
* No side effects in `View()`
* Commands wrap errors and return `errMsg`
---
*End of instructions.*

View File

@@ -34,6 +34,14 @@ jobs:
- goos: darwin
goarch: arm64
suffix: darwin-arm64
# Windows AMD64
- goos: windows
goarch: amd64
suffix: windows-amd64
# Windows ARM64
- goos: windows
goarch: arm64
suffix: windows-arm64
steps:
- name: Checkout code
@@ -60,19 +68,30 @@ jobs:
run: |
mkdir -p dist
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: |
cd dist
tar -czf sshm-${{ matrix.suffix }}.tar.gz sshm-${{ matrix.suffix }}
rm sshm-${{ matrix.suffix }}
if [ "${{ matrix.goos }}" = "windows" ]; then
zip sshm-${{ matrix.suffix }}.zip sshm-${{ matrix.suffix }}.exe
rm sshm-${{ matrix.suffix }}.exe
else
tar -czf sshm-${{ matrix.suffix }}.tar.gz sshm-${{ matrix.suffix }}
rm sshm-${{ matrix.suffix }}
fi
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: sshm-${{ matrix.suffix }}
path: dist/sshm-${{ matrix.suffix }}.tar.gz
path: |
dist/sshm-${{ matrix.suffix }}.tar.gz
dist/sshm-${{ matrix.suffix }}.zip
release:
name: Create Release
@@ -91,6 +110,7 @@ jobs:
run: |
mkdir -p release
find ./artifacts -name "*.tar.gz" -exec cp {} ./release/ \;
find ./artifacts -name "*.zip" -exec cp {} ./release/ \;
ls -la ./release/
- name: Create Release

198
README.md
View File

@@ -9,14 +9,18 @@
[![Go](https://img.shields.io/badge/Go-1.23+-00ADD8?style=for-the-badge&logo=go)](https://golang.org/)
[![Release](https://img.shields.io/github/v/release/Gu1llaum-3/sshm?style=for-the-badge)](https://github.com/Gu1llaum-3/sshm/releases)
[![License](https://img.shields.io/github/license/Gu1llaum-3/sshm?style=for-the-badge)](LICENSE)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS-lightgrey?style=for-the-badge)](https://github.com/Gu1llaum-3/sshm/releases)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-lightgrey?style=for-the-badge)](https://github.com/Gu1llaum-3/sshm/releases)
> **A modern, interactive SSH Manager for your terminal** 🔥
SSHM is a beautiful command-line tool that transforms how you manage and connect to your SSH hosts. Built with Go and featuring an intuitive TUI interface, it makes SSH connection management effortless and enjoyable.
<p align="center">
<img src="images/sshm.gif" alt="Demo SSHM Terminal" width="600" />
<a href="images/sshm.gif" target="_blank">
<img src="images/sshm.gif" alt="Demo SSHM Terminal" width="800" />
</a>
<br>
<em>🖱️ Click on the image to view in full size</em>
</p>
## ✨ Features
@@ -24,10 +28,12 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
### 🎯 **Core Features**
- **🎨 Beautiful TUI Interface** - Navigate your SSH hosts with an elegant, interactive terminal UI
- **⚡ 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
- **🔍 Smart Search** - Find hosts quickly with built-in filtering and search
- **🔒 Secure** - Works directly with your existing `~/.ssh/config` file
- **📁 Custom Config Support** - Use any SSH configuration file with the `-c` flag
- **⚙️ SSH Options Support** - Add any SSH configuration option through intuitive forms
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
@@ -35,6 +41,7 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
- **Add new SSH hosts** with interactive forms
- **Edit existing configurations** in-place
- **Delete hosts** with confirmation prompts
- **Port forwarding setup** with intuitive interface for Local (-L), Remote (-R), and Dynamic (-D) forwarding
- **Backup configurations** automatically before changes
- **Validate settings** to prevent configuration errors
- **ProxyJump support** for secure connection tunneling through bastion hosts
@@ -44,19 +51,26 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
### 🎮 **User Experience**
- **Zero configuration** - Works out of the box with your existing SSH setup
- **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
## 🚀 Quick Start
### Installation
**One-line install (Recommended):**
**Unix/Linux/macOS (One-line install):**
```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:**
*Linux/macOS:*
```bash
# Download specific release
wget https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-amd64.tar.gz
@@ -66,6 +80,14 @@ tar -xzf sshm-linux-amd64.tar.gz
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
### Interactive Mode
@@ -82,9 +104,18 @@ sshm
- `a` - Add new host
- `e` - Edit selected host
- `d` - Delete selected host
- `f` - Port forwarding setup
- `q` - Quit
- `/` - Search/filter hosts
**Sorting & Filtering:**
- `s` - Switch between sorting modes (name ↔ last login)
- `n` - Sort by **name** (alphabetical)
- `r` - Sort by **recent** (last login time)
- `Tab` - Cycle between filtering modes
- Filter by **name** (default) - Search through host names
- Filter by **last login** - Sort and filter by most recently used connections
The interactive forms will guide you through configuration:
- **Hostname/IP** - Server address
- **Username** - SSH user
@@ -94,6 +125,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`)
- **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
SSHM provides both command-line operations and an interactive TUI interface:
@@ -102,15 +217,24 @@ SSHM provides both command-line operations and an interactive TUI interface:
# Launch interactive TUI mode for browsing and connecting to hosts
sshm
# Launch TUI with custom SSH config file
sshm -c /path/to/custom/ssh_config
# Add a new host using interactive form
sshm add
# Add a new host with pre-filled hostname
sshm add hostname
# Add a new host with custom SSH config file
sshm add hostname -c /path/to/custom/ssh_config
# Edit an existing host configuration
sshm edit my-server
# Edit host with custom SSH config file
sshm edit my-server -c /path/to/custom/ssh_config
# Show version information
sshm --version
@@ -118,6 +242,32 @@ sshm --version
sshm --help
```
### Configuration File Options
By default, SSHM uses the standard SSH configuration file at `~/.ssh/config`. You can specify a different configuration file using the `-c` flag:
```bash
# Use custom config file in TUI mode
sshm -c /path/to/custom/ssh_config
# Use custom config file with commands
sshm add hostname -c /path/to/custom/ssh_config
sshm edit hostname -c /path/to/custom/ssh_config
```
### 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
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.
@@ -222,24 +372,44 @@ go build -o sshm .
```
sshm/
├── cmd/ # CLI commands (Cobra)
├── main.go # Application entry point
├── cmd/ # CLI commands (Cobra)
│ ├── root.go # Root command and interactive mode
│ ├── add.go # Add host command
── edit.go # Edit host command
── edit.go # Edit host command
│ └── search.go # Search command
├── internal/
│ ├── config/ # SSH configuration management
│ │ └── ssh.go # Config parsing and manipulation
│ ├── ui/ # Terminal UI components
│ │ ── tui.go # Main TUI interface
│ ├── add_form.go # Add host form
│ │ ── edit_form.go# Edit host form
│ ├── history/ # Connection history tracking
│ │ ── history.go # History management and last login tracking
├── ui/ # Terminal UI components (Bubble Tea)
│ │ ── tui.go # Main TUI interface and program setup
│ │ ├── model.go # Core TUI model and state
│ │ ├── update.go # Message handling and state updates
│ │ ├── view.go # UI rendering and layout
│ │ ├── table.go # Host list table component
│ │ ├── add_form.go # Add host form interface
│ │ ├── edit_form.go# Edit host form interface
│ │ ├── styles.go # Lip Gloss styling definitions
│ │ ├── sort.go # Sorting and filtering logic
│ │ └── utils.go # UI utility functions
│ └── validation/ # Input validation
│ └── ssh.go # SSH config validation
├── images/ # Documentation assets
│ ├── logo.png # Project logo
│ └── sshm.gif # Demo animation
├── install/ # Installation scripts
│ ├── unix.sh # Unix/Linux/macOS installer
│ └── README.md # Installation guide
── .github/workflows/ # CI/CD pipelines
── build.yml # Multi-platform builds
── .github/ # GitHub configuration
── copilot-instructions.md # Development guidelines
│ └── workflows/ # CI/CD pipelines
│ └── build.yml # Multi-platform builds
├── go.mod # Go module definition
├── go.sum # Go module checksums
├── LICENSE # MIT license
└── README.md # Project documentation
```
### Dependencies
@@ -259,6 +429,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) |
| 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) |
| 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

View File

@@ -18,7 +18,7 @@ var addCmd = &cobra.Command{
hostname = args[0]
}
err := ui.RunAddForm(hostname)
err := ui.RunAddForm(hostname, configFile)
if err != nil {
fmt.Printf("Error adding host: %v\n", err)
}

View File

@@ -15,7 +15,7 @@ var editCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
hostname := args[0]
err := ui.RunEditForm(hostname)
err := ui.RunEditForm(hostname, configFile)
if err != nil {
fmt.Printf("Error editing host: %v\n", err)
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"os"
"strings"
"sshm/internal/config"
"sshm/internal/ui"
@@ -14,12 +15,20 @@ import (
// version will be set at build time via -ldflags
var version = "dev"
// configFile holds the path to the SSH config file
var configFile string
var rootCmd = &cobra.Command{
Use: "sshm",
Short: "SSH Manager - A modern SSH connection manager",
Long: `SSH Manager (sshm) is a modern command-line tool for managing SSH connections.
It provides an interactive interface to browse and connect to your SSH hosts
configured in your ~/.ssh/config file.`,
Long: `SSHM is a modern SSH manager for your terminal.
Main usage:
Running 'sshm' (without arguments) opens the interactive TUI window to browse, search, and connect to your SSH hosts graphically.
You can also use sshm in CLI mode for direct operations.
Hosts are read from your ~/.ssh/config file by default.`,
Version: version,
Run: func(cmd *cobra.Command, args []string) {
// If no arguments provided, run interactive mode
@@ -36,7 +45,15 @@ configured in your ~/.ssh/config file.`,
func runInteractiveMode() {
// Parse SSH configurations
hosts, err := config.ParseSSHConfig()
var hosts []config.SSHHost
var err error
if configFile != "" {
hosts, err = config.ParseSSHConfigFile(configFile)
} else {
hosts, err = config.ParseSSHConfig()
}
if err != nil {
log.Fatalf("Error reading SSH config file: %v", err)
}
@@ -47,12 +64,16 @@ func runInteractiveMode() {
var response string
_, err := fmt.Scanln(&response)
if err == nil && (response == "y" || response == "Y") {
err := ui.RunAddForm("")
err := ui.RunAddForm("", configFile)
if err != nil {
fmt.Printf("Error adding host: %v\n", err)
}
// After adding, try to reload hosts and continue if any exist
hosts, err = config.ParseSSHConfig()
if configFile != "" {
hosts, err = config.ParseSSHConfigFile(configFile)
} else {
hosts, err = config.ParseSSHConfig()
}
if err != nil || len(hosts) == 0 {
fmt.Println("No hosts available, exiting.")
os.Exit(1)
@@ -64,14 +85,22 @@ func runInteractiveMode() {
}
// Run the interactive TUI
if err := ui.RunInteractiveMode(hosts); err != nil {
if err := ui.RunInteractiveMode(hosts, configFile); err != nil {
log.Fatalf("Error running interactive mode: %v", err)
}
}
func connectToHost(hostName string) {
// Parse SSH configurations to verify host exists
hosts, err := config.ParseSSHConfig()
var hosts []config.SSHHost
var err error
if configFile != "" {
hosts, err = config.ParseSSHConfigFile(configFile)
} else {
hosts, err = config.ParseSSHConfig()
}
if err != nil {
log.Fatalf("Error reading SSH config file: %v", err)
}
@@ -93,9 +122,18 @@ func connectToHost(hostName string) {
// Connect to the host
fmt.Printf("Connecting to %s...\n", hostName)
// Build the SSH command with the appropriate config file
var sshCmd []string
if configFile != "" {
sshCmd = []string{"ssh", "-F", configFile, hostName}
} else {
sshCmd = []string{"ssh", hostName}
}
// Note: In a real implementation, you'd use exec.Command here
// For now, just print the command that would be executed
fmt.Printf("ssh %s\n", hostName)
fmt.Printf("%s\n", strings.Join(sshCmd, " "))
}
// Execute adds all child commands to the root command and sets flags appropriately.
@@ -105,3 +143,8 @@ func Execute() {
os.Exit(1)
}
}
func init() {
// Add the config file flag
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)")
}

244
cmd/search.go Normal file
View File

@@ -0,0 +1,244 @@
package cmd
import (
"fmt"
"os"
"strings"
"sshm/internal/config"
"github.com/spf13/cobra"
)
var (
// outputFormat defines the output format (table, json, simple)
outputFormat string
// tagsOnly limits search to tags only
tagsOnly bool
// namesOnly limits search to host names only
namesOnly bool
)
var searchCmd = &cobra.Command{
Use: "search [query]",
Short: "Search SSH hosts by name, hostname, or tags",
Long: `Search through your SSH hosts configuration by name, hostname, or tags.
The search is case-insensitive and will match partial strings.
Examples:
sshm search web # Search for hosts containing "web"
sshm search --tags dev # Search only in tags for "dev"
sshm search --names prod # Search only in host names for "prod"
sshm search --format json server # Output results in JSON format`,
Args: cobra.MaximumNArgs(1),
Run: runSearch,
}
func runSearch(cmd *cobra.Command, args []string) {
// Parse SSH configurations
var hosts []config.SSHHost
var err error
if configFile != "" {
hosts, err = config.ParseSSHConfigFile(configFile)
} else {
hosts, err = config.ParseSSHConfig()
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading SSH config file: %v\n", err)
os.Exit(1)
}
if len(hosts) == 0 {
fmt.Println("No SSH hosts found in your configuration file.")
os.Exit(1)
}
// Get search query
var query string
if len(args) > 0 {
query = args[0]
}
// Filter hosts based on search criteria
filteredHosts := filterHosts(hosts, query, tagsOnly, namesOnly)
// Display results
if len(filteredHosts) == 0 {
if query == "" {
fmt.Println("No hosts found.")
} else {
fmt.Printf("No hosts found matching '%s'.\n", query)
}
return
}
// Output results in specified format
switch outputFormat {
case "json":
outputJSON(filteredHosts)
case "simple":
outputSimple(filteredHosts)
default:
outputTable(filteredHosts)
}
}
// filterHosts filters hosts according to the search query and options
func filterHosts(hosts []config.SSHHost, query string, tagsOnly, namesOnly bool) []config.SSHHost {
var filtered []config.SSHHost
if query == "" {
return hosts
}
query = strings.ToLower(query)
for _, host := range hosts {
matched := false
// Search in names if not tags-only
if !tagsOnly {
// Check the host name
if strings.Contains(strings.ToLower(host.Name), query) {
matched = true
}
// Check the hostname if not names-only
if !namesOnly && !matched && strings.Contains(strings.ToLower(host.Hostname), query) {
matched = true
}
}
// Search in tags if not names-only
if !namesOnly && !matched {
for _, tag := range host.Tags {
if strings.Contains(strings.ToLower(tag), query) {
matched = true
break
}
}
}
if matched {
filtered = append(filtered, host)
}
}
return filtered
}
// outputTable displays results in a formatted table
func outputTable(hosts []config.SSHHost) {
if len(hosts) == 0 {
return
}
// Calculate column widths
nameWidth := 4 // "Name"
hostWidth := 8 // "Hostname"
userWidth := 4 // "User"
tagsWidth := 4 // "Tags"
for _, host := range hosts {
if len(host.Name) > nameWidth {
nameWidth = len(host.Name)
}
if len(host.Hostname) > hostWidth {
hostWidth = len(host.Hostname)
}
if len(host.User) > userWidth {
userWidth = len(host.User)
}
tagsStr := strings.Join(host.Tags, ", ")
if len(tagsStr) > tagsWidth {
tagsWidth = len(tagsStr)
}
}
// Add padding
nameWidth += 2
hostWidth += 2
userWidth += 2
tagsWidth += 2
// Print header
fmt.Printf("%-*s %-*s %-*s %-*s\n", nameWidth, "Name", hostWidth, "Hostname", userWidth, "User", tagsWidth, "Tags")
fmt.Printf("%s %s %s %s\n",
strings.Repeat("-", nameWidth),
strings.Repeat("-", hostWidth),
strings.Repeat("-", userWidth),
strings.Repeat("-", tagsWidth))
// Print hosts
for _, host := range hosts {
user := host.User
if user == "" {
user = "-"
}
tags := strings.Join(host.Tags, ", ")
if tags == "" {
tags = "-"
}
fmt.Printf("%-*s %-*s %-*s %-*s\n", nameWidth, host.Name, hostWidth, host.Hostname, userWidth, user, tagsWidth, tags)
}
fmt.Printf("\nFound %d host(s)\n", len(hosts))
}
// outputSimple displays results in simple format (one per line)
func outputSimple(hosts []config.SSHHost) {
for _, host := range hosts {
fmt.Println(host.Name)
}
}
// outputJSON displays results in JSON format
func outputJSON(hosts []config.SSHHost) {
fmt.Println("[")
for i, host := range hosts {
fmt.Printf(" {\n")
fmt.Printf(" \"name\": \"%s\",\n", escapeJSON(host.Name))
fmt.Printf(" \"hostname\": \"%s\",\n", escapeJSON(host.Hostname))
fmt.Printf(" \"user\": \"%s\",\n", escapeJSON(host.User))
fmt.Printf(" \"port\": \"%s\",\n", escapeJSON(host.Port))
fmt.Printf(" \"identity\": \"%s\",\n", escapeJSON(host.Identity))
fmt.Printf(" \"proxy_jump\": \"%s\",\n", escapeJSON(host.ProxyJump))
fmt.Printf(" \"options\": \"%s\",\n", escapeJSON(host.Options))
fmt.Printf(" \"tags\": [")
for j, tag := range host.Tags {
fmt.Printf("\"%s\"", escapeJSON(tag))
if j < len(host.Tags)-1 {
fmt.Printf(", ")
}
}
fmt.Printf("]\n")
if i < len(hosts)-1 {
fmt.Printf(" },\n")
} else {
fmt.Printf(" }\n")
}
}
fmt.Println("]")
}
// escapeJSON escapes special characters for JSON output
func escapeJSON(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "\"", "\\\"")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "\\r")
s = strings.ReplaceAll(s, "\t", "\\t")
return s
}
func init() {
// Add search command to root
rootCmd.AddCommand(searchCmd)
// Add flags
searchCmd.Flags().StringVarP(&outputFormat, "format", "f", "table", "Output format (table, json, simple)")
searchCmd.Flags().BoolVar(&tagsOnly, "tags", false, "Search only in tags")
searchCmd.Flags().BoolVar(&namesOnly, "names", false, "Search only in host names")
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 615 KiB

After

Width:  |  Height:  |  Size: 797 KiB

View File

@@ -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.
## Windows Installation
### Quick Install (Recommended)
```powershell
irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex
```
### 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:**
```bash
FORCE_INSTALL=true bash -c "$(curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh)"

135
install/windows.ps1 Normal file
View 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
}

View 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)
}

View 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
}

View File

@@ -6,6 +6,7 @@ import (
"io"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
)
@@ -22,6 +23,46 @@ type SSHHost struct {
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
var configMutex sync.Mutex
@@ -46,12 +87,10 @@ func backupConfig(configPath string) error {
// ParseSSHConfig parses the SSH config file and returns the list of hosts
func ParseSSHConfig() ([]SSHHost, error) {
homeDir, err := os.UserHomeDir()
configPath, err := GetDefaultSSHConfigPath()
if err != nil {
return nil, err
}
configPath := filepath.Join(homeDir, ".ssh", "config")
return ParseSSHConfigFile(configPath)
}
@@ -59,18 +98,22 @@ func ParseSSHConfig() ([]SSHHost, error) {
func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
// Check if the file exists, otherwise create it (and the parent directory if needed)
if _, err := os.Stat(configPath); os.IsNotExist(err) {
dir := filepath.Dir(configPath)
if _, err := os.Stat(dir); os.IsNotExist(err) {
err = os.MkdirAll(dir, 0700)
if err != nil {
return nil, fmt.Errorf("failed to create .ssh directory: %w", err)
}
// Ensure .ssh directory exists with proper permissions
if err := ensureSSHDirectory(); err != nil {
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 created, return empty host list
return []SSHHost{}, nil
}
@@ -181,15 +224,17 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
// AddSSHHost adds a new SSH host to the config file
func AddSSHHost(host SSHHost) error {
configMutex.Lock()
defer configMutex.Unlock()
homeDir, err := os.UserHomeDir()
configPath, err := GetDefaultSSHConfigPath()
if err != nil {
return err
}
return AddSSHHostToFile(host, configPath)
}
configPath := filepath.Join(homeDir, ".ssh", "config")
// AddSSHHostToFile adds a new SSH host to a specific config file
func AddSSHHostToFile(host SSHHost, configPath string) error {
configMutex.Lock()
defer configMutex.Unlock()
// Create backup before modification if file exists
if _, err := os.Stat(configPath); err == nil {
@@ -198,8 +243,8 @@ func AddSSHHost(host SSHHost) error {
}
}
// Check if host already exists
exists, err := HostExists(host.Name)
// Check if host already exists in the specified config file
exists, err := HostExistsInFile(host.Name, configPath)
if err != nil {
return err
}
@@ -354,6 +399,21 @@ func HostExists(hostName string) (bool, error) {
return false, nil
}
// HostExistsInFile checks if a host exists in a specific config file
func HostExistsInFile(hostName string, configPath string) (bool, error) {
hosts, err := ParseSSHConfigFile(configPath)
if err != nil {
return false, err
}
for _, host := range hosts {
if host.Name == hostName {
return true, nil
}
}
return false, nil
}
// GetSSHHost retrieves a specific host configuration by name
func GetSSHHost(hostName string) (*SSHHost, error) {
hosts, err := ParseSSHConfig()
@@ -369,17 +429,34 @@ func GetSSHHost(hostName string) (*SSHHost, error) {
return nil, fmt.Errorf("host '%s' not found", hostName)
}
// GetSSHHostFromFile retrieves a specific host configuration by name from a specific config file
func GetSSHHostFromFile(hostName string, configPath string) (*SSHHost, error) {
hosts, err := ParseSSHConfigFile(configPath)
if err != nil {
return nil, err
}
for _, host := range hosts {
if host.Name == hostName {
return &host, nil
}
}
return nil, fmt.Errorf("host '%s' not found", hostName)
}
// UpdateSSHHost updates an existing SSH host configuration
func UpdateSSHHost(oldName string, newHost SSHHost) error {
configMutex.Lock()
defer configMutex.Unlock()
homeDir, err := os.UserHomeDir()
configPath, err := GetDefaultSSHConfigPath()
if err != nil {
return err
}
return UpdateSSHHostInFile(oldName, newHost, configPath)
}
configPath := filepath.Join(homeDir, ".ssh", "config")
// UpdateSSHHostInFile updates an existing SSH host configuration in a specific file
func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) error {
configMutex.Lock()
defer configMutex.Unlock()
// Create backup before modification
if err := backupConfig(configPath); err != nil {
@@ -528,15 +605,17 @@ func UpdateSSHHost(oldName string, newHost SSHHost) error {
// DeleteSSHHost removes an SSH host configuration from the config file
func DeleteSSHHost(hostName string) error {
configMutex.Lock()
defer configMutex.Unlock()
homeDir, err := os.UserHomeDir()
configPath, err := GetDefaultSSHConfigPath()
if err != nil {
return err
}
return DeleteSSHHostFromFile(hostName, configPath)
}
configPath := filepath.Join(homeDir, ".ssh", "config")
// DeleteSSHHostFromFile deletes an SSH host from a specific config file
func DeleteSSHHostFromFile(hostName, configPath string) error {
configMutex.Lock()
defer configMutex.Unlock()
// Create backup before modification
if err := backupConfig(configPath); err != nil {

View File

@@ -0,0 +1,73 @@
package config
import (
"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)
}
}

View File

@@ -13,17 +13,18 @@ import (
)
type addFormModel struct {
inputs []textinput.Model
focused int
err string
styles Styles
success bool
width int
height int
inputs []textinput.Model
focused int
err string
styles Styles
success bool
width int
height int
configFile string
}
// NewAddForm creates a new add form model
func NewAddForm(hostname string, styles Styles, width, height int) *addFormModel {
func NewAddForm(hostname string, styles Styles, width, height int, configFile string) *addFormModel {
// Get current user for default
currentUser, _ := user.Current()
defaultUser := "root"
@@ -100,11 +101,12 @@ func NewAddForm(hostname string, styles Styles, width, height int) *addFormModel
inputs[tagsInput].Width = 50
return &addFormModel{
inputs: inputs,
focused: nameInput,
styles: styles,
width: width,
height: height,
inputs: inputs,
focused: nameInput,
styles: styles,
width: width,
height: height,
configFile: configFile,
}
}
@@ -268,9 +270,9 @@ func (m standaloneAddForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// RunAddForm provides backward compatibility for standalone add form
func RunAddForm(hostname string) error {
func RunAddForm(hostname string, configFile string) error {
styles := NewStyles(80)
addForm := NewAddForm(hostname, styles, 80, 24)
addForm := NewAddForm(hostname, styles, 80, 24, configFile)
m := standaloneAddForm{addForm}
p := tea.NewProgram(m, tea.WithAltScreen())
@@ -327,7 +329,12 @@ func (m *addFormModel) submitForm() tea.Cmd {
}
// Add to config
err := config.AddSSHHost(host)
var err error
if m.configFile != "" {
err = config.AddSSHHostToFile(host, m.configFile)
} else {
err = config.AddSSHHost(host)
}
return addFormSubmitMsg{hostname: name, err: err}
}
}

View File

@@ -18,12 +18,21 @@ type editFormModel struct {
originalName string
width int
height int
configFile string
}
// NewEditForm creates a new edit form model
func NewEditForm(hostName string, styles Styles, width, height int) (*editFormModel, error) {
func NewEditForm(hostName string, styles Styles, width, height int, configFile string) (*editFormModel, error) {
// Get the existing host configuration
host, err := config.GetSSHHost(hostName)
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
}
@@ -93,6 +102,7 @@ func NewEditForm(hostName string, styles Styles, width, height int) (*editFormMo
inputs: inputs,
focused: nameInput,
originalName: hostName,
configFile: configFile,
styles: styles,
width: width,
height: height,
@@ -248,9 +258,9 @@ func (m standaloneEditForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// RunEditForm provides backward compatibility for standalone edit form
func RunEditForm(hostName string) error {
func RunEditForm(hostName string, configFile string) error {
styles := NewStyles(80)
editForm, err := NewEditForm(hostName, styles, 80, 24)
editForm, err := NewEditForm(hostName, styles, 80, 24, configFile)
if err != nil {
return err
}
@@ -308,7 +318,12 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
}
// Update the configuration
err := config.UpdateSSHHost(m.originalName, host)
var err error
if m.configFile != "" {
err = config.UpdateSSHHostInFile(m.originalName, host, m.configFile)
} else {
err = config.UpdateSSHHost(m.originalName, host)
}
return editFormSubmitMsg{hostname: name, err: err}
}
}

View File

@@ -35,8 +35,31 @@ const (
ViewList ViewMode = iota
ViewAdd
ViewEdit
ViewPortForward
)
// 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
type Model struct {
table table.Model
@@ -48,11 +71,13 @@ type Model struct {
deleteHost string
historyManager *history.HistoryManager
sortMode SortMode
configFile string // Path to the SSH config file
// View management
viewMode ViewMode
addForm *addFormModel
editForm *editFormModel
viewMode ViewMode
addForm *addFormModel
editForm *editFormModel
portForwardForm *portForwardModel
// Terminal size and styles
width int

View 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
}

View File

@@ -36,9 +36,12 @@ type Styles struct {
Error lipgloss.Style
// Form styles (for add/edit forms)
FormTitle lipgloss.Style
FormField lipgloss.Style
FormHelp lipgloss.Style
FormTitle lipgloss.Style
FormField lipgloss.Style
FormHelp lipgloss.Style
FormContainer lipgloss.Style
Label lipgloss.Style
FocusedLabel lipgloss.Style
}
// NewStyles creates a new Styles struct with the given terminal width
@@ -105,6 +108,18 @@ func NewStyles(width int) Styles {
FormHelp: lipgloss.NewStyle().
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)).
Bold(true),
}
}

View File

@@ -130,4 +130,119 @@ func (m *Model) updateTableRows() {
}
m.table.SetRows(rows)
// Update table height and columns based on current terminal size
m.updateTableHeight()
m.updateTableColumns()
}
// updateTableHeight dynamically adjusts table height based on terminal size
func (m *Model) updateTableHeight() {
if !m.ready {
return
}
// Calculate dynamic table height based on terminal size
// Layout breakdown:
// - ASCII title: 5 lines (1 empty + 4 text lines)
// - Search bar: 1 line
// - Sort info: 1 line
// - Help text: 2 lines (multi-line text)
// - App margins/spacing: 2 lines
// Total reserved: 11 lines, mais réduisons à 7 pour forcer plus d'espace
reservedHeight := 7 // Réduction agressive pour tester
availableHeight := m.height - reservedHeight
hostCount := len(m.table.Rows())
// Minimum height should be at least 5 rows for usability
minTableHeight := 6 // 1 header + 5 data rows
maxTableHeight := availableHeight
if maxTableHeight < minTableHeight {
maxTableHeight = minTableHeight
}
tableHeight := 1 // header
dataRowsNeeded := hostCount
maxDataRows := maxTableHeight - 1 // subtract 1 for header
if dataRowsNeeded <= maxDataRows {
// We have enough space for all hosts
tableHeight += dataRowsNeeded
} else {
// We need to limit to available space
tableHeight += maxDataRows
}
// FORCE: Ajoutons une ligne supplémentaire pour résoudre le problème
tableHeight += 1
// Update table height
m.table.SetHeight(tableHeight)
}
// updateTableColumns dynamically adjusts table column widths based on terminal size
func (m *Model) updateTableColumns() {
if !m.ready {
return
}
hostsToShow := m.filteredHosts
if hostsToShow == nil {
hostsToShow = m.hosts
}
// Calculate base column widths
nameWidth := calculateNameColumnWidth(hostsToShow)
tagsWidth := calculateTagsColumnWidth(hostsToShow)
lastLoginWidth := calculateLastLoginColumnWidth(hostsToShow, m.historyManager)
// Fixed column widths
hostnameWidth := 25
userWidth := 12
portWidth := 6
// Calculate total width needed for all columns
totalFixedWidth := hostnameWidth + userWidth + portWidth
totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth
totalWidth := totalFixedWidth + totalVariableWidth
// Available width (accounting for table borders and padding)
availableWidth := m.width - 4 // 4 chars for borders and padding
// If the table is too wide, scale down the variable columns proportionally
if totalWidth > availableWidth {
excessWidth := totalWidth - availableWidth
variableColumnsWidth := totalVariableWidth
if variableColumnsWidth > 0 {
// Reduce variable columns proportionally
nameReduction := (excessWidth * nameWidth) / variableColumnsWidth
tagsReduction := (excessWidth * tagsWidth) / variableColumnsWidth
lastLoginReduction := excessWidth - nameReduction - tagsReduction
nameWidth = max(8, nameWidth-nameReduction)
tagsWidth = max(8, tagsWidth-tagsReduction)
lastLoginWidth = max(10, lastLoginWidth-lastLoginReduction)
}
}
// Create new columns with updated widths
columns := []table.Column{
{Title: "Name", Width: nameWidth},
{Title: "Hostname", Width: hostnameWidth},
{Title: "User", Width: userWidth},
{Title: "Port", Width: portWidth},
{Title: "Tags", Width: tagsWidth},
{Title: "Last Login", Width: lastLoginWidth},
}
m.table.SetColumns(columns)
}
// max returns the maximum of two integers
func max(a, b int) int {
if a > b {
return a
}
return b
}

View File

@@ -14,7 +14,7 @@ import (
)
// NewModel creates a new TUI model with the given SSH hosts
func NewModel(hosts []config.SSHHost) Model {
func NewModel(hosts []config.SSHHost, configFile string) Model {
// Initialize the history manager
historyManager, err := history.NewHistoryManager()
if err != nil {
@@ -31,6 +31,7 @@ func NewModel(hosts []config.SSHHost) Model {
hosts: hosts,
historyManager: historyManager,
sortMode: SortByName,
configFile: configFile,
styles: styles,
width: 80,
height: 24,
@@ -98,21 +99,12 @@ func NewModel(hosts []config.SSHHost) Model {
})
}
// Determine table height: 1 (header) + number of hosts (max 10)
hostCount := len(rows)
tableHeight := 1 // header
if hostCount < 10 {
tableHeight += hostCount
} else {
tableHeight += 10
}
// Create the table
// Create the table with initial height (will be updated on first WindowSizeMsg)
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(tableHeight),
table.WithHeight(10), // Initial height, will be recalculated dynamically
)
// Style the table
@@ -134,12 +126,15 @@ func NewModel(hosts []config.SSHHost) Model {
// Initialize table styles based on initial focus state
m.updateTableStyles()
// The table height will be properly set on the first WindowSizeMsg
// when m.ready becomes true and actual terminal dimensions are known
return m
}
// RunInteractiveMode starts the interactive TUI interface
func RunInteractiveMode(hosts []config.SSHHost) error {
m := NewModel(hosts)
func RunInteractiveMode(hosts []config.SSHHost, configFile string) error {
m := NewModel(hosts, configFile)
// Start the application in alt screen mode for clean output
p := tea.NewProgram(m, tea.WithAltScreen())

View File

@@ -31,6 +31,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.styles = NewStyles(m.width)
m.ready = true
// Update table height and columns based on new window size
m.updateTableHeight()
m.updateTableColumns()
// Update sub-forms if they exist
if m.addForm != nil {
m.addForm.width = m.width
@@ -42,6 +46,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.editForm.height = m.height
m.editForm.styles = m.styles
}
if m.portForwardForm != nil {
m.portForwardForm.width = m.width
m.portForwardForm.height = m.height
m.portForwardForm.styles = m.styles
}
return m, nil
case addFormSubmitMsg:
@@ -53,12 +62,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
} else {
// Success: refresh hosts and return to list view
hosts, err := config.ParseSSHConfig()
var hosts []config.SSHHost
var err error
if m.configFile != "" {
hosts, err = config.ParseSSHConfigFile(m.configFile)
} else {
hosts, err = config.ParseSSHConfig()
}
if err != nil {
return m, tea.Quit
}
m.hosts = m.sortHosts(hosts)
m.filteredHosts = m.hosts
// Reapply search filter if there is one active
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.hosts
}
m.updateTableRows()
m.viewMode = ViewList
m.addForm = nil
@@ -82,12 +106,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
} else {
// Success: refresh hosts and return to list view
hosts, err := config.ParseSSHConfig()
var hosts []config.SSHHost
var err error
if m.configFile != "" {
hosts, err = config.ParseSSHConfigFile(m.configFile)
} else {
hosts, err = config.ParseSSHConfig()
}
if err != nil {
return m, tea.Quit
}
m.hosts = m.sortHosts(hosts)
m.filteredHosts = m.hosts
// Reapply search filter if there is one active
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.hosts
}
m.updateTableRows()
m.viewMode = ViewList
m.editForm = nil
@@ -102,6 +141,45 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.table.Focus()
return m, nil
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 tea.KeyMsg:
// Handle view-specific key presses
switch m.viewMode {
@@ -119,6 +197,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.editForm = 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 ViewList:
// Handle list view keys
return m.handleListViewKeys(msg)
@@ -183,7 +268,12 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
} else if m.deleteMode {
// Confirm deletion
err := config.DeleteSSHHost(m.deleteHost)
var err error
if m.configFile != "" {
err = config.DeleteSSHHostFromFile(m.deleteHost, m.configFile)
} else {
err = config.DeleteSSHHost(m.deleteHost)
}
if err != nil {
// Could display an error message here
m.deleteMode = false
@@ -192,8 +282,16 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
// Refresh the hosts list
hosts, err := config.ParseSSHConfig()
if err != nil {
var hosts []config.SSHHost
var parseErr error
if m.configFile != "" {
hosts, parseErr = config.ParseSSHConfigFile(m.configFile)
} else {
hosts, parseErr = config.ParseSSHConfig()
}
if parseErr != nil {
// Could display an error message here
m.deleteMode = false
m.deleteHost = ""
@@ -201,7 +299,14 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
m.hosts = m.sortHosts(hosts)
m.filteredHosts = m.hosts
// Reapply search filter if there is one active
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.hosts
}
m.updateTableRows()
m.deleteMode = false
m.deleteHost = ""
@@ -222,7 +327,15 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
return m, tea.ExecProcess(exec.Command("ssh", hostName), func(err error) tea.Msg {
// Build the SSH command with the appropriate config file
var sshCmd *exec.Cmd
if m.configFile != "" {
sshCmd = exec.Command("ssh", "-F", m.configFile, hostName)
} else {
sshCmd = exec.Command("ssh", hostName)
}
return m, tea.ExecProcess(sshCmd, func(err error) tea.Msg {
return tea.Quit()
})
}
@@ -233,7 +346,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
selected := m.table.SelectedRow()
if len(selected) > 0 {
hostName := selected[0] // The hostname is in the first column
editForm, err := NewEditForm(hostName, m.styles, m.width, m.height)
editForm, err := NewEditForm(hostName, m.styles, m.width, m.height, m.configFile)
if err != nil {
// Handle error - could show in UI
return m, nil
@@ -246,7 +359,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "a":
if !m.searchMode && !m.deleteMode {
// Add a new host
m.addForm = NewAddForm("", m.styles, m.width, m.height)
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile)
m.viewMode = ViewAdd
return m, textinput.Blink
}
@@ -262,6 +375,17 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
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 "s":
if !m.searchMode && !m.deleteMode {
// Cycle through sort modes (only 2 modes now)

View File

@@ -23,6 +23,10 @@ func (m Model) View() string {
if m.editForm != nil {
return m.editForm.View()
}
case ViewPortForward:
if m.portForwardForm != nil {
return m.portForwardForm.View()
}
case ViewList:
return m.renderListView()
}
@@ -39,7 +43,7 @@ func (m Model) renderListView() string {
components = append(components, m.styles.Header.Render(asciiTitle))
// Add the search bar with the appropriate style based on focus
searchPrompt := "Search (/ to focus, Tab to switch): "
searchPrompt := "Search (/ to focus): "
if m.searchMode {
components = append(components, m.styles.SearchFocused.Render(searchPrompt+m.searchInput.View()))
} else {
@@ -62,7 +66,7 @@ func (m Model) renderListView() string {
// Add the help text
var helpText string
if !m.searchMode {
helpText = " Use ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • Tab to switch\n Sort: (s)witch • (r)ecent • (n)ame • q/ESC to quit"
helpText = " Use ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • (f)orward • / to search • Tab to switch\n Sort: (s)witch • (r)ecent • (n)ame • q/ESC to quit"
} else {
helpText = " Type to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit"
}