diff --git a/Roadmap_v0.2.0.md b/Roadmap_v0.2.0.md new file mode 100644 index 0000000..2546979 --- /dev/null +++ b/Roadmap_v0.2.0.md @@ -0,0 +1,94 @@ +## Theme + +Routes, tunnels and cleaner TUI. + +sshkeeper v0.2.0 focuses on real-world SSH workflows where servers are accessed through bastions, jump chains and port forwards, while keeping the TUI simple and discoverable. + +## Planned features + +### 1. Cleaner TUI action model + +- Replace always-visible hotkey overload with a compact action bar. +- Keep only primary actions visible: + - Connect + - Add + - Edit + - Search + - Templates + - Forwards + - Select + - Help + - Quit +- Move secondary shortcuts to the help screen. +- Add a contextual action menu for less frequent actions: + - Delete + - Test + - Tags + - Import/export + - Vault actions + +### 2. Route / ProxyJump UX + +- Rename raw `ProxyJump` handling in the UI to `Route`. +- Support three route modes: + - Direct + - Via jump host + - Via chain +- Allow selecting jump hosts from existing sshkeeper profiles. +- Allow entering raw jump hosts manually. +- Display route summary in the server list: + - `direct → target` + - `bastion → target` + - `bastion → dmz-gw → target` +- Keep full technical ProxyJump value visible in server details. + +### 3. Port forwarding manager + +- Add per-server forwarding management screen. +- Support: + - Local forwarding + - Remote forwarding + - Dynamic SOCKS forwarding +- Show human-readable forwarding table: + - type + - listen address/port + - target address/port +- Show generated OpenSSH preview for each forward. +- Add `ExitOnForwardFailure` option. +- Support normal SSH session with forwards. +- Support forward-only mode using `ssh -N`. + +### 4. CLI support for routes and forwards + +- Add commands: + - `sshkeeper forward list ` + - `sshkeeper forward add ...` + - `sshkeeper forward delete ` + - `sshkeeper tunnel ` + - `sshkeeper tunnel --forward-only` + - `sshkeeper route show ` + - `sshkeeper route set ...` + - `sshkeeper route clear ` + +### 5. Search improvements + +- Extend search to notes, tags, proxy/jump route and forward ports. +- Make search useful for real admin memory: + - host names + - aliases + - groups + - tags + - notes + - bastion names + - exposed local ports + +### 6. README update + +- Add a section explaining that sshkeeper is not Ansible. +- Add examples for: + - jump host + - jump chain + - local port forward + - dynamic SOCKS proxy + - forward-only session +- Add screenshots for route and forwarding screens. \ No newline at end of file diff --git a/internal/tui/app.go b/internal/tui/app.go index 69ed3c7..246f281 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -6,7 +6,6 @@ import ( "time" "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -75,7 +74,6 @@ type backgroundRunDoneMsg struct { results []templateRunResult } -// connectRequestMsg — TUI requests a connect action to be handled outside type connectRequestMsg struct { server *model.Server } @@ -86,7 +84,7 @@ type templateRunRequestMsg struct { command string } -// --- Server list item --- +// --- List items --- type serverItem struct { server *model.Server @@ -289,7 +287,6 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case connectRequestMsg: - // Store result and quit TUI — caller will handle the connect m.result = &TUIResult{ Server: msg.server, Action: "connect", @@ -328,7 +325,6 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.form.err = nil return m, nil } - // Update test status in DB and reload list if item, ok := m.list.SelectedItem().(serverItem); ok && UpdateTestResult != nil { status := model.TestUnknown if msg.ok { @@ -408,7 +404,6 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case tea.KeyEnter: - // Connect to selected server if item, ok := m.list.SelectedItem().(serverItem); ok { return m, func() tea.Msg { return connectRequestMsg{server: item.server} @@ -432,13 +427,11 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Quit case tea.KeyCtrlA: - // Add server m.form = newFormModel(m.width, m.height) m.screen = screenForm return m, nil case tea.KeyCtrlE: - // Edit selected server if item, ok := m.list.SelectedItem().(serverItem); ok { m.form = newEditFormModel(item.server, m.width, m.height) m.screen = screenForm @@ -446,7 +439,6 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyCtrlD: - // Delete selected server if item, ok := m.list.SelectedItem().(serverItem); ok { return m, func() tea.Msg { err := DeleteServer(item.server.Alias) @@ -459,7 +451,6 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case tea.KeyCtrlT: - // Test connection if item, ok := m.list.SelectedItem().(serverItem); ok { return m, func() tea.Msg { ok, testErr := TestConnection(item.server) @@ -468,7 +459,6 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case tea.KeyCtrlF, tea.KeyCtrlS: - // Search m.screen = screenSearch m.searchInput.Focus() return m, nil @@ -742,11 +732,11 @@ func (m *tuiModel) updateTemplateMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return templateRunRequestMsg{servers: servers, templateName: tpl.Name, command: tpl.Command} } - } - default: - if msg.Type == tea.KeyEsc { - m.screen = screenTemplatePicker - return m, nil + default: + if msg.Type == tea.KeyEsc { + m.screen = screenTemplatePicker + return m, nil + } } } return m, nil @@ -776,7 +766,6 @@ func (m *tuiModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.form = nil m.err = nil m.success = "" - // Reload server list after form close return m, func() tea.Msg { servers, err := ListServers() return serversLoadedMsg{servers: servers, err: err} @@ -966,125 +955,6 @@ func (m *tuiModel) backgroundResultForAlias(alias string) *templateRunResult { return nil } -func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string { - width := m.width - 2 - if width <= 0 { - width = 80 - } - lines := wrapHelpItems(m.listHelpItems(selectedCount, hasBackgroundResult), width) - rendered := make([]string, len(lines)) - for i, line := range lines { - rendered[i] = " " + renderHelpLine(line) - } - return strings.Join(rendered, "\n") -} - -func (m *tuiModel) listHelpItems(selectedCount int, hasBackgroundResult bool) []helpItem { - insAction := "select" - 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}, - } - 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"}, - 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+Q", Action: "quit"}, - ) -} - -func wrapHelpItems(items []helpItem, width int) [][]helpItem { - if width <= 0 { - return [][]helpItem{items} - } - var lines [][]helpItem - var current []helpItem - currentWidth := 0 - for _, item := range items { - itemWidth := len(plainHelpItem(item)) - if len(current) == 0 { - current = []helpItem{item} - currentWidth = itemWidth - continue - } - nextWidth := currentWidth + len(" | ") + itemWidth - if nextWidth > width { - lines = append(lines, current) - current = []helpItem{item} - currentWidth = itemWidth - continue - } - current = append(current, item) - currentWidth = nextWidth - } - if len(current) > 0 { - lines = append(lines, current) - } - return lines -} - -func renderHelpLine(items []helpItem) string { - parts := make([]string, len(items)) - for i, item := range items { - parts[i] = hotkeyStyle.Render(item.Key) + helpTextStyle.Render(": "+item.Action) - } - return strings.Join(parts, helpTextStyle.Render(" | ")) -} - -func renderHelp(items []helpItem, width int) string { - if width <= 0 { - width = 80 - } - lines := wrapHelpItems(items, width-2) - rendered := make([]string, len(lines)) - for i, line := range lines { - rendered[i] = " " + renderHelpLine(line) - } - return strings.Join(rendered, "\n") -} - -func plainHelpItem(item helpItem) string { - return item.Key + ": " + item.Action -} - -func plainHelpLine(items []helpItem) string { - parts := make([]string, len(items)) - for i, item := range items { - parts[i] = plainHelpItem(item) - } - return strings.Join(parts, " | ") -} - -func bottomPaddingLines(content string, footer string, height int) int { - if height <= 0 { - return 0 - } - used := strings.Count(content, "\n") + displayLineCount(footer) - if used >= height { - return 0 - } - return height - used -} - -func displayLineCount(s string) int { - s = strings.TrimRight(s, "\n") - if s == "" { - return 0 - } - return strings.Count(s, "\n") + 1 -} - func (m *tuiModel) renderBackgroundOutputLine(line string) string { line = strings.ReplaceAll(strings.TrimRight(line, "\r"), "\t", " ") line = " " + line @@ -1109,7 +979,6 @@ func (m *tuiModel) visibleServerRows() int { if m.height <= 0 { return len(m.servers) } - const fixedRows = 16 rows := m.height - fixedRows if rows < 3 { @@ -1118,32 +987,6 @@ func (m *tuiModel) visibleServerRows() int { return rows } -func visibleServerRange(total, selected, available int) (int, int) { - if total <= 0 || available <= 0 { - return 0, 0 - } - if available >= total { - return 0, total - } - if selected < 0 { - selected = 0 - } - if selected >= total { - selected = total - 1 - } - - start := selected - available + 1 - if start < 0 { - start = 0 - } - end := start + available - if end > total { - end = total - start = end - available - } - return start, end -} - func (m *tuiModel) viewSelectedServer(server *model.Server) string { displayName := server.DisplayName if displayName == "" { @@ -1395,6 +1238,55 @@ func (m *tuiModel) setTags(tags []string) { m.tagList = newStringList(tags, "Tags", m.width, managerListHeight(m.height)) } +// --- Server list footer --- + +func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string { + width := m.width - 2 + if width <= 0 { + width = 80 + } + lines := wrapHelpItems(m.listHelpItems(selectedCount, hasBackgroundResult), width) + rendered := make([]string, len(lines)) + for i, line := range lines { + rendered[i] = " " + renderHelpLine(line) + } + return strings.Join(rendered, "\n") +} + +func (m *tuiModel) listHelpItems(selectedCount int, hasBackgroundResult bool) []helpItem { + insAction := "select" + 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}, + } + 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"}, + 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+Q", Action: "quit"}, + ) +} + +// --- Utility functions --- + +func managerListHeight(height int) int { + if height <= 8 { + return 3 + } + return height - 6 +} + func testSummary(servers []*model.Server) string { okCount := 0 failedCount := 0 @@ -1429,783 +1321,56 @@ func testStatusLabel(server *model.Server) string { case model.TestOK: return "OK" case model.TestFailed: - if server.LastTestError != "" { - return "FAIL" - } return "FAIL" default: return "?" } } -// --- 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 // existing group names - groupList list.Model // dropdown list for groups - showGroupList bool // whether group dropdown is visible - authList list.Model - showAuthList bool +func visibleServerRange(total, selected, available int) (int, int) { + if total <= 0 || available <= 0 { + return 0, 0 + } + if available >= total { + return 0, total + } + if selected < 0 { + selected = 0 + } + if selected >= total { + selected = total - 1 + } + start := selected - available + 1 + if start < 0 { + start = 0 + } + end := start + available + if end > total { + end = total + start = end - available + } + return start, end } -// groupItem implements list.Item for the group dropdown -type groupItem struct { - name string +func bottomPaddingLines(content string, footer string, height int) int { + if height <= 0 { + return 0 + } + used := strings.Count(content, "\n") + displayLineCount(footer) + if used >= height { + return 0 + } + return height - used } -func (i groupItem) Title() string { return i.name } -func (i groupItem) Description() string { return "" } -func (i groupItem) FilterValue() string { return i.name } - -func newFormModel(w, h int) *formModel { - inputs := make([]textinput.Model, 12) - labels := []string{ - "Alias", - "Display Name", - "Host", - "Port", - "User", - "Auth Method (password/key/key_passphrase/agent)", - "Identity File", - "ProxyJump", - "Group (type new or pick from list)", - "Notes", - "Startup Command", - "Tags (comma-separated)", +func displayLineCount(s string) int { + s = strings.TrimRight(s, "\n") + if s == "" { + return 0 } - for i, label := range labels { - inputs[i] = textinput.New() - inputs[i].Placeholder = placeholderForLabel(label) - inputs[i].CharLimit = 128 - } - - pw := textinput.New() - pw.Placeholder = "optional" - pw.CharLimit = 256 - pw.EchoMode = textinput.EchoPassword - - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) - - inputs[0].Focus() - - fm := &formModel{ - inputs: inputs, - labels: labels, - password: pw, - passwordLabel: "Password / Passphrase", - focusIdx: 0, - spinner: s, - width: w, - height: h, - } - fm.authList = newStringList([]string{ - string(model.AuthPassword), - string(model.AuthKey), - string(model.AuthKeyPassphrase), - string(model.AuthAgent), - }, "Select auth method", 34, 16) - - // Load existing groups - if GetGroups != nil { - if groups, err := GetGroups(); err == nil && len(groups) > 0 { - fm.groups = groups - fm.groupList = newStringList(groups, "Select group", 30, 8) - } - } - - fm.updateFocus() - return fm + return strings.Count(s, "\n") + 1 } -func placeholderForLabel(label string) string { - switch label { - case "Alias": - return "mail.kp" - case "Display Name": - return "Production mail" - case "Host": - return "mail.example.org" - case "Port": - return "22" - case "User": - return "root" - case "Auth Method (password/key/key_passphrase/agent)": - return "key" - case "Identity File": - return "~/.ssh/id_ed25519" - case "ProxyJump": - return "optional" - case "Group (type new or pick from list)": - return "KP" - case "Notes": - return "optional" - case "Startup Command": - return "optional" - case "Tags (comma-separated)": - return "prod, web" - default: - return label - } -} - -func newStringList(values []string, title string, width, height int) list.Model { - items := make([]list.Item, len(values)) - for i, value := range values { - items[i] = groupItem{name: value} - } - l := list.New(items, list.NewDefaultDelegate(), width, height) - l.SetShowStatusBar(false) - l.SetShowHelp(false) - l.SetShowPagination(false) - l.Title = title - l.Styles.Title = titleStyle - return l -} - -func newEditFormModel(s *model.Server, w, h int) *formModel { - fm := newFormModel(w, h) - fm.edit = true - fm.server = s - fm.inputs[0].SetValue(s.Alias) - fm.inputs[1].SetValue(s.DisplayName) - fm.inputs[2].SetValue(s.Host) - fm.inputs[3].SetValue(fmt.Sprintf("%d", s.Port)) - fm.inputs[4].SetValue(s.User) - fm.inputs[5].SetValue(string(s.AuthMethod)) - fm.inputs[6].SetValue(s.IdentityFile) - fm.inputs[7].SetValue(s.ProxyJump) - fm.inputs[8].SetValue(s.GroupName) - fm.inputs[9].SetValue(s.Notes) - 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.updateFocus() - return fm -} - -func (fm *formModel) Init() tea.Cmd { - return nil -} - -func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // Handle test/save completion - switch msg := msg.(type) { - case testDoneMsg: - fm.testing = false - if msg.ok { - fm.testResult = "Connection OK." - fm.testOK = true - } else { - fm.testResult = fmt.Sprintf("Connection failed:\n%s", msg.err) - fm.testOK = false - } - fm.testResultTime = time.Now() - fm.err = nil - return fm, nil - case saveDoneMsg: - fm.saving = false - if msg.err != nil { - fm.err = msg.err - fm.saved = false - } else { - fm.saved = true - fm.savedTime = time.Now() - fm.err = nil - } - return fm, nil - } - - // Handle spinner tick while testing/saving - if fm.testing || fm.saving { - var cmd tea.Cmd - fm.spinner, cmd = fm.spinner.Update(msg) - if _, ok := msg.(tea.KeyMsg); ok { - return fm, cmd - } - return fm, cmd - } - - // Handle group dropdown - if fm.showGroupList { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.Type { - case tea.KeyEsc: - fm.showGroupList = false - return fm, nil - case tea.KeyEnter: - if item, ok := fm.groupList.SelectedItem().(groupItem); ok { - fm.inputs[8].SetValue(item.name) - } - fm.showGroupList = false - return fm, nil - } - } - // Pass other keys to the list - var cmd tea.Cmd - fm.groupList, cmd = fm.groupList.Update(msg) - return fm, cmd - } - - if fm.showAuthList { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.Type { - case tea.KeyEsc: - fm.showAuthList = false - return fm, nil - case tea.KeyEnter: - if item, ok := fm.authList.SelectedItem().(groupItem); ok { - fm.inputs[5].SetValue(item.name) - } - fm.showAuthList = false - return fm, nil - } - } - var cmd tea.Cmd - fm.authList, cmd = fm.authList.Update(msg) - return fm, cmd - } - - switch msg := msg.(type) { - 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() - return fm, nil - - case tea.KeyShiftTab: - fm.focusIdx-- - if fm.focusIdx < 0 { - total := len(fm.inputs) + 3 - fm.focusIdx = total - 1 - } - fm.updateFocus() - return fm, nil - - case tea.KeyRunes: - if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 5 { - fm.showAuthList = true - return fm, nil - } - // '/' on Group field opens group dropdown - if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 8 && len(fm.groups) > 0 { - fm.showGroupList = true - return fm, nil - } - - case tea.KeyEnter: - switch { - case fm.focusIdx == len(fm.inputs)+1: - return fm, fm.runTest() - 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() - return fm, nil - } - - case tea.KeyEsc: - return fm, nil - - case tea.KeyDown: - fm.focusIdx++ - total := len(fm.inputs) + 3 - if fm.focusIdx >= total { - fm.focusIdx = 0 - } - fm.updateFocus() - return fm, nil - - case tea.KeyUp: - fm.focusIdx-- - if fm.focusIdx < 0 { - total := len(fm.inputs) + 3 - fm.focusIdx = total - 1 - } - fm.updateFocus() - return fm, nil - } - } - - if fm.focusIdx < len(fm.inputs) { - var cmd tea.Cmd - fm.inputs[fm.focusIdx], cmd = fm.inputs[fm.focusIdx].Update(msg) - return fm, cmd - } - - if fm.focusIdx == len(fm.inputs) { - var cmd tea.Cmd - fm.password, cmd = fm.password.Update(msg) - return fm, cmd - } - - return fm, nil -} - -func (fm *formModel) updateFocus() { - for i := range fm.inputs { - fm.inputs[i].Blur() - fm.inputs[i].Prompt = blurredStyle.Render(fm.labelAt(i) + ": ") - } - fm.password.Blur() - fm.password.Prompt = blurredStyle.Render(fm.passwordLabel + ": ") - - if fm.focusIdx < len(fm.inputs) { - fm.inputs[fm.focusIdx].Focus() - 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 + "> ") - } -} - -func (fm *formModel) labelAt(index int) string { - if index >= 0 && index < len(fm.labels) { - if index == 5 { - return "Auth Method (/ pick)" - } - if index == 8 { - if len(fm.groups) > 0 { - return "Group (/ pick)" - } - return "Group" - } - return fm.labels[index] - } - return "" -} - -func (fm *formModel) runTest() tea.Cmd { - fm.testing = true - fm.testResult = "" - fm.err = nil - fm.saved = false - - s := fm.buildServer() - pw := fm.password.Value() - - return tea.Batch( - fm.spinner.Tick, - func() tea.Msg { - // Use direct password test if available (for form test before save) - if TestConnectionWithPassword != nil { - ok, testErr := TestConnectionWithPassword(s, pw) - return testDoneMsg{ok: ok, err: testErr} - } - // Fallback to vault-based test - if s.AuthMethod == model.AuthPassword && pw == "" { - return testDoneMsg{ok: false, err: "Password is required for password auth."} - } - ok, testErr := TestConnection(s) - return testDoneMsg{ok: ok, err: testErr} - }, - ) -} - -func (fm *formModel) runSave() tea.Cmd { - fm.saving = true - fm.err = nil - fm.saved = false - fm.testResult = "" - - s := fm.buildServer() - pw := fm.password.Value() - - return tea.Batch( - fm.spinner.Tick, - func() tea.Msg { - if s.Alias == "" { - return saveDoneMsg{err: fmt.Errorf("alias is required")} - } - if s.Host == "" { - return saveDoneMsg{err: fmt.Errorf("host is required")} - } - oldAlias := "" - if fm.edit && fm.server != nil { - oldAlias = fm.server.Alias - } - err := SaveServer(s, pw, oldAlias) - return saveDoneMsg{err: err} - }, - ) -} - -func (fm *formModel) buildServer() *model.Server { - port := 22 - fmt.Sscanf(fm.inputs[3].Value(), "%d", &port) - authMethod := model.AuthMethod(fm.inputs[5].Value()) - if authMethod == "" { - authMethod = model.AuthKey - } - return &model.Server{ - Alias: fm.inputs[0].Value(), - DisplayName: fm.inputs[1].Value(), - Host: fm.inputs[2].Value(), - Port: port, - User: fm.inputs[4].Value(), - AuthMethod: authMethod, - IdentityFile: fm.inputs[6].Value(), - ProxyJump: fm.inputs[7].Value(), - GroupName: fm.inputs[8].Value(), - Notes: fm.inputs[9].Value(), - StartupCommand: fm.inputs[10].Value(), - Tags: splitCSV(fm.inputs[11].Value()), - } -} - -func (fm *formModel) View() string { - var b strings.Builder - - title := "Add Server" - if fm.edit { - title = "Edit Server: " + fm.server.Alias - } - b.WriteString(titleStyle.Render(title)) - b.WriteString("\n\n") - - // Calculate visible range based on terminal height - // Reserve lines for: title (2) + password (1) + buttons (3) + help (1) + padding (2) = ~9 - reserved := 9 - available := fm.height - reserved - if available < 4 { - available = 4 - } - - numInputs := len(fm.inputs) - startIdx := 0 - endIdx := numInputs - - // Scroll: keep focused field visible - if numInputs > available { - focusInput := fm.focusIdx - if focusInput >= numInputs { - focusInput = numInputs - 1 - } - // Try to show `available` fields centered on focus - startIdx = focusInput - available/2 - if startIdx < 0 { - startIdx = 0 - } - endIdx = startIdx + available - if endIdx > numInputs { - endIdx = numInputs - startIdx = endIdx - available - if startIdx < 0 { - startIdx = 0 - } - } - } - - // Show scroll indicator if needed - if startIdx > 0 { - b.WriteString(helpStyle.Render(" ↑ more fields above\n")) - } - - for i := startIdx; i < endIdx; i++ { - if section := formSectionTitle(i); section != "" { - b.WriteString(sectionStyle.Render(section)) - b.WriteString("\n") - } - if i == 5 { - fm.inputs[i].Placeholder = "password/key/key_passphrase/agent" - } - // Show group hint inline in placeholder for Group field - if i == 8 && len(fm.groups) > 0 && !fm.showGroupList { - fm.inputs[i].Placeholder = truncate(strings.Join(fm.groups, ", "), 25) - } - b.WriteString(fm.inputs[i].View()) - b.WriteString("\n") - if i == 5 && fm.showAuthList { - b.WriteString("\n" + renderDropdown(fm.authList) + "\n") - b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width)) - return b.String() - } - if i == 8 && fm.showGroupList { - b.WriteString("\n" + renderDropdown(fm.groupList) + "\n") - b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width)) - return b.String() - } - } - - if endIdx < numInputs { - b.WriteString(helpStyle.Render(fmt.Sprintf(" ↓ more fields below (%d-%d of %d)\n", startIdx+1, endIdx, numInputs))) - } - - b.WriteString(fm.password.View()) - b.WriteString("\n") - - showResults := time.Since(fm.testResultTime) < 10*time.Second || time.Since(fm.savedTime) < 10*time.Second - - if fm.testing { - b.WriteString("\n" + fm.spinner.View() + " Testing connection...\n") - } else if fm.saving { - b.WriteString("\n" + fm.spinner.View() + " Saving...\n") - } else if showResults { - if fm.testResult != "" { - b.WriteString("\n") - if fm.testOK { - b.WriteString(testOKStyle.Render("✓ " + fm.testResult)) - } else { - b.WriteString(testFailStyle.Render("✗ " + fm.testResult)) - } - b.WriteString("\n") - } - if fm.saved { - b.WriteString("\n" + successStyle.Render("✓ Saved.") + "\n") - } - if fm.err != nil { - b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) + "\n") - } - } - - testBtn := "[ Test ]" - saveBtn := "[ Save ]" - - if fm.focusIdx == len(fm.inputs)+1 { - testBtn = selectedStyle.Render(testBtn) - } else { - testBtn = normalStyle.Render(testBtn) - } - - if fm.focusIdx == len(fm.inputs)+2 { - saveBtn = selectedStyle.Render(saveBtn) - } else { - saveBtn = normalStyle.Render(saveBtn) - } - - b.WriteString("\n" + sectionStyle.Render("Actions") + "\n") - b.WriteString(testBtn + " " + saveBtn + "\n\n") - b.WriteString(renderHelp([]helpItem{ - {Key: "Tab/↓", Action: "next"}, - {Key: "↑", Action: "prev"}, - {Key: "/", Action: "pick list"}, - {Key: "Enter", Action: "select"}, - {Key: "Esc", Action: "back"}, - }, fm.width)) - - return b.String() -} - -func renderDropdown(l list.Model) string { - var b strings.Builder - b.WriteString(sectionStyle.Render(l.Title)) - b.WriteString("\n") - for i, item := range l.Items() { - group, ok := item.(groupItem) - if !ok { - continue - } - prefix := " " - style := normalStyle - if i == l.Index() { - prefix = "> " - style = selectedRowStyle - } - b.WriteString(style.Render(prefix + group.name)) - b.WriteString("\n") - } - return strings.TrimRight(b.String(), "\n") -} - -type templateFormModel struct { - edit bool - oldName string - inputs []textinput.Model - labels []string - focusIdx int - err error - saved bool - width int - height int -} - -func newTemplateFormModel(t *model.CommandTemplate, w, h int) *templateFormModel { - labels := []string{"Name", "Command", "Description"} - inputs := make([]textinput.Model, len(labels)) - for i := range inputs { - inputs[i] = textinput.New() - inputs[i].CharLimit = 512 - } - inputs[0].Placeholder = "uptime" - inputs[1].Placeholder = "uptime" - inputs[2].Placeholder = "optional" - inputs[0].Focus() - - tf := &templateFormModel{inputs: inputs, labels: labels, width: w, height: h} - if t != nil { - tf.edit = true - tf.oldName = t.Name - inputs[0].SetValue(t.Name) - inputs[1].SetValue(t.Command) - inputs[2].SetValue(t.Description) - } - tf.updateFocus() - return tf -} - -func (tf *templateFormModel) Init() tea.Cmd { - return nil -} - -func (tf *templateFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.Type { - case tea.KeyTab, tea.KeyDown: - tf.focusIdx++ - if tf.focusIdx > len(tf.inputs) { - tf.focusIdx = 0 - } - tf.updateFocus() - return tf, nil - case tea.KeyShiftTab, tea.KeyUp: - tf.focusIdx-- - if tf.focusIdx < 0 { - tf.focusIdx = len(tf.inputs) - } - tf.updateFocus() - return tf, nil - case tea.KeyEnter: - if tf.focusIdx == len(tf.inputs) { - return tf, tf.save() - } - tf.focusIdx++ - tf.updateFocus() - return tf, nil - } - } - if tf.focusIdx < len(tf.inputs) { - var cmd tea.Cmd - tf.inputs[tf.focusIdx], cmd = tf.inputs[tf.focusIdx].Update(msg) - return tf, cmd - } - return tf, nil -} - -func (tf *templateFormModel) updateFocus() { - for i := range tf.inputs { - tf.inputs[i].Blur() - tf.inputs[i].Prompt = blurredStyle.Render(tf.labels[i] + ": ") - } - if tf.focusIdx < len(tf.inputs) { - tf.inputs[tf.focusIdx].Focus() - tf.inputs[tf.focusIdx].Prompt = focusedStyle.Render(tf.labels[tf.focusIdx] + "> ") - } -} - -func (tf *templateFormModel) save() tea.Cmd { - return func() tea.Msg { - if SaveCommandTemplate == nil { - return saveDoneMsg{err: fmt.Errorf("template storage is unavailable")} - } - t := &model.CommandTemplate{ - Name: strings.TrimSpace(tf.inputs[0].Value()), - Command: strings.TrimSpace(tf.inputs[1].Value()), - Description: strings.TrimSpace(tf.inputs[2].Value()), - } - if t.Name == "" { - return saveDoneMsg{err: fmt.Errorf("name is required")} - } - if t.Command == "" { - return saveDoneMsg{err: fmt.Errorf("command is required")} - } - if err := SaveCommandTemplate(tf.oldName, t); err != nil { - return saveDoneMsg{err: err} - } - return saveDoneMsg{} - } -} - -func (tf *templateFormModel) View() string { - var b strings.Builder - title := "Add Template" - if tf.edit { - title = "Edit Template" - } - b.WriteString(titleStyle.Render(title)) - b.WriteString("\n\n") - for i := range tf.inputs { - b.WriteString(tf.inputs[i].View()) - b.WriteString("\n") - } - button := "[ Save ]" - if tf.focusIdx == len(tf.inputs) { - button = selectedStyle.Render(button) - } - b.WriteString("\n" + button + "\n\n") - if tf.err != nil { - b.WriteString(errorStyle.Render(tf.err.Error())) - b.WriteString("\n") - } - b.WriteString(renderHelp([]helpItem{ - {Key: "Tab/↓", Action: "next"}, - {Key: "↑", Action: "prev"}, - {Key: "Enter", Action: "select"}, - {Key: "Esc", Action: "back"}, - }, tf.width)) - return b.String() -} - -func formSectionTitle(index int) string { - switch index { - case 0: - return "Identity" - case 2: - return "Connection" - case 5: - return "Authentication" - case 8: - return "Metadata" - default: - return "" - } -} - -// truncate limits a string to maxLen, adding "..." if truncated func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s @@ -2245,10 +1410,3 @@ func serverAliases(servers []*model.Server) []string { } return aliases } - -func managerListHeight(height int) int { - if height <= 8 { - return 3 - } - return height - 6 -} diff --git a/internal/tui/form.go b/internal/tui/form.go new file mode 100644 index 0000000..ea6da0d --- /dev/null +++ b/internal/tui/form.go @@ -0,0 +1,632 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/mirivlad/sshkeeper/internal/model" +) + +// groupItem implements list.Item for dropdowns (groups, auth methods, etc.) +type groupItem struct { + name string +} + +func (i groupItem) Title() string { return i.name } +func (i groupItem) Description() string { return "" } +func (i groupItem) FilterValue() string { return i.name } + +func newStringList(values []string, title string, width, height int) list.Model { + items := make([]list.Item, len(values)) + for i, value := range values { + items[i] = groupItem{name: value} + } + l := list.New(items, list.NewDefaultDelegate(), width, height) + l.SetShowStatusBar(false) + l.SetShowHelp(false) + l.SetShowPagination(false) + l.Title = title + l.Styles.Title = titleStyle + return l +} + +// --- 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 +} + +func newFormModel(w, h int) *formModel { + inputs := make([]textinput.Model, 12) + labels := []string{ + "Alias", + "Display Name", + "Host", + "Port", + "User", + "Auth Method (password/key/key_passphrase/agent)", + "Identity File", + "ProxyJump", + "Group (type new or pick from list)", + "Notes", + "Startup Command", + "Tags (comma-separated)", + } + for i, label := range labels { + inputs[i] = textinput.New() + inputs[i].Placeholder = placeholderForLabel(label) + inputs[i].CharLimit = 128 + } + + pw := textinput.New() + pw.Placeholder = "optional" + pw.CharLimit = 256 + pw.EchoMode = textinput.EchoPassword + + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) + + inputs[0].Focus() + + fm := &formModel{ + inputs: inputs, + labels: labels, + password: pw, + passwordLabel: "Password / Passphrase", + focusIdx: 0, + spinner: s, + width: w, + height: h, + } + fm.authList = newStringList([]string{ + string(model.AuthPassword), + string(model.AuthKey), + string(model.AuthKeyPassphrase), + string(model.AuthAgent), + }, "Select auth method", 34, 16) + + // Load existing groups + if GetGroups != nil { + if groups, err := GetGroups(); err == nil && len(groups) > 0 { + fm.groups = groups + fm.groupList = newStringList(groups, "Select group", 30, 8) + } + } + + fm.updateFocus() + return fm +} + +func placeholderForLabel(label string) string { + switch label { + case "Alias": + return "mail.kp" + case "Display Name": + return "Production mail" + case "Host": + return "mail.example.org" + case "Port": + return "22" + case "User": + return "root" + case "Auth Method (password/key/key_passphrase/agent)": + return "key" + case "Identity File": + return "~/.ssh/id_ed25519" + case "ProxyJump": + return "optional" + case "Group (type new or pick from list)": + return "KP" + case "Notes": + return "optional" + case "Startup Command": + return "optional" + case "Tags (comma-separated)": + return "prod, web" + default: + return label + } +} + +func newEditFormModel(s *model.Server, w, h int) *formModel { + fm := newFormModel(w, h) + fm.edit = true + fm.server = s + fm.inputs[0].SetValue(s.Alias) + fm.inputs[1].SetValue(s.DisplayName) + fm.inputs[2].SetValue(s.Host) + fm.inputs[3].SetValue(fmt.Sprintf("%d", s.Port)) + fm.inputs[4].SetValue(s.User) + fm.inputs[5].SetValue(string(s.AuthMethod)) + fm.inputs[6].SetValue(s.IdentityFile) + fm.inputs[7].SetValue(s.ProxyJump) + fm.inputs[8].SetValue(s.GroupName) + fm.inputs[9].SetValue(s.Notes) + 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.updateFocus() + return fm +} + +func (fm *formModel) Init() tea.Cmd { + return nil +} + +func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Handle test/save completion + switch msg := msg.(type) { + case testDoneMsg: + fm.testing = false + if msg.ok { + fm.testResult = "Connection OK." + fm.testOK = true + } else { + fm.testResult = fmt.Sprintf("Connection failed:\n%s", msg.err) + fm.testOK = false + } + fm.testResultTime = time.Now() + fm.err = nil + return fm, nil + case saveDoneMsg: + fm.saving = false + if msg.err != nil { + fm.err = msg.err + fm.saved = false + } else { + fm.saved = true + fm.savedTime = time.Now() + fm.err = nil + } + return fm, nil + } + + // Handle spinner tick while testing/saving + if fm.testing || fm.saving { + var cmd tea.Cmd + fm.spinner, cmd = fm.spinner.Update(msg) + if _, ok := msg.(tea.KeyMsg); ok { + return fm, cmd + } + return fm, cmd + } + + // Handle group dropdown + if fm.showGroupList { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEsc: + fm.showGroupList = false + return fm, nil + case tea.KeyEnter: + if item, ok := fm.groupList.SelectedItem().(groupItem); ok { + fm.inputs[8].SetValue(item.name) + } + fm.showGroupList = false + return fm, nil + } + } + var cmd tea.Cmd + fm.groupList, cmd = fm.groupList.Update(msg) + return fm, cmd + } + + if fm.showAuthList { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEsc: + fm.showAuthList = false + return fm, nil + case tea.KeyEnter: + if item, ok := fm.authList.SelectedItem().(groupItem); ok { + fm.inputs[5].SetValue(item.name) + } + fm.showAuthList = false + return fm, nil + } + } + var cmd tea.Cmd + fm.authList, cmd = fm.authList.Update(msg) + return fm, cmd + } + + switch msg := msg.(type) { + 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() + return fm, nil + + case tea.KeyShiftTab: + fm.focusIdx-- + if fm.focusIdx < 0 { + total := len(fm.inputs) + 3 + fm.focusIdx = total - 1 + } + fm.updateFocus() + return fm, nil + + case tea.KeyRunes: + if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 5 { + fm.showAuthList = true + return fm, nil + } + if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 8 && len(fm.groups) > 0 { + fm.showGroupList = true + return fm, nil + } + + case tea.KeyEnter: + switch { + case fm.focusIdx == len(fm.inputs)+1: + return fm, fm.runTest() + 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() + return fm, nil + } + + case tea.KeyEsc: + return fm, nil + + case tea.KeyDown: + fm.focusIdx++ + total := len(fm.inputs) + 3 + if fm.focusIdx >= total { + fm.focusIdx = 0 + } + fm.updateFocus() + return fm, nil + + case tea.KeyUp: + fm.focusIdx-- + if fm.focusIdx < 0 { + total := len(fm.inputs) + 3 + fm.focusIdx = total - 1 + } + fm.updateFocus() + return fm, nil + } + } + + if fm.focusIdx < len(fm.inputs) { + var cmd tea.Cmd + fm.inputs[fm.focusIdx], cmd = fm.inputs[fm.focusIdx].Update(msg) + return fm, cmd + } + + if fm.focusIdx == len(fm.inputs) { + var cmd tea.Cmd + fm.password, cmd = fm.password.Update(msg) + return fm, cmd + } + + return fm, nil +} + +func (fm *formModel) updateFocus() { + for i := range fm.inputs { + fm.inputs[i].Blur() + fm.inputs[i].Prompt = blurredStyle.Render(fm.labelAt(i) + ": ") + } + fm.password.Blur() + fm.password.Prompt = blurredStyle.Render(fm.passwordLabel + ": ") + + if fm.focusIdx < len(fm.inputs) { + fm.inputs[fm.focusIdx].Focus() + 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 + "> ") + } +} + +func (fm *formModel) labelAt(index int) string { + if index >= 0 && index < len(fm.labels) { + if index == 5 { + return "Auth Method (/ pick)" + } + if index == 8 { + if len(fm.groups) > 0 { + return "Group (/ pick)" + } + return "Group" + } + return fm.labels[index] + } + return "" +} + +func (fm *formModel) runTest() tea.Cmd { + fm.testing = true + fm.testResult = "" + fm.err = nil + fm.saved = false + + s := fm.buildServer() + pw := fm.password.Value() + + return tea.Batch( + fm.spinner.Tick, + func() tea.Msg { + if TestConnectionWithPassword != nil { + ok, testErr := TestConnectionWithPassword(s, pw) + return testDoneMsg{ok: ok, err: testErr} + } + if s.AuthMethod == model.AuthPassword && pw == "" { + return testDoneMsg{ok: false, err: "Password is required for password auth."} + } + ok, testErr := TestConnection(s) + return testDoneMsg{ok: ok, err: testErr} + }, + ) +} + +func (fm *formModel) runSave() tea.Cmd { + fm.saving = true + fm.err = nil + fm.saved = false + fm.testResult = "" + + s := fm.buildServer() + pw := fm.password.Value() + + return tea.Batch( + fm.spinner.Tick, + func() tea.Msg { + if s.Alias == "" { + return saveDoneMsg{err: fmt.Errorf("alias is required")} + } + if s.Host == "" { + return saveDoneMsg{err: fmt.Errorf("host is required")} + } + oldAlias := "" + if fm.edit && fm.server != nil { + oldAlias = fm.server.Alias + } + err := SaveServer(s, pw, oldAlias) + return saveDoneMsg{err: err} + }, + ) +} + +func (fm *formModel) buildServer() *model.Server { + port := 22 + fmt.Sscanf(fm.inputs[3].Value(), "%d", &port) + authMethod := model.AuthMethod(fm.inputs[5].Value()) + if authMethod == "" { + authMethod = model.AuthKey + } + return &model.Server{ + Alias: fm.inputs[0].Value(), + DisplayName: fm.inputs[1].Value(), + Host: fm.inputs[2].Value(), + Port: port, + User: fm.inputs[4].Value(), + AuthMethod: authMethod, + IdentityFile: fm.inputs[6].Value(), + ProxyJump: fm.inputs[7].Value(), + GroupName: fm.inputs[8].Value(), + Notes: fm.inputs[9].Value(), + StartupCommand: fm.inputs[10].Value(), + Tags: splitCSV(fm.inputs[11].Value()), + } +} + +func (fm *formModel) View() string { + var b strings.Builder + + title := "Add Server" + if fm.edit { + title = "Edit Server: " + fm.server.Alias + } + b.WriteString(titleStyle.Render(title)) + b.WriteString("\n\n") + + reserved := 9 + available := fm.height - reserved + if available < 4 { + available = 4 + } + + numInputs := len(fm.inputs) + startIdx := 0 + endIdx := numInputs + + if numInputs > available { + focusInput := fm.focusIdx + if focusInput >= numInputs { + focusInput = numInputs - 1 + } + startIdx = focusInput - available/2 + if startIdx < 0 { + startIdx = 0 + } + endIdx = startIdx + available + if endIdx > numInputs { + endIdx = numInputs + startIdx = endIdx - available + if startIdx < 0 { + startIdx = 0 + } + } + } + + if startIdx > 0 { + b.WriteString(helpStyle.Render(" ↑ more fields above\n")) + } + + for i := startIdx; i < endIdx; i++ { + if section := formSectionTitle(i); section != "" { + b.WriteString(sectionStyle.Render(section)) + b.WriteString("\n") + } + if i == 5 { + fm.inputs[i].Placeholder = "password/key/key_passphrase/agent" + } + if i == 8 && len(fm.groups) > 0 && !fm.showGroupList { + fm.inputs[i].Placeholder = truncate(strings.Join(fm.groups, ", "), 25) + } + b.WriteString(fm.inputs[i].View()) + b.WriteString("\n") + if i == 5 && fm.showAuthList { + b.WriteString("\n" + renderDropdown(fm.authList) + "\n") + b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width)) + return b.String() + } + if i == 8 && fm.showGroupList { + b.WriteString("\n" + renderDropdown(fm.groupList) + "\n") + b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width)) + return b.String() + } + } + + if endIdx < numInputs { + b.WriteString(helpStyle.Render(fmt.Sprintf(" ↓ more fields below (%d-%d of %d)\n", startIdx+1, endIdx, numInputs))) + } + + b.WriteString(fm.password.View()) + b.WriteString("\n") + + showResults := time.Since(fm.testResultTime) < 10*time.Second || time.Since(fm.savedTime) < 10*time.Second + + if fm.testing { + b.WriteString("\n" + fm.spinner.View() + " Testing connection...\n") + } else if fm.saving { + b.WriteString("\n" + fm.spinner.View() + " Saving...\n") + } else if showResults { + if fm.testResult != "" { + b.WriteString("\n") + if fm.testOK { + b.WriteString(testOKStyle.Render("✓ " + fm.testResult)) + } else { + b.WriteString(testFailStyle.Render("✗ " + fm.testResult)) + } + b.WriteString("\n") + } + if fm.saved { + b.WriteString("\n" + successStyle.Render("✓ Saved.") + "\n") + } + if fm.err != nil { + b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) + "\n") + } + } + + testBtn := "[ Test ]" + saveBtn := "[ Save ]" + + if fm.focusIdx == len(fm.inputs)+1 { + testBtn = selectedStyle.Render(testBtn) + } else { + testBtn = normalStyle.Render(testBtn) + } + + if fm.focusIdx == len(fm.inputs)+2 { + saveBtn = selectedStyle.Render(saveBtn) + } else { + saveBtn = normalStyle.Render(saveBtn) + } + + b.WriteString("\n" + sectionStyle.Render("Actions") + "\n") + b.WriteString(testBtn + " " + saveBtn + "\n\n") + b.WriteString(renderHelp([]helpItem{ + {Key: "Tab/↓", Action: "next"}, + {Key: "↑", Action: "prev"}, + {Key: "/", Action: "pick list"}, + {Key: "Enter", Action: "select"}, + {Key: "Esc", Action: "back"}, + }, fm.width)) + + return b.String() +} + +func renderDropdown(l list.Model) string { + var b strings.Builder + b.WriteString(sectionStyle.Render(l.Title)) + b.WriteString("\n") + for i, item := range l.Items() { + group, ok := item.(groupItem) + if !ok { + continue + } + prefix := " " + style := normalStyle + if i == l.Index() { + prefix = "> " + style = selectedRowStyle + } + b.WriteString(style.Render(prefix + group.name)) + b.WriteString("\n") + } + return strings.TrimRight(b.String(), "\n") +} + +func formSectionTitle(index int) string { + switch index { + case 0: + return "Identity" + case 2: + return "Connection" + case 5: + return "Authentication" + case 8: + return "Metadata" + default: + return "" + } +} diff --git a/internal/tui/help.go b/internal/tui/help.go new file mode 100644 index 0000000..fc9ed67 --- /dev/null +++ b/internal/tui/help.go @@ -0,0 +1,69 @@ +package tui + +import ( + "strings" +) + +// --- Help rendering utilities --- + +func renderHelp(items []helpItem, width int) string { + if width <= 0 { + width = 80 + } + lines := wrapHelpItems(items, width-2) + rendered := make([]string, len(lines)) + for i, line := range lines { + rendered[i] = " " + renderHelpLine(line) + } + return strings.Join(rendered, "\n") +} + +func renderHelpLine(items []helpItem) string { + parts := make([]string, len(items)) + for i, item := range items { + parts[i] = hotkeyStyle.Render(item.Key) + helpTextStyle.Render(": "+item.Action) + } + return strings.Join(parts, helpTextStyle.Render(" | ")) +} + +func wrapHelpItems(items []helpItem, width int) [][]helpItem { + if width <= 0 { + return [][]helpItem{items} + } + var lines [][]helpItem + var current []helpItem + currentWidth := 0 + for _, item := range items { + itemWidth := len(plainHelpItem(item)) + if len(current) == 0 { + current = []helpItem{item} + currentWidth = itemWidth + continue + } + nextWidth := currentWidth + len(" | ") + itemWidth + if nextWidth > width { + lines = append(lines, current) + current = []helpItem{item} + currentWidth = itemWidth + continue + } + current = append(current, item) + currentWidth = nextWidth + } + if len(current) > 0 { + lines = append(lines, current) + } + return lines +} + +func plainHelpItem(item helpItem) string { + return item.Key + ": " + item.Action +} + +func plainHelpLine(items []helpItem) string { + parts := make([]string, len(items)) + for i, item := range items { + parts[i] = plainHelpItem(item) + } + return strings.Join(parts, " | ") +} diff --git a/internal/tui/template_form.go b/internal/tui/template_form.go new file mode 100644 index 0000000..2e61763 --- /dev/null +++ b/internal/tui/template_form.go @@ -0,0 +1,151 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbletea" + "github.com/mirivlad/sshkeeper/internal/model" +) + +// --- Template form model --- + +type templateFormModel struct { + edit bool + oldName string + inputs []textinput.Model + labels []string + focusIdx int + err error + saved bool + width int + height int +} + +func newTemplateFormModel(t *model.CommandTemplate, w, h int) *templateFormModel { + labels := []string{"Name", "Command", "Description"} + inputs := make([]textinput.Model, len(labels)) + for i := range inputs { + inputs[i] = textinput.New() + inputs[i].CharLimit = 512 + } + inputs[0].Placeholder = "uptime" + inputs[1].Placeholder = "uptime" + inputs[2].Placeholder = "optional" + inputs[0].Focus() + + tf := &templateFormModel{inputs: inputs, labels: labels, width: w, height: h} + if t != nil { + tf.edit = true + tf.oldName = t.Name + inputs[0].SetValue(t.Name) + inputs[1].SetValue(t.Command) + inputs[2].SetValue(t.Description) + } + tf.updateFocus() + return tf +} + +func (tf *templateFormModel) Init() tea.Cmd { + return nil +} + +func (tf *templateFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyTab, tea.KeyDown: + tf.focusIdx++ + if tf.focusIdx > len(tf.inputs) { + tf.focusIdx = 0 + } + tf.updateFocus() + return tf, nil + case tea.KeyShiftTab, tea.KeyUp: + tf.focusIdx-- + if tf.focusIdx < 0 { + tf.focusIdx = len(tf.inputs) + } + tf.updateFocus() + return tf, nil + case tea.KeyEnter: + if tf.focusIdx == len(tf.inputs) { + return tf, tf.save() + } + tf.focusIdx++ + tf.updateFocus() + return tf, nil + } + } + if tf.focusIdx < len(tf.inputs) { + var cmd tea.Cmd + tf.inputs[tf.focusIdx], cmd = tf.inputs[tf.focusIdx].Update(msg) + return tf, cmd + } + return tf, nil +} + +func (tf *templateFormModel) updateFocus() { + for i := range tf.inputs { + tf.inputs[i].Blur() + tf.inputs[i].Prompt = blurredStyle.Render(tf.labels[i] + ": ") + } + if tf.focusIdx < len(tf.inputs) { + tf.inputs[tf.focusIdx].Focus() + tf.inputs[tf.focusIdx].Prompt = focusedStyle.Render(tf.labels[tf.focusIdx] + "> ") + } +} + +func (tf *templateFormModel) save() tea.Cmd { + return func() tea.Msg { + if SaveCommandTemplate == nil { + return saveDoneMsg{err: fmt.Errorf("template storage is unavailable")} + } + t := &model.CommandTemplate{ + Name: strings.TrimSpace(tf.inputs[0].Value()), + Command: strings.TrimSpace(tf.inputs[1].Value()), + Description: strings.TrimSpace(tf.inputs[2].Value()), + } + if t.Name == "" { + return saveDoneMsg{err: fmt.Errorf("name is required")} + } + if t.Command == "" { + return saveDoneMsg{err: fmt.Errorf("command is required")} + } + if err := SaveCommandTemplate(tf.oldName, t); err != nil { + return saveDoneMsg{err: err} + } + return saveDoneMsg{} + } +} + +func (tf *templateFormModel) View() string { + var b strings.Builder + title := "Add Template" + if tf.edit { + title = "Edit Template" + } + b.WriteString(titleStyle.Render(title)) + b.WriteString("\n\n") + for i := range tf.inputs { + b.WriteString(tf.inputs[i].View()) + b.WriteString("\n") + } + button := "[ Save ]" + if tf.focusIdx == len(tf.inputs) { + button = selectedStyle.Render(button) + } + b.WriteString("\n" + button + "\n\n") + if tf.err != nil { + b.WriteString(errorStyle.Render(tf.err.Error())) + b.WriteString("\n") + } + b.WriteString(renderHelp([]helpItem{ + {Key: "Tab/↓", Action: "next"}, + {Key: "↑", Action: "prev"}, + {Key: "Enter", Action: "select"}, + {Key: "Esc", Action: "back"}, + }, tf.width)) + return b.String() +}