mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2025-09-06 21:00:45 +02:00
Compare commits
2 Commits
98aa2b6579
...
adde6eb666
Author | SHA1 | Date | |
---|---|---|---|
adde6eb666 | |||
94225cbfbe |
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.*
|
48
cmd/root.go
48
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,6 +15,9 @@ 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",
|
||||||
@ -36,7 +40,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)
|
||||||
}
|
}
|
||||||
@ -52,7 +64,11 @@ func runInteractiveMode() {
|
|||||||
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 +80,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 +117,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 +138,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")
|
||||||
|
}
|
@ -181,15 +181,18 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
|||||||
|
|
||||||
// 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()
|
|
||||||
defer configMutex.Unlock()
|
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
configPath := filepath.Join(homeDir, ".ssh", "config")
|
||||||
|
return AddSSHHostToFile(host, configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 +201,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 +357,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 +387,35 @@ 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()
|
|
||||||
defer configMutex.Unlock()
|
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
configPath := filepath.Join(homeDir, ".ssh", "config")
|
||||||
|
return UpdateSSHHostInFile(oldName, newHost, configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 +564,18 @@ 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()
|
|
||||||
defer configMutex.Unlock()
|
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
configPath := filepath.Join(homeDir, ".ssh", "config")
|
||||||
|
return DeleteSSHHostFromFile(hostName, configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
@ -13,17 +13,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type addFormModel struct {
|
type addFormModel struct {
|
||||||
inputs []textinput.Model
|
inputs []textinput.Model
|
||||||
focused int
|
focused int
|
||||||
err string
|
err string
|
||||||
styles Styles
|
styles Styles
|
||||||
success bool
|
success bool
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
|
configFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAddForm creates a new add form model
|
// NewAddForm creates a new add form model
|
||||||
func NewAddForm(hostname string, styles Styles, width, height int) *addFormModel {
|
func NewAddForm(hostname string, styles Styles, width, height int, configFile string) *addFormModel {
|
||||||
// Get current user for default
|
// Get current user for default
|
||||||
currentUser, _ := user.Current()
|
currentUser, _ := user.Current()
|
||||||
defaultUser := "root"
|
defaultUser := "root"
|
||||||
@ -100,11 +101,12 @@ func NewAddForm(hostname string, styles Styles, width, height int) *addFormModel
|
|||||||
inputs[tagsInput].Width = 50
|
inputs[tagsInput].Width = 50
|
||||||
|
|
||||||
return &addFormModel{
|
return &addFormModel{
|
||||||
inputs: inputs,
|
inputs: inputs,
|
||||||
focused: nameInput,
|
focused: nameInput,
|
||||||
styles: styles,
|
styles: styles,
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
|
configFile: configFile,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -270,7 +272,7 @@ func (m standaloneAddForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
// RunAddForm provides backward compatibility for standalone add form
|
// RunAddForm provides backward compatibility for standalone add form
|
||||||
func RunAddForm(hostname string) error {
|
func RunAddForm(hostname string) error {
|
||||||
styles := NewStyles(80)
|
styles := NewStyles(80)
|
||||||
addForm := NewAddForm(hostname, styles, 80, 24)
|
addForm := NewAddForm(hostname, styles, 80, 24, "")
|
||||||
m := standaloneAddForm{addForm}
|
m := standaloneAddForm{addForm}
|
||||||
|
|
||||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
@ -327,7 +329,12 @@ func (m *addFormModel) submitForm() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add to config
|
// Add to config
|
||||||
err := config.AddSSHHost(host)
|
var err error
|
||||||
|
if m.configFile != "" {
|
||||||
|
err = config.AddSSHHostToFile(host, m.configFile)
|
||||||
|
} else {
|
||||||
|
err = config.AddSSHHost(host)
|
||||||
|
}
|
||||||
return addFormSubmitMsg{hostname: name, err: err}
|
return addFormSubmitMsg{hostname: name, err: err}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,12 +18,21 @@ type editFormModel struct {
|
|||||||
originalName string
|
originalName string
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
|
configFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEditForm creates a new edit form model
|
// NewEditForm creates a new edit form model
|
||||||
func NewEditForm(hostName string, styles Styles, width, height int) (*editFormModel, error) {
|
func NewEditForm(hostName string, styles Styles, width, height int, configFile string) (*editFormModel, error) {
|
||||||
// Get the existing host configuration
|
// 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 nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -93,6 +102,7 @@ func NewEditForm(hostName string, styles Styles, width, height int) (*editFormMo
|
|||||||
inputs: inputs,
|
inputs: inputs,
|
||||||
focused: nameInput,
|
focused: nameInput,
|
||||||
originalName: hostName,
|
originalName: hostName,
|
||||||
|
configFile: configFile,
|
||||||
styles: styles,
|
styles: styles,
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
@ -250,7 +260,7 @@ func (m standaloneEditForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
// RunEditForm provides backward compatibility for standalone edit form
|
// RunEditForm provides backward compatibility for standalone edit form
|
||||||
func RunEditForm(hostName string) error {
|
func RunEditForm(hostName string) error {
|
||||||
styles := NewStyles(80)
|
styles := NewStyles(80)
|
||||||
editForm, err := NewEditForm(hostName, styles, 80, 24)
|
editForm, err := NewEditForm(hostName, styles, 80, 24, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -308,7 +318,12 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update the configuration
|
// Update the configuration
|
||||||
err := config.UpdateSSHHost(m.originalName, host)
|
var err error
|
||||||
|
if m.configFile != "" {
|
||||||
|
err = config.UpdateSSHHostInFile(m.originalName, host, m.configFile)
|
||||||
|
} else {
|
||||||
|
err = config.UpdateSSHHost(m.originalName, host)
|
||||||
|
}
|
||||||
return editFormSubmitMsg{hostname: name, err: err}
|
return editFormSubmitMsg{hostname: name, err: err}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -48,6 +48,7 @@ type Model struct {
|
|||||||
deleteHost string
|
deleteHost string
|
||||||
historyManager *history.HistoryManager
|
historyManager *history.HistoryManager
|
||||||
sortMode SortMode
|
sortMode SortMode
|
||||||
|
configFile string // Path to the SSH config file
|
||||||
|
|
||||||
// View management
|
// View management
|
||||||
viewMode ViewMode
|
viewMode ViewMode
|
||||||
|
@ -14,7 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 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 {
|
||||||
// Initialize the history manager
|
// Initialize the history manager
|
||||||
historyManager, err := history.NewHistoryManager()
|
historyManager, err := history.NewHistoryManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -31,6 +31,7 @@ func NewModel(hosts []config.SSHHost) Model {
|
|||||||
hosts: hosts,
|
hosts: hosts,
|
||||||
historyManager: historyManager,
|
historyManager: historyManager,
|
||||||
sortMode: SortByName,
|
sortMode: SortByName,
|
||||||
|
configFile: configFile,
|
||||||
styles: styles,
|
styles: styles,
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 24,
|
height: 24,
|
||||||
@ -138,8 +139,8 @@ func NewModel(hosts []config.SSHHost) Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RunInteractiveMode starts the interactive TUI interface
|
// RunInteractiveMode starts the interactive TUI interface
|
||||||
func RunInteractiveMode(hosts []config.SSHHost) error {
|
func RunInteractiveMode(hosts []config.SSHHost, configFile string) error {
|
||||||
m := NewModel(hosts)
|
m := NewModel(hosts, configFile)
|
||||||
|
|
||||||
// Start the application in alt screen mode for clean output
|
// Start the application in alt screen mode for clean output
|
||||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
|
@ -53,7 +53,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
} else {
|
} else {
|
||||||
// Success: refresh hosts and return to list view
|
// Success: refresh hosts and return to list view
|
||||||
hosts, err := config.ParseSSHConfig()
|
var hosts []config.SSHHost
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if m.configFile != "" {
|
||||||
|
hosts, err = config.ParseSSHConfigFile(m.configFile)
|
||||||
|
} else {
|
||||||
|
hosts, err = config.ParseSSHConfig()
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
@ -82,7 +90,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
} else {
|
} else {
|
||||||
// Success: refresh hosts and return to list view
|
// Success: refresh hosts and return to list view
|
||||||
hosts, err := config.ParseSSHConfig()
|
var hosts []config.SSHHost
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if m.configFile != "" {
|
||||||
|
hosts, err = config.ParseSSHConfigFile(m.configFile)
|
||||||
|
} else {
|
||||||
|
hosts, err = config.ParseSSHConfig()
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
@ -183,7 +199,12 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
} else if m.deleteMode {
|
} else if m.deleteMode {
|
||||||
// Confirm deletion
|
// Confirm deletion
|
||||||
err := config.DeleteSSHHost(m.deleteHost)
|
var err error
|
||||||
|
if m.configFile != "" {
|
||||||
|
err = config.DeleteSSHHostFromFile(m.deleteHost, m.configFile)
|
||||||
|
} else {
|
||||||
|
err = config.DeleteSSHHost(m.deleteHost)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Could display an error message here
|
// Could display an error message here
|
||||||
m.deleteMode = false
|
m.deleteMode = false
|
||||||
@ -192,8 +213,16 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
// Refresh the hosts list
|
// Refresh the hosts list
|
||||||
hosts, err := config.ParseSSHConfig()
|
var hosts []config.SSHHost
|
||||||
if err != nil {
|
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
|
// Could display an error message here
|
||||||
m.deleteMode = false
|
m.deleteMode = false
|
||||||
m.deleteHost = ""
|
m.deleteHost = ""
|
||||||
@ -222,7 +251,15 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, tea.ExecProcess(exec.Command("ssh", hostName), func(err error) tea.Msg {
|
// Build the SSH command with the appropriate config file
|
||||||
|
var sshCmd *exec.Cmd
|
||||||
|
if m.configFile != "" {
|
||||||
|
sshCmd = exec.Command("ssh", "-F", m.configFile, hostName)
|
||||||
|
} else {
|
||||||
|
sshCmd = exec.Command("ssh", hostName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, tea.ExecProcess(sshCmd, func(err error) tea.Msg {
|
||||||
return tea.Quit()
|
return tea.Quit()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -233,7 +270,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
selected := m.table.SelectedRow()
|
selected := m.table.SelectedRow()
|
||||||
if len(selected) > 0 {
|
if len(selected) > 0 {
|
||||||
hostName := selected[0] // The hostname is in the first column
|
hostName := selected[0] // The hostname is in the first column
|
||||||
editForm, err := NewEditForm(hostName, m.styles, m.width, m.height)
|
editForm, err := NewEditForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Handle error - could show in UI
|
// Handle error - could show in UI
|
||||||
return m, nil
|
return m, nil
|
||||||
@ -246,7 +283,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "a":
|
case "a":
|
||||||
if !m.searchMode && !m.deleteMode {
|
if !m.searchMode && !m.deleteMode {
|
||||||
// Add a new host
|
// Add a new host
|
||||||
m.addForm = NewAddForm("", m.styles, m.width, m.height)
|
m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile)
|
||||||
m.viewMode = ViewAdd
|
m.viewMode = ViewAdd
|
||||||
return m, textinput.Blink
|
return m, textinput.Blink
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user