From 446f55f74090f2545ca4976f86d31a022c608cc3 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Wed, 3 Jun 2026 09:33:53 +0800 Subject: [PATCH] =?UTF-8?q?sshkeeper:=20v0.2.0=20=E2=80=94=20Phase=201:=20?= =?UTF-8?q?Cleaner=20TUI=20action=20model=20(action=20bar,=20help=20screen?= =?UTF-8?q?,=20action=20menu)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/app.go | 120 +++++++++++++++++++++++--- internal/tui/app_test.go | 4 +- internal/tui/help_screen.go | 162 ++++++++++++++++++++++++++++++++++++ 3 files changed, 274 insertions(+), 12 deletions(-) create mode 100644 internal/tui/help_screen.go diff --git a/internal/tui/app.go b/internal/tui/app.go index 246f281..3b23794 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -159,6 +159,8 @@ const ( screenTemplatePicker screenTemplateMode screenBackgroundResults + screenHelp + screenActionMenu ) // --- Result type — returned from TUI to caller --- @@ -195,6 +197,8 @@ type tuiModel struct { width int height int result *TUIResult + helpScreen *helpScreenModel + actionMenu *actionMenuModel } func New(servers []*model.Server) *tuiModel { @@ -389,6 +393,10 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateTemplateMode(msg) case screenBackgroundResults: return m.updateBackgroundResults(msg) + case screenHelp: + return m.updateHelp(msg) + case screenActionMenu: + return m.updateActionMenu(msg) } } @@ -474,6 +482,23 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyCtrlR: return m.openTemplatePicker() + case tea.KeyRunes: + if msg.String() == "?" { + m.helpScreen = newHelpScreenModel(m.width, m.height) + m.screen = screenHelp + return m, nil + } + + case tea.KeyF1: + m.helpScreen = newHelpScreenModel(m.width, m.height) + m.screen = screenHelp + return m, nil + + case tea.KeyCtrlX: + m.actionMenu = newActionMenuModel(m.width, m.height) + m.screen = screenActionMenu + return m, nil + default: var cmd tea.Cmd m.list, cmd = m.list.Update(msg) @@ -813,6 +838,16 @@ func (m *tuiModel) View() string { case screenBackgroundResults: b.WriteString(m.viewBackgroundResults()) + + case screenHelp: + if m.helpScreen != nil { + b.WriteString(m.helpScreen.View()) + } + + case screenActionMenu: + if m.actionMenu != nil { + b.WriteString(m.actionMenu.View()) + } } if m.err != nil { @@ -827,6 +862,73 @@ func (m *tuiModel) View() string { return b.String() } +func (m *tuiModel) updateHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + updated, cmd := m.helpScreen.Update(msg) + if hs, ok := updated.(*helpScreenModel); ok { + m.helpScreen = hs + } + // Esc or Enter closes help + if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEnter { + m.screen = screenList + m.helpScreen = nil + return m, nil + } + return m, cmd +} + +func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + updated, action := m.actionMenu.Update(msg) + m.actionMenu = updated + + if msg.Type == tea.KeyEsc || action == nil && msg.Type != tea.KeyDown && msg.Type != tea.KeyUp && msg.Type != tea.KeyLeft && msg.Type != tea.KeyRight { + if msg.Type == tea.KeyEsc { + m.screen = screenList + m.actionMenu = nil + return m, nil + } + } + + if action != nil { + m.screen = screenList + m.actionMenu = nil + // Handle known actions + switch *action { + case "delete": + if item, ok := m.list.SelectedItem().(serverItem); ok { + return m, func() tea.Msg { + err := DeleteServer(item.server.Alias) + if err != nil { + return saveDoneMsg{err: err} + } + servers, err := ListServers() + return serversLoadedMsg{servers: servers, err: err} + } + } + case "test": + if item, ok := m.list.SelectedItem().(serverItem); ok { + return m, func() tea.Msg { + ok, testErr := TestConnection(item.server) + return testDoneMsg{ok: ok, err: testErr} + } + } + case "tags": + m.screen = screenTags + return m, m.loadTagsCmd() + case "import": + m.err = fmt.Errorf("import not yet implemented") + case "export": + m.err = fmt.Errorf("export not yet implemented") + case "vault_lock": + m.err = fmt.Errorf("vault lock not yet implemented") + case "vault_change_pw": + m.err = fmt.Errorf("vault change password not yet implemented") + } + return m, nil + } + + return m, nil +} + func (m *tuiModel) viewServerList() string { var b strings.Builder selectedAlias := "" @@ -1258,24 +1360,22 @@ func (m *tuiModel) listHelpItems(selectedCount int, hasBackgroundResult bool) [] if selectedCount > 0 { insAction = fmt.Sprintf("select (%d selected)", selectedCount) } - items := []helpItem{ - {Key: "Enter", Action: "connect"}, - {Key: "Ctrl+R", Action: "run tpl"}, - {Key: "Ins", Action: insAction}, - } + var items []helpItem if hasBackgroundResult { items = append(items, helpItem{Key: "Esc", Action: "clear result"}) } - return append(items, - helpItem{Key: "Ctrl+P", Action: "tpl mgr"}, - helpItem{Key: "Ctrl+G", Action: "tags"}, + items = append(items, + helpItem{Key: "Enter", Action: "connect"}, helpItem{Key: "Ctrl+A", Action: "add"}, helpItem{Key: "Ctrl+E", Action: "edit"}, - helpItem{Key: "Ctrl+D", Action: "del"}, - helpItem{Key: "Ctrl+T", Action: "test"}, helpItem{Key: "Ctrl+F", Action: "search"}, + helpItem{Key: "Ctrl+P", Action: "tmpl"}, + helpItem{Key: "Ctrl+G", Action: "tags"}, + helpItem{Key: "Ins", Action: insAction}, + helpItem{Key: "?", Action: "help"}, helpItem{Key: "Ctrl+Q", Action: "quit"}, ) + return items } // --- Utility functions --- diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index ee3b614..d821a71 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -117,7 +117,7 @@ func TestServerListHelpWrapsOnNarrowTerminal(t *testing.T) { t.Fatalf("expected help line to be bounded, got width %d: %q\nview:\n%s", lipgloss.Width(line), line, view) } } - for _, want := range []string{"Ctrl+R", "run tpl", "Ctrl+P", "tpl mgr"} { + for _, want := range []string{"Ctrl+P", "tmpl", "Ctrl+F", "search", "?", "help"} { if !strings.Contains(view, want) { t.Fatalf("expected help to contain %q\nview:\n%s", want, view) } @@ -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+P: tpl mgr", "Ctrl+Q: quit"} { + for _, want := range []string{"Ins: select (2 selected)", "Esc: clear result", "Ctrl+P: tmpl", "Ctrl+Q: quit"} { if !strings.Contains(joined, want) { t.Fatalf("expected wrapped help to contain %q\nlines:%#v", want, lines) } diff --git a/internal/tui/help_screen.go b/internal/tui/help_screen.go new file mode 100644 index 0000000..46441f6 --- /dev/null +++ b/internal/tui/help_screen.go @@ -0,0 +1,162 @@ +package tui + +import ( + "fmt" + "io" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbletea" +) + +// --- Help screen --- + +type helpScreenModel struct { + list list.Model + width int +} + +func newHelpScreenModel(w, h int) *helpScreenModel { + items := []list.Item{ + helpScreenItem{key: "Enter", action: "Connect to server", section: "Navigation"}, + helpScreenItem{key: "↑/↓", action: "Navigate list", section: "Navigation"}, + helpScreenItem{key: "Tab/↓", action: "Next field", section: "Forms"}, + helpScreenItem{key: "Shift+Tab/↑", action: "Previous field", section: "Forms"}, + helpScreenItem{key: "/", action: "Open dropdown picker", section: "Forms"}, + helpScreenItem{key: "Esc", action: "Back / Cancel", section: "Navigation"}, + helpScreenItem{key: "Ctrl+A", action: "Add server", section: "Actions"}, + helpScreenItem{key: "Ctrl+E", action: "Edit server", section: "Actions"}, + helpScreenItem{key: "Ctrl+D", action: "Delete server", section: "Actions"}, + helpScreenItem{key: "Ctrl+T", action: "Test connection", section: "Actions"}, + helpScreenItem{key: "Ctrl+F", action: "Search", section: "Actions"}, + helpScreenItem{key: "Ctrl+G", action: "Tags manager", section: "Actions"}, + helpScreenItem{key: "Ctrl+P", action: "Templates manager", section: "Actions"}, + helpScreenItem{key: "Ctrl+R", action: "Run template", section: "Templates"}, + helpScreenItem{key: "Ctrl+B", action: "Run in background", section: "Templates"}, + helpScreenItem{key: "Ins", action: "Select / deselect", section: "Selection"}, + helpScreenItem{key: "Ctrl+X", action: "Action menu", section: "Actions"}, + helpScreenItem{key: "?", action: "This help screen", section: "Navigation"}, + helpScreenItem{key: "Ctrl+Q", action: "Quit", section: "Navigation"}, + } + + l := list.New(items, helpScreenDelegate{}, w, h-4) + l.Title = "sshkeeper — Help" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.Styles.Title = titleStyle + + return &helpScreenModel{list: l, width: w} +} + +type helpScreenItem struct { + key string + action string + section string +} + +func (i helpScreenItem) Title() string { return i.key } +func (i helpScreenItem) Description() string { return i.action } +func (i helpScreenItem) FilterValue() string { return i.key + " " + i.action } + +type helpScreenDelegate struct{} + +func (d helpScreenDelegate) Height() int { return 2 } +func (d helpScreenDelegate) Spacing() int { return 0 } +func (d helpScreenDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } +func (d helpScreenDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + i, ok := item.(helpScreenItem) + if !ok { + return + } + style := normalStyle + if index == m.Index() { + style = selectedRowStyle + } + keyStr := fmt.Sprintf("%-12s", i.key) + actionStr := i.action + line := hotkeyStyle.Render(keyStr) + helpTextStyle.Render(actionStr) + w.Write([]byte(style.Render(" " + line + "\n"))) +} + +func (m *helpScreenModel) Init() tea.Cmd { + return nil +} + +func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEsc, tea.KeyEnter: + return m, nil // caller checks screen transition + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.list.SetSize(msg.Width, msg.Height-4) + return m, nil + } + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m *helpScreenModel) View() string { + return m.list.View() +} + +// --- Action menu --- + +type actionMenuItem struct { + label string + action string +} + +func (i actionMenuItem) Title() string { return i.label } +func (i actionMenuItem) Description() string { return "" } +func (i actionMenuItem) FilterValue() string { return i.label } + +type actionMenuModel struct { + list list.Model + width int +} + +func newActionMenuModel(w, h int) *actionMenuModel { + items := []list.Item{ + actionMenuItem{label: "Delete", action: "delete"}, + actionMenuItem{label: "Test connection", action: "test"}, + actionMenuItem{label: "Tags", action: "tags"}, + actionMenuItem{label: "Import", action: "import"}, + actionMenuItem{label: "Export", action: "export"}, + actionMenuItem{label: "Vault: lock", action: "vault_lock"}, + actionMenuItem{label: "Vault: change password", action: "vault_change_pw"}, + } + + l := list.New(items, list.NewDefaultDelegate(), 30, len(items)+2) + l.Title = "Actions" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.SetShowHelp(false) + l.Styles.Title = titleStyle + + return &actionMenuModel{list: l, width: w} +} + +func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEsc: + return m, nil + case tea.KeyEnter: + if item, ok := m.list.SelectedItem().(actionMenuItem); ok { + return m, &item.action + } + } + } + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + _ = cmd + return m, nil +} + +func (m *actionMenuModel) View() string { + return m.list.View() +}