diff --git a/internal/tui/commands.go b/internal/tui/commands.go index fc86031..4e5a78e 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -2,56 +2,14 @@ package tui import ( - "fmt" - "os" - "strings" - "tusshi/internal/config" - "tusshi/internal/ssh" "tusshi/internal/tui/commands" "tusshi/internal/tui/components" "tusshi/internal/tui/theme" - "tusshi/internal/utils" - "github.com/atotto/clipboard" tea "github.com/charmbracelet/bubbletea" ) -const ( - quitCmd = "q, quit" - newCmd = "n, new" - editCmd = "e, edit" - deleteCmd = "d, rm" - moveCmd = "m, mv" - helpCmd = "h, help, ?" - addConfigCmd = "addconf, add-config" - renameConfigCmd = "mvconf, rename-config" - deleteConfigCmd = "rmconf, delete-config" - pingCmd = "p, ping" - pingAllCmd = "P, pingall" - tagCmd = "tag" - untagCmd = "untag" - serviceCmd = "service, services, svc" -) - -// helpOptions centralizes all interactive command shortcuts and their help text -var helpOptions = []components.HelpOption{ - {Shortcut: newCmd, Description: "Create a new connection"}, - {Shortcut: editCmd, Description: "Edit selected connection"}, - {Shortcut: deleteCmd, Description: "Delete selected connection"}, - {Shortcut: moveCmd, Description: "Move connection to a file/tab"}, - {Shortcut: tagCmd, Description: "Add tags to connection (:tag [alias] )"}, - {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"}, - {Shortcut: quitCmd, Description: "Quit the application"}, - {Shortcut: helpCmd, Description: "Show this help dialog"}, -} - // cmdContext implements commands.Context to proxy actions to the Model. type cmdContext struct { model *Model @@ -66,7 +24,7 @@ func (c *cmdContext) Quit() { // OpenHelp sets the active component to help overlay. func (c *cmdContext) OpenHelp() { c.model.ActiveComponent = &components.Help{ - Options: helpOptions, + Options: commands.HelpOptions(), Theme: theme.Global, } } @@ -99,308 +57,59 @@ 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.HostName - } 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() +// GetActiveTab returns the model's active tab path. +func (c *cmdContext) GetActiveTab() string { + return c.model.ActiveTab } -// 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) +// SetActiveTab sets the model's active tab path. +func (c *cmdContext) SetActiveTab(tab string) { + c.model.ActiveTab = tab } -// 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 { - if h.IsService && h.Alias == alias { - found = h - break - } - } - if found == nil { - c.SetError(fmt.Sprintf("Service host %q not found", alias)) - return +// GetSelectedHost returns the currently selected host from the filtered list. +func (c *cmdContext) GetSelectedHost() *config.Host { + if len(c.model.Filtered) > 0 && c.model.SelectedIndex >= 0 && c.model.SelectedIndex < len(c.model.Filtered) { + return c.model.Filtered[c.model.SelectedIndex] } + return nil +} - keyPath := expandTildePath(found.IdentityFile) - var hasKeyFile bool - if keyPath != "" { - if _, err := os.Stat(keyPath); err == nil { - hasKeyFile = true - } - } +// GetManager returns the configuration manager instance. +func (c *cmdContext) GetManager() *config.Manager { + return c.model.Manager +} +// Confirm sets the active component to a confirmation dialog overlay. +func (c *cmdContext) Confirm(title, message string, destructive bool, onConfirm func()) { c.model.ActiveComponent = &components.Confirm{ - Title: "Delete Service Connection?", - Message: fmt.Sprintf("Are you sure you want to delete service host '%s'?", alias), + Title: title, + Message: message, Theme: theme.Global, - Destructive: true, + Destructive: destructive, OnConfirm: func() tea.Cmd { - 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 - }, - } + onConfirm() 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 - } - - _ = clipboard.WriteAll(pubKey) - - m.ActiveComponent = &components.Alert{ - 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 - } - - 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 +// PingHost triggers a background ping check for a single host. +func (c *cmdContext) PingHost(host *config.Host) { + c.cmd = c.model.PingHost(host) } -// SetActiveTab sets the model's active tab path. -func (c *cmdContext) SetActiveTab(tab string) { - c.model.ActiveTab = tab +// PingAll triggers background ping checks for all hosts. +func (c *cmdContext) PingAll() { + c.cmd = c.model.PingAll() } -// executeCommand runs commands typed into the command mode bar. +// executeCommand runs commands typed into the command mode bar via the commands registry. func (m *Model) executeCommand(raw string) (tea.Model, tea.Cmd) { - parts := strings.Fields(strings.TrimPrefix(raw, ":")) - if len(parts) == 0 { - return m, nil - } - - cmd := parts[0] - var action func(commands.Context) - - switch { - case utils.MatchesMultipleStringOptions(cmd, quitCmd): - action = commands.Quit() - - case utils.MatchesMultipleStringOptions(cmd, newCmd): - action = commands.New() - - case utils.MatchesMultipleStringOptions(cmd, editCmd): - action = commands.Edit(len(m.Filtered) > 0) - - case utils.MatchesMultipleStringOptions(cmd, deleteCmd): - if len(m.Filtered) > 0 { - selected := m.Filtered[m.SelectedIndex] - m.ActiveComponent = &components.Confirm{ - Title: "Delete Connection?", - Message: fmt.Sprintf("Are you sure you want to delete host '%s'?", selected.Alias), - Theme: theme.Global, - Destructive: true, - OnConfirm: func() tea.Cmd { - ctx := &cmdContext{model: m} - action := commands.Delete(m.Manager, selected) - action(ctx) - return ctx.cmd - }, - } - } - return m, nil - - case utils.MatchesMultipleStringOptions(cmd, moveCmd): - if len(m.Filtered) > 0 { - selected := m.Filtered[m.SelectedIndex] - action = commands.Move(m.Manager, selected, parts) - } else { - return m, nil - } - - case utils.MatchesMultipleStringOptions(cmd, helpCmd): - action = commands.Help() - - case utils.MatchesMultipleStringOptions(cmd, pingAllCmd): - return m, m.PingAll() - - case utils.MatchesMultipleStringOptions(cmd, pingCmd): - if len(m.Filtered) > 0 { - selected := m.Filtered[m.SelectedIndex] - return m, m.PingHost(selected) - } - return m, nil - - case utils.MatchesMultipleStringOptions(cmd, addConfigCmd): - action = commands.AddConfig(m.Manager, parts) - - case utils.MatchesMultipleStringOptions(cmd, renameConfigCmd): - action = commands.RenameConfig(m.Manager, parts) - - case utils.MatchesMultipleStringOptions(cmd, deleteConfigCmd): - action = commands.DeleteConfig(m.Manager, parts) - - 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 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 + ctx := &cmdContext{model: m} + if err := commands.Dispatch(raw, ctx); err != nil { + m.ErrorText = err.Error() return m, nil } - - ctx := &cmdContext{model: m} - action(ctx) - return m, ctx.cmd } diff --git a/internal/tui/commands/config.go b/internal/tui/commands/config.go index 311e805..1705de1 100644 --- a/internal/tui/commands/config.go +++ b/internal/tui/commands/config.go @@ -6,129 +6,140 @@ import ( "path/filepath" "strings" - "tusshi/internal/config" "tusshi/internal/validation" ) const tabAll = "All" -// AddConfig triggers creation of a new configuration file. -func AddConfig(mgr *config.Manager, parts []string) func(Context) { - return func(ctx Context) { - if len(parts) < 2 { - ctx.SetError("Usage: :add-config ") - return - } +type addConfigCmd struct{} - arg := parts[1] - var targetPath string - if filepath.IsAbs(arg) || strings.HasPrefix(arg, "~/") { - targetPath = arg - } else { - targetPath = filepath.Join(filepath.Dir(mgr.PrimaryPath), arg) - } +func (c *addConfigCmd) Keys() []string { return []string{"addconf", "add-config"} } +func (c *addConfigCmd) Description() string { return "Add a new config file" } +func (c *addConfigCmd) Execute(ctx Context, parts []string) { + if len(parts) < 2 { + ctx.SetError("Usage: :add-config ") + return + } - if err := validation.ValidateConfigName(filepath.Base(targetPath)); err != nil { - ctx.SetError("Invalid config name: " + err.Error()) - return - } + mgr := ctx.GetManager() + arg := parts[1] + var targetPath string + if filepath.IsAbs(arg) || strings.HasPrefix(arg, "~/") { + targetPath = arg + } else { + targetPath = filepath.Join(filepath.Dir(mgr.PrimaryPath), arg) + } - if err := mgr.AddConfigFile(targetPath); err != nil { - ctx.SetError("Add config error: " + err.Error()) - } else { - ctx.SetAlert(fmt.Sprintf("Created config file %q.", filepath.Base(targetPath))) - ctx.SetActiveTab(targetPath) - } - ctx.Reload() + if err := validation.ValidateConfigName(filepath.Base(targetPath)); err != nil { + ctx.SetError("Invalid config name: " + err.Error()) + return + } + + if err := mgr.AddConfigFile(targetPath); err != nil { + ctx.SetError("Add config error: " + err.Error()) + } else { + ctx.SetAlert(fmt.Sprintf("Created config file %q.", filepath.Base(targetPath))) + ctx.SetActiveTab(targetPath) } + ctx.Reload() } -// RenameConfig triggers renaming of an existing configuration file. -func RenameConfig(mgr *config.Manager, parts []string) func(Context) { - return func(ctx Context) { - activeTab := ctx.GetActiveTab() - - var oldName, newName string - switch { - case len(parts) == 2: - if activeTab == tabAll { - ctx.SetError("Cannot rename from 'All' tab. Usage: :rename-config ") - return - } - oldName = activeTab - newName = parts[1] - case len(parts) >= 3: - oldName = parts[1] - newName = parts[2] - default: - if activeTab == tabAll { - ctx.SetError("Usage: :rename-config ") - } else { - ctx.SetError("Usage: :rename-config ") - } - return - } +type renameConfigCmd struct{} + +func (c *renameConfigCmd) Keys() []string { return []string{"mvconf", "rename-config"} } +func (c *renameConfigCmd) Description() string { return "Rename a config file" } +func (c *renameConfigCmd) Execute(ctx Context, parts []string) { + mgr := ctx.GetManager() + activeTab := ctx.GetActiveTab() - oldPath, found := mgr.FindConfigFile(oldName) - if !found { - ctx.SetError(fmt.Sprintf("Config file %q not found", oldName)) + var oldName, newName string + switch { + case len(parts) == 2: + if activeTab == tabAll { + ctx.SetError("Cannot rename from 'All' tab. Usage: :rename-config ") return } - - var newPath string - if filepath.IsAbs(newName) || strings.HasPrefix(newName, "~/") { - newPath = newName + oldName = activeTab + newName = parts[1] + case len(parts) >= 3: + oldName = parts[1] + newName = parts[2] + default: + if activeTab == tabAll { + ctx.SetError("Usage: :rename-config ") } else { - newPath = filepath.Join(filepath.Dir(mgr.PrimaryPath), newName) + ctx.SetError("Usage: :rename-config ") } + return + } - if err := validation.ValidateConfigName(filepath.Base(newPath)); err != nil { - ctx.SetError("Invalid config name: " + err.Error()) - return - } + oldPath, found := mgr.FindConfigFile(oldName) + if !found { + ctx.SetError(fmt.Sprintf("Config file %q not found", oldName)) + return + } - if err := mgr.RenameConfigFile(oldPath, newPath); err != nil { - ctx.SetError("Rename config error: " + err.Error()) - } else { - ctx.SetAlert(fmt.Sprintf("Renamed config file to %q.", filepath.Base(newPath))) - if activeTab == oldPath { - ctx.SetActiveTab(newPath) - } + var newPath string + if filepath.IsAbs(newName) || strings.HasPrefix(newName, "~/") { + newPath = newName + } else { + newPath = filepath.Join(filepath.Dir(mgr.PrimaryPath), newName) + } + + if err := validation.ValidateConfigName(filepath.Base(newPath)); err != nil { + ctx.SetError("Invalid config name: " + err.Error()) + return + } + + if err := mgr.RenameConfigFile(oldPath, newPath); err != nil { + ctx.SetError("Rename config error: " + err.Error()) + } else { + ctx.SetAlert(fmt.Sprintf("Renamed config file to %q.", filepath.Base(newPath))) + if activeTab == oldPath { + ctx.SetActiveTab(newPath) } - ctx.Reload() } + ctx.Reload() } -// DeleteConfig triggers deletion of a configuration file when it contains no connections. -func DeleteConfig(mgr *config.Manager, parts []string) func(Context) { - return func(ctx Context) { - activeTab := ctx.GetActiveTab() +type deleteConfigCmd struct{} - var targetName string - if len(parts) >= 2 { - targetName = parts[1] - } else { - if activeTab == tabAll { - ctx.SetError("Usage: :delete-config (or switch to a tab and run :delete-config)") - return - } - targetName = activeTab - } +func (c *deleteConfigCmd) Keys() []string { return []string{"rmconf", "delete-config"} } +func (c *deleteConfigCmd) Description() string { return "Delete empty config file" } +func (c *deleteConfigCmd) Execute(ctx Context, parts []string) { + mgr := ctx.GetManager() + activeTab := ctx.GetActiveTab() - targetPath, found := mgr.FindConfigFile(targetName) - if !found { - ctx.SetError(fmt.Sprintf("Config file %q not found", targetName)) + var targetName string + if len(parts) >= 2 { + targetName = parts[1] + } else { + if activeTab == tabAll { + ctx.SetError("Usage: :delete-config (or switch to a tab and run :delete-config)") return } + targetName = activeTab + } - if err := mgr.DeleteConfigFile(targetPath); err != nil { - ctx.SetError("Delete config error: " + err.Error()) - } else { - ctx.SetAlert(fmt.Sprintf("Deleted config file %q.", filepath.Base(targetPath))) - if activeTab == targetPath { - ctx.SetActiveTab(tabAll) - } + targetPath, found := mgr.FindConfigFile(targetName) + if !found { + ctx.SetError(fmt.Sprintf("Config file %q not found", targetName)) + return + } + + if err := mgr.DeleteConfigFile(targetPath); err != nil { + ctx.SetError("Delete config error: " + err.Error()) + } else { + ctx.SetAlert(fmt.Sprintf("Deleted config file %q.", filepath.Base(targetPath))) + if activeTab == targetPath { + ctx.SetActiveTab(tabAll) } - ctx.Reload() } + ctx.Reload() +} + +func init() { + Register(&addConfigCmd{}) + Register(&renameConfigCmd{}) + Register(&deleteConfigCmd{}) } diff --git a/internal/tui/commands/delete.go b/internal/tui/commands/delete.go deleted file mode 100644 index fbf24ef..0000000 --- a/internal/tui/commands/delete.go +++ /dev/null @@ -1,19 +0,0 @@ -package commands - -import ( - "fmt" - - "tusshi/internal/config" -) - -// Delete returns a function that executes the deletion of a host connection. -func Delete(mgr *config.Manager, selectedHost *config.Host) func(Context) { - return func(ctx Context) { - if err := mgr.DeleteHost(selectedHost.Alias); err != nil { - ctx.SetError("Delete error: " + err.Error()) - } else { - ctx.SetAlert(fmt.Sprintf("Deleted connection %q.", selectedHost.Alias)) - } - ctx.Reload() - } -} diff --git a/internal/tui/commands/form.go b/internal/tui/commands/form.go deleted file mode 100644 index 75b809d..0000000 --- a/internal/tui/commands/form.go +++ /dev/null @@ -1,17 +0,0 @@ -package commands - -// New triggers opening a form for creating a new host connection. -func New() func(Context) { - return func(ctx Context) { - ctx.OpenForm("add") - } -} - -// Edit triggers opening a form for editing the selected host connection. -func Edit(hasFiltered bool) func(Context) { - return func(ctx Context) { - if hasFiltered { - ctx.OpenForm("edit") - } - } -} diff --git a/internal/tui/commands/help.go b/internal/tui/commands/help.go index 3580911..d3bfa24 100644 --- a/internal/tui/commands/help.go +++ b/internal/tui/commands/help.go @@ -1,8 +1,19 @@ package commands -// Help triggers opening the interactive help dialog. -func Help() func(Context) { - return func(ctx Context) { - ctx.OpenHelp() - } +type helpCommand struct{} + +func (h *helpCommand) Keys() []string { + return []string{"h", "help", "?"} +} + +func (h *helpCommand) Description() string { + return "Show this help overlay" +} + +func (h *helpCommand) Execute(ctx Context, _ []string) { + ctx.OpenHelp() +} + +func init() { + Register(&helpCommand{}) } diff --git a/internal/tui/commands/host.go b/internal/tui/commands/host.go new file mode 100644 index 0000000..496f881 --- /dev/null +++ b/internal/tui/commands/host.go @@ -0,0 +1,106 @@ +package commands + +import ( + "fmt" + "path/filepath" +) + +type newHostCmd struct{} + +func (c *newHostCmd) Keys() []string { return []string{"n", "new"} } +func (c *newHostCmd) Description() string { return "Create a new connection" } +func (c *newHostCmd) Execute(ctx Context, _ []string) { + ctx.OpenForm("add") +} + +type editHostCmd struct{} + +func (c *editHostCmd) Keys() []string { return []string{"e", "edit"} } +func (c *editHostCmd) Description() string { return "Edit selected connection" } +func (c *editHostCmd) Execute(ctx Context, _ []string) { + if ctx.GetSelectedHost() != nil { + ctx.OpenForm("edit") + } +} + +type deleteHostCmd struct{} + +func (c *deleteHostCmd) Keys() []string { return []string{"d", "rm"} } +func (c *deleteHostCmd) Description() string { return "Delete selected connection" } +func (c *deleteHostCmd) Execute(ctx Context, _ []string) { + selected := ctx.GetSelectedHost() + if selected == nil { + return + } + + title := "Delete Connection?" + message := fmt.Sprintf("Are you sure you want to delete host '%s'?", selected.Alias) + + ctx.Confirm(title, message, true, func() { + mgr := ctx.GetManager() + if err := mgr.DeleteHost(selected.Alias); err != nil { + ctx.SetError("Delete error: " + err.Error()) + } else { + ctx.SetAlert(fmt.Sprintf("Deleted connection %q.", selected.Alias)) + } + ctx.Reload() + }) +} + +type moveHostCmd struct{} + +func (c *moveHostCmd) Keys() []string { return []string{"m", "mv"} } +func (c *moveHostCmd) Description() string { return "Move connection to another config file" } +func (c *moveHostCmd) Execute(ctx Context, args []string) { + selected := ctx.GetSelectedHost() + if selected == nil { + return + } + + if len(args) < 2 { + ctx.SetError("Usage: :move ") + return + } + + mgr := ctx.GetManager() + targetNickname := args[1] + matchedFile, found := mgr.FindConfigFile(targetNickname) + if !found { + matchedFile = filepath.Join(filepath.Dir(mgr.PrimaryPath), targetNickname) + } + + if err := mgr.MoveHost(selected.Alias, matchedFile); err != nil { + ctx.SetError("Move error: " + err.Error()) + } else { + ctx.SetAlert(fmt.Sprintf("Moved %q to %s.", selected.Alias, filepath.Base(matchedFile))) + } + ctx.Reload() +} + +type pingHostCmd struct{} + +func (c *pingHostCmd) Keys() []string { return []string{"p", "ping"} } +func (c *pingHostCmd) Description() string { return "Ping selected connection" } +func (c *pingHostCmd) Execute(ctx Context, _ []string) { + selected := ctx.GetSelectedHost() + if selected != nil { + ctx.PingHost(selected) + } +} + +type pingAllCmd struct{} + +func (c *pingAllCmd) Keys() []string { return []string{"P", "pingall"} } +func (c *pingAllCmd) Description() string { return "Ping all connections" } +func (c *pingAllCmd) Execute(ctx Context, _ []string) { + ctx.PingAll() +} + +func init() { + Register(&newHostCmd{}) + Register(&editHostCmd{}) + Register(&deleteHostCmd{}) + Register(&moveHostCmd{}) + Register(&pingHostCmd{}) + Register(&pingAllCmd{}) +} diff --git a/internal/tui/commands/move.go b/internal/tui/commands/move.go deleted file mode 100644 index a9d04a0..0000000 --- a/internal/tui/commands/move.go +++ /dev/null @@ -1,31 +0,0 @@ -package commands - -import ( - "fmt" - "path/filepath" - - "tusshi/internal/config" -) - -// Move handles moving a connection from one configuration file/tab to another. -func Move(mgr *config.Manager, selectedHost *config.Host, parts []string) func(Context) { - return func(ctx Context) { - if len(parts) < 2 { - ctx.SetError("Usage: :move ") - return - } - - targetNickname := parts[1] - matchedFile, found := mgr.FindConfigFile(targetNickname) - if !found { - matchedFile = filepath.Join(filepath.Dir(mgr.PrimaryPath), targetNickname) - } - - if err := mgr.MoveHost(selectedHost.Alias, matchedFile); err != nil { - ctx.SetError("Move error: " + err.Error()) - } else { - ctx.SetAlert(fmt.Sprintf("Moved %q to %s.", selectedHost.Alias, filepath.Base(matchedFile))) - } - ctx.Reload() - } -} diff --git a/internal/tui/commands/quit.go b/internal/tui/commands/quit.go index 00b2973..4447dfc 100644 --- a/internal/tui/commands/quit.go +++ b/internal/tui/commands/quit.go @@ -1,8 +1,19 @@ package commands -// Quit executes the application quit request. -func Quit() func(Context) { - return func(ctx Context) { - ctx.Quit() - } +type quitCommand struct{} + +func (q *quitCommand) Keys() []string { + return []string{"q", "quit"} +} + +func (q *quitCommand) Description() string { + return "Exit the application" +} + +func (q *quitCommand) Execute(ctx Context, _ []string) { + ctx.Quit() +} + +func init() { + Register(&quitCommand{}) } diff --git a/internal/tui/commands/registry.go b/internal/tui/commands/registry.go new file mode 100644 index 0000000..781d872 --- /dev/null +++ b/internal/tui/commands/registry.go @@ -0,0 +1,84 @@ +// Package commands defines the interactive command execution handlers for the TUSSHI TUI. +package commands + +import ( + "fmt" + "strings" + + "tusshi/internal/tui/components" +) + +// Command defines the behavioral contract for executable TUI commands. +type Command interface { + Keys() []string + Description() string + Execute(ctx Context, args []string) +} + +// Registry manages the set of registered commands. +type Registry struct { + commands []Command +} + +// NewRegistry creates a new empty command registry. +func NewRegistry() *Registry { + return &Registry{ + commands: make([]Command, 0), + } +} + +// Register adds a command to the registry. +func (r *Registry) Register(cmd Command) { + r.commands = append(r.commands, cmd) +} + +// HelpOptions dynamically builds help options from all registered commands. +func (r *Registry) HelpOptions() []components.HelpOption { + opts := make([]components.HelpOption, 0, len(r.commands)) + for _, cmd := range r.commands { + shortcut := strings.Join(cmd.Keys(), ", ") + opts = append(opts, components.HelpOption{ + Shortcut: shortcut, + Description: cmd.Description(), + }) + } + return opts +} + +// Dispatch matches raw input against registered commands and executes the handler. +func (r *Registry) Dispatch(raw string, ctx Context) error { + parts := strings.Fields(strings.TrimPrefix(raw, ":")) + if len(parts) == 0 { + return nil + } + + key := parts[0] + for _, cmd := range r.commands { + for _, k := range cmd.Keys() { + if key == strings.TrimSpace(k) { + cmd.Execute(ctx, parts) + return nil + } + } + } + + return fmt.Errorf("unknown command: %s", key) +} + +// DefaultRegistry is the central command registry instance. +var DefaultRegistry = NewRegistry() + +// Register adds a command to the default package registry. +func Register(cmd Command) { + DefaultRegistry.Register(cmd) +} + +// Dispatch runs raw command string on the default package registry. +func Dispatch(raw string, ctx Context) error { + return DefaultRegistry.Dispatch(raw, ctx) +} + +// HelpOptions builds help options dynamically from the default registry. +func HelpOptions() []components.HelpOption { + return DefaultRegistry.HelpOptions() +} diff --git a/internal/tui/commands/service.go b/internal/tui/commands/service.go index 3367bb9..692baca 100644 --- a/internal/tui/commands/service.go +++ b/internal/tui/commands/service.go @@ -1,25 +1,39 @@ 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() +type serviceCmd struct{} + +func (c *serviceCmd) Keys() []string { return []string{"service", "services", "svc"} } +func (c *serviceCmd) Description() string { return "Manage SSH services (:svc [add|edit|rm])" } +func (c *serviceCmd) Execute(ctx Context, parts []string) { + subcmd := "" + alias := "" + if len(parts) > 1 { + subcmd = parts[1] + } + if len(parts) > 2 { + alias = parts[2] + } + + 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() } } + +func init() { + Register(&serviceCmd{}) +} diff --git a/internal/tui/commands/tag.go b/internal/tui/commands/tag.go index 534f3e1..c3afdca 100644 --- a/internal/tui/commands/tag.go +++ b/internal/tui/commands/tag.go @@ -8,85 +8,93 @@ import ( "tusshi/internal/config" ) -// Tag appends metadata tags to a target host or the selected host. -func Tag(mgr *config.Manager, selectedHost *config.Host, parts []string) func(Context) { - return func(ctx Context) { - if len(parts) < 2 { - ctx.SetError("Usage: :tag [alias] [tag2...]") - return - } +type tagCmd struct{} + +func (c *tagCmd) Keys() []string { return []string{"tag"} } +func (c *tagCmd) Description() string { return "Add tags to connection (:tag [alias] )" } +func (c *tagCmd) Execute(ctx Context, parts []string) { + if len(parts) < 2 { + ctx.SetError("Usage: :tag [alias] [tag2...]") + return + } - targetHost, tagArgs := resolveTargetHostAndTags(mgr, selectedHost, parts[1:]) - if targetHost == nil { - ctx.SetError("No connection selected") - return - } + mgr := ctx.GetManager() + selectedHost := ctx.GetSelectedHost() + targetHost, tagArgs := resolveTargetHostAndTags(mgr, selectedHost, parts[1:]) + if targetHost == nil { + ctx.SetError("No connection selected") + return + } - if len(tagArgs) == 0 { - ctx.SetError("Usage: :tag [alias] [tag2...]") - return - } + if len(tagArgs) == 0 { + ctx.SetError("Usage: :tag [alias] [tag2...]") + return + } - newTags := targetHost.Tags - for _, t := range tagArgs { - clean := config.ExtractTagsFromComment("# tags: " + t) - for _, ct := range clean { - if !slices.Contains(newTags, ct) { - newTags = append(newTags, ct) - } + newTags := targetHost.Tags + for _, t := range tagArgs { + clean := config.ExtractTagsFromComment("# tags: " + t) + for _, ct := range clean { + if !slices.Contains(newTags, ct) { + newTags = append(newTags, ct) } } + } - targetHost.Tags = newTags - if err := mgr.UpdateHost(targetHost.Alias, targetHost); err != nil { - ctx.SetError("Tag error: " + err.Error()) - } else { - ctx.SetAlert(fmt.Sprintf("Tagged %q with %s.", targetHost.Alias, strings.Join(tagArgs, ", "))) - } - ctx.Reload() + targetHost.Tags = newTags + if err := mgr.UpdateHost(targetHost.Alias, targetHost); err != nil { + ctx.SetError("Tag error: " + err.Error()) + } else { + ctx.SetAlert(fmt.Sprintf("Tagged %q with %s.", targetHost.Alias, strings.Join(tagArgs, ", "))) } + ctx.Reload() } -// Untag removes metadata tags from a target host or the selected host. -func Untag(mgr *config.Manager, selectedHost *config.Host, parts []string) func(Context) { - return func(ctx Context) { - if len(parts) < 2 { - ctx.SetError("Usage: :untag [alias] [tag2...]") - return - } +type untagCmd struct{} - targetHost, tagArgs := resolveTargetHostAndTags(mgr, selectedHost, parts[1:]) - if targetHost == nil { - ctx.SetError("No connection selected") - return - } +func (c *untagCmd) Keys() []string { return []string{"untag"} } +func (c *untagCmd) Description() string { + return "Remove tags from connection (:untag [alias] )" +} +func (c *untagCmd) Execute(ctx Context, parts []string) { + if len(parts) < 2 { + ctx.SetError("Usage: :untag [alias] [tag2...]") + return + } - if len(tagArgs) == 0 { - ctx.SetError("Usage: :untag [alias] [tag2...]") - return - } + mgr := ctx.GetManager() + selectedHost := ctx.GetSelectedHost() + targetHost, tagArgs := resolveTargetHostAndTags(mgr, selectedHost, parts[1:]) + if targetHost == nil { + ctx.SetError("No connection selected") + return + } - var tagsToRemove []string - for _, t := range tagArgs { - clean := config.ExtractTagsFromComment("# tags: " + t) - tagsToRemove = append(tagsToRemove, clean...) - } + if len(tagArgs) == 0 { + ctx.SetError("Usage: :untag [alias] [tag2...]") + return + } - var remaining []string - for _, t := range targetHost.Tags { - if !slices.Contains(tagsToRemove, t) { - remaining = append(remaining, t) - } - } + var tagsToRemove []string + for _, t := range tagArgs { + clean := config.ExtractTagsFromComment("# tags: " + t) + tagsToRemove = append(tagsToRemove, clean...) + } - targetHost.Tags = remaining - if err := mgr.UpdateHost(targetHost.Alias, targetHost); err != nil { - ctx.SetError("Untag error: " + err.Error()) - } else { - ctx.SetAlert(fmt.Sprintf("Removed tags from %q.", targetHost.Alias)) + var remaining []string + for _, t := range targetHost.Tags { + if !slices.Contains(tagsToRemove, t) { + remaining = append(remaining, t) } - ctx.Reload() } + + targetHost.Tags = remaining + if err := mgr.UpdateHost(targetHost.Alias, targetHost); err != nil { + ctx.SetError("Untag error: " + err.Error()) + } else { + ctx.SetAlert(fmt.Sprintf("Removed tags from %q.", targetHost.Alias)) + } + ctx.Reload() } func resolveTargetHostAndTags(mgr *config.Manager, selectedHost *config.Host, args []string) (*config.Host, []string) { @@ -104,3 +112,8 @@ func resolveTargetHostAndTags(mgr *config.Manager, selectedHost *config.Host, ar return selectedHost, args } + +func init() { + Register(&tagCmd{}) + Register(&untagCmd{}) +} diff --git a/internal/tui/commands/tag_test.go b/internal/tui/commands/tag_test.go index 7d6fd7d..0f25f33 100644 --- a/internal/tui/commands/tag_test.go +++ b/internal/tui/commands/tag_test.go @@ -12,27 +12,30 @@ import ( ) type mockContext struct { - alertText string - errorText string - reloaded bool + alertText string + errorText string + reloaded bool + selectedHost *config.Host + mgr *config.Manager } -func (m *mockContext) Quit() {} -func (m *mockContext) OpenHelp() {} -func (m *mockContext) OpenForm(_ string) {} -func (m *mockContext) SetAlert(text string) { - m.alertText = text -} -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) OpenServiceForm(_ string, _ *config.Host) {} -func (m *mockContext) OpenServiceEdit(_ string) {} -func (m *mockContext) DeleteService(_ string) {} -func (m *mockContext) OpenServices() {} +func (m *mockContext) Quit() {} +func (m *mockContext) OpenHelp() {} +func (m *mockContext) OpenForm(_ string) {} +func (m *mockContext) SetAlert(text string) { m.alertText = text } +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) OpenServiceForm(_ string, _ *config.Host) {} +func (m *mockContext) OpenServiceEdit(_ string) {} +func (m *mockContext) DeleteService(_ string) {} +func (m *mockContext) OpenServices() {} +func (m *mockContext) GetSelectedHost() *config.Host { return m.selectedHost } +func (m *mockContext) GetManager() *config.Manager { return m.mgr } +func (m *mockContext) Confirm(_, _ string, _ bool, onConfirm func()) { onConfirm() } +func (m *mockContext) PingHost(_ *config.Host) {} +func (m *mockContext) PingAll() {} func TestTagCommand(t *testing.T) { tmpDir := t.TempDir() @@ -54,9 +57,9 @@ Host web-server assert.Len(t, hosts, 1) t.Run("adds new tags to selected host", func(t *testing.T) { - ctx := &mockContext{} - action := commands.Tag(mgr, hosts[0], []string{":tag", "aws", "k8s"}) - action(ctx) + ctx := &mockContext{mgr: mgr, selectedHost: hosts[0]} + err := commands.Dispatch(":tag aws k8s", ctx) + assert.NoError(t, err) assert.True(t, ctx.reloaded) assert.Contains(t, ctx.alertText, "Tagged") @@ -66,9 +69,9 @@ Host web-server }) t.Run("removes tags from selected host", func(t *testing.T) { - ctx := &mockContext{} - action := commands.Untag(mgr, hosts[0], []string{":untag", "aws"}) - action(ctx) + ctx := &mockContext{mgr: mgr, selectedHost: hosts[0]} + err := commands.Dispatch(":untag aws", ctx) + assert.NoError(t, err) assert.True(t, ctx.reloaded) assert.Contains(t, ctx.alertText, "Removed tags") @@ -78,9 +81,9 @@ Host web-server }) t.Run("error on missing arguments", func(t *testing.T) { - ctx := &mockContext{} - action := commands.Tag(mgr, hosts[0], []string{":tag"}) - action(ctx) + ctx := &mockContext{mgr: mgr, selectedHost: hosts[0]} + err := commands.Dispatch(":tag", ctx) + assert.NoError(t, err) assert.Contains(t, ctx.errorText, "Usage") }) diff --git a/internal/tui/commands/types.go b/internal/tui/commands/types.go index b25c2fe..c00a1b2 100644 --- a/internal/tui/commands/types.go +++ b/internal/tui/commands/types.go @@ -16,4 +16,9 @@ type Context interface { OpenServiceEdit(alias string) DeleteService(alias string) OpenServices() + GetSelectedHost() *config.Host + GetManager() *config.Manager + Confirm(title, message string, destructive bool, onConfirm func()) + PingHost(host *config.Host) + PingAll() } diff --git a/internal/tui/components/format.go b/internal/tui/components/format.go new file mode 100644 index 0000000..6e0df19 --- /dev/null +++ b/internal/tui/components/format.go @@ -0,0 +1,30 @@ +package components + +import ( + "os" + "path/filepath" + "strings" +) + +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/components/services.go b/internal/tui/components/services.go index e163dfd..ea3faa0 100644 --- a/internal/tui/components/services.go +++ b/internal/tui/components/services.go @@ -2,8 +2,6 @@ package components import ( "fmt" - "os" - "path/filepath" "strings" "tusshi/internal/config" "tusshi/internal/constants" @@ -239,26 +237,3 @@ func (s *Services) View(width int) string { 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/forms.go b/internal/tui/host_form.go similarity index 74% rename from internal/tui/forms.go rename to internal/tui/host_form.go index ef66659..e15d4f2 100644 --- a/internal/tui/forms.go +++ b/internal/tui/host_form.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "path/filepath" "strings" @@ -10,9 +11,7 @@ import ( "github.com/charmbracelet/huh" ) -// BuildHostForm creates a beautiful multi-step interactive form using Huh -// for adding or editing an SSH connection. It accommodates standard fields -// and common advanced settings cleanly. +// BuildHostForm creates a multi-step interactive form for adding or editing an SSH connection. func (m *Model) BuildHostForm(defaultFile string) *huh.Form { m.FormHost = &config.Host{ Properties: make(map[string]string), @@ -122,3 +121,40 @@ func (m *Model) ValidateForm() error { } return validation.ValidateAlias(m.FormHost.Alias) } + +// executeFormSubmit saves the completed CRUD form details back to disk AST. +func (m *Model) executeFormSubmit() { + var err error + + m.FormHost.SourceFile = m.FormDestFile + m.FormHost.Tags = config.ExtractTagsFromComment("# tags: " + m.FormTagsString) + if m.FormProxyJump != "" { + m.FormHost.Properties["ProxyJump"] = m.FormProxyJump + } else { + delete(m.FormHost.Properties, "ProxyJump") + } + if m.FormForwardAgent != "" { + m.FormHost.Properties["ForwardAgent"] = m.FormForwardAgent + } else { + delete(m.FormHost.Properties, "ForwardAgent") + } + + switch m.FormAction { + case actionAdd: + err = m.Manager.AddHost(m.FormHost.SourceFile, m.FormHost) + if err == nil { + m.AlertText = fmt.Sprintf("Host %q added successfully!", m.FormHost.Alias) + } + case actionEdit: + err = m.Manager.UpdateHost(m.FormOriginalAlias, m.FormHost) + if err == nil { + m.AlertText = fmt.Sprintf("Host %q updated successfully!", m.FormHost.Alias) + } + } + + if err != nil { + m.ErrorText = "Error saving host: " + err.Error() + } + + m.Reload() +} diff --git a/internal/tui/keybinds.go b/internal/tui/keybinds.go index db6291a..a2f69c7 100644 --- a/internal/tui/keybinds.go +++ b/internal/tui/keybinds.go @@ -1,8 +1,6 @@ package tui import ( - "fmt" - "tusshi/internal/constants" "tusshi/internal/ssh" "tusshi/internal/tui/commands" @@ -100,24 +98,16 @@ func (m *Model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case utils.MatchesMultipleStringOptions(msg.String(), constants.KeyDelete): if len(m.Filtered) > 0 { - selected := m.Filtered[m.SelectedIndex] - m.ActiveComponent = &components.Confirm{ - Title: "Delete Connection?", - Message: fmt.Sprintf("Are you sure you want to delete host '%s'?", selected.Alias), - Theme: theme.Global, - Destructive: true, - OnConfirm: func() tea.Cmd { - ctx := &cmdContext{model: m} - action := commands.Delete(m.Manager, selected) - action(ctx) - return ctx.cmd - }, + ctx := &cmdContext{model: m} + _ = commands.Dispatch(":d", ctx) + if m.ActiveComponent != nil { + return m, m.ActiveComponent.Init() } - return m, m.ActiveComponent.Init() + return m, nil } case utils.MatchesMultipleStringOptions(msg.String(), constants.KeyHelp): m.ActiveComponent = &components.Help{ - Options: helpOptions, + Options: commands.HelpOptions(), Theme: theme.Global, } return m, m.ActiveComponent.Init() diff --git a/internal/tui/services.go b/internal/tui/service_check.go similarity index 100% rename from internal/tui/services.go rename to internal/tui/service_check.go diff --git a/internal/tui/forms_service.go b/internal/tui/service_form.go similarity index 100% rename from internal/tui/forms_service.go rename to internal/tui/service_form.go diff --git a/internal/tui/forms_service_test.go b/internal/tui/service_form_test.go similarity index 100% rename from internal/tui/forms_service_test.go rename to internal/tui/service_form_test.go diff --git a/internal/tui/service_overlay.go b/internal/tui/service_overlay.go new file mode 100644 index 0000000..88a99d8 --- /dev/null +++ b/internal/tui/service_overlay.go @@ -0,0 +1,205 @@ +package tui + +import ( + "fmt" + "os" + + "tusshi/internal/config" + "tusshi/internal/ssh" + "tusshi/internal/tui/components" + "tusshi/internal/tui/theme" + + "github.com/atotto/clipboard" + tea "github.com/charmbracelet/bubbletea" +) + +// 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.HostName + } 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, with optional SSH key cleanup. +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 + } + + 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 !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 + }, + } + 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 + } + + _ = clipboard.WriteAll(pubKey) + + m.ActiveComponent = &components.Alert{ + 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 + } + + m.AlertText = fmt.Sprintf("Service host %q configured", s.HostAlias) +} diff --git a/internal/tui/update.go b/internal/tui/update.go index 421adb6..89595d7 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -3,7 +3,6 @@ package tui import ( "fmt" - "tusshi/internal/config" "tusshi/internal/tui/components" tea "github.com/charmbracelet/bubbletea" @@ -126,40 +125,3 @@ func (m *Model) navigateTabs(direction int) { m.SelectedIndex = 0 m.FilterHosts() } - -// executeFormSubmit saves the completed CRUD form details back to disk AST. -func (m *Model) executeFormSubmit() { - var err error - - m.FormHost.SourceFile = m.FormDestFile - m.FormHost.Tags = config.ExtractTagsFromComment("# tags: " + m.FormTagsString) - if m.FormProxyJump != "" { - m.FormHost.Properties["ProxyJump"] = m.FormProxyJump - } else { - delete(m.FormHost.Properties, "ProxyJump") - } - if m.FormForwardAgent != "" { - m.FormHost.Properties["ForwardAgent"] = m.FormForwardAgent - } else { - delete(m.FormHost.Properties, "ForwardAgent") - } - - switch m.FormAction { - case actionAdd: - err = m.Manager.AddHost(m.FormHost.SourceFile, m.FormHost) - if err == nil { - m.AlertText = fmt.Sprintf("Host %q added successfully!", m.FormHost.Alias) - } - case actionEdit: - err = m.Manager.UpdateHost(m.FormOriginalAlias, m.FormHost) - if err == nil { - m.AlertText = fmt.Sprintf("Host %q updated successfully!", m.FormHost.Alias) - } - } - - if err != nil { - m.ErrorText = "Error saving host: " + err.Error() - } - - m.Reload() -} diff --git a/internal/tui/view_footer.go b/internal/tui/view_footer.go index 977287a..af29015 100644 --- a/internal/tui/view_footer.go +++ b/internal/tui/view_footer.go @@ -20,7 +20,7 @@ func (m *Model) renderFooter(width int) string { case ModeCommand: cmdBar = m.CommandInput.View() case ModeSearch: - cmdBar = style.NormalPrompt.Render("[Search Mode] ") + getSearchShortcuts(width) + cmdBar = style.NormalPrompt.Render("[Search] ") + getSearchShortcuts(width) default: cmdBar = style.Footer.Render(getShortcuts(width)) } @@ -57,7 +57,7 @@ func getShortcuts(width int) string { } func getSearchShortcuts(width int) string { - full := "[Search] Type to filter. Esc/Enter: Done" + full := "Type to filter. Esc/Enter: Done" short := "Type to filter • Esc: Exit" if width >= len(full) { return full