sshkeeper: v0.2.0 — Phase 0: TUI refactoring (extract form, template_form, help into separate files)

This commit is contained in:
mirivlad 2026-06-03 09:26:50 +08:00
parent b2d8ea959f
commit 31f26164cc
5 changed files with 1038 additions and 934 deletions

94
Roadmap_v0.2.0.md Normal file
View File

@ -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 <alias>`
- `sshkeeper forward add <alias> ...`
- `sshkeeper forward delete <alias> <id>`
- `sshkeeper tunnel <alias>`
- `sshkeeper tunnel <alias> --forward-only`
- `sshkeeper route show <alias>`
- `sshkeeper route set <alias> ...`
- `sshkeeper route clear <alias>`
### 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.

File diff suppressed because it is too large Load Diff

632
internal/tui/form.go Normal file
View File

@ -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 ""
}
}

69
internal/tui/help.go Normal file
View File

@ -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, " | ")
}

View File

@ -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()
}