From db25f9011bb7be763b5198ba3798992bd41ce43d Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sat, 5 Sep 2026 21:00:15 +0800 Subject: [PATCH] feat: add model-aware management workflows --- cmd/identity_files.go | 39 +++ cmd/tui.go | 7 + cmd/vault.go | 11 +- internal/db/servers_test.go | 71 +++++ internal/model/server.go | 16 -- internal/tui/app.go | 356 +++++++++++++++++++++--- internal/tui/app_test.go | 132 +++++++-- internal/tui/form.go | 453 +++++++++++++++++++++++++------ internal/tui/help_screen.go | 53 ++-- internal/tui/layout_test.go | 22 +- internal/tui/status_help_test.go | 12 +- internal/tui/tunnel.go | 14 +- internal/vault/vault.go | 58 ++-- internal/vault/vault_test.go | 21 ++ 14 files changed, 1067 insertions(+), 198 deletions(-) create mode 100644 cmd/identity_files.go diff --git a/cmd/identity_files.go b/cmd/identity_files.go new file mode 100644 index 0000000..4031180 --- /dev/null +++ b/cmd/identity_files.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +func listIdentityFiles() ([]string, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + sshDir := filepath.Join(home, ".ssh") + entries, err := os.ReadDir(sshDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var paths []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + lower := strings.ToLower(name) + if strings.HasSuffix(lower, ".pub") || lower == "config" || strings.HasPrefix(lower, "known_hosts") || lower == "authorized_keys" { + continue + } + if strings.HasPrefix(name, "id_") || strings.HasSuffix(lower, ".pem") || strings.HasSuffix(lower, ".key") { + paths = append(paths, filepath.Join(sshDir, name)) + } + } + sort.Strings(paths) + return paths, nil +} diff --git a/cmd/tui.go b/cmd/tui.go index 87b26d0..76bbf58 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -93,9 +93,16 @@ func runTUI() error { return nil } + tui.ListIdentityFiles = listIdentityFiles tui.GetGroups = func() ([]string, error) { return appDB.GetGroups() } + tui.ListGroups = func() ([]*model.Group, error) { + return appDB.ListGroups() + } + tui.CreateGroup = func(name string) error { + return appDB.CreateGroup(name) + } tui.ResolveRouteAlias = func(alias string) (int64, bool) { return appDB.ResolveAlias(alias) } diff --git a/cmd/vault.go b/cmd/vault.go index 499e774..6e2b2fc 100644 --- a/cmd/vault.go +++ b/cmd/vault.go @@ -250,7 +250,16 @@ func formatVaultSecretsList(v *vault.Vault) (string, error) { var b strings.Builder fmt.Fprintf(&b, "%-24s %-18s\n", "ALIAS", "TYPE") for _, meta := range metas { - fmt.Fprintf(&b, "%-24s %-18s\n", meta.Alias, meta.Type) + alias := meta.Alias + if alias == "" && meta.ServerID > 0 { + alias = fmt.Sprintf("#%d", meta.ServerID) + if appDB != nil { + if server, err := appDB.GetServerByID(meta.ServerID); err == nil && server != nil { + alias = server.Alias + } + } + } + fmt.Fprintf(&b, "%-24s %-18s\n", alias, meta.Type) } return b.String(), nil } diff --git a/internal/db/servers_test.go b/internal/db/servers_test.go index cf405cb..3b30450 100644 --- a/internal/db/servers_test.go +++ b/internal/db/servers_test.go @@ -1,6 +1,7 @@ package db import ( + "strings" "testing" "github.com/mirivlad/sshkeeper/internal/model" @@ -286,3 +287,73 @@ func TestSearchServersMatchesTagsRoutesAndForwardPorts(t *testing.T) { }) } } + +func TestRouteProfileReferenceSurvivesAliasRename(t *testing.T) { + db, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + bastion := &model.Server{Alias: "bastion", Host: "gw.example", Port: 22, User: "root", AuthMethod: model.AuthKey} + if err := db.CreateServer(bastion); err != nil { + t.Fatalf("create bastion: %v", err) + } + target := &model.Server{Alias: "prod", Host: "10.0.0.10", Port: 22, User: "ops", AuthMethod: model.AuthKey, Route: model.Route{Hops: []model.RouteHop{{ServerID: bastion.ID, Alias: bastion.Alias, IsProfile: true}}}} + if err := db.CreateServer(target); err != nil { + t.Fatalf("create target: %v", err) + } + bastion.Alias = "edge-gw" + if err := db.UpdateServerByAlias("bastion", bastion); err != nil { + t.Fatalf("rename bastion: %v", err) + } + got, err := db.GetServer("prod") + if err != nil { + t.Fatalf("load target: %v", err) + } + if len(got.Route.Hops) != 1 || got.Route.Hops[0].ServerID != bastion.ID || got.Route.Hops[0].Alias != "edge-gw" { + t.Fatalf("route did not follow renamed profile: %#v", got.Route.Hops) + } +} + +func TestDeleteServerRejectsReferencedRouteProfile(t *testing.T) { + db, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + bastion := &model.Server{Alias: "bastion", Host: "gw.example", Port: 22, AuthMethod: model.AuthKey} + if err := db.CreateServer(bastion); err != nil { + t.Fatalf("create bastion: %v", err) + } + target := &model.Server{Alias: "prod", Host: "10.0.0.10", Port: 22, AuthMethod: model.AuthKey, Route: model.Route{Hops: []model.RouteHop{{ServerID: bastion.ID, Alias: bastion.Alias, IsProfile: true}}}} + if err := db.CreateServer(target); err != nil { + t.Fatalf("create target: %v", err) + } + if err := db.DeleteServer("bastion"); err == nil || !strings.Contains(err.Error(), "prod") { + t.Fatalf("expected dependent-route delete error, got %v", err) + } +} + +func TestRouteCycleIsRejected(t *testing.T) { + db, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + a := &model.Server{Alias: "a", Host: "a.example", Port: 22, AuthMethod: model.AuthKey} + b := &model.Server{Alias: "b", Host: "b.example", Port: 22, AuthMethod: model.AuthKey} + if err := db.CreateServer(a); err != nil { + t.Fatalf("create a: %v", err) + } + if err := db.CreateServer(b); err != nil { + t.Fatalf("create b: %v", err) + } + a.Route = model.Route{Hops: []model.RouteHop{{ServerID: b.ID, Alias: b.Alias, IsProfile: true}}} + if err := db.UpdateServer(a); err != nil { + t.Fatalf("set a route: %v", err) + } + b.Route = model.Route{Hops: []model.RouteHop{{ServerID: a.ID, Alias: a.Alias, IsProfile: true}}} + if err := db.UpdateServer(b); err == nil || !strings.Contains(strings.ToLower(err.Error()), "cycle") { + t.Fatalf("expected cycle error, got %v", err) + } +} diff --git a/internal/model/server.go b/internal/model/server.go index f1f411a..54eec14 100644 --- a/internal/model/server.go +++ b/internal/model/server.go @@ -48,22 +48,6 @@ type Server struct { LastTestError string `json:"last_test_error"` } -type SecretType string - -const ( - SecretSSHPassword SecretType = "ssh_password" - SecretKeyPassphrase SecretType = "key_passphrase" - SecretSudoPassword SecretType = "sudo_password" - SecretCustom SecretType = "custom_secret" -) - -type Secret struct { - ID string `json:"id"` - Type SecretType `json:"type"` - Nonce []byte `json:"nonce"` - Data []byte `json:"data"` -} - type ForwardType string const ( diff --git a/internal/tui/app.go b/internal/tui/app.go index 669ab1e..4a19a20 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -74,6 +74,13 @@ type tagsLoadedMsg struct { err error } +type groupsLoadedMsg struct { + groups []*model.Group + deleted bool + deletedName string + err error +} + type backgroundRunDoneMsg struct { results []templateRunResult } @@ -146,6 +153,16 @@ func (i serverItem) FilterValue() string { return strings.Join(parts, " ") } +type groupManagerItem struct { + group *model.Group +} + +func (i groupManagerItem) Title() string { return i.group.Name } +func (i groupManagerItem) Description() string { + return fmt.Sprintf("%d servers", i.group.ServerCount) +} +func (i groupManagerItem) FilterValue() string { return i.group.Name } + type templateItem struct { template *model.CommandTemplate } @@ -179,7 +196,10 @@ var ( SaveServer func(server *model.Server, password string, oldAlias string) error UpdateTestResult func(alias string, status model.TestStatus, testErr string) error HasSecret func(alias string, secretType string) bool + ListIdentityFiles func() ([]string, error) GetGroups func() ([]string, error) + ListGroups func() ([]*model.Group, error) + CreateGroup func(name string) error ResolveRouteAlias func(alias string) (int64, bool) RenameGroup func(oldName, newName string) error DeleteGroup func(name string) error @@ -210,6 +230,8 @@ const ( screenSearch screenTags screenTagInput + screenGroups + screenGroupInput screenTemplates screenTemplateForm screenTemplatePicker @@ -217,6 +239,7 @@ const ( screenBackgroundResults screenHelp screenActionMenu + screenManageMenu screenForwardList screenForwardForm screenTunnelManager @@ -271,6 +294,11 @@ type tuiModel struct { tagInput textinput.Model tagMode string tagOldName string + groups []*model.Group + groupList list.Model + groupInput textinput.Model + groupMode string + groupOldName string selected map[string]bool tunnelScreen *tunnelScreenModel bgResults []templateRunResult @@ -281,6 +309,7 @@ type tuiModel struct { result *TUIResult helpScreen *helpScreenModel actionMenu *actionMenuModel + manageMenu *actionMenuModel forwardScreen *forwardScreenModel forwardForm *forwardFormModel confirm *confirmState @@ -308,11 +337,19 @@ func New(servers []*model.Server) *tuiModel { tagInput := textinput.New() tagInput.Placeholder = "tag" tagInput.CharLimit = 64 + + groupInput := textinput.New() + groupInput.Placeholder = "group" + groupInput.CharLimit = 64 templateList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) templateList.SetShowStatusBar(false) templateList.SetFilteringEnabled(false) templateList.SetShowHelp(false) tagList := newStringList(nil, "Tags", 0, 0) + groupList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) + groupList.SetShowStatusBar(false) + groupList.SetFilteringEnabled(false) + groupList.SetShowHelp(false) vaultIsUnlocked := true if VaultUnlocked != nil { @@ -326,8 +363,10 @@ func New(servers []*model.Server) *tuiModel { searchInput: search, selected: map[string]bool{}, tagInput: tagInput, + groupInput: groupInput, templateList: templateList, tagList: tagList, + groupList: groupList, vaultUnlocked: vaultIsUnlocked, } } @@ -385,6 +424,7 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.templateList.SetSize(msg.Width, managerListHeight(msg.Height)) m.tagList.SetSize(msg.Width, managerListHeight(msg.Height)) + m.groupList.SetSize(msg.Width, managerListHeight(msg.Height)) return m, nil case serversLoadedMsg: @@ -434,6 +474,23 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.setTags(msg.tags) return m, nil + case groupsLoadedMsg: + if m.confirm != nil && m.confirm.pending && m.confirm.parent == screenGroups { + m.finishConfirm() + } + if msg.err != nil { + if msg.deleted { + m.removeGroup(msg.deletedName) + m.err = nil + m.success = fmt.Sprintf("Deleted %q; refresh failed: %v", msg.deletedName, msg.err) + } else { + m.err = msg.err + } + return m, nil + } + m.setGroups(msg.groups) + return m, nil + case connectRequestMsg: m.result = &TUIResult{ Server: msg.server, @@ -707,6 +764,10 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateTags(msg) case screenTagInput: return m.updateTagInput(msg) + case screenGroups: + return m.updateGroups(msg) + case screenGroupInput: + return m.updateGroupInput(msg) case screenTemplates: return m.updateTemplates(msg) case screenTemplateForm: @@ -721,6 +782,8 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateHelp(msg) case screenActionMenu: return m.updateActionMenu(msg) + case screenManageMenu: + return m.updateManageMenu(msg) case screenForwardList: return m.updateForwardList(msg) case screenForwardForm: @@ -813,6 +876,11 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.openTemplatePicker() case tea.KeyRunes: + if msg.String() == "m" || msg.String() == "M" { + m.manageMenu = newManageMenuModel(m.width, m.height) + m.screen = screenManageMenu + return m, nil + } if msg.String() == "?" { m.helpParent = m.screen m.helpScreen = newHelpScreenModel(m.width, m.height) @@ -1014,6 +1082,101 @@ func (m *tuiModel) updateTagInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } +func (m *tuiModel) updateGroups(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + m.screen = screenList + return m, m.reloadServersCmd() + case tea.KeyCtrlA: + m.groupMode = "add" + m.groupOldName = "" + m.groupInput.SetValue("") + m.groupInput.Focus() + m.screen = screenGroupInput + return m, nil + case tea.KeyCtrlE: + if item, ok := m.groupList.SelectedItem().(groupManagerItem); ok && item.group != nil { + m.groupMode = "rename" + m.groupOldName = item.group.Name + m.groupInput.SetValue(item.group.Name) + m.groupInput.Focus() + m.screen = screenGroupInput + } + return m, nil + case tea.KeyCtrlD: + if item, ok := m.groupList.SelectedItem().(groupManagerItem); ok && item.group != nil { + name := item.group.Name + count := item.group.ServerCount + m.beginConfirm(confirmState{ + title: "Delete group?", + target: fmt.Sprintf("%q", name), + consequence: fmt.Sprintf("The group is removed; %d server profile(s) become ungrouped.", count), + verb: "Delete", + parent: screenGroups, + action: func() tea.Cmd { + return func() tea.Msg { + if DeleteGroup == nil { + return groupsLoadedMsg{err: fmt.Errorf("group deletion is unavailable")} + } + if err := DeleteGroup(name); err != nil { + return groupsLoadedMsg{err: err} + } + if ListGroups == nil { + return groupsLoadedMsg{deleted: true, deletedName: name, err: fmt.Errorf("group reload is unavailable")} + } + groups, err := ListGroups() + return groupsLoadedMsg{groups: groups, deleted: true, deletedName: name, err: err} + } + }, + }) + } + return m, nil + } + var cmd tea.Cmd + m.groupList, cmd = m.groupList.Update(msg) + return m, cmd +} + +func (m *tuiModel) updateGroupInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + m.screen = screenGroups + m.groupInput.Blur() + return m, nil + case tea.KeyEnter: + value := strings.TrimSpace(m.groupInput.Value()) + if value == "" { + m.screen = screenGroups + return m, nil + } + mode := m.groupMode + oldName := m.groupOldName + return m, func() tea.Msg { + switch mode { + case "rename": + if RenameGroup == nil { + return groupsLoadedMsg{err: fmt.Errorf("group rename is unavailable")} + } + if err := RenameGroup(oldName, value); err != nil { + return groupsLoadedMsg{err: err} + } + default: + if CreateGroup == nil { + return groupsLoadedMsg{err: fmt.Errorf("group creation is unavailable")} + } + if err := CreateGroup(value); err != nil { + return groupsLoadedMsg{err: err} + } + } + groups, err := ListGroups() + return groupsLoadedMsg{groups: groups, err: err} + } + } + var cmd tea.Cmd + m.groupInput, cmd = m.groupInput.Update(msg) + return m, cmd +} + func (m *tuiModel) updateTemplates(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEsc: @@ -1226,6 +1389,10 @@ func (m *tuiModel) View() string { case screenTagInput: b.WriteString(m.viewTagInput()) + case screenGroups: + b.WriteString(m.viewGroups()) + case screenGroupInput: + b.WriteString(m.viewGroupInput()) case screenTemplates: b.WriteString(m.viewTemplates()) @@ -1256,6 +1423,10 @@ func (m *tuiModel) View() string { if m.actionMenu != nil { b.WriteString(m.actionMenu.View()) } + case screenManageMenu: + if m.manageMenu != nil { + b.WriteString(m.manageMenu.View()) + } case screenForwardList: if m.forwardScreen != nil { @@ -1350,11 +1521,6 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.actionMenu = nil return m, m.forwardScreen.loadForwards() } - case "tunnels": - m.tunnelScreen = newTunnelScreenModel(m.width, m.height) - m.screen = screenTunnelManager - m.actionMenu = nil - return m, m.tunnelScreen.loadTunnels() case "route": if item, ok := m.list.SelectedItem().(serverItem); ok { m.form = newEditFormModel(item.server, m.width, m.height) @@ -1387,39 +1553,6 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.confirmServerDelete(item.server) return m, nil } - case "import": - m.screen = screenList - m.actionMenu = nil - return m, func() tea.Msg { - if ImportServers == nil { - return importDoneMsg{err: fmt.Errorf("import is unavailable")} - } - count, err := ImportServers() - if err != nil { - return importDoneMsg{err: err} - } - servers, err := ListServers() - return importDoneMsg{servers: servers, count: count, err: err} - } - case "export": - m.actionMenu = nil - m.result = &TUIResult{Action: "export"} - return m, tea.Quit - case "vault_lock": - m.screen = screenList - m.actionMenu = nil - if LockVault == nil { - m.err = fmt.Errorf("vault lock is unavailable") - } else if err := LockVault(); err != nil { - m.err = err - } else { - m.vaultUnlocked = false - m.success = "Vault locked." - } - case "vault_change_pw": - m.actionMenu = nil - m.result = &TUIResult{Action: "vault_change_pw"} - return m, tea.Quit } return m, nil } @@ -1427,6 +1560,65 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m *tuiModel) updateManageMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + updated, action := m.manageMenu.Update(msg) + m.manageMenu = updated + if msg.Type == tea.KeyEsc { + m.screen = screenList + m.manageMenu = nil + return m, nil + } + if action == nil { + return m, nil + } + m.manageMenu = nil + switch *action { + case "groups": + m.screen = screenGroups + return m, m.loadGroupsCmd() + case "tags": + m.screen = screenTags + return m, m.loadTagsCmd() + case "templates": + m.screen = screenTemplates + return m, m.loadTemplatesCmd() + case "tunnels": + m.tunnelScreen = newTunnelScreenModel(m.width, m.height) + m.screen = screenTunnelManager + return m, m.tunnelScreen.loadTunnels() + case "import": + m.screen = screenList + return m, func() tea.Msg { + if ImportServers == nil { + return importDoneMsg{err: fmt.Errorf("import is unavailable")} + } + count, err := ImportServers() + if err != nil { + return importDoneMsg{err: err} + } + servers, err := ListServers() + return importDoneMsg{servers: servers, count: count, err: err} + } + case "export": + m.result = &TUIResult{Action: "export"} + return m, tea.Quit + case "vault_lock": + m.screen = screenList + if LockVault == nil { + m.err = fmt.Errorf("vault lock is unavailable") + } else if err := LockVault(); err != nil { + m.err = err + } else { + m.vaultUnlocked = false + m.success = "Vault locked." + } + case "vault_change_pw": + m.result = &TUIResult{Action: "vault_change_pw"} + return m, tea.Quit + } + return m, nil +} + func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEsc: @@ -1758,7 +1950,7 @@ func (m *tuiModel) updateFullHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *tuiModel) screenOwnsPrintableInput() bool { switch m.screen { - case screenForm, screenSearch, screenTagInput, screenTemplateForm, screenForwardForm: + case screenForm, screenSearch, screenTagInput, screenGroupInput, screenTemplateForm, screenForwardForm: return true default: return false @@ -1991,6 +2183,54 @@ func (m *tuiModel) viewTagInput() string { }) } +func (m *tuiModel) viewGroups() string { + return renderScreenShell(screenShell{ + breadcrumb: "Groups", + status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d groups", len(m.groups))), + notification: m.rootNotification(), + width: m.width, + height: m.height, + body: func(width, height int) string { + if len(m.groups) == 0 { + return renderPaddedPanel(width, height, []string{dashboardHelp("No groups yet. Ctrl+A creates one.")}) + } + capacity := max(1, height-2) + start, end := visibleServerRange(len(m.groupList.Items()), m.groupList.Index(), capacity) + lines := make([]string, 0, capacity) + for index := start; index < end; index++ { + item, ok := m.groupList.Items()[index].(groupManagerItem) + if !ok || item.group == nil { + continue + } + marker := " " + if index == m.groupList.Index() { + marker = "> " + } + lines = append(lines, fmt.Sprintf("%s%-28s %d server(s)", marker, item.group.Name, item.group.ServerCount)) + } + return renderPaddedPanel(width, height, lines) + }, + footer: []helpItem{{Key: "Ctrl+A", Action: "add"}, {Key: "Ctrl+E", Action: "rename"}, {Key: "Ctrl+D", Action: "delete"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}}, + }) +} + +func (m *tuiModel) viewGroupInput() string { + title := "Add Group" + if m.groupMode == "rename" { + title = "Rename Group" + } + return renderScreenShell(screenShell{ + breadcrumb: title, + status: shellStatus(m.vaultUnlocked, "Group editor"), + width: m.width, + height: m.height, + body: func(width, height int) string { + return renderPaddedPanel(width, height, []string{dashboardSection(title), "", m.groupInput.View()}) + }, + footer: []helpItem{{Key: "Enter", Action: "save"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}}, + }) +} + func (m *tuiModel) viewTemplates() string { return renderScreenShell(screenShell{ breadcrumb: "Command Templates", @@ -2144,6 +2384,41 @@ func (m *tuiModel) reloadServersCmd() tea.Cmd { } } +func (m *tuiModel) loadGroupsCmd() tea.Cmd { + return func() tea.Msg { + if ListGroups == nil { + return groupsLoadedMsg{err: fmt.Errorf("group storage is unavailable")} + } + groups, err := ListGroups() + return groupsLoadedMsg{groups: groups, err: err} + } +} + +func (m *tuiModel) setGroups(groups []*model.Group) { + m.groups = groups + items := make([]list.Item, len(groups)) + for i, group := range groups { + items[i] = groupManagerItem{group: group} + } + l := list.New(items, list.NewDefaultDelegate(), m.width, managerListHeight(m.height)) + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.SetShowHelp(false) + l.Title = "Groups" + l.Styles.Title = titleStyle + m.groupList = l +} + +func (m *tuiModel) removeGroup(name string) { + groups := make([]*model.Group, 0, len(m.groups)) + for _, group := range m.groups { + if group != nil && group.Name != name { + groups = append(groups, group) + } + } + m.setGroups(groups) +} + func (m *tuiModel) loadTemplatesCmd() tea.Cmd { return func() tea.Msg { if ListCommandTemplates == nil { @@ -2230,7 +2505,8 @@ func (m *tuiModel) listHelpItems(selectedCount int, hasBackgroundResult bool) [] } items = append(items, helpItem{Key: "Enter", Action: "connect"}, - helpItem{Key: "Ctrl+X", Action: "actions"}, + helpItem{Key: "Ctrl+X", Action: "server actions"}, + helpItem{Key: "m", Action: "manage"}, helpItem{Key: "Ctrl+A", Action: "add"}, helpItem{Key: "Ctrl+E", Action: "edit"}, helpItem{Key: "Ctrl+F", Action: "search"}, diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 451a76d..cc84653 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -148,7 +148,7 @@ func TestServerListHelpWrapsSelectionAndResultHints(t *testing.T) { plainLines = append(plainLines, plainHelpLine(line)) } joined := strings.Join(plainLines, "\n") - for _, want := range []string{"Ins: select (2 selected)", "Esc: clear result", "Ctrl+X: actions", "Ctrl+Q: quit"} { + for _, want := range []string{"Ins: select (2 selected)", "Esc: clear result", "Ctrl+X: server actions", "m: manage", "Ctrl+Q: quit"} { if !strings.Contains(joined, want) { t.Fatalf("expected wrapped help to contain %q\nlines:%#v", want, lines) } @@ -444,7 +444,7 @@ func TestFormViewUsesSectionsAndStableLabels(t *testing.T) { "Alias", "Display Name", "Auth Method", - "Password / Passphrase", + "Identity File", } { if !strings.Contains(view, want) { t.Fatalf("expected form view to contain %q\nview:\n%s", want, view) @@ -452,6 +452,28 @@ func TestFormViewUsesSectionsAndStableLabels(t *testing.T) { } } +func TestFormAuthFieldsAreContextual(t *testing.T) { + fm := newFormModel(100, 30) + + fm.inputs[5].SetValue(string(model.AuthPassword)) + view := fm.View() + if !strings.Contains(view, "Password") || strings.Contains(view, "Identity File") { + t.Fatalf("password auth fields are not contextual:\n%s", view) + } + + fm.inputs[5].SetValue(string(model.AuthKeyPassphrase)) + view = fm.View() + if !strings.Contains(view, "Key passphrase") || !strings.Contains(view, "Identity File") { + t.Fatalf("key passphrase auth fields are not contextual:\n%s", view) + } + + fm.inputs[5].SetValue(string(model.AuthAgent)) + view = fm.View() + if strings.Contains(view, "Identity File") || strings.Contains(view, "Password") || strings.Contains(view, "passphrase") { + t.Fatalf("agent auth still shows credential fields:\n%s", view) + } +} + func TestFormTestResultDoesNotUpdateSelectedListServer(t *testing.T) { oldUpdateTestResult := UpdateTestResult oldListServers := ListServers @@ -791,7 +813,12 @@ func TestActionMenuClosesOnAllActions(t *testing.T) { // Test delete closes menu m.actionMenu = newActionMenuModel(m.width, m.height) m.screen = screenActionMenu - m.actionMenu.list.Select(3) // Delete + for i := 0; i < len(m.actionMenu.list.Items()); i++ { + m.actionMenu.list.Select(i) + if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "delete" { + break + } + } DeleteServer = func(alias string) error { return nil } ListServers = func() ([]*model.Server, error) { return []*model.Server{server}, nil } updated, _ := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter}) @@ -848,7 +875,7 @@ func TestActionMenuManageRouteOpensRouteField(t *testing.T) { } } -func TestActionMenuImportUsesCallbackAndRefreshesList(t *testing.T) { +func TestManageMenuImportUsesCallbackAndRefreshesList(t *testing.T) { server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey} imported := false ImportServers = func() (int, error) { @@ -866,16 +893,16 @@ func TestActionMenuImportUsesCallbackAndRefreshesList(t *testing.T) { m := New([]*model.Server{}) m.width = 100 m.height = 30 - m.actionMenu = newActionMenuModel(m.width, m.height) - m.screen = screenActionMenu - for i := 0; i < len(m.actionMenu.list.Items()); i++ { - m.actionMenu.list.Select(i) - if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "import" { + m.manageMenu = newManageMenuModel(m.width, m.height) + m.screen = screenManageMenu + for i := 0; i < len(m.manageMenu.list.Items()); i++ { + m.manageMenu.list.Select(i) + if item, ok := m.manageMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "import" { break } } - updated, cmd := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter}) m = updated.(*tuiModel) if cmd == nil { t.Fatal("expected import command") @@ -895,23 +922,23 @@ func TestActionMenuImportUsesCallbackAndRefreshesList(t *testing.T) { } } -func TestActionMenuExportAndVaultChangePasswordExitTUI(t *testing.T) { +func TestManageMenuExportAndVaultChangePasswordExitTUI(t *testing.T) { server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey} for _, action := range []string{"export", "vault_change_pw"} { t.Run(action, func(t *testing.T) { m := New([]*model.Server{server}) m.width = 100 m.height = 30 - m.actionMenu = newActionMenuModel(m.width, m.height) - m.screen = screenActionMenu - for i := 0; i < len(m.actionMenu.list.Items()); i++ { - m.actionMenu.list.Select(i) - if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == action { + m.manageMenu = newManageMenuModel(m.width, m.height) + m.screen = screenManageMenu + for i := 0; i < len(m.manageMenu.list.Items()); i++ { + m.manageMenu.list.Select(i) + if item, ok := m.manageMenu.list.SelectedItem().(actionMenuItem); ok && item.action == action { break } } - updated, cmd := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter}) m = updated.(*tuiModel) if cmd == nil { t.Fatalf("expected %s to quit TUI", action) @@ -923,7 +950,7 @@ func TestActionMenuExportAndVaultChangePasswordExitTUI(t *testing.T) { } } -func TestActionMenuVaultLockUsesCallback(t *testing.T) { +func TestManageMenuVaultLockUsesCallback(t *testing.T) { server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey} locked := false LockVault = func() error { @@ -935,16 +962,16 @@ func TestActionMenuVaultLockUsesCallback(t *testing.T) { m := New([]*model.Server{server}) m.width = 100 m.height = 30 - m.actionMenu = newActionMenuModel(m.width, m.height) - m.screen = screenActionMenu - for i := 0; i < len(m.actionMenu.list.Items()); i++ { - m.actionMenu.list.Select(i) - if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "vault_lock" { + m.manageMenu = newManageMenuModel(m.width, m.height) + m.screen = screenManageMenu + for i := 0; i < len(m.manageMenu.list.Items()); i++ { + m.manageMenu.list.Select(i) + if item, ok := m.manageMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "vault_lock" { break } } - updated, _ := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter}) m = updated.(*tuiModel) if !locked { @@ -954,3 +981,60 @@ func TestActionMenuVaultLockUsesCallback(t *testing.T) { t.Fatalf("expected vault lock success, got %q", m.success) } } + +func TestServerActionMenuContainsOnlyServerScopedActions(t *testing.T) { + menu := newActionMenuModel(100, 30) + for _, raw := range menu.list.Items() { + item := raw.(actionMenuItem) + switch item.action { + case "import", "export", "vault_lock", "vault_change_pw", "groups", "tags", "templates", "tunnels": + t.Fatalf("global action %q leaked into server actions", item.action) + } + } +} + +func TestManageMenuOpensGroups(t *testing.T) { + oldList := ListGroups + t.Cleanup(func() { ListGroups = oldList }) + ListGroups = func() ([]*model.Group, error) { return []*model.Group{{ID: 1, Name: "Prod", ServerCount: 3}}, nil } + m := New(nil) + m.width, m.height = 100, 30 + m.manageMenu = newManageMenuModel(m.width, m.height) + m.screen = screenManageMenu + for i := range m.manageMenu.list.Items() { + m.manageMenu.list.Select(i) + if item := m.manageMenu.list.SelectedItem().(actionMenuItem); item.action == "groups" { + break + } + } + updated, cmd := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(*tuiModel) + if m.screen != screenGroups || cmd == nil { + t.Fatalf("manage groups did not open: screen=%v cmd=%v", m.screen, cmd) + } + updated, _ = m.Update(cmd()) + m = updated.(*tuiModel) + if len(m.groups) != 1 || m.groups[0].ServerCount != 3 { + t.Fatalf("groups were not loaded: %#v", m.groups) + } +} + +func TestStartupTemplatePickerCopiesCommand(t *testing.T) { + oldList := ListCommandTemplates + t.Cleanup(func() { ListCommandTemplates = oldList }) + ListCommandTemplates = func() ([]*model.CommandTemplate, error) { + return []*model.CommandTemplate{{ID: 1, Name: "Ops", Command: "tmux attach -t ops"}}, nil + } + fm := newFormModel(100, 30) + fm.focusIdx = 10 + updated, _ := fm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + fm = updated.(*formModel) + if !fm.showStartupList { + t.Fatal("startup template picker did not open") + } + updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyEnter}) + fm = updated.(*formModel) + if got := fm.inputs[10].Value(); got != "tmux attach -t ops" { + t.Fatalf("startup command = %q", got) + } +} diff --git a/internal/tui/form.go b/internal/tui/form.go index 14bcd28..cee3fe9 100644 --- a/internal/tui/form.go +++ b/internal/tui/form.go @@ -78,35 +78,46 @@ func (fm *formModel) setRouteProfiles(servers []*model.Server) { // --- Form model --- type formModel struct { - edit bool - server *model.Server - inputs []textinput.Model - labels []string - password textinput.Model - passwordLabel string - focusIdx int - testResult string - testOK bool - testResultTime time.Time - testing bool - saving bool - saved bool - savedTime time.Time - err error - spinner spinner.Model - width int - height int - groups []string - groupList list.Model - showGroupList bool - authList list.Model - showAuthList bool - routeProfiles []*model.Server - routeList list.Model - showRouteList bool - routePane int // 0=current route, 1=available profiles - routeCursor int - initial formSnapshot + edit bool + server *model.Server + inputs []textinput.Model + labels []string + password textinput.Model + passwordLabel string + focusIdx int + testResult string + testOK bool + testResultTime time.Time + testing bool + saving bool + saved bool + savedTime time.Time + err error + spinner spinner.Model + width int + height int + groups []string + groupList list.Model + showGroupList bool + authList list.Model + showAuthList bool + identityFiles []string + identityList list.Model + showIdentityList bool + tagValues []string + tagList list.Model + showTagList bool + startupTemplates []*model.CommandTemplate + startupList list.Model + showStartupList bool + hasSavedPassword bool + hasSavedPassphrase bool + routeProfiles []*model.Server + routeList list.Model + showRouteList bool + routePane int // 0=current route, 1=available profiles + routeCursor int + initial formSnapshot } type formSnapshot struct { @@ -136,6 +147,7 @@ func newFormModel(w, h int) *formModel { inputs[i].CharLimit = 128 } inputs[3].SetValue("22") + inputs[5].SetValue(string(model.AuthKey)) pw := textinput.New() pw.Placeholder = "optional" @@ -231,18 +243,8 @@ func newEditFormModel(s *model.Server, w, h int) *formModel { fm.inputs[10].SetValue(s.StartupCommand) fm.inputs[11].SetValue(strings.Join(s.Tags, ", ")) if HasSecret != nil { - switch s.AuthMethod { - case model.AuthPassword: - if HasSecret(s.Alias, "ssh_password") { - fm.passwordLabel = "Password (secret saved; leave blank to keep)" - fm.password.Placeholder = "" - } - case model.AuthKeyPassphrase: - if HasSecret(s.Alias, "key_passphrase") { - fm.passwordLabel = "Key passphrase (secret saved; leave blank to keep)" - fm.password.Placeholder = "" - } - } + fm.hasSavedPassword = HasSecret(s.Alias, "ssh_password") + fm.hasSavedPassphrase = HasSecret(s.Alias, "key_passphrase") } fm.updateFocus() fm.initial = fm.snapshot() @@ -270,6 +272,197 @@ func (fm *formModel) Dirty() bool { return false } +func (fm *formModel) authMethodValue() model.AuthMethod { + method := model.AuthMethod(strings.TrimSpace(fm.inputs[5].Value())) + if method == "" { + return model.AuthKey + } + return method +} + +func (fm *formModel) usesIdentityFile() bool { + switch fm.authMethodValue() { + case model.AuthKey, model.AuthKeyPassphrase: + return true + default: + return false + } +} + +func (fm *formModel) usesSecretInput() bool { + switch fm.authMethodValue() { + case model.AuthPassword, model.AuthKeyPassphrase: + return true + default: + return false + } +} + +func (fm *formModel) currentPasswordLabel() string { + switch fm.authMethodValue() { + case model.AuthPassword: + if fm.edit && fm.hasSavedPassword { + return "Password (secret saved; leave blank to keep)" + } + return "Password" + case model.AuthKeyPassphrase: + if fm.edit && fm.hasSavedPassphrase { + return "Key passphrase (secret saved; leave blank to keep)" + } + return "Key passphrase" + default: + return "" + } +} + +func (fm *formModel) focusAllowed(index int) bool { + if index == 6 && !fm.usesIdentityFile() { + return false + } + if index == len(fm.inputs) && !fm.usesSecretInput() { + return false + } + return index >= 0 && index < len(fm.inputs)+3 +} + +func (fm *formModel) advanceFocus(delta int) { + total := len(fm.inputs) + 3 + for tries := 0; tries < total; tries++ { + fm.focusIdx = (fm.focusIdx + delta + total) % total + if fm.focusAllowed(fm.focusIdx) { + break + } + } + fm.updateFocus() +} + +func (fm *formModel) loadIdentityPicker() { + fm.identityFiles = nil + if ListIdentityFiles != nil { + if files, err := ListIdentityFiles(); err == nil { + fm.identityFiles = files + } + } + fm.identityList = newStringList(fm.identityFiles, "Select SSH private key", 52, 14) + fm.identityList.SetFilteringEnabled(true) + fm.showIdentityList = true +} + +func (fm *formModel) loadTagPicker() { + fm.tagValues = nil + if ListTags != nil { + if tags, err := ListTags(); err == nil { + fm.tagValues = tags + } + } + fm.tagList = newStringList(fm.tagValues, "Select tags", 40, 14) + fm.tagList.SetFilteringEnabled(true) + fm.showTagList = true +} + +func (fm *formModel) loadStartupPicker() { + fm.startupTemplates = nil + if ListCommandTemplates != nil { + if templates, err := ListCommandTemplates(); err == nil { + fm.startupTemplates = templates + } + } + items := make([]list.Item, 0, len(fm.startupTemplates)) + for _, template := range fm.startupTemplates { + if template != nil { + items = append(items, templateItem{template: template}) + } + } + l := list.New(items, list.NewDefaultDelegate(), 64, 14) + l.Title = "Insert command template" + l.SetShowStatusBar(false) + l.SetShowHelp(false) + l.SetFilteringEnabled(true) + l.Styles.Title = titleStyle + fm.startupList = l + fm.showStartupList = true +} + +func (fm *formModel) updateStartupPicker(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.Type { + case tea.KeyEsc: + fm.showStartupList = false + return fm, nil + case tea.KeyEnter: + if item, ok := fm.startupList.SelectedItem().(templateItem); ok && item.template != nil { + fm.inputs[10].SetValue(item.template.Command) + } + fm.showStartupList = false + return fm, nil + } + } + var cmd tea.Cmd + fm.startupList, cmd = fm.startupList.Update(msg) + return fm, cmd +} + +func containsString(values []string, value string) bool { + for _, item := range values { + if item == value { + return true + } + } + return false +} + +func toggleTagValue(values []string, value string) []string { + if containsString(values, value) { + result := make([]string, 0, len(values)-1) + for _, item := range values { + if item != value { + result = append(result, item) + } + } + return result + } + return append(values, value) +} + +func (fm *formModel) updateIdentityPicker(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.Type { + case tea.KeyEsc: + fm.showIdentityList = false + return fm, nil + case tea.KeyEnter: + if item, ok := fm.identityList.SelectedItem().(groupItem); ok { + fm.inputs[6].SetValue(item.name) + } + fm.showIdentityList = false + return fm, nil + } + } + var cmd tea.Cmd + fm.identityList, cmd = fm.identityList.Update(msg) + return fm, cmd +} + +func (fm *formModel) updateTagPicker(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.Type { + case tea.KeyEsc: + fm.showTagList = false + return fm, nil + case tea.KeySpace, tea.KeyEnter: + if item, ok := fm.tagList.SelectedItem().(groupItem); ok { + tags := splitCSV(fm.inputs[11].Value()) + tags = toggleTagValue(tags, item.name) + fm.inputs[11].SetValue(strings.Join(tags, ", ")) + } + return fm, nil + } + } + var cmd tea.Cmd + fm.tagList, cmd = fm.tagList.Update(msg) + return fm, cmd +} + func (fm *formModel) resolveRouteAlias(alias string) (int64, bool) { alias = strings.TrimSpace(alias) for _, server := range fm.routeProfiles { @@ -440,6 +633,15 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return fm, cmd } + if fm.showIdentityList { + return fm.updateIdentityPicker(msg) + } + if fm.showTagList { + return fm.updateTagPicker(msg) + } + if fm.showStartupList { + return fm.updateStartupPicker(msg) + } if fm.showRouteList { return fm.updateRouteEditor(msg) } @@ -476,6 +678,7 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { fm.inputs[5].SetValue(item.name) } fm.showAuthList = false + fm.updateFocus() return fm, nil } } @@ -488,21 +691,11 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.Type { case tea.KeyTab: - fm.focusIdx++ - total := len(fm.inputs) + 3 - if fm.focusIdx >= total { - fm.focusIdx = 0 - } - fm.updateFocus() + fm.advanceFocus(1) return fm, nil case tea.KeyShiftTab: - fm.focusIdx-- - if fm.focusIdx < 0 { - total := len(fm.inputs) + 3 - fm.focusIdx = total - 1 - } - fm.updateFocus() + fm.advanceFocus(-1) return fm, nil case tea.KeyRunes: @@ -510,6 +703,10 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { fm.showAuthList = true return fm, nil } + if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 6 && fm.usesIdentityFile() { + fm.loadIdentityPicker() + return fm, nil + } if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 7 { fm.showRouteList = true fm.routePane = 1 @@ -523,6 +720,14 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { fm.showGroupList = true return fm, nil } + if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 10 { + fm.loadStartupPicker() + return fm, nil + } + if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 11 { + fm.loadTagPicker() + return fm, nil + } case tea.KeyEnter: switch { @@ -531,12 +736,7 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case fm.focusIdx == len(fm.inputs)+2: return fm, fm.runSave() default: - fm.focusIdx++ - total := len(fm.inputs) + 3 - if fm.focusIdx >= total { - fm.focusIdx = 0 - } - fm.updateFocus() + fm.advanceFocus(1) return fm, nil } @@ -544,21 +744,11 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return fm, nil case tea.KeyDown: - fm.focusIdx++ - total := len(fm.inputs) + 3 - if fm.focusIdx >= total { - fm.focusIdx = 0 - } - fm.updateFocus() + fm.advanceFocus(1) return fm, nil case tea.KeyUp: - fm.focusIdx-- - if fm.focusIdx < 0 { - total := len(fm.inputs) + 3 - fm.focusIdx = total - 1 - } - fm.updateFocus() + fm.advanceFocus(-1) return fm, nil } } @@ -599,7 +789,24 @@ func (fm *formModel) applySaveError(err error) { fm.updateFocus() } +func (fm *formModel) updateCredentialPresentation() { + fm.passwordLabel = fm.currentPasswordLabel() + if !fm.usesSecretInput() { + return + } + fm.password.Placeholder = "optional" + if (fm.authMethodValue() == model.AuthPassword && fm.hasSavedPassword) || (fm.authMethodValue() == model.AuthKeyPassphrase && fm.hasSavedPassphrase) { + fm.password.Placeholder = "" + } + prompt := blurredStyle.Render(fm.passwordLabel + ": ") + if fm.focusIdx == len(fm.inputs) { + prompt = focusedStyle.Render(fm.passwordLabel + "> ") + } + fm.password.Prompt = prompt +} + func (fm *formModel) updateFocus() { + fm.updateCredentialPresentation() for i := range fm.inputs { fm.inputs[i].Blur() fm.inputs[i].Prompt = blurredStyle.Render(fm.labelAt(i) + ": ") @@ -612,8 +819,8 @@ func (fm *formModel) updateFocus() { fm.inputs[fm.focusIdx].Prompt = focusedStyle.Render(fm.labelAt(fm.focusIdx) + "> ") } else if fm.focusIdx == len(fm.inputs) { fm.password.Focus() - fm.password.Prompt = focusedStyle.Render(fm.passwordLabel + "> ") } + fm.updateCredentialPresentation() } func (fm *formModel) labelAt(index int) string { @@ -629,15 +836,24 @@ func (fm *formModel) labelAt(index int) string { if index == 5 { return "Auth Method (/ pick)" } + if index == 6 { + return "Identity File (/ pick)" + } if index == 7 { return "Route (/ edit)" } + if index == 10 { + return "Startup Command (/ template)" + } if index == 8 { if len(fm.groups) > 0 { return "Group (/ pick)" } return "Group" } + if index == 11 { + return "Tags (/ pick, type to create)" + } return fm.labels[index] } return "" @@ -773,10 +989,53 @@ func parsePort(value string) (int, error) { } func (fm *formModel) View() string { + fm.updateCredentialPresentation() title := "Add Server" if fm.edit { title = "Edit Server: " + fm.server.Alias } + if fm.showIdentityList { + return renderScreenShell(screenShell{ + breadcrumb: title + " / Identity File", status: "Choose a private key", width: fm.width, height: fm.height, + body: func(width, height int) string { + return renderPaddedPanel(width, height, splitBlock(renderDropdown(fm.identityList))) + }, + footer: []helpItem{{Key: "/", Action: "filter"}, {Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, + }) + } + if fm.showStartupList { + return renderScreenShell(screenShell{ + breadcrumb: title + " / Startup Command", + status: "Choose a command template", + width: fm.width, + height: fm.height, + body: func(width, height int) string { + lines := []string{fm.inputs[10].View(), ""} + if len(fm.startupList.Items()) == 0 { + lines = append(lines, dashboardHelp("No command templates yet. Use Manage → Command templates to create one.")) + } else { + capacity := max(1, height-4) + start, end := visibleServerRange(len(fm.startupList.Items()), fm.startupList.Index(), capacity) + for index := start; index < end; index++ { + item, ok := fm.startupList.Items()[index].(templateItem) + if !ok || item.template == nil { + continue + } + marker := " " + if index == fm.startupList.Index() { + marker = "> " + } + lines = append(lines, marker+item.template.Name+" "+item.template.Command) + } + } + return renderPaddedPanel(width, height, lines) + }, + footer: []helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "insert copy"}, {Key: "Esc", Action: "cancel"}}, + }) + } + if fm.showTagList { + return fm.tagPickerView(title) + } if fm.showRouteList { return fm.routeEditorView(title) } @@ -818,6 +1077,9 @@ func (fm *formModel) View() string { allFields := make([]string, 0, len(fm.inputs)+5) focusRows := make([]int, len(fm.inputs)+1) for i := range fm.inputs { + if i == 6 && !fm.usesIdentityFile() { + continue + } if richLayout { if section := formSectionTitle(i); section != "" { allFields = append(allFields, sectionStyle.Copy().MarginTop(0).Render(section)) @@ -832,10 +1094,12 @@ func (fm *formModel) View() string { focusRows[i] = len(allFields) allFields = append(allFields, fm.inputs[i].View()) } - focusRows[len(fm.inputs)] = len(allFields) - allFields = append(allFields, fm.password.View()) - focusField := len(allFields) - 1 - if fm.focusIdx <= len(fm.inputs) { + if fm.usesSecretInput() { + focusRows[len(fm.inputs)] = len(allFields) + allFields = append(allFields, fm.password.View()) + } + focusField := max(0, len(allFields)-1) + if fm.focusIdx <= len(fm.inputs) && fm.focusAllowed(fm.focusIdx) { focusField = focusRows[fm.focusIdx] } actionRows := 1 @@ -875,6 +1139,43 @@ func (fm *formModel) View() string { }) } +func (fm *formModel) tagPickerView(title string) string { + selected := splitCSV(fm.inputs[11].Value()) + body := func(width, height int) string { + lines := []string{dashboardSection("Existing tags")} + if len(fm.tagList.Items()) == 0 { + lines = append(lines, dashboardHelp("No saved tags yet. Esc and type a new tag in the field.")) + } else { + capacity := max(1, height-4) + start, end := visibleServerRange(len(fm.tagList.Items()), fm.tagList.Index(), capacity) + for index := start; index < end; index++ { + item, ok := fm.tagList.Items()[index].(groupItem) + if !ok { + continue + } + mark := "[ ]" + if containsString(selected, item.name) { + mark = "[x]" + } + line := " " + mark + " " + item.name + if index == fm.tagList.Index() { + line = selectedRowStyle.Render("> " + mark + " " + item.name) + } + lines = append(lines, line) + } + } + return renderPaddedPanel(width, height, lines) + } + return renderScreenShell(screenShell{ + breadcrumb: title + " / Tags", + status: fmt.Sprintf("%d selected", len(selected)), + width: fm.width, + height: fm.height, + body: body, + footer: []helpItem{{Key: "/", Action: "filter"}, {Key: "↑/↓", Action: "move"}, {Key: "Space/Enter", Action: "toggle"}, {Key: "Esc", Action: "done"}}, + }) +} + func (fm *formModel) routeEditorView(title string) string { route := fm.currentRoute() body := func(width, height int) string { diff --git a/internal/tui/help_screen.go b/internal/tui/help_screen.go index 6e1b3c2..b754918 100644 --- a/internal/tui/help_screen.go +++ b/internal/tui/help_screen.go @@ -26,7 +26,8 @@ func newHelpScreenModel(w, h int) *helpScreenModel { helpScreenItem{key: "Ctrl+A", action: "Add server", section: "Server list"}, helpScreenItem{key: "Ctrl+E", action: "Edit server", section: "Server list"}, helpScreenItem{key: "Ctrl+F", action: "Search", section: "Server list"}, - helpScreenItem{key: "Ctrl+X", action: "Action menu", section: "Server list"}, + helpScreenItem{key: "Ctrl+X", action: "Server actions", section: "Server list"}, + helpScreenItem{key: "m", action: "Manage groups / tags / templates / tunnels / vault", section: "Server list"}, helpScreenItem{key: "Ins", action: "Select / deselect", section: "Server list"}, helpScreenItem{key: "Ctrl+W", action: "Manage port forwards", section: "Forwards"}, helpScreenItem{key: "?", action: "This quick help", section: "Other"}, @@ -207,21 +208,29 @@ func (m *fullHelpModel) View() string { {"Ctrl+A", "Add server"}, {"Ctrl+E", "Edit server"}, {"Ctrl+F", "Search"}, - {"Ctrl+X", "Action menu"}, + {"Ctrl+X", "Server actions"}, + {"m", "Manage global entities"}, {"Ins", "Select / deselect"}, }}, - {"Action menu (Ctrl+X)", [][2]string{ + {"Server actions (Ctrl+X)", [][2]string{ {"Connect", "Standard SSH session"}, {"Connect with tunnels", "SSH + all enabled forwards"}, {"Start tunnels only", "Forwards without shell"}, {"Start tunnels in bg", "Background tunnel process"}, - {"Manage port forwards", "Add / edit / delete forwards"}, - {"Manage tunnels", "View and stop running tunnels"}, - {"Manage route", "Configure ProxyJump / bastions"}, + {"Port forwards", "Add / edit / enable / delete forwards"}, + {"Route", "Configure ordered bastions"}, {"Test connection", "Check if server is reachable"}, {"Edit", "Edit server profile"}, {"Delete", "Remove server profile"}, }}, + {"Manage (m)", [][2]string{ + {"Groups", "Create / rename / remove groups"}, + {"Tags", "Manage and apply tags"}, + {"Command templates", "Manage reusable commands"}, + {"Running tunnels", "View and stop tracked tunnels"}, + {"Import / Export", "Move server profile data"}, + {"Vault", "Lock or change master password"}, + }}, {"Routes / ProxyJump", [][2]string{ {"", "Routes define how to reach a server through jump hosts."}, {"● direct", "No jump host"}, @@ -296,6 +305,7 @@ func (i actionMenuItem) FilterValue() string { return i.label } type actionMenuModel struct { list list.Model + title string width int height int } @@ -306,26 +316,37 @@ func newActionMenuModel(w, h int) *actionMenuModel { actionMenuItem{label: "Connect with tunnels", action: "tunnel", description: "Open SSH and activate enabled port forwards."}, actionMenuItem{label: "Start tunnels only", action: "tunnel_n", description: "Activate enabled forwards without a shell."}, actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg", description: "Run enabled forwards as a background process."}, - actionMenuItem{label: "Manage port forwards", action: "forwards", description: "Add, edit, enable, or remove forwarding rules."}, - actionMenuItem{label: "Manage tunnels", action: "tunnels", description: "Inspect and stop running tunnel processes."}, - actionMenuItem{label: "Manage route", action: "route", description: "Configure direct or ProxyJump routing."}, + actionMenuItem{label: "Port forwards", action: "forwards", description: "Add, edit, enable, or remove forwarding rules for this server."}, + actionMenuItem{label: "Route", action: "route", description: "Configure direct or bastion routing for this server."}, actionMenuItem{label: "Test connection", action: "test", description: "Check SSH reachability for this profile."}, actionMenuItem{label: "Edit", action: "edit", description: "Change this server profile."}, actionMenuItem{label: "Delete", action: "delete", description: "Permanently remove this server profile."}, - actionMenuItem{label: "Import", action: "import", description: "Import profiles from a supported source."}, - actionMenuItem{label: "Export", action: "export", description: "Export selected server profiles."}, + } + return newMenuModel("Server Actions", items, w, h) +} + +func newManageMenuModel(w, h int) *actionMenuModel { + items := []list.Item{ + actionMenuItem{label: "Groups", action: "groups", description: "Create, rename, and remove server groups."}, + actionMenuItem{label: "Tags", action: "tags", description: "Manage tags and apply them to selected servers."}, + actionMenuItem{label: "Command templates", action: "templates", description: "Manage reusable commands."}, + actionMenuItem{label: "Running tunnels", action: "tunnels", description: "Inspect and stop tracked background tunnels."}, + actionMenuItem{label: "Import SSH config", action: "import", description: "Import profiles from ~/.ssh/config."}, + actionMenuItem{label: "Export", action: "export", description: "Export server profiles."}, actionMenuItem{label: "Vault: lock", action: "vault_lock", description: "Lock secrets for the current session."}, actionMenuItem{label: "Vault: change password", action: "vault_change_pw", description: "Change the password protecting stored secrets."}, } + return newMenuModel("Manage", items, w, h) +} - l := list.New(items, list.NewDefaultDelegate(), 30, len(items)+2) - l.Title = "Actions" +func newMenuModel(title string, items []list.Item, w, h int) *actionMenuModel { + l := list.New(items, list.NewDefaultDelegate(), 34, len(items)+2) + l.Title = title l.SetShowStatusBar(false) l.SetFilteringEnabled(false) l.SetShowHelp(false) l.Styles.Title = titleStyle - - return &actionMenuModel{list: l, width: w, height: h} + return &actionMenuModel{list: l, title: title, width: w, height: h} } func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) { @@ -368,7 +389,7 @@ func (m *actionMenuModel) View() string { return renderPaddedPanel(width, height, listLines) } return renderScreenShell(screenShell{ - breadcrumb: "Actions", + breadcrumb: m.title, status: fmt.Sprintf("%d actions", len(m.list.Items())), width: m.width, height: m.height, diff --git a/internal/tui/layout_test.go b/internal/tui/layout_test.go index e4e5df2..54f62c8 100644 --- a/internal/tui/layout_test.go +++ b/internal/tui/layout_test.go @@ -140,7 +140,7 @@ func TestActionMenuFitsSupportedTerminalSizes(t *testing.T) { view := menu.View() assertViewFits(t, view, size.width, size.height) assertUnifiedScreen(t, view, size.width, size.height) - for _, want := range []string{"Actions", "Connect", "Manage port forwards", "Esc"} { + for _, want := range []string{"Server Actions", "Connect", "Port forwards", "Esc"} { if !strings.Contains(view, want) { t.Fatalf("action menu at %dx%d missing %q:\n%s", size.width, size.height, want, view) } @@ -148,6 +148,20 @@ func TestActionMenuFitsSupportedTerminalSizes(t *testing.T) { } } +func TestManageMenuFitsSupportedTerminalSizes(t *testing.T) { + for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} { + menu := newManageMenuModel(size.width, size.height) + view := menu.View() + assertViewFits(t, view, size.width, size.height) + assertUnifiedScreen(t, view, size.width, size.height) + for _, want := range []string{"Manage", "Groups", "Command templates", "Vault", "Esc"} { + if !strings.Contains(view, want) { + t.Fatalf("manage menu at %dx%d missing %q:\n%s", size.width, size.height, want, view) + } + } + } +} + func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) { for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} { m := New(nil) @@ -214,6 +228,7 @@ func TestManagerScreensUseUnifiedShell(t *testing.T) { template := &model.CommandTemplate{Name: "Disk usage", Command: "df -h", Description: "Show mounted filesystems"} m.setTemplates([]*model.CommandTemplate{template}) m.setTags([]string{"production"}) + m.setGroups([]*model.Group{{ID: 1, Name: "Production", ServerCount: 1}}) m.pendingTemplate = template m.bgResults = []templateRunResult{{Alias: "prod", Output: "ok\n数据库 ready"}} @@ -224,6 +239,8 @@ func TestManagerScreensUseUnifiedShell(t *testing.T) { {"search", screenSearch}, {"tags", screenTags}, {"tag-input", screenTagInput}, + {"groups", screenGroups}, + {"group-input", screenGroupInput}, {"templates", screenTemplates}, {"template-picker", screenTemplatePicker}, {"template-mode", screenTemplateMode}, @@ -248,6 +265,8 @@ func TestLayoutMatrixInventoriesEveryScreen(t *testing.T) { screenSearch: "manager matrix", screenTags: "manager matrix", screenTagInput: "manager matrix", + screenGroups: "manager matrix", + screenGroupInput: "manager matrix", screenTemplates: "manager matrix", screenTemplateForm: "template form", screenTemplatePicker: "manager matrix", @@ -255,6 +274,7 @@ func TestLayoutMatrixInventoriesEveryScreen(t *testing.T) { screenBackgroundResults: "manager matrix", screenHelp: "help matrix", screenActionMenu: "action matrix", + screenManageMenu: "manage matrix", screenForwardList: "forward matrix", screenForwardForm: "forward form matrix", screenTunnelManager: "manager matrix", diff --git a/internal/tui/status_help_test.go b/internal/tui/status_help_test.go index ea5055b..0eae279 100644 --- a/internal/tui/status_help_test.go +++ b/internal/tui/status_help_test.go @@ -18,16 +18,16 @@ func TestVaultStatusTracksSuccessfulLock(t *testing.T) { if !strings.Contains(m.View(), "Vault unlocked") { t.Fatalf("initial status is not unlocked:\n%s", m.View()) } - m.actionMenu = newActionMenuModel(80, 24) - m.screen = screenActionMenu - for i := range m.actionMenu.list.Items() { - m.actionMenu.list.Select(i) - item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem) + m.manageMenu = newManageMenuModel(80, 24) + m.screen = screenManageMenu + for i := range m.manageMenu.list.Items() { + m.manageMenu.list.Select(i) + item, ok := m.manageMenu.list.SelectedItem().(actionMenuItem) if ok && item.action == "vault_lock" { break } } - updated, _ := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter}) m = updated.(*tuiModel) view := m.View() if !strings.Contains(view, "Vault locked") || strings.Contains(view, "Vault unlocked") { diff --git a/internal/tui/tunnel.go b/internal/tui/tunnel.go index 105d839..81bbe5b 100644 --- a/internal/tui/tunnel.go +++ b/internal/tui/tunnel.go @@ -93,6 +93,16 @@ func (m *tunnelScreenModel) stopSelected() tea.Cmd { return nil } +func (m *tunnelScreenModel) runningCount() int { + count := 0 + for _, state := range m.tunnels { + if state != nil && tunnel.IsRunning(state.ID) { + count++ + } + } + return count +} + func (m *tunnelScreenModel) View() string { notification := "" if m.err != nil { @@ -100,7 +110,7 @@ func (m *tunnelScreenModel) View() string { } body := func(width, height int) string { if len(m.tunnels) == 0 { - return renderPaddedPanel(width, height, []string{dashboardHelp("No running tunnels.")}) + return renderPaddedPanel(width, height, []string{dashboardHelp("No tracked tunnels.")}) } capacity := max(1, height-2) start, end := visibleServerRange(len(m.tunnels), m.list.Index(), max(1, capacity/3)) @@ -120,7 +130,7 @@ func (m *tunnelScreenModel) View() string { } return renderScreenShell(screenShell{ breadcrumb: "Tunnel Manager", - status: fmt.Sprintf("%d running", len(m.tunnels)), + status: fmt.Sprintf("%d running · %d tracked", m.runningCount(), len(m.tunnels)), notification: notification, width: m.width, height: m.height, diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 3a934f3..71251ed 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -9,6 +9,7 @@ import ( "io" "os" "sort" + "strconv" "strings" "sync" "time" @@ -67,9 +68,10 @@ type derivedKey struct { } type SecretMeta struct { - ID string - Alias string - Type string + ID string + Alias string + ServerID int64 + Type string } func New(path string) *Vault { @@ -271,24 +273,32 @@ func (v *Vault) ListSecrets() ([]SecretMeta, error) { metas := make([]SecretMeta, 0, len(v.records)) for id, record := range v.records { - alias, secretType, ok := parseServerSecretID(id) - if !ok { - continue + alias, secretType, legacy := parseServerSecretID(id) + serverID := int64(0) + if !legacy { + var ok bool + serverID, secretType, ok = parseStableServerSecretID(id) + if !ok { + continue + } } if record.secretType != "" { secretType = record.secretType } - metas = append(metas, SecretMeta{ - ID: id, - Alias: alias, - Type: secretType, - }) + metas = append(metas, SecretMeta{ID: id, Alias: alias, ServerID: serverID, Type: secretType}) } sort.Slice(metas, func(i, j int) bool { - if metas[i].Alias == metas[j].Alias { + left, right := metas[i].Alias, metas[j].Alias + if left == "" { + left = fmt.Sprintf("#%d", metas[i].ServerID) + } + if right == "" { + right = fmt.Sprintf("#%d", metas[j].ServerID) + } + if left == right { return metas[i].Type < metas[j].Type } - return metas[i].Alias < metas[j].Alias + return left < right }) return metas, nil } @@ -495,10 +505,14 @@ func inferSecretType(id string, recordType string) string { return recordType } _, secretType, ok := parseServerSecretID(id) - if !ok { - return "" + if ok { + return secretType } - return secretType + _, secretType, ok = parseStableServerSecretID(id) + if ok { + return secretType + } + return "" } func parseServerSecretID(id string) (string, string, bool) { @@ -509,6 +523,18 @@ func parseServerSecretID(id string) (string, string, bool) { return parts[1], parts[2], true } +func parseStableServerSecretID(id string) (int64, string, bool) { + parts := strings.Split(id, ":") + if len(parts) != 3 || parts[0] != "server-id" || parts[1] == "" || parts[2] == "" { + return 0, "", false + } + serverID, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || serverID <= 0 { + return 0, "", false + } + return serverID, parts[2], true +} + func decryptRecord(key []byte, rec Record) ([]byte, error) { aead, err := chacha20poly1305.NewX(key) if err != nil { diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go index 17dd51a..a386b9a 100644 --- a/internal/vault/vault_test.go +++ b/internal/vault/vault_test.go @@ -266,3 +266,24 @@ func TestHasSecretReportsPresenceWithoutReturningValue(t *testing.T) { t.Fatal("expected missing passphrase to be reported absent") } } + +func TestListSecretsIncludesStableServerIDs(t *testing.T) { + path := filepath.Join(t.TempDir(), "vault.bin") + if err := Create(path, "master"); err != nil { + t.Fatalf("create: %v", err) + } + v := New(path) + if err := v.Unlock("master"); err != nil { + t.Fatalf("unlock: %v", err) + } + if err := v.Put("server-id:42:ssh_password", "ssh_password", []byte("secret")); err != nil { + t.Fatalf("put: %v", err) + } + metas, err := v.ListSecrets() + if err != nil { + t.Fatalf("list: %v", err) + } + if len(metas) != 1 || metas[0].ServerID != 42 || metas[0].Type != "ssh_password" || metas[0].Alias != "" { + t.Fatalf("unexpected stable metadata: %#v", metas) + } +}