mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2026-01-27 03:04:21 +01:00
Compare commits
22 Commits
1.0.0
...
146d04c9b7
| Author | SHA1 | Date | |
|---|---|---|---|
| 146d04c9b7 | |||
| 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.*
|
||||||
30
.github/workflows/build.yml
vendored
30
.github/workflows/build.yml
vendored
@@ -34,6 +34,14 @@ jobs:
|
|||||||
- goos: darwin
|
- goos: darwin
|
||||||
goarch: arm64
|
goarch: arm64
|
||||||
suffix: darwin-arm64
|
suffix: darwin-arm64
|
||||||
|
# Windows AMD64
|
||||||
|
- goos: windows
|
||||||
|
goarch: amd64
|
||||||
|
suffix: windows-amd64
|
||||||
|
# Windows ARM64
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm64
|
||||||
|
suffix: windows-arm64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -60,19 +68,30 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
VERSION=${GITHUB_REF#refs/tags/}
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }} .
|
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||||
|
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }}.exe .
|
||||||
|
else
|
||||||
|
go build -ldflags="-s -w -X sshm/cmd.version=${VERSION}" -o dist/sshm-${{ matrix.suffix }} .
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Create tarball
|
- name: Create archive
|
||||||
run: |
|
run: |
|
||||||
cd dist
|
cd dist
|
||||||
tar -czf sshm-${{ matrix.suffix }}.tar.gz sshm-${{ matrix.suffix }}
|
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||||
rm sshm-${{ matrix.suffix }}
|
zip sshm-${{ matrix.suffix }}.zip sshm-${{ matrix.suffix }}.exe
|
||||||
|
rm sshm-${{ matrix.suffix }}.exe
|
||||||
|
else
|
||||||
|
tar -czf sshm-${{ matrix.suffix }}.tar.gz sshm-${{ matrix.suffix }}
|
||||||
|
rm sshm-${{ matrix.suffix }}
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: sshm-${{ matrix.suffix }}
|
name: sshm-${{ matrix.suffix }}
|
||||||
path: dist/sshm-${{ matrix.suffix }}.tar.gz
|
path: |
|
||||||
|
dist/sshm-${{ matrix.suffix }}.tar.gz
|
||||||
|
dist/sshm-${{ matrix.suffix }}.zip
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: Create Release
|
name: Create Release
|
||||||
@@ -91,6 +110,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p release
|
mkdir -p release
|
||||||
find ./artifacts -name "*.tar.gz" -exec cp {} ./release/ \;
|
find ./artifacts -name "*.tar.gz" -exec cp {} ./release/ \;
|
||||||
|
find ./artifacts -name "*.zip" -exec cp {} ./release/ \;
|
||||||
ls -la ./release/
|
ls -la ./release/
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
|
|||||||
198
README.md
198
README.md
@@ -9,14 +9,18 @@
|
|||||||
[](https://golang.org/)
|
[](https://golang.org/)
|
||||||
[](https://github.com/Gu1llaum-3/sshm/releases)
|
[](https://github.com/Gu1llaum-3/sshm/releases)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/Gu1llaum-3/sshm/releases)
|
[](https://github.com/Gu1llaum-3/sshm/releases)
|
||||||
|
|
||||||
> **A modern, interactive SSH Manager for your terminal** 🔥
|
> **A modern, interactive SSH Manager for your terminal** 🔥
|
||||||
|
|
||||||
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.
|
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">
|
<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>
|
</p>
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
@@ -24,10 +28,12 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
|
|||||||
### 🎯 **Core Features**
|
### 🎯 **Core Features**
|
||||||
- **🎨 Beautiful TUI Interface** - Navigate your SSH hosts with an elegant, interactive terminal UI
|
- **🎨 Beautiful TUI Interface** - Navigate your SSH hosts with an elegant, interactive terminal UI
|
||||||
- **⚡ Quick Connect** - Connect to any host instantly
|
- **⚡ Quick Connect** - Connect to any host instantly
|
||||||
- **📝 Easy Management** - Add, edit, and manage SSH configurations seamlessly
|
- **🔄 Port Forwarding** - Easy setup for Local, Remote, and Dynamic (SOCKS) forwarding
|
||||||
|
- **📝Easy Management** - Add, edit, and manage SSH configurations seamlessly
|
||||||
- **🏷️ Tag Support** - Organize your hosts with custom tags for better categorization
|
- **🏷️ Tag Support** - Organize your hosts with custom tags for better categorization
|
||||||
- **🔍 Smart Search** - Find hosts quickly with built-in filtering and search
|
- **🔍 Smart Search** - Find hosts quickly with built-in filtering and search
|
||||||
- **🔒 Secure** - Works directly with your existing `~/.ssh/config` file
|
- **🔒 Secure** - Works directly with your existing `~/.ssh/config` file
|
||||||
|
- **📁 Custom Config Support** - Use any SSH configuration file with the `-c` flag
|
||||||
- **⚙️ SSH Options Support** - Add any SSH configuration option through intuitive forms
|
- **⚙️ SSH Options Support** - Add any SSH configuration option through intuitive forms
|
||||||
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
|
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
|
||||||
|
|
||||||
@@ -35,6 +41,7 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
|
|||||||
- **Add new SSH hosts** with interactive forms
|
- **Add new SSH hosts** with interactive forms
|
||||||
- **Edit existing configurations** in-place
|
- **Edit existing configurations** in-place
|
||||||
- **Delete hosts** with confirmation prompts
|
- **Delete hosts** with confirmation prompts
|
||||||
|
- **Port forwarding setup** with intuitive interface for Local (-L), Remote (-R), and Dynamic (-D) forwarding
|
||||||
- **Backup configurations** automatically before changes
|
- **Backup configurations** automatically before changes
|
||||||
- **Validate settings** to prevent configuration errors
|
- **Validate settings** to prevent configuration errors
|
||||||
- **ProxyJump support** for secure connection tunneling through bastion hosts
|
- **ProxyJump support** for secure connection tunneling through bastion hosts
|
||||||
@@ -44,19 +51,26 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
|
|||||||
### 🎮 **User Experience**
|
### 🎮 **User Experience**
|
||||||
- **Zero configuration** - Works out of the box with your existing SSH setup
|
- **Zero configuration** - Works out of the box with your existing SSH setup
|
||||||
- **Keyboard shortcuts** for power users
|
- **Keyboard shortcuts** for power users
|
||||||
- **Cross-platform** - Supports Linux and macOS (Intel & Apple Silicon)
|
- **Cross-platform** - Supports Linux, macOS (Intel & Apple Silicon), and Windows
|
||||||
- **Lightweight** - Single binary with no dependencies
|
- **Lightweight** - Single binary with no dependencies
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
**One-line install (Recommended):**
|
**Unix/Linux/macOS (One-line install):**
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh | bash
|
curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Windows (PowerShell):**
|
||||||
|
```powershell
|
||||||
|
irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex
|
||||||
|
```
|
||||||
|
|
||||||
**Alternative methods:**
|
**Alternative methods:**
|
||||||
|
|
||||||
|
*Linux/macOS:*
|
||||||
```bash
|
```bash
|
||||||
# Download specific release
|
# Download specific release
|
||||||
wget https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-amd64.tar.gz
|
wget https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-amd64.tar.gz
|
||||||
@@ -66,6 +80,14 @@ tar -xzf sshm-linux-amd64.tar.gz
|
|||||||
sudo mv sshm-linux-amd64 /usr/local/bin/sshm
|
sudo mv sshm-linux-amd64 /usr/local/bin/sshm
|
||||||
```
|
```
|
||||||
|
|
||||||
|
*Windows:*
|
||||||
|
```powershell
|
||||||
|
# Download and extract
|
||||||
|
Invoke-WebRequest -Uri "https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-windows-amd64.zip" -OutFile "sshm-windows-amd64.zip"
|
||||||
|
Expand-Archive sshm-windows-amd64.zip -DestinationPath C:\tools\
|
||||||
|
# Add C:\tools to your PATH environment variable
|
||||||
|
```
|
||||||
|
|
||||||
## 📖 Usage
|
## 📖 Usage
|
||||||
|
|
||||||
### Interactive Mode
|
### Interactive Mode
|
||||||
@@ -82,9 +104,18 @@ sshm
|
|||||||
- `a` - Add new host
|
- `a` - Add new host
|
||||||
- `e` - Edit selected host
|
- `e` - Edit selected host
|
||||||
- `d` - Delete selected host
|
- `d` - Delete selected host
|
||||||
|
- `f` - Port forwarding setup
|
||||||
- `q` - Quit
|
- `q` - Quit
|
||||||
- `/` - Search/filter hosts
|
- `/` - Search/filter hosts
|
||||||
|
|
||||||
|
**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:
|
The interactive forms will guide you through configuration:
|
||||||
- **Hostname/IP** - Server address
|
- **Hostname/IP** - Server address
|
||||||
- **Username** - SSH user
|
- **Username** - SSH user
|
||||||
@@ -94,6 +125,90 @@ The interactive forms will guide you through configuration:
|
|||||||
- **SSH Options** - Additional SSH options in `-o` format (e.g., `-o Compression=yes -o ServerAliveInterval=60`)
|
- **SSH Options** - Additional SSH options in `-o` format (e.g., `-o Compression=yes -o ServerAliveInterval=60`)
|
||||||
- **Tags** - Comma-separated tags for organization
|
- **Tags** - Comma-separated tags for organization
|
||||||
|
|
||||||
|
### Port Forwarding
|
||||||
|
|
||||||
|
SSHM provides an intuitive interface for setting up SSH port forwarding. Press `f` while selecting a host to open the port forwarding setup:
|
||||||
|
|
||||||
|
**Forward Types:**
|
||||||
|
- **Local (-L)** - Forward a local port to a remote host/port through the SSH connection
|
||||||
|
- Example: Access a remote database on `localhost:5432` via local port `15432`
|
||||||
|
- Use case: `ssh -L 15432:localhost:5432 server` → Database accessible on `localhost:15432`
|
||||||
|
|
||||||
|
- **Remote (-R)** - Forward a remote port back to a local host/port
|
||||||
|
- Example: Expose local web server on remote host's port `8080`
|
||||||
|
- Use case: `ssh -R 8080:localhost:3000 server` → Local app accessible from remote host's port 8080
|
||||||
|
- ⚠️ **Requirements for external access:**
|
||||||
|
- **SSH Server Config**: Add `GatewayPorts yes` to `/etc/ssh/sshd_config` and restart SSH service
|
||||||
|
- **Firewall**: Open the remote port in the server's firewall (`ufw allow 8080` or equivalent)
|
||||||
|
- **Port Availability**: Ensure the remote port is not already in use
|
||||||
|
- **Bind Address**: Use `0.0.0.0` for external access, `127.0.0.1` for local-only
|
||||||
|
|
||||||
|
- **Dynamic (-D)** - Create a SOCKS proxy for secure browsing
|
||||||
|
- Example: Route web traffic through the SSH connection
|
||||||
|
- Use case: `ssh -D 1080 server` → Configure browser to use `localhost:1080` as SOCKS proxy
|
||||||
|
- ⚠️ **Configuration requirements:**
|
||||||
|
- **Browser Setup**: Configure SOCKS v5 proxy in browser settings
|
||||||
|
- **DNS**: Enable "Proxy DNS when using SOCKS v5" for full privacy
|
||||||
|
- **Applications**: Only SOCKS-aware applications will use the proxy
|
||||||
|
- **Bind Address**: Use `127.0.0.1` for security (local access only)
|
||||||
|
|
||||||
|
**Port Forwarding Interface:**
|
||||||
|
- Choose forward type with ←/→ arrow keys
|
||||||
|
- Configure ports and addresses with guided forms
|
||||||
|
- Optional bind address configuration (defaults to 127.0.0.1)
|
||||||
|
- Real-time validation of port numbers and addresses
|
||||||
|
- Connect automatically with configured forwarding options
|
||||||
|
|
||||||
|
**Troubleshooting Port Forwarding:**
|
||||||
|
|
||||||
|
*Remote Forwarding Issues:*
|
||||||
|
```bash
|
||||||
|
# Error: "remote port forwarding failed for listen port X"
|
||||||
|
# Solutions:
|
||||||
|
1. Check if port is already in use: ssh server "netstat -tln | grep :X"
|
||||||
|
2. Use a different port that's available
|
||||||
|
3. Enable GatewayPorts in SSH config for external access
|
||||||
|
```
|
||||||
|
|
||||||
|
*SSH Server Configuration for Remote Forwarding:*
|
||||||
|
```bash
|
||||||
|
# Edit SSH daemon config on the server:
|
||||||
|
sudo nano /etc/ssh/sshd_config
|
||||||
|
|
||||||
|
# Add or uncomment:
|
||||||
|
GatewayPorts yes
|
||||||
|
|
||||||
|
# Restart SSH service:
|
||||||
|
sudo systemctl restart sshd # Ubuntu/Debian/CentOS 7+
|
||||||
|
# OR
|
||||||
|
sudo service ssh restart # Older systems
|
||||||
|
```
|
||||||
|
|
||||||
|
*Firewall Configuration:*
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian (UFW):
|
||||||
|
sudo ufw allow [port_number]
|
||||||
|
|
||||||
|
# CentOS/RHEL/Rocky (firewalld):
|
||||||
|
sudo firewall-cmd --add-port=[port_number]/tcp --permanent
|
||||||
|
sudo firewall-cmd --reload
|
||||||
|
|
||||||
|
# Check if port is accessible:
|
||||||
|
telnet [server_ip] [port_number]
|
||||||
|
```
|
||||||
|
|
||||||
|
*Dynamic Forwarding (SOCKS) Browser Setup:*
|
||||||
|
```
|
||||||
|
Firefox: about:preferences → Network Settings
|
||||||
|
- Manual proxy configuration
|
||||||
|
- SOCKS Host: localhost, Port: [your_port]
|
||||||
|
- SOCKS v5: ✓
|
||||||
|
- Proxy DNS when using SOCKS v5: ✓
|
||||||
|
|
||||||
|
Chrome: Launch with proxy
|
||||||
|
chrome --proxy-server="socks5://localhost:[your_port]"
|
||||||
|
```
|
||||||
|
|
||||||
### CLI Usage
|
### CLI Usage
|
||||||
|
|
||||||
SSHM provides both command-line operations and an interactive TUI interface:
|
SSHM provides both command-line operations and an interactive TUI interface:
|
||||||
@@ -102,15 +217,24 @@ SSHM provides both command-line operations and an interactive TUI interface:
|
|||||||
# Launch interactive TUI mode for browsing and connecting to hosts
|
# Launch interactive TUI mode for browsing and connecting to hosts
|
||||||
sshm
|
sshm
|
||||||
|
|
||||||
|
# Launch TUI with custom SSH config file
|
||||||
|
sshm -c /path/to/custom/ssh_config
|
||||||
|
|
||||||
# Add a new host using interactive form
|
# Add a new host using interactive form
|
||||||
sshm add
|
sshm add
|
||||||
|
|
||||||
# Add a new host with pre-filled hostname
|
# Add a new host with pre-filled hostname
|
||||||
sshm add 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
|
# Edit an existing host configuration
|
||||||
sshm edit my-server
|
sshm edit my-server
|
||||||
|
|
||||||
|
# Edit host with custom SSH config file
|
||||||
|
sshm edit my-server -c /path/to/custom/ssh_config
|
||||||
|
|
||||||
# Show version information
|
# Show version information
|
||||||
sshm --version
|
sshm --version
|
||||||
|
|
||||||
@@ -118,6 +242,32 @@ sshm --version
|
|||||||
sshm --help
|
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
|
## 🏗️ Configuration
|
||||||
|
|
||||||
SSHM works directly with your standard SSH configuration file (`~/.ssh/config`). It adds special comment tags for enhanced functionality while maintaining full compatibility with standard SSH tools.
|
SSHM works directly with your standard SSH configuration file (`~/.ssh/config`). It adds special comment tags for enhanced functionality while maintaining full compatibility with standard SSH tools.
|
||||||
@@ -222,24 +372,44 @@ go build -o sshm .
|
|||||||
|
|
||||||
```
|
```
|
||||||
sshm/
|
sshm/
|
||||||
├── cmd/ # CLI commands (Cobra)
|
├── main.go # Application entry point
|
||||||
|
├── cmd/ # CLI commands (Cobra)
|
||||||
│ ├── root.go # Root command and interactive mode
|
│ ├── root.go # Root command and interactive mode
|
||||||
│ ├── add.go # Add host command
|
│ ├── add.go # Add host command
|
||||||
│ └── edit.go # Edit host command
|
│ ├── edit.go # Edit host command
|
||||||
|
│ └── search.go # Search command
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── config/ # SSH configuration management
|
│ ├── config/ # SSH configuration management
|
||||||
│ │ └── ssh.go # Config parsing and manipulation
|
│ │ └── ssh.go # Config parsing and manipulation
|
||||||
│ ├── ui/ # Terminal UI components
|
│ ├── history/ # Connection history tracking
|
||||||
│ │ ├── tui.go # Main TUI interface
|
│ │ └── history.go # History management and last login tracking
|
||||||
│ │ ├── add_form.go # Add host form
|
│ ├── ui/ # Terminal UI components (Bubble Tea)
|
||||||
│ │ └── edit_form.go# Edit host form
|
│ │ ├── 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
|
│ └── validation/ # Input validation
|
||||||
│ └── ssh.go # SSH config validation
|
│ └── ssh.go # SSH config validation
|
||||||
|
├── images/ # Documentation assets
|
||||||
|
│ ├── logo.png # Project logo
|
||||||
|
│ └── sshm.gif # Demo animation
|
||||||
├── install/ # Installation scripts
|
├── install/ # Installation scripts
|
||||||
│ ├── unix.sh # Unix/Linux/macOS installer
|
│ ├── unix.sh # Unix/Linux/macOS installer
|
||||||
│ └── README.md # Installation guide
|
│ └── README.md # Installation guide
|
||||||
└── .github/workflows/ # CI/CD pipelines
|
├── .github/ # GitHub configuration
|
||||||
└── build.yml # Multi-platform builds
|
│ ├── 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
|
### Dependencies
|
||||||
@@ -259,6 +429,8 @@ Automated releases are built for multiple platforms:
|
|||||||
| Linux | ARM64 | [sshm-linux-arm64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-arm64.tar.gz) |
|
| Linux | ARM64 | [sshm-linux-arm64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-linux-arm64.tar.gz) |
|
||||||
| macOS | Intel | [sshm-darwin-amd64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-darwin-amd64.tar.gz) |
|
| macOS | Intel | [sshm-darwin-amd64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-darwin-amd64.tar.gz) |
|
||||||
| macOS | Apple Silicon | [sshm-darwin-arm64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-darwin-arm64.tar.gz) |
|
| macOS | Apple Silicon | [sshm-darwin-arm64.tar.gz](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-darwin-arm64.tar.gz) |
|
||||||
|
| Windows | AMD64 | [sshm-windows-amd64.zip](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-windows-amd64.zip) |
|
||||||
|
| Windows | ARM64 | [sshm-windows-arm64.zip](https://github.com/Gu1llaum-3/sshm/releases/latest/download/sshm-windows-arm64.zip) |
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ var addCmd = &cobra.Command{
|
|||||||
hostname = args[0]
|
hostname = args[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
err := ui.RunAddForm(hostname)
|
err := ui.RunAddForm(hostname, configFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error adding host: %v\n", err)
|
fmt.Printf("Error adding host: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ var editCmd = &cobra.Command{
|
|||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
hostname := args[0]
|
hostname := args[0]
|
||||||
|
|
||||||
err := ui.RunEditForm(hostname)
|
err := ui.RunEditForm(hostname, configFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error editing host: %v\n", err)
|
fmt.Printf("Error editing host: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|||||||
61
cmd/root.go
61
cmd/root.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"sshm/internal/config"
|
"sshm/internal/config"
|
||||||
"sshm/internal/ui"
|
"sshm/internal/ui"
|
||||||
@@ -14,12 +15,20 @@ import (
|
|||||||
// version will be set at build time via -ldflags
|
// version will be set at build time via -ldflags
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
|
// configFile holds the path to the SSH config file
|
||||||
|
var configFile string
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "sshm",
|
Use: "sshm",
|
||||||
Short: "SSH Manager - A modern SSH connection manager",
|
Short: "SSH Manager - A modern SSH connection manager",
|
||||||
Long: `SSH Manager (sshm) is a modern command-line tool for managing SSH connections.
|
Long: `SSHM is a modern SSH manager for your terminal.
|
||||||
It provides an interactive interface to browse and connect to your SSH hosts
|
|
||||||
configured in your ~/.ssh/config file.`,
|
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,
|
Version: version,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
// If no arguments provided, run interactive mode
|
// If no arguments provided, run interactive mode
|
||||||
@@ -36,7 +45,15 @@ configured in your ~/.ssh/config file.`,
|
|||||||
|
|
||||||
func runInteractiveMode() {
|
func runInteractiveMode() {
|
||||||
// Parse SSH configurations
|
// 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 {
|
if err != nil {
|
||||||
log.Fatalf("Error reading SSH config file: %v", err)
|
log.Fatalf("Error reading SSH config file: %v", err)
|
||||||
}
|
}
|
||||||
@@ -47,12 +64,16 @@ func runInteractiveMode() {
|
|||||||
var response string
|
var response string
|
||||||
_, err := fmt.Scanln(&response)
|
_, err := fmt.Scanln(&response)
|
||||||
if err == nil && (response == "y" || response == "Y") {
|
if err == nil && (response == "y" || response == "Y") {
|
||||||
err := ui.RunAddForm("")
|
err := ui.RunAddForm("", configFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error adding host: %v\n", err)
|
fmt.Printf("Error adding host: %v\n", err)
|
||||||
}
|
}
|
||||||
// After adding, try to reload hosts and continue if any exist
|
// 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 {
|
if err != nil || len(hosts) == 0 {
|
||||||
fmt.Println("No hosts available, exiting.")
|
fmt.Println("No hosts available, exiting.")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -64,14 +85,22 @@ func runInteractiveMode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run the interactive TUI
|
// 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)
|
log.Fatalf("Error running interactive mode: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func connectToHost(hostName string) {
|
func connectToHost(hostName string) {
|
||||||
// Parse SSH configurations to verify host exists
|
// 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 {
|
if err != nil {
|
||||||
log.Fatalf("Error reading SSH config file: %v", err)
|
log.Fatalf("Error reading SSH config file: %v", err)
|
||||||
}
|
}
|
||||||
@@ -93,9 +122,18 @@ func connectToHost(hostName string) {
|
|||||||
|
|
||||||
// Connect to the host
|
// Connect to the host
|
||||||
fmt.Printf("Connecting to %s...\n", hostName)
|
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
|
// Note: In a real implementation, you'd use exec.Command here
|
||||||
// For now, just print the command that would be executed
|
// 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.
|
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||||
@@ -105,3 +143,8 @@ func Execute() {
|
|||||||
os.Exit(1)
|
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")
|
||||||
|
}
|
||||||
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.
|
**Note:** When using the pipe method, the installer will automatically proceed with installation if SSHM is already installed.
|
||||||
|
|
||||||
|
## Windows Installation
|
||||||
|
|
||||||
|
### Quick Install (Recommended)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex
|
||||||
|
```
|
||||||
|
|
||||||
### Install Options
|
### Install Options
|
||||||
|
|
||||||
|
**Force install without prompts:**
|
||||||
|
```powershell
|
||||||
|
iex "& { $(irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1) } -Force"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Custom installation directory:**
|
||||||
|
```powershell
|
||||||
|
iex "& { $(irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1) } -InstallDir 'C:\tools'"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unix/Linux/macOS Advanced Options
|
||||||
|
|
||||||
**Force install without prompts:**
|
**Force install without prompts:**
|
||||||
```bash
|
```bash
|
||||||
FORCE_INSTALL=true bash -c "$(curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh)"
|
FORCE_INSTALL=true bash -c "$(curl -sSL https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/unix.sh)"
|
||||||
|
|||||||
@@ -74,10 +74,10 @@ downloadBinary() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Find the extracted binary
|
# Check if the expected binary exists (no find needed)
|
||||||
EXTRACTED_BINARY=$(find . -name "sshm-${OS}-${ARCH}" -type f)
|
EXTRACTED_BINARY="./sshm-${OS}-${ARCH}"
|
||||||
if [ -z "$EXTRACTED_BINARY" ]; then
|
if [ ! -f "$EXTRACTED_BINARY" ]; then
|
||||||
printf "${RED}Could not find extracted binary${NC}\n"
|
printf "${RED}Could not find extracted binary: $EXTRACTED_BINARY${NC}\n"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -88,17 +88,37 @@ downloadBinary() {
|
|||||||
install() {
|
install() {
|
||||||
printf "${YELLOW}Installing SSHM...${NC}\n"
|
printf "${YELLOW}Installing SSHM...${NC}\n"
|
||||||
|
|
||||||
|
# Backup old version if it exists to prevent interference during installation
|
||||||
|
OLD_BACKUP=""
|
||||||
|
if [ -f "$EXECUTABLE_PATH" ]; then
|
||||||
|
OLD_BACKUP="$EXECUTABLE_PATH.backup.$$"
|
||||||
|
runAsRoot mv "$EXECUTABLE_PATH" "$OLD_BACKUP"
|
||||||
|
fi
|
||||||
|
|
||||||
chmod +x "sshm-tmp"
|
chmod +x "sshm-tmp"
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
printf "${RED}Failed to set permissions${NC}\n"
|
printf "${RED}Failed to set permissions${NC}\n"
|
||||||
|
# Restore backup if installation fails
|
||||||
|
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
|
||||||
|
runAsRoot mv "$OLD_BACKUP" "$EXECUTABLE_PATH"
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
runAsRoot mv "sshm-tmp" "$EXECUTABLE_PATH"
|
runAsRoot mv "sshm-tmp" "$EXECUTABLE_PATH"
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
printf "${RED}Failed to install binary${NC}\n"
|
printf "${RED}Failed to install binary${NC}\n"
|
||||||
|
# Restore backup if installation fails
|
||||||
|
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
|
||||||
|
runAsRoot mv "$OLD_BACKUP" "$EXECUTABLE_PATH"
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Clean up backup if installation succeeded
|
||||||
|
if [ -n "$OLD_BACKUP" ] && [ -f "$OLD_BACKUP" ]; then
|
||||||
|
runAsRoot rm -f "$OLD_BACKUP"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
@@ -161,7 +181,8 @@ main() {
|
|||||||
# Show version
|
# Show version
|
||||||
printf "${YELLOW}Verifying installation...${NC}\n"
|
printf "${YELLOW}Verifying installation...${NC}\n"
|
||||||
if command -v sshm >/dev/null 2>&1; then
|
if command -v sshm >/dev/null 2>&1; then
|
||||||
sshm --version
|
# Use the full path to ensure we're using the newly installed version
|
||||||
|
"$EXECUTABLE_PATH" --version 2>/dev/null || echo "Version check failed, but installation completed"
|
||||||
else
|
else
|
||||||
printf "${RED}Warning: 'sshm' command not found in PATH. You may need to restart your terminal or add $INSTALL_DIR to your PATH.${NC}\n"
|
printf "${RED}Warning: 'sshm' command not found in PATH. You may need to restart your terminal or add $INSTALL_DIR to your PATH.${NC}\n"
|
||||||
fi
|
fi
|
||||||
|
|||||||
135
install/windows.ps1
Normal file
135
install/windows.ps1
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# SSHM Windows Installation Script
|
||||||
|
# Usage:
|
||||||
|
# Online: irm https://raw.githubusercontent.com/Gu1llaum-3/sshm/main/install/windows.ps1 | iex
|
||||||
|
# Local: .\install\windows.ps1 -LocalBinary ".\sshm.exe"
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$InstallDir = "$env:LOCALAPPDATA\sshm",
|
||||||
|
[switch]$Force = $false,
|
||||||
|
[string]$LocalBinary = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
function Write-ColorOutput($ForegroundColor) {
|
||||||
|
$fc = $host.UI.RawUI.ForegroundColor
|
||||||
|
$host.UI.RawUI.ForegroundColor = $ForegroundColor
|
||||||
|
if ($args) {
|
||||||
|
Write-Output $args
|
||||||
|
}
|
||||||
|
$host.UI.RawUI.ForegroundColor = $fc
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Info { Write-ColorOutput Green $args }
|
||||||
|
function Write-Warning { Write-ColorOutput Yellow $args }
|
||||||
|
function Write-Error { Write-ColorOutput Red $args }
|
||||||
|
|
||||||
|
Write-Info "🚀 Installing SSHM - SSH Manager"
|
||||||
|
Write-Info ""
|
||||||
|
|
||||||
|
# Check if SSHM is already installed
|
||||||
|
$existingSSHM = Get-Command sshm -ErrorAction SilentlyContinue
|
||||||
|
if ($existingSSHM -and -not $Force) {
|
||||||
|
$currentVersion = & sshm --version 2>$null | Select-String "version" | ForEach-Object { $_.ToString().Split()[-1] }
|
||||||
|
Write-Warning "SSHM is already installed (version: $currentVersion)"
|
||||||
|
$response = Read-Host "Do you want to continue with the installation? (y/N)"
|
||||||
|
if ($response -ne "y" -and $response -ne "Y") {
|
||||||
|
Write-Info "Installation cancelled."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect architecture
|
||||||
|
$arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "386" }
|
||||||
|
Write-Info "Detected platform: Windows ($arch)"
|
||||||
|
|
||||||
|
# Check if using local binary
|
||||||
|
if ($LocalBinary -ne "") {
|
||||||
|
if (-not (Test-Path $LocalBinary)) {
|
||||||
|
Write-Error "Local binary not found: $LocalBinary"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Info "Using local binary: $LocalBinary"
|
||||||
|
$targetPath = "$InstallDir\sshm.exe"
|
||||||
|
|
||||||
|
# Create installation directory
|
||||||
|
if (-not (Test-Path $InstallDir)) {
|
||||||
|
Write-Info "Creating installation directory: $InstallDir"
|
||||||
|
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Copy local binary
|
||||||
|
Write-Info "Installing binary to: $targetPath"
|
||||||
|
Copy-Item -Path $LocalBinary -Destination $targetPath -Force
|
||||||
|
|
||||||
|
} else {
|
||||||
|
# Online installation
|
||||||
|
Write-Info "Starting online installation..."
|
||||||
|
|
||||||
|
# Get latest version
|
||||||
|
Write-Info "Fetching latest version..."
|
||||||
|
try {
|
||||||
|
$latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/Gu1llaum-3/sshm/releases/latest"
|
||||||
|
$latestVersion = $latestRelease.tag_name
|
||||||
|
Write-Info "Target version: $latestVersion"
|
||||||
|
} catch {
|
||||||
|
Write-Error "Failed to fetch version information"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Download binary
|
||||||
|
$fileName = "sshm-windows-$arch.zip"
|
||||||
|
$downloadUrl = "https://github.com/Gu1llaum-3/sshm/releases/download/$latestVersion/$fileName"
|
||||||
|
$tempFile = "$env:TEMP\$fileName"
|
||||||
|
|
||||||
|
Write-Info "Downloading $fileName..."
|
||||||
|
try {
|
||||||
|
Invoke-WebRequest -Uri $downloadUrl -OutFile $tempFile
|
||||||
|
} catch {
|
||||||
|
Write-Error "Download failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create installation directory
|
||||||
|
if (-not (Test-Path $InstallDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract archive
|
||||||
|
Write-Info "Extracting..."
|
||||||
|
try {
|
||||||
|
Expand-Archive -Path $tempFile -DestinationPath $env:TEMP -Force
|
||||||
|
$extractedBinary = "$env:TEMP\sshm-windows-$arch.exe"
|
||||||
|
$targetPath = "$InstallDir\sshm.exe"
|
||||||
|
|
||||||
|
Move-Item -Path $extractedBinary -Destination $targetPath -Force
|
||||||
|
} catch {
|
||||||
|
Write-Error "Extraction failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
Remove-Item $tempFile -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check PATH
|
||||||
|
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
||||||
|
if ($userPath -notlike "*$InstallDir*") {
|
||||||
|
Write-Warning "The directory $InstallDir is not in your PATH."
|
||||||
|
Write-Info "Adding to user PATH..."
|
||||||
|
[Environment]::SetEnvironmentVariable("Path", "$userPath;$InstallDir", "User")
|
||||||
|
Write-Info "Please restart your terminal to use the 'sshm' command."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Info ""
|
||||||
|
Write-Info "✅ SSHM successfully installed to: $targetPath"
|
||||||
|
Write-Info "You can now use the 'sshm' command!"
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
if (Test-Path $targetPath) {
|
||||||
|
Write-Info ""
|
||||||
|
Write-Info "Verifying installation..."
|
||||||
|
& $targetPath --version
|
||||||
|
}
|
||||||
11
internal/config/permissions_unix.go
Normal file
11
internal/config/permissions_unix.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// SetSecureFilePermissions configures secure permissions on Unix systems
|
||||||
|
func SetSecureFilePermissions(filepath string) error {
|
||||||
|
// Set file permissions to 0600 (owner read/write only)
|
||||||
|
return os.Chmod(filepath, 0600)
|
||||||
|
}
|
||||||
24
internal/config/permissions_windows.go
Normal file
24
internal/config/permissions_windows.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetSecureFilePermissions configures secure permissions on Windows
|
||||||
|
func SetSecureFilePermissions(filepath string) error {
|
||||||
|
// On Windows, file permissions work differently
|
||||||
|
// We ensure the file is not read-only and has basic permissions
|
||||||
|
info, err := os.Stat(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the file is not read-only
|
||||||
|
if info.Mode()&os.ModeType == 0 {
|
||||||
|
return os.Chmod(filepath, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -22,6 +23,46 @@ type SSHHost struct {
|
|||||||
Tags []string
|
Tags []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDefaultSSHConfigPath returns the default SSH config path for the current platform
|
||||||
|
func GetDefaultSSHConfigPath() (string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
return filepath.Join(homeDir, ".ssh", "config"), nil
|
||||||
|
default:
|
||||||
|
// Linux, macOS, etc.
|
||||||
|
return filepath.Join(homeDir, ".ssh", "config"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSSHDirectory returns the .ssh directory path
|
||||||
|
func GetSSHDirectory() (string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(homeDir, ".ssh"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSSHDirectory creates the .ssh directory with appropriate permissions
|
||||||
|
func ensureSSHDirectory() error {
|
||||||
|
sshDir, err := GetSSHDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(sshDir); os.IsNotExist(err) {
|
||||||
|
// 0700 provides owner-only access across platforms
|
||||||
|
return os.MkdirAll(sshDir, 0700)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// configMutex protects SSH config file operations from race conditions
|
// configMutex protects SSH config file operations from race conditions
|
||||||
var configMutex sync.Mutex
|
var configMutex sync.Mutex
|
||||||
|
|
||||||
@@ -46,32 +87,54 @@ func backupConfig(configPath string) error {
|
|||||||
|
|
||||||
// ParseSSHConfig parses the SSH config file and returns the list of hosts
|
// ParseSSHConfig parses the SSH config file and returns the list of hosts
|
||||||
func ParseSSHConfig() ([]SSHHost, error) {
|
func ParseSSHConfig() ([]SSHHost, error) {
|
||||||
homeDir, err := os.UserHomeDir()
|
configPath, err := GetDefaultSSHConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
|
||||||
return ParseSSHConfigFile(configPath)
|
return ParseSSHConfigFile(configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseSSHConfigFile parses a specific SSH config file and returns the list of hosts
|
// ParseSSHConfigFile parses a specific SSH config file and returns the list of hosts
|
||||||
func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
||||||
|
return parseSSHConfigFileWithProcessedFiles(configPath, make(map[string]bool))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSSHConfigFileWithProcessedFiles parses SSH config with include support
|
||||||
|
func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[string]bool) ([]SSHHost, error) {
|
||||||
|
// Resolve absolute path to prevent infinite recursion
|
||||||
|
absPath, err := filepath.Abs(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve absolute path for %s: %w", configPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for circular includes
|
||||||
|
if processedFiles[absPath] {
|
||||||
|
return []SSHHost{}, nil // Skip already processed files silently
|
||||||
|
}
|
||||||
|
processedFiles[absPath] = true
|
||||||
|
|
||||||
// Check if the file exists, otherwise create it (and the parent directory if needed)
|
// Check if the file exists, otherwise create it (and the parent directory if needed)
|
||||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
dir := filepath.Dir(configPath)
|
// Only create the main config file, not included files
|
||||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
if absPath == getMainConfigPath() {
|
||||||
err = os.MkdirAll(dir, 0700)
|
// Ensure .ssh directory exists with proper permissions
|
||||||
if err != nil {
|
if err := ensureSSHDirectory(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create .ssh directory: %w", err)
|
return nil, fmt.Errorf("failed to create .ssh directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
file, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create SSH config file: %w", err)
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
// Set secure permissions on the config file
|
||||||
|
if err := SetSecureFilePermissions(configPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set secure permissions: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
file, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY, 0600)
|
|
||||||
if err != nil {
|
// File doesn't exist, return empty host list
|
||||||
return nil, fmt.Errorf("failed to create SSH config file: %w", err)
|
|
||||||
}
|
|
||||||
file.Close()
|
|
||||||
// File created, return empty host list
|
|
||||||
return []SSHHost{}, nil
|
return []SSHHost{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,11 +188,25 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
|||||||
value := strings.Join(parts[1:], " ")
|
value := strings.Join(parts[1:], " ")
|
||||||
|
|
||||||
switch key {
|
switch key {
|
||||||
|
case "include":
|
||||||
|
// Handle Include directive
|
||||||
|
includeHosts, err := processIncludeDirective(value, configPath, processedFiles)
|
||||||
|
if err != nil {
|
||||||
|
// Don't fail the entire parse if include fails, just skip it
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hosts = append(hosts, includeHosts...)
|
||||||
case "host":
|
case "host":
|
||||||
// New host, save previous one if it exists
|
// New host, save previous one if it exists
|
||||||
if currentHost != nil {
|
if currentHost != nil {
|
||||||
hosts = append(hosts, *currentHost)
|
hosts = append(hosts, *currentHost)
|
||||||
}
|
}
|
||||||
|
// Skip hosts with wildcards (*, ?) as they are typically patterns, not actual hosts
|
||||||
|
if strings.ContainsAny(value, "*?") {
|
||||||
|
currentHost = nil
|
||||||
|
pendingTags = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Create new host
|
// Create new host
|
||||||
currentHost = &SSHHost{
|
currentHost = &SSHHost{
|
||||||
Name: value,
|
Name: value,
|
||||||
@@ -179,17 +256,68 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
|||||||
return hosts, scanner.Err()
|
return hosts, scanner.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// processIncludeDirective processes an Include directive and returns hosts from included files
|
||||||
|
func processIncludeDirective(pattern string, baseConfigPath string, processedFiles map[string]bool) ([]SSHHost, error) {
|
||||||
|
// Expand tilde to home directory
|
||||||
|
if strings.HasPrefix(pattern, "~") {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||||
|
}
|
||||||
|
pattern = filepath.Join(homeDir, pattern[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pattern is not absolute, make it relative to the base config directory
|
||||||
|
if !filepath.IsAbs(pattern) {
|
||||||
|
baseDir := filepath.Dir(baseConfigPath)
|
||||||
|
pattern = filepath.Join(baseDir, pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use glob to find matching files
|
||||||
|
matches, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to glob pattern %s: %w", pattern, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var allHosts []SSHHost
|
||||||
|
for _, match := range matches {
|
||||||
|
// Skip directories
|
||||||
|
if info, err := os.Stat(match); err == nil && info.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively parse the included file
|
||||||
|
hosts, err := parseSSHConfigFileWithProcessedFiles(match, processedFiles)
|
||||||
|
if err != nil {
|
||||||
|
// Skip files that can't be parsed rather than failing completely
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allHosts = append(allHosts, hosts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return allHosts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMainConfigPath returns the main SSH config path for comparison
|
||||||
|
func getMainConfigPath() string {
|
||||||
|
configPath, _ := GetDefaultSSHConfigPath()
|
||||||
|
absPath, _ := filepath.Abs(configPath)
|
||||||
|
return absPath
|
||||||
|
}
|
||||||
|
|
||||||
// AddSSHHost adds a new SSH host to the config file
|
// AddSSHHost adds a new SSH host to the config file
|
||||||
func AddSSHHost(host SSHHost) error {
|
func AddSSHHost(host SSHHost) error {
|
||||||
configMutex.Lock()
|
configPath, err := GetDefaultSSHConfigPath()
|
||||||
defer configMutex.Unlock()
|
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
// Create backup before modification if file exists
|
||||||
if _, err := os.Stat(configPath); err == nil {
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
@@ -198,8 +326,8 @@ func AddSSHHost(host SSHHost) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if host already exists
|
// Check if host already exists in the specified config file
|
||||||
exists, err := HostExists(host.Name)
|
exists, err := HostExistsInFile(host.Name, configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -354,6 +482,21 @@ func HostExists(hostName string) (bool, error) {
|
|||||||
return false, nil
|
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
|
// GetSSHHost retrieves a specific host configuration by name
|
||||||
func GetSSHHost(hostName string) (*SSHHost, error) {
|
func GetSSHHost(hostName string) (*SSHHost, error) {
|
||||||
hosts, err := ParseSSHConfig()
|
hosts, err := ParseSSHConfig()
|
||||||
@@ -369,17 +512,34 @@ func GetSSHHost(hostName string) (*SSHHost, error) {
|
|||||||
return nil, fmt.Errorf("host '%s' not found", hostName)
|
return nil, fmt.Errorf("host '%s' not found", hostName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSSHHostFromFile retrieves a specific host configuration by name from a specific config file
|
||||||
|
func GetSSHHostFromFile(hostName string, configPath string) (*SSHHost, error) {
|
||||||
|
hosts, err := ParseSSHConfigFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
if host.Name == hostName {
|
||||||
|
return &host, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("host '%s' not found", hostName)
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateSSHHost updates an existing SSH host configuration
|
// UpdateSSHHost updates an existing SSH host configuration
|
||||||
func UpdateSSHHost(oldName string, newHost SSHHost) error {
|
func UpdateSSHHost(oldName string, newHost SSHHost) error {
|
||||||
configMutex.Lock()
|
configPath, err := GetDefaultSSHConfigPath()
|
||||||
defer configMutex.Unlock()
|
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return UpdateSSHHostInFile(oldName, newHost, configPath)
|
||||||
|
}
|
||||||
|
|
||||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
// UpdateSSHHostInFile updates an existing SSH host configuration in a specific file
|
||||||
|
func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) error {
|
||||||
|
configMutex.Lock()
|
||||||
|
defer configMutex.Unlock()
|
||||||
|
|
||||||
// Create backup before modification
|
// Create backup before modification
|
||||||
if err := backupConfig(configPath); err != nil {
|
if err := backupConfig(configPath); err != nil {
|
||||||
@@ -528,15 +688,17 @@ func UpdateSSHHost(oldName string, newHost SSHHost) error {
|
|||||||
|
|
||||||
// DeleteSSHHost removes an SSH host configuration from the config file
|
// DeleteSSHHost removes an SSH host configuration from the config file
|
||||||
func DeleteSSHHost(hostName string) error {
|
func DeleteSSHHost(hostName string) error {
|
||||||
configMutex.Lock()
|
configPath, err := GetDefaultSSHConfigPath()
|
||||||
defer configMutex.Unlock()
|
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return DeleteSSHHostFromFile(hostName, configPath)
|
||||||
|
}
|
||||||
|
|
||||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
// DeleteSSHHostFromFile deletes an SSH host from a specific config file
|
||||||
|
func DeleteSSHHostFromFile(hostName, configPath string) error {
|
||||||
|
configMutex.Lock()
|
||||||
|
defer configMutex.Unlock()
|
||||||
|
|
||||||
// Create backup before modification
|
// Create backup before modification
|
||||||
if err := backupConfig(configPath); err != nil {
|
if err := backupConfig(configPath); err != nil {
|
||||||
|
|||||||
367
internal/config/ssh_test.go
Normal file
367
internal/config/ssh_test.go
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetDefaultSSHConfigPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
goos string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"Linux", "linux", ".ssh/config"},
|
||||||
|
{"macOS", "darwin", ".ssh/config"},
|
||||||
|
{"Windows", "windows", ".ssh/config"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Save original GOOS
|
||||||
|
originalGOOS := runtime.GOOS
|
||||||
|
defer func() {
|
||||||
|
// Note: We can't actually change runtime.GOOS at runtime
|
||||||
|
// This test verifies the function logic with the current OS
|
||||||
|
_ = originalGOOS
|
||||||
|
}()
|
||||||
|
|
||||||
|
configPath, err := GetDefaultSSHConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDefaultSSHConfigPath() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(configPath, tt.expected) {
|
||||||
|
t.Errorf("Expected path to end with %q, got %q", tt.expected, configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the path uses the correct separator for current OS
|
||||||
|
expectedSeparator := string(filepath.Separator)
|
||||||
|
if !strings.Contains(configPath, expectedSeparator) && len(configPath) > len(tt.expected) {
|
||||||
|
t.Errorf("Path should use OS-specific separator %q, got %q", expectedSeparator, configPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSSHDirectory(t *testing.T) {
|
||||||
|
sshDir, err := GetSSHDirectory()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSSHDirectory() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(sshDir, ".ssh") {
|
||||||
|
t.Errorf("Expected directory to end with .ssh, got %q", sshDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the path uses the correct separator for current OS
|
||||||
|
expectedSeparator := string(filepath.Separator)
|
||||||
|
if !strings.Contains(sshDir, expectedSeparator) && len(sshDir) > 4 {
|
||||||
|
t.Errorf("Path should use OS-specific separator %q, got %q", expectedSeparator, sshDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureSSHDirectory(t *testing.T) {
|
||||||
|
// This test just ensures the function doesn't panic
|
||||||
|
// and returns without error when .ssh directory already exists
|
||||||
|
err := ensureSSHDirectory()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ensureSSHDirectory() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSSHConfigWithInclude(t *testing.T) {
|
||||||
|
// Create temporary directory for test files
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create main config file
|
||||||
|
mainConfig := filepath.Join(tempDir, "config")
|
||||||
|
mainConfigContent := `Host main-host
|
||||||
|
HostName example.com
|
||||||
|
User mainuser
|
||||||
|
|
||||||
|
Include included.conf
|
||||||
|
Include subdir/*
|
||||||
|
|
||||||
|
Host another-host
|
||||||
|
HostName another.example.com
|
||||||
|
User anotheruser
|
||||||
|
`
|
||||||
|
|
||||||
|
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create main config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create included file
|
||||||
|
includedConfig := filepath.Join(tempDir, "included.conf")
|
||||||
|
includedConfigContent := `Host included-host
|
||||||
|
HostName included.example.com
|
||||||
|
User includeduser
|
||||||
|
Port 2222
|
||||||
|
`
|
||||||
|
|
||||||
|
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create included config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create subdirectory with another config file
|
||||||
|
subDir := filepath.Join(tempDir, "subdir")
|
||||||
|
err = os.MkdirAll(subDir, 0700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create subdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subConfig := filepath.Join(subDir, "sub.conf")
|
||||||
|
subConfigContent := `Host sub-host
|
||||||
|
HostName sub.example.com
|
||||||
|
User subuser
|
||||||
|
IdentityFile ~/.ssh/sub_key
|
||||||
|
`
|
||||||
|
|
||||||
|
err = os.WriteFile(subConfig, []byte(subConfigContent), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create sub config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the main config file
|
||||||
|
hosts, err := ParseSSHConfigFile(mainConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that we got all expected hosts
|
||||||
|
expectedHosts := map[string]struct{}{
|
||||||
|
"main-host": {},
|
||||||
|
"included-host": {},
|
||||||
|
"sub-host": {},
|
||||||
|
"another-host": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hosts) != len(expectedHosts) {
|
||||||
|
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
if _, exists := expectedHosts[host.Name]; !exists {
|
||||||
|
t.Errorf("Unexpected host found: %s", host.Name)
|
||||||
|
}
|
||||||
|
delete(expectedHosts, host.Name)
|
||||||
|
|
||||||
|
// Validate specific host properties
|
||||||
|
switch host.Name {
|
||||||
|
case "main-host":
|
||||||
|
if host.Hostname != "example.com" || host.User != "mainuser" {
|
||||||
|
t.Errorf("main-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||||
|
}
|
||||||
|
case "included-host":
|
||||||
|
if host.Hostname != "included.example.com" || host.User != "includeduser" || host.Port != "2222" {
|
||||||
|
t.Errorf("included-host properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port)
|
||||||
|
}
|
||||||
|
case "sub-host":
|
||||||
|
if host.Hostname != "sub.example.com" || host.User != "subuser" || host.Identity != "~/.ssh/sub_key" {
|
||||||
|
t.Errorf("sub-host properties incorrect: hostname=%s, user=%s, identity=%s", host.Hostname, host.User, host.Identity)
|
||||||
|
}
|
||||||
|
case "another-host":
|
||||||
|
if host.Hostname != "another.example.com" || host.User != "anotheruser" {
|
||||||
|
t.Errorf("another-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that all expected hosts were found
|
||||||
|
if len(expectedHosts) > 0 {
|
||||||
|
var missing []string
|
||||||
|
for host := range expectedHosts {
|
||||||
|
missing = append(missing, host)
|
||||||
|
}
|
||||||
|
t.Errorf("Missing hosts: %v", missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSSHConfigWithCircularInclude(t *testing.T) {
|
||||||
|
// Create temporary directory for test files
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create config1 that includes config2
|
||||||
|
config1 := filepath.Join(tempDir, "config1")
|
||||||
|
config1Content := `Host host1
|
||||||
|
HostName example1.com
|
||||||
|
|
||||||
|
Include config2
|
||||||
|
`
|
||||||
|
|
||||||
|
err := os.WriteFile(config1, []byte(config1Content), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create config1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config2 that includes config1 (circular)
|
||||||
|
config2 := filepath.Join(tempDir, "config2")
|
||||||
|
config2Content := `Host host2
|
||||||
|
HostName example2.com
|
||||||
|
|
||||||
|
Include config1
|
||||||
|
`
|
||||||
|
|
||||||
|
err = os.WriteFile(config2, []byte(config2Content), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create config2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the config file - should not cause infinite recursion
|
||||||
|
hosts, err := ParseSSHConfigFile(config1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should get both hosts exactly once
|
||||||
|
expectedHosts := map[string]bool{
|
||||||
|
"host1": false,
|
||||||
|
"host2": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
if _, exists := expectedHosts[host.Name]; !exists {
|
||||||
|
t.Errorf("Unexpected host found: %s", host.Name)
|
||||||
|
} else {
|
||||||
|
if expectedHosts[host.Name] {
|
||||||
|
t.Errorf("Host %s found multiple times", host.Name)
|
||||||
|
}
|
||||||
|
expectedHosts[host.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all hosts were found
|
||||||
|
for hostName, found := range expectedHosts {
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Host %s not found", hostName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSSHConfigWithNonExistentInclude(t *testing.T) {
|
||||||
|
// Create temporary directory for test files
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create main config file with non-existent include
|
||||||
|
mainConfig := filepath.Join(tempDir, "config")
|
||||||
|
mainConfigContent := `Host main-host
|
||||||
|
HostName example.com
|
||||||
|
|
||||||
|
Include non-existent-file.conf
|
||||||
|
|
||||||
|
Host another-host
|
||||||
|
HostName another.example.com
|
||||||
|
`
|
||||||
|
|
||||||
|
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create main config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse should succeed and ignore the non-existent include
|
||||||
|
hosts, err := ParseSSHConfigFile(mainConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should get the hosts that exist, ignoring the failed include
|
||||||
|
if len(hosts) != 2 {
|
||||||
|
t.Errorf("Expected 2 hosts, got %d", len(hosts))
|
||||||
|
}
|
||||||
|
|
||||||
|
hostNames := make(map[string]bool)
|
||||||
|
for _, host := range hosts {
|
||||||
|
hostNames[host.Name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hostNames["main-host"] || !hostNames["another-host"] {
|
||||||
|
t.Errorf("Expected main-host and another-host, got: %v", hostNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSSHConfigWithWildcardHosts(t *testing.T) {
|
||||||
|
// Create temporary directory for test files
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create config file with wildcard hosts
|
||||||
|
configFile := filepath.Join(tempDir, "config")
|
||||||
|
configContent := `# Wildcard patterns should be ignored
|
||||||
|
Host *.example.com
|
||||||
|
User defaultuser
|
||||||
|
IdentityFile ~/.ssh/id_rsa
|
||||||
|
|
||||||
|
Host server-*
|
||||||
|
Port 2222
|
||||||
|
|
||||||
|
Host *
|
||||||
|
ServerAliveInterval 60
|
||||||
|
|
||||||
|
# Real hosts should be included
|
||||||
|
Host real-server
|
||||||
|
HostName real.example.com
|
||||||
|
User realuser
|
||||||
|
|
||||||
|
Host another-real-server
|
||||||
|
HostName another.example.com
|
||||||
|
User anotheruser
|
||||||
|
`
|
||||||
|
|
||||||
|
err := os.WriteFile(configFile, []byte(configContent), 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the config file
|
||||||
|
hosts, err := ParseSSHConfigFile(configFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should only get real hosts, not wildcard patterns
|
||||||
|
expectedHosts := map[string]bool{
|
||||||
|
"real-server": false,
|
||||||
|
"another-real-server": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hosts) != len(expectedHosts) {
|
||||||
|
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
|
||||||
|
for _, host := range hosts {
|
||||||
|
t.Logf("Found host: %s", host.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
if _, expected := expectedHosts[host.Name]; !expected {
|
||||||
|
t.Errorf("Unexpected host found: %s", host.Name)
|
||||||
|
} else {
|
||||||
|
expectedHosts[host.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that all expected hosts were found
|
||||||
|
for hostName, found := range expectedHosts {
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Expected host %s not found", hostName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify host properties
|
||||||
|
for _, host := range hosts {
|
||||||
|
switch host.Name {
|
||||||
|
case "real-server":
|
||||||
|
if host.Hostname != "real.example.com" || host.User != "realuser" {
|
||||||
|
t.Errorf("real-server properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||||
|
}
|
||||||
|
case "another-real-server":
|
||||||
|
if host.Hostname != "another.example.com" || host.User != "anotheruser" {
|
||||||
|
t.Errorf("another-real-server properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
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 {
|
type addFormModel struct {
|
||||||
inputs []textinput.Model
|
inputs []textinput.Model
|
||||||
focused int
|
focused int
|
||||||
err string
|
err string
|
||||||
success bool
|
styles Styles
|
||||||
|
success bool
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
configFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
// NewAddForm creates a new add form model
|
||||||
nameInput = iota
|
func NewAddForm(hostname string, styles Styles, width, height int, configFile string) *addFormModel {
|
||||||
hostnameInput
|
|
||||||
userInput
|
|
||||||
portInput
|
|
||||||
identityInput
|
|
||||||
proxyJumpInput
|
|
||||||
optionsInput
|
|
||||||
tagsInput
|
|
||||||
)
|
|
||||||
|
|
||||||
func RunAddForm(hostname string) error {
|
|
||||||
// Get current user for default
|
// Get current user for default
|
||||||
currentUser, _ := user.Current()
|
currentUser, _ := user.Current()
|
||||||
defaultUser := "root"
|
defaultUser := "root"
|
||||||
@@ -123,28 +100,53 @@ func RunAddForm(hostname string) error {
|
|||||||
inputs[tagsInput].CharLimit = 200
|
inputs[tagsInput].CharLimit = 200
|
||||||
inputs[tagsInput].Width = 50
|
inputs[tagsInput].Width = 50
|
||||||
|
|
||||||
m := addFormModel{
|
return &addFormModel{
|
||||||
inputs: inputs,
|
inputs: inputs,
|
||||||
focused: nameInput,
|
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 {
|
func (m *addFormModel) Init() tea.Cmd {
|
||||||
return textinput.Blink
|
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
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
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:
|
case tea.KeyMsg:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c", "esc":
|
case "ctrl+c", "esc":
|
||||||
return m, tea.Quit
|
return m, func() tea.Msg { return addFormCancelMsg{} }
|
||||||
|
|
||||||
case "ctrl+enter":
|
case "ctrl+enter":
|
||||||
// Allow submission from any field with 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...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
case submitResult:
|
case addFormSubmitMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.err = msg.err.Error()
|
m.err = msg.err.Error()
|
||||||
} else {
|
} else {
|
||||||
m.success = true
|
m.success = true
|
||||||
m.err = ""
|
m.err = ""
|
||||||
return m, tea.Quit
|
// Don't quit here, let parent handle the success
|
||||||
}
|
}
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update inputs
|
// Update inputs
|
||||||
@@ -209,7 +212,7 @@ func (m *addFormModel) View() string {
|
|||||||
|
|
||||||
var b strings.Builder
|
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")
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
fields := []string{
|
fields := []string{
|
||||||
@@ -224,27 +227,57 @@ func (m *addFormModel) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i, field := range fields {
|
for i, field := range fields {
|
||||||
b.WriteString(fieldStyle.Render(field))
|
b.WriteString(m.styles.FormField.Render(field))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(m.inputs[i].View())
|
b.WriteString(m.inputs[i].View())
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.err != "" {
|
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("\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("\n")
|
||||||
b.WriteString(helpStyle.Render("* Required fields"))
|
b.WriteString(m.styles.FormHelp.Render("* Required fields"))
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
type submitResult struct {
|
// Standalone wrapper for add form
|
||||||
hostname string
|
type standaloneAddForm struct {
|
||||||
err error
|
*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 {
|
func (m *addFormModel) submitForm() tea.Cmd {
|
||||||
@@ -269,7 +302,7 @@ func (m *addFormModel) submitForm() tea.Cmd {
|
|||||||
|
|
||||||
// Validate all fields
|
// Validate all fields
|
||||||
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
|
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())
|
tagsStr := strings.TrimSpace(m.inputs[tagsInput].Value())
|
||||||
@@ -296,7 +329,12 @@ func (m *addFormModel) submitForm() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add to config
|
// Add to config
|
||||||
err := config.AddSSHHost(host)
|
var err error
|
||||||
return submitResult{hostname: name, err: err}
|
if m.configFile != "" {
|
||||||
|
err = config.AddSSHHostToFile(host, m.configFile)
|
||||||
|
} else {
|
||||||
|
err = config.AddSSHHost(host)
|
||||||
|
}
|
||||||
|
return addFormSubmitMsg{hostname: name, err: err}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,23 +7,6 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"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 {
|
type editFormModel struct {
|
||||||
@@ -31,14 +14,27 @@ type editFormModel struct {
|
|||||||
focused int
|
focused int
|
||||||
err string
|
err string
|
||||||
success bool
|
success bool
|
||||||
|
styles Styles
|
||||||
originalName string
|
originalName string
|
||||||
|
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
|
// 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 {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
inputs := make([]textinput.Model, 8)
|
inputs := make([]textinput.Model, 8)
|
||||||
@@ -102,30 +98,43 @@ func RunEditForm(hostName string) error {
|
|||||||
inputs[tagsInput].SetValue(strings.Join(host.Tags, ", "))
|
inputs[tagsInput].SetValue(strings.Join(host.Tags, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
m := editFormModel{
|
return &editFormModel{
|
||||||
inputs: inputs,
|
inputs: inputs,
|
||||||
focused: nameInput,
|
focused: nameInput,
|
||||||
originalName: hostName,
|
originalName: hostName,
|
||||||
}
|
configFile: configFile,
|
||||||
|
styles: styles,
|
||||||
// Open in separate window like add form
|
width: width,
|
||||||
p := tea.NewProgram(&m, tea.WithAltScreen())
|
height: height,
|
||||||
_, err = p.Run()
|
}, nil
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Messages for communication with parent model
|
||||||
|
type editFormSubmitMsg struct {
|
||||||
|
hostname string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type editFormCancelMsg struct{}
|
||||||
|
|
||||||
func (m *editFormModel) Init() tea.Cmd {
|
func (m *editFormModel) Init() tea.Cmd {
|
||||||
return textinput.Blink
|
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
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
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:
|
case tea.KeyMsg:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c", "esc":
|
case "ctrl+c", "esc":
|
||||||
return m, tea.Quit
|
return m, func() tea.Msg { return editFormCancelMsg{} }
|
||||||
|
|
||||||
case "ctrl+enter":
|
case "ctrl+enter":
|
||||||
// Allow submission from any field with Ctrl+Enter
|
// Allow submission from any field with Ctrl+Enter
|
||||||
@@ -163,14 +172,15 @@ func (m *editFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
case editResult:
|
case editFormSubmitMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.err = msg.err.Error()
|
m.err = msg.err.Error()
|
||||||
} else {
|
} else {
|
||||||
m.success = true
|
m.success = true
|
||||||
m.err = ""
|
m.err = ""
|
||||||
return m, tea.Quit
|
// Don't quit here, let parent handle the success
|
||||||
}
|
}
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update inputs
|
// Update inputs
|
||||||
@@ -190,7 +200,7 @@ func (m *editFormModel) View() string {
|
|||||||
|
|
||||||
var b strings.Builder
|
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\n")
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
fields := []string{
|
fields := []string{
|
||||||
@@ -205,27 +215,60 @@ func (m *editFormModel) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i, field := range fields {
|
for i, field := range fields {
|
||||||
b.WriteString(fieldStyleEdit.Render(field))
|
b.WriteString(m.styles.FormField.Render(field))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(m.inputs[i].View())
|
b.WriteString(m.inputs[i].View())
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.err != "" {
|
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("\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("\n")
|
||||||
b.WriteString(helpStyleEdit.Render("* Required fields"))
|
b.WriteString(m.styles.FormHelp.Render("* Required fields"))
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
type editResult struct {
|
// Standalone wrapper for edit form
|
||||||
hostname string
|
type standaloneEditForm struct {
|
||||||
err error
|
*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 {
|
func (m *editFormModel) submitEditForm() tea.Cmd {
|
||||||
@@ -247,7 +290,7 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
|
|||||||
|
|
||||||
// Validate all fields
|
// Validate all fields
|
||||||
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
|
if err := validation.ValidateHost(name, hostname, port, identity); err != nil {
|
||||||
return editResult{err: err}
|
return editFormSubmitMsg{err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse tags
|
// Parse tags
|
||||||
@@ -275,7 +318,12 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update the configuration
|
// Update the configuration
|
||||||
err := config.UpdateSSHHost(m.originalName, host)
|
var err error
|
||||||
return editResult{hostname: name, err: err}
|
if m.configFile != "" {
|
||||||
|
err = config.UpdateSSHHostInFile(m.originalName, host, m.configFile)
|
||||||
|
} else {
|
||||||
|
err = config.UpdateSSHHost(m.originalName, host)
|
||||||
|
}
|
||||||
|
return editFormSubmitMsg{hostname: name, err: err}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
109
internal/ui/help_form.go
Normal file
109
internal/ui/help_form.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type helpModel struct {
|
||||||
|
styles Styles
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpCloseMsg is sent when the help window is closed
|
||||||
|
type helpCloseMsg struct{}
|
||||||
|
|
||||||
|
// NewHelpForm creates a new help form model
|
||||||
|
func NewHelpForm(styles Styles, width, height int) *helpModel {
|
||||||
|
return &helpModel{
|
||||||
|
styles: styles,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *helpModel) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *helpModel) Update(msg tea.Msg) (*helpModel, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "q", "h", "enter", "ctrl+c":
|
||||||
|
return m, func() tea.Msg { return helpCloseMsg{} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *helpModel) View() string {
|
||||||
|
// Title
|
||||||
|
title := m.styles.Header.Render("📖 SSHM - Help & Commands")
|
||||||
|
|
||||||
|
// Create horizontal sections with compact layout
|
||||||
|
line1 := lipgloss.JoinHorizontal(lipgloss.Center,
|
||||||
|
m.styles.FocusedLabel.Render("🧭 ↑/↓/j/k"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("navigate"),
|
||||||
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("⏎"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("connect"),
|
||||||
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("a/e/d"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("add/edit/delete"),
|
||||||
|
)
|
||||||
|
|
||||||
|
line2 := lipgloss.JoinHorizontal(lipgloss.Center,
|
||||||
|
m.styles.FocusedLabel.Render("Tab"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("switch focus"),
|
||||||
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("f"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("port forward"),
|
||||||
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("s/r/n"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("sort modes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
line3 := lipgloss.JoinHorizontal(lipgloss.Center,
|
||||||
|
m.styles.FocusedLabel.Render("/"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("search"),
|
||||||
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("h"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("help"),
|
||||||
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("q/ESC"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("quit"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create the main content
|
||||||
|
content := lipgloss.JoinVertical(lipgloss.Center,
|
||||||
|
title,
|
||||||
|
"",
|
||||||
|
line1,
|
||||||
|
"",
|
||||||
|
line2,
|
||||||
|
"",
|
||||||
|
line3,
|
||||||
|
"",
|
||||||
|
m.styles.HelpText.Render("Press ESC, h, q or Enter to close"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Center the help window
|
||||||
|
return lipgloss.Place(
|
||||||
|
m.width,
|
||||||
|
m.height,
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
m.styles.FormContainer.Render(content),
|
||||||
|
)
|
||||||
|
}
|
||||||
227
internal/ui/info_form.go
Normal file
227
internal/ui/info_form.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sshm/internal/config"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type infoFormModel struct {
|
||||||
|
host *config.SSHHost
|
||||||
|
styles Styles
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
configFile string
|
||||||
|
hostName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages for communication with parent model
|
||||||
|
type infoFormEditMsg struct {
|
||||||
|
hostName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type infoFormCancelMsg struct{}
|
||||||
|
|
||||||
|
// NewInfoForm creates a new info form model for displaying host details in read-only mode
|
||||||
|
func NewInfoForm(hostName string, styles Styles, width, height int, configFile string) (*infoFormModel, error) {
|
||||||
|
// Get the existing host configuration
|
||||||
|
var host *config.SSHHost
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if configFile != "" {
|
||||||
|
host, err = config.GetSSHHostFromFile(hostName, configFile)
|
||||||
|
} else {
|
||||||
|
host, err = config.GetSSHHost(hostName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &infoFormModel{
|
||||||
|
host: host,
|
||||||
|
hostName: hostName,
|
||||||
|
configFile: configFile,
|
||||||
|
styles: styles,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *infoFormModel) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *infoFormModel) Update(msg tea.Msg) (*infoFormModel, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
m.styles = NewStyles(m.width)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "ctrl+c", "esc", "q":
|
||||||
|
return m, func() tea.Msg { return infoFormCancelMsg{} }
|
||||||
|
|
||||||
|
case "e", "enter":
|
||||||
|
// Switch to edit mode
|
||||||
|
return m, func() tea.Msg { return infoFormEditMsg{hostName: m.hostName} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *infoFormModel) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// Title
|
||||||
|
title := fmt.Sprintf("SSH Host Information: %s", m.host.Name)
|
||||||
|
b.WriteString(m.styles.FormTitle.Render(title))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Create info sections with consistent formatting
|
||||||
|
sections := []struct {
|
||||||
|
label string
|
||||||
|
value string
|
||||||
|
}{
|
||||||
|
{"Host Name", m.host.Name},
|
||||||
|
{"Hostname/IP", m.host.Hostname},
|
||||||
|
{"User", formatOptionalValue(m.host.User)},
|
||||||
|
{"Port", formatOptionalValue(m.host.Port)},
|
||||||
|
{"Identity File", formatOptionalValue(m.host.Identity)},
|
||||||
|
{"ProxyJump", formatOptionalValue(m.host.ProxyJump)},
|
||||||
|
{"SSH Options", formatSSHOptions(m.host.Options)},
|
||||||
|
{"Tags", formatTags(m.host.Tags)},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render each section
|
||||||
|
for _, section := range sections {
|
||||||
|
// Label style
|
||||||
|
labelStyle := lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("39")). // Bright blue
|
||||||
|
Width(15).
|
||||||
|
AlignHorizontal(lipgloss.Right)
|
||||||
|
|
||||||
|
// Value style
|
||||||
|
valueStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("255")) // White
|
||||||
|
|
||||||
|
// If value is empty or default, use a muted style
|
||||||
|
if section.value == "Not set" || section.value == "22" && section.label == "Port" {
|
||||||
|
valueStyle = valueStyle.Foreground(lipgloss.Color("243")) // Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
line := lipgloss.JoinHorizontal(
|
||||||
|
lipgloss.Top,
|
||||||
|
labelStyle.Render(section.label+":"),
|
||||||
|
" ",
|
||||||
|
valueStyle.Render(section.value),
|
||||||
|
)
|
||||||
|
b.WriteString(line)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Action instructions
|
||||||
|
helpStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("243")).
|
||||||
|
Italic(true)
|
||||||
|
|
||||||
|
b.WriteString(helpStyle.Render("Actions:"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
actionStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("120")). // Green
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(actionStyle.Render("e/Enter"))
|
||||||
|
b.WriteString(helpStyle.Render(" - Switch to edit mode"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(actionStyle.Render("q/Esc"))
|
||||||
|
b.WriteString(helpStyle.Render(" - Return to host list"))
|
||||||
|
|
||||||
|
// Wrap in a border for better visual separation
|
||||||
|
content := b.String()
|
||||||
|
|
||||||
|
borderStyle := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("39")).
|
||||||
|
Padding(1).
|
||||||
|
Margin(1)
|
||||||
|
|
||||||
|
// Center the info window
|
||||||
|
return lipgloss.Place(
|
||||||
|
m.width,
|
||||||
|
m.height,
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
borderStyle.Render(content),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for formatting values
|
||||||
|
|
||||||
|
func formatOptionalValue(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return "Not set"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSSHOptions(options string) string {
|
||||||
|
if options == "" {
|
||||||
|
return "Not set"
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTags(tags []string) string {
|
||||||
|
if len(tags) == 0 {
|
||||||
|
return "Not set"
|
||||||
|
}
|
||||||
|
return strings.Join(tags, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone wrapper for info form (for testing or standalone use)
|
||||||
|
type standaloneInfoForm struct {
|
||||||
|
*infoFormModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m standaloneInfoForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.(type) {
|
||||||
|
case infoFormCancelMsg:
|
||||||
|
return m, tea.Quit
|
||||||
|
case infoFormEditMsg:
|
||||||
|
// For standalone mode, just quit - parent should handle edit transition
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
|
||||||
|
newForm, cmd := m.infoFormModel.Update(msg)
|
||||||
|
m.infoFormModel = newForm
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunInfoForm provides a standalone info form for testing
|
||||||
|
func RunInfoForm(hostName string, configFile string) error {
|
||||||
|
styles := NewStyles(80)
|
||||||
|
infoForm, err := NewInfoForm(hostName, styles, 80, 24, configFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m := standaloneInfoForm{infoForm}
|
||||||
|
|
||||||
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
|
_, err = p.Run()
|
||||||
|
return err
|
||||||
|
}
|
||||||
115
internal/ui/model.go
Normal file
115
internal/ui/model.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sshm/internal/config"
|
||||||
|
"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
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
sortMode SortMode
|
||||||
|
configFile string // Path to the SSH config file
|
||||||
|
|
||||||
|
// View management
|
||||||
|
viewMode ViewMode
|
||||||
|
addForm *addFormModel
|
||||||
|
editForm *editFormModel
|
||||||
|
infoForm *infoFormModel
|
||||||
|
portForwardForm *portForwardModel
|
||||||
|
helpForm *helpModel
|
||||||
|
|
||||||
|
// 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 = `
|
||||||
|
_____ _____ __ __ _____
|
||||||
|
| __| __| | | |
|
||||||
|
|__ |__ | | | | |
|
||||||
|
|_____|_____|__|__|_|_|_|
|
||||||
|
`
|
||||||
257
internal/ui/table.go
Normal file
257
internal/ui/table.go
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sshm/internal/config"
|
||||||
|
"sshm/internal/history"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/table"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTableRows updates the table with filtered hosts
|
||||||
|
func (m *Model) updateTableRows() {
|
||||||
|
var rows []table.Row
|
||||||
|
hostsToShow := m.filteredHosts
|
||||||
|
if hostsToShow == nil {
|
||||||
|
hostsToShow = m.hosts
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range hostsToShow {
|
||||||
|
// Format tags for display
|
||||||
|
var tagsStr string
|
||||||
|
if len(host.Tags) > 0 {
|
||||||
|
// Add the # prefix to each tag and join them with spaces
|
||||||
|
var formattedTags []string
|
||||||
|
for _, tag := range host.Tags {
|
||||||
|
formattedTags = append(formattedTags, "#"+tag)
|
||||||
|
}
|
||||||
|
tagsStr = strings.Join(formattedTags, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format last login information
|
||||||
|
var lastLoginStr string
|
||||||
|
if m.historyManager != nil {
|
||||||
|
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
|
||||||
|
lastLoginStr = formatTimeAgo(lastConnect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = append(rows, table.Row{
|
||||||
|
host.Name,
|
||||||
|
host.Hostname,
|
||||||
|
// host.User, // Commented to save space
|
||||||
|
// host.Port, // Commented to save space
|
||||||
|
tagsStr,
|
||||||
|
lastLoginStr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
m.table.SetRows(rows)
|
||||||
|
|
||||||
|
// Update table height and columns based on current terminal size
|
||||||
|
m.updateTableHeight()
|
||||||
|
m.updateTableColumns()
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTableHeight dynamically adjusts table height based on terminal size
|
||||||
|
func (m *Model) updateTableHeight() {
|
||||||
|
if !m.ready {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate dynamic table height based on terminal size
|
||||||
|
// Layout breakdown:
|
||||||
|
// - ASCII title: 5 lines (1 empty + 4 text lines)
|
||||||
|
// - Search bar: 1 line
|
||||||
|
// - Help text: 1 line
|
||||||
|
// - App margins/spacing: 3 lines
|
||||||
|
// - Safety margin: 3 lines (to ensure UI elements are always visible)
|
||||||
|
// Total reserved: 12 lines minimum to preserve essential UI elements
|
||||||
|
reservedHeight := 12
|
||||||
|
availableHeight := m.height - reservedHeight
|
||||||
|
hostCount := len(m.table.Rows())
|
||||||
|
|
||||||
|
// Minimum height should be at least 3 rows for basic usability
|
||||||
|
// Even in very small terminals, we want to show at least header + 2 hosts
|
||||||
|
minTableHeight := 4 // 1 header + 3 data rows minimum
|
||||||
|
maxTableHeight := availableHeight
|
||||||
|
if maxTableHeight < minTableHeight {
|
||||||
|
maxTableHeight = minTableHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
tableHeight := 1 // header
|
||||||
|
dataRowsNeeded := hostCount
|
||||||
|
maxDataRows := maxTableHeight - 1 // subtract 1 for header
|
||||||
|
|
||||||
|
if dataRowsNeeded <= maxDataRows {
|
||||||
|
// We have enough space for all hosts
|
||||||
|
tableHeight += dataRowsNeeded
|
||||||
|
} else {
|
||||||
|
// We need to limit to available space
|
||||||
|
tableHeight += maxDataRows
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update table height
|
||||||
|
m.table.SetHeight(tableHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTableColumns dynamically adjusts table column widths based on terminal size
|
||||||
|
func (m *Model) updateTableColumns() {
|
||||||
|
if !m.ready {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hostsToShow := m.filteredHosts
|
||||||
|
if hostsToShow == nil {
|
||||||
|
hostsToShow = m.hosts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate base column widths
|
||||||
|
nameWidth := calculateNameColumnWidth(hostsToShow)
|
||||||
|
tagsWidth := calculateTagsColumnWidth(hostsToShow)
|
||||||
|
lastLoginWidth := calculateLastLoginColumnWidth(hostsToShow, m.historyManager)
|
||||||
|
|
||||||
|
// Fixed column widths
|
||||||
|
hostnameWidth := 25
|
||||||
|
// userWidth := 12 // Commented to save space
|
||||||
|
// portWidth := 6 // Commented to save space
|
||||||
|
|
||||||
|
// Calculate total width needed for all columns
|
||||||
|
totalFixedWidth := hostnameWidth // + userWidth + portWidth // Commented columns
|
||||||
|
totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth
|
||||||
|
totalWidth := totalFixedWidth + totalVariableWidth
|
||||||
|
|
||||||
|
// Available width (accounting for table borders and padding)
|
||||||
|
availableWidth := m.width - 4 // 4 chars for borders and padding
|
||||||
|
|
||||||
|
// If the table is too wide, scale down the variable columns proportionally
|
||||||
|
if totalWidth > availableWidth {
|
||||||
|
excessWidth := totalWidth - availableWidth
|
||||||
|
variableColumnsWidth := totalVariableWidth
|
||||||
|
|
||||||
|
if variableColumnsWidth > 0 {
|
||||||
|
// Reduce variable columns proportionally
|
||||||
|
nameReduction := (excessWidth * nameWidth) / variableColumnsWidth
|
||||||
|
tagsReduction := (excessWidth * tagsWidth) / variableColumnsWidth
|
||||||
|
lastLoginReduction := excessWidth - nameReduction - tagsReduction
|
||||||
|
|
||||||
|
nameWidth = max(8, nameWidth-nameReduction)
|
||||||
|
tagsWidth = max(8, tagsWidth-tagsReduction)
|
||||||
|
lastLoginWidth = max(10, lastLoginWidth-lastLoginReduction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new columns with updated widths and sort indicators
|
||||||
|
nameTitle := "Name"
|
||||||
|
lastLoginTitle := "Last Login"
|
||||||
|
|
||||||
|
// Add sort indicators based on current sort mode
|
||||||
|
switch m.sortMode {
|
||||||
|
case SortByName:
|
||||||
|
nameTitle += " ↓"
|
||||||
|
case SortByLastUsed:
|
||||||
|
lastLoginTitle += " ↓"
|
||||||
|
}
|
||||||
|
|
||||||
|
columns := []table.Column{
|
||||||
|
{Title: nameTitle, Width: nameWidth},
|
||||||
|
{Title: "Hostname", Width: hostnameWidth},
|
||||||
|
// {Title: "User", Width: userWidth}, // Commented to save space
|
||||||
|
// {Title: "Port", Width: portWidth}, // Commented to save space
|
||||||
|
{Title: "Tags", Width: tagsWidth},
|
||||||
|
{Title: lastLoginTitle, Width: lastLoginWidth},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.table.SetColumns(columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// max returns the maximum of two integers
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -2,11 +2,10 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"sshm/internal/config"
|
"sshm/internal/config"
|
||||||
|
"sshm/internal/history"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/table"
|
"github.com/charmbracelet/bubbles/table"
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
@@ -14,258 +13,36 @@ import (
|
|||||||
"github.com/charmbracelet/lipgloss"
|
"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
|
// NewModel creates a new TUI model with the given SSH hosts
|
||||||
func NewModel(hosts []config.SSHHost) Model {
|
func NewModel(hosts []config.SSHHost, configFile string) Model {
|
||||||
// Sort hosts alphabetically by name
|
// Initialize the history manager
|
||||||
sortedHosts := sortHostsByName(hosts)
|
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
|
||||||
|
|
||||||
|
// Create the model with default sorting by name
|
||||||
|
m := Model{
|
||||||
|
hosts: hosts,
|
||||||
|
historyManager: historyManager,
|
||||||
|
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 := textinput.New()
|
||||||
ti.Placeholder = "Search hosts or tags..."
|
ti.Placeholder = "Search hosts or tags..."
|
||||||
ti.CharLimit = 50
|
ti.CharLimit = 50
|
||||||
@@ -277,13 +54,17 @@ func NewModel(hosts []config.SSHHost) Model {
|
|||||||
// Calculate optimal width for the Tags column
|
// Calculate optimal width for the Tags column
|
||||||
tagsWidth := calculateTagsColumnWidth(sortedHosts)
|
tagsWidth := calculateTagsColumnWidth(sortedHosts)
|
||||||
|
|
||||||
|
// Calculate optimal width for the Last Login column
|
||||||
|
lastLoginWidth := calculateLastLoginColumnWidth(sortedHosts, historyManager)
|
||||||
|
|
||||||
// Create table columns
|
// Create table columns
|
||||||
columns := []table.Column{
|
columns := []table.Column{
|
||||||
{Title: "Name", Width: nameWidth},
|
{Title: "Name", Width: nameWidth},
|
||||||
{Title: "Hostname", Width: 25},
|
{Title: "Hostname", Width: 25},
|
||||||
{Title: "User", Width: 12},
|
// {Title: "User", Width: 12}, // Commented to save space
|
||||||
{Title: "Port", Width: 6},
|
// {Title: "Port", Width: 6}, // Commented to save space
|
||||||
{Title: "Tags", Width: tagsWidth},
|
{Title: "Tags", Width: tagsWidth},
|
||||||
|
{Title: "Last Login", Width: lastLoginWidth},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert hosts to table rows
|
// Convert hosts to table rows
|
||||||
@@ -292,7 +73,7 @@ func NewModel(hosts []config.SSHHost) Model {
|
|||||||
// Format tags for display
|
// Format tags for display
|
||||||
var tagsStr string
|
var tagsStr string
|
||||||
if len(host.Tags) > 0 {
|
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
|
var formattedTags []string
|
||||||
for _, tag := range host.Tags {
|
for _, tag := range host.Tags {
|
||||||
formattedTags = append(formattedTags, "#"+tag)
|
formattedTags = append(formattedTags, "#"+tag)
|
||||||
@@ -300,197 +81,67 @@ func NewModel(hosts []config.SSHHost) Model {
|
|||||||
tagsStr = strings.Join(formattedTags, " ")
|
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{
|
rows = append(rows, table.Row{
|
||||||
host.Name,
|
host.Name,
|
||||||
host.Hostname,
|
host.Hostname,
|
||||||
host.User,
|
// host.User, // Commented to save space
|
||||||
host.Port,
|
// host.Port, // Commented to save space
|
||||||
tagsStr,
|
tagsStr,
|
||||||
|
lastLoginStr,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Déterminer la hauteur du tableau : 1 (header) + nombre de hosts (max 10)
|
// Create the table with initial height (will be updated on first WindowSizeMsg)
|
||||||
hostCount := len(rows)
|
|
||||||
tableHeight := 1 // header
|
|
||||||
if hostCount < 10 {
|
|
||||||
tableHeight += hostCount
|
|
||||||
} else {
|
|
||||||
tableHeight += 10
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create table
|
|
||||||
t := table.New(
|
t := table.New(
|
||||||
table.WithColumns(columns),
|
table.WithColumns(columns),
|
||||||
table.WithRows(rows),
|
table.WithRows(rows),
|
||||||
table.WithFocused(true),
|
table.WithFocused(true),
|
||||||
table.WithHeight(tableHeight),
|
table.WithHeight(10), // Initial height, will be recalculated dynamically
|
||||||
)
|
)
|
||||||
|
|
||||||
// Style the table
|
// Style the table
|
||||||
s := table.DefaultStyles()
|
s := table.DefaultStyles()
|
||||||
s.Header = s.Header.
|
s.Header = s.Header.
|
||||||
BorderStyle(lipgloss.NormalBorder()).
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
BorderForeground(lipgloss.Color("240")).
|
BorderForeground(lipgloss.Color(SecondaryColor)).
|
||||||
BorderBottom(true).
|
BorderBottom(true).
|
||||||
Bold(false)
|
Bold(false)
|
||||||
s.Selected = s.Selected.
|
s.Selected = m.styles.Selected
|
||||||
Foreground(lipgloss.Color("229")).
|
|
||||||
Background(lipgloss.Color("57")).
|
|
||||||
Bold(false)
|
|
||||||
t.SetStyles(s)
|
t.SetStyles(s)
|
||||||
|
|
||||||
return Model{
|
// Update the model with the table and other properties
|
||||||
table: t,
|
m.table = t
|
||||||
searchInput: ti,
|
m.searchInput = ti
|
||||||
hosts: sortedHosts,
|
m.filteredHosts = sortedHosts
|
||||||
filteredHosts: sortedHosts,
|
|
||||||
searchMode: false,
|
// 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
|
// RunInteractiveMode starts the interactive TUI interface
|
||||||
func RunInteractiveMode(hosts []config.SSHHost) error {
|
func RunInteractiveMode(hosts []config.SSHHost, configFile string) error {
|
||||||
for {
|
m := NewModel(hosts, configFile)
|
||||||
m := NewModel(hosts)
|
|
||||||
|
|
||||||
// Start the application in terminal (without alt screen)
|
// Start the application in alt screen mode for clean output
|
||||||
p := tea.NewProgram(m)
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
finalModel, err := p.Run()
|
_, err := p.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error running TUI: %w", err)
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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()
|
|
||||||
}
|
|
||||||
|
|||||||
524
internal/ui/update.go
Normal file
524
internal/ui/update.go
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"sshm/internal/config"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init initializes the model
|
||||||
|
func (m Model) Init() tea.Cmd {
|
||||||
|
return tea.Batch(
|
||||||
|
textinput.Blink,
|
||||||
|
// Ajoute ici d'autres tea.Cmd si tu veux charger des données, démarrer un spinner, etc.
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
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 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 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 := selected[0] // The hostname is in the 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 := selected[0] // The hostname is in the 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 := selected[0] // The hostname is in the first column
|
||||||
|
infoForm, err := NewInfoForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||||
|
if err != nil {
|
||||||
|
// Handle error - could show in UI
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.infoForm = infoForm
|
||||||
|
m.viewMode = ViewInfo
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "a":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Add a new host
|
||||||
|
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile)
|
||||||
|
m.viewMode = ViewAdd
|
||||||
|
return m, textinput.Blink
|
||||||
|
}
|
||||||
|
case "d":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Delete the selected host
|
||||||
|
selected := m.table.SelectedRow()
|
||||||
|
if len(selected) > 0 {
|
||||||
|
hostName := selected[0] // The hostname is in the first column
|
||||||
|
m.deleteMode = true
|
||||||
|
m.deleteHost = hostName
|
||||||
|
m.table.Blur()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "f":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Port forwarding for the selected host
|
||||||
|
selected := m.table.SelectedRow()
|
||||||
|
if len(selected) > 0 {
|
||||||
|
hostName := selected[0] // The hostname is in the first column
|
||||||
|
m.portForwardForm = NewPortForwardForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||||
|
m.viewMode = ViewPortForward
|
||||||
|
return m, textinput.Blink
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "h":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Show help
|
||||||
|
m.helpForm = NewHelpForm(m.styles, m.width, m.height)
|
||||||
|
m.viewMode = ViewHelp
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
case "s":
|
||||||
|
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
|
||||||
|
}
|
||||||
57
internal/ui/utils.go
Normal file
57
internal/ui/utils.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
155
internal/ui/view.go
Normal file
155
internal/ui/view.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
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 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 • 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user