From 94225cbfbeffa08ce4bb4b90bf1fca9f9bd0c12b Mon Sep 17 00:00:00 2001 From: Gu1llaum-3 Date: Tue, 2 Sep 2025 17:00:17 +0200 Subject: [PATCH] feat: support custom SSH config files via -c flag --- .github/copilot-instructions.md | 426 ++++++++++++++++++++++++++++++++ cmd/root.go | 48 +++- internal/config/ssh.go | 67 +++-- internal/ui/add_form.go | 37 +-- internal/ui/edit_form.go | 23 +- internal/ui/model.go | 1 + internal/ui/tui.go | 7 +- internal/ui/update.go | 53 +++- 8 files changed, 613 insertions(+), 49 deletions(-) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..5a919a7 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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.* diff --git a/cmd/root.go b/cmd/root.go index 8eba8ea..04529fc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "os" + "strings" "sshm/internal/config" "sshm/internal/ui" @@ -14,6 +15,9 @@ import ( // version will be set at build time via -ldflags var version = "dev" +// configFile holds the path to the SSH config file +var configFile string + var rootCmd = &cobra.Command{ Use: "sshm", Short: "SSH Manager - A modern SSH connection manager", @@ -36,7 +40,15 @@ configured in your ~/.ssh/config file.`, func runInteractiveMode() { // Parse SSH configurations - hosts, err := config.ParseSSHConfig() + var hosts []config.SSHHost + var err error + + if configFile != "" { + hosts, err = config.ParseSSHConfigFile(configFile) + } else { + hosts, err = config.ParseSSHConfig() + } + if err != nil { log.Fatalf("Error reading SSH config file: %v", err) } @@ -52,7 +64,11 @@ func runInteractiveMode() { fmt.Printf("Error adding host: %v\n", err) } // After adding, try to reload hosts and continue if any exist - hosts, err = config.ParseSSHConfig() + if configFile != "" { + hosts, err = config.ParseSSHConfigFile(configFile) + } else { + hosts, err = config.ParseSSHConfig() + } if err != nil || len(hosts) == 0 { fmt.Println("No hosts available, exiting.") os.Exit(1) @@ -64,14 +80,22 @@ func runInteractiveMode() { } // Run the interactive TUI - if err := ui.RunInteractiveMode(hosts); err != nil { + if err := ui.RunInteractiveMode(hosts, configFile); err != nil { log.Fatalf("Error running interactive mode: %v", err) } } func connectToHost(hostName string) { // Parse SSH configurations to verify host exists - hosts, err := config.ParseSSHConfig() + var hosts []config.SSHHost + var err error + + if configFile != "" { + hosts, err = config.ParseSSHConfigFile(configFile) + } else { + hosts, err = config.ParseSSHConfig() + } + if err != nil { log.Fatalf("Error reading SSH config file: %v", err) } @@ -93,9 +117,18 @@ func connectToHost(hostName string) { // Connect to the host fmt.Printf("Connecting to %s...\n", hostName) + + // Build the SSH command with the appropriate config file + var sshCmd []string + if configFile != "" { + sshCmd = []string{"ssh", "-F", configFile, hostName} + } else { + sshCmd = []string{"ssh", hostName} + } + // Note: In a real implementation, you'd use exec.Command here // For now, just print the command that would be executed - fmt.Printf("ssh %s\n", hostName) + fmt.Printf("%s\n", strings.Join(sshCmd, " ")) } // Execute adds all child commands to the root command and sets flags appropriately. @@ -105,3 +138,8 @@ func Execute() { os.Exit(1) } } + +func init() { + // Add the config file flag + rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)") +} diff --git a/internal/config/ssh.go b/internal/config/ssh.go index 6e250fd..061c998 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -181,15 +181,18 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) { // AddSSHHost adds a new SSH host to the config file func AddSSHHost(host SSHHost) error { - configMutex.Lock() - defer configMutex.Unlock() - homeDir, err := os.UserHomeDir() if err != nil { return err } - 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 if _, err := os.Stat(configPath); err == nil { @@ -198,8 +201,8 @@ func AddSSHHost(host SSHHost) error { } } - // Check if host already exists - exists, err := HostExists(host.Name) + // Check if host already exists in the specified config file + exists, err := HostExistsInFile(host.Name, configPath) if err != nil { return err } @@ -354,6 +357,21 @@ func HostExists(hostName string) (bool, error) { return false, nil } +// HostExistsInFile checks if a host exists in a specific config file +func HostExistsInFile(hostName string, configPath string) (bool, error) { + hosts, err := ParseSSHConfigFile(configPath) + if err != nil { + return false, err + } + + for _, host := range hosts { + if host.Name == hostName { + return true, nil + } + } + return false, nil +} + // GetSSHHost retrieves a specific host configuration by name func GetSSHHost(hostName string) (*SSHHost, error) { hosts, err := ParseSSHConfig() @@ -369,17 +387,35 @@ func GetSSHHost(hostName string) (*SSHHost, error) { return nil, fmt.Errorf("host '%s' not found", hostName) } +// GetSSHHostFromFile retrieves a specific host configuration by name from a specific config file +func GetSSHHostFromFile(hostName string, configPath string) (*SSHHost, error) { + hosts, err := ParseSSHConfigFile(configPath) + if err != nil { + return nil, err + } + + for _, host := range hosts { + if host.Name == hostName { + return &host, nil + } + } + return nil, fmt.Errorf("host '%s' not found", hostName) +} + // UpdateSSHHost updates an existing SSH host configuration func UpdateSSHHost(oldName string, newHost SSHHost) error { - configMutex.Lock() - defer configMutex.Unlock() - homeDir, err := os.UserHomeDir() if err != nil { return err } - 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 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 func DeleteSSHHost(hostName string) error { - configMutex.Lock() - defer configMutex.Unlock() - homeDir, err := os.UserHomeDir() if err != nil { return err } - 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 if err := backupConfig(configPath); err != nil { diff --git a/internal/ui/add_form.go b/internal/ui/add_form.go index 89caea0..e588445 100644 --- a/internal/ui/add_form.go +++ b/internal/ui/add_form.go @@ -13,17 +13,18 @@ import ( ) type addFormModel struct { - inputs []textinput.Model - focused int - err string - styles Styles - success bool - width int - height int + inputs []textinput.Model + focused int + err string + styles Styles + success bool + width int + height int + configFile string } // NewAddForm creates a new add form model -func NewAddForm(hostname string, styles Styles, width, height int) *addFormModel { +func NewAddForm(hostname string, styles Styles, width, height int, configFile string) *addFormModel { // Get current user for default currentUser, _ := user.Current() defaultUser := "root" @@ -100,11 +101,12 @@ func NewAddForm(hostname string, styles Styles, width, height int) *addFormModel inputs[tagsInput].Width = 50 return &addFormModel{ - inputs: inputs, - focused: nameInput, - styles: styles, - width: width, - height: height, + inputs: inputs, + focused: nameInput, + styles: styles, + width: width, + height: height, + configFile: configFile, } } @@ -270,7 +272,7 @@ func (m standaloneAddForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // RunAddForm provides backward compatibility for standalone add form func RunAddForm(hostname string) error { styles := NewStyles(80) - addForm := NewAddForm(hostname, styles, 80, 24) + addForm := NewAddForm(hostname, styles, 80, 24, "") m := standaloneAddForm{addForm} p := tea.NewProgram(m, tea.WithAltScreen()) @@ -327,7 +329,12 @@ func (m *addFormModel) submitForm() tea.Cmd { } // Add to config - err := config.AddSSHHost(host) + var err error + if m.configFile != "" { + err = config.AddSSHHostToFile(host, m.configFile) + } else { + err = config.AddSSHHost(host) + } return addFormSubmitMsg{hostname: name, err: err} } } diff --git a/internal/ui/edit_form.go b/internal/ui/edit_form.go index 0716c4a..8fce798 100644 --- a/internal/ui/edit_form.go +++ b/internal/ui/edit_form.go @@ -18,12 +18,21 @@ type editFormModel struct { originalName string width int height int + configFile string } // NewEditForm creates a new edit form model -func NewEditForm(hostName string, styles Styles, width, height int) (*editFormModel, error) { +func NewEditForm(hostName string, styles Styles, width, height int, configFile string) (*editFormModel, error) { // Get the existing host configuration - host, err := config.GetSSHHost(hostName) + var host *config.SSHHost + var err error + + if configFile != "" { + host, err = config.GetSSHHostFromFile(hostName, configFile) + } else { + host, err = config.GetSSHHost(hostName) + } + if err != nil { return nil, err } @@ -93,6 +102,7 @@ func NewEditForm(hostName string, styles Styles, width, height int) (*editFormMo inputs: inputs, focused: nameInput, originalName: hostName, + configFile: configFile, styles: styles, width: width, height: height, @@ -250,7 +260,7 @@ func (m standaloneEditForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // RunEditForm provides backward compatibility for standalone edit form func RunEditForm(hostName string) error { styles := NewStyles(80) - editForm, err := NewEditForm(hostName, styles, 80, 24) + editForm, err := NewEditForm(hostName, styles, 80, 24, "") if err != nil { return err } @@ -308,7 +318,12 @@ func (m *editFormModel) submitEditForm() tea.Cmd { } // Update the configuration - err := config.UpdateSSHHost(m.originalName, host) + var err error + if m.configFile != "" { + err = config.UpdateSSHHostInFile(m.originalName, host, m.configFile) + } else { + err = config.UpdateSSHHost(m.originalName, host) + } return editFormSubmitMsg{hostname: name, err: err} } } diff --git a/internal/ui/model.go b/internal/ui/model.go index fd21ed6..28f5bbe 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -48,6 +48,7 @@ type Model struct { deleteHost string historyManager *history.HistoryManager sortMode SortMode + configFile string // Path to the SSH config file // View management viewMode ViewMode diff --git a/internal/ui/tui.go b/internal/ui/tui.go index 874e734..e998429 100644 --- a/internal/ui/tui.go +++ b/internal/ui/tui.go @@ -14,7 +14,7 @@ import ( ) // NewModel creates a new TUI model with the given SSH hosts -func NewModel(hosts []config.SSHHost) Model { +func NewModel(hosts []config.SSHHost, configFile string) Model { // Initialize the history manager historyManager, err := history.NewHistoryManager() if err != nil { @@ -31,6 +31,7 @@ func NewModel(hosts []config.SSHHost) Model { hosts: hosts, historyManager: historyManager, sortMode: SortByName, + configFile: configFile, styles: styles, width: 80, height: 24, @@ -138,8 +139,8 @@ func NewModel(hosts []config.SSHHost) Model { } // RunInteractiveMode starts the interactive TUI interface -func RunInteractiveMode(hosts []config.SSHHost) error { - m := NewModel(hosts) +func RunInteractiveMode(hosts []config.SSHHost, configFile string) error { + m := NewModel(hosts, configFile) // Start the application in alt screen mode for clean output p := tea.NewProgram(m, tea.WithAltScreen()) diff --git a/internal/ui/update.go b/internal/ui/update.go index 996eb07..5422dd0 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -53,7 +53,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } else { // Success: refresh hosts and return to list view - hosts, err := config.ParseSSHConfig() + var hosts []config.SSHHost + var err error + + if m.configFile != "" { + hosts, err = config.ParseSSHConfigFile(m.configFile) + } else { + hosts, err = config.ParseSSHConfig() + } + if err != nil { return m, tea.Quit } @@ -82,7 +90,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } else { // Success: refresh hosts and return to list view - hosts, err := config.ParseSSHConfig() + var hosts []config.SSHHost + var err error + + if m.configFile != "" { + hosts, err = config.ParseSSHConfigFile(m.configFile) + } else { + hosts, err = config.ParseSSHConfig() + } + if err != nil { return m, tea.Quit } @@ -183,7 +199,12 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } else if m.deleteMode { // Confirm deletion - err := config.DeleteSSHHost(m.deleteHost) + var err error + if m.configFile != "" { + err = config.DeleteSSHHostFromFile(m.deleteHost, m.configFile) + } else { + err = config.DeleteSSHHost(m.deleteHost) + } if err != nil { // Could display an error message here m.deleteMode = false @@ -192,8 +213,16 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } // Refresh the hosts list - hosts, err := config.ParseSSHConfig() - if err != nil { + var hosts []config.SSHHost + var parseErr error + + if m.configFile != "" { + hosts, parseErr = config.ParseSSHConfigFile(m.configFile) + } else { + hosts, parseErr = config.ParseSSHConfig() + } + + if parseErr != nil { // Could display an error message here m.deleteMode = false m.deleteHost = "" @@ -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() }) } @@ -233,7 +270,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { selected := m.table.SelectedRow() if len(selected) > 0 { hostName := selected[0] // The hostname is in the first column - editForm, err := NewEditForm(hostName, m.styles, m.width, m.height) + editForm, err := NewEditForm(hostName, m.styles, m.width, m.height, m.configFile) if err != nil { // Handle error - could show in UI return m, nil @@ -246,7 +283,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "a": if !m.searchMode && !m.deleteMode { // Add a new host - m.addForm = NewAddForm("", m.styles, m.width, m.height) + m.addForm = NewAddForm("", m.styles, m.width, m.height, m.configFile) m.viewMode = ViewAdd return m, textinput.Blink }