diff --git a/cmd/tui.go b/cmd/tui.go index 83ae297..e4ee95d 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -146,6 +146,9 @@ func runTUI() error { v.Lock() return nil } + tui.VaultUnlocked = func() bool { + return getOrCreateVault().IsUnlocked() + } tui.UpdateTestResult = func(alias string, status model.TestStatus, testErr string) error { return appDB.UpdateTestResult(alias, status, testErr) } diff --git a/internal/tui/app.go b/internal/tui/app.go index 30c0eb8..babc4e1 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -185,6 +185,7 @@ var ( DeleteForward func(forwardID int64) error ImportServers func() (int, error) LockVault func() error + VaultUnlocked func() bool ) // --- Screen type --- @@ -270,6 +271,8 @@ type tuiModel struct { forwardForm *forwardFormModel confirm *confirmState fullHelp *fullHelpModel + helpParent screen + vaultUnlocked bool } func New(servers []*model.Server) *tuiModel { @@ -297,15 +300,21 @@ func New(servers []*model.Server) *tuiModel { templateList.SetShowHelp(false) tagList := newStringList(nil, "Tags", 0, 0) + vaultIsUnlocked := true + if VaultUnlocked != nil { + vaultIsUnlocked = VaultUnlocked() + } + return &tuiModel{ - screen: screenList, - list: l, - servers: servers, - searchInput: search, - selected: map[string]bool{}, - tagInput: tagInput, - templateList: templateList, - tagList: tagList, + screen: screenList, + list: l, + servers: servers, + searchInput: search, + selected: map[string]bool{}, + tagInput: tagInput, + templateList: templateList, + tagList: tagList, + vaultUnlocked: vaultIsUnlocked, } } @@ -328,6 +337,37 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.form.width = msg.Width m.form.height = msg.Height } + if m.templateForm != nil { + m.templateForm.width = msg.Width + m.templateForm.height = msg.Height + } + if m.forwardScreen != nil { + m.forwardScreen.width = msg.Width + m.forwardScreen.height = msg.Height + } + if m.forwardForm != nil { + m.forwardForm.width = msg.Width + m.forwardForm.height = msg.Height + } + if m.tunnelScreen != nil { + m.tunnelScreen.width = msg.Width + m.tunnelScreen.height = msg.Height + m.tunnelScreen.list.SetSize(msg.Width, managerListHeight(msg.Height)) + } + if m.helpScreen != nil { + updated, _ := m.helpScreen.Update(msg) + if help, ok := updated.(*helpScreenModel); ok { + m.helpScreen = help + } + } + if m.fullHelp != nil { + m.fullHelp.width = msg.Width + m.fullHelp.height = msg.Height + } + if m.actionMenu != nil { + m.actionMenu.width = msg.Width + m.actionMenu.list.SetSize(msg.Width, managerListHeight(msg.Height)) + } m.templateList.SetSize(msg.Width, managerListHeight(msg.Height)) m.tagList.SetSize(msg.Width, managerListHeight(msg.Height)) return m, nil @@ -571,6 +611,22 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: + if m.err != nil || m.success != "" { + m.err = nil + m.success = "" + } + if msg.Type == tea.KeyF1 && m.screen != screenHelp && m.screen != screenFullHelp && m.screen != screenConfirm { + m.helpParent = m.screen + m.fullHelp = newFullHelpModel(m.width, m.height) + m.screen = screenFullHelp + return m, nil + } + if msg.Type == tea.KeyRunes && msg.String() == "?" && !m.screenOwnsPrintableInput() && m.screen != screenHelp && m.screen != screenFullHelp && m.screen != screenConfirm { + m.helpParent = m.screen + m.helpScreen = newHelpScreenModel(m.width, m.height) + m.screen = screenHelp + return m, nil + } switch m.screen { case screenList: return m.updateList(msg) @@ -687,16 +743,12 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyRunes: if msg.String() == "?" { + m.helpParent = m.screen m.helpScreen = newHelpScreenModel(m.width, m.height) m.screen = screenHelp return m, nil } - case tea.KeyF1: - m.fullHelp = newFullHelpModel(m.width, m.height) - m.screen = screenFullHelp - return m, nil - case tea.KeyCtrlW: // Open forward manager for selected server if item, ok := m.list.SelectedItem().(serverItem); ok { @@ -1118,11 +1170,9 @@ func (m *tuiModel) View() string { if m.err != nil { b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("Error: %v", m.err))) - m.err = nil } if m.success != "" { b.WriteString("\n" + successStyle.Render(m.success)) - m.success = "" } return b.String() @@ -1134,8 +1184,8 @@ func (m *tuiModel) updateHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.helpScreen = hs } // Esc or Enter closes help - if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEnter { - m.screen = screenList + if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEnter || (msg.Type == tea.KeyRunes && (msg.String() == "q" || msg.String() == "Q" || msg.String() == "?")) { + m.screen = m.helpParent m.helpScreen = nil return m, nil } @@ -1260,6 +1310,7 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } else if err := LockVault(); err != nil { m.err = err } else { + m.vaultUnlocked = false m.success = "Vault locked." } case "vault_change_pw": @@ -1524,14 +1575,23 @@ func (m *tuiModel) updateFullHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if fh, ok := updated.(*fullHelpModel); ok { m.fullHelp = fh } - if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEnter { - m.screen = screenList + if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEnter || (msg.Type == tea.KeyRunes && (msg.String() == "q" || msg.String() == "Q")) { + m.screen = m.helpParent m.fullHelp = nil return m, nil } return m, nil } +func (m *tuiModel) screenOwnsPrintableInput() bool { + switch m.screen { + case screenForm, screenSearch, screenTagInput, screenTemplateForm, screenForwardForm: + return true + default: + return false + } +} + func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if msg.Type == tea.KeyEsc { m.screen = screenForwardList @@ -1562,7 +1622,11 @@ func (m *tuiModel) viewServerList() string { b.WriteString(titleStyle.Render(fmt.Sprintf("sshkeeper %d servers", len(m.servers)))) b.WriteString("\n") - b.WriteString(helpStyle.Render(fmt.Sprintf("Vault unlocked | %s", testSummary(m.servers)))) + vaultStatus := "Vault locked" + if m.vaultUnlocked { + vaultStatus = "Vault unlocked" + } + b.WriteString(helpStyle.Render(fmt.Sprintf("%s | %s", vaultStatus, testSummary(m.servers)))) b.WriteString("\n\n") b.WriteString(listHeaderStyle.Render(fmt.Sprintf(" %-20s %-20s %-34s %-12s %-10s %s", "NAME", "ALIAS", "ROUTE", "AUTH", "GROUP", "STATUS"))) b.WriteString("\n") diff --git a/internal/tui/status_help_test.go b/internal/tui/status_help_test.go new file mode 100644 index 0000000..72f0991 --- /dev/null +++ b/internal/tui/status_help_test.go @@ -0,0 +1,114 @@ +package tui + +import ( + "errors" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestVaultStatusTracksSuccessfulLock(t *testing.T) { + oldUnlocked, oldLock := VaultUnlocked, LockVault + t.Cleanup(func() { VaultUnlocked, LockVault = oldUnlocked, oldLock }) + VaultUnlocked = func() bool { return true } + LockVault = func() error { return nil } + + m := New(nil) + 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) + if ok && item.action == "vault_lock" { + break + } + } + updated, _ := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(*tuiModel) + view := m.View() + if !strings.Contains(view, "Vault locked") || strings.Contains(view, "Vault unlocked") { + t.Fatalf("status after lock is false:\n%s", view) + } +} + +func TestNotificationSurvivesRepeatedView(t *testing.T) { + m := New(nil) + m.err = errors.New("reload failed") + first := m.View() + second := m.View() + if !strings.Contains(first, "reload failed") || !strings.Contains(second, "reload failed") { + t.Fatalf("notification was consumed by View: first=%q second=%q", first, second) + } +} + +func TestFullHelpReturnsToOriginatingScreen(t *testing.T) { + m := New(nil) + m.screen = screenForwardList + m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyF1}) + m = updated.(*tuiModel) + if m.screen != screenFullHelp || m.fullHelp == nil { + t.Fatalf("F1 did not open full help from forward list: screen=%v", m.screen) + } + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = updated.(*tuiModel) + if m.screen != screenForwardList || m.fullHelp != nil { + t.Fatalf("help did not return to forward list: screen=%v help=%v", m.screen, m.fullHelp) + } +} + +func TestContextHelpReturnsToOriginatingManager(t *testing.T) { + m := New(nil) + m.screen = screenForwardList + m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) + m = updated.(*tuiModel) + if m.screen != screenHelp || m.helpScreen == nil { + t.Fatalf("? did not open help from manager: screen=%v", m.screen) + } + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + m = updated.(*tuiModel) + if m.screen != screenForwardList || m.helpScreen != nil { + t.Fatalf("q did not return to manager: screen=%v help=%v", m.screen, m.helpScreen) + } +} + +func TestHelpShortcutDoesNotStealQuestionMarkFromSearch(t *testing.T) { + m := New(nil) + m.screen = screenSearch + m.searchInput.Focus() + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) + m = updated.(*tuiModel) + if m.screen != screenSearch || m.searchInput.Value() != "?" { + t.Fatalf("search input lost printable rune: screen=%v value=%q", m.screen, m.searchInput.Value()) + } +} + +func TestResizePropagatesToActiveChildren(t *testing.T) { + m := New(nil) + m.form = newFormModel(80, 24) + m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24) + m.forwardForm = newForwardFormModel(1, 80, 24) + m.templateForm = newTemplateFormModel(nil, 80, 24) + m.tunnelScreen = newTunnelScreenModel(80, 24) + m.helpScreen = newHelpScreenModel(80, 24) + m.fullHelp = newFullHelpModel(80, 24) + m.actionMenu = newActionMenuModel(80, 24) + + updated, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 16}) + m = updated.(*tuiModel) + if m.form.width != 60 || m.form.height != 16 || + m.forwardScreen.width != 60 || m.forwardScreen.height != 16 || + m.forwardForm.width != 60 || m.forwardForm.height != 16 || + m.templateForm.width != 60 || m.templateForm.height != 16 || + m.tunnelScreen.width != 60 || m.tunnelScreen.height != 16 || + m.helpScreen.width != 60 || m.fullHelp.width != 60 || m.fullHelp.height != 16 { + t.Fatalf("resize did not reach every child: %#v", m) + } +}