mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2025-09-06 21:00:45 +02:00
Compare commits
26 Commits
Author | SHA1 | Date | |
---|---|---|---|
42387eb1fa | |||
6577002e2b | |||
be3dcaa1cd | |||
b67f5abbbc | |||
b587defabc | |||
22586484c7 | |||
420db56ff5 | |||
|
7600eaaa9b | ||
|
e0dd32993a | ||
1cea3795e4 | |||
2ade315ddc | |||
2deec405f7 | |||
21c5d41977 | |||
20bc506e36 | |||
e8c6e602a2 | |||
b5d8d505cf | |||
3a72694e5a | |||
8f2837db78 | |||
959c084466 | |||
adde6eb666 | |||
94225cbfbe | |||
98aa2b6579 | |||
5dca755b11 | |||
1d50e7cb47 | |||
01f2b4e6be | |||
534b7d9a6c |
426
.github/copilot-instructions.md
vendored
Normal file
426
.github/copilot-instructions.md
vendored
Normal 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.*
|
42
.github/workflows/build.yml
vendored
42
.github/workflows/build.yml
vendored
@ -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,14 +110,25 @@ jobs:
|
||||
run: |
|
||||
mkdir -p release
|
||||
find ./artifacts -name "*.tar.gz" -exec cp {} ./release/ \;
|
||||
find ./artifacts -name "*.zip" -exec cp {} ./release/ \;
|
||||
ls -la ./release/
|
||||
|
||||
- name: Check if pre-release
|
||||
id: check_prerelease
|
||||
run: |
|
||||
if [[ "${GITHUB_REF#refs/tags/}" == *"-beta"* ]] || [[ "${GITHUB_REF#refs/tags/}" == *"-alpha"* ]] || [[ "${GITHUB_REF#refs/tags/}" == *"-rc"* ]]; then
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ./release/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }}
|
||||
generate_release_notes: true
|
||||
name: ${{ github.ref_name }}${{ steps.check_prerelease.outputs.is_prerelease == 'true' && ' (Pre-release)' || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
248
README.md
248
README.md
@ -9,14 +9,18 @@
|
||||
[](https://golang.org/)
|
||||
[](https://github.com/Gu1llaum-3/sshm/releases)
|
||||
[](LICENSE)
|
||||
[](https://github.com/Gu1llaum-3/sshm/releases)
|
||||
[](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,13 @@ 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 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
|
||||
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
|
||||
|
||||
@ -35,6 +42,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 +52,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 +81,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 +105,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 +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`)
|
||||
- **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 +218,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,11 +243,85 @@ 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.
|
||||
|
||||
### 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:**
|
||||
Include ~/.ssh/conf.d/*
|
||||
|
||||
```ssh
|
||||
# Tags: production, web, frontend
|
||||
Host web-prod-01
|
||||
@ -222,24 +421,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 +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) |
|
||||
| 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
|
||||
|
||||
@ -280,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
|
||||
- [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
|
||||
|
||||
---
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
61
cmd/root.go
61
cmd/root.go
@ -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
244
cmd/search.go
Normal 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")
|
||||
}
|
7
go.mod
7
go.mod
@ -28,7 +28,8 @@ require (
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
)
|
||||
|
8
go.sum
8
go.sum
@ -49,15 +49,23 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
BIN
images/sshm.gif
BIN
images/sshm.gif
Binary file not shown.
Before Width: | Height: | Size: 615 KiB After Width: | Height: | Size: 797 KiB |
@ -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)"
|
||||
|
@ -74,10 +74,10 @@ downloadBinary() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find the extracted binary
|
||||
EXTRACTED_BINARY=$(find . -name "sshm-${OS}-${ARCH}" -type f)
|
||||
if [ -z "$EXTRACTED_BINARY" ]; then
|
||||
printf "${RED}Could not find extracted binary${NC}\n"
|
||||
# Check if the expected binary exists (no find needed)
|
||||
EXTRACTED_BINARY="./sshm-${OS}-${ARCH}"
|
||||
if [ ! -f "$EXTRACTED_BINARY" ]; then
|
||||
printf "${RED}Could not find extracted binary: $EXTRACTED_BINARY${NC}\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@ -88,17 +88,37 @@ downloadBinary() {
|
||||
install() {
|
||||
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"
|
||||
if [ $? -ne 0 ]; then
|
||||
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
|
||||
fi
|
||||
|
||||
runAsRoot mv "sshm-tmp" "$EXECUTABLE_PATH"
|
||||
if [ $? -ne 0 ]; then
|
||||
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
|
||||
fi
|
||||
|
||||
# Clean up backup if installation succeeded
|
||||
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
|
||||
runAsRoot rm -f "$OLD_BACKUP"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
@ -161,7 +181,8 @@ main() {
|
||||
# Show version
|
||||
printf "${YELLOW}Verifying installation...${NC}\n"
|
||||
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
|
||||
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
|
||||
|
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,20 +6,62 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SSHHost represents an SSH host configuration
|
||||
type SSHHost struct {
|
||||
Name string
|
||||
Hostname string
|
||||
User string
|
||||
Port string
|
||||
Identity string
|
||||
ProxyJump string
|
||||
Options string
|
||||
Tags []string
|
||||
Name string
|
||||
Hostname string
|
||||
User string
|
||||
Port string
|
||||
Identity string
|
||||
ProxyJump string
|
||||
Options string
|
||||
Tags []string
|
||||
SourceFile string // Path to the config file where this host is defined
|
||||
}
|
||||
|
||||
// GetDefaultSSHConfigPath returns the default SSH config path for the current platform
|
||||
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
|
||||
@ -46,32 +88,54 @@ 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)
|
||||
}
|
||||
|
||||
// ParseSSHConfigFile parses a specific SSH config file and returns the list of hosts
|
||||
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)
|
||||
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 {
|
||||
// Only create the main config file, not included files
|
||||
if absPath == getMainConfigPath() {
|
||||
// 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, 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()
|
||||
// File created, return empty host list
|
||||
|
||||
// File doesn't exist, return empty host list
|
||||
return []SSHHost{}, nil
|
||||
}
|
||||
|
||||
@ -125,16 +189,31 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
||||
value := strings.Join(parts[1:], " ")
|
||||
|
||||
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":
|
||||
// New host, save previous one if it exists
|
||||
if currentHost != nil {
|
||||
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
|
||||
currentHost = &SSHHost{
|
||||
Name: value,
|
||||
Port: "22", // Default port
|
||||
Tags: pendingTags, // Assign pending tags to this host
|
||||
Name: value,
|
||||
Port: "22", // Default port
|
||||
Tags: pendingTags, // Assign pending tags to this host
|
||||
SourceFile: absPath, // Track which file this host comes from
|
||||
}
|
||||
// Clear pending tags for next host
|
||||
pendingTags = nil
|
||||
@ -179,17 +258,78 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
||||
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
|
||||
}
|
||||
|
||||
// Skip backup files created by sshm (*.backup)
|
||||
if strings.HasSuffix(match, ".backup") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip markdown files (*.md)
|
||||
if strings.HasSuffix(match, ".md") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Recursively parse the included file
|
||||
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
|
||||
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 +338,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 +494,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 +524,30 @@ func GetSSHHost(hostName string) (*SSHHost, error) {
|
||||
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()
|
||||
// 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 err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
||||
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 {
|
||||
return UpdateSSHHostV2(oldName, newHost)
|
||||
}
|
||||
|
||||
// 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,16 +696,14 @@ func UpdateSSHHost(oldName string, newHost SSHHost) error {
|
||||
|
||||
// DeleteSSHHost removes an SSH host configuration from the config file
|
||||
func DeleteSSHHost(hostName string) error {
|
||||
return DeleteSSHHostV2(hostName)
|
||||
}
|
||||
|
||||
// DeleteSSHHostFromFile deletes an SSH host from a specific config file
|
||||
func DeleteSSHHostFromFile(hostName, configPath string) error {
|
||||
configMutex.Lock()
|
||||
defer configMutex.Unlock()
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
||||
|
||||
// Create backup before modification
|
||||
if err := backupConfig(configPath); err != nil {
|
||||
return fmt.Errorf("failed to create backup: %w", err)
|
||||
@ -614,3 +780,115 @@ func DeleteSSHHost(hostName string) error {
|
||||
newContent := strings.Join(newLines, "\n")
|
||||
return os.WriteFile(configPath, []byte(newContent), 0600)
|
||||
}
|
||||
|
||||
// FindHostInAllConfigs finds a host in all configuration files and returns the host with its source file
|
||||
func FindHostInAllConfigs(hostName string) (*SSHHost, error) {
|
||||
hosts, err := ParseSSHConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
if host.Name == hostName {
|
||||
return &host, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("host '%s' not found in any configuration file", hostName)
|
||||
}
|
||||
|
||||
// GetAllConfigFiles returns all SSH config files (main + included files)
|
||||
func GetAllConfigFiles() ([]string, error) {
|
||||
configPath, err := GetDefaultSSHConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
processedFiles := make(map[string]bool)
|
||||
_, _ = parseSSHConfigFileWithProcessedFiles(configPath, processedFiles)
|
||||
|
||||
files := make([]string, 0, len(processedFiles))
|
||||
for file := range processedFiles {
|
||||
files = append(files, file)
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// GetAllConfigFilesFromBase returns all SSH config files starting from a specific base config file
|
||||
func GetAllConfigFilesFromBase(baseConfigPath string) ([]string, error) {
|
||||
if baseConfigPath == "" {
|
||||
// Fallback to default behavior
|
||||
return GetAllConfigFiles()
|
||||
}
|
||||
|
||||
processedFiles := make(map[string]bool)
|
||||
_, _ = parseSSHConfigFileWithProcessedFiles(baseConfigPath, processedFiles)
|
||||
|
||||
files := make([]string, 0, len(processedFiles))
|
||||
for file := range processedFiles {
|
||||
files = append(files, file)
|
||||
}
|
||||
|
||||
return files, nil
|
||||
} // UpdateSSHHostV2 updates an existing SSH host configuration, searching in all config files
|
||||
func UpdateSSHHostV2(oldName string, newHost SSHHost) error {
|
||||
// Find the host to determine which file it's in
|
||||
existingHost, err := FindHostInAllConfigs(oldName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the host in its source file
|
||||
newHost.SourceFile = existingHost.SourceFile
|
||||
return UpdateSSHHostInFile(oldName, newHost, existingHost.SourceFile)
|
||||
}
|
||||
|
||||
// DeleteSSHHostV2 removes an SSH host configuration, searching in all config files
|
||||
func DeleteSSHHostV2(hostName string) error {
|
||||
// Find the host to determine which file it's in
|
||||
existingHost, err := FindHostInAllConfigs(hostName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the host from its source file
|
||||
return DeleteSSHHostFromFile(hostName, existingHost.SourceFile)
|
||||
}
|
||||
|
||||
// AddSSHHostWithFileSelection adds a new SSH host to a user-specified config file
|
||||
func AddSSHHostWithFileSelection(host SSHHost, targetFile string) error {
|
||||
if targetFile == "" {
|
||||
// Use default file if none specified
|
||||
return AddSSHHost(host)
|
||||
}
|
||||
return AddSSHHostToFile(host, targetFile)
|
||||
}
|
||||
|
||||
// GetIncludedConfigFiles returns a list of config files that can be used for adding hosts
|
||||
func GetIncludedConfigFiles() ([]string, error) {
|
||||
allFiles, err := GetAllConfigFiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter out files that don't exist or can't be written to
|
||||
var writableFiles []string
|
||||
mainConfig, err := GetDefaultSSHConfigPath()
|
||||
if err == nil {
|
||||
writableFiles = append(writableFiles, mainConfig)
|
||||
}
|
||||
|
||||
for _, file := range allFiles {
|
||||
if file == mainConfig {
|
||||
continue // Already added
|
||||
}
|
||||
|
||||
// Check if file exists and is writable
|
||||
if info, err := os.Stat(file); err == nil && !info.IsDir() {
|
||||
writableFiles = append(writableFiles, file)
|
||||
}
|
||||
}
|
||||
|
||||
return writableFiles, nil
|
||||
}
|
||||
|
699
internal/config/ssh_test.go
Normal file
699
internal/config/ssh_test.go
Normal file
@ -0,0 +1,699 @@
|
||||
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)
|
||||
}
|
||||
if host.SourceFile != mainConfig {
|
||||
t.Errorf("main-host SourceFile incorrect: expected=%s, got=%s", mainConfig, host.SourceFile)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if host.SourceFile != includedConfig {
|
||||
t.Errorf("included-host SourceFile incorrect: expected=%s, got=%s", includedConfig, host.SourceFile)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if host.SourceFile != subConfig {
|
||||
t.Errorf("sub-host SourceFile incorrect: expected=%s, got=%s", subConfig, host.SourceFile)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if host.SourceFile != mainConfig {
|
||||
t.Errorf("another-host SourceFile incorrect: expected=%s, got=%s", mainConfig, host.SourceFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSHConfigExcludesBackupFiles(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create main config file with include pattern
|
||||
mainConfig := filepath.Join(tempDir, "config")
|
||||
mainConfigContent := `Host main-host
|
||||
HostName example.com
|
||||
|
||||
Include *.conf
|
||||
`
|
||||
|
||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main config: %v", err)
|
||||
}
|
||||
|
||||
// Create a regular config file
|
||||
regularConfig := filepath.Join(tempDir, "regular.conf")
|
||||
regularConfigContent := `Host regular-host
|
||||
HostName regular.example.com
|
||||
`
|
||||
|
||||
err = os.WriteFile(regularConfig, []byte(regularConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create regular config: %v", err)
|
||||
}
|
||||
|
||||
// Create a backup file that should be excluded
|
||||
backupConfig := filepath.Join(tempDir, "regular.conf.backup")
|
||||
backupConfigContent := `Host backup-host
|
||||
HostName backup.example.com
|
||||
`
|
||||
|
||||
err = os.WriteFile(backupConfig, []byte(backupConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create backup config: %v", err)
|
||||
}
|
||||
|
||||
// Parse the config file
|
||||
hosts, err := ParseSSHConfigFile(mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||
}
|
||||
|
||||
// Should only get main-host and regular-host, not backup-host
|
||||
expectedHosts := map[string]bool{
|
||||
"main-host": false,
|
||||
"regular-host": false,
|
||||
}
|
||||
|
||||
if len(hosts) != len(expectedHosts) {
|
||||
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
|
||||
for _, host := range hosts {
|
||||
t.Logf("Found host: %s", host.Name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
if _, expected := expectedHosts[host.Name]; !expected {
|
||||
t.Errorf("Unexpected host found: %s (backup files should be excluded)", host.Name)
|
||||
} else {
|
||||
expectedHosts[host.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check that backup-host was not included
|
||||
for _, host := range hosts {
|
||||
if host.Name == "backup-host" {
|
||||
t.Error("backup-host should not be included (backup files should be excluded)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindHostInAllConfigs(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create main config file
|
||||
mainConfig := filepath.Join(tempDir, "config")
|
||||
mainConfigContent := `Host main-host
|
||||
HostName example.com
|
||||
|
||||
Include included.conf
|
||||
`
|
||||
|
||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main config: %v", err)
|
||||
}
|
||||
|
||||
// Create included config file
|
||||
includedConfig := filepath.Join(tempDir, "included.conf")
|
||||
includedConfigContent := `Host included-host
|
||||
HostName included.example.com
|
||||
User includeduser
|
||||
`
|
||||
|
||||
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create included config: %v", err)
|
||||
}
|
||||
|
||||
// Test finding host from main config
|
||||
host, err := GetSSHHostFromFile("main-host", mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSSHHostFromFile() error = %v", err)
|
||||
}
|
||||
if host.Name != "main-host" || host.Hostname != "example.com" {
|
||||
t.Errorf("main-host not found correctly: name=%s, hostname=%s", host.Name, host.Hostname)
|
||||
}
|
||||
if host.SourceFile != mainConfig {
|
||||
t.Errorf("main-host SourceFile incorrect: expected=%s, got=%s", mainConfig, host.SourceFile)
|
||||
}
|
||||
|
||||
// Test finding host from included config
|
||||
// Note: This tests the full parsing with includes
|
||||
hosts, err := ParseSSHConfigFile(mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||
}
|
||||
|
||||
var includedHost *SSHHost
|
||||
for _, h := range hosts {
|
||||
if h.Name == "included-host" {
|
||||
includedHost = &h
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if includedHost == nil {
|
||||
t.Fatal("included-host not found")
|
||||
}
|
||||
if includedHost.Hostname != "included.example.com" || includedHost.User != "includeduser" {
|
||||
t.Errorf("included-host properties incorrect: hostname=%s, user=%s", includedHost.Hostname, includedHost.User)
|
||||
}
|
||||
if includedHost.SourceFile != includedConfig {
|
||||
t.Errorf("included-host SourceFile incorrect: expected=%s, got=%s", includedConfig, includedHost.SourceFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllConfigFiles(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create main config file
|
||||
mainConfig := filepath.Join(tempDir, "config")
|
||||
mainConfigContent := `Host main-host
|
||||
HostName example.com
|
||||
|
||||
Include included.conf
|
||||
Include subdir/*.conf
|
||||
`
|
||||
|
||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main config: %v", err)
|
||||
}
|
||||
|
||||
// Create included config file
|
||||
includedConfig := filepath.Join(tempDir, "included.conf")
|
||||
err = os.WriteFile(includedConfig, []byte("Host included-host\n HostName included.example.com\n"), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create included config: %v", err)
|
||||
}
|
||||
|
||||
// Create subdirectory with config files
|
||||
subDir := filepath.Join(tempDir, "subdir")
|
||||
err = os.MkdirAll(subDir, 0700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create subdir: %v", err)
|
||||
}
|
||||
|
||||
subConfig := filepath.Join(subDir, "sub.conf")
|
||||
err = os.WriteFile(subConfig, []byte("Host sub-host\n HostName sub.example.com\n"), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create sub config: %v", err)
|
||||
}
|
||||
|
||||
// Parse to populate the processed files map
|
||||
_, err = ParseSSHConfigFile(mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||
}
|
||||
|
||||
// Note: GetAllConfigFiles() uses a fresh parse, so we test it indirectly
|
||||
// by checking that all files are found during parsing
|
||||
hosts, err := ParseSSHConfigFile(mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||
}
|
||||
|
||||
// Check that hosts from all files are found
|
||||
sourceFiles := make(map[string]bool)
|
||||
for _, host := range hosts {
|
||||
sourceFiles[host.SourceFile] = true
|
||||
}
|
||||
|
||||
expectedFiles := []string{mainConfig, includedConfig, subConfig}
|
||||
for _, expectedFile := range expectedFiles {
|
||||
if !sourceFiles[expectedFile] {
|
||||
t.Errorf("Expected config file not found in SourceFile: %s", expectedFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllConfigFilesFromBase(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create main config file
|
||||
mainConfig := filepath.Join(tempDir, "config")
|
||||
mainConfigContent := `Host main-host
|
||||
HostName example.com
|
||||
|
||||
Include included.conf
|
||||
`
|
||||
|
||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main config: %v", err)
|
||||
}
|
||||
|
||||
// Create included config file
|
||||
includedConfig := filepath.Join(tempDir, "included.conf")
|
||||
includedConfigContent := `Host included-host
|
||||
HostName included.example.com
|
||||
|
||||
Include subdir/*.conf
|
||||
`
|
||||
|
||||
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create included config: %v", err)
|
||||
}
|
||||
|
||||
// Create subdirectory with config files
|
||||
subDir := filepath.Join(tempDir, "subdir")
|
||||
err = os.MkdirAll(subDir, 0700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create subdir: %v", err)
|
||||
}
|
||||
|
||||
subConfig := filepath.Join(subDir, "sub.conf")
|
||||
err = os.WriteFile(subConfig, []byte("Host sub-host\n HostName sub.example.com\n"), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create sub config: %v", err)
|
||||
}
|
||||
|
||||
// Create an isolated config file that should not be included
|
||||
isolatedConfig := filepath.Join(tempDir, "isolated.conf")
|
||||
err = os.WriteFile(isolatedConfig, []byte("Host isolated-host\n HostName isolated.example.com\n"), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create isolated config: %v", err)
|
||||
}
|
||||
|
||||
// Test GetAllConfigFilesFromBase with main config as base
|
||||
files, err := GetAllConfigFilesFromBase(mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllConfigFilesFromBase() error = %v", err)
|
||||
}
|
||||
|
||||
// Should find main config, included config, and sub config, but not isolated config
|
||||
expectedFiles := map[string]bool{
|
||||
mainConfig: false,
|
||||
includedConfig: false,
|
||||
subConfig: false,
|
||||
}
|
||||
|
||||
if len(files) != len(expectedFiles) {
|
||||
t.Errorf("Expected %d config files, got %d", len(expectedFiles), len(files))
|
||||
for i, file := range files {
|
||||
t.Logf("Found file %d: %s", i+1, file)
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if _, expected := expectedFiles[file]; expected {
|
||||
expectedFiles[file] = true
|
||||
} else if file == isolatedConfig {
|
||||
t.Errorf("Isolated config file should not be included: %s", file)
|
||||
} else {
|
||||
t.Logf("Unexpected file found: %s", file)
|
||||
}
|
||||
}
|
||||
|
||||
// Check that all expected files were found
|
||||
for file, found := range expectedFiles {
|
||||
if !found {
|
||||
t.Errorf("Expected config file not found: %s", file)
|
||||
}
|
||||
}
|
||||
|
||||
// Test GetAllConfigFilesFromBase with isolated config as base (should only return itself)
|
||||
isolatedFiles, err := GetAllConfigFilesFromBase(isolatedConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllConfigFilesFromBase() error = %v", err)
|
||||
}
|
||||
|
||||
if len(isolatedFiles) != 1 || isolatedFiles[0] != isolatedConfig {
|
||||
t.Errorf("Expected only isolated config file, got: %v", isolatedFiles)
|
||||
}
|
||||
|
||||
// Test with empty base config file path (should fallback to default behavior)
|
||||
defaultFiles, err := GetAllConfigFilesFromBase("")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllConfigFilesFromBase('') error = %v", err)
|
||||
}
|
||||
|
||||
// Should behave like GetAllConfigFiles()
|
||||
allFiles, err := GetAllConfigFiles()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllConfigFiles() error = %v", err)
|
||||
}
|
||||
|
||||
if len(defaultFiles) != len(allFiles) {
|
||||
t.Errorf("GetAllConfigFilesFromBase('') should behave like GetAllConfigFiles(). Got %d vs %d files", len(defaultFiles), len(allFiles))
|
||||
}
|
||||
}
|
212
internal/connectivity/ping.go
Normal file
212
internal/connectivity/ping.go
Normal file
@ -0,0 +1,212 @@
|
||||
package connectivity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sshm/internal/config"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// PingStatus represents the connectivity status of an SSH host
|
||||
type PingStatus int
|
||||
|
||||
const (
|
||||
StatusUnknown PingStatus = iota
|
||||
StatusConnecting
|
||||
StatusOnline
|
||||
StatusOffline
|
||||
)
|
||||
|
||||
func (s PingStatus) String() string {
|
||||
switch s {
|
||||
case StatusUnknown:
|
||||
return "unknown"
|
||||
case StatusConnecting:
|
||||
return "connecting"
|
||||
case StatusOnline:
|
||||
return "online"
|
||||
case StatusOffline:
|
||||
return "offline"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// HostPingResult represents the result of pinging a host
|
||||
type HostPingResult struct {
|
||||
HostName string
|
||||
Status PingStatus
|
||||
Error error
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// PingManager manages SSH connectivity checks for multiple hosts
|
||||
type PingManager struct {
|
||||
results map[string]*HostPingResult
|
||||
mutex sync.RWMutex
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewPingManager creates a new ping manager with the specified timeout
|
||||
func NewPingManager(timeout time.Duration) *PingManager {
|
||||
return &PingManager{
|
||||
results: make(map[string]*HostPingResult),
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatus returns the current status for a host
|
||||
func (pm *PingManager) GetStatus(hostName string) PingStatus {
|
||||
pm.mutex.RLock()
|
||||
defer pm.mutex.RUnlock()
|
||||
|
||||
if result, exists := pm.results[hostName]; exists {
|
||||
return result.Status
|
||||
}
|
||||
return StatusUnknown
|
||||
}
|
||||
|
||||
// GetResult returns the complete result for a host
|
||||
func (pm *PingManager) GetResult(hostName string) (*HostPingResult, bool) {
|
||||
pm.mutex.RLock()
|
||||
defer pm.mutex.RUnlock()
|
||||
|
||||
result, exists := pm.results[hostName]
|
||||
return result, exists
|
||||
}
|
||||
|
||||
// updateStatus updates the status for a host
|
||||
func (pm *PingManager) updateStatus(hostName string, status PingStatus, err error, duration time.Duration) {
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
|
||||
pm.results[hostName] = &HostPingResult{
|
||||
HostName: hostName,
|
||||
Status: status,
|
||||
Error: err,
|
||||
Duration: duration,
|
||||
}
|
||||
}
|
||||
|
||||
// PingHost performs an SSH connectivity check for a single host
|
||||
func (pm *PingManager) PingHost(ctx context.Context, host config.SSHHost) *HostPingResult {
|
||||
start := time.Now()
|
||||
|
||||
// Mark as connecting
|
||||
pm.updateStatus(host.Name, StatusConnecting, nil, 0)
|
||||
|
||||
// Determine the actual hostname and port
|
||||
hostname := host.Hostname
|
||||
if hostname == "" {
|
||||
hostname = host.Name
|
||||
}
|
||||
|
||||
port := host.Port
|
||||
if port == "" {
|
||||
port = "22"
|
||||
}
|
||||
|
||||
// Create context with timeout
|
||||
pingCtx, cancel := context.WithTimeout(ctx, pm.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Try to establish a TCP connection first (faster than SSH handshake)
|
||||
dialer := &net.Dialer{}
|
||||
conn, err := dialer.DialContext(pingCtx, "tcp", net.JoinHostPort(hostname, port))
|
||||
if err != nil {
|
||||
duration := time.Since(start)
|
||||
pm.updateStatus(host.Name, StatusOffline, err, duration)
|
||||
return &HostPingResult{
|
||||
HostName: host.Name,
|
||||
Status: StatusOffline,
|
||||
Error: err,
|
||||
Duration: duration,
|
||||
}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// If TCP connection succeeds, try SSH handshake
|
||||
sshConfig := &ssh.ClientConfig{
|
||||
User: host.User,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // For ping purposes only
|
||||
Timeout: time.Second * 2, // Short timeout for handshake
|
||||
}
|
||||
|
||||
// We don't need to authenticate, just check if SSH is responding
|
||||
sshConn, _, _, err := ssh.NewClientConn(conn, net.JoinHostPort(hostname, port), sshConfig)
|
||||
if sshConn != nil {
|
||||
sshConn.Close()
|
||||
}
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
// Even if SSH handshake fails, if we got a TCP connection, consider it online
|
||||
// This handles cases where authentication fails but the host is reachable
|
||||
status := StatusOnline
|
||||
if err != nil && isConnectionError(err) {
|
||||
status = StatusOffline
|
||||
}
|
||||
|
||||
pm.updateStatus(host.Name, status, err, duration)
|
||||
return &HostPingResult{
|
||||
HostName: host.Name,
|
||||
Status: status,
|
||||
Error: err,
|
||||
Duration: duration,
|
||||
}
|
||||
}
|
||||
|
||||
// PingAllHosts pings all hosts concurrently and returns a channel of results
|
||||
func (pm *PingManager) PingAllHosts(ctx context.Context, hosts []config.SSHHost) <-chan *HostPingResult {
|
||||
resultChan := make(chan *HostPingResult, len(hosts))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, host := range hosts {
|
||||
wg.Add(1)
|
||||
go func(h config.SSHHost) {
|
||||
defer wg.Done()
|
||||
result := pm.PingHost(ctx, h)
|
||||
select {
|
||||
case resultChan <- result:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}(host)
|
||||
}
|
||||
|
||||
// Close the channel when all goroutines are done
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resultChan)
|
||||
}()
|
||||
|
||||
return resultChan
|
||||
}
|
||||
|
||||
// isConnectionError determines if an error is a connection-related error
|
||||
func isConnectionError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
connectionErrors := []string{
|
||||
"connection refused",
|
||||
"no route to host",
|
||||
"network is unreachable",
|
||||
"timeout",
|
||||
"connection timed out",
|
||||
}
|
||||
|
||||
for _, connErr := range connectionErrors {
|
||||
if strings.Contains(strings.ToLower(errStr), connErr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
208
internal/history/history.go
Normal file
208
internal/history/history.go
Normal file
@ -0,0 +1,208 @@
|
||||
package history
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"sshm/internal/config"
|
||||
)
|
||||
|
||||
// ConnectionHistory represents the history of SSH connections
|
||||
type ConnectionHistory struct {
|
||||
Connections map[string]ConnectionInfo `json:"connections"`
|
||||
}
|
||||
|
||||
// ConnectionInfo stores information about a specific connection
|
||||
type ConnectionInfo struct {
|
||||
HostName string `json:"host_name"`
|
||||
LastConnect time.Time `json:"last_connect"`
|
||||
ConnectCount int `json:"connect_count"`
|
||||
}
|
||||
|
||||
// HistoryManager manages the connection history
|
||||
type HistoryManager struct {
|
||||
historyPath string
|
||||
history *ConnectionHistory
|
||||
}
|
||||
|
||||
// NewHistoryManager creates a new history manager
|
||||
func NewHistoryManager() (*HistoryManager, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
historyPath := filepath.Join(homeDir, ".ssh", "sshm_history.json")
|
||||
|
||||
hm := &HistoryManager{
|
||||
historyPath: historyPath,
|
||||
history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)},
|
||||
}
|
||||
|
||||
// Load existing history if it exists
|
||||
err = hm.loadHistory()
|
||||
if err != nil {
|
||||
// If file doesn't exist, that's okay - we'll create it when needed
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return hm, nil
|
||||
}
|
||||
|
||||
// loadHistory loads the connection history from the JSON file
|
||||
func (hm *HistoryManager) loadHistory() error {
|
||||
data, err := os.ReadFile(hm.historyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, hm.history)
|
||||
}
|
||||
|
||||
// saveHistory saves the connection history to the JSON file
|
||||
func (hm *HistoryManager) saveHistory() error {
|
||||
// Ensure the directory exists
|
||||
dir := filepath.Dir(hm.historyPath)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(hm.history, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(hm.historyPath, data, 0600)
|
||||
}
|
||||
|
||||
// RecordConnection records a new connection for the specified host
|
||||
func (hm *HistoryManager) RecordConnection(hostName string) error {
|
||||
now := time.Now()
|
||||
|
||||
if conn, exists := hm.history.Connections[hostName]; exists {
|
||||
// Update existing connection
|
||||
conn.LastConnect = now
|
||||
conn.ConnectCount++
|
||||
hm.history.Connections[hostName] = conn
|
||||
} else {
|
||||
// Create new connection record
|
||||
hm.history.Connections[hostName] = ConnectionInfo{
|
||||
HostName: hostName,
|
||||
LastConnect: now,
|
||||
ConnectCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
return hm.saveHistory()
|
||||
}
|
||||
|
||||
// GetLastConnectionTime returns the last connection time for a host
|
||||
func (hm *HistoryManager) GetLastConnectionTime(hostName string) (time.Time, bool) {
|
||||
if conn, exists := hm.history.Connections[hostName]; exists {
|
||||
return conn.LastConnect, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// GetConnectionCount returns the total number of connections for a host
|
||||
func (hm *HistoryManager) GetConnectionCount(hostName string) int {
|
||||
if conn, exists := hm.history.Connections[hostName]; exists {
|
||||
return conn.ConnectCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// SortHostsByLastUsed sorts hosts by their last connection time (most recent first)
|
||||
func (hm *HistoryManager) SortHostsByLastUsed(hosts []config.SSHHost) []config.SSHHost {
|
||||
sorted := make([]config.SSHHost, len(hosts))
|
||||
copy(sorted, hosts)
|
||||
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
timeI, existsI := hm.GetLastConnectionTime(sorted[i].Name)
|
||||
timeJ, existsJ := hm.GetLastConnectionTime(sorted[j].Name)
|
||||
|
||||
// If both have history, sort by most recent first
|
||||
if existsI && existsJ {
|
||||
return timeI.After(timeJ)
|
||||
}
|
||||
|
||||
// Hosts with history come before hosts without history
|
||||
if existsI && !existsJ {
|
||||
return true
|
||||
}
|
||||
if !existsI && existsJ {
|
||||
return false
|
||||
}
|
||||
|
||||
// If neither has history, sort alphabetically
|
||||
return sorted[i].Name < sorted[j].Name
|
||||
})
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
// SortHostsByMostUsed sorts hosts by their connection count (most used first)
|
||||
func (hm *HistoryManager) SortHostsByMostUsed(hosts []config.SSHHost) []config.SSHHost {
|
||||
sorted := make([]config.SSHHost, len(hosts))
|
||||
copy(sorted, hosts)
|
||||
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
countI := hm.GetConnectionCount(sorted[i].Name)
|
||||
countJ := hm.GetConnectionCount(sorted[j].Name)
|
||||
|
||||
// If counts are different, sort by count (highest first)
|
||||
if countI != countJ {
|
||||
return countI > countJ
|
||||
}
|
||||
|
||||
// If counts are equal, sort by most recent
|
||||
timeI, existsI := hm.GetLastConnectionTime(sorted[i].Name)
|
||||
timeJ, existsJ := hm.GetLastConnectionTime(sorted[j].Name)
|
||||
|
||||
if existsI && existsJ {
|
||||
return timeI.After(timeJ)
|
||||
}
|
||||
|
||||
// If neither has history, sort alphabetically
|
||||
return sorted[i].Name < sorted[j].Name
|
||||
})
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
// CleanupOldEntries removes connection history for hosts that no longer exist
|
||||
func (hm *HistoryManager) CleanupOldEntries(currentHosts []config.SSHHost) error {
|
||||
// Create a set of current host names
|
||||
currentHostNames := make(map[string]bool)
|
||||
for _, host := range currentHosts {
|
||||
currentHostNames[host.Name] = true
|
||||
}
|
||||
|
||||
// Remove entries for hosts that no longer exist
|
||||
for hostName := range hm.history.Connections {
|
||||
if !currentHostNames[hostName] {
|
||||
delete(hm.history.Connections, hostName)
|
||||
}
|
||||
}
|
||||
|
||||
return hm.saveHistory()
|
||||
}
|
||||
|
||||
// GetAllConnectionsInfo returns all connection information sorted by last connection time
|
||||
func (hm *HistoryManager) GetAllConnectionsInfo() []ConnectionInfo {
|
||||
var connections []ConnectionInfo
|
||||
for _, conn := range hm.history.Connections {
|
||||
connections = append(connections, conn)
|
||||
}
|
||||
|
||||
sort.Slice(connections, func(i, j int) bool {
|
||||
return connections[i].LastConnect.After(connections[j].LastConnect)
|
||||
})
|
||||
|
||||
return connections
|
||||
}
|
@ -10,44 +10,21 @@ import (
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFDF5")).
|
||||
Background(lipgloss.Color("#25A065")).
|
||||
Padding(0, 1)
|
||||
|
||||
fieldStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#04B575"))
|
||||
|
||||
errorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FF0000"))
|
||||
|
||||
helpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#626262"))
|
||||
)
|
||||
|
||||
type addFormModel struct {
|
||||
inputs []textinput.Model
|
||||
focused int
|
||||
err string
|
||||
success bool
|
||||
inputs []textinput.Model
|
||||
focused int
|
||||
err string
|
||||
styles Styles
|
||||
success bool
|
||||
width int
|
||||
height int
|
||||
configFile string
|
||||
}
|
||||
|
||||
const (
|
||||
nameInput = iota
|
||||
hostnameInput
|
||||
userInput
|
||||
portInput
|
||||
identityInput
|
||||
proxyJumpInput
|
||||
optionsInput
|
||||
tagsInput
|
||||
)
|
||||
|
||||
func RunAddForm(hostname string) error {
|
||||
// NewAddForm creates a new add form model
|
||||
func NewAddForm(hostname string, styles Styles, width, height int, configFile string) *addFormModel {
|
||||
// Get current user for default
|
||||
currentUser, _ := user.Current()
|
||||
defaultUser := "root"
|
||||
@ -123,28 +100,53 @@ func RunAddForm(hostname string) error {
|
||||
inputs[tagsInput].CharLimit = 200
|
||||
inputs[tagsInput].Width = 50
|
||||
|
||||
m := addFormModel{
|
||||
inputs: inputs,
|
||||
focused: nameInput,
|
||||
return &addFormModel{
|
||||
inputs: inputs,
|
||||
focused: nameInput,
|
||||
styles: styles,
|
||||
width: width,
|
||||
height: height,
|
||||
configFile: configFile,
|
||||
}
|
||||
|
||||
p := tea.NewProgram(&m, tea.WithAltScreen())
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
|
||||
const (
|
||||
nameInput = iota
|
||||
hostnameInput
|
||||
userInput
|
||||
portInput
|
||||
identityInput
|
||||
proxyJumpInput
|
||||
optionsInput
|
||||
tagsInput
|
||||
)
|
||||
|
||||
// Messages for communication with parent model
|
||||
type addFormSubmitMsg struct {
|
||||
hostname string
|
||||
err error
|
||||
}
|
||||
|
||||
type addFormCancelMsg struct{}
|
||||
|
||||
func (m *addFormModel) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m *addFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
func (m *addFormModel) Update(msg tea.Msg) (*addFormModel, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.styles = NewStyles(m.width)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
return m, func() tea.Msg { return addFormCancelMsg{} }
|
||||
|
||||
case "ctrl+enter":
|
||||
// Allow submission from any field with Ctrl+Enter
|
||||
@ -182,14 +184,15 @@ func (m *addFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
case submitResult:
|
||||
case addFormSubmitMsg:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err.Error()
|
||||
} else {
|
||||
m.success = true
|
||||
m.err = ""
|
||||
return m, tea.Quit
|
||||
// Don't quit here, let parent handle the success
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Update inputs
|
||||
@ -209,7 +212,7 @@ func (m *addFormModel) View() string {
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(titleStyle.Render("Add SSH Host Configuration"))
|
||||
b.WriteString(m.styles.FormTitle.Render("Add SSH Host Configuration"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
fields := []string{
|
||||
@ -224,27 +227,57 @@ func (m *addFormModel) View() string {
|
||||
}
|
||||
|
||||
for i, field := range fields {
|
||||
b.WriteString(fieldStyle.Render(field))
|
||||
b.WriteString(m.styles.FormField.Render(field))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.inputs[i].View())
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if m.err != "" {
|
||||
b.WriteString(errorStyle.Render("Error: " + m.err))
|
||||
b.WriteString(m.styles.Error.Render("Error: " + m.err))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString(helpStyle.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
|
||||
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("* Required fields"))
|
||||
b.WriteString(m.styles.FormHelp.Render("* Required fields"))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type submitResult struct {
|
||||
hostname string
|
||||
err error
|
||||
// Standalone wrapper for add form
|
||||
type standaloneAddForm struct {
|
||||
*addFormModel
|
||||
}
|
||||
|
||||
func (m standaloneAddForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case addFormSubmitMsg:
|
||||
if msg.err != nil {
|
||||
m.addFormModel.err = msg.err.Error()
|
||||
} else {
|
||||
m.addFormModel.success = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
case addFormCancelMsg:
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
newForm, cmd := m.addFormModel.Update(msg)
|
||||
m.addFormModel = newForm
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// RunAddForm provides backward compatibility for standalone add form
|
||||
func RunAddForm(hostname string, configFile string) error {
|
||||
styles := NewStyles(80)
|
||||
addForm := NewAddForm(hostname, styles, 80, 24, configFile)
|
||||
m := standaloneAddForm{addForm}
|
||||
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *addFormModel) submitForm() tea.Cmd {
|
||||
@ -269,7 +302,7 @@ func (m *addFormModel) submitForm() tea.Cmd {
|
||||
|
||||
// Validate all fields
|
||||
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
|
||||
return submitResult{err: err}
|
||||
return addFormSubmitMsg{err: err}
|
||||
}
|
||||
|
||||
tagsStr := strings.TrimSpace(m.inputs[tagsInput].Value())
|
||||
@ -296,7 +329,12 @@ func (m *addFormModel) submitForm() tea.Cmd {
|
||||
}
|
||||
|
||||
// Add to config
|
||||
err := config.AddSSHHost(host)
|
||||
return submitResult{hostname: name, err: err}
|
||||
var err error
|
||||
if m.configFile != "" {
|
||||
err = config.AddSSHHostToFile(host, m.configFile)
|
||||
} else {
|
||||
err = config.AddSSHHost(host)
|
||||
}
|
||||
return addFormSubmitMsg{hostname: name, err: err}
|
||||
}
|
||||
}
|
||||
|
@ -10,35 +10,33 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyleEdit = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFDF5")).
|
||||
Background(lipgloss.Color("#25A065")).
|
||||
Padding(0, 1)
|
||||
|
||||
fieldStyleEdit = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#04B575"))
|
||||
|
||||
errorStyleEdit = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FF0000"))
|
||||
|
||||
helpStyleEdit = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#626262"))
|
||||
)
|
||||
|
||||
type editFormModel struct {
|
||||
inputs []textinput.Model
|
||||
focused int
|
||||
err string
|
||||
success bool
|
||||
styles Styles
|
||||
originalName string
|
||||
host *config.SSHHost // Store the original host with SourceFile
|
||||
width int
|
||||
height int
|
||||
configFile string
|
||||
}
|
||||
|
||||
func RunEditForm(hostName string) error {
|
||||
// NewEditForm creates a new edit form model
|
||||
func NewEditForm(hostName string, styles Styles, width, height int, configFile string) (*editFormModel, error) {
|
||||
// Get the existing host configuration
|
||||
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 err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inputs := make([]textinput.Model, 8)
|
||||
@ -102,30 +100,44 @@ func RunEditForm(hostName string) error {
|
||||
inputs[tagsInput].SetValue(strings.Join(host.Tags, ", "))
|
||||
}
|
||||
|
||||
m := editFormModel{
|
||||
return &editFormModel{
|
||||
inputs: inputs,
|
||||
focused: nameInput,
|
||||
originalName: hostName,
|
||||
}
|
||||
|
||||
// Open in separate window like add form
|
||||
p := tea.NewProgram(&m, tea.WithAltScreen())
|
||||
_, err = p.Run()
|
||||
return err
|
||||
host: host,
|
||||
configFile: configFile,
|
||||
styles: styles,
|
||||
width: width,
|
||||
height: height,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Messages for communication with parent model
|
||||
type editFormSubmitMsg struct {
|
||||
hostname string
|
||||
err error
|
||||
}
|
||||
|
||||
type editFormCancelMsg struct{}
|
||||
|
||||
func (m *editFormModel) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
func (m *editFormModel) Update(msg tea.Msg) (*editFormModel, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.styles = NewStyles(m.width)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
return m, func() tea.Msg { return editFormCancelMsg{} }
|
||||
|
||||
case "ctrl+enter":
|
||||
// Allow submission from any field with Ctrl+Enter
|
||||
@ -163,14 +175,15 @@ func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
case editResult:
|
||||
case editFormSubmitMsg:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err.Error()
|
||||
} else {
|
||||
m.success = true
|
||||
m.err = ""
|
||||
return m, tea.Quit
|
||||
// Don't quit here, let parent handle the success
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Update inputs
|
||||
@ -190,7 +203,25 @@ func (m *editFormModel) View() string {
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(titleStyleEdit.Render("Edit SSH Host Configuration"))
|
||||
b.WriteString(m.styles.FormTitle.Render("Edit SSH Host Configuration"))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Show source file information
|
||||
if m.host != nil && m.host.SourceFile != "" {
|
||||
b.WriteString("\n") // Ligne d'espace avant Config file
|
||||
|
||||
// Style for "Config file:" label in primary color
|
||||
labelStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#00ADD8")). // Primary color
|
||||
Bold(true)
|
||||
|
||||
// Style for the file path in white
|
||||
pathStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFFFF"))
|
||||
|
||||
configInfo := labelStyle.Render("Config file: ") + pathStyle.Render(formatConfigFile(m.host.SourceFile))
|
||||
b.WriteString(configInfo)
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
fields := []string{
|
||||
@ -205,27 +236,60 @@ func (m *editFormModel) View() string {
|
||||
}
|
||||
|
||||
for i, field := range fields {
|
||||
b.WriteString(fieldStyleEdit.Render(field))
|
||||
b.WriteString(m.styles.FormField.Render(field))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.inputs[i].View())
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if m.err != "" {
|
||||
b.WriteString(errorStyleEdit.Render("Error: " + m.err))
|
||||
b.WriteString(m.styles.Error.Render("Error: " + m.err))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString(helpStyleEdit.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
|
||||
b.WriteString(m.styles.FormHelp.Render("Tab/Shift+Tab: navigate • Enter on last field: submit • Ctrl+Enter: submit • Ctrl+C/Esc: cancel"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyleEdit.Render("* Required fields"))
|
||||
b.WriteString(m.styles.FormHelp.Render("* Required fields"))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type editResult struct {
|
||||
hostname string
|
||||
err error
|
||||
// Standalone wrapper for edit form
|
||||
type standaloneEditForm struct {
|
||||
*editFormModel
|
||||
}
|
||||
|
||||
func (m standaloneEditForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case editFormSubmitMsg:
|
||||
if msg.err != nil {
|
||||
m.editFormModel.err = msg.err.Error()
|
||||
} else {
|
||||
m.editFormModel.success = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
case editFormCancelMsg:
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
newForm, cmd := m.editFormModel.Update(msg)
|
||||
m.editFormModel = newForm
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// RunEditForm provides backward compatibility for standalone edit form
|
||||
func RunEditForm(hostName string, configFile string) error {
|
||||
styles := NewStyles(80)
|
||||
editForm, err := NewEditForm(hostName, styles, 80, 24, configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m := standaloneEditForm{editForm}
|
||||
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
_, err = p.Run()
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *editFormModel) submitEditForm() tea.Cmd {
|
||||
@ -247,7 +311,7 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
|
||||
|
||||
// Validate all fields
|
||||
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
|
||||
return editResult{err: err}
|
||||
return editFormSubmitMsg{err: err}
|
||||
}
|
||||
|
||||
// Parse tags
|
||||
@ -275,7 +339,12 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
|
||||
}
|
||||
|
||||
// Update the configuration
|
||||
err := config.UpdateSSHHost(m.originalName, host)
|
||||
return editResult{hostname: name, err: err}
|
||||
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}
|
||||
}
|
||||
}
|
||||
|
162
internal/ui/file_selector.go
Normal file
162
internal/ui/file_selector.go
Normal file
@ -0,0 +1,162 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sshm/internal/config"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type fileSelectorModel struct {
|
||||
files []string // Chemins absolus des fichiers
|
||||
displayNames []string // Noms d'affichage conviviaux
|
||||
selected int
|
||||
styles Styles
|
||||
width int
|
||||
height int
|
||||
title string
|
||||
}
|
||||
|
||||
type fileSelectorMsg struct {
|
||||
selectedFile string
|
||||
cancelled bool
|
||||
}
|
||||
|
||||
// NewFileSelector creates a new file selector for choosing config files
|
||||
func NewFileSelector(title string, styles Styles, width, height int) (*fileSelectorModel, error) {
|
||||
files, err := config.GetAllConfigFiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newFileSelectorFromFiles(title, styles, width, height, files)
|
||||
}
|
||||
|
||||
// NewFileSelectorFromBase creates a new file selector starting from a specific base config file
|
||||
func NewFileSelectorFromBase(title string, styles Styles, width, height int, baseConfigFile string) (*fileSelectorModel, error) {
|
||||
var files []string
|
||||
var err error
|
||||
|
||||
if baseConfigFile != "" {
|
||||
files, err = config.GetAllConfigFilesFromBase(baseConfigFile)
|
||||
} else {
|
||||
files, err = config.GetAllConfigFiles()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newFileSelectorFromFiles(title, styles, width, height, files)
|
||||
}
|
||||
|
||||
// newFileSelectorFromFiles creates a file selector from a list of files
|
||||
func newFileSelectorFromFiles(title string, styles Styles, width, height int, files []string) (*fileSelectorModel, error) {
|
||||
|
||||
// Convert absolute paths to more user-friendly names
|
||||
var displayNames []string
|
||||
homeDir, _ := config.GetSSHDirectory()
|
||||
|
||||
for _, file := range files {
|
||||
// Check if it's the main config file
|
||||
mainConfig, _ := config.GetDefaultSSHConfigPath()
|
||||
if file == mainConfig {
|
||||
displayNames = append(displayNames, "Main SSH Config (~/.ssh/config)")
|
||||
} else {
|
||||
// Try to make path relative to home/.ssh/
|
||||
if strings.HasPrefix(file, homeDir) {
|
||||
relPath, err := filepath.Rel(homeDir, file)
|
||||
if err == nil {
|
||||
displayNames = append(displayNames, fmt.Sprintf("~/.ssh/%s", relPath))
|
||||
} else {
|
||||
displayNames = append(displayNames, file)
|
||||
}
|
||||
} else {
|
||||
displayNames = append(displayNames, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &fileSelectorModel{
|
||||
files: files,
|
||||
displayNames: displayNames,
|
||||
selected: 0,
|
||||
styles: styles,
|
||||
width: width,
|
||||
height: height,
|
||||
title: title,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *fileSelectorModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *fileSelectorModel) Update(msg tea.Msg) (*fileSelectorModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.styles = NewStyles(m.width)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, func() tea.Msg {
|
||||
return fileSelectorMsg{cancelled: true}
|
||||
}
|
||||
|
||||
case "enter":
|
||||
selectedFile := ""
|
||||
if m.selected < len(m.files) {
|
||||
selectedFile = m.files[m.selected]
|
||||
}
|
||||
return m, func() tea.Msg {
|
||||
return fileSelectorMsg{selectedFile: selectedFile}
|
||||
}
|
||||
|
||||
case "up", "k":
|
||||
if m.selected > 0 {
|
||||
m.selected--
|
||||
}
|
||||
|
||||
case "down", "j":
|
||||
if m.selected < len(m.files)-1 {
|
||||
m.selected++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *fileSelectorModel) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(m.styles.FormTitle.Render(m.title))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if len(m.files) == 0 {
|
||||
b.WriteString(m.styles.Error.Render("No SSH config files found."))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(m.styles.FormHelp.Render("Esc: cancel"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
for i, displayName := range m.displayNames {
|
||||
if i == m.selected {
|
||||
b.WriteString(m.styles.Selected.Render(fmt.Sprintf("▶ %s", displayName)))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf(" %s", displayName))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.styles.FormHelp.Render("↑/↓: navigate • Enter: select • Esc: cancel"))
|
||||
|
||||
return b.String()
|
||||
}
|
113
internal/ui/help_form.go
Normal file
113
internal/ui/help_form.go
Normal file
@ -0,0 +1,113 @@
|
||||
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("p"),
|
||||
" ",
|
||||
m.styles.HelpText.Render("ping all"),
|
||||
" ",
|
||||
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),
|
||||
)
|
||||
}
|
228
internal/ui/info_form.go
Normal file
228
internal/ui/info_form.go
Normal file
@ -0,0 +1,228 @@
|
||||
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},
|
||||
{"Config File", formatConfigFile(m.host.SourceFile)},
|
||||
{"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
|
||||
}
|
119
internal/ui/model.go
Normal file
119
internal/ui/model.go
Normal file
@ -0,0 +1,119 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/connectivity"
|
||||
"sshm/internal/history"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// SortMode defines the available sorting modes
|
||||
type SortMode int
|
||||
|
||||
const (
|
||||
SortByName SortMode = iota
|
||||
SortByLastUsed
|
||||
)
|
||||
|
||||
func (s SortMode) String() string {
|
||||
switch s {
|
||||
case SortByName:
|
||||
return "Name (A-Z)"
|
||||
case SortByLastUsed:
|
||||
return "Last Login"
|
||||
default:
|
||||
return "Name (A-Z)"
|
||||
}
|
||||
}
|
||||
|
||||
// ViewMode defines the current view state
|
||||
type ViewMode int
|
||||
|
||||
const (
|
||||
ViewList ViewMode = iota
|
||||
ViewAdd
|
||||
ViewEdit
|
||||
ViewInfo
|
||||
ViewPortForward
|
||||
ViewHelp
|
||||
ViewFileSelector
|
||||
)
|
||||
|
||||
// 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
|
||||
searchInput textinput.Model
|
||||
hosts []config.SSHHost
|
||||
filteredHosts []config.SSHHost
|
||||
searchMode bool
|
||||
deleteMode bool
|
||||
deleteHost string
|
||||
historyManager *history.HistoryManager
|
||||
pingManager *connectivity.PingManager
|
||||
sortMode SortMode
|
||||
configFile string // Path to the SSH config file
|
||||
|
||||
// View management
|
||||
viewMode ViewMode
|
||||
addForm *addFormModel
|
||||
editForm *editFormModel
|
||||
infoForm *infoFormModel
|
||||
portForwardForm *portForwardModel
|
||||
helpForm *helpModel
|
||||
fileSelectorForm *fileSelectorModel
|
||||
|
||||
// Terminal size and styles
|
||||
width int
|
||||
height int
|
||||
styles Styles
|
||||
ready bool
|
||||
}
|
||||
|
||||
// updateTableStyles updates the table header border color based on focus state
|
||||
func (m *Model) updateTableStyles() {
|
||||
s := table.DefaultStyles()
|
||||
s.Selected = m.styles.Selected
|
||||
|
||||
if m.searchMode {
|
||||
// When in search mode, use secondary color for table header
|
||||
s.Header = s.Header.
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color(SecondaryColor)).
|
||||
BorderBottom(true).
|
||||
Bold(false)
|
||||
} else {
|
||||
// When table is focused, use primary color for table header
|
||||
s.Header = s.Header.
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color(PrimaryColor)).
|
||||
BorderBottom(true).
|
||||
Bold(false)
|
||||
}
|
||||
|
||||
m.table.SetStyles(s)
|
||||
}
|
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
|
||||
}
|
71
internal/ui/sort.go
Normal file
71
internal/ui/sort.go
Normal file
@ -0,0 +1,71 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"sshm/internal/config"
|
||||
)
|
||||
|
||||
// sortHosts sorts hosts according to the current sort mode
|
||||
func (m Model) sortHosts(hosts []config.SSHHost) []config.SSHHost {
|
||||
if m.historyManager == nil {
|
||||
return sortHostsByName(hosts)
|
||||
}
|
||||
|
||||
switch m.sortMode {
|
||||
case SortByLastUsed:
|
||||
return m.historyManager.SortHostsByLastUsed(hosts)
|
||||
case SortByName:
|
||||
fallthrough
|
||||
default:
|
||||
return sortHostsByName(hosts)
|
||||
}
|
||||
}
|
||||
|
||||
// sortHostsByName sorts a slice of SSH hosts alphabetically by name
|
||||
func sortHostsByName(hosts []config.SSHHost) []config.SSHHost {
|
||||
sorted := make([]config.SSHHost, len(hosts))
|
||||
copy(sorted, hosts)
|
||||
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return strings.ToLower(sorted[i].Name) < strings.ToLower(sorted[j].Name)
|
||||
})
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
// filterHosts filters hosts according to the search query (name or tags)
|
||||
func (m Model) filterHosts(query string) []config.SSHHost {
|
||||
var filtered []config.SSHHost
|
||||
|
||||
if query == "" {
|
||||
filtered = m.hosts
|
||||
} else {
|
||||
query = strings.ToLower(query)
|
||||
|
||||
for _, host := range m.hosts {
|
||||
// Check the hostname
|
||||
if strings.Contains(strings.ToLower(host.Name), query) {
|
||||
filtered = append(filtered, host)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check the hostname
|
||||
if strings.Contains(strings.ToLower(host.Hostname), query) {
|
||||
filtered = append(filtered, host)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check the tags
|
||||
for _, tag := range host.Tags {
|
||||
if strings.Contains(strings.ToLower(tag), query) {
|
||||
filtered = append(filtered, host)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m.sortHosts(filtered)
|
||||
}
|
134
internal/ui/styles.go
Normal file
134
internal/ui/styles.go
Normal file
@ -0,0 +1,134 @@
|
||||
package ui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// Theme colors
|
||||
var (
|
||||
// Primary interface color - easily modifiable
|
||||
PrimaryColor = "#00ADD8" // Official Go logo blue color
|
||||
|
||||
// Secondary colors
|
||||
SecondaryColor = "240" // Gray
|
||||
ErrorColor = "1" // Red
|
||||
SuccessColor = "36" // Green (for reference if needed)
|
||||
)
|
||||
|
||||
// Styles struct centralizes all lipgloss styles
|
||||
type Styles struct {
|
||||
// Layout
|
||||
App lipgloss.Style
|
||||
Header lipgloss.Style
|
||||
|
||||
// Search styles
|
||||
SearchFocused lipgloss.Style
|
||||
SearchUnfocused lipgloss.Style
|
||||
|
||||
// Table styles
|
||||
TableFocused lipgloss.Style
|
||||
TableUnfocused lipgloss.Style
|
||||
Selected lipgloss.Style
|
||||
|
||||
// Info and help styles
|
||||
SortInfo lipgloss.Style
|
||||
HelpText lipgloss.Style
|
||||
|
||||
// Error and confirmation styles
|
||||
Error lipgloss.Style
|
||||
|
||||
// Form styles (for add/edit forms)
|
||||
FormTitle lipgloss.Style
|
||||
FormField 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
|
||||
func NewStyles(width int) Styles {
|
||||
return Styles{
|
||||
// Main app container
|
||||
App: lipgloss.NewStyle().
|
||||
Padding(1),
|
||||
|
||||
// Header style
|
||||
Header: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(PrimaryColor)).
|
||||
Bold(true).
|
||||
Align(lipgloss.Center),
|
||||
|
||||
// Search styles
|
||||
SearchFocused: lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color(PrimaryColor)).
|
||||
Padding(0, 1),
|
||||
|
||||
SearchUnfocused: lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color(SecondaryColor)).
|
||||
Padding(0, 1),
|
||||
|
||||
// Table styles
|
||||
TableFocused: lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color(PrimaryColor)),
|
||||
|
||||
TableUnfocused: lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color(SecondaryColor)),
|
||||
|
||||
// Style for selected items
|
||||
Selected: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("229")).
|
||||
Background(lipgloss.Color(PrimaryColor)).
|
||||
Bold(false),
|
||||
|
||||
// Info styles
|
||||
SortInfo: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(SecondaryColor)),
|
||||
|
||||
HelpText: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(SecondaryColor)),
|
||||
|
||||
// Error style
|
||||
Error: lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color(ErrorColor)).
|
||||
Padding(1, 2),
|
||||
|
||||
// Form styles
|
||||
FormTitle: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFDF5")).
|
||||
Background(lipgloss.Color(PrimaryColor)).
|
||||
Padding(0, 1),
|
||||
|
||||
FormField: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(PrimaryColor)),
|
||||
|
||||
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)),
|
||||
|
||||
HelpSection: lipgloss.NewStyle().
|
||||
Padding(0, 2),
|
||||
}
|
||||
}
|
||||
|
||||
// Application ASCII title
|
||||
const asciiTitle = `
|
||||
_____ _____ __ __ _____
|
||||
| __| __| | | |
|
||||
|__ |__ | | | | |
|
||||
|_____|_____|__|__|_|_|_|
|
||||
`
|
346
internal/ui/table.go
Normal file
346
internal/ui/table.go
Normal file
@ -0,0 +1,346 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/history"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
)
|
||||
|
||||
// calculateDynamicColumnWidths calculates optimal column widths based on terminal width
|
||||
// and content length, ensuring all content fits when possible
|
||||
func (m *Model) calculateDynamicColumnWidths(hosts []config.SSHHost) (int, int, int, int) {
|
||||
if m.width <= 0 {
|
||||
// Fallback to static widths if terminal width is not available
|
||||
return calculateNameColumnWidth(hosts), 25, calculateTagsColumnWidth(hosts), calculateLastLoginColumnWidth(hosts, m.historyManager)
|
||||
}
|
||||
|
||||
// Calculate content lengths
|
||||
maxNameLength := 8 // Minimum for "Name" header + status indicator
|
||||
maxHostnameLength := 8 // Minimum for "Hostname" header
|
||||
maxTagsLength := 8 // Minimum for "Tags" header
|
||||
maxLastLoginLength := 12 // Minimum for "Last Login" header
|
||||
|
||||
for _, host := range hosts {
|
||||
// Name column includes status indicator (2 chars) + space (1 char) + name
|
||||
nameLength := 3 + len(host.Name)
|
||||
if nameLength > maxNameLength {
|
||||
maxNameLength = nameLength
|
||||
}
|
||||
|
||||
if len(host.Hostname) > maxHostnameLength {
|
||||
maxHostnameLength = len(host.Hostname)
|
||||
}
|
||||
|
||||
// Calculate tags string length
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
var formattedTags []string
|
||||
for _, tag := range host.Tags {
|
||||
formattedTags = append(formattedTags, "#"+tag)
|
||||
}
|
||||
tagsStr = strings.Join(formattedTags, " ")
|
||||
}
|
||||
if len(tagsStr) > maxTagsLength {
|
||||
maxTagsLength = len(tagsStr)
|
||||
}
|
||||
|
||||
// Calculate last login length
|
||||
if m.historyManager != nil {
|
||||
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
|
||||
timeStr := formatTimeAgo(lastConnect)
|
||||
if len(timeStr) > maxLastLoginLength {
|
||||
maxLastLoginLength = len(timeStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add padding to each column
|
||||
maxNameLength += 2
|
||||
maxHostnameLength += 2
|
||||
maxTagsLength += 2
|
||||
maxLastLoginLength += 2
|
||||
|
||||
// Calculate available width (minus borders and separators)
|
||||
// Table has borders (2 chars) + column separators (3 chars between 4 columns)
|
||||
availableWidth := m.width - 5
|
||||
|
||||
totalNeededWidth := maxNameLength + maxHostnameLength + maxTagsLength + maxLastLoginLength
|
||||
|
||||
if totalNeededWidth <= availableWidth {
|
||||
// Everything fits perfectly
|
||||
return maxNameLength, maxHostnameLength, maxTagsLength, maxLastLoginLength
|
||||
}
|
||||
|
||||
// Need to adjust widths - prioritize columns by importance
|
||||
// Priority: Name > Hostname > Last Login > Tags
|
||||
|
||||
// Calculate minimum widths
|
||||
minNameWidth := 15 // Enough for status + short name
|
||||
minHostnameWidth := 15
|
||||
minLastLoginWidth := 12
|
||||
minTagsWidth := 10
|
||||
|
||||
remainingWidth := availableWidth
|
||||
|
||||
// Allocate minimum widths first
|
||||
nameWidth := minNameWidth
|
||||
hostnameWidth := minHostnameWidth
|
||||
lastLoginWidth := minLastLoginWidth
|
||||
tagsWidth := minTagsWidth
|
||||
|
||||
remainingWidth -= (nameWidth + hostnameWidth + lastLoginWidth + tagsWidth)
|
||||
|
||||
// Distribute remaining space proportionally
|
||||
if remainingWidth > 0 {
|
||||
// Calculate how much each column wants beyond minimum
|
||||
nameWant := maxNameLength - minNameWidth
|
||||
hostnameWant := maxHostnameLength - minHostnameWidth
|
||||
lastLoginWant := maxLastLoginLength - minLastLoginWidth
|
||||
tagsWant := maxTagsLength - minTagsWidth
|
||||
|
||||
totalWant := nameWant + hostnameWant + lastLoginWant + tagsWant
|
||||
|
||||
if totalWant > 0 {
|
||||
// Distribute proportionally
|
||||
nameExtra := (nameWant * remainingWidth) / totalWant
|
||||
hostnameExtra := (hostnameWant * remainingWidth) / totalWant
|
||||
lastLoginExtra := (lastLoginWant * remainingWidth) / totalWant
|
||||
tagsExtra := remainingWidth - nameExtra - hostnameExtra - lastLoginExtra
|
||||
|
||||
nameWidth += nameExtra
|
||||
hostnameWidth += hostnameExtra
|
||||
lastLoginWidth += lastLoginExtra
|
||||
tagsWidth += tagsExtra
|
||||
}
|
||||
}
|
||||
|
||||
return nameWidth, hostnameWidth, tagsWidth, lastLoginWidth
|
||||
}
|
||||
|
||||
// updateTableRows updates the table with filtered hosts
|
||||
func (m *Model) updateTableRows() {
|
||||
var rows []table.Row
|
||||
hostsToShow := m.filteredHosts
|
||||
if hostsToShow == nil {
|
||||
hostsToShow = m.hosts
|
||||
}
|
||||
|
||||
for _, host := range hostsToShow {
|
||||
// Get ping status indicator
|
||||
statusIndicator := m.getPingStatusIndicator(host.Name)
|
||||
|
||||
// Format tags for display
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
// Add the # prefix to each tag and join them with spaces
|
||||
var formattedTags []string
|
||||
for _, tag := range host.Tags {
|
||||
formattedTags = append(formattedTags, "#"+tag)
|
||||
}
|
||||
tagsStr = strings.Join(formattedTags, " ")
|
||||
}
|
||||
|
||||
// Format last login information
|
||||
var lastLoginStr string
|
||||
if m.historyManager != nil {
|
||||
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
|
||||
lastLoginStr = formatTimeAgo(lastConnect)
|
||||
}
|
||||
}
|
||||
|
||||
rows = append(rows, table.Row{
|
||||
statusIndicator + " " + host.Name,
|
||||
host.Hostname,
|
||||
// host.User, // Commented to save space
|
||||
// host.Port, // Commented to save space
|
||||
tagsStr,
|
||||
lastLoginStr,
|
||||
})
|
||||
}
|
||||
|
||||
m.table.SetRows(rows)
|
||||
|
||||
// Update table height and columns based on current terminal size
|
||||
m.updateTableHeight()
|
||||
m.updateTableColumns()
|
||||
}
|
||||
|
||||
// updateTableHeight dynamically adjusts table height based on terminal size
|
||||
func (m *Model) updateTableHeight() {
|
||||
if !m.ready {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate dynamic table height based on terminal size
|
||||
// Layout breakdown:
|
||||
// - ASCII title: 5 lines (1 empty + 4 text lines)
|
||||
// - Search bar: 1 line
|
||||
// - Help text: 1 line
|
||||
// - App margins/spacing: 3 lines
|
||||
// - Safety margin: 3 lines (to ensure UI elements are always visible)
|
||||
// Total reserved: 13 lines minimum to preserve essential UI elements
|
||||
reservedHeight := 13
|
||||
availableHeight := m.height - reservedHeight
|
||||
hostCount := len(m.table.Rows())
|
||||
|
||||
// Minimum height should be at least 3 rows for basic usability
|
||||
// Even in very small terminals, we want to show at least header + 2 hosts
|
||||
minTableHeight := 4 // 1 header + 3 data rows minimum
|
||||
maxTableHeight := availableHeight
|
||||
if maxTableHeight < minTableHeight {
|
||||
maxTableHeight = minTableHeight
|
||||
}
|
||||
|
||||
tableHeight := 1 // header
|
||||
dataRowsNeeded := hostCount
|
||||
maxDataRows := maxTableHeight - 1 // subtract 1 for header
|
||||
|
||||
if dataRowsNeeded <= maxDataRows {
|
||||
// We have enough space for all hosts
|
||||
tableHeight += dataRowsNeeded
|
||||
} else {
|
||||
// We need to limit to available space
|
||||
tableHeight += maxDataRows
|
||||
}
|
||||
|
||||
// Add one extra line to prevent the last host from being hidden
|
||||
// This compensates for table rendering quirks in bubble tea
|
||||
tableHeight += 1
|
||||
|
||||
// Update table height
|
||||
m.table.SetHeight(tableHeight)
|
||||
}
|
||||
|
||||
// updateTableColumns dynamically adjusts table column widths based on terminal size
|
||||
func (m *Model) updateTableColumns() {
|
||||
if !m.ready {
|
||||
return
|
||||
}
|
||||
|
||||
hostsToShow := m.filteredHosts
|
||||
if hostsToShow == nil {
|
||||
hostsToShow = m.hosts
|
||||
}
|
||||
|
||||
// Use dynamic column width calculation
|
||||
nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(hostsToShow)
|
||||
|
||||
// Create new columns with updated widths and sort indicators
|
||||
nameTitle := "Name"
|
||||
lastLoginTitle := "Last Login"
|
||||
|
||||
// Add sort indicators based on current sort mode
|
||||
switch m.sortMode {
|
||||
case SortByName:
|
||||
nameTitle += " ↓"
|
||||
case SortByLastUsed:
|
||||
lastLoginTitle += " ↓"
|
||||
}
|
||||
|
||||
columns := []table.Column{
|
||||
{Title: nameTitle, Width: nameWidth},
|
||||
{Title: "Hostname", Width: hostnameWidth},
|
||||
// {Title: "User", Width: userWidth}, // Commented to save space
|
||||
// {Title: "Port", Width: portWidth}, // Commented to save space
|
||||
{Title: "Tags", Width: tagsWidth},
|
||||
{Title: lastLoginTitle, Width: lastLoginWidth},
|
||||
}
|
||||
|
||||
m.table.SetColumns(columns)
|
||||
}
|
||||
|
||||
// max returns the maximum of two integers
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Legacy functions for compatibility
|
||||
|
||||
// calculateNameColumnWidth calculates the optimal width for the Name column
|
||||
// based on the longest hostname, with a minimum of 8 and maximum of 40 characters
|
||||
func calculateNameColumnWidth(hosts []config.SSHHost) int {
|
||||
maxLength := 8 // Minimum width to accommodate the "Name" header
|
||||
|
||||
for _, host := range hosts {
|
||||
if len(host.Name) > maxLength {
|
||||
maxLength = len(host.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Add some padding (2 characters) for better visual spacing
|
||||
maxLength += 2
|
||||
|
||||
// Limit the maximum width to avoid extremely large columns
|
||||
if maxLength > 40 {
|
||||
maxLength = 40
|
||||
}
|
||||
|
||||
return maxLength
|
||||
}
|
||||
|
||||
// calculateTagsColumnWidth calculates the optimal width for the Tags column
|
||||
// based on the longest tag string, with a minimum of 8 and maximum of 40 characters
|
||||
func calculateTagsColumnWidth(hosts []config.SSHHost) int {
|
||||
maxLength := 8 // Minimum width to accommodate the "Tags" header
|
||||
|
||||
for _, host := range hosts {
|
||||
// Format tags exactly as they appear in the table
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
// Add the # prefix to each tag and join them with spaces
|
||||
var formattedTags []string
|
||||
for _, tag := range host.Tags {
|
||||
formattedTags = append(formattedTags, "#"+tag)
|
||||
}
|
||||
tagsStr = strings.Join(formattedTags, " ")
|
||||
}
|
||||
|
||||
if len(tagsStr) > maxLength {
|
||||
maxLength = len(tagsStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Add some padding (2 characters) for better visual spacing
|
||||
maxLength += 2
|
||||
|
||||
// Limit the maximum width to avoid extremely large columns
|
||||
if maxLength > 40 {
|
||||
maxLength = 40
|
||||
}
|
||||
|
||||
return maxLength
|
||||
}
|
||||
|
||||
// calculateLastLoginColumnWidth calculates the optimal width for the Last Login column
|
||||
// based on the longest time format, with a minimum of 12 and maximum of 20 characters
|
||||
func calculateLastLoginColumnWidth(hosts []config.SSHHost, historyManager *history.HistoryManager) int {
|
||||
maxLength := 12 // Minimum width to accommodate the "Last Login" header
|
||||
|
||||
if historyManager != nil {
|
||||
for _, host := range hosts {
|
||||
if lastConnect, exists := historyManager.GetLastConnectionTime(host.Name); exists {
|
||||
timeStr := formatTimeAgo(lastConnect)
|
||||
if len(timeStr) > maxLength {
|
||||
maxLength = len(timeStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add some padding (2 characters) for better visual spacing
|
||||
maxLength += 2
|
||||
|
||||
// Limit the maximum width to avoid extremely large columns
|
||||
if maxLength > 20 {
|
||||
maxLength = 20
|
||||
}
|
||||
|
||||
return maxLength
|
||||
}
|
@ -2,11 +2,12 @@ package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/connectivity"
|
||||
"sshm/internal/history"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
@ -14,285 +15,68 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var baseStyle = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240"))
|
||||
|
||||
var searchStyle = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("36")).
|
||||
Padding(0, 1)
|
||||
|
||||
type Model struct {
|
||||
table table.Model
|
||||
searchInput textinput.Model
|
||||
hosts []config.SSHHost
|
||||
filteredHosts []config.SSHHost
|
||||
searchMode bool
|
||||
deleteMode bool
|
||||
deleteHost string
|
||||
exitAction string
|
||||
exitHostName string
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
// Handle key messages
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc", "ctrl+c":
|
||||
if m.searchMode {
|
||||
// Exit search mode
|
||||
m.searchMode = false
|
||||
m.searchInput.Blur()
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
if m.deleteMode {
|
||||
// Exit delete mode
|
||||
m.deleteMode = false
|
||||
m.deleteHost = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Quit
|
||||
case "q":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
return m, tea.Quit
|
||||
}
|
||||
case "/", "ctrl+f":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Enter search mode
|
||||
m.searchMode = true
|
||||
m.table.Blur()
|
||||
m.searchInput.Focus()
|
||||
return m, textinput.Blink
|
||||
}
|
||||
case "enter":
|
||||
if m.searchMode {
|
||||
// Exit search mode and focus table
|
||||
m.searchMode = false
|
||||
m.searchInput.Blur()
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
} else if m.deleteMode {
|
||||
// Confirm deletion
|
||||
err := config.DeleteSSHHost(m.deleteHost)
|
||||
if err != nil {
|
||||
// Could show error message here
|
||||
m.deleteMode = false
|
||||
m.deleteHost = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
// Refresh the host list
|
||||
hosts, err := config.ParseSSHConfig()
|
||||
if err != nil {
|
||||
// Could show error message here
|
||||
m.deleteMode = false
|
||||
m.deleteHost = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
m.hosts = sortHostsByName(hosts)
|
||||
m.filteredHosts = m.hosts
|
||||
m.updateTableRows()
|
||||
m.deleteMode = false
|
||||
m.deleteHost = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
} else {
|
||||
// Connect to selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // Host name is in the first column
|
||||
return m, tea.ExecProcess(exec.Command("ssh", hostName), func(err error) tea.Msg {
|
||||
return tea.Quit()
|
||||
})
|
||||
}
|
||||
}
|
||||
case "e":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Edit selected host using dedicated edit form
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // Host name is in the first column
|
||||
// Store the edit action and exit
|
||||
m.exitAction = "edit"
|
||||
m.exitHostName = hostName
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
case "a":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Add new host using dedicated add form
|
||||
m.exitAction = "add"
|
||||
return m, tea.Quit
|
||||
}
|
||||
case "d":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Delete selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := selected[0] // Host name is in the first column
|
||||
m.deleteMode = true
|
||||
m.deleteHost = hostName
|
||||
m.table.Blur()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update components based on mode
|
||||
if m.searchMode {
|
||||
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||
// Filter hosts when search input changes
|
||||
if m.searchInput.Value() != "" {
|
||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||
} else {
|
||||
m.filteredHosts = m.hosts
|
||||
}
|
||||
m.updateTableRows()
|
||||
} else {
|
||||
m.table, cmd = m.table.Update(msg)
|
||||
}
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.deleteMode {
|
||||
return m.renderDeleteConfirmation()
|
||||
}
|
||||
|
||||
var view strings.Builder
|
||||
|
||||
// Add search bar
|
||||
searchPrompt := "Search (/ to search, ESC to exit search): "
|
||||
if m.searchMode {
|
||||
view.WriteString(searchStyle.Render(searchPrompt+m.searchInput.View()) + "\n\n")
|
||||
}
|
||||
|
||||
// Add table
|
||||
view.WriteString(baseStyle.Render(m.table.View()))
|
||||
|
||||
// Add help text
|
||||
if !m.searchMode {
|
||||
view.WriteString("\nUse ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • (q)uit")
|
||||
} else {
|
||||
view.WriteString("\nType to filter hosts by name or tag • Enter to select • ESC to exit search")
|
||||
}
|
||||
|
||||
return view.String()
|
||||
}
|
||||
|
||||
// sortHostsByName sorts a slice of SSH hosts alphabetically by name
|
||||
func sortHostsByName(hosts []config.SSHHost) []config.SSHHost {
|
||||
sorted := make([]config.SSHHost, len(hosts))
|
||||
copy(sorted, hosts)
|
||||
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return strings.ToLower(sorted[i].Name) < strings.ToLower(sorted[j].Name)
|
||||
})
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
// calculateNameColumnWidth calculates the optimal width for the Name column
|
||||
// based on the longest host name, with a minimum of 8 and maximum of 40 characters
|
||||
func calculateNameColumnWidth(hosts []config.SSHHost) int {
|
||||
maxLength := 8 // Minimum width to accommodate the "Name" header
|
||||
|
||||
for _, host := range hosts {
|
||||
if len(host.Name) > maxLength {
|
||||
maxLength = len(host.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Add some padding (2 characters) for better visual spacing
|
||||
maxLength += 2
|
||||
|
||||
// Cap the maximum width to avoid extremely wide columns
|
||||
if maxLength > 40 {
|
||||
maxLength = 40
|
||||
}
|
||||
|
||||
return maxLength
|
||||
}
|
||||
|
||||
// calculateTagsColumnWidth calculates the optimal width for the Tags column
|
||||
// based on the longest tags string, with a minimum of 8 and maximum of 50 characters
|
||||
func calculateTagsColumnWidth(hosts []config.SSHHost) int {
|
||||
maxLength := 8 // Minimum width to accommodate the "Tags" header
|
||||
|
||||
for _, host := range hosts {
|
||||
// Format tags exactly the same way they appear in the table
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
// Add # prefix to each tag and join with spaces
|
||||
var formattedTags []string
|
||||
for _, tag := range host.Tags {
|
||||
formattedTags = append(formattedTags, "#"+tag)
|
||||
}
|
||||
tagsStr = strings.Join(formattedTags, " ")
|
||||
}
|
||||
|
||||
if len(tagsStr) > maxLength {
|
||||
maxLength = len(tagsStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Add some padding (2 characters) for better visual spacing
|
||||
maxLength += 2
|
||||
|
||||
// Cap the maximum width to avoid extremely wide columns
|
||||
if maxLength > 50 {
|
||||
maxLength = 50
|
||||
}
|
||||
|
||||
return maxLength
|
||||
}
|
||||
|
||||
// NewModel creates a new TUI model with the given SSH hosts
|
||||
func NewModel(hosts []config.SSHHost) Model {
|
||||
// Sort hosts alphabetically by name
|
||||
sortedHosts := sortHostsByName(hosts)
|
||||
func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||
// Initialize the history manager
|
||||
historyManager, err := history.NewHistoryManager()
|
||||
if err != nil {
|
||||
// Log the error but continue without the history functionality
|
||||
fmt.Printf("Warning: Could not initialize history manager: %v\n", err)
|
||||
historyManager = nil
|
||||
}
|
||||
|
||||
// Create search input
|
||||
// Create initial styles (will be updated on first WindowSizeMsg)
|
||||
styles := NewStyles(80) // Default width
|
||||
|
||||
// Initialize ping manager with 5 second timeout
|
||||
pingManager := connectivity.NewPingManager(5 * time.Second)
|
||||
|
||||
// Create the model with default sorting by name
|
||||
m := Model{
|
||||
hosts: hosts,
|
||||
historyManager: historyManager,
|
||||
pingManager: pingManager,
|
||||
sortMode: SortByName,
|
||||
configFile: configFile,
|
||||
styles: styles,
|
||||
width: 80,
|
||||
height: 24,
|
||||
ready: false,
|
||||
viewMode: ViewList,
|
||||
}
|
||||
|
||||
// Sort hosts according to the default sort mode
|
||||
sortedHosts := m.sortHosts(hosts)
|
||||
|
||||
// Create the search input
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Search hosts or tags..."
|
||||
ti.CharLimit = 50
|
||||
ti.Width = 50
|
||||
|
||||
// Calculate optimal width for the Name column
|
||||
nameWidth := calculateNameColumnWidth(sortedHosts)
|
||||
|
||||
// Calculate optimal width for the Tags column
|
||||
tagsWidth := calculateTagsColumnWidth(sortedHosts)
|
||||
// Use dynamic column width calculation (will fallback to static if width not available)
|
||||
nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(sortedHosts)
|
||||
|
||||
// Create table columns
|
||||
columns := []table.Column{
|
||||
{Title: "Name", Width: nameWidth},
|
||||
{Title: "Hostname", Width: 25},
|
||||
{Title: "User", Width: 12},
|
||||
{Title: "Port", Width: 6},
|
||||
{Title: "Hostname", Width: hostnameWidth},
|
||||
// {Title: "User", Width: 12}, // Commented to save space
|
||||
// {Title: "Port", Width: 6}, // Commented to save space
|
||||
{Title: "Tags", Width: tagsWidth},
|
||||
{Title: "Last Login", Width: lastLoginWidth},
|
||||
}
|
||||
|
||||
// Convert hosts to table rows
|
||||
var rows []table.Row
|
||||
for _, host := range sortedHosts {
|
||||
// Get ping status indicator
|
||||
statusIndicator := m.getPingStatusIndicator(host.Name)
|
||||
|
||||
// Format tags for display
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
// Add # prefix to each tag and join with spaces
|
||||
// Add the # prefix to each tag and join them with spaces
|
||||
var formattedTags []string
|
||||
for _, tag := range host.Tags {
|
||||
formattedTags = append(formattedTags, "#"+tag)
|
||||
@ -300,197 +84,67 @@ func NewModel(hosts []config.SSHHost) Model {
|
||||
tagsStr = strings.Join(formattedTags, " ")
|
||||
}
|
||||
|
||||
// Format last login information
|
||||
var lastLoginStr string
|
||||
if historyManager != nil {
|
||||
if lastConnect, exists := historyManager.GetLastConnectionTime(host.Name); exists {
|
||||
lastLoginStr = formatTimeAgo(lastConnect)
|
||||
}
|
||||
}
|
||||
|
||||
rows = append(rows, table.Row{
|
||||
host.Name,
|
||||
statusIndicator + " " + host.Name,
|
||||
host.Hostname,
|
||||
host.User,
|
||||
host.Port,
|
||||
// host.User, // Commented to save space
|
||||
// host.Port, // Commented to save space
|
||||
tagsStr,
|
||||
lastLoginStr,
|
||||
})
|
||||
}
|
||||
|
||||
// Déterminer la hauteur du tableau : 1 (header) + nombre de hosts (max 10)
|
||||
hostCount := len(rows)
|
||||
tableHeight := 1 // header
|
||||
if hostCount < 10 {
|
||||
tableHeight += hostCount
|
||||
} else {
|
||||
tableHeight += 10
|
||||
}
|
||||
|
||||
// Create 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
|
||||
s := table.DefaultStyles()
|
||||
s.Header = s.Header.
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240")).
|
||||
BorderForeground(lipgloss.Color(SecondaryColor)).
|
||||
BorderBottom(true).
|
||||
Bold(false)
|
||||
s.Selected = s.Selected.
|
||||
Foreground(lipgloss.Color("229")).
|
||||
Background(lipgloss.Color("57")).
|
||||
Bold(false)
|
||||
s.Selected = m.styles.Selected
|
||||
|
||||
t.SetStyles(s)
|
||||
|
||||
return Model{
|
||||
table: t,
|
||||
searchInput: ti,
|
||||
hosts: sortedHosts,
|
||||
filteredHosts: sortedHosts,
|
||||
searchMode: false,
|
||||
}
|
||||
// Update the model with the table and other properties
|
||||
m.table = t
|
||||
m.searchInput = ti
|
||||
m.filteredHosts = sortedHosts
|
||||
|
||||
// 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
|
||||
func RunInteractiveMode(hosts []config.SSHHost) error {
|
||||
for {
|
||||
m := NewModel(hosts)
|
||||
// RunInteractiveMode starts the interactive TUI interface
|
||||
func RunInteractiveMode(hosts []config.SSHHost, configFile string) error {
|
||||
m := NewModel(hosts, configFile)
|
||||
|
||||
// Start the application in terminal (without alt screen)
|
||||
p := tea.NewProgram(m)
|
||||
finalModel, err := p.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error running TUI: %w", err)
|
||||
}
|
||||
|
||||
// Check if the final model indicates an action
|
||||
if model, ok := finalModel.(Model); ok {
|
||||
if model.exitAction == "edit" && model.exitHostName != "" {
|
||||
// Launch the dedicated edit form (opens in separate window)
|
||||
if err := RunEditForm(model.exitHostName); err != nil {
|
||||
fmt.Printf("Error editing host: %v\n", err)
|
||||
// Continue the loop to return to the main interface
|
||||
continue
|
||||
}
|
||||
|
||||
// Clear screen before returning to TUI
|
||||
fmt.Print("\033[2J\033[H")
|
||||
|
||||
// Refresh the hosts list after editing
|
||||
refreshedHosts, err := config.ParseSSHConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error refreshing hosts after edit: %w", err)
|
||||
}
|
||||
hosts = refreshedHosts
|
||||
|
||||
// Continue the loop to return to the main interface
|
||||
continue
|
||||
} else if model.exitAction == "add" {
|
||||
// Launch the dedicated add form (opens in separate window)
|
||||
if err := RunAddForm(""); err != nil {
|
||||
fmt.Printf("Error adding host: %v\n", err)
|
||||
// Continue the loop to return to the main interface
|
||||
continue
|
||||
}
|
||||
|
||||
// Clear screen before returning to TUI
|
||||
fmt.Print("\033[2J\033[H")
|
||||
|
||||
// Refresh the hosts list after adding
|
||||
refreshedHosts, err := config.ParseSSHConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error refreshing hosts after add: %w", err)
|
||||
}
|
||||
hosts = refreshedHosts
|
||||
|
||||
// Continue the loop to return to the main interface
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// If no special command, exit normally
|
||||
break
|
||||
// Start the application in alt screen mode for clean output
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
_, err := p.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error running TUI: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterHosts filters hosts based on search query (name or tags)
|
||||
func (m Model) filterHosts(query string) []config.SSHHost {
|
||||
if query == "" {
|
||||
return sortHostsByName(m.hosts)
|
||||
}
|
||||
|
||||
query = strings.ToLower(query)
|
||||
var filtered []config.SSHHost
|
||||
|
||||
for _, host := range m.hosts {
|
||||
// Check host name
|
||||
if strings.Contains(strings.ToLower(host.Name), query) {
|
||||
filtered = append(filtered, host)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check hostname
|
||||
if strings.Contains(strings.ToLower(host.Hostname), query) {
|
||||
filtered = append(filtered, host)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check tags
|
||||
for _, tag := range host.Tags {
|
||||
if strings.Contains(strings.ToLower(tag), query) {
|
||||
filtered = append(filtered, host)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sortHostsByName(filtered)
|
||||
}
|
||||
|
||||
// updateTableRows updates the table with filtered hosts
|
||||
func (m *Model) updateTableRows() {
|
||||
var rows []table.Row
|
||||
hostsToShow := m.filteredHosts
|
||||
if hostsToShow == nil {
|
||||
hostsToShow = m.hosts
|
||||
}
|
||||
|
||||
// Sort hosts alphabetically by name
|
||||
sortedHosts := sortHostsByName(hostsToShow)
|
||||
|
||||
for _, host := range sortedHosts {
|
||||
// Format tags for display
|
||||
var tagsStr string
|
||||
if len(host.Tags) > 0 {
|
||||
// Add # prefix to each tag and join with spaces
|
||||
var formattedTags []string
|
||||
for _, tag := range host.Tags {
|
||||
formattedTags = append(formattedTags, "#"+tag)
|
||||
}
|
||||
tagsStr = strings.Join(formattedTags, " ")
|
||||
}
|
||||
|
||||
rows = append(rows, table.Row{
|
||||
host.Name,
|
||||
host.Hostname,
|
||||
host.User,
|
||||
host.Port,
|
||||
tagsStr,
|
||||
})
|
||||
}
|
||||
|
||||
m.table.SetRows(rows)
|
||||
}
|
||||
|
||||
// enterEditMode initializes edit mode for a specific host
|
||||
// renderDeleteConfirmation renders the delete confirmation dialog
|
||||
func (m Model) renderDeleteConfirmation() string {
|
||||
var view strings.Builder
|
||||
|
||||
view.WriteString(lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("1")). // Red border
|
||||
Padding(1, 2).
|
||||
Render(fmt.Sprintf("⚠️ Delete SSH Host\n\nAre you sure you want to delete host '%s'?\n\nThis action cannot be undone.\n\nPress Enter to confirm or Esc to cancel", m.deleteHost)))
|
||||
|
||||
return view.String()
|
||||
}
|
||||
|
634
internal/ui/update.go
Normal file
634
internal/ui/update.go
Normal file
@ -0,0 +1,634 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"sshm/internal/config"
|
||||
"sshm/internal/connectivity"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// Messages for SSH ping functionality
|
||||
type (
|
||||
pingResultMsg *connectivity.HostPingResult
|
||||
)
|
||||
|
||||
// startPingAllCmd creates a command to ping all hosts concurrently
|
||||
func (m Model) startPingAllCmd() tea.Cmd {
|
||||
if m.pingManager == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return tea.Batch(
|
||||
// Create individual ping commands for each host
|
||||
func() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
for _, host := range m.hosts {
|
||||
cmds = append(cmds, pingSingleHostCmd(m.pingManager, host))
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}(),
|
||||
)
|
||||
}
|
||||
|
||||
// listenForPingResultsCmd is no longer needed since we use individual ping commands
|
||||
|
||||
// pingSingleHostCmd creates a command to ping a single host
|
||||
func pingSingleHostCmd(pingManager *connectivity.PingManager, host config.SSHHost) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result := pingManager.PingHost(ctx, host)
|
||||
return pingResultMsg(result)
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the model
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
textinput.Blink,
|
||||
// Ping is now optional - use 'p' key to start ping
|
||||
)
|
||||
}
|
||||
|
||||
// Update handles model updates
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
// Handle different message types
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
// Update terminal size and recalculate styles
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
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
|
||||
m.addForm.height = m.height
|
||||
m.addForm.styles = m.styles
|
||||
}
|
||||
if m.editForm != nil {
|
||||
m.editForm.width = m.width
|
||||
m.editForm.height = m.height
|
||||
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
|
||||
}
|
||||
if m.fileSelectorForm != nil {
|
||||
m.fileSelectorForm.width = m.width
|
||||
m.fileSelectorForm.height = m.height
|
||||
m.fileSelectorForm.styles = m.styles
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case pingResultMsg:
|
||||
// Handle ping result - update table display
|
||||
if msg != nil {
|
||||
// Update the table to reflect the new ping status
|
||||
m.updateTableRows()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case addFormSubmitMsg:
|
||||
if msg.err != nil {
|
||||
// Show error in form
|
||||
if m.addForm != nil {
|
||||
m.addForm.err = msg.err.Error()
|
||||
}
|
||||
return m, nil
|
||||
} else {
|
||||
// Success: refresh hosts and return to list view
|
||||
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)
|
||||
|
||||
// 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
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case addFormCancelMsg:
|
||||
// Cancel: return to list view
|
||||
m.viewMode = ViewList
|
||||
m.addForm = nil
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
|
||||
case editFormSubmitMsg:
|
||||
if msg.err != nil {
|
||||
// Show error in form
|
||||
if m.editForm != nil {
|
||||
m.editForm.err = msg.err.Error()
|
||||
}
|
||||
return m, nil
|
||||
} else {
|
||||
// Success: refresh hosts and return to list view
|
||||
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)
|
||||
|
||||
// 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
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case editFormCancelMsg:
|
||||
// Cancel: return to list view
|
||||
m.viewMode = ViewList
|
||||
m.editForm = nil
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
|
||||
case infoFormCancelMsg:
|
||||
// Cancel: return to list view
|
||||
m.viewMode = ViewList
|
||||
m.infoForm = nil
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
|
||||
case fileSelectorMsg:
|
||||
if msg.cancelled {
|
||||
// Cancel: return to list view
|
||||
m.viewMode = ViewList
|
||||
m.fileSelectorForm = nil
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
} else {
|
||||
// File selected: proceed to add form with selected file
|
||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, msg.selectedFile)
|
||||
m.viewMode = ViewAdd
|
||||
m.fileSelectorForm = nil
|
||||
return m, textinput.Blink
|
||||
}
|
||||
|
||||
case infoFormEditMsg:
|
||||
// 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:
|
||||
// Handle view-specific key presses
|
||||
switch m.viewMode {
|
||||
case ViewAdd:
|
||||
if m.addForm != nil {
|
||||
var newForm *addFormModel
|
||||
newForm, cmd = m.addForm.Update(msg)
|
||||
m.addForm = newForm
|
||||
return m, cmd
|
||||
}
|
||||
case ViewEdit:
|
||||
if m.editForm != nil {
|
||||
var newForm *editFormModel
|
||||
newForm, cmd = m.editForm.Update(msg)
|
||||
m.editForm = newForm
|
||||
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 ViewFileSelector:
|
||||
if m.fileSelectorForm != nil {
|
||||
var newForm *fileSelectorModel
|
||||
newForm, cmd = m.fileSelectorForm.Update(msg)
|
||||
m.fileSelectorForm = newForm
|
||||
return m, cmd
|
||||
}
|
||||
case ViewList:
|
||||
// Handle list view keys
|
||||
return m.handleListViewKeys(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg.String() {
|
||||
case "esc", "ctrl+c":
|
||||
if m.deleteMode {
|
||||
// Exit delete mode
|
||||
m.deleteMode = false
|
||||
m.deleteHost = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Quit
|
||||
case "q":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
return m, tea.Quit
|
||||
}
|
||||
case "/", "ctrl+f":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Enter search mode
|
||||
m.searchMode = true
|
||||
m.updateTableStyles()
|
||||
m.table.Blur()
|
||||
m.searchInput.Focus()
|
||||
return m, textinput.Blink
|
||||
}
|
||||
case "tab":
|
||||
if !m.deleteMode {
|
||||
// Switch focus between search input and table
|
||||
if m.searchMode {
|
||||
// Switch from search to table
|
||||
m.searchMode = false
|
||||
m.updateTableStyles()
|
||||
m.searchInput.Blur()
|
||||
m.table.Focus()
|
||||
} else {
|
||||
// Switch from table to search
|
||||
m.searchMode = true
|
||||
m.updateTableStyles()
|
||||
m.table.Blur()
|
||||
m.searchInput.Focus()
|
||||
return m, textinput.Blink
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
case "enter":
|
||||
if m.searchMode {
|
||||
// Validate search and return to table mode to allow commands
|
||||
m.searchMode = false
|
||||
m.updateTableStyles()
|
||||
m.searchInput.Blur()
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
} else if m.deleteMode {
|
||||
// Confirm deletion
|
||||
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
|
||||
m.deleteHost = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
// Refresh the hosts list
|
||||
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 = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
}
|
||||
m.hosts = m.sortHosts(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 = ""
|
||||
m.table.Focus()
|
||||
return m, nil
|
||||
} else {
|
||||
// Connect to the selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
||||
|
||||
// Record the connection in history
|
||||
if m.historyManager != nil {
|
||||
err := m.historyManager.RecordConnection(hostName)
|
||||
if err != nil {
|
||||
// Log the error but don't prevent the connection
|
||||
fmt.Printf("Warning: Could not record connection history: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
case "e":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Edit the selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
||||
editForm, err := NewEditForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||
if err != nil {
|
||||
// Handle error - could show in UI
|
||||
return m, nil
|
||||
}
|
||||
m.editForm = editForm
|
||||
m.viewMode = ViewEdit
|
||||
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 := extractHostNameFromTableRow(selected[0]) // Extract hostname from 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":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Check if there are multiple config files starting from the current base config
|
||||
var configFiles []string
|
||||
var err error
|
||||
|
||||
if m.configFile != "" {
|
||||
// Use the specified config file as base
|
||||
configFiles, err = config.GetAllConfigFilesFromBase(m.configFile)
|
||||
} else {
|
||||
// Use the default config file as base
|
||||
configFiles, err = config.GetAllConfigFiles()
|
||||
}
|
||||
|
||||
if err != nil || len(configFiles) <= 1 {
|
||||
// Only one config file (or error), go directly to add form
|
||||
var configFile string
|
||||
if len(configFiles) == 1 {
|
||||
configFile = configFiles[0]
|
||||
} else {
|
||||
configFile = m.configFile
|
||||
}
|
||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, configFile)
|
||||
m.viewMode = ViewAdd
|
||||
} else {
|
||||
// Multiple config files, show file selector
|
||||
fileSelectorForm, err := NewFileSelectorFromBase("Select config file to add host to:", m.styles, m.width, m.height, m.configFile)
|
||||
if err != nil {
|
||||
// Fallback to default behavior if file selector fails
|
||||
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile)
|
||||
m.viewMode = ViewAdd
|
||||
} else {
|
||||
m.fileSelectorForm = fileSelectorForm
|
||||
m.viewMode = ViewFileSelector
|
||||
}
|
||||
}
|
||||
return m, textinput.Blink
|
||||
}
|
||||
case "d":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Delete the selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column
|
||||
m.deleteMode = true
|
||||
m.deleteHost = hostName
|
||||
m.table.Blur()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
case "p":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Ping all hosts
|
||||
return m, m.startPingAllCmd()
|
||||
}
|
||||
case "f":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Port forwarding for the selected host
|
||||
selected := m.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from 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":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Cycle through sort modes (only 2 modes now)
|
||||
m.sortMode = (m.sortMode + 1) % 2
|
||||
// Re-apply the current filter with the new sort mode
|
||||
if m.searchInput.Value() != "" {
|
||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||
} else {
|
||||
m.filteredHosts = m.sortHosts(m.hosts)
|
||||
}
|
||||
m.updateTableRows()
|
||||
return m, nil
|
||||
}
|
||||
case "r":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Switch to sort by recent (last used)
|
||||
m.sortMode = SortByLastUsed
|
||||
// Re-apply the current filter with the new sort mode
|
||||
if m.searchInput.Value() != "" {
|
||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||
} else {
|
||||
m.filteredHosts = m.sortHosts(m.hosts)
|
||||
}
|
||||
m.updateTableRows()
|
||||
return m, nil
|
||||
}
|
||||
case "n":
|
||||
if !m.searchMode && !m.deleteMode {
|
||||
// Switch to sort by name
|
||||
m.sortMode = SortByName
|
||||
// Re-apply the current filter with the new sort mode
|
||||
if m.searchInput.Value() != "" {
|
||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||
} else {
|
||||
m.filteredHosts = m.sortHosts(m.hosts)
|
||||
}
|
||||
m.updateTableRows()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Update the appropriate component based on mode
|
||||
if m.searchMode {
|
||||
oldValue := m.searchInput.Value()
|
||||
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||
// Update filtered hosts only if the search value has changed
|
||||
if m.searchInput.Value() != oldValue {
|
||||
if m.searchInput.Value() != "" {
|
||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||
} else {
|
||||
m.filteredHosts = m.sortHosts(m.hosts)
|
||||
}
|
||||
m.updateTableRows()
|
||||
}
|
||||
} else {
|
||||
m.table, cmd = m.table.Update(msg)
|
||||
}
|
||||
|
||||
return m, cmd
|
||||
}
|
105
internal/ui/utils.go
Normal file
105
internal/ui/utils.go
Normal file
@ -0,0 +1,105 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sshm/internal/connectivity"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// formatTimeAgo formats a time into a readable "X time ago" string
|
||||
func formatTimeAgo(t time.Time) string {
|
||||
now := time.Now()
|
||||
duration := now.Sub(t)
|
||||
|
||||
switch {
|
||||
case duration < time.Minute:
|
||||
seconds := int(duration.Seconds())
|
||||
if seconds <= 1 {
|
||||
return "1 second ago"
|
||||
}
|
||||
return fmt.Sprintf("%d seconds ago", seconds)
|
||||
case duration < time.Hour:
|
||||
minutes := int(duration.Minutes())
|
||||
if minutes == 1 {
|
||||
return "1 minute ago"
|
||||
}
|
||||
return fmt.Sprintf("%d minutes ago", minutes)
|
||||
case duration < 24*time.Hour:
|
||||
hours := int(duration.Hours())
|
||||
if hours == 1 {
|
||||
return "1 hour ago"
|
||||
}
|
||||
return fmt.Sprintf("%d hours ago", hours)
|
||||
case duration < 7*24*time.Hour:
|
||||
days := int(duration.Hours() / 24)
|
||||
if days == 1 {
|
||||
return "1 day ago"
|
||||
}
|
||||
return fmt.Sprintf("%d days ago", days)
|
||||
case duration < 30*24*time.Hour:
|
||||
weeks := int(duration.Hours() / (24 * 7))
|
||||
if weeks == 1 {
|
||||
return "1 week ago"
|
||||
}
|
||||
return fmt.Sprintf("%d weeks ago", weeks)
|
||||
case duration < 365*24*time.Hour:
|
||||
months := int(duration.Hours() / (24 * 30))
|
||||
if months == 1 {
|
||||
return "1 month ago"
|
||||
}
|
||||
return fmt.Sprintf("%d months ago", months)
|
||||
default:
|
||||
years := int(duration.Hours() / (24 * 365))
|
||||
if years == 1 {
|
||||
return "1 year ago"
|
||||
}
|
||||
return fmt.Sprintf("%d years ago", years)
|
||||
}
|
||||
}
|
||||
|
||||
// formatConfigFile formats a config file path for display
|
||||
func formatConfigFile(filePath string) string {
|
||||
if filePath == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
// Show just the filename and parent directory for readability
|
||||
parts := strings.Split(filePath, "/")
|
||||
if len(parts) >= 2 {
|
||||
return fmt.Sprintf(".../%s/%s", parts[len(parts)-2], parts[len(parts)-1])
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
// getPingStatusIndicator returns a colored circle indicator based on ping status
|
||||
func (m *Model) getPingStatusIndicator(hostName string) string {
|
||||
if m.pingManager == nil {
|
||||
return "⚫" // Gray circle for unknown
|
||||
}
|
||||
|
||||
status := m.pingManager.GetStatus(hostName)
|
||||
switch status {
|
||||
case connectivity.StatusOnline:
|
||||
return "🟢" // Green circle for online
|
||||
case connectivity.StatusOffline:
|
||||
return "🔴" // Red circle for offline
|
||||
case connectivity.StatusConnecting:
|
||||
return "🟡" // Yellow circle for connecting
|
||||
default:
|
||||
return "⚫" // Gray circle for unknown
|
||||
}
|
||||
}
|
||||
|
||||
// extractHostNameFromTableRow extracts the host name from the first column,
|
||||
// removing the ping status indicator
|
||||
func extractHostNameFromTableRow(firstColumn string) string {
|
||||
// The first column format is: "🟢 hostname" or "⚫ hostname" etc.
|
||||
// We need to remove the emoji and space to get just the hostname
|
||||
parts := strings.Fields(firstColumn)
|
||||
if len(parts) >= 2 {
|
||||
// Return everything after the first part (the emoji)
|
||||
return strings.Join(parts[1:], " ")
|
||||
}
|
||||
// Fallback: if there's no space, return the whole string
|
||||
return firstColumn
|
||||
}
|
159
internal/ui/view.go
Normal file
159
internal/ui/view.go
Normal file
@ -0,0 +1,159 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// View renders the complete user interface
|
||||
func (m Model) View() string {
|
||||
if !m.ready {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
// Handle different view modes
|
||||
switch m.viewMode {
|
||||
case ViewAdd:
|
||||
if m.addForm != nil {
|
||||
return m.addForm.View()
|
||||
}
|
||||
case ViewEdit:
|
||||
if m.editForm != nil {
|
||||
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 ViewFileSelector:
|
||||
if m.fileSelectorForm != nil {
|
||||
return m.fileSelectorForm.View()
|
||||
}
|
||||
case ViewList:
|
||||
return m.renderListView()
|
||||
}
|
||||
|
||||
return m.renderListView()
|
||||
}
|
||||
|
||||
// renderListView renders the main list interface
|
||||
func (m Model) renderListView() string {
|
||||
// Build the interface components
|
||||
components := []string{}
|
||||
|
||||
// Add the ASCII title
|
||||
components = append(components, m.styles.Header.Render(asciiTitle))
|
||||
|
||||
// Add the search bar with the appropriate style based on focus
|
||||
searchPrompt := "Search (/ to focus): "
|
||||
if m.searchMode {
|
||||
components = append(components, m.styles.SearchFocused.Render(searchPrompt+m.searchInput.View()))
|
||||
} else {
|
||||
components = append(components, m.styles.SearchUnfocused.Render(searchPrompt+m.searchInput.View()))
|
||||
}
|
||||
|
||||
// Add the table with the appropriate style based on focus
|
||||
if m.searchMode {
|
||||
// The table is not focused, use the unfocused style
|
||||
components = append(components, m.styles.TableUnfocused.Render(m.table.View()))
|
||||
} else {
|
||||
// The table is focused, use the focused style with the primary color
|
||||
components = append(components, m.styles.TableFocused.Render(m.table.View()))
|
||||
}
|
||||
|
||||
// Add the help text
|
||||
var helpText string
|
||||
if !m.searchMode {
|
||||
helpText = " ↑/↓: navigate • Enter: connect • p: ping all • i: info • h: help • q: quit"
|
||||
} else {
|
||||
helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit"
|
||||
}
|
||||
components = append(components, m.styles.HelpText.Render(helpText))
|
||||
|
||||
// Join all components vertically with appropriate spacing
|
||||
mainView := m.styles.App.Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
components...,
|
||||
),
|
||||
)
|
||||
|
||||
// If in delete mode, overlay the confirmation dialog
|
||||
if m.deleteMode {
|
||||
// Combine the main view with the confirmation dialog overlay
|
||||
confirmation := m.renderDeleteConfirmation()
|
||||
|
||||
// Center the confirmation dialog on the screen
|
||||
centeredConfirmation := lipgloss.Place(
|
||||
m.width,
|
||||
m.height,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
confirmation,
|
||||
)
|
||||
|
||||
return centeredConfirmation
|
||||
}
|
||||
|
||||
return mainView
|
||||
}
|
||||
|
||||
// renderDeleteConfirmation renders a clean delete confirmation dialog
|
||||
func (m Model) renderDeleteConfirmation() string {
|
||||
// Remove emojis (uncertain width depending on terminal) to stabilize the frame
|
||||
title := "DELETE SSH HOST"
|
||||
question := fmt.Sprintf("Are you sure you want to delete host '%s'?", m.deleteHost)
|
||||
action := "This action cannot be undone."
|
||||
help := "Enter: confirm • Esc: cancel"
|
||||
|
||||
// Individual styles (do not affect width via internal centering)
|
||||
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("196"))
|
||||
questionStyle := lipgloss.NewStyle()
|
||||
actionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
|
||||
lines := []string{
|
||||
titleStyle.Render(title),
|
||||
"",
|
||||
questionStyle.Render(question),
|
||||
"",
|
||||
actionStyle.Render(action),
|
||||
"",
|
||||
helpStyle.Render(help),
|
||||
}
|
||||
|
||||
// Compute the real maximum width (ANSI-safe via lipgloss.Width)
|
||||
maxw := 0
|
||||
for _, ln := range lines {
|
||||
w := lipgloss.Width(ln)
|
||||
if w > maxw {
|
||||
maxw = w
|
||||
}
|
||||
}
|
||||
// Minimal width for aesthetics
|
||||
if maxw < 40 {
|
||||
maxw = 40
|
||||
}
|
||||
|
||||
// Build the raw text block (without centering) then apply the container style
|
||||
raw := strings.Join(lines, "\n")
|
||||
|
||||
// Container style: wider horizontal padding, stable border
|
||||
box := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("196")).
|
||||
PaddingTop(1).PaddingBottom(1).PaddingLeft(2).PaddingRight(2).
|
||||
Width(maxw + 4) // +4 = internal margin (2 spaces of left/right padding)
|
||||
|
||||
return box.Render(raw)
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user