sshkeeper: v0.2.0 — Phase 1: Cleaner TUI action model (action bar, help screen, action menu)
This commit is contained in:
parent
31f26164cc
commit
446f55f740
|
|
@ -159,6 +159,8 @@ const (
|
||||||
screenTemplatePicker
|
screenTemplatePicker
|
||||||
screenTemplateMode
|
screenTemplateMode
|
||||||
screenBackgroundResults
|
screenBackgroundResults
|
||||||
|
screenHelp
|
||||||
|
screenActionMenu
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Result type — returned from TUI to caller ---
|
// --- Result type — returned from TUI to caller ---
|
||||||
|
|
@ -195,6 +197,8 @@ type tuiModel struct {
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
result *TUIResult
|
result *TUIResult
|
||||||
|
helpScreen *helpScreenModel
|
||||||
|
actionMenu *actionMenuModel
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(servers []*model.Server) *tuiModel {
|
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)
|
return m.updateTemplateMode(msg)
|
||||||
case screenBackgroundResults:
|
case screenBackgroundResults:
|
||||||
return m.updateBackgroundResults(msg)
|
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:
|
case tea.KeyCtrlR:
|
||||||
return m.openTemplatePicker()
|
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:
|
default:
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.list, cmd = m.list.Update(msg)
|
m.list, cmd = m.list.Update(msg)
|
||||||
|
|
@ -813,6 +838,16 @@ func (m *tuiModel) View() string {
|
||||||
|
|
||||||
case screenBackgroundResults:
|
case screenBackgroundResults:
|
||||||
b.WriteString(m.viewBackgroundResults())
|
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 {
|
if m.err != nil {
|
||||||
|
|
@ -827,6 +862,73 @@ func (m *tuiModel) View() string {
|
||||||
return b.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 {
|
func (m *tuiModel) viewServerList() string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
selectedAlias := ""
|
selectedAlias := ""
|
||||||
|
|
@ -1258,24 +1360,22 @@ func (m *tuiModel) listHelpItems(selectedCount int, hasBackgroundResult bool) []
|
||||||
if selectedCount > 0 {
|
if selectedCount > 0 {
|
||||||
insAction = fmt.Sprintf("select (%d selected)", selectedCount)
|
insAction = fmt.Sprintf("select (%d selected)", selectedCount)
|
||||||
}
|
}
|
||||||
items := []helpItem{
|
var items []helpItem
|
||||||
{Key: "Enter", Action: "connect"},
|
|
||||||
{Key: "Ctrl+R", Action: "run tpl"},
|
|
||||||
{Key: "Ins", Action: insAction},
|
|
||||||
}
|
|
||||||
if hasBackgroundResult {
|
if hasBackgroundResult {
|
||||||
items = append(items, helpItem{Key: "Esc", Action: "clear result"})
|
items = append(items, helpItem{Key: "Esc", Action: "clear result"})
|
||||||
}
|
}
|
||||||
return append(items,
|
items = append(items,
|
||||||
helpItem{Key: "Ctrl+P", Action: "tpl mgr"},
|
helpItem{Key: "Enter", Action: "connect"},
|
||||||
helpItem{Key: "Ctrl+G", Action: "tags"},
|
|
||||||
helpItem{Key: "Ctrl+A", Action: "add"},
|
helpItem{Key: "Ctrl+A", Action: "add"},
|
||||||
helpItem{Key: "Ctrl+E", Action: "edit"},
|
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+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"},
|
helpItem{Key: "Ctrl+Q", Action: "quit"},
|
||||||
)
|
)
|
||||||
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Utility functions ---
|
// --- Utility functions ---
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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) {
|
if !strings.Contains(view, want) {
|
||||||
t.Fatalf("expected help to contain %q\nview:\n%s", want, view)
|
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))
|
plainLines = append(plainLines, plainHelpLine(line))
|
||||||
}
|
}
|
||||||
joined := strings.Join(plainLines, "\n")
|
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) {
|
if !strings.Contains(joined, want) {
|
||||||
t.Fatalf("expected wrapped help to contain %q\nlines:%#v", want, lines)
|
t.Fatalf("expected wrapped help to contain %q\nlines:%#v", want, lines)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue