feat: support custom SSH config files via -c flag

This commit is contained in:
Gu1llaum-3 2025-09-02 17:00:17 +02:00
parent 98aa2b6579
commit 94225cbfbe
8 changed files with 613 additions and 49 deletions

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

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

View File

@ -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)")
}

View File

@ -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 {

View File

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

View File

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

View File

@ -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

View File

@ -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())

View File

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