package tui import ( "fmt" "strings" "time" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/mirivlad/sshkeeper/internal/model" ) // --- Styles --- var ( titleStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("12")). MarginLeft(2) selectedStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("15")). Background(lipgloss.Color("4")). Bold(true) normalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) selectedRowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Background(lipgloss.Color("4")) listHeaderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) sectionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true).MarginTop(1) testOKStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true) testFailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true) helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).MarginLeft(2) hotkeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Bold(true) helpTextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true) successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true) focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true) blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) ) // --- Messages --- type serversLoadedMsg struct { servers []*model.Server err error } type testDoneMsg struct { ok bool err string } type saveDoneMsg struct { err error } type templatesLoadedMsg struct { templates []*model.CommandTemplate deleted bool deletedName string err error } type tagsLoadedMsg struct { tags []string deleted bool deletedName string err error } type backgroundRunDoneMsg struct { results []templateRunResult } type connectRequestMsg struct { server *model.Server } type templateRunRequestMsg struct { servers []*model.Server templateName string command string } type forwardsLoadedMsg struct { forwards []*model.Forward err error } type forwardDeletedMsg struct { id int64 err error } type serverDeletedMsg struct { alias string servers []*model.Server deleted bool err error } type quitAfterDiscardMsg struct{} type discardFormMsg struct { origin screen } type importDoneMsg struct { servers []*model.Server count int err error } // --- List items --- type serverItem struct { server *model.Server } func (i serverItem) Title() string { return i.server.Alias } func (i serverItem) Description() string { target := fmt.Sprintf("%s@%s:%d", i.server.User, i.server.Host, i.server.Port) routeStr := i.server.Route.DisplaySummary(target) return fmt.Sprintf("%s %s", routeStr, i.server.AuthMethod) } func (i serverItem) FilterValue() string { parts := []string{ i.server.Alias, i.server.DisplayName, i.server.Host, i.server.User, i.server.GroupName, i.server.Notes, i.server.ProxyJump, strings.Join(i.server.Tags, " "), } for _, h := range i.server.Route.Hops { parts = append(parts, h.Alias, h.Raw) } return strings.Join(parts, " ") } type templateItem struct { template *model.CommandTemplate } func (i templateItem) Title() string { return i.template.Name } func (i templateItem) Description() string { return i.template.Command } func (i templateItem) FilterValue() string { return i.template.Name + " " + i.template.Command + " " + i.template.Description } type templateRunResult struct { Alias string Output string Err string } type helpItem struct { Key string Action string } // --- External callbacks --- var ( ListServers func() ([]*model.Server, error) SearchServers func(query string) ([]*model.Server, error) DeleteServer func(alias string) error TestConnection func(server *model.Server) (bool, string) // TestConnectionWithPassword tests with explicit password (for form test before save) TestConnectionWithPassword func(server *model.Server, password string) (bool, string) SaveServer func(server *model.Server, password string, oldAlias string) error UpdateTestResult func(alias string, status model.TestStatus, testErr string) error HasSecret func(alias string, secretType string) bool GetGroups func() ([]string, error) RenameGroup func(oldName, newName string) error DeleteGroup func(name string) error ListTags func() ([]string, error) RenameTag func(oldName, newName string) error DeleteTag func(name string) error SetServerTags func(server *model.Server, tags []string) error ListCommandTemplates func() ([]*model.CommandTemplate, error) SaveCommandTemplate func(oldName string, template *model.CommandTemplate) error DeleteCommandTemplate func(name string) error RunTemplateBackground func(server *model.Server, command string) (string, error) ListForwards func(serverID int64) ([]*model.Forward, error) SaveForward func(fwd *model.Forward) error UpdateForward func(fwd *model.Forward) error DeleteForward func(forwardID int64) error ImportServers func() (int, error) LockVault func() error VaultUnlocked func() bool ) // --- Screen type --- type screen int const ( screenList screen = iota screenForm screenSearch screenTags screenTagInput screenTemplates screenTemplateForm screenTemplatePicker screenTemplateMode screenBackgroundResults screenHelp screenActionMenu screenForwardList screenForwardForm screenTunnelManager screenConfirm screenFullHelp ) type confirmChoice int const ( confirmCancel confirmChoice = iota confirmAccept ) type confirmState struct { title string target string consequence string verb string parent screen complete screen completeSet bool focus confirmChoice pending bool action func() tea.Cmd } // --- Result type — returned from TUI to caller --- type TUIResult struct { Server *model.Server Servers []*model.Server Action string // "connect" or "run_template_foreground" Command string TemplateName string } // --- Main TUI model --- type tuiModel struct { screen screen list list.Model servers []*model.Server searchInput textinput.Model form *formModel templateForm *templateFormModel templates []*model.CommandTemplate templateList list.Model pendingTemplate *model.CommandTemplate tagList list.Model tags []string tagInput textinput.Model tagMode string tagOldName string selected map[string]bool tunnelScreen *tunnelScreenModel bgResults []templateRunResult err error success string width int height int result *TUIResult helpScreen *helpScreenModel actionMenu *actionMenuModel forwardScreen *forwardScreenModel forwardForm *forwardFormModel confirm *confirmState fullHelp *fullHelpModel helpParent screen vaultUnlocked bool } func New(servers []*model.Server) *tuiModel { items := make([]list.Item, len(servers)) for i, s := range servers { items[i] = serverItem{server: s} } l := list.New(items, list.NewDefaultDelegate(), 0, 0) l.Title = "sshkeeper" l.SetShowStatusBar(false) l.SetFilteringEnabled(false) l.Styles.Title = titleStyle search := textinput.New() search.Placeholder = "Search..." search.CharLimit = 64 tagInput := textinput.New() tagInput.Placeholder = "tag" tagInput.CharLimit = 64 templateList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) templateList.SetShowStatusBar(false) templateList.SetFilteringEnabled(false) templateList.SetShowHelp(false) tagList := newStringList(nil, "Tags", 0, 0) vaultIsUnlocked := true if VaultUnlocked != nil { vaultIsUnlocked = VaultUnlocked() } return &tuiModel{ screen: screenList, list: l, servers: servers, searchInput: search, selected: map[string]bool{}, tagInput: tagInput, templateList: templateList, tagList: tagList, vaultUnlocked: vaultIsUnlocked, } } func (m *tuiModel) Result() *TUIResult { return m.result } func (m *tuiModel) Init() tea.Cmd { return nil } func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.list.SetSize(msg.Width, msg.Height-4) if m.form != nil { m.form.width = msg.Width m.form.height = msg.Height } if m.templateForm != nil { m.templateForm.width = msg.Width m.templateForm.height = msg.Height } if m.forwardScreen != nil { m.forwardScreen.width = msg.Width m.forwardScreen.height = msg.Height } if m.forwardForm != nil { m.forwardForm.width = msg.Width m.forwardForm.height = msg.Height } if m.tunnelScreen != nil { m.tunnelScreen.width = msg.Width m.tunnelScreen.height = msg.Height m.tunnelScreen.list.SetSize(msg.Width, managerListHeight(msg.Height)) } if m.helpScreen != nil { updated, _ := m.helpScreen.Update(msg) if help, ok := updated.(*helpScreenModel); ok { m.helpScreen = help } } if m.fullHelp != nil { m.fullHelp.width = msg.Width m.fullHelp.height = msg.Height } if m.actionMenu != nil { m.actionMenu.width = msg.Width m.actionMenu.height = msg.Height m.actionMenu.list.SetSize(msg.Width, managerListHeight(msg.Height)) } m.templateList.SetSize(msg.Width, managerListHeight(msg.Height)) m.tagList.SetSize(msg.Width, managerListHeight(msg.Height)) return m, nil case serversLoadedMsg: if msg.err != nil { m.err = msg.err } else { m.servers = msg.servers items := make([]list.Item, len(msg.servers)) for i, s := range msg.servers { items[i] = serverItem{server: s} } m.list.SetItems(items) } return m, nil case templatesLoadedMsg: if m.confirm != nil && m.confirm.pending && m.confirm.parent == screenTemplates { m.finishConfirm() } if msg.err != nil { if msg.deleted { m.removeTemplate(msg.deletedName) m.err = nil m.success = fmt.Sprintf("Deleted %q; refresh failed: %v", msg.deletedName, msg.err) } else { m.err = msg.err } return m, nil } m.setTemplates(msg.templates) return m, nil case tagsLoadedMsg: if m.confirm != nil && m.confirm.pending && m.confirm.parent == screenTags { m.finishConfirm() } if msg.err != nil { if msg.deleted { m.removeTag(msg.deletedName) m.err = nil m.success = fmt.Sprintf("Deleted %q; refresh failed: %v", msg.deletedName, msg.err) } else { m.err = msg.err } return m, nil } m.setTags(msg.tags) return m, nil case connectRequestMsg: m.result = &TUIResult{ Server: msg.server, Action: "connect", } return m, tea.Quit case templateRunRequestMsg: m.result = &TUIResult{ Servers: msg.servers, Action: "run_template_foreground", Command: msg.command, TemplateName: msg.templateName, } if len(msg.servers) == 1 { m.result.Server = msg.servers[0] } return m, tea.Quit case backgroundRunDoneMsg: m.bgResults = msg.results m.screen = screenList m.pendingTemplate = nil return m, nil case importDoneMsg: if msg.err != nil { m.err = msg.err return m, nil } m.servers = msg.servers items := make([]list.Item, len(msg.servers)) for i, s := range msg.servers { items[i] = serverItem{server: s} } m.list.SetItems(items) m.success = fmt.Sprintf("Imported %d server(s).", msg.count) return m, nil case forwardsLoadedMsg: if m.forwardScreen != nil { if msg.err != nil { m.forwardScreen.err = msg.err } else { m.forwardScreen.list = msg.forwards if len(msg.forwards) > 0 && m.forwardScreen.selected < 0 { m.forwardScreen.selected = 0 } } } return m, nil case forwardDeletedMsg: m.finishConfirm() if m.forwardScreen != nil { if msg.err != nil { m.forwardScreen.err = msg.err return m, nil } m.forwardScreen.err = nil forwards := make([]*model.Forward, 0, len(m.forwardScreen.list)) for _, forward := range m.forwardScreen.list { if forward.ID != msg.id { forwards = append(forwards, forward) } } m.forwardScreen.list = forwards if m.forwardScreen.selected >= len(forwards) { m.forwardScreen.selected = max(0, len(forwards)-1) } return m, m.forwardScreen.loadForwards() } return m, nil case serverDeletedMsg: m.finishConfirm() if msg.deleted { m.removeServer(msg.alias) } if msg.err != nil { if msg.deleted { m.err = nil m.success = fmt.Sprintf("Deleted %q; refresh failed: %v", msg.alias, msg.err) } else { m.err = msg.err } return m, nil } m.servers = msg.servers items := make([]list.Item, len(msg.servers)) for i, server := range msg.servers { items[i] = serverItem{server: server} } m.list.SetItems(items) delete(m.selected, msg.alias) return m, nil case quitAfterDiscardMsg: m.confirm = nil m.form = nil m.forwardForm = nil m.templateForm = nil return m, tea.Quit case discardFormMsg: m.finishConfirm() switch msg.origin { case screenForm: m.form = nil case screenForwardForm: m.forwardForm = nil case screenTemplateForm: m.templateForm = nil } return m, nil case forwardDeleteConfirmMsg: m.beginConfirm(confirmState{ title: "Delete port forward?", target: msg.name, consequence: "This removes the saved forwarding rule.", verb: "Delete", parent: screenForwardList, action: func() tea.Cmd { return func() tea.Msg { if DeleteForward == nil { return forwardDeletedMsg{id: msg.id, err: fmt.Errorf("forward deletion is unavailable")} } return forwardDeletedMsg{id: msg.id, err: DeleteForward(msg.id)} } }, }) return m, nil case forwardEditSignal: if m.forwardScreen != nil { if m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) { fwd := m.forwardScreen.list[m.forwardScreen.selected] m.forwardForm = newForwardEditModel(m.forwardScreen.serverID, fwd, m.width, m.height) m.screen = screenForwardForm } } return m, nil case tunnelsLoadedMsg: if m.tunnelScreen != nil { m.tunnelScreen.tunnels = nil for _, item := range msg.items { if ti, ok := item.(tunnelItem); ok { m.tunnelScreen.tunnels = append(m.tunnelScreen.tunnels, ti.state) } } m.tunnelScreen.rebuildList() } return m, nil case tunnelStoppedMsg: m.finishConfirm() if m.tunnelScreen != nil { if msg.err != nil { m.tunnelScreen.err = msg.err return m, nil } m.tunnelScreen.err = nil return m, m.tunnelScreen.loadTunnels() } return m, nil case testDoneMsg: if m.form != nil { m.form.testing = false if msg.ok { m.form.testResult = "Connection OK." m.form.testOK = true } else { m.form.testResult = fmt.Sprintf("Connection failed:\n%s", msg.err) m.form.testOK = false } m.form.testResultTime = time.Now() m.form.err = nil return m, nil } if item, ok := m.list.SelectedItem().(serverItem); ok && UpdateTestResult != nil { status := model.TestUnknown if msg.ok { status = model.TestOK } else if msg.err != "" { status = model.TestFailed } UpdateTestResult(item.server.Alias, status, msg.err) } return m, func() tea.Msg { servers, err := ListServers() return serversLoadedMsg{servers: servers, err: err} } case saveDoneMsg: if m.forwardForm != nil { if msg.err != nil { m.forwardForm.applySaveError(msg.err) m.forwardForm.saved = false // Stay on screenForwardForm to show error return m, nil } m.forwardForm.saved = true // Return to forward list and reload m.forwardForm = nil m.screen = screenForwardList if m.forwardScreen != nil { return m, m.forwardScreen.loadForwards() } return m, nil } if m.templateForm != nil { if msg.err != nil { m.templateForm.err = msg.err m.templateForm.saved = false } else { m.templateForm.saved = true } if m.templateForm.saved { m.screen = screenTemplates m.templateForm = nil return m, m.loadTemplatesCmd() } return m, nil } if m.form != nil { m.form.saving = false if msg.err != nil { m.form.applySaveError(msg.err) m.form.saved = false } else { m.form.saved = true m.form.savedTime = time.Now() m.form.err = nil m.form.password.SetValue("") m.form.initial = m.form.snapshot() } } return m, nil case tea.KeyMsg: if m.err != nil || m.success != "" { m.err = nil m.success = "" } if msg.Type == tea.KeyCtrlH && m.screen != screenHelp && m.screen != screenFullHelp && m.screen != screenConfirm { m.helpParent = m.screen m.fullHelp = newFullHelpModel(m.width, m.height) m.screen = screenFullHelp return m, nil } if msg.Type == tea.KeyCtrlQ && m.screen != screenConfirm { return m.requestQuit() } if msg.Type == tea.KeyRunes && msg.String() == "?" && !m.screenOwnsPrintableInput() && m.screen != screenHelp && m.screen != screenFullHelp && m.screen != screenConfirm { m.helpParent = m.screen m.helpScreen = newHelpScreenModel(m.width, m.height) m.screen = screenHelp return m, nil } switch m.screen { case screenList: return m.updateList(msg) case screenForm: return m.updateForm(msg) case screenSearch: return m.updateSearch(msg) case screenTags: return m.updateTags(msg) case screenTagInput: return m.updateTagInput(msg) case screenTemplates: return m.updateTemplates(msg) case screenTemplateForm: return m.updateTemplateForm(msg) case screenTemplatePicker: return m.updateTemplatePicker(msg) case screenTemplateMode: return m.updateTemplateMode(msg) case screenBackgroundResults: return m.updateBackgroundResults(msg) case screenHelp: return m.updateHelp(msg) case screenActionMenu: return m.updateActionMenu(msg) case screenForwardList: return m.updateForwardList(msg) case screenForwardForm: return m.updateForwardForm(msg) case screenTunnelManager: return m.updateTunnelManager(msg) case screenConfirm: return m.updateConfirm(msg) case screenFullHelp: return m.updateFullHelp(msg) } } return m, nil } func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEsc: if len(m.bgResults) > 0 { m.bgResults = nil return m, nil } case tea.KeyEnter: if item, ok := m.list.SelectedItem().(serverItem); ok { return m, func() tea.Msg { return connectRequestMsg{server: item.server} } } case tea.KeyInsert: if item, ok := m.list.SelectedItem().(serverItem); ok { if m.selected[item.server.Alias] { delete(m.selected, item.server.Alias) } else { m.selected[item.server.Alias] = true } if m.list.Index() < len(m.servers)-1 { m.list.Select(m.list.Index() + 1) } } return m, nil case tea.KeyCtrlC, tea.KeyCtrlQ: return m, tea.Quit case tea.KeyCtrlA: m.form = newFormModel(m.width, m.height) m.screen = screenForm return m, nil case tea.KeyCtrlE: if item, ok := m.list.SelectedItem().(serverItem); ok { m.form = newEditFormModel(item.server, m.width, m.height) m.screen = screenForm } return m, nil case tea.KeyCtrlD: if item, ok := m.list.SelectedItem().(serverItem); ok { m.confirmServerDelete(item.server) return m, nil } case tea.KeyCtrlT: if item, ok := m.list.SelectedItem().(serverItem); ok { return m, func() tea.Msg { ok, testErr := TestConnection(item.server) return testDoneMsg{ok: ok, err: testErr} } } case tea.KeyCtrlF, tea.KeyCtrlS: m.screen = screenSearch m.searchInput.Focus() return m, nil case tea.KeyCtrlG: m.screen = screenTags return m, m.loadTagsCmd() case tea.KeyCtrlP: m.screen = screenTemplates return m, m.loadTemplatesCmd() case tea.KeyCtrlR: return m.openTemplatePicker() case tea.KeyRunes: if msg.String() == "?" { m.helpParent = m.screen m.helpScreen = newHelpScreenModel(m.width, m.height) m.screen = screenHelp return m, nil } case tea.KeyCtrlW: // Open forward manager for selected server if item, ok := m.list.SelectedItem().(serverItem); ok { m.forwardScreen = newForwardScreenModel(item.server.ID, item.server.Alias, m.width, m.height) m.screen = screenForwardList return m, m.forwardScreen.loadForwards() } case tea.KeyCtrlX: m.actionMenu = newActionMenuModel(m.width, m.height) m.screen = screenActionMenu return m, nil default: var cmd tea.Cmd m.list, cmd = m.list.Update(msg) return m, cmd } return m, nil } func (m *tuiModel) requestQuit() (tea.Model, tea.Cmd) { dirty := false switch m.screen { case screenForm: dirty = m.form != nil && m.form.Dirty() case screenForwardForm: dirty = m.forwardForm != nil && m.forwardForm.Dirty() case screenTemplateForm: dirty = m.templateForm != nil && m.templateForm.Dirty() } if !dirty { return m, tea.Quit } origin := m.screen m.beginConfirm(confirmState{ title: "Discard changes and quit?", target: "Unsaved form changes", consequence: "Your edits will be lost before sshkeeper exits.", verb: "Quit", parent: origin, action: func() tea.Cmd { return func() tea.Msg { return quitAfterDiscardMsg{} } }, }) return m, nil } func (m *tuiModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEsc: m.screen = screenList m.searchInput.Blur() m.searchInput.SetValue("") return m, nil case tea.KeyEnter: m.screen = screenList m.searchInput.Blur() query := m.searchInput.Value() if query != "" { return m, func() tea.Msg { servers, err := SearchServers(query) return serversLoadedMsg{servers: servers, err: err} } } return m, func() tea.Msg { servers, err := ListServers() return serversLoadedMsg{servers: servers, err: err} } default: var cmd tea.Cmd m.searchInput, cmd = m.searchInput.Update(msg) return m, cmd } } func (m *tuiModel) updateTags(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEsc: m.screen = screenList return m, m.reloadServersCmd() case tea.KeyCtrlA: m.tagMode = "add" m.tagOldName = "" m.tagInput.SetValue("") m.tagInput.Focus() m.screen = screenTagInput return m, nil case tea.KeyCtrlE: if item, ok := m.tagList.SelectedItem().(groupItem); ok { m.tagMode = "rename" m.tagOldName = item.name m.tagInput.SetValue(item.name) m.tagInput.Focus() m.screen = screenTagInput } return m, nil case tea.KeyCtrlD: if item, ok := m.tagList.SelectedItem().(groupItem); ok { name := item.name m.beginConfirm(confirmState{ title: "Delete tag?", target: fmt.Sprintf("%q", name), consequence: "This removes the tag from every server profile.", verb: "Delete", parent: screenTags, action: func() tea.Cmd { return func() tea.Msg { if DeleteTag == nil { return tagsLoadedMsg{err: fmt.Errorf("tag deletion is unavailable")} } if err := DeleteTag(name); err != nil { return tagsLoadedMsg{err: err} } if ListTags == nil { return tagsLoadedMsg{deleted: true, deletedName: name, err: fmt.Errorf("tag reload is unavailable")} } tags, err := ListTags() return tagsLoadedMsg{tags: tags, deleted: true, deletedName: name, err: err} } }, }) return m, nil } case tea.KeyEnter: if item, ok := m.tagList.SelectedItem().(groupItem); ok && SetServerTags != nil { servers := m.selectedServers() if len(servers) == 0 { if selected := m.selectedServer(); selected != nil { servers = []*model.Server{selected} } } tag := item.name return m, func() tea.Msg { for _, server := range servers { tags := toggleString(server.Tags, tag) if err := SetServerTags(server, tags); err != nil { return tagsLoadedMsg{err: err} } } loaded, err := ListTags() return tagsLoadedMsg{tags: loaded, err: err} } } } var cmd tea.Cmd m.tagList, cmd = m.tagList.Update(msg) return m, cmd } func (m *tuiModel) updateTagInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEsc: m.screen = screenTags return m, nil case tea.KeyEnter: value := strings.TrimSpace(m.tagInput.Value()) if value == "" { m.screen = screenTags return m, nil } mode := m.tagMode oldName := m.tagOldName return m, func() tea.Msg { if mode == "rename" && RenameTag != nil { if err := RenameTag(oldName, value); err != nil { return tagsLoadedMsg{err: err} } } else if SetServerTags != nil { servers := m.selectedServers() if len(servers) == 0 { if selected := m.selectedServer(); selected != nil { servers = []*model.Server{selected} } } for _, server := range servers { tags := append(splitCSV(strings.Join(server.Tags, ",")), value) if err := SetServerTags(server, tags); err != nil { return tagsLoadedMsg{err: err} } } } tags, err := ListTags() return tagsLoadedMsg{tags: tags, err: err} } } var cmd tea.Cmd m.tagInput, cmd = m.tagInput.Update(msg) return m, cmd } func (m *tuiModel) updateTemplates(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEsc: m.screen = screenList return m, nil case tea.KeyCtrlA: m.templateForm = newTemplateFormModel(nil, m.width, m.height) m.screen = screenTemplateForm return m, nil case tea.KeyCtrlE: if item, ok := m.templateList.SelectedItem().(templateItem); ok { m.templateForm = newTemplateFormModel(item.template, m.width, m.height) m.screen = screenTemplateForm } return m, nil case tea.KeyCtrlD: if item, ok := m.templateList.SelectedItem().(templateItem); ok { name := item.template.Name m.beginConfirm(confirmState{ title: "Delete command template?", target: fmt.Sprintf("%q", name), consequence: "This removes the saved command template.", verb: "Delete", parent: screenTemplates, action: func() tea.Cmd { return func() tea.Msg { if DeleteCommandTemplate == nil { return templatesLoadedMsg{err: fmt.Errorf("template deletion is unavailable")} } if err := DeleteCommandTemplate(name); err != nil { return templatesLoadedMsg{err: err} } if ListCommandTemplates == nil { return templatesLoadedMsg{deleted: true, deletedName: name, err: fmt.Errorf("template reload is unavailable")} } templates, err := ListCommandTemplates() return templatesLoadedMsg{templates: templates, deleted: true, deletedName: name, err: err} } }, }) return m, nil } } var cmd tea.Cmd m.templateList, cmd = m.templateList.Update(msg) return m, cmd } func (m *tuiModel) updateTemplateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if msg.Type == tea.KeyEsc { if m.templateForm != nil && m.templateForm.Dirty() { m.confirmDiscard("Command template", screenTemplateForm, screenTemplates) return m, nil } m.screen = screenTemplates m.templateForm = nil return m, nil } updated, cmd := m.templateForm.Update(msg) if tf, ok := updated.(*templateFormModel); ok { m.templateForm = tf if tf.saved { m.screen = screenTemplates m.templateForm = nil return m, m.loadTemplatesCmd() } } return m, cmd } func (m *tuiModel) updateTemplatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEsc: m.screen = screenList return m, nil case tea.KeyEnter: if item, ok := m.templateList.SelectedItem().(templateItem); ok { m.pendingTemplate = item.template m.screen = screenTemplateMode } return m, nil } var cmd tea.Cmd m.templateList, cmd = m.templateList.Update(msg) return m, cmd } func (m *tuiModel) updateTemplateMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.pendingTemplate == nil { m.screen = screenList return m, nil } switch msg.Type { case tea.KeyCtrlB: servers := m.targetServers() tpl := m.pendingTemplate return m, func() tea.Msg { results := make([]templateRunResult, 0, len(servers)) for _, server := range servers { output, err := RunTemplateBackground(server, tpl.Command) result := templateRunResult{Alias: server.Alias, Output: strings.TrimSpace(output)} if err != nil { result.Err = err.Error() } results = append(results, result) } return backgroundRunDoneMsg{results: results} } case tea.KeyCtrlF, tea.KeyEnter: servers := m.targetServers() tpl := m.pendingTemplate return m, func() tea.Msg { return templateRunRequestMsg{servers: servers, templateName: tpl.Name, command: tpl.Command} } case tea.KeyEsc: m.screen = screenTemplatePicker return m, nil case tea.KeyRunes: switch msg.String() { case "b", "B": servers := m.targetServers() tpl := m.pendingTemplate return m, func() tea.Msg { results := make([]templateRunResult, 0, len(servers)) for _, server := range servers { output, err := RunTemplateBackground(server, tpl.Command) result := templateRunResult{Alias: server.Alias, Output: strings.TrimSpace(output)} if err != nil { result.Err = err.Error() } results = append(results, result) } return backgroundRunDoneMsg{results: results} } case "f", "F": servers := m.targetServers() tpl := m.pendingTemplate 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 } } } return m, nil } func (m *tuiModel) updateBackgroundResults(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEsc, tea.KeyEnter: m.screen = screenList m.pendingTemplate = nil return m, m.reloadServersCmd() } return m, nil } func (m *tuiModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if msg.Type == tea.KeyEsc { if m.form != nil && (m.form.showGroupList || m.form.showAuthList) { updated, cmd := m.form.Update(msg) if fm, ok := updated.(*formModel); ok { m.form = fm } return m, cmd } if m.form != nil && m.form.Dirty() { m.confirmDiscard("Server profile", screenForm, screenList) return m, nil } m.screen = screenList m.form = nil m.err = nil m.success = "" return m, func() tea.Msg { servers, err := ListServers() return serversLoadedMsg{servers: servers, err: err} } } updated, cmd := m.form.Update(msg) if fm, ok := updated.(*formModel); ok { m.form = fm } return m, cmd } func (m *tuiModel) View() string { if classifyTerminal(m.width, m.height) == sizeBelowFloor { return minimumSizeView(m.width) } var b strings.Builder switch m.screen { case screenList: b.WriteString(m.viewServerList()) case screenSearch: b.WriteString(m.viewSearch()) case screenForm: b.WriteString(m.form.View()) case screenTags: b.WriteString(m.viewTags()) case screenTagInput: b.WriteString(m.viewTagInput()) case screenTemplates: b.WriteString(m.viewTemplates()) case screenTemplateForm: b.WriteString(m.templateForm.View()) case screenTemplatePicker: b.WriteString(m.viewTemplatePicker()) case screenTemplateMode: b.WriteString(m.viewTemplateMode()) case screenBackgroundResults: b.WriteString(m.viewBackgroundResults()) case screenHelp: if m.helpScreen != nil { b.WriteString(m.helpScreen.View()) } case screenFullHelp: if m.fullHelp != nil { b.WriteString(m.fullHelp.View()) } case screenActionMenu: if m.actionMenu != nil { b.WriteString(m.actionMenu.View()) } case screenForwardList: if m.forwardScreen != nil { b.WriteString(m.forwardScreen.View()) } case screenForwardForm: if m.forwardForm != nil { b.WriteString(m.forwardForm.View()) } case screenTunnelManager: if m.tunnelScreen != nil { b.WriteString(m.tunnelScreen.View()) } case screenConfirm: b.WriteString(m.viewConfirm()) } return b.String() } func (m *tuiModel) updateHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) { updated, cmd := m.helpScreen.Update(msg) if hs, ok := updated.(*helpScreenModel); ok { m.helpScreen = hs } // Esc or Enter closes help if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEnter || (msg.Type == tea.KeyRunes && (msg.String() == "q" || msg.String() == "Q" || msg.String() == "?")) { m.screen = m.helpParent m.helpScreen = nil return m, nil } return m, cmd } func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { updated, action := m.actionMenu.Update(msg) m.actionMenu = updated if msg.Type == tea.KeyEsc { m.screen = screenList m.actionMenu = nil return m, nil } if action != nil { switch *action { case "connect": if item, ok := m.list.SelectedItem().(serverItem); ok { m.screen = screenList m.actionMenu = nil return m, func() tea.Msg { return connectRequestMsg{server: item.server} } } case "tunnel": if item, ok := m.list.SelectedItem().(serverItem); ok { m.actionMenu = nil m.result = &TUIResult{ Server: item.server, Action: "tunnel", Servers: []*model.Server{item.server}, } return m, tea.Quit } case "tunnel_n": if item, ok := m.list.SelectedItem().(serverItem); ok { m.actionMenu = nil m.result = &TUIResult{ Server: item.server, Action: "tunnel_n", Servers: []*model.Server{item.server}, } return m, tea.Quit } case "tunnel_bg": if item, ok := m.list.SelectedItem().(serverItem); ok { m.actionMenu = nil m.result = &TUIResult{ Server: item.server, Action: "tunnel_bg", Servers: []*model.Server{item.server}, } return m, tea.Quit } case "forwards": if item, ok := m.list.SelectedItem().(serverItem); ok { m.forwardScreen = newForwardScreenModel(item.server.ID, item.server.Alias, m.width, m.height) m.screen = screenForwardList m.actionMenu = nil return m, m.forwardScreen.loadForwards() } case "tunnels": m.tunnelScreen = newTunnelScreenModel(m.width, m.height) m.screen = screenTunnelManager m.actionMenu = nil return m, m.tunnelScreen.loadTunnels() case "route": if item, ok := m.list.SelectedItem().(serverItem); ok { m.form = newEditFormModel(item.server, m.width, m.height) m.form.focusIdx = 7 m.form.updateFocus() m.screen = screenForm m.actionMenu = nil } case "test": if item, ok := m.list.SelectedItem().(serverItem); ok { m.screen = screenList m.actionMenu = nil return m, func() tea.Msg { ok, testErr := TestConnection(item.server) return testDoneMsg{ok: ok, err: testErr} } } case "edit": if item, ok := m.list.SelectedItem().(serverItem); ok { m.form = newEditFormModel(item.server, m.width, m.height) m.screen = screenForm m.actionMenu = nil return m, nil } case "delete": if item, ok := m.list.SelectedItem().(serverItem); ok { m.actionMenu = nil m.confirmServerDelete(item.server) return m, nil } case "import": m.screen = screenList m.actionMenu = nil return m, func() tea.Msg { if ImportServers == nil { return importDoneMsg{err: fmt.Errorf("import is unavailable")} } count, err := ImportServers() if err != nil { return importDoneMsg{err: err} } servers, err := ListServers() return importDoneMsg{servers: servers, count: count, err: err} } case "export": m.actionMenu = nil m.result = &TUIResult{Action: "export"} return m, tea.Quit case "vault_lock": m.screen = screenList m.actionMenu = nil if LockVault == nil { m.err = fmt.Errorf("vault lock is unavailable") } else if err := LockVault(); err != nil { m.err = err } else { m.vaultUnlocked = false m.success = "Vault locked." } case "vault_change_pw": m.actionMenu = nil m.result = &TUIResult{Action: "vault_change_pw"} return m, tea.Quit } return m, nil } return m, nil } func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEsc: m.screen = screenList m.forwardScreen = nil return m, nil case tea.KeyCtrlA: // Add forward if m.forwardScreen != nil { m.forwardForm = newForwardFormModel(m.forwardScreen.serverID, m.width, m.height) m.screen = screenForwardForm return m, nil } case tea.KeyCtrlD: if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) { fwd := m.forwardScreen.list[m.forwardScreen.selected] m.confirmForwardDelete(fwd) return m, nil } case tea.KeyCtrlE, tea.KeyEnter: if m.forwardScreen != nil { return m, m.forwardScreen.editSelected() } case tea.KeyRunes: switch msg.String() { case "a", "A": if m.forwardScreen != nil { m.forwardForm = newForwardFormModel(m.forwardScreen.serverID, m.width, m.height) m.screen = screenForwardForm return m, nil } case "d", "D": if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) { fwd := m.forwardScreen.list[m.forwardScreen.selected] m.confirmForwardDelete(fwd) return m, nil } } case tea.KeyDown: if m.forwardScreen != nil && m.forwardScreen.selected < len(m.forwardScreen.list)-1 { m.forwardScreen.selected++ } return m, nil case tea.KeyUp: if m.forwardScreen != nil && m.forwardScreen.selected > 0 { m.forwardScreen.selected-- } return m, nil } return m, nil } func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEsc: m.screen = screenList m.tunnelScreen = nil return m, nil case tea.KeyCtrlD: m.confirmTunnelStop() return m, nil case tea.KeyCtrlR: if m.tunnelScreen != nil { return m, m.tunnelScreen.loadTunnels() } case tea.KeyRunes: switch msg.String() { case "d", "D", "s", "S": m.confirmTunnelStop() return m, nil case "r", "R": if m.tunnelScreen != nil { return m, m.tunnelScreen.loadTunnels() } } } var cmd tea.Cmd m.tunnelScreen.list, cmd = m.tunnelScreen.list.Update(msg) return m, cmd } func (m *tuiModel) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.confirm == nil { m.screen = screenList return m, nil } if m.confirm.pending { return m, nil } switch msg.Type { case tea.KeyEsc: return m.cancelConfirm() case tea.KeyTab, tea.KeyShiftTab, tea.KeyLeft, tea.KeyRight: if m.confirm.focus == confirmCancel { m.confirm.focus = confirmAccept } else { m.confirm.focus = confirmCancel } return m, nil case tea.KeyEnter: if m.confirm.focus == confirmCancel { return m.cancelConfirm() } return m.acceptConfirm() case tea.KeyRunes: switch msg.String() { case "y", "Y": return m.acceptConfirm() case "n", "N": return m.cancelConfirm() } } return m, nil } func (m *tuiModel) viewConfirm() string { if m.confirm == nil { return "" } body := func(width, height int) string { innerWidth := max(1, width-4) innerHeight := max(1, height-2) message := wrapCells(m.confirm.target, innerWidth) if m.confirm.consequence != "" { message = append(message, "") message = append(message, wrapCells(m.confirm.consequence, innerWidth)...) } cancel := "[ Cancel ]" accept := "[ " + m.confirm.verb + " ]" if m.confirm.focus == confirmCancel { cancel = selectedStyle.Render("> " + cancel) } else { accept = errorStyle.Render("> " + accept) } action := cancel + " " + accept if m.confirm.pending { action = m.confirm.verb + " in progress…" } messageRows := max(0, innerHeight-2) if len(message) > messageRows { message = message[:messageRows] if len(message) > 0 { message[len(message)-1] = truncateCells(strings.TrimSpace(message[len(message)-1])+" …", innerWidth) } } lines := []string{dashboardSection(m.confirm.title)} lines = append(lines, message...) for len(lines) < innerHeight-1 { lines = append(lines, "") } lines = append(lines, action) return renderPaddedPanel(width, height, lines) } return renderScreenShell(screenShell{ breadcrumb: "Confirm", status: shellStatus(m.vaultUnlocked, "Action required"), width: m.width, height: m.height, body: body, footer: []helpItem{ {Key: "Tab", Action: "choose"}, {Key: "Enter", Action: "activate"}, {Key: "Esc", Action: "cancel"}, }, }) } func (m *tuiModel) beginConfirm(state confirmState) { state.focus = confirmCancel m.confirm = &state m.screen = screenConfirm } func (m *tuiModel) cancelConfirm() (tea.Model, tea.Cmd) { parent := m.confirm.parent m.confirm = nil m.screen = parent return m, nil } func (m *tuiModel) acceptConfirm() (tea.Model, tea.Cmd) { if m.confirm == nil || m.confirm.pending || m.confirm.action == nil { return m, nil } m.confirm.pending = true return m, m.confirm.action() } func (m *tuiModel) finishConfirm() { if m.confirm == nil { return } destination := m.confirm.parent if m.confirm.completeSet { destination = m.confirm.complete } m.screen = destination m.confirm = nil } func (m *tuiModel) confirmDiscard(title string, origin, destination screen) { m.beginConfirm(confirmState{ title: "Discard unsaved changes?", target: title, consequence: "Your edits on this form will be lost.", verb: "Discard", parent: origin, complete: destination, completeSet: true, action: func() tea.Cmd { return func() tea.Msg { return discardFormMsg{origin: origin} } }, }) } func (m *tuiModel) confirmServerDelete(server *model.Server) { alias := server.Alias m.beginConfirm(confirmState{ title: "Delete server profile?", target: fmt.Sprintf("%q", alias), consequence: "This also removes its saved port forwards and vault secrets.", verb: "Delete", parent: screenList, action: func() tea.Cmd { return func() tea.Msg { if DeleteServer == nil { return serverDeletedMsg{alias: alias, err: fmt.Errorf("server deletion is unavailable")} } if err := DeleteServer(alias); err != nil { return serverDeletedMsg{alias: alias, err: err} } if ListServers == nil { return serverDeletedMsg{alias: alias, deleted: true, err: fmt.Errorf("server reload is unavailable")} } servers, err := ListServers() return serverDeletedMsg{alias: alias, servers: servers, deleted: true, err: err} } }, }) } func (m *tuiModel) removeServer(alias string) { servers := make([]*model.Server, 0, len(m.servers)) for _, server := range m.servers { if server.Alias != alias { servers = append(servers, server) } } m.servers = servers items := make([]list.Item, len(servers)) for index, server := range servers { items[index] = serverItem{server: server} } m.list.SetItems(items) delete(m.selected, alias) } func (m *tuiModel) confirmForwardDelete(fwd *model.Forward) { name := fwd.Name if strings.TrimSpace(name) == "" { name = fwd.ForwardListen() } id := fwd.ID m.beginConfirm(confirmState{ title: "Delete port forward?", target: fmt.Sprintf("%q · %s → %s", name, fwd.ForwardListen(), fwd.ForwardTarget()), consequence: "This removes the saved forwarding rule. Active tunnels are not stopped.", verb: "Delete", parent: screenForwardList, action: func() tea.Cmd { return func() tea.Msg { if DeleteForward == nil { return forwardDeletedMsg{id: id, err: fmt.Errorf("forward deletion is unavailable")} } return forwardDeletedMsg{id: id, err: DeleteForward(id)} } }, }) } func (m *tuiModel) confirmTunnelStop() { if m.tunnelScreen == nil { return } item, ok := m.tunnelScreen.list.SelectedItem().(tunnelItem) if !ok || item.state == nil { return } state := item.state m.beginConfirm(confirmState{ title: "Stop running tunnel?", target: fmt.Sprintf("%q · PID %d · %s", state.Name, state.PID, state.ServerAlias), consequence: "Active forwarded connections through this process will close.", verb: "Stop", parent: screenTunnelManager, action: func() tea.Cmd { return m.tunnelScreen.stopSelected() }, }) } func (m *tuiModel) updateFullHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) { updated, _ := m.fullHelp.Update(msg) if fh, ok := updated.(*fullHelpModel); ok { m.fullHelp = fh } if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEnter || (msg.Type == tea.KeyRunes && (msg.String() == "q" || msg.String() == "Q")) { m.screen = m.helpParent m.fullHelp = nil return m, nil } return m, nil } func (m *tuiModel) screenOwnsPrintableInput() bool { switch m.screen { case screenForm, screenSearch, screenTagInput, screenTemplateForm, screenForwardForm: return true default: return false } } func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if msg.Type == tea.KeyEsc { if m.forwardForm != nil && m.forwardForm.Dirty() { m.confirmDiscard("Port forward", screenForwardForm, screenForwardList) return m, nil } m.screen = screenForwardList m.forwardForm = nil return m, nil } updated, cmd := m.forwardForm.Update(msg) if fm, ok := updated.(*forwardFormModel); ok { m.forwardForm = fm if fm.saved { m.screen = screenForwardList m.forwardForm = nil // Reload forward list if m.forwardScreen != nil { return m, m.forwardScreen.loadForwards() } } } return m, cmd } func (m *tuiModel) viewServerList() string { return m.renderServerDashboard() } func (m *tuiModel) rootNotification() string { if m.err != nil { return errorStyle.Render("Error: " + m.err.Error()) } if m.success != "" { return successStyle.Render(m.success) } return "" } func (m *tuiModel) viewSearch() string { return renderScreenShell(screenShell{ breadcrumb: "Search", status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d profiles", len(m.servers))), notification: m.rootNotification(), width: m.width, height: m.height, body: func(width, height int) string { return renderPaddedPanel(width, height, []string{ dashboardSection("Find server"), "", m.searchInput.View(), "", dashboardHelp("Search alias, host, display name, group, tags, notes, and route."), }) }, footer: []helpItem{{Key: "Type", Action: "search"}, {Key: "Enter", Action: "confirm"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}}, }) } func (m *tuiModel) viewInlineBackgroundResults() string { var b strings.Builder b.WriteString(sectionStyle.Render("Last Background Run")) b.WriteString("\n") for _, result := range m.bgResults { status := "OK" if result.Err != "" { status = "FAIL" } b.WriteString(fmt.Sprintf(" %-20s %s", result.Alias, status)) if result.Err != "" { b.WriteString(" " + result.Err) } b.WriteString("\n") } selectedAlias := "" if selected := m.selectedServer(); selected != nil { selectedAlias = selected.Alias } result := m.backgroundResultForAlias(selectedAlias) if result == nil && len(m.bgResults) == 1 { result = &m.bgResults[0] } if result != nil { output := strings.TrimSpace(result.Output) if output == "" && result.Err != "" { output = result.Err } if output != "" { b.WriteString(helpStyle.Render(" Output: " + result.Alias)) b.WriteString("\n") for _, line := range strings.Split(output, "\n") { b.WriteString(m.renderBackgroundOutputLine(line)) b.WriteString("\n") } } } return b.String() } func (m *tuiModel) backgroundResultForAlias(alias string) *templateRunResult { for i := range m.bgResults { if m.bgResults[i].Alias == alias { return &m.bgResults[i] } } return nil } func (m *tuiModel) renderBackgroundOutputLine(line string) string { line = strings.ReplaceAll(strings.TrimRight(line, "\r"), "\t", " ") line = " " + line width := m.width if width <= 0 { return line } if len(line) > width { line = truncate(line, width) } return line + strings.Repeat(" ", width-len(line)) } func (m *tuiModel) selectedServer() *model.Server { if item, ok := m.list.SelectedItem().(serverItem); ok && item.server != nil { return item.server } return nil } func (m *tuiModel) visibleServerRows() int { if m.height <= 0 { return len(m.servers) } const fixedRows = 16 rows := m.height - fixedRows if rows < 3 { return 3 } return rows } func (m *tuiModel) viewSelectedServer(server *model.Server) string { displayName := server.DisplayName if displayName == "" { displayName = "-" } group := server.GroupName if group == "" { group = "-" } var b strings.Builder b.WriteString(sectionStyle.Render("Selected")) b.WriteString("\n") b.WriteString(fmt.Sprintf(" Alias: %s\n", server.Alias)) b.WriteString(fmt.Sprintf(" Display Name: %s\n", displayName)) b.WriteString(fmt.Sprintf(" Host: %s\n", server.Host)) b.WriteString(fmt.Sprintf(" Port: %d\n", server.Port)) b.WriteString(fmt.Sprintf(" User: %s\n", server.User)) target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port) b.WriteString(fmt.Sprintf(" Target: %s\n", target)) b.WriteString(fmt.Sprintf(" Auth: %s\n", authLabel(server.AuthMethod))) if len(server.Route.Hops) > 0 { b.WriteString(fmt.Sprintf(" Route: %s\n", server.Route.DisplaySummary(target))) } else if server.ProxyJump != "" { b.WriteString(fmt.Sprintf(" ProxyJump: %s\n", server.ProxyJump)) } b.WriteString(fmt.Sprintf(" Group: %s\n", group)) if len(server.Tags) > 0 { b.WriteString(fmt.Sprintf(" Tags: %s\n", strings.Join(server.Tags, ", "))) } if server.StartupCommand != "" { b.WriteString(fmt.Sprintf(" Startup: %s\n", server.StartupCommand)) } b.WriteString(fmt.Sprintf(" Status: %s\n", testStatusLabel(server))) return b.String() } func (m *tuiModel) viewTags() string { return renderScreenShell(screenShell{ breadcrumb: "Tags", status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d tags", len(m.tags))), notification: m.rootNotification(), width: m.width, height: m.height, body: func(width, height int) string { if len(m.tags) == 0 { return renderPaddedPanel(width, height, []string{dashboardHelp("No tags yet. Ctrl+A adds one to the selected servers.")}) } capacity := max(1, height-2) start, end := visibleServerRange(len(m.tagList.Items()), m.tagList.Index(), capacity) lines := make([]string, 0, capacity) for index := start; index < end; index++ { tag, ok := m.tagList.Items()[index].(groupItem) if !ok { continue } marker := " " if index == m.tagList.Index() { marker = "> " } lines = append(lines, marker+tag.name) } return renderPaddedPanel(width, height, lines) }, footer: []helpItem{{Key: "Enter", Action: "toggle"}, {Key: "Ctrl+A", Action: "add"}, {Key: "Ctrl+E", Action: "rename"}, {Key: "Ctrl+D", Action: "delete"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}}, }) } func (m *tuiModel) viewTagInput() string { title := "Add Tag" if m.tagMode == "rename" { title = "Rename Tag" } return renderScreenShell(screenShell{ breadcrumb: title, status: shellStatus(m.vaultUnlocked, "Tag editor"), width: m.width, height: m.height, body: func(width, height int) string { return renderPaddedPanel(width, height, []string{dashboardSection(title), "", m.tagInput.View()}) }, footer: []helpItem{{Key: "Enter", Action: "save"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}}, }) } func (m *tuiModel) viewTemplates() string { return renderScreenShell(screenShell{ breadcrumb: "Command Templates", status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d templates", len(m.templates))), notification: m.rootNotification(), width: m.width, height: m.height, body: func(width, height int) string { if len(m.templates) == 0 { return renderPaddedPanel(width, height, []string{dashboardHelp("No command templates yet. Ctrl+A adds one.")}) } capacity := max(1, height-2) start, end := visibleServerRange(len(m.templateList.Items()), m.templateList.Index(), capacity) lines := make([]string, 0, capacity) for index := start; index < end; index++ { tpl, ok := m.templateList.Items()[index].(templateItem) if !ok { continue } marker := " " if index == m.templateList.Index() { marker = "> " } line := marker + tpl.template.Name + " " + tpl.template.Command if tpl.template.Description != "" && classifyShellContent(width) != sizeNarrow { line += " — " + tpl.template.Description } lines = append(lines, line) } return renderPaddedPanel(width, height, lines) }, footer: []helpItem{{Key: "Ctrl+A", Action: "add"}, {Key: "Ctrl+E", Action: "edit"}, {Key: "Ctrl+D", Action: "delete"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}}, }) } func (m *tuiModel) viewTemplatePicker() string { targets := strings.Join(serverAliases(m.targetServers()), ", ") return renderScreenShell(screenShell{ breadcrumb: "Run Template", status: "Targets: " + targets, width: m.width, height: m.height, body: func(width, height int) string { lines := []string{dashboardSection("Choose template"), dashboardHelp("Targets: " + targets), ""} if len(m.templates) == 0 { lines = append(lines, dashboardHelp("No command templates. Press Esc, then Ctrl+P to add one.")) } else { capacity := max(1, height-len(lines)-2) start, end := visibleServerRange(len(m.templateList.Items()), m.templateList.Index(), capacity) for index := start; index < end; index++ { tpl, ok := m.templateList.Items()[index].(templateItem) if !ok { continue } marker := " " if index == m.templateList.Index() { marker = "> " } lines = append(lines, marker+tpl.template.Name+" "+tpl.template.Command) } } return renderPaddedPanel(width, height, lines) }, footer: []helpItem{{Key: "Enter", Action: "choose"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}}, }) } func (m *tuiModel) viewTemplateMode() string { name := "" command := "" if m.pendingTemplate != nil { name = m.pendingTemplate.Name command = m.pendingTemplate.Command } targets := strings.Join(serverAliases(m.targetServers()), ", ") return renderScreenShell(screenShell{ breadcrumb: "Run Template / Mode", status: "Targets: " + targets, width: m.width, height: m.height, body: func(width, height int) string { return renderPaddedPanel(width, height, []string{dashboardSection("Execution"), "", "Template: " + name, "Command: " + command, "Targets: " + targets, "", "Choose foreground for an interactive run or background to keep using sshkeeper."}) }, footer: []helpItem{{Key: "Ctrl+F (Enter)", Action: "Foreground"}, {Key: "Ctrl+B", Action: "Background"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}}, }) } func (m *tuiModel) viewBackgroundResults() string { return renderScreenShell(screenShell{ breadcrumb: "Background Results", status: fmt.Sprintf("%d results", len(m.bgResults)), width: m.width, height: m.height, body: func(width, height int) string { lines := make([]string, 0) for _, result := range m.bgResults { status := "OK" if result.Err != "" { status = "FAIL: " + result.Err } lines = append(lines, dashboardSection(result.Alias+" "+status)) if result.Output != "" { for _, line := range strings.Split(result.Output, "\n") { lines = append(lines, strings.ReplaceAll(line, "\t", " ")) } } lines = append(lines, "") } return renderPaddedPanel(width, height, lines) }, footer: []helpItem{{Key: "Enter/Esc", Action: "back"}, {Key: "Ctrl+H", Action: "help"}}, }) } func (m *tuiModel) selectedServers() []*model.Server { if len(m.selected) == 0 { return nil } servers := make([]*model.Server, 0, len(m.selected)) for _, server := range m.servers { if m.selected[server.Alias] { servers = append(servers, server) } } return servers } func (m *tuiModel) targetServers() []*model.Server { servers := m.selectedServers() if len(servers) > 0 { return servers } if selected := m.selectedServer(); selected != nil { return []*model.Server{selected} } return nil } func (m *tuiModel) openTemplatePicker() (tea.Model, tea.Cmd) { if m.selectedServer() == nil && len(m.selectedServers()) == 0 { return m, nil } m.screen = screenTemplatePicker return m, m.loadTemplatesCmd() } func (m *tuiModel) reloadServersCmd() tea.Cmd { return func() tea.Msg { servers, err := ListServers() return serversLoadedMsg{servers: servers, err: err} } } func (m *tuiModel) loadTemplatesCmd() tea.Cmd { return func() tea.Msg { if ListCommandTemplates == nil { return templatesLoadedMsg{err: fmt.Errorf("template storage is unavailable")} } templates, err := ListCommandTemplates() return templatesLoadedMsg{templates: templates, err: err} } } func (m *tuiModel) loadTagsCmd() tea.Cmd { return func() tea.Msg { if ListTags == nil { return tagsLoadedMsg{err: fmt.Errorf("tag storage is unavailable")} } tags, err := ListTags() return tagsLoadedMsg{tags: tags, err: err} } } func (m *tuiModel) setTemplates(templates []*model.CommandTemplate) { m.templates = templates items := make([]list.Item, len(templates)) for i, template := range templates { items[i] = templateItem{template: template} } l := list.New(items, list.NewDefaultDelegate(), m.width, managerListHeight(m.height)) l.SetShowStatusBar(false) l.SetFilteringEnabled(false) l.SetShowHelp(false) l.Title = "Command Templates" l.Styles.Title = titleStyle m.templateList = l } func (m *tuiModel) setTags(tags []string) { m.tags = tags m.tagList = newStringList(tags, "Tags", m.width, managerListHeight(m.height)) } func (m *tuiModel) removeTemplate(name string) { templates := make([]*model.CommandTemplate, 0, len(m.templates)) for _, template := range m.templates { if template.Name != name { templates = append(templates, template) } } m.setTemplates(templates) } func (m *tuiModel) removeTag(name string) { tags := make([]string, 0, len(m.tags)) for _, tag := range m.tags { if tag != name { tags = append(tags, tag) } } m.setTags(tags) } // --- Server list footer --- func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string { width := m.width - 3 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) } var items []helpItem if hasBackgroundResult { items = append(items, helpItem{Key: "Esc", Action: "clear result"}) } items = append(items, helpItem{Key: "Enter", Action: "connect"}, helpItem{Key: "Ctrl+X", Action: "actions"}, helpItem{Key: "Ctrl+A", Action: "add"}, helpItem{Key: "Ctrl+E", Action: "edit"}, helpItem{Key: "Ctrl+F", Action: "search"}, helpItem{Key: "Ins", Action: insAction}, helpItem{Key: "?", Action: "hotkeys"}, helpItem{Key: "Ctrl+H", Action: "help"}, helpItem{Key: "Ctrl+Q", Action: "quit"}, ) return items } // --- 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 for _, server := range servers { switch server.LastTestStatus { case model.TestOK: okCount++ case model.TestFailed: failedCount++ } } return fmt.Sprintf("%d OK | %d FAIL", okCount, failedCount) } func authLabel(auth model.AuthMethod) string { switch auth { case model.AuthPassword: return "password" case model.AuthKey: return "key" case model.AuthKeyPassphrase: return "key+phrase" case model.AuthAgent: return "agent" default: return string(auth) } } func testStatusLabel(server *model.Server) string { switch server.LastTestStatus { case model.TestOK: return "OK" case model.TestFailed: return "FAIL" default: return "?" } } 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 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 truncate(s string, maxLen int) string { return truncateCells(s, maxLen) } func splitCSV(value string) []string { parts := strings.Split(value, ",") result := make([]string, 0, len(parts)) seen := map[string]bool{} for _, part := range parts { item := strings.TrimSpace(part) if item == "" || seen[item] { continue } seen[item] = true result = append(result, item) } return result } func toggleString(values []string, value string) []string { clean := splitCSV(strings.Join(values, ",")) for i, item := range clean { if item == value { return append(clean[:i], clean[i+1:]...) } } return append(clean, value) } func serverAliases(servers []*model.Server) []string { aliases := make([]string, 0, len(servers)) for _, server := range servers { aliases = append(aliases, server.Alias) } return aliases }