package tui import ( "fmt" "strconv" "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 } type routeProfileItem struct { server *model.Server } func (i routeProfileItem) Title() string { return i.server.Alias } func (i routeProfileItem) Description() string { target := i.server.Host if i.server.User != "" { target = i.server.User + "@" + i.server.Host } return fmt.Sprintf("%s:%d", target, i.server.Port) } func (i routeProfileItem) FilterValue() string { return strings.Join([]string{i.server.Alias, i.server.DisplayName, i.server.Host, i.server.User, i.server.GroupName}, " ") } 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 (fm *formModel) setRouteProfiles(servers []*model.Server) { fm.routeProfiles = nil items := []list.Item{} for _, server := range servers { if server == nil { continue } if fm.server != nil && ((fm.server.ID > 0 && server.ID == fm.server.ID) || server.Alias == fm.server.Alias) { continue } fm.routeProfiles = append(fm.routeProfiles, server) items = append(items, routeProfileItem{server: server}) } l := list.New(items, list.NewDefaultDelegate(), 44, 14) l.Title = "Available server profiles" l.SetShowStatusBar(false) l.SetShowHelp(false) l.SetFilteringEnabled(true) l.Styles.Title = titleStyle fm.routeList = 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 identityFiles []string identityList list.Model showIdentityList bool tagValues []string tagList list.Model showTagList bool startupTemplates []*model.CommandTemplate startupList list.Model showStartupList bool hasSavedPassword bool hasSavedPassphrase bool routeProfiles []*model.Server routeList list.Model showRouteList bool routePane int // 0=current route, 1=available profiles routeCursor int initial formSnapshot } type formSnapshot struct { values []string password string } 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", "Route (direct / ordered bastions)", "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 } inputs[3].SetValue("22") inputs[5].SetValue(string(model.AuthKey)) 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) 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() fm.initial = fm.snapshot() 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 "Route (direct / ordered bastions)": return "profile:bastion, raw:user@gw.example" 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) // Store an unambiguous route spec in the editable field. if len(s.Route.Hops) > 0 { fm.inputs[7].SetValue(model.FormatRouteSpec(s.Route)) } else if s.ProxyJump != "" { 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 { fm.hasSavedPassword = HasSecret(s.Alias, "ssh_password") fm.hasSavedPassphrase = HasSecret(s.Alias, "key_passphrase") } fm.updateFocus() fm.initial = fm.snapshot() return fm } func (fm *formModel) snapshot() formSnapshot { values := make([]string, len(fm.inputs)) for i := range fm.inputs { values[i] = fm.inputs[i].Value() } return formSnapshot{values: values, password: fm.password.Value()} } func (fm *formModel) Dirty() bool { current := fm.snapshot() if current.password != fm.initial.password || len(current.values) != len(fm.initial.values) { return true } for i := range current.values { if current.values[i] != fm.initial.values[i] { return true } } return false } func (fm *formModel) authMethodValue() model.AuthMethod { method := model.AuthMethod(strings.TrimSpace(fm.inputs[5].Value())) if method == "" { return model.AuthKey } return method } func (fm *formModel) usesIdentityFile() bool { switch fm.authMethodValue() { case model.AuthKey, model.AuthKeyPassphrase: return true default: return false } } func (fm *formModel) usesSecretInput() bool { switch fm.authMethodValue() { case model.AuthPassword, model.AuthKeyPassphrase: return true default: return false } } func (fm *formModel) currentPasswordLabel() string { switch fm.authMethodValue() { case model.AuthPassword: if fm.edit && fm.hasSavedPassword { return "Password (secret saved; leave blank to keep)" } return "Password" case model.AuthKeyPassphrase: if fm.edit && fm.hasSavedPassphrase { return "Key passphrase (secret saved; leave blank to keep)" } return "Key passphrase" default: return "" } } func (fm *formModel) focusAllowed(index int) bool { if index == 6 && !fm.usesIdentityFile() { return false } if index == len(fm.inputs) && !fm.usesSecretInput() { return false } return index >= 0 && index < len(fm.inputs)+3 } func (fm *formModel) advanceFocus(delta int) { total := len(fm.inputs) + 3 for tries := 0; tries < total; tries++ { fm.focusIdx = (fm.focusIdx + delta + total) % total if fm.focusAllowed(fm.focusIdx) { break } } fm.updateFocus() } func (fm *formModel) loadIdentityPicker() { fm.identityFiles = nil if ListIdentityFiles != nil { if files, err := ListIdentityFiles(); err == nil { fm.identityFiles = files } } fm.identityList = newStringList(fm.identityFiles, "Select SSH private key", 52, 14) fm.identityList.SetFilteringEnabled(true) fm.showIdentityList = true } func (fm *formModel) loadTagPicker() { fm.tagValues = nil if ListTags != nil { if tags, err := ListTags(); err == nil { fm.tagValues = tags } } fm.tagList = newStringList(fm.tagValues, "Select tags", 40, 14) fm.tagList.SetFilteringEnabled(true) fm.showTagList = true } func (fm *formModel) loadStartupPicker() { fm.startupTemplates = nil if ListCommandTemplates != nil { if templates, err := ListCommandTemplates(); err == nil { fm.startupTemplates = templates } } items := make([]list.Item, 0, len(fm.startupTemplates)) for _, template := range fm.startupTemplates { if template != nil { items = append(items, templateItem{template: template}) } } l := list.New(items, list.NewDefaultDelegate(), 64, 14) l.Title = "Insert command template" l.SetShowStatusBar(false) l.SetShowHelp(false) l.SetFilteringEnabled(true) l.Styles.Title = titleStyle fm.startupList = l fm.showStartupList = true } func (fm *formModel) updateStartupPicker(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.Type { case tea.KeyEsc: fm.showStartupList = false return fm, nil case tea.KeyEnter: if item, ok := fm.startupList.SelectedItem().(templateItem); ok && item.template != nil { fm.inputs[10].SetValue(item.template.Command) } fm.showStartupList = false return fm, nil } } var cmd tea.Cmd fm.startupList, cmd = fm.startupList.Update(msg) return fm, cmd } func containsString(values []string, value string) bool { for _, item := range values { if item == value { return true } } return false } func toggleTagValue(values []string, value string) []string { if containsString(values, value) { result := make([]string, 0, len(values)-1) for _, item := range values { if item != value { result = append(result, item) } } return result } return append(values, value) } func (fm *formModel) updateIdentityPicker(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.Type { case tea.KeyEsc: fm.showIdentityList = false return fm, nil case tea.KeyEnter: if item, ok := fm.identityList.SelectedItem().(groupItem); ok { fm.inputs[6].SetValue(item.name) } fm.showIdentityList = false return fm, nil } } var cmd tea.Cmd fm.identityList, cmd = fm.identityList.Update(msg) return fm, cmd } func (fm *formModel) updateTagPicker(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.Type { case tea.KeyEsc: fm.showTagList = false return fm, nil case tea.KeySpace, tea.KeyEnter: if item, ok := fm.tagList.SelectedItem().(groupItem); ok { tags := splitCSV(fm.inputs[11].Value()) tags = toggleTagValue(tags, item.name) fm.inputs[11].SetValue(strings.Join(tags, ", ")) } return fm, nil } } var cmd tea.Cmd fm.tagList, cmd = fm.tagList.Update(msg) return fm, cmd } func (fm *formModel) resolveRouteAlias(alias string) (int64, bool) { alias = strings.TrimSpace(alias) for _, server := range fm.routeProfiles { if server != nil && server.Alias == alias && server.ID > 0 { return server.ID, true } } if ResolveRouteAlias != nil { return ResolveRouteAlias(alias) } return 0, false } func (fm *formModel) parseRouteInput() (model.Route, error) { return model.ParseRouteSpec(fm.inputs[7].Value(), fm.resolveRouteAlias) } func (fm *formModel) currentRoute() model.Route { route, err := fm.parseRouteInput() if err != nil { return model.Route{} } return route } func (fm *formModel) setCurrentRoute(route model.Route) { fm.inputs[7].SetValue(model.FormatRouteSpec(route)) if len(route.Hops) == 0 { fm.routeCursor = 0 } else if fm.routeCursor >= len(route.Hops) { fm.routeCursor = len(route.Hops) - 1 } } func (fm *formModel) routeContainsProfile(route model.Route, serverID int64) bool { for _, hop := range route.Hops { if hop.Profile() && hop.ServerID == serverID { return true } } return false } func (fm *formModel) updateRouteEditor(msg tea.Msg) (tea.Model, tea.Cmd) { key, ok := msg.(tea.KeyMsg) if !ok { if fm.routePane == 1 { var cmd tea.Cmd fm.routeList, cmd = fm.routeList.Update(msg) return fm, cmd } return fm, nil } if key.Type == tea.KeyEsc { fm.showRouteList = false fm.inputs[7].Focus() return fm, nil } if key.Type == tea.KeyTab || key.Type == tea.KeyShiftTab { if fm.routePane == 0 { fm.routePane = 1 } else { fm.routePane = 0 } return fm, nil } route := fm.currentRoute() if fm.routePane == 0 { switch key.Type { case tea.KeyUp: if fm.routeCursor > 0 { fm.routeCursor-- } return fm, nil case tea.KeyDown: if fm.routeCursor+1 < len(route.Hops) { fm.routeCursor++ } return fm, nil case tea.KeyBackspace, tea.KeyDelete: if len(route.Hops) > 0 && fm.routeCursor < len(route.Hops) { route.Hops = append(route.Hops[:fm.routeCursor], route.Hops[fm.routeCursor+1:]...) fm.setCurrentRoute(route) } return fm, nil case tea.KeyRunes: switch key.String() { case "x", "X": if len(route.Hops) > 0 && fm.routeCursor < len(route.Hops) { route.Hops = append(route.Hops[:fm.routeCursor], route.Hops[fm.routeCursor+1:]...) fm.setCurrentRoute(route) } return fm, nil case "[": if fm.routeCursor > 0 && fm.routeCursor < len(route.Hops) { i := fm.routeCursor route.Hops[i-1], route.Hops[i] = route.Hops[i], route.Hops[i-1] fm.routeCursor-- fm.setCurrentRoute(route) } return fm, nil case "]": if fm.routeCursor >= 0 && fm.routeCursor+1 < len(route.Hops) { i := fm.routeCursor route.Hops[i], route.Hops[i+1] = route.Hops[i+1], route.Hops[i] fm.routeCursor++ fm.setCurrentRoute(route) } return fm, nil } } return fm, nil } if key.Type == tea.KeyEnter { if item, ok := fm.routeList.SelectedItem().(routeProfileItem); ok && item.server != nil { if !fm.routeContainsProfile(route, item.server.ID) { route.Hops = append(route.Hops, model.RouteHop{ServerID: item.server.ID, Alias: item.server.Alias, IsProfile: true}) fm.routeCursor = len(route.Hops) - 1 fm.setCurrentRoute(route) } return fm, nil } } var cmd tea.Cmd fm.routeList, cmd = fm.routeList.Update(msg) return fm, cmd } func (fm *formModel) Init() tea.Cmd { return nil } func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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.applySaveError(msg.err) fm.saved = false } else { fm.saved = true fm.savedTime = time.Now() fm.err = nil } return fm, nil } 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 } if fm.showIdentityList { return fm.updateIdentityPicker(msg) } if fm.showTagList { return fm.updateTagPicker(msg) } if fm.showStartupList { return fm.updateStartupPicker(msg) } if fm.showRouteList { return fm.updateRouteEditor(msg) } 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 fm.updateFocus() 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.advanceFocus(1) return fm, nil case tea.KeyShiftTab: fm.advanceFocus(-1) 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 == 6 && fm.usesIdentityFile() { fm.loadIdentityPicker() return fm, nil } if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 7 { fm.showRouteList = true fm.routePane = 1 route := fm.currentRoute() if len(route.Hops) > 0 { fm.routeCursor = len(route.Hops) - 1 } 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 } if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 10 { fm.loadStartupPicker() return fm, nil } if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 11 { fm.loadTagPicker() 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.advanceFocus(1) return fm, nil } case tea.KeyEsc: return fm, nil case tea.KeyDown: fm.advanceFocus(1) return fm, nil case tea.KeyUp: fm.advanceFocus(-1) 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) applySaveError(err error) { fm.err = err if err == nil { return } message := strings.ToLower(err.Error()) switch { case strings.Contains(message, "alias is required"): fm.focusIdx = 0 case strings.Contains(message, "host is required"): fm.focusIdx = 2 case strings.Contains(message, "port"): fm.focusIdx = 3 case strings.Contains(message, "route"): fm.focusIdx = 7 default: return } fm.updateFocus() } func (fm *formModel) updateCredentialPresentation() { fm.passwordLabel = fm.currentPasswordLabel() if !fm.usesSecretInput() { return } fm.password.Placeholder = "optional" if (fm.authMethodValue() == model.AuthPassword && fm.hasSavedPassword) || (fm.authMethodValue() == model.AuthKeyPassphrase && fm.hasSavedPassphrase) { fm.password.Placeholder = "" } prompt := blurredStyle.Render(fm.passwordLabel + ": ") if fm.focusIdx == len(fm.inputs) { prompt = focusedStyle.Render(fm.passwordLabel + "> ") } fm.password.Prompt = prompt } func (fm *formModel) updateFocus() { fm.updateCredentialPresentation() 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.updateCredentialPresentation() } func (fm *formModel) labelAt(index int) string { if index >= 0 && index < len(fm.labels) { switch index { case 0: return "Alias *" case 2: return "Host *" case 3: return "Port *" } if index == 5 { return "Auth Method (/ pick)" } if index == 6 { return "Identity File (/ pick)" } if index == 7 { return "Route (/ edit)" } if index == 10 { return "Startup Command (/ template)" } if index == 8 { if len(fm.groups) > 0 { return "Group (/ pick)" } return "Group" } if index == 11 { return "Tags (/ pick, type to create)" } return fm.labels[index] } return "" } func (fm *formModel) runTest() tea.Cmd { fm.testing = true fm.testResult = "" fm.err = nil fm.saved = false if _, err := parsePort(fm.inputs[3].Value()); err != nil { fm.testing = false return func() tea.Msg { return testDoneMsg{ok: false, err: err.Error()} } } s, err := fm.buildServerValidated() if err != nil { fm.testing = false return func() tea.Msg { return testDoneMsg{ok: false, err: err.Error()} } } 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 = "" if _, err := parsePort(fm.inputs[3].Value()); err != nil { return func() tea.Msg { return saveDoneMsg{err: err} } } s, err := fm.buildServerValidated() if err != nil { return func() tea.Msg { return saveDoneMsg{err: err} } } 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} }, ) } // parseRouteHops parses an explicit route spec. Exact known aliases become // stable profile references; unknown unprefixed values remain raw OpenSSH targets. func parseRouteHops(input string) (model.Route, error) { return model.ParseRouteSpec(input, ResolveRouteAlias) } func (fm *formModel) buildServerValidated() (*model.Server, error) { port, err := parsePort(fm.inputs[3].Value()) if err != nil { return nil, err } authMethod := model.AuthMethod(strings.TrimSpace(fm.inputs[5].Value())) if authMethod == "" { authMethod = model.AuthKey } route, err := fm.parseRouteInput() if err != nil { return nil, fmt.Errorf("route: %w", err) } server := &model.Server{ Alias: strings.TrimSpace(fm.inputs[0].Value()), DisplayName: strings.TrimSpace(fm.inputs[1].Value()), Host: strings.TrimSpace(fm.inputs[2].Value()), Port: port, User: strings.TrimSpace(fm.inputs[4].Value()), AuthMethod: authMethod, IdentityFile: strings.TrimSpace(fm.inputs[6].Value()), ProxyJump: route.ProxyJumpString(), Route: route, GroupName: strings.TrimSpace(fm.inputs[8].Value()), Notes: fm.inputs[9].Value(), StartupCommand: fm.inputs[10].Value(), Tags: splitCSV(fm.inputs[11].Value()), } if fm.edit && fm.server != nil { server.ID = fm.server.ID } if err := model.ValidateServerBasics(server); err != nil { return nil, err } return server, nil } // buildServer is kept as a convenience for view/tests that already populate // valid fields. Save/Test paths use buildServerValidated and surface errors. func (fm *formModel) buildServer() *model.Server { server, _ := fm.buildServerValidated() return server } func parsePort(value string) (int, error) { value = strings.TrimSpace(value) port, err := strconv.Atoi(value) if err != nil { return 0, fmt.Errorf("Port must be a number from 1 to 65535") } if port < 1 || port > 65535 { return 0, fmt.Errorf("Port must be between 1 and 65535") } return port, nil } func (fm *formModel) View() string { fm.updateCredentialPresentation() title := "Add Server" if fm.edit { title = "Edit Server: " + fm.server.Alias } if fm.showIdentityList { return renderScreenShell(screenShell{ breadcrumb: title + " / Identity File", status: "Choose a private key", width: fm.width, height: fm.height, body: func(width, height int) string { return renderPaddedPanel(width, height, splitBlock(renderDropdown(fm.identityList))) }, footer: []helpItem{{Key: "/", Action: "filter"}, {Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, }) } if fm.showStartupList { return renderScreenShell(screenShell{ breadcrumb: title + " / Startup Command", status: "Choose a command template", width: fm.width, height: fm.height, body: func(width, height int) string { lines := []string{fm.inputs[10].View(), ""} if len(fm.startupList.Items()) == 0 { lines = append(lines, dashboardHelp("No command templates yet. Use Manage → Command templates to create one.")) } else { capacity := max(1, height-4) start, end := visibleServerRange(len(fm.startupList.Items()), fm.startupList.Index(), capacity) for index := start; index < end; index++ { item, ok := fm.startupList.Items()[index].(templateItem) if !ok || item.template == nil { continue } marker := " " if index == fm.startupList.Index() { marker = "> " } lines = append(lines, marker+item.template.Name+" "+item.template.Command) } } return renderPaddedPanel(width, height, lines) }, footer: []helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "insert copy"}, {Key: "Esc", Action: "cancel"}}, }) } if fm.showTagList { return fm.tagPickerView(title) } if fm.showRouteList { return fm.routeEditorView(title) } if fm.showAuthList || fm.showGroupList { var dropdown list.Model fieldIndex := 8 if fm.showAuthList { dropdown = fm.authList fieldIndex = 5 } else { dropdown = fm.groupList } return renderScreenShell(screenShell{ breadcrumb: title + " / Picker", status: "Choose a value", width: fm.width, height: fm.height, body: func(width, height int) string { lines := []string{fm.inputs[fieldIndex].View(), ""} lines = append(lines, splitBlock(renderDropdown(dropdown))...) return renderPaddedPanel(width, height, lines) }, footer: []helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}}, }) } status := fm.formStatusLine() testBtn, saveBtn := " [ Test ]", " [ Save ]" if fm.focusIdx == len(fm.inputs)+1 { testBtn = selectedStyle.Render("> [ Test ]") } if fm.focusIdx == len(fm.inputs)+2 { saveBtn = selectedStyle.Render("> [ Save ]") } actions := testBtn + " " + saveBtn body := func(width, height int) string { richLayout := width >= 90 && height >= 20 allFields := make([]string, 0, len(fm.inputs)+5) focusRows := make([]int, len(fm.inputs)+1) for i := range fm.inputs { if i == 6 && !fm.usesIdentityFile() { continue } if richLayout { if section := formSectionTitle(i); section != "" { allFields = append(allFields, sectionStyle.Copy().MarginTop(0).Render(section)) } } if i == 5 { fm.inputs[i].Placeholder = "password/key/key_passphrase/agent" } if i == 8 && len(fm.groups) > 0 { fm.inputs[i].Placeholder = truncateCells(strings.Join(fm.groups, ", "), 25) } focusRows[i] = len(allFields) allFields = append(allFields, fm.inputs[i].View()) } if fm.usesSecretInput() { focusRows[len(fm.inputs)] = len(allFields) allFields = append(allFields, fm.password.View()) } focusField := max(0, len(allFields)-1) if fm.focusIdx <= len(fm.inputs) && fm.focusAllowed(fm.focusIdx) { focusField = focusRows[fm.focusIdx] } actionRows := 1 if richLayout { actionRows = 2 } fieldRows := max(1, height-2-actionRows) start, end := visibleServerRange(len(allFields), focusField, fieldRows) visible := append([]string(nil), allFields[start:end]...) if start > 0 && len(visible) > 0 { visible[0] = "↑ more fields · " + visible[0] } if end < len(allFields) && len(visible) > 0 { visible[len(visible)-1] += " · more ↓" } if richLayout { visible = append(visible, sectionStyle.Copy().MarginTop(0).Render("Actions")) } visible = append(visible, actions) return renderPaddedPanel(width, height, visible) } return renderScreenShell(screenShell{ breadcrumb: title, status: "Server profile", notification: status, width: fm.width, height: fm.height, body: body, footer: []helpItem{ {Key: "Tab/↓", Action: "next"}, {Key: "↑", Action: "prev"}, {Key: "/", Action: "pick list"}, {Key: "Enter", Action: "select"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}, }, }) } func (fm *formModel) tagPickerView(title string) string { selected := splitCSV(fm.inputs[11].Value()) body := func(width, height int) string { lines := []string{dashboardSection("Existing tags")} if len(fm.tagList.Items()) == 0 { lines = append(lines, dashboardHelp("No saved tags yet. Esc and type a new tag in the field.")) } else { capacity := max(1, height-4) start, end := visibleServerRange(len(fm.tagList.Items()), fm.tagList.Index(), capacity) for index := start; index < end; index++ { item, ok := fm.tagList.Items()[index].(groupItem) if !ok { continue } mark := "[ ]" if containsString(selected, item.name) { mark = "[x]" } line := " " + mark + " " + item.name if index == fm.tagList.Index() { line = selectedRowStyle.Render("> " + mark + " " + item.name) } lines = append(lines, line) } } return renderPaddedPanel(width, height, lines) } return renderScreenShell(screenShell{ breadcrumb: title + " / Tags", status: fmt.Sprintf("%d selected", len(selected)), width: fm.width, height: fm.height, body: body, footer: []helpItem{{Key: "/", Action: "filter"}, {Key: "↑/↓", Action: "move"}, {Key: "Space/Enter", Action: "toggle"}, {Key: "Esc", Action: "done"}}, }) } func (fm *formModel) routeEditorView(title string) string { route := fm.currentRoute() body := func(width, height int) string { lines := []string{dashboardSection("Current route")} if len(route.Hops) == 0 { line := " Direct connection" if fm.routePane == 0 { line = selectedRowStyle.Render("> Direct connection") } lines = append(lines, line) } else { for index, hop := range route.Hops { kind := "raw" name := hop.Raw if hop.Profile() { kind = "profile" name = hop.Alias } prefix := " " line := fmt.Sprintf("%s%d. %-24s [%s]", prefix, index+1, name, kind) if fm.routePane == 0 && index == fm.routeCursor { line = selectedRowStyle.Render(fmt.Sprintf("> %d. %-24s [%s]", index+1, name, kind)) } lines = append(lines, line) } } target := strings.TrimSpace(fm.inputs[2].Value()) if target == "" { target = "target" } lines = append(lines, dashboardHelp("Preview: "+route.DisplaySummary(target)), "", dashboardSection("Available server profiles")) if len(fm.routeList.Items()) == 0 { lines = append(lines, dashboardHelp("No other server profiles are available.")) } else { capacity := max(1, height-len(lines)-4) start, end := visibleServerRange(len(fm.routeList.Items()), fm.routeList.Index(), capacity) for index := start; index < end; index++ { item, ok := fm.routeList.Items()[index].(routeProfileItem) if !ok || item.server == nil { continue } prefix := " " mark := " " if fm.routeContainsProfile(route, item.server.ID) { mark = "✓" } target := item.server.Host if item.server.User != "" { target = item.server.User + "@" + item.server.Host } line := fmt.Sprintf("%s%s %-20s %s:%d", prefix, mark, item.server.Alias, target, item.server.Port) if fm.routePane == 1 && index == fm.routeList.Index() { line = selectedRowStyle.Render(fmt.Sprintf("> %s %-20s %s:%d", mark, item.server.Alias, target, item.server.Port)) } lines = append(lines, fitLine(line, max(1, width-4))) } } lines = append(lines, "", dashboardHelp("Need a host that is not a sshkeeper profile? Esc and type raw: in the Route field.")) return renderPaddedPanel(width, height, lines) } pane := "profiles" if fm.routePane == 0 { pane = "current route" } return renderScreenShell(screenShell{ breadcrumb: title + " / Route Editor", status: "Editing " + pane, width: fm.width, height: fm.height, body: body, footer: []helpItem{ {Key: "Tab", Action: "switch pane"}, {Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "add profile"}, {Key: "x/Del", Action: "remove hop"}, {Key: "[/]", Action: "reorder hop"}, {Key: "/", Action: "filter profiles"}, {Key: "Esc", Action: "done"}, }, }) } func (fm *formModel) formStatusLine() string { if fm.err != nil { return errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) } if fm.testing { return fm.spinner.View() + " Testing connection..." } if fm.saving { return fm.spinner.View() + " Saving..." } showResults := time.Since(fm.testResultTime) < 10*time.Second || time.Since(fm.savedTime) < 10*time.Second if showResults && fm.testResult != "" { if fm.testOK { return testOKStyle.Render("✓ " + fm.testResult) } return testFailStyle.Render("✗ " + strings.ReplaceAll(fm.testResult, "\n", " ")) } if showResults && fm.saved { return successStyle.Render("✓ Saved.") } return "" } func renderDropdown(l list.Model) string { var b strings.Builder b.WriteString(dashboardSection(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 "" } }