From 073dc97c9939ca58f1924bd5189b70fdc2eed55c Mon Sep 17 00:00:00 2001 From: Sem Van Broekhoven <144097969+dotsem@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:31:28 +0200 Subject: [PATCH 01/11] feat: add new service menu --- .golangci.yml | 3 + internal/config/editor.go | 45 ++++ internal/config/manager.go | 1 + internal/config/model.go | 4 + internal/config/tags.go | 33 +++ internal/constants/keybinds.go | 20 ++ internal/ssh/authcheck.go | 55 ++++ internal/ssh/keygen.go | 50 ++++ internal/ssh/presets.go | 31 +++ internal/tui/commands.go | 203 +++++++++++++-- internal/tui/commands/service.go | 25 ++ internal/tui/commands/tag_test.go | 10 +- internal/tui/commands/types.go | 6 + internal/tui/components/alert.go | 25 +- internal/tui/components/component.go | 5 - internal/tui/components/confirm.go | 13 +- internal/tui/components/form.go | 81 ++---- internal/tui/components/help.go | 31 ++- internal/tui/components/services.go | 190 ++++++++++++++ internal/tui/constants.go | 2 - internal/tui/forms_service.go | 237 ++++++++++++++++++ internal/tui/keybinds.go | 38 +-- internal/tui/model.go | 3 +- internal/tui/services.go | 35 +++ internal/tui/update.go | 21 ++ .../utils/matches_multiple_string_options.go | 15 ++ .../matches_multiple_string_options_test.go | 34 +++ 27 files changed, 1071 insertions(+), 145 deletions(-) create mode 100644 internal/constants/keybinds.go create mode 100644 internal/ssh/authcheck.go create mode 100644 internal/ssh/keygen.go create mode 100644 internal/ssh/presets.go create mode 100644 internal/tui/commands/service.go create mode 100644 internal/tui/components/services.go create mode 100644 internal/tui/forms_service.go create mode 100644 internal/tui/services.go create mode 100644 internal/utils/matches_multiple_string_options.go create mode 100644 internal/utils/matches_multiple_string_options_test.go diff --git a/.golangci.yml b/.golangci.yml index 27f1907..e93d055 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -23,6 +23,9 @@ linters: linters: - dupl # Exclude duplicate check from tests - goconst # Exclude constant check from tests + - path: internal/constants/ + linters: + - revive issues: max-issues-per-linter: 0 diff --git a/internal/config/editor.go b/internal/config/editor.go index c115980..8360326 100644 --- a/internal/config/editor.go +++ b/internal/config/editor.go @@ -65,6 +65,51 @@ func (m *Manager) AddHost(targetFile string, h *Host) error { return m.SaveFile(absTarget) } +// AddServiceHost writes a service host block (e.g. GitHub, GitLab) directly to the +// primary SSH config file with a # tusshi: service marker, keeping it invisible in +// the tuSSHi connection list while remaining fully functional for git and SSH auth. +func (m *Manager) AddServiceHost(h *Host) error { + hostBlockStr := buildHostString(h) + decoded, err := ssh_config.Decode(strings.NewReader(hostBlockStr)) + if err != nil { + return err + } + + var newASTHost *ssh_config.Host + for _, astHost := range decoded.Hosts { + val := reflect.ValueOf(astHost) + isImplicit := false + if val.Kind() == reflect.Pointer && !val.IsNil() { + elem := val.Elem() + implicitField := elem.FieldByName("implicit") + if implicitField.IsValid() && implicitField.Kind() == reflect.Bool && implicitField.Bool() { + isImplicit = true + } + } + if !isImplicit && len(astHost.Patterns) > 0 { + newASTHost = astHost + break + } + } + + if newASTHost == nil { + return fmt.Errorf("could not parse host block for alias %q", h.Alias) + } + + if err := WriteServiceMarker(newASTHost); err != nil { + return err + } + + primaryCfg, exists := m.Configs[m.PrimaryPath] + if !exists { + primaryCfg = &ssh_config.Config{Hosts: []*ssh_config.Host{}} + m.Configs[m.PrimaryPath] = primaryCfg + } + + primaryCfg.Hosts = append(primaryCfg.Hosts, newASTHost) + return m.SaveFile(m.PrimaryPath) +} + // UpdateHost edits an existing Host block matched by its original alias. // It preserves formatting, comments, and other unrecognized nodes in the block. func (m *Manager) UpdateHost(originalAlias string, h *Host) error { diff --git a/internal/config/manager.go b/internal/config/manager.go index 7d23a2e..56071b4 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -81,6 +81,7 @@ func (m *Manager) GetHosts() []*Host { Alias: alias, SourceFile: filePath, IsWildcard: isWildcard, + IsService: ExtractServiceMarker(astHost.Nodes), Properties: make(map[string]string), } diff --git a/internal/config/model.go b/internal/config/model.go index 34179ea..a0b5634 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -29,6 +29,10 @@ type Host struct { // (e.g., "Host *") rather than a specific destination connection. IsWildcard bool + // IsService marks host blocks that exist for key-based service auth (e.g. GitHub, GitLab) + // but should not appear in the interactive connection list. + IsService bool + // Tags holds custom metadata tags extracted losslessly from host comments. Tags []string diff --git a/internal/config/tags.go b/internal/config/tags.go index c91e0bc..cfb0c2a 100644 --- a/internal/config/tags.go +++ b/internal/config/tags.go @@ -135,3 +135,36 @@ func createTagCommentNode(tags []string) (ssh_config.Node, error) { } return decoded.Hosts[0].Nodes[0], nil } + +const serviceMarker = "tusshi: service" + +// ExtractServiceMarker returns true when any comment node inside the host block +// contains the "tusshi: service" marker (case-insensitive). +func ExtractServiceMarker(nodes []ssh_config.Node) bool { + for _, node := range nodes { + if empty, ok := node.(*ssh_config.Empty); ok { + line := strings.ToLower(strings.TrimSpace(empty.String())) + if strings.Contains(line, serviceMarker) { + return true + } + } + } + return false +} + +// WriteServiceMarker prepends a "# tusshi: service" comment node to the host block +// so the entry is hidden from the tuSSHi connection list on next load. +func WriteServiceMarker(astHost *ssh_config.Host) error { + if astHost == nil { + return fmt.Errorf("astHost cannot be nil") + } + + decoded, err := ssh_config.Decode(strings.NewReader(" # " + serviceMarker + "\n")) + if err != nil || len(decoded.Hosts) == 0 || len(decoded.Hosts[0].Nodes) == 0 { + return fmt.Errorf("failed to create service marker AST node: %w", err) + } + + node := decoded.Hosts[0].Nodes[0] + astHost.Nodes = append([]ssh_config.Node{node}, astHost.Nodes...) + return nil +} diff --git a/internal/constants/keybinds.go b/internal/constants/keybinds.go new file mode 100644 index 0000000..daf782c --- /dev/null +++ b/internal/constants/keybinds.go @@ -0,0 +1,20 @@ +// Package constants holds global constants used across all packages +package constants + +const ( + KeyEsc = "esc" + KeyEnter = "enter" + KeyAdd = "a" + KeyDelete = "d" + KeyEdit = "e" + KeyHelp = "?" + KeyQuit = "q" + KeySearch = "/" + KeyCommand = ":" + KeyUp = "up, k" + KeyDown = "down, j" + KeyLeft = "left, h" + KeyRight = "right, l" + KeyPing = "p" + KeyPingAll = "P" +) diff --git a/internal/ssh/authcheck.go b/internal/ssh/authcheck.go new file mode 100644 index 0000000..f306cef --- /dev/null +++ b/internal/ssh/authcheck.go @@ -0,0 +1,55 @@ +package ssh + +import ( + "errors" + "os/exec" + "strings" +) + +// AuthResult holds the outcome of an SSH service authentication check. +type AuthResult struct { + OK bool + Error string // first line of stderr, only populated on exit code 255 +} + +// CheckAuth tests whether the SSH key for the given host alias authenticates +// successfully. It uses BatchMode to suppress interactive prompts and interprets +// exit code 255 as an SSH-level failure. Exit codes 0 and 1 both indicate the +// handshake succeeded — the server simply closed the session without opening a shell, +// which is the expected behaviour for Git hosting services. +func CheckAuth(alias string) AuthResult { + // #nosec G204 — alias is a Host block name from the user's own ssh config + cmd := exec.Command("ssh", + "-T", + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=5", + "-o", "StrictHostKeyChecking=accept-new", + alias, + ) + + var stderr strings.Builder + cmd.Stderr = &stderr + + err := cmd.Run() + if err == nil { + return AuthResult{OK: true} + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() != 255 { + return AuthResult{OK: true} + } + + errMsg := firstLine(strings.TrimSpace(stderr.String())) + if errMsg == "" { + errMsg = err.Error() + } + return AuthResult{OK: false, Error: errMsg} +} + +func firstLine(s string) string { + if before, _, ok := strings.Cut(s, "\n"); ok { + return before + } + return s +} diff --git a/internal/ssh/keygen.go b/internal/ssh/keygen.go new file mode 100644 index 0000000..98208b3 --- /dev/null +++ b/internal/ssh/keygen.go @@ -0,0 +1,50 @@ +// Package ssh provides helpers for SSH key generation and service authentication checks. +package ssh + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// Key type identifiers supported by ssh-keygen. +const ( + KeyTypeED25519 = "ed25519" + KeyTypeRSA = "rsa" + KeyTypeECDSA = "ecdsa" +) + +// KeyTypeOptions contains the list of supported SSH key types. +var KeyTypeOptions = []string{KeyTypeED25519, KeyTypeRSA, KeyTypeECDSA} + +// GenerateKey runs ssh-keygen to create a new keypair at the given path. +// keyType must be one of: ed25519, rsa, ecdsa. An empty passphrase is always used. +func GenerateKey(path, keyType, comment string) error { + if keyType == "" { + keyType = KeyTypeED25519 + } + + args := []string{"-t", keyType, "-f", path, "-N", "", "-C", comment} + if keyType == KeyTypeRSA { + args = append(args, "-b", "4096") + } + + // #nosec G204 — path and args are controlled by the user via the TUI wizard + cmd := exec.Command("ssh-keygen", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ssh-keygen failed: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil +} + +// ReadPublicKey reads the public key file corresponding to the given private key path. +func ReadPublicKey(privateKeyPath string) (string, error) { + pubPath := privateKeyPath + ".pub" + data, err := os.ReadFile(pubPath) // #nosec G304 — user-provided path from TUI + if err != nil { + return "", fmt.Errorf("reading public key %q: %w", pubPath, err) + } + return strings.TrimSpace(string(data)), nil +} diff --git a/internal/ssh/presets.go b/internal/ssh/presets.go new file mode 100644 index 0000000..f6b75bc --- /dev/null +++ b/internal/ssh/presets.go @@ -0,0 +1,31 @@ +package ssh + +// PresetCustom represents the custom service preset identifier. +const PresetCustom = "custom" + +// ServicePreset describes a known Git/SSH service that uses key-based auth. +type ServicePreset struct { + Name string // display name, e.g. "GitHub" + Alias string // SSH config Host alias, e.g. "github" + HostName string // actual destination, e.g. "github.com" + User string // remote user, always "git" for hosting services +} + +// Presets contains built-in service definitions for the key wizard. +// Custom and Bitbucket entries can be added in future iterations. +var Presets = []ServicePreset{ + {Name: "GitHub", Alias: "github", HostName: "github.com", User: "git"}, + {Name: "GitLab", Alias: "gitlab", HostName: "gitlab.com", User: "git"}, + // TODO: add more + {Name: "Custom", Alias: "service", HostName: "", User: ""}, +} + +// FindPreset returns the preset matching the given alias, or false if not found. +func FindPreset(alias string) (ServicePreset, bool) { + for _, p := range Presets { + if p.Alias == alias { + return p, true + } + } + return ServicePreset{}, false +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 2bf0c1f..14dfc3f 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -6,9 +6,11 @@ import ( "strings" "tusshi/internal/config" + "tusshi/internal/ssh" "tusshi/internal/tui/commands" "tusshi/internal/tui/components" "tusshi/internal/tui/theme" + "tusshi/internal/utils" tea "github.com/charmbracelet/bubbletea" ) @@ -27,6 +29,7 @@ const ( pingAllCmd = "P, pingall" tagCmd = "tag" untagCmd = "untag" + serviceCmd = "service, services, svc" ) // helpOptions centralizes all interactive command shortcuts and their help text @@ -39,6 +42,7 @@ var helpOptions = []components.HelpOption{ {Shortcut: untagCmd, Description: "Remove tags from connection (:untag [alias] )"}, {Shortcut: pingCmd, Description: "Ping selected connection"}, {Shortcut: pingAllCmd, Description: "Ping all connections"}, + {Shortcut: serviceCmd, Description: "Manage SSH services (:svc [add|edit|rm])"}, {Shortcut: addConfigCmd, Description: "Add a new config file"}, {Shortcut: renameConfigCmd, Description: "Rename a config file"}, {Shortcut: deleteConfigCmd, Description: "Delete empty config file"}, @@ -46,16 +50,6 @@ var helpOptions = []components.HelpOption{ {Shortcut: helpCmd, Description: "Show this help dialog"}, } -func matchesCommand(cmd string, shouldMatch string) bool { - cmds := strings.SplitSeq(shouldMatch, ",") - for s := range cmds { - if cmd == strings.TrimSpace(s) { - return true - } - } - return false -} - // cmdContext implements commands.Context to proxy actions to the Model. type cmdContext struct { model *Model @@ -68,7 +62,6 @@ func (c *cmdContext) Quit() { } // OpenHelp sets the active component to help overlay. - func (c *cmdContext) OpenHelp() { c.model.ActiveComponent = &components.Help{ Options: helpOptions, @@ -76,7 +69,7 @@ func (c *cmdContext) OpenHelp() { } } -// OpenForm sets up and opens the add/edit interactive form. +// OpenForm sets up and opens the add/edit interactive form for connections. func (c *cmdContext) OpenForm(action string) { c.model.FormAction = action c.model.ActiveComponent = &components.Form{ @@ -104,6 +97,155 @@ func (c *cmdContext) Reload() { c.model.Reload() } +// OpenServiceForm opens the SSH service form for adding or editing a service host. +func (c *cmdContext) OpenServiceForm(action string, targetHost *config.Host) { + state := &ServiceFormState{ + Action: action, + KeySource: keySourceGenerate, + KeyType: ssh.KeyTypeED25519, + PresetAlias: "github", + } + + if action == actionEdit && targetHost != nil { + state.OriginalAlias = targetHost.Alias + state.HostAlias = targetHost.Alias + state.HostName = targetHost.Name + state.HostUser = targetHost.User + state.KeyPath = targetHost.IdentityFile + state.KeySource = keySourceExisting + if preset, ok := ssh.FindPreset(targetHost.Alias); ok { + state.PresetAlias = preset.Alias + } else { + state.PresetAlias = ssh.PresetCustom + } + + } + + c.model.ActiveComponent = &components.Form{ + Form: BuildServiceForm(state), + OnSubmit: func() { + c.model.executeServiceFormSubmit(state) + }, + } + c.cmd = c.model.ActiveComponent.Init() +} + +// OpenServiceEdit locates a service host by alias and opens its edit form. +func (c *cmdContext) OpenServiceEdit(alias string) { + var found *config.Host + for _, h := range c.model.Hosts { + if h.IsService && h.Alias == alias { + found = h + break + } + } + if found == nil { + c.SetError(fmt.Sprintf("Service host %q not found", alias)) + return + } + c.OpenServiceForm(actionEdit, found) +} + +// DeleteService prompts for confirmation and deletes a service host by alias. +func (c *cmdContext) DeleteService(alias string) { + var found *config.Host + for _, h := range c.model.Hosts { + if h.IsService && h.Alias == alias { + found = h + break + } + } + if found == nil { + c.SetError(fmt.Sprintf("Service host %q not found", alias)) + return + } + + c.model.ActiveComponent = &components.Confirm{ + Title: "Delete Service Connection?", + Message: fmt.Sprintf("Are you sure you want to delete service host '%s'?", alias), + Theme: theme.Global, + Destructive: true, + OnConfirm: func() tea.Cmd { + if err := c.model.Manager.DeleteHost(alias); err != nil { + c.model.ErrorText = "Failed to delete service host: " + err.Error() + } else { + c.model.AlertText = fmt.Sprintf("Service host %q deleted", alias) + } + c.model.Reload() + return nil + }, + } +} + +// OpenServices opens the services overlay and triggers background auth checks. +func (c *cmdContext) OpenServices() { + var serviceHosts []*config.Host + for _, h := range c.model.Hosts { + if h.IsService { + serviceHosts = append(serviceHosts, h) + } + } + c.model.ActiveComponent = &components.Services{ + Hosts: serviceHosts, + Results: make(map[string]*components.ServiceStatus), + Theme: theme.Global, + } + c.cmd = c.model.CheckAllServices() +} + +// executeServiceFormSubmit processes service form submission for both add and edit actions. +func (m *Model) executeServiceFormSubmit(s *ServiceFormState) { + s.ApplyPreset() + + resolved := s.ResolvedKeyPath() + + if s.KeySource == keySourceGenerate { + if err := ssh.GenerateKey(resolved, s.KeyType, s.KeyComment); err != nil { + m.ErrorText = "Key generation failed: " + err.Error() + return + } + } + + h := &config.Host{ + Alias: s.HostAlias, + Name: s.HostName, + User: s.HostUser, + IdentityFile: resolved, + IsService: true, + Properties: make(map[string]string), + } + + var err error + if s.Action == actionEdit { + err = m.Manager.UpdateHost(s.OriginalAlias, h) + } else { + err = m.Manager.AddServiceHost(h) + } + + if err != nil { + m.ErrorText = "Failed to save service host: " + err.Error() + return + } + + m.Reload() + + if s.KeySource == keySourceGenerate { + pubKey, err := ssh.ReadPublicKey(resolved) + if err != nil { + m.AlertText = fmt.Sprintf("Key created at %s — could not read public key: %s", resolved, err) + return + } + m.ActiveComponent = &components.Alert{ + Title: "SSH Key Created — Add it to " + s.HostName, + Message: pubKey, + Theme: theme.Global, + } + return + } + + m.AlertText = fmt.Sprintf("Service host %q configured", s.HostAlias) +} + // GetActiveTab returns the model's active tab path. func (c *cmdContext) GetActiveTab() string { return c.model.ActiveTab @@ -125,16 +267,16 @@ func (m *Model) executeCommand(raw string) (tea.Model, tea.Cmd) { var action func(commands.Context) switch { - case matchesCommand(cmd, quitCmd): + case utils.MatchesMultipleStringOptions(cmd, quitCmd): action = commands.Quit() - case matchesCommand(cmd, newCmd): + case utils.MatchesMultipleStringOptions(cmd, newCmd): action = commands.New() - case matchesCommand(cmd, editCmd): + case utils.MatchesMultipleStringOptions(cmd, editCmd): action = commands.Edit(len(m.Filtered) > 0) - case matchesCommand(cmd, deleteCmd): + case utils.MatchesMultipleStringOptions(cmd, deleteCmd): if len(m.Filtered) > 0 { selected := m.Filtered[m.SelectedIndex] m.ActiveComponent = &components.Confirm{ @@ -152,7 +294,7 @@ func (m *Model) executeCommand(raw string) (tea.Model, tea.Cmd) { } return m, nil - case matchesCommand(cmd, moveCmd): + case utils.MatchesMultipleStringOptions(cmd, moveCmd): if len(m.Filtered) > 0 { selected := m.Filtered[m.SelectedIndex] action = commands.Move(m.Manager, selected, parts) @@ -160,42 +302,53 @@ func (m *Model) executeCommand(raw string) (tea.Model, tea.Cmd) { return m, nil } - case matchesCommand(cmd, helpCmd): + case utils.MatchesMultipleStringOptions(cmd, helpCmd): action = commands.Help() - case matchesCommand(cmd, pingAllCmd): + case utils.MatchesMultipleStringOptions(cmd, pingAllCmd): return m, m.PingAll() - case matchesCommand(cmd, pingCmd): + case utils.MatchesMultipleStringOptions(cmd, pingCmd): if len(m.Filtered) > 0 { selected := m.Filtered[m.SelectedIndex] return m, m.PingHost(selected) } return m, nil - case matchesCommand(cmd, addConfigCmd): + case utils.MatchesMultipleStringOptions(cmd, addConfigCmd): action = commands.AddConfig(m.Manager, parts) - case matchesCommand(cmd, renameConfigCmd): + case utils.MatchesMultipleStringOptions(cmd, renameConfigCmd): action = commands.RenameConfig(m.Manager, parts) - case matchesCommand(cmd, deleteConfigCmd): + case utils.MatchesMultipleStringOptions(cmd, deleteConfigCmd): action = commands.DeleteConfig(m.Manager, parts) - case matchesCommand(cmd, tagCmd): + case utils.MatchesMultipleStringOptions(cmd, tagCmd): var selected *config.Host if len(m.Filtered) > 0 { selected = m.Filtered[m.SelectedIndex] } action = commands.Tag(m.Manager, selected, parts) - case matchesCommand(cmd, untagCmd): + case utils.MatchesMultipleStringOptions(cmd, untagCmd): var selected *config.Host if len(m.Filtered) > 0 { selected = m.Filtered[m.SelectedIndex] } action = commands.Untag(m.Manager, selected, parts) + case utils.MatchesMultipleStringOptions(cmd, serviceCmd): + subcmd := "" + alias := "" + if len(parts) > 1 { + subcmd = parts[1] + } + if len(parts) > 2 { + alias = parts[2] + } + action = commands.Service(subcmd, alias) + default: m.ErrorText = "Unknown command: " + cmd return m, nil diff --git a/internal/tui/commands/service.go b/internal/tui/commands/service.go new file mode 100644 index 0000000..3367bb9 --- /dev/null +++ b/internal/tui/commands/service.go @@ -0,0 +1,25 @@ +package commands + +// Service returns a command function handling :service subcommands (add, edit, rm, list). +func Service(subcmd, alias string) func(Context) { + return func(ctx Context) { + switch subcmd { + case "add", "a": + ctx.OpenServiceForm("add", nil) + case "edit", "e": + if alias == "" { + ctx.SetError("Usage: :service edit ") + return + } + ctx.OpenServiceEdit(alias) + case "rm", "d": + if alias == "" { + ctx.SetError("Usage: :service rm ") + return + } + ctx.DeleteService(alias) + default: + ctx.OpenServices() + } + } +} diff --git a/internal/tui/commands/tag_test.go b/internal/tui/commands/tag_test.go index c5bcc52..7d6fd7d 100644 --- a/internal/tui/commands/tag_test.go +++ b/internal/tui/commands/tag_test.go @@ -26,9 +26,13 @@ func (m *mockContext) SetAlert(text string) { func (m *mockContext) SetError(text string) { m.errorText = text } -func (m *mockContext) Reload() { m.reloaded = true } -func (m *mockContext) GetActiveTab() string { return "All" } -func (m *mockContext) SetActiveTab(_ string) {} +func (m *mockContext) Reload() { m.reloaded = true } +func (m *mockContext) GetActiveTab() string { return "All" } +func (m *mockContext) SetActiveTab(_ string) {} +func (m *mockContext) OpenServiceForm(_ string, _ *config.Host) {} +func (m *mockContext) OpenServiceEdit(_ string) {} +func (m *mockContext) DeleteService(_ string) {} +func (m *mockContext) OpenServices() {} func TestTagCommand(t *testing.T) { tmpDir := t.TempDir() diff --git a/internal/tui/commands/types.go b/internal/tui/commands/types.go index 4d408ef..b25c2fe 100644 --- a/internal/tui/commands/types.go +++ b/internal/tui/commands/types.go @@ -1,5 +1,7 @@ package commands +import "tusshi/internal/config" + // Context defines the behavioral interface for executing TUI commands. type Context interface { Quit() @@ -10,4 +12,8 @@ type Context interface { Reload() GetActiveTab() string SetActiveTab(tab string) + OpenServiceForm(action string, targetHost *config.Host) + OpenServiceEdit(alias string) + DeleteService(alias string) + OpenServices() } diff --git a/internal/tui/components/alert.go b/internal/tui/components/alert.go index c400819..69cec62 100644 --- a/internal/tui/components/alert.go +++ b/internal/tui/components/alert.go @@ -2,13 +2,15 @@ package components import ( "strings" + "tusshi/internal/constants" "tusshi/internal/tui/theme" + "tusshi/internal/utils" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) -// Alert represents a reusable, self-contained TUI dialog modal for notices or errors. +// Alert represents a reusable TUI alert overlay component. type Alert struct { Title string Message string @@ -16,7 +18,7 @@ type Alert struct { Theme theme.Theme } -// Init initializes the alert component. +// Init initializes the alert dialog. func (a *Alert) Init() tea.Cmd { return nil } @@ -24,8 +26,10 @@ func (a *Alert) Init() tea.Cmd { // Update processes navigation and dismiss events. func (a *Alert) Update(msg tea.Msg) (tea.Cmd, bool) { if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch keyMsg.String() { - case keyEsc, "q", keyEnter: + switch { + case utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyEsc), + utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyQuit), + utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyEnter): return nil, true } } @@ -52,24 +56,13 @@ func (a *Alert) View(width int) string { divider := lipgloss.NewStyle().Foreground(a.Theme.Muted).Render(strings.Repeat("─", width)) - okBtn := lipgloss.NewStyle(). - Background(accentColor). - Foreground(lipgloss.Color("0")). - Bold(true). - Padding(0, 3). - Render(" OK ") - - buttonsStyle := lipgloss.NewStyle().Align(lipgloss.Center).Width(width) - rows := []string{ titleStyle.Render(a.Title), divider, "", msgStyle.Render(a.Message), "", - buttonsStyle.Render(okBtn), - "", - lipgloss.NewStyle().Foreground(a.Theme.Muted).Align(lipgloss.Center).Width(width).Render("Press Enter or Esc to dismiss"), + lipgloss.NewStyle().Foreground(a.Theme.Muted).Align(lipgloss.Center).Width(width).Render("Press OK / Enter / Esc / q to dismiss"), } return strings.Join(rows, "\n") diff --git a/internal/tui/components/component.go b/internal/tui/components/component.go index def0d11..219fdd6 100644 --- a/internal/tui/components/component.go +++ b/internal/tui/components/component.go @@ -3,11 +3,6 @@ package components import tea "github.com/charmbracelet/bubbletea" -const ( - keyEsc = "esc" - keyEnter = "enter" -) - // Component represents a self-contained interactive UI overlay. type Component interface { // Init initializes the component and returns any setup commands. diff --git a/internal/tui/components/confirm.go b/internal/tui/components/confirm.go index e864351..7402902 100644 --- a/internal/tui/components/confirm.go +++ b/internal/tui/components/confirm.go @@ -2,7 +2,9 @@ package components import ( "strings" + "tusshi/internal/constants" "tusshi/internal/tui/theme" + "tusshi/internal/utils" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -42,17 +44,18 @@ func (c *Confirm) Init() tea.Cmd { // Update processes navigation and selection events. func (c *Confirm) Update(msg tea.Msg) (tea.Cmd, bool) { if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch keyMsg.String() { - case "left", "h": + switch { + case utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyLeft): c.YesSelected = true - case "right", "l": + case utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyRight): c.YesSelected = false - case keyEnter: + case utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyEnter): if c.YesSelected && c.OnConfirm != nil { return c.OnConfirm(), true } return nil, true - case keyEsc, "q": + case utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyEsc), + utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyQuit): return nil, true } } diff --git a/internal/tui/components/form.go b/internal/tui/components/form.go index 9772a61..68c2bd7 100644 --- a/internal/tui/components/form.go +++ b/internal/tui/components/form.go @@ -1,15 +1,14 @@ package components import ( - "reflect" - "strings" + "tusshi/internal/constants" + "tusshi/internal/utils" - "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" ) -// Form wraps a huh.Form and its submission callback. +// Form is an interactive form component wrapping Huh form logic. type Form struct { Form *huh.Form OnSubmit func() @@ -24,11 +23,11 @@ func (f *Form) Init() tea.Cmd { // Update delegates key inputs to Huh and triggers submission. func (f *Form) Update(msg tea.Msg) (tea.Cmd, bool) { if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch keyMsg.String() { - case keyEsc: + switch { + case utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyEsc): return nil, true - case "alt+enter", "ctrl+s": + case utils.MatchesMultipleStringOptions(keyMsg.String(), "alt+enter, ctrl+s"): if focused := f.Form.GetFocusedField(); focused != nil { _ = focused.Blur() } @@ -50,62 +49,26 @@ func (f *Form) Update(msg tea.Msg) (tea.Cmd, bool) { } } - newForm, cmd := f.Form.Update(msg) - f.Form = newForm.(*huh.Form) - - switch f.Form.State { - case huh.StateCompleted: - if f.OnSubmit != nil { - f.OnSubmit() + _, formCmd := f.Form.Update(msg) + if f.Form.State == huh.StateCompleted || f.Form.State == huh.StateAborted { + if f.Form.State == huh.StateCompleted { + if f.Validate != nil { + if err := f.Validate(); err != nil { + return formCmd, false + } + } + if f.OnSubmit != nil { + f.OnSubmit() + } } - return nil, true - case huh.StateAborted: - return nil, true + return formCmd, true } - return cmd, false + return formCmd, false + } -// View renders the huh form with a custom help footer. +// View renders the interactive Huh form. func (f *Form) View(_ int) string { - formView := f.Form.View() - if f.Form.State != huh.StateNormal { - return formView - } - - var bindings []key.Binding - - bindings = append(bindings, key.NewBinding( - key.WithKeys("ctrl+s", "alt+enter"), - key.WithHelp("ctrl+s/alt+enter", "save"), - )) - - if focused := f.Form.GetFocusedField(); focused != nil { - focusedType := reflect.TypeOf(focused).String() - if strings.Contains(focusedType, "Select") { - bindings = append(bindings, key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select"), - )) - } else { - bindings = append(bindings, key.NewBinding( - key.WithKeys("enter", "tab"), - key.WithHelp("enter", "next"), - )) - } - } - - bindings = append(bindings, key.NewBinding( - key.WithKeys("shift+tab"), - key.WithHelp("shift+tab", "back"), - )) - - bindings = append(bindings, key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "exit"), - )) - - helpView := f.Form.Help().ShortHelpView(bindings) - - return formView + "\n\n" + helpView + return f.Form.View() } diff --git a/internal/tui/components/help.go b/internal/tui/components/help.go index e11cc67..9846613 100644 --- a/internal/tui/components/help.go +++ b/internal/tui/components/help.go @@ -3,19 +3,21 @@ package components import ( "fmt" "strings" + "tusshi/internal/constants" "tusshi/internal/tui/theme" + "tusshi/internal/utils" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) -// HelpOption represents a single command shortcut and its description. +// HelpOption defines a shortcut key and description pair for the help menu. type HelpOption struct { Shortcut string Description string } -// Help represents the interactive help dialog component. +// Help is a TUI overlay component displaying available shortcuts and commands. type Help struct { Options []HelpOption Theme theme.Theme @@ -29,8 +31,10 @@ func (h *Help) Init() tea.Cmd { // Update handles closing the help dialog. func (h *Help) Update(msg tea.Msg) (tea.Cmd, bool) { if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch keyMsg.String() { - case keyEsc, "q", keyEnter: + switch { + case utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyEsc), + utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyQuit), + utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyEnter): return nil, true } } @@ -45,18 +49,23 @@ func (h *Help) View(width int) string { Align(lipgloss.Center). Width(width) - header := titleStyle.Render("Available Commands") divider := lipgloss.NewStyle().Foreground(h.Theme.Muted).Render(strings.Repeat("─", width)) - - cmdStyle := lipgloss.NewStyle().Foreground(h.Theme.Primary).Bold(true) + keyStyle := lipgloss.NewStyle().Foreground(h.Theme.Secondary).Bold(true) descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255")) - var rows []string - rows = append(rows, header, divider) + rows := []string{ + titleStyle.Render("Available Commands"), + divider, + "", + } + for _, opt := range h.Options { - rows = append(rows, fmt.Sprintf(" %-22s %s", cmdStyle.Render(opt.Shortcut), descStyle.Render(opt.Description))) + row := fmt.Sprintf(" %-25s %s", keyStyle.Render(opt.Shortcut), descStyle.Render(opt.Description)) + rows = append(rows, row) } - rows = append(rows, "", lipgloss.NewStyle().Foreground(h.Theme.Muted).Align(lipgloss.Center).Width(width).Render("Press Esc to close")) + + rows = append(rows, "") + rows = append(rows, lipgloss.NewStyle().Foreground(h.Theme.Muted).Align(lipgloss.Center).Width(width).Render("Press Esc / q / Enter to close")) return strings.Join(rows, "\n") } diff --git a/internal/tui/components/services.go b/internal/tui/components/services.go new file mode 100644 index 0000000..a608d6e --- /dev/null +++ b/internal/tui/components/services.go @@ -0,0 +1,190 @@ +package components + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "tusshi/internal/config" + "tusshi/internal/constants" + gossh "tusshi/internal/ssh" + "tusshi/internal/tui/theme" + "tusshi/internal/utils" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ServiceStatus holds the live auth check state for one service host. +type ServiceStatus struct { + Checked bool + Result gossh.AuthResult +} + +// ServiceActionMsg is dispatched when a user triggers an action (edit, add, delete) from the Services overlay. +type ServiceActionMsg struct { + Action string // "edit", "add", "delete" + Host *config.Host +} + +// Services is an interactive overlay listing all IsService hosts with async auth indicators. +type Services struct { + Hosts []*config.Host + SelectedIndex int + Results map[string]*ServiceStatus + Theme theme.Theme +} + +// Init triggers auth checks for all service hosts immediately. +func (s *Services) Init() tea.Cmd { + return nil +} + +// Update handles navigation and action keybindings in the services overlay. +func (s *Services) Update(msg tea.Msg) (tea.Cmd, bool) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch { + case utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyEsc): + return nil, true + + case utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyUp): + if s.SelectedIndex > 0 { + s.SelectedIndex-- + } + return nil, false + + case utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyDown): + if s.SelectedIndex < len(s.Hosts)-1 { + s.SelectedIndex++ + } + return nil, false + + case utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyEdit): + if s.SelectedIndex >= 0 && s.SelectedIndex < len(s.Hosts) { + selected := s.Hosts[s.SelectedIndex] + return func() tea.Msg { + return ServiceActionMsg{Action: "edit", Host: selected} + }, true + } + + case utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyAdd): + return func() tea.Msg { + return ServiceActionMsg{Action: "add"} + }, true + + case utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyDelete): + if s.SelectedIndex >= 0 && s.SelectedIndex < len(s.Hosts) { + selected := s.Hosts[s.SelectedIndex] + return func() tea.Msg { + return ServiceActionMsg{Action: "delete", Host: selected} + }, true + } + } + } + return nil, false +} + +// SetResult stores an auth check result received from a background tea.Cmd. +func (s *Services) SetResult(alias string, result gossh.AuthResult) { + if s.Results == nil { + s.Results = make(map[string]*ServiceStatus) + } + s.Results[alias] = &ServiceStatus{Checked: true, Result: result} +} + +// View renders the interactive services overlay table. +func (s *Services) View(width int) string { + titleStyle := lipgloss.NewStyle(). + Foreground(s.Theme.Primary). + Bold(true). + Align(lipgloss.Center). + Width(width) + + divider := lipgloss.NewStyle(). + Foreground(s.Theme.Muted). + Render(strings.Repeat("─", width)) + + muteStyle := lipgloss.NewStyle().Foreground(s.Theme.Muted) + onlineStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + offlineStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + + selectedRowStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("237")). + Bold(true) + + rows := []string{ + titleStyle.Render("SSH Services"), + divider, + "", + } + + if len(s.Hosts) == 0 { + rows = append(rows, muteStyle.Align(lipgloss.Center).Width(width).Render("No service hosts configured — press 'a' or use :svc add")) + } else { + headerStyle := lipgloss.NewStyle().Foreground(s.Theme.Muted).Bold(true) + header := fmt.Sprintf(" %-12s %-20s %-28s %s", + headerStyle.Render("ALIAS"), + headerStyle.Render("HOST"), + headerStyle.Render("KEY"), + headerStyle.Render("AUTH"), + ) + rows = append(rows, header) + rows = append(rows, muteStyle.Render(strings.Repeat("─", width))) + + for i, h := range s.Hosts { + authCell := muteStyle.Render("○ Checking…") + if st, ok := s.Results[h.Alias]; ok && st.Checked { + if st.Result.OK { + authCell = onlineStyle.Render("● OK") + } else { + errSnippet := st.Result.Error + if len(errSnippet) > 30 { + errSnippet = errSnippet[:27] + "…" + } + authCell = offlineStyle.Render("● " + errSnippet) + } + } + + keyDisplay := shortenPath(h.IdentityFile) + rowText := fmt.Sprintf(" %-12s %-20s %-28s %s", + truncateStr(h.Alias, 12), + truncateStr(h.Name, 20), + truncateStr(keyDisplay, 28), + authCell, + ) + + if i == s.SelectedIndex { + rowText = selectedRowStyle.Width(width).Render(rowText) + } + rows = append(rows, rowText) + } + } + + rows = append(rows, "") + rows = append(rows, muteStyle.Align(lipgloss.Center).Width(width).Render("↑/↓ navigate • e edit • a add • d delete • Esc close")) + + return strings.Join(rows, "\n") +} + +func shortenPath(p string) string { + if p == "" { + return "—" + } + home, err := os.UserHomeDir() + if err != nil { + return p + } + rel, err := filepath.Rel(home, p) + if err != nil || strings.HasPrefix(rel, "..") { + return p + } + return "~/" + rel +} + +func truncateStr(s string, limit int) string { + runes := []rune(s) + if len(runes) <= limit { + return s + } + return string(runes[:limit-1]) + "…" +} diff --git a/internal/tui/constants.go b/internal/tui/constants.go index d0f21ba..abc82d1 100644 --- a/internal/tui/constants.go +++ b/internal/tui/constants.go @@ -4,6 +4,4 @@ const ( actionAdd = "add" actionEdit = "edit" tabAll = "All" - keyEsc = "esc" - keyEnter = "enter" ) diff --git a/internal/tui/forms_service.go b/internal/tui/forms_service.go new file mode 100644 index 0000000..5943841 --- /dev/null +++ b/internal/tui/forms_service.go @@ -0,0 +1,237 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + tussh "tusshi/internal/ssh" + + "github.com/charmbracelet/huh" +) + +const ( + keySourceGenerate = "generate" + keySourceExisting = "existing" +) + +// ServiceFormState holds all mutable form values for adding or editing an SSH service connection. +type ServiceFormState struct { + Action string // "add" or "edit" + OriginalAlias string + KeySource string + KeyType string + KeyPath string + KeyComment string + PresetAlias string + HostAlias string + HostName string + HostUser string + + lastPreset string + lastKeyType string +} + +// BuildServiceForm constructs the multi-step form for adding or editing a service host. +func BuildServiceForm(s *ServiceFormState) *huh.Form { + if s.Action == actionAdd { + s.ApplyPreset() + s.lastPreset = s.PresetAlias + s.lastKeyType = s.KeyType + } + + presetOptions := make([]huh.Option[string], len(tussh.Presets)) + for i, p := range tussh.Presets { + presetOptions[i] = huh.NewOption(p.Name, p.Alias) + } + + keyTypeOptions := []huh.Option[string]{ + huh.NewOption("ed25519 (recommended)", tussh.KeyTypeED25519), + huh.NewOption("RSA 4096", tussh.KeyTypeRSA), + huh.NewOption("ECDSA", tussh.KeyTypeECDSA), + } + + keySourceOptions := []huh.Option[string]{ + huh.NewOption("Generate a new key", keySourceGenerate), + huh.NewOption("Use an existing key file", keySourceExisting), + } + + inputKeyPath := huh.NewInput(). + Title("Output Key Path"). + Description("Where to save the private key"). + Value(&s.KeyPath) + + inputExistingKeyPath := huh.NewInput(). + Title("Private Key Path"). + Description("Absolute or ~ path to your existing private key"). + Placeholder("~/.ssh/id_ed25519"). + Value(&s.KeyPath). + Validate(func(v string) error { + expanded := expandTildePath(v) + if _, err := os.Stat(expanded); err != nil { + return fmt.Errorf("file not found: %s", expanded) + } + return nil + }) + + inputHostAlias := huh.NewInput(). + Title("Host Alias"). + Description("Name used in SSH config (e.g. github or github-work)"). + Value(&s.HostAlias) + + inputHostName := huh.NewInput(). + Title("HostName"). + Description("Actual destination hostname"). + Value(&s.HostName) + + inputHostUser := huh.NewInput(). + Title("User"). + Description("Remote user for this service"). + Value(&s.HostUser) + + syncFields := func() { + if s.Action != actionAdd { + return + } + if s.PresetAlias != s.lastPreset || s.KeyType != s.lastKeyType { + if preset, ok := tussh.FindPreset(s.PresetAlias); ok { + s.HostAlias = preset.Alias + s.HostName = preset.HostName + s.HostUser = preset.User + } else if s.PresetAlias == tussh.PresetCustom { + if s.lastPreset != "" { + s.HostAlias = "" + s.HostName = "" + s.HostUser = "git" + } + } + s.KeyPath = s.ProvideDefaultKeyPath() + + inputKeyPath.Value(&s.KeyPath) + inputHostAlias.Value(&s.HostAlias) + inputHostName.Value(&s.HostName) + inputHostUser.Value(&s.HostUser) + + s.lastPreset = s.PresetAlias + s.lastKeyType = s.KeyType + } + } + + step1 := huh.NewGroup( + huh.NewSelect[string](). + Title("Service Preset"). + Description("Which service are you configuring?"). + Options(presetOptions...). + Value(&s.PresetAlias), + huh.NewSelect[string](). + Title("Key Source"). + Description("How do you want to provide the SSH key?"). + Options(keySourceOptions...). + Value(&s.KeySource), + ) + + step2generate := huh.NewGroup( + huh.NewSelect[string](). + Title("Key Type"). + Options(keyTypeOptions...). + Value(&s.KeyType), + inputKeyPath, + huh.NewInput(). + Title("Comment"). + Description("Identifies the key (e.g. email)"). + Placeholder("you@example.com"). + Value(&s.KeyComment), + ).WithHideFunc(func() bool { + syncFields() + return s.KeySource != keySourceGenerate + }) + + step2existing := huh.NewGroup( + inputExistingKeyPath, + ).WithHideFunc(func() bool { + syncFields() + return s.KeySource != keySourceExisting + }) + + step3 := huh.NewGroup( + inputHostAlias, + inputHostName, + inputHostUser, + ).WithHideFunc(func() bool { + syncFields() + return false + }) + + form := huh.NewForm(step1, step2generate, step2existing, step3). + WithTheme(huh.ThemeCharm()). + WithWidth(60). + WithShowHelp(false) + + return form +} + +// ApplyPreset fills HostAlias, HostName, and HostUser from the selected preset if still empty on submit. +func (s *ServiceFormState) ApplyPreset() { + if preset, ok := tussh.FindPreset(s.PresetAlias); ok { + s.HostAlias = preset.Alias + s.HostName = preset.HostName + s.HostUser = preset.User + } else if s.PresetAlias == tussh.PresetCustom { + if s.HostUser == "" { + s.HostUser = "git" + } + } + + s.KeyPath = s.ProvideDefaultKeyPath() +} + +// ProvideDefaultKeyPath generates a non-colliding default SSH key path. +func (s *ServiceFormState) ProvideDefaultKeyPath() string { + alias := s.HostAlias + if alias == "" { + alias = s.PresetAlias + } + if alias == "" || alias == tussh.PresetCustom { + alias = "service" + } + kType := s.KeyType + if kType == "" { + kType = tussh.KeyTypeED25519 + } + + home, err := os.UserHomeDir() + if err != nil { + home = "~" + } + base := filepath.Join(home, ".ssh", fmt.Sprintf("id_%s_%s", kType, alias)) + target := base + counter := 1 + for { + if _, err := os.Stat(target); os.IsNotExist(err) { + break + } + target = fmt.Sprintf("%s_%d", base, counter) + counter++ + } + + if strings.HasPrefix(target, home) { + return "~" + target[len(home):] + } + return target +} + +// ResolvedKeyPath returns the expanded absolute path for the configured key. +func (s *ServiceFormState) ResolvedKeyPath() string { + return expandTildePath(s.KeyPath) +} + +func expandTildePath(path string) string { + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err == nil { + return filepath.Join(home, path[2:]) + } + } + return path +} diff --git a/internal/tui/keybinds.go b/internal/tui/keybinds.go index 8d049ea..db6291a 100644 --- a/internal/tui/keybinds.go +++ b/internal/tui/keybinds.go @@ -3,10 +3,12 @@ package tui import ( "fmt" + "tusshi/internal/constants" "tusshi/internal/ssh" "tusshi/internal/tui/commands" "tusshi/internal/tui/components" "tusshi/internal/tui/theme" + "tusshi/internal/utils" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -26,27 +28,27 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // handleNormalKey processes keyboard shortcuts when the application is in normal mode. func (m *Model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q": + switch { + case utils.MatchesMultipleStringOptions(msg.String(), constants.KeyQuit): return m, tea.Quit - case "j", "down": + case utils.MatchesMultipleStringOptions(msg.String(), constants.KeyDown): if m.SelectedIndex < len(m.Filtered)-1 { m.SelectedIndex++ } - case "k", "up": + case utils.MatchesMultipleStringOptions(msg.String(), constants.KeyUp): if m.SelectedIndex > 0 { m.SelectedIndex-- } - case "h", "left": + case utils.MatchesMultipleStringOptions(msg.String(), constants.KeyLeft): m.navigateTabs(-1) - case "l", "right": + case utils.MatchesMultipleStringOptions(msg.String(), constants.KeyRight): m.navigateTabs(1) - case "p": + case utils.MatchesMultipleStringOptions(msg.String(), constants.KeyPing): if len(m.Filtered) > 0 { selected := m.Filtered[m.SelectedIndex] if m.PingResults == nil { @@ -56,23 +58,23 @@ func (m *Model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.PingHost(selected) } - case "P": + case utils.MatchesMultipleStringOptions(msg.String(), constants.KeyPingAll): return m, m.PingAll() - case "/": + case utils.MatchesMultipleStringOptions(msg.String(), constants.KeySearch): m.Mode = ModeSearch m.SearchInput.SetValue("") m.SearchInput.Focus() m.FilterHosts() return m, textinput.Blink - case ":": + case utils.MatchesMultipleStringOptions(msg.String(), constants.KeyCommand): m.Mode = ModeCommand m.CommandInput.SetValue("") m.CommandInput.Focus() return m, textinput.Blink - case "a": + case utils.MatchesMultipleStringOptions(msg.String(), constants.KeyAdd): m.FormAction = actionAdd m.ActiveComponent = &components.Form{ Form: m.BuildHostForm(m.ActiveTab), @@ -83,7 +85,7 @@ func (m *Model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, m.ActiveComponent.Init() - case "e": + case utils.MatchesMultipleStringOptions(msg.String(), constants.KeyEdit): if len(m.Filtered) > 0 { m.FormAction = actionEdit m.ActiveComponent = &components.Form{ @@ -96,7 +98,7 @@ func (m *Model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.ActiveComponent.Init() } - case "d": + case utils.MatchesMultipleStringOptions(msg.String(), constants.KeyDelete): if len(m.Filtered) > 0 { selected := m.Filtered[m.SelectedIndex] m.ActiveComponent = &components.Confirm{ @@ -113,14 +115,14 @@ func (m *Model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, m.ActiveComponent.Init() } - case "?", ",": + case utils.MatchesMultipleStringOptions(msg.String(), constants.KeyHelp): m.ActiveComponent = &components.Help{ Options: helpOptions, Theme: theme.Global, } return m, m.ActiveComponent.Init() - case keyEnter: + case utils.MatchesMultipleStringOptions(msg.String(), constants.KeyEnter): if len(m.Filtered) > 0 { selected := m.Filtered[m.SelectedIndex] sshCmd := ssh.NewSSHCommand(selected.Alias) @@ -136,7 +138,7 @@ func (m *Model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // handleSearchKey processes keyboard input when performing a text search. func (m *Model) handleSearchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { - case keyEsc, keyEnter: + case constants.KeyEsc, constants.KeyEnter: m.Mode = ModeNormal m.SearchInput.Blur() return m, nil @@ -151,11 +153,11 @@ func (m *Model) handleSearchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // handleCommandKey processes keyboard input when typing command-line instructions. func (m *Model) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { - case keyEsc: + case constants.KeyEsc: m.Mode = ModeNormal m.CommandInput.Blur() return m, nil - case keyEnter: + case constants.KeyEnter: rawCmd := m.CommandInput.Value() m.Mode = ModeNormal m.CommandInput.Blur() diff --git a/internal/tui/model.go b/internal/tui/model.go index 8fd1dce..8776076 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -188,7 +188,8 @@ func (m *Model) FilterHosts() { } // why: wildcard configs (e.g. Host *) are metadata, not connectable hosts - if h.IsWildcard { + // why: service configs are not meant to be connected to directly (e.g. GitHub ssh connection to clone repos over ssh) + if h.IsWildcard || h.IsService { continue } diff --git a/internal/tui/services.go b/internal/tui/services.go new file mode 100644 index 0000000..7da7c7b --- /dev/null +++ b/internal/tui/services.go @@ -0,0 +1,35 @@ +package tui + +import ( + "tusshi/internal/config" + gossh "tusshi/internal/ssh" + + tea "github.com/charmbracelet/bubbletea" +) + +// ServiceCheckResult carries the auth check outcome for a single service host. +type ServiceCheckResult struct { + Alias string + Result gossh.AuthResult +} + +// CheckServiceAuth runs the SSH auth check for a service host as a background tea.Cmd. +func CheckServiceAuth(h *config.Host) tea.Cmd { + return func() tea.Msg { + return ServiceCheckResult{ + Alias: h.Alias, + Result: gossh.CheckAuth(h.Alias), + } + } +} + +// CheckAllServices returns a batch command to auth-check every service host. +func (m *Model) CheckAllServices() tea.Cmd { + var cmds []tea.Cmd + for _, h := range m.Hosts { + if h.IsService { + cmds = append(cmds, CheckServiceAuth(h)) + } + } + return tea.Batch(cmds...) +} diff --git a/internal/tui/update.go b/internal/tui/update.go index 29c9c94..adefbe4 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -4,6 +4,7 @@ import ( "fmt" "tusshi/internal/config" + "tusshi/internal/tui/components" tea "github.com/charmbracelet/bubbletea" ) @@ -43,6 +44,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case ServiceCheckResult: + if svc, ok := m.ActiveComponent.(*components.Services); ok { + svc.SetResult(msg.Alias, msg.Result) + } + return m, nil + + case components.ServiceActionMsg: + ctx := &cmdContext{model: m} + switch msg.Action { + case "add": // TODO generalize string + ctx.OpenServiceForm("add", nil) + case "edit": + ctx.OpenServiceForm("edit", msg.Host) + case "delete": + if msg.Host != nil { + ctx.DeleteService(msg.Host.Alias) + } + } + return m, ctx.cmd + case tea.KeyMsg: if msg.Type == tea.KeyCtrlC { return m, tea.Quit diff --git a/internal/utils/matches_multiple_string_options.go b/internal/utils/matches_multiple_string_options.go new file mode 100644 index 0000000..60a4708 --- /dev/null +++ b/internal/utils/matches_multiple_string_options.go @@ -0,0 +1,15 @@ +// Package utils provides general helper functions for string matching and formatting. +package utils + +import "strings" + +// MatchesMultipleStringOptions checks if input matches any of the comma-separated options. +func MatchesMultipleStringOptions(input string, options string) bool { + cmds := strings.SplitSeq(options, ",") + for cmd := range cmds { + if strings.TrimSpace(input) == strings.TrimSpace(cmd) { + return true + } + } + return false +} diff --git a/internal/utils/matches_multiple_string_options_test.go b/internal/utils/matches_multiple_string_options_test.go new file mode 100644 index 0000000..2528e1b --- /dev/null +++ b/internal/utils/matches_multiple_string_options_test.go @@ -0,0 +1,34 @@ +package utils_test + +import ( + "testing" + "tusshi/internal/utils" + + "github.com/stretchr/testify/assert" +) + +func TestMatchesMultipleStringOptions(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + input string + options string + expected bool + }{ + {"a", "a,b,c", true}, + {"b", "a,b,c", true}, + {"c", "a,b,c", true}, + {"d", "a,b,c", false}, + {"", "a,b,c", false}, + {"a", "", false}, + {"a", " a , b, c ", true}, + {"A", "a,b,c", false}, + {"a,b,c", "a,b,c", false}, + {" a ", " a , b, c ", true}, + } + + for _, test := range tests { + actual := utils.MatchesMultipleStringOptions(test.input, test.options) + assert.Equal(test.expected, actual, "Input: %q, Options: %q", test.input, test.options) + } +} From 7d52d17a9cc173b41e02361be8679268fd487668 Mon Sep 17 00:00:00 2001 From: Sem Van Broekhoven <144097969+dotsem@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:54:18 +0200 Subject: [PATCH 02/11] feat: improve list tile for service --- internal/tui/components/services.go | 54 +++++++++++++++++++---------- internal/tui/view.go | 10 ++++-- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/internal/tui/components/services.go b/internal/tui/components/services.go index a608d6e..9939745 100644 --- a/internal/tui/components/services.go +++ b/internal/tui/components/services.go @@ -121,42 +121,58 @@ func (s *Services) View(width int) string { if len(s.Hosts) == 0 { rows = append(rows, muteStyle.Align(lipgloss.Center).Width(width).Render("No service hosts configured — press 'a' or use :svc add")) } else { + aliasW := 14 + hostW := 20 + keyW := max(width-aliasW-hostW-7, 24) + headerStyle := lipgloss.NewStyle().Foreground(s.Theme.Muted).Bold(true) - header := fmt.Sprintf(" %-12s %-20s %-28s %s", - headerStyle.Render("ALIAS"), - headerStyle.Render("HOST"), + header := fmt.Sprintf(" %-*s %-*s %s", + aliasW, headerStyle.Render("ALIAS"), + hostW, headerStyle.Render("HOST"), headerStyle.Render("KEY"), - headerStyle.Render("AUTH"), ) rows = append(rows, header) rows = append(rows, muteStyle.Render(strings.Repeat("─", width))) for i, h := range s.Hosts { - authCell := muteStyle.Render("○ Checking…") + indicator := muteStyle.Render("○") + hasError := false + errMsg := "" + if st, ok := s.Results[h.Alias]; ok && st.Checked { if st.Result.OK { - authCell = onlineStyle.Render("● OK") + indicator = onlineStyle.Render("●") } else { - errSnippet := st.Result.Error - if len(errSnippet) > 30 { - errSnippet = errSnippet[:27] + "…" - } - authCell = offlineStyle.Render("● " + errSnippet) + indicator = offlineStyle.Render("●") + hasError = true + errMsg = st.Result.Error } } - keyDisplay := shortenPath(h.IdentityFile) - rowText := fmt.Sprintf(" %-12s %-20s %-28s %s", - truncateStr(h.Alias, 12), - truncateStr(h.Name, 20), - truncateStr(keyDisplay, 28), - authCell, + aliasCell := truncateStr(h.Alias, aliasW) + hostCell := truncateStr(h.Name, hostW) + keyCell := truncateStr(shortenPath(h.IdentityFile), keyW) + + line1 := fmt.Sprintf(" %s %-*s %-*s %s", + indicator, + aliasW, aliasCell, + hostW, hostCell, + keyCell, ) if i == s.SelectedIndex { - rowText = selectedRowStyle.Width(width).Render(rowText) + line1 = selectedRowStyle.Width(width).Render(line1) + } + rows = append(rows, line1) + + if hasError { + errText := truncateStr(errMsg, max(width-8, 15)) + line2 := fmt.Sprintf(" %s %s", muteStyle.Render("└"), offlineStyle.Render(errText)) + if i == s.SelectedIndex { + line2 = selectedRowStyle.Width(width).Render(line2) + } + rows = append(rows, line2) } - rows = append(rows, rowText) } } diff --git a/internal/tui/view.go b/internal/tui/view.go index bd3dc41..e17ac9b 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -24,19 +24,23 @@ func (m *Model) View() string { var dialogContent string var showDialog bool + dialogWidth := min(100, m.Width-4) + if dialogWidth < 50 { + dialogWidth = max(40, m.Width-2) + } if m.ActiveComponent != nil { - dialogContent = m.ActiveComponent.View(54) + dialogContent = m.ActiveComponent.View(dialogWidth - 4) showDialog = true } if showDialog { bgLines := strings.Split(stripANSI(bgString), "\n") - dialogWidth := min(60, m.Width-4) - dialogHeight := min(20, m.Height-2) + dialogHeight := min(22, m.Height-2) dialogBox := style.Dialog.Width(dialogWidth).Height(dialogHeight).Render(dialogContent) + dialogLines := strings.Split(dialogBox, "\n") dialogW := lipgloss.Width(dialogBox) From 87c254bce04442e25ec2b003770515692d052d35 Mon Sep 17 00:00:00 2001 From: Sem Van Broekhoven <144097969+dotsem@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:58:41 +0200 Subject: [PATCH 03/11] feat: add dialog instructing what to do with public key --- internal/tui/commands.go | 8 ++++++-- internal/tui/update.go | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 14dfc3f..aa2bc18 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -12,6 +12,7 @@ import ( "tusshi/internal/tui/theme" "tusshi/internal/utils" + "github.com/atotto/clipboard" tea "github.com/charmbracelet/bubbletea" ) @@ -235,9 +236,12 @@ func (m *Model) executeServiceFormSubmit(s *ServiceFormState) { m.AlertText = fmt.Sprintf("Key created at %s — could not read public key: %s", resolved, err) return } + + _ = clipboard.WriteAll(pubKey) + m.ActiveComponent = &components.Alert{ - Title: "SSH Key Created — Add it to " + s.HostName, - Message: pubKey, + Title: "SSH Key Created — Add to " + s.HostName, + Message: fmt.Sprintf("Public key copied to your clipboard!\n\nPaste this key into your %s account SSH settings:\n\n%s", s.HostName, pubKey), Theme: theme.Global, } return diff --git a/internal/tui/update.go b/internal/tui/update.go index adefbe4..6a9565c 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -73,9 +73,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.ActiveComponent != nil { + currentComponent := m.ActiveComponent var activeCmd tea.Cmd activeCmd, done := m.ActiveComponent.Update(msg) - if done { + if done && m.ActiveComponent == currentComponent { m.ActiveComponent = nil return m, tea.Batch(activeCmd, m.PingAll()) } From 16bc76c0e7fdd8a395856b4ff6811352cbd9acc3 Mon Sep 17 00:00:00 2001 From: Sem Van Broekhoven <144097969+dotsem@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:03:02 +0200 Subject: [PATCH 04/11] fix: black bars & inchoative list line highlighting --- internal/tui/components/services.go | 58 +++++++++++++++++++---------- internal/tui/style/styles.go | 1 - 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/internal/tui/components/services.go b/internal/tui/components/services.go index 9939745..e9aba3c 100644 --- a/internal/tui/components/services.go +++ b/internal/tui/components/services.go @@ -135,15 +135,14 @@ func (s *Services) View(width int) string { rows = append(rows, muteStyle.Render(strings.Repeat("─", width))) for i, h := range s.Hosts { - indicator := muteStyle.Render("○") + isOK := false hasError := false errMsg := "" if st, ok := s.Results[h.Alias]; ok && st.Checked { if st.Result.OK { - indicator = onlineStyle.Render("●") + isOK = true } else { - indicator = offlineStyle.Render("●") hasError = true errMsg = st.Result.Error } @@ -153,27 +152,48 @@ func (s *Services) View(width int) string { hostCell := truncateStr(h.Name, hostW) keyCell := truncateStr(shortenPath(h.IdentityFile), keyW) - line1 := fmt.Sprintf(" %s %-*s %-*s %s", - indicator, - aliasW, aliasCell, - hostW, hostCell, - keyCell, - ) - if i == s.SelectedIndex { - line1 = selectedRowStyle.Width(width).Render(line1) - } - rows = append(rows, line1) + indicatorSymbol := "○" + if isOK || hasError { + indicatorSymbol = "●" + } + listLine1 := fmt.Sprintf(" %s %-*s %-*s %s", + indicatorSymbol, + aliasW, aliasCell, + hostW, hostCell, + keyCell, + ) + rows = append(rows, selectedRowStyle.Width(width).Render(listLine1)) + + if hasError { + errText := truncateStr(errMsg, max(width-8, 15)) + listLine2 := fmt.Sprintf(" └ %s", errText) + rows = append(rows, selectedRowStyle.Width(width).Render(listLine2)) + } + } else { + indicator := muteStyle.Render("○") + if isOK { + indicator = onlineStyle.Render("●") + } else if hasError { + indicator = offlineStyle.Render("●") + } - if hasError { - errText := truncateStr(errMsg, max(width-8, 15)) - line2 := fmt.Sprintf(" %s %s", muteStyle.Render("└"), offlineStyle.Render(errText)) - if i == s.SelectedIndex { - line2 = selectedRowStyle.Width(width).Render(line2) + line1 := fmt.Sprintf(" %s %-*s %-*s %s", + indicator, + aliasW, aliasCell, + hostW, hostCell, + keyCell, + ) + rows = append(rows, line1) + + if hasError { + errText := truncateStr(errMsg, max(width-8, 15)) + line2 := fmt.Sprintf(" %s %s", muteStyle.Render("└"), offlineStyle.Render(errText)) + rows = append(rows, line2) } - rows = append(rows, line2) } } + } rows = append(rows, "") diff --git a/internal/tui/style/styles.go b/internal/tui/style/styles.go index 6817c48..c7068ae 100644 --- a/internal/tui/style/styles.go +++ b/internal/tui/style/styles.go @@ -89,7 +89,6 @@ var ( Dialog = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(theme.Global.Primary). - Background(lipgloss.Color("#1A1A1A")). Padding(1, 2) Muted = lipgloss.NewStyle(). From 702f4001572a9f9b722cbd489c53217e3c9ef956 Mon Sep 17 00:00:00 2001 From: Sem Van Broekhoven <144097969+dotsem@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:08:53 +0200 Subject: [PATCH 05/11] refactor: rename gossh to tussh --- internal/tui/components/services.go | 6 +++--- internal/tui/services.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/tui/components/services.go b/internal/tui/components/services.go index e9aba3c..d0cdbec 100644 --- a/internal/tui/components/services.go +++ b/internal/tui/components/services.go @@ -7,7 +7,7 @@ import ( "strings" "tusshi/internal/config" "tusshi/internal/constants" - gossh "tusshi/internal/ssh" + tussh "tusshi/internal/ssh" "tusshi/internal/tui/theme" "tusshi/internal/utils" @@ -18,7 +18,7 @@ import ( // ServiceStatus holds the live auth check state for one service host. type ServiceStatus struct { Checked bool - Result gossh.AuthResult + Result tussh.AuthResult } // ServiceActionMsg is dispatched when a user triggers an action (edit, add, delete) from the Services overlay. @@ -85,7 +85,7 @@ func (s *Services) Update(msg tea.Msg) (tea.Cmd, bool) { } // SetResult stores an auth check result received from a background tea.Cmd. -func (s *Services) SetResult(alias string, result gossh.AuthResult) { +func (s *Services) SetResult(alias string, result tussh.AuthResult) { if s.Results == nil { s.Results = make(map[string]*ServiceStatus) } diff --git a/internal/tui/services.go b/internal/tui/services.go index 7da7c7b..c4e922b 100644 --- a/internal/tui/services.go +++ b/internal/tui/services.go @@ -2,7 +2,7 @@ package tui import ( "tusshi/internal/config" - gossh "tusshi/internal/ssh" + tussh "tusshi/internal/ssh" tea "github.com/charmbracelet/bubbletea" ) @@ -10,7 +10,7 @@ import ( // ServiceCheckResult carries the auth check outcome for a single service host. type ServiceCheckResult struct { Alias string - Result gossh.AuthResult + Result tussh.AuthResult } // CheckServiceAuth runs the SSH auth check for a service host as a background tea.Cmd. @@ -18,7 +18,7 @@ func CheckServiceAuth(h *config.Host) tea.Cmd { return func() tea.Msg { return ServiceCheckResult{ Alias: h.Alias, - Result: gossh.CheckAuth(h.Alias), + Result: tussh.CheckAuth(h.Alias), } } } From 9b976ae51d0d2f8e8f1235b2dbb2ba19d221fef8 Mon Sep 17 00:00:00 2001 From: Sem Van Broekhoven <144097969+dotsem@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:16:43 +0200 Subject: [PATCH 06/11] feat: add shortcut to copy pubkey from service --- internal/constants/keybinds.go | 1 + internal/tui/components/services.go | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/internal/constants/keybinds.go b/internal/constants/keybinds.go index daf782c..805be54 100644 --- a/internal/constants/keybinds.go +++ b/internal/constants/keybinds.go @@ -17,4 +17,5 @@ const ( KeyRight = "right, l" KeyPing = "p" KeyPingAll = "P" + KeyCopy = "c" ) diff --git a/internal/tui/components/services.go b/internal/tui/components/services.go index d0cdbec..07ff199 100644 --- a/internal/tui/components/services.go +++ b/internal/tui/components/services.go @@ -11,6 +11,7 @@ import ( "tusshi/internal/tui/theme" "tusshi/internal/utils" + "github.com/atotto/clipboard" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -41,6 +42,7 @@ func (s *Services) Init() tea.Cmd { } // Update handles navigation and action keybindings in the services overlay. +// It returns true when the overlay should close, false otherwise. func (s *Services) Update(msg tea.Msg) (tea.Cmd, bool) { if keyMsg, ok := msg.(tea.KeyMsg); ok { switch { @@ -79,8 +81,19 @@ func (s *Services) Update(msg tea.Msg) (tea.Cmd, bool) { return ServiceActionMsg{Action: "delete", Host: selected} }, true } + case utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyCopy): + if s.SelectedIndex >= 0 && s.SelectedIndex < len(s.Hosts) { + // TODO: send success & error message to toast when toast component is implemented + selected := s.Hosts[s.SelectedIndex] + pubKey, _ := tussh.ReadPublicKey(selected.IdentityFile) + + _ = clipboard.WriteAll(pubKey) + return nil, false + } + } } + return nil, false } @@ -197,7 +210,7 @@ func (s *Services) View(width int) string { } rows = append(rows, "") - rows = append(rows, muteStyle.Align(lipgloss.Center).Width(width).Render("↑/↓ navigate • e edit • a add • d delete • Esc close")) + rows = append(rows, muteStyle.Align(lipgloss.Center).Width(width).Render("↑/↓ navigate • e edit • a add • d delete • c copy pubkey • Esc close")) return strings.Join(rows, "\n") } From c36ec26303571deba3a1578d88665bd2046b89a0 Mon Sep 17 00:00:00 2001 From: Sem Van Broekhoven <144097969+dotsem@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:20:04 +0200 Subject: [PATCH 07/11] fix: service indicator having no color when listtile is active --- internal/tui/components/services.go | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/internal/tui/components/services.go b/internal/tui/components/services.go index 07ff199..b3a4ed8 100644 --- a/internal/tui/components/services.go +++ b/internal/tui/components/services.go @@ -166,17 +166,19 @@ func (s *Services) View(width int) string { keyCell := truncateStr(shortenPath(h.IdentityFile), keyW) if i == s.SelectedIndex { - indicatorSymbol := "○" - if isOK || hasError { - indicatorSymbol = "●" + indicator := muteStyle.Background(lipgloss.Color("237")).Bold(true).Render("○") + if isOK { + indicator = onlineStyle.Background(lipgloss.Color("237")).Bold(true).Render("●") + } else if hasError { + indicator = offlineStyle.Background(lipgloss.Color("237")).Bold(true).Render("●") } - listLine1 := fmt.Sprintf(" %s %-*s %-*s %s", - indicatorSymbol, - aliasW, aliasCell, - hostW, hostCell, - keyCell, - ) - rows = append(rows, selectedRowStyle.Width(width).Render(listLine1)) + + padLeft := selectedRowStyle.Render(" ") + padMid := selectedRowStyle.Render(" ") + restText := fmt.Sprintf("%-*s %-*s %s", aliasW, aliasCell, hostW, hostCell, keyCell) + restFormatted := selectedRowStyle.Width(max(width-5, 10)).Render(restText) + + rows = append(rows, padLeft+indicator+padMid+restFormatted) if hasError { errText := truncateStr(errMsg, max(width-8, 15)) From 211183867ff19e89d38a55be2b8654177c7d5b88 Mon Sep 17 00:00:00 2001 From: Sem Van Broekhoven <144097969+dotsem@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:23:00 +0200 Subject: [PATCH 08/11] feat: add pinging to services --- internal/tui/components/services.go | 25 ++++++++++++++++++++++++- internal/tui/update.go | 6 ++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/internal/tui/components/services.go b/internal/tui/components/services.go index b3a4ed8..e163dfd 100644 --- a/internal/tui/components/services.go +++ b/internal/tui/components/services.go @@ -91,6 +91,29 @@ func (s *Services) Update(msg tea.Msg) (tea.Cmd, bool) { return nil, false } + case utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyPing): + if s.SelectedIndex >= 0 && s.SelectedIndex < len(s.Hosts) { + selected := s.Hosts[s.SelectedIndex] + if s.Results == nil { + s.Results = make(map[string]*ServiceStatus) + } + s.Results[selected.Alias] = &ServiceStatus{Checked: false} + return func() tea.Msg { + return ServiceActionMsg{Action: "ping", Host: selected} + }, false + } + + case utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyPingAll): + if s.Results == nil { + s.Results = make(map[string]*ServiceStatus) + } + for _, h := range s.Hosts { + s.Results[h.Alias] = &ServiceStatus{Checked: false} + } + return func() tea.Msg { + return ServiceActionMsg{Action: "pingall"} + }, false + } } @@ -212,7 +235,7 @@ func (s *Services) View(width int) string { } rows = append(rows, "") - rows = append(rows, muteStyle.Align(lipgloss.Center).Width(width).Render("↑/↓ navigate • e edit • a add • d delete • c copy pubkey • Esc close")) + rows = append(rows, muteStyle.Align(lipgloss.Center).Width(width).Render("↑/↓ navigate • e edit • a add • d delete • c copy • p/P recheck • Esc close")) return strings.Join(rows, "\n") } diff --git a/internal/tui/update.go b/internal/tui/update.go index 6a9565c..421adb6 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -61,6 +61,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Host != nil { ctx.DeleteService(msg.Host.Alias) } + case "ping": + if msg.Host != nil { + return m, CheckServiceAuth(msg.Host) + } + case "pingall": + return m, m.CheckAllServices() } return m, ctx.cmd From 7fecd7c254778694b2e6e23acf93caad8acef8f4 Mon Sep 17 00:00:00 2001 From: Sem Van Broekhoven <144097969+dotsem@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:24:22 +0200 Subject: [PATCH 09/11] test: add tests --- internal/ssh/keygen_test.go | 35 ++++++ internal/ssh/presets_test.go | 32 ++++++ internal/tui/components/services_test.go | 139 +++++++++++++++++++++++ internal/tui/forms_service_test.go | 64 +++++++++++ 4 files changed, 270 insertions(+) create mode 100644 internal/ssh/keygen_test.go create mode 100644 internal/ssh/presets_test.go create mode 100644 internal/tui/components/services_test.go create mode 100644 internal/tui/forms_service_test.go diff --git a/internal/ssh/keygen_test.go b/internal/ssh/keygen_test.go new file mode 100644 index 0000000..f454396 --- /dev/null +++ b/internal/ssh/keygen_test.go @@ -0,0 +1,35 @@ +package ssh_test + +import ( + "os" + "path/filepath" + "testing" + + "tusshi/internal/ssh" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKeygen(t *testing.T) { + t.Run("generate and read ed25519 keypair", func(t *testing.T) { + tempDir := t.TempDir() + keyPath := filepath.Join(tempDir, "id_ed25519_test") + + err := ssh.GenerateKey(keyPath, ssh.KeyTypeED25519, "test-key") + require.NoError(t, err) + + _, err = os.Stat(keyPath) + assert.NoError(t, err, "private key file should exist") + + pubKey, err := ssh.ReadPublicKey(keyPath) + assert.NoError(t, err, "reading public key should succeed") + assert.Contains(t, pubKey, "ssh-ed25519") + assert.Contains(t, pubKey, "test-key") + }) + + t.Run("returns error when reading non-existent public key", func(t *testing.T) { + _, err := ssh.ReadPublicKey("/non/existent/path/id_ed25519") + assert.Error(t, err) + }) +} diff --git a/internal/ssh/presets_test.go b/internal/ssh/presets_test.go new file mode 100644 index 0000000..304f2f6 --- /dev/null +++ b/internal/ssh/presets_test.go @@ -0,0 +1,32 @@ +package ssh_test + +import ( + "testing" + + "tusshi/internal/ssh" + + "github.com/stretchr/testify/assert" +) + +func TestFindPreset(t *testing.T) { + t.Run("returns built-in github preset", func(t *testing.T) { + preset, ok := ssh.FindPreset("github") + assert.True(t, ok) + assert.Equal(t, "GitHub", preset.Name) + assert.Equal(t, "github.com", preset.HostName) + assert.Equal(t, "git", preset.User) + }) + + t.Run("returns built-in gitlab preset", func(t *testing.T) { + preset, ok := ssh.FindPreset("gitlab") + assert.True(t, ok) + assert.Equal(t, "GitLab", preset.Name) + assert.Equal(t, "gitlab.com", preset.HostName) + assert.Equal(t, "git", preset.User) + }) + + t.Run("returns false for unknown preset", func(t *testing.T) { + _, ok := ssh.FindPreset("unknown-service") + assert.False(t, ok) + }) +} diff --git a/internal/tui/components/services_test.go b/internal/tui/components/services_test.go new file mode 100644 index 0000000..0e5d0ee --- /dev/null +++ b/internal/tui/components/services_test.go @@ -0,0 +1,139 @@ +package components_test + +import ( + "testing" + + "tusshi/internal/config" + gossh "tusshi/internal/ssh" + "tusshi/internal/tui/components" + "tusshi/internal/tui/theme" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" +) + +func TestServicesComponent(t *testing.T) { + sampleHosts := []*config.Host{ + {Alias: "github", Name: "github.com", User: "git", IdentityFile: "~/.ssh/id_ed25519_github", IsService: true}, + {Alias: "gitlab", Name: "gitlab.com", User: "git", IdentityFile: "~/.ssh/id_ed25519_gitlab", IsService: true}, + } + + t.Run("renders table rows and handles down/up navigation", func(t *testing.T) { + svc := &components.Services{ + Hosts: sampleHosts, + Theme: theme.Global, + } + + assert.Equal(t, 0, svc.SelectedIndex) + + // Press down arrow + cmd, done := svc.Update(tea.KeyMsg{Type: tea.KeyDown}) + assert.Nil(t, cmd) + assert.False(t, done) + assert.Equal(t, 1, svc.SelectedIndex) + + // Press up arrow + cmd, done = svc.Update(tea.KeyMsg{Type: tea.KeyUp}) + assert.Nil(t, cmd) + assert.False(t, done) + assert.Equal(t, 0, svc.SelectedIndex) + }) + + t.Run("dispatches edit action on 'e' key", func(t *testing.T) { + svc := &components.Services{ + Hosts: sampleHosts, + Theme: theme.Global, + } + + cmd, done := svc.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")}) + assert.True(t, done) + assert.NotNil(t, cmd) + + msg := cmd() + actionMsg, ok := msg.(components.ServiceActionMsg) + assert.True(t, ok) + assert.Equal(t, "edit", actionMsg.Action) + assert.Equal(t, "github", actionMsg.Host.Alias) + }) + + t.Run("dispatches add action on 'a' key", func(t *testing.T) { + svc := &components.Services{ + Hosts: sampleHosts, + Theme: theme.Global, + } + + cmd, done := svc.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) + assert.True(t, done) + assert.NotNil(t, cmd) + + msg := cmd() + actionMsg, ok := msg.(components.ServiceActionMsg) + assert.True(t, ok) + assert.Equal(t, "add", actionMsg.Action) + }) + + t.Run("dispatches delete action on 'd' key", func(t *testing.T) { + svc := &components.Services{ + Hosts: sampleHosts, + Theme: theme.Global, + } + + cmd, done := svc.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + assert.True(t, done) + assert.NotNil(t, cmd) + + msg := cmd() + actionMsg, ok := msg.(components.ServiceActionMsg) + assert.True(t, ok) + assert.Equal(t, "delete", actionMsg.Action) + assert.Equal(t, "github", actionMsg.Host.Alias) + }) + + t.Run("stores auth check result and renders status", func(t *testing.T) { + svc := &components.Services{ + Hosts: sampleHosts, + Theme: theme.Global, + } + + svc.SetResult("github", gossh.AuthResult{OK: true}) + svc.SetResult("gitlab", gossh.AuthResult{OK: false, Error: "Permission denied"}) + + viewStr := svc.View(80) + assert.Contains(t, viewStr, "github") + assert.Contains(t, viewStr, "gitlab") + assert.Contains(t, viewStr, "Permission denied") + }) + + t.Run("dispatches ping action on 'p' key", func(t *testing.T) { + svc := &components.Services{ + Hosts: sampleHosts, + Theme: theme.Global, + } + + cmd, done := svc.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")}) + assert.False(t, done) + assert.NotNil(t, cmd) + + msg := cmd() + actionMsg, ok := msg.(components.ServiceActionMsg) + assert.True(t, ok) + assert.Equal(t, "ping", actionMsg.Action) + assert.Equal(t, "github", actionMsg.Host.Alias) + }) + + t.Run("dispatches pingall action on 'P' key", func(t *testing.T) { + svc := &components.Services{ + Hosts: sampleHosts, + Theme: theme.Global, + } + + cmd, done := svc.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("P")}) + assert.False(t, done) + assert.NotNil(t, cmd) + + msg := cmd() + actionMsg, ok := msg.(components.ServiceActionMsg) + assert.True(t, ok) + assert.Equal(t, "pingall", actionMsg.Action) + }) +} diff --git a/internal/tui/forms_service_test.go b/internal/tui/forms_service_test.go new file mode 100644 index 0000000..8f0ff1d --- /dev/null +++ b/internal/tui/forms_service_test.go @@ -0,0 +1,64 @@ +package tui_test + +import ( + "testing" + + tussh "tusshi/internal/ssh" + "tusshi/internal/tui" + + "github.com/stretchr/testify/assert" +) + +func TestServiceFormState(t *testing.T) { + t.Run("ApplyPreset populates github defaults when fields empty", func(t *testing.T) { + state := &tui.ServiceFormState{ + Action: "add", + PresetAlias: "github", + KeySource: "generate", + KeyType: tussh.KeyTypeED25519, + } + + state.ApplyPreset() + + assert.Equal(t, "github", state.HostAlias) + assert.Equal(t, "github.com", state.HostName) + assert.Equal(t, "git", state.HostUser) + assert.Contains(t, state.KeyPath, "id_ed25519_github") + }) + + t.Run("ApplyPreset populates gitlab defaults when fields empty", func(t *testing.T) { + state := &tui.ServiceFormState{ + Action: "add", + PresetAlias: "gitlab", + KeySource: "generate", + KeyType: tussh.KeyTypeED25519, + } + + state.ApplyPreset() + + assert.Equal(t, "gitlab", state.HostAlias) + assert.Equal(t, "gitlab.com", state.HostName) + assert.Equal(t, "git", state.HostUser) + assert.Contains(t, state.KeyPath, "id_ed25519_gitlab") + }) + + t.Run("ProvideDefaultKeyPath generates non-colliding key path", func(t *testing.T) { + state := &tui.ServiceFormState{ + HostAlias: "github-work", + KeyType: tussh.KeyTypeED25519, + } + + path := state.ProvideDefaultKeyPath() + assert.Contains(t, path, "id_ed25519_github-work") + }) + + t.Run("BuildServiceForm returns valid non-nil form", func(t *testing.T) { + state := &tui.ServiceFormState{ + Action: "add", + PresetAlias: "github", + } + + form := tui.BuildServiceForm(state) + assert.NotNil(t, form) + }) +} From 1d579f3e53afbc68a23de62d654b92591cb6cc0f Mon Sep 17 00:00:00 2001 From: Sem Van Broekhoven <144097969+dotsem@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:33:16 +0200 Subject: [PATCH 10/11] fix: preset alias didn't match openssh expected hostname for service --- internal/ssh/presets.go | 24 +++++++++++++----------- internal/ssh/presets_test.go | 6 ++++-- internal/tui/commands.go | 2 +- internal/tui/forms_service.go | 27 +++++++++++++++++---------- internal/tui/forms_service_test.go | 14 +++++++------- 5 files changed, 42 insertions(+), 31 deletions(-) diff --git a/internal/ssh/presets.go b/internal/ssh/presets.go index f6b75bc..030b072 100644 --- a/internal/ssh/presets.go +++ b/internal/ssh/presets.go @@ -1,29 +1,31 @@ package ssh -// PresetCustom represents the custom service preset identifier. -const PresetCustom = "custom" +const ( + // PresetCustom represents the custom service preset identifier. + PresetCustom = "custom" + defaultUserGit = "git" +) // ServicePreset describes a known Git/SSH service that uses key-based auth. type ServicePreset struct { Name string // display name, e.g. "GitHub" - Alias string // SSH config Host alias, e.g. "github" - HostName string // actual destination, e.g. "github.com" + KeyName string // base name for SSH key file, e.g. "github" + HostName string // SSH Host pattern and destination hostname, e.g. "github.com" User string // remote user, always "git" for hosting services } // Presets contains built-in service definitions for the key wizard. -// Custom and Bitbucket entries can be added in future iterations. var Presets = []ServicePreset{ - {Name: "GitHub", Alias: "github", HostName: "github.com", User: "git"}, - {Name: "GitLab", Alias: "gitlab", HostName: "gitlab.com", User: "git"}, + {Name: "GitHub", KeyName: "github", HostName: "github.com", User: defaultUserGit}, + {Name: "GitLab", KeyName: "gitlab", HostName: "gitlab.com", User: defaultUserGit}, // TODO: add more - {Name: "Custom", Alias: "service", HostName: "", User: ""}, + {Name: "Custom", KeyName: "service", HostName: "service", User: defaultUserGit}, } -// FindPreset returns the preset matching the given alias, or false if not found. -func FindPreset(alias string) (ServicePreset, bool) { +// FindPreset returns the preset matching the given name, hostname, or keyname. +func FindPreset(query string) (ServicePreset, bool) { for _, p := range Presets { - if p.Alias == alias { + if p.HostName == query || p.Name == query || p.KeyName == query { return p, true } } diff --git a/internal/ssh/presets_test.go b/internal/ssh/presets_test.go index 304f2f6..84e1579 100644 --- a/internal/ssh/presets_test.go +++ b/internal/ssh/presets_test.go @@ -10,17 +10,19 @@ import ( func TestFindPreset(t *testing.T) { t.Run("returns built-in github preset", func(t *testing.T) { - preset, ok := ssh.FindPreset("github") + preset, ok := ssh.FindPreset("github.com") assert.True(t, ok) assert.Equal(t, "GitHub", preset.Name) + assert.Equal(t, "github", preset.KeyName) assert.Equal(t, "github.com", preset.HostName) assert.Equal(t, "git", preset.User) }) t.Run("returns built-in gitlab preset", func(t *testing.T) { - preset, ok := ssh.FindPreset("gitlab") + preset, ok := ssh.FindPreset("gitlab.com") assert.True(t, ok) assert.Equal(t, "GitLab", preset.Name) + assert.Equal(t, "gitlab", preset.KeyName) assert.Equal(t, "gitlab.com", preset.HostName) assert.Equal(t, "git", preset.User) }) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index aa2bc18..1bdf307 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -115,7 +115,7 @@ func (c *cmdContext) OpenServiceForm(action string, targetHost *config.Host) { state.KeyPath = targetHost.IdentityFile state.KeySource = keySourceExisting if preset, ok := ssh.FindPreset(targetHost.Alias); ok { - state.PresetAlias = preset.Alias + state.PresetAlias = preset.HostName } else { state.PresetAlias = ssh.PresetCustom } diff --git a/internal/tui/forms_service.go b/internal/tui/forms_service.go index 5943841..cb8a72d 100644 --- a/internal/tui/forms_service.go +++ b/internal/tui/forms_service.go @@ -43,7 +43,7 @@ func BuildServiceForm(s *ServiceFormState) *huh.Form { presetOptions := make([]huh.Option[string], len(tussh.Presets)) for i, p := range tussh.Presets { - presetOptions[i] = huh.NewOption(p.Name, p.Alias) + presetOptions[i] = huh.NewOption(p.Name, p.HostName) } keyTypeOptions := []huh.Option[string]{ @@ -96,7 +96,7 @@ func BuildServiceForm(s *ServiceFormState) *huh.Form { } if s.PresetAlias != s.lastPreset || s.KeyType != s.lastKeyType { if preset, ok := tussh.FindPreset(s.PresetAlias); ok { - s.HostAlias = preset.Alias + s.HostAlias = preset.HostName s.HostName = preset.HostName s.HostUser = preset.User } else if s.PresetAlias == tussh.PresetCustom { @@ -174,7 +174,7 @@ func BuildServiceForm(s *ServiceFormState) *huh.Form { // ApplyPreset fills HostAlias, HostName, and HostUser from the selected preset if still empty on submit. func (s *ServiceFormState) ApplyPreset() { if preset, ok := tussh.FindPreset(s.PresetAlias); ok { - s.HostAlias = preset.Alias + s.HostAlias = preset.HostName s.HostName = preset.HostName s.HostUser = preset.User } else if s.PresetAlias == tussh.PresetCustom { @@ -188,13 +188,19 @@ func (s *ServiceFormState) ApplyPreset() { // ProvideDefaultKeyPath generates a non-colliding default SSH key path. func (s *ServiceFormState) ProvideDefaultKeyPath() string { - alias := s.HostAlias - if alias == "" { - alias = s.PresetAlias - } - if alias == "" || alias == tussh.PresetCustom { - alias = "service" + keyBaseName := "" + if preset, ok := tussh.FindPreset(s.PresetAlias); ok && preset.KeyName != "" { + keyBaseName = preset.KeyName + } else { + keyBaseName = s.HostAlias + if keyBaseName == "" { + keyBaseName = s.PresetAlias + } + if keyBaseName == "" || keyBaseName == tussh.PresetCustom { + keyBaseName = "service" + } } + kType := s.KeyType if kType == "" { kType = tussh.KeyTypeED25519 @@ -204,7 +210,8 @@ func (s *ServiceFormState) ProvideDefaultKeyPath() string { if err != nil { home = "~" } - base := filepath.Join(home, ".ssh", fmt.Sprintf("id_%s_%s", kType, alias)) + base := filepath.Join(home, ".ssh", fmt.Sprintf("id_%s_%s", kType, keyBaseName)) + target := base counter := 1 for { diff --git a/internal/tui/forms_service_test.go b/internal/tui/forms_service_test.go index 8f0ff1d..72bb8a3 100644 --- a/internal/tui/forms_service_test.go +++ b/internal/tui/forms_service_test.go @@ -10,33 +10,33 @@ import ( ) func TestServiceFormState(t *testing.T) { - t.Run("ApplyPreset populates github defaults when fields empty", func(t *testing.T) { + t.Run("ApplyPreset populates github.com defaults when fields empty", func(t *testing.T) { state := &tui.ServiceFormState{ Action: "add", - PresetAlias: "github", + PresetAlias: "github.com", KeySource: "generate", KeyType: tussh.KeyTypeED25519, } state.ApplyPreset() - assert.Equal(t, "github", state.HostAlias) + assert.Equal(t, "github.com", state.HostAlias) assert.Equal(t, "github.com", state.HostName) assert.Equal(t, "git", state.HostUser) assert.Contains(t, state.KeyPath, "id_ed25519_github") }) - t.Run("ApplyPreset populates gitlab defaults when fields empty", func(t *testing.T) { + t.Run("ApplyPreset populates gitlab.com defaults when fields empty", func(t *testing.T) { state := &tui.ServiceFormState{ Action: "add", - PresetAlias: "gitlab", + PresetAlias: "gitlab.com", KeySource: "generate", KeyType: tussh.KeyTypeED25519, } state.ApplyPreset() - assert.Equal(t, "gitlab", state.HostAlias) + assert.Equal(t, "gitlab.com", state.HostAlias) assert.Equal(t, "gitlab.com", state.HostName) assert.Equal(t, "git", state.HostUser) assert.Contains(t, state.KeyPath, "id_ed25519_gitlab") @@ -55,7 +55,7 @@ func TestServiceFormState(t *testing.T) { t.Run("BuildServiceForm returns valid non-nil form", func(t *testing.T) { state := &tui.ServiceFormState{ Action: "add", - PresetAlias: "github", + PresetAlias: "github.com", } form := tui.BuildServiceForm(state) From a56dc088ee74b9839b2e283132fb0809373b1666 Mon Sep 17 00:00:00 2001 From: Sem Van Broekhoven <144097969+dotsem@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:39:28 +0200 Subject: [PATCH 11/11] feat: add confirmation prompt to actually remove keys from teh system when deleting a service --- internal/tui/commands.go | 53 ++++++++++++++++++++++++++---- internal/tui/components/confirm.go | 4 +++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 1bdf307..fc86031 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -3,6 +3,7 @@ package tui import ( "fmt" + "os" "strings" "tusshi/internal/config" @@ -147,7 +148,7 @@ func (c *cmdContext) OpenServiceEdit(alias string) { c.OpenServiceForm(actionEdit, found) } -// DeleteService prompts for confirmation and deletes a service host by alias. +// DeleteService prompts for confirmation and deletes a service host by alias, with optional SSH key cleanup. func (c *cmdContext) DeleteService(alias string) { var found *config.Host for _, h := range c.model.Hosts { @@ -161,18 +162,58 @@ func (c *cmdContext) DeleteService(alias string) { return } + keyPath := expandTildePath(found.IdentityFile) + var hasKeyFile bool + if keyPath != "" { + if _, err := os.Stat(keyPath); err == nil { + hasKeyFile = true + } + } + c.model.ActiveComponent = &components.Confirm{ Title: "Delete Service Connection?", Message: fmt.Sprintf("Are you sure you want to delete service host '%s'?", alias), Theme: theme.Global, Destructive: true, OnConfirm: func() tea.Cmd { - if err := c.model.Manager.DeleteHost(alias); err != nil { - c.model.ErrorText = "Failed to delete service host: " + err.Error() - } else { - c.model.AlertText = fmt.Sprintf("Service host %q deleted", alias) + if !hasKeyFile { + if err := c.model.Manager.DeleteHost(alias); err != nil { + c.model.ErrorText = "Failed to delete service host: " + err.Error() + } else { + c.model.AlertText = fmt.Sprintf("Service host %q deleted", alias) + } + c.model.Reload() + return nil + } + + c.model.ActiveComponent = &components.Confirm{ + Title: "Delete Associated SSH Key Files?", + Message: fmt.Sprintf("Do you also want to remove key files from disk?\n\n• Private: %s\n• Public: %s.pub", found.IdentityFile, found.IdentityFile), + Theme: theme.Global, + YesStr: " Delete Keys ", + NoStr: " Keep Keys ", + Destructive: true, + OnConfirm: func() tea.Cmd { + _ = os.Remove(keyPath) + _ = os.Remove(keyPath + ".pub") + if err := c.model.Manager.DeleteHost(alias); err != nil { + c.model.ErrorText = "Failed to delete service host: " + err.Error() + } else { + c.model.AlertText = fmt.Sprintf("Service host %q and SSH key files deleted", alias) + } + c.model.Reload() + return nil + }, + OnCancel: func() tea.Cmd { + if err := c.model.Manager.DeleteHost(alias); err != nil { + c.model.ErrorText = "Failed to delete service host: " + err.Error() + } else { + c.model.AlertText = fmt.Sprintf("Service host %q deleted (keys preserved)", alias) + } + c.model.Reload() + return nil + }, } - c.model.Reload() return nil }, } diff --git a/internal/tui/components/confirm.go b/internal/tui/components/confirm.go index 7402902..9b29760 100644 --- a/internal/tui/components/confirm.go +++ b/internal/tui/components/confirm.go @@ -17,6 +17,7 @@ type Confirm struct { YesSelected bool Theme theme.Theme OnConfirm func() tea.Cmd + OnCancel func() tea.Cmd YesStr string NoStr string Destructive bool @@ -53,6 +54,9 @@ func (c *Confirm) Update(msg tea.Msg) (tea.Cmd, bool) { if c.YesSelected && c.OnConfirm != nil { return c.OnConfirm(), true } + if !c.YesSelected && c.OnCancel != nil { + return c.OnCancel(), true + } return nil, true case utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyEsc), utils.MatchesMultipleStringOptions(keyMsg.String(), constants.KeyQuit):