diff --git a/pkg/tui/host_list_tab.go b/pkg/tui/host_list_tab.go index e916017..31c1579 100644 --- a/pkg/tui/host_list_tab.go +++ b/pkg/tui/host_list_tab.go @@ -83,6 +83,16 @@ func (t *HostListTab) Update(msg tea.Msg) (Tab, tea.Cmd) { } } + case "ctrl+k": + return t, func() tea.Msg { + return openKeyListMsg{} + } + + case "ctrl+p": + return t, func() tea.Msg { + return openSnippetListMsg{} + } + case "q", "ctrl+c": return t, func() tea.Msg { return quitMsg{} diff --git a/pkg/tui/key_form_tab.go b/pkg/tui/key_form_tab.go new file mode 100644 index 0000000..478a822 --- /dev/null +++ b/pkg/tui/key_form_tab.go @@ -0,0 +1,225 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "git.tukangketik.id/swanadiva/hostkeeper/internal/models" +) + +type keyFormMode int + +const ( + keyFormAdd keyFormMode = iota + keyFormEdit +) + +type keyFieldID int + +const ( + keyFieldName keyFieldID = iota + keyFieldType + keyFieldPrivateKey + keyFieldPassphrase + keyFieldCount +) + +var keyFieldLabels = map[keyFieldID]string{ + keyFieldName: "Name", + keyFieldType: "Type (rsa/ed25519/ecdsa)", + keyFieldPrivateKey: "Private Key (PEM)", + keyFieldPassphrase: "Passphrase", +} + +// KeyFormTab is a tab for adding/editing SSH key pairs +type KeyFormTab struct { + mode keyFormMode + editing *models.KeyPair + dataDir string + + inputs []textinput.Model + focus keyFieldID + width int + height int + + err error + saved bool +} + +func NewAddKeyFormTab(dataDir string) *KeyFormTab { + return newKeyFormTab(keyFormAdd, nil, dataDir) +} + +func NewEditKeyFormTab(key *models.KeyPair, dataDir string) *KeyFormTab { + return newKeyFormTab(keyFormEdit, key, dataDir) +} + +func newKeyFormTab(mode keyFormMode, key *models.KeyPair, dataDir string) *KeyFormTab { + inputs := make([]textinput.Model, keyFieldCount) + for i := range inputs { + inputs[i] = textinput.New() + inputs[i].Prompt = "" + } + + inputs[keyFieldName].Placeholder = "My SSH Key" + inputs[keyFieldType].Placeholder = "ed25519" + inputs[keyFieldType].SetValue("ed25519") + inputs[keyFieldPrivateKey].Placeholder = "-----BEGIN OPENSSH PRIVATE KEY-----" + inputs[keyFieldPassphrase].Placeholder = "Optional passphrase" + + if mode == keyFormEdit && key != nil { + inputs[keyFieldName].SetValue(key.Name) + inputs[keyFieldType].SetValue(key.Type) + inputs[keyFieldPrivateKey].SetValue(key.PrivateKey) + inputs[keyFieldPassphrase].SetValue(key.Passphrase) + } + + inputs[keyFieldName].Focus() + inputs[keyFieldName].Prompt = "> " + + return &KeyFormTab{ + mode: mode, + editing: key, + dataDir: dataDir, + inputs: inputs, + } +} + +func (t *KeyFormTab) Name() string { + if t.mode == keyFormEdit { + return "Edit Key" + } + return "Add Key" +} + +func (t *KeyFormTab) Init() tea.Cmd { + return textinput.Blink +} + +func (t *KeyFormTab) Update(msg tea.Msg) (Tab, tea.Cmd) { + if t.saved { + return t, nil + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + t.width = msg.Width + t.height = msg.Height + + case tea.KeyMsg: + switch msg.String() { + case "esc": + return t, func() tea.Msg { return closeFormMsg{} } + + case "enter": + if t.focus == keyFieldCount-1 { + return t.submit() + } + t.nextField() + + case "tab", "down": + t.nextField() + + case "shift+tab", "up": + t.prevField() + + default: + if msg.String() == "ctrl+s" { + return t.submit() + } + t.inputs[t.focus].Update(msg) + } + } + + return t, nil +} + +func (t *KeyFormTab) nextField() { + t.inputs[t.focus].Blur() + t.inputs[t.focus].Prompt = "" + t.focus++ + if t.focus >= keyFieldCount { + t.focus = keyFieldCount - 1 + } + t.inputs[t.focus].Focus() + t.inputs[t.focus].Prompt = "> " +} + +func (t *KeyFormTab) prevField() { + t.inputs[t.focus].Blur() + t.inputs[t.focus].Prompt = "" + t.focus-- + if t.focus < 0 { + t.focus = 0 + } + t.inputs[t.focus].Focus() + t.inputs[t.focus].Prompt = "> " +} + +func (t *KeyFormTab) submit() (Tab, tea.Cmd) { + name := t.inputs[keyFieldName].Value() + privateKey := t.inputs[keyFieldPrivateKey].Value() + + if name == "" || privateKey == "" { + t.err = fmt.Errorf("name and private key are required") + return t, nil + } + + var key *models.KeyPair + if t.mode == keyFormEdit && t.editing != nil { + key = t.editing + key.Name = name + key.Type = t.inputs[keyFieldType].Value() + key.PrivateKey = privateKey + key.Passphrase = t.inputs[keyFieldPassphrase].Value() + } else { + key = &models.KeyPair{ + Name: name, + Type: t.inputs[keyFieldType].Value(), + PrivateKey: privateKey, + Passphrase: t.inputs[keyFieldPassphrase].Value(), + } + } + + t.saved = true + return t, saveKeyCmd(key, t.dataDir) +} + +func (t *KeyFormTab) View() string { + var b strings.Builder + + title := "Add SSH Key" + if t.mode == keyFormEdit { + title = "Edit SSH Key" + } + b.WriteString(AppTitleStyle.Render(title)) + b.WriteString("\n\n") + + if t.err != nil { + b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) + b.WriteString("\n\n") + } + + for i := keyFieldID(0); i < keyFieldCount; i++ { + input := t.inputs[i] + label := keyFieldLabels[i] + style := SubtitleStyle + if i == t.focus { + style = HighlightStyle + } + b.WriteString(style.Render(label + ":")) + b.WriteString("\n ") + b.WriteString(input.View()) + b.WriteString("\n\n") + } + + b.WriteString(SubtitleStyle.Render( + "↑↓: navigate Enter: next Ctrl+S: save Esc: cancel")) + + return b.String() +} + +func (t *KeyFormTab) Close() {} diff --git a/pkg/tui/key_list_tab.go b/pkg/tui/key_list_tab.go new file mode 100644 index 0000000..d27360d --- /dev/null +++ b/pkg/tui/key_list_tab.go @@ -0,0 +1,192 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "sync" + + tea "github.com/charmbracelet/bubbletea" + + "git.tukangketik.id/swanadiva/hostkeeper/internal/models" + "git.tukangketik.id/swanadiva/hostkeeper/pkg/storage" +) + +type keyListState int + +const ( + keyListLoading keyListState = iota + keyListReady + keyListError +) + +// KeyListTab displays and manages SSH key pairs +type KeyListTab struct { + dataDir string + keys []*models.KeyPair + selected int + state keyListState + err error + width int + height int + mu sync.Mutex +} + +func NewKeyListTab(dataDir string) *KeyListTab { + return &KeyListTab{ + dataDir: dataDir, + state: keyListLoading, + } +} + +func (t *KeyListTab) Name() string { return "SSH Keys" } + +func (t *KeyListTab) Init() tea.Cmd { + return func() tea.Msg { + store, err := storage.NewJSONStorage(t.dataDir) + if err != nil { + return keyListLoadedMsg{err: err} + } + keys, err := store.ListKeyPairs(context.Background()) + if err != nil { + return keyListLoadedMsg{err: err} + } + return keyListLoadedMsg{keys: keys} + } +} + +func (t *KeyListTab) Update(msg tea.Msg) (Tab, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + t.mu.Lock() + t.width = msg.Width + t.height = msg.Height + t.mu.Unlock() + + case keyListLoadedMsg: + t.mu.Lock() + if msg.err != nil { + t.state = keyListError + t.err = msg.err + } else { + t.state = keyListReady + t.keys = msg.keys + } + t.mu.Unlock() + + case saveKeyResultMsg: + return t, t.Init() + + case deleteKeyResultMsg: + if msg.err != nil { + t.mu.Lock() + t.err = msg.err + t.mu.Unlock() + } + return t, t.Init() + + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + t.mu.Lock() + if t.selected > 0 { + t.selected-- + } + t.mu.Unlock() + + case "down", "j": + t.mu.Lock() + if t.selected < len(t.keys)-1 { + t.selected++ + } + t.mu.Unlock() + + case "ctrl+n": + return t, func() tea.Msg { return openKeyFormMsg{} } + + case "ctrl+e": + t.mu.Lock() + keys := t.keys + idx := t.selected + t.mu.Unlock() + if len(keys) > 0 && idx >= 0 && idx < len(keys) { + return t, func() tea.Msg { return openKeyFormMsg{editing: keys[idx]} } + } + + case "delete", "d": + t.mu.Lock() + keys := t.keys + idx := t.selected + t.mu.Unlock() + if len(keys) > 0 && idx >= 0 && idx < len(keys) { + return t, deleteKeyCmd(keys[idx].ID, t.dataDir) + } + + case "esc": + return t, func() tea.Msg { return closeFormMsg{} } + } + } + + return t, nil +} + +func (t *KeyListTab) View() string { + t.mu.Lock() + defer t.mu.Unlock() + + var b strings.Builder + + b.WriteString(AppTitleStyle.Render("SSH Key Pairs")) + b.WriteString("\n\n") + + switch t.state { + case keyListLoading: + b.WriteString(SubtitleStyle.Render("Loading keys...")) + return b.String() + case keyListError: + b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) + b.WriteString("\n\n") + b.WriteString(SubtitleStyle.Render("Press Esc to go back")) + return b.String() + } + + if t.err != nil { + b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) + b.WriteString("\n\n") + } + + if len(t.keys) == 0 { + b.WriteString(SubtitleStyle.Render("No SSH keys stored.")) + b.WriteString("\n") + b.WriteString(SubtitleStyle.Render("Press Ctrl+N to add a new key.")) + } else { + for i, key := range t.keys { + var line string + if key.Type != "" { + line = fmt.Sprintf(" %s (%s)", key.Name, key.Type) + } else { + line = fmt.Sprintf(" %s", key.Name) + } + + if i == t.selected { + b.WriteString(SelectedStyle.Render(line)) + } else { + b.WriteString(HostNameStyle.Render(line)) + } + b.WriteString("\n") + } + } + + b.WriteString("\n") + b.WriteString(SubtitleStyle.Render("Ctrl+N:add Ctrl+E:edit D:delete Esc:back")) + + return b.String() +} + +func (t *KeyListTab) Close() {} + +// keyListLoadedMsg carries the loaded key list +type keyListLoadedMsg struct { + keys []*models.KeyPair + err error +} diff --git a/pkg/tui/messages.go b/pkg/tui/messages.go index 567571f..b1f459b 100644 --- a/pkg/tui/messages.go +++ b/pkg/tui/messages.go @@ -55,6 +55,118 @@ type loadedHostsMsg struct { hosts []*models.Host } +// Key management messages +type openKeyListMsg struct{} + +type openKeyFormMsg struct { + editing *models.KeyPair +} + +type saveKeyMsg struct { + key *models.KeyPair + dataDir string +} + +type saveKeyResultMsg struct { + key *models.KeyPair + err error +} + +type deleteKeyMsg struct { + id string + dataDir string +} + +type deleteKeyResultMsg struct { + err error +} + +// Snippet management messages +type openSnippetListMsg struct{} + +type openSnippetFormMsg struct { + editing *models.Snippet +} + +type saveSnippetMsg struct { + snippet *models.Snippet + dataDir string +} + +type saveSnippetResultMsg struct { + snippet *models.Snippet + err error +} + +type deleteSnippetMsg struct { + id string + dataDir string +} + +type deleteSnippetResultMsg struct { + err error +} + +// saveKeyCmd creates a command that saves a key pair to storage +func saveKeyCmd(key *models.KeyPair, dataDir string) tea.Cmd { + return func() tea.Msg { + store, err := storage.NewJSONStorage(dataDir) + if err != nil { + return saveKeyResultMsg{err: err} + } + ctx := context.Background() + if err := store.SaveKeyPair(ctx, key); err != nil { + return saveKeyResultMsg{err: err} + } + return saveKeyResultMsg{key: key} + } +} + +// deleteKeyCmd creates a command that deletes a key pair +func deleteKeyCmd(id, dataDir string) tea.Cmd { + return func() tea.Msg { + store, err := storage.NewJSONStorage(dataDir) + if err != nil { + return deleteKeyResultMsg{err: err} + } + ctx := context.Background() + if err := store.DeleteKeyPair(ctx, id); err != nil { + return deleteKeyResultMsg{err: err} + } + return deleteKeyResultMsg{} + } +} + +// saveSnippetCmd creates a command that saves a snippet to storage +func saveSnippetCmd(snippet *models.Snippet, dataDir string) tea.Cmd { + return func() tea.Msg { + store, err := storage.NewJSONStorage(dataDir) + if err != nil { + return saveSnippetResultMsg{err: err} + } + ctx := context.Background() + if err := store.SaveSnippet(ctx, snippet); err != nil { + return saveSnippetResultMsg{err: err} + } + return saveSnippetResultMsg{snippet: snippet} + } +} + +// deleteSnippetCmd creates a command that deletes a snippet +func deleteSnippetCmd(id, dataDir string) tea.Cmd { + return func() tea.Msg { + store, err := storage.NewJSONStorage(dataDir) + if err != nil { + return deleteSnippetResultMsg{err: err} + } + ctx := context.Background() + if err := store.DeleteSnippet(ctx, id); err != nil { + return deleteSnippetResultMsg{err: err} + } + return deleteSnippetResultMsg{} + } +} + // saveHostCmd creates a command that saves a host to storage func saveHostCmd(host *models.Host, dataDir string) tea.Cmd { return func() tea.Msg { diff --git a/pkg/tui/snippet_form_tab.go b/pkg/tui/snippet_form_tab.go new file mode 100644 index 0000000..b960143 --- /dev/null +++ b/pkg/tui/snippet_form_tab.go @@ -0,0 +1,233 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "git.tukangketik.id/swanadiva/hostkeeper/internal/models" +) + +type snippetFormMode int + +const ( + snippetFormAdd snippetFormMode = iota + snippetFormEdit +) + +type snippetFieldID int + +const ( + snippetFieldName snippetFieldID = iota + snippetFieldCommand + snippetFieldDescription + snippetFieldTags + snippetFieldCount +) + +var snippetFieldLabels = map[snippetFieldID]string{ + snippetFieldName: "Name", + snippetFieldCommand: "Command", + snippetFieldDescription: "Description", + snippetFieldTags: "Tags (comma-separated)", +} + +// SnippetFormTab is a tab for adding/editing command snippets +type SnippetFormTab struct { + mode snippetFormMode + editing *models.Snippet + dataDir string + + inputs []textinput.Model + focus snippetFieldID + width int + height int + + err error + saved bool +} + +func NewAddSnippetFormTab(dataDir string) *SnippetFormTab { + return newSnippetFormTab(snippetFormAdd, nil, dataDir) +} + +func NewEditSnippetFormTab(snippet *models.Snippet, dataDir string) *SnippetFormTab { + return newSnippetFormTab(snippetFormEdit, snippet, dataDir) +} + +func newSnippetFormTab(mode snippetFormMode, sn *models.Snippet, dataDir string) *SnippetFormTab { + inputs := make([]textinput.Model, snippetFieldCount) + for i := range inputs { + inputs[i] = textinput.New() + inputs[i].Prompt = "" + } + + inputs[snippetFieldName].Placeholder = "Check logs" + inputs[snippetFieldCommand].Placeholder = "journalctl -u nginx --no-pager -n 100" + inputs[snippetFieldDescription].Placeholder = "View last 100 nginx log entries" + inputs[snippetFieldTags].Placeholder = "nginx,logs,troubleshooting" + + if mode == snippetFormEdit && sn != nil { + inputs[snippetFieldName].SetValue(sn.Name) + inputs[snippetFieldCommand].SetValue(sn.Command) + inputs[snippetFieldDescription].SetValue(sn.Description) + inputs[snippetFieldTags].SetValue(strings.Join(sn.Tags, ",")) + } + + inputs[snippetFieldName].Focus() + inputs[snippetFieldName].Prompt = "> " + + return &SnippetFormTab{ + mode: mode, + editing: sn, + dataDir: dataDir, + inputs: inputs, + } +} + +func (t *SnippetFormTab) Name() string { + if t.mode == snippetFormEdit { + return "Edit Snippet" + } + return "Add Snippet" +} + +func (t *SnippetFormTab) Init() tea.Cmd { + return textinput.Blink +} + +func (t *SnippetFormTab) Update(msg tea.Msg) (Tab, tea.Cmd) { + if t.saved { + return t, nil + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + t.width = msg.Width + t.height = msg.Height + + case tea.KeyMsg: + switch msg.String() { + case "esc": + return t, func() tea.Msg { return closeFormMsg{} } + + case "enter": + if t.focus == snippetFieldCount-1 { + return t.submit() + } + t.nextField() + + case "tab", "down": + t.nextField() + + case "shift+tab", "up": + t.prevField() + + default: + if msg.String() == "ctrl+s" { + return t.submit() + } + t.inputs[t.focus].Update(msg) + } + } + + return t, nil +} + +func (t *SnippetFormTab) nextField() { + t.inputs[t.focus].Blur() + t.inputs[t.focus].Prompt = "" + t.focus++ + if t.focus >= snippetFieldCount { + t.focus = snippetFieldCount - 1 + } + t.inputs[t.focus].Focus() + t.inputs[t.focus].Prompt = "> " +} + +func (t *SnippetFormTab) prevField() { + t.inputs[t.focus].Blur() + t.inputs[t.focus].Prompt = "" + t.focus-- + if t.focus < 0 { + t.focus = 0 + } + t.inputs[t.focus].Focus() + t.inputs[t.focus].Prompt = "> " +} + +func (t *SnippetFormTab) submit() (Tab, tea.Cmd) { + name := t.inputs[snippetFieldName].Value() + command := t.inputs[snippetFieldCommand].Value() + + if name == "" || command == "" { + t.err = fmt.Errorf("name and command are required") + return t, nil + } + + var tags []string + if tagStr := t.inputs[snippetFieldTags].Value(); tagStr != "" { + for _, tag := range strings.Split(tagStr, ",") { + if trimmed := strings.TrimSpace(tag); trimmed != "" { + tags = append(tags, trimmed) + } + } + } + + var sn *models.Snippet + if t.mode == snippetFormEdit && t.editing != nil { + sn = t.editing + sn.Name = name + sn.Command = command + sn.Description = t.inputs[snippetFieldDescription].Value() + sn.Tags = tags + } else { + sn = &models.Snippet{ + Name: name, + Command: command, + Description: t.inputs[snippetFieldDescription].Value(), + Tags: tags, + } + } + + t.saved = true + return t, saveSnippetCmd(sn, t.dataDir) +} + +func (t *SnippetFormTab) View() string { + var b strings.Builder + + title := "Add Snippet" + if t.mode == snippetFormEdit { + title = "Edit Snippet" + } + b.WriteString(AppTitleStyle.Render(title)) + b.WriteString("\n\n") + + if t.err != nil { + b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) + b.WriteString("\n\n") + } + + for i := snippetFieldID(0); i < snippetFieldCount; i++ { + input := t.inputs[i] + label := snippetFieldLabels[i] + style := SubtitleStyle + if i == t.focus { + style = HighlightStyle + } + b.WriteString(style.Render(label + ":")) + b.WriteString("\n ") + b.WriteString(input.View()) + b.WriteString("\n\n") + } + + b.WriteString(SubtitleStyle.Render( + "↑↓: navigate Enter: next Ctrl+S: save Esc: cancel")) + + return b.String() +} + +func (t *SnippetFormTab) Close() {} diff --git a/pkg/tui/snippet_list_tab.go b/pkg/tui/snippet_list_tab.go new file mode 100644 index 0000000..fa61b49 --- /dev/null +++ b/pkg/tui/snippet_list_tab.go @@ -0,0 +1,193 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "sync" + + tea "github.com/charmbracelet/bubbletea" + + "git.tukangketik.id/swanadiva/hostkeeper/internal/models" + "git.tukangketik.id/swanadiva/hostkeeper/pkg/storage" +) + +type snippetListState int + +const ( + snippetListLoading snippetListState = iota + snippetListReady + snippetListError +) + +// SnippetListTab displays and manages command snippets +type SnippetListTab struct { + dataDir string + snippets []*models.Snippet + selected int + state snippetListState + err error + width int + height int + mu sync.Mutex +} + +func NewSnippetListTab(dataDir string) *SnippetListTab { + return &SnippetListTab{ + dataDir: dataDir, + state: snippetListLoading, + } +} + +func (t *SnippetListTab) Name() string { return "Snippets" } + +func (t *SnippetListTab) Init() tea.Cmd { + return func() tea.Msg { + store, err := storage.NewJSONStorage(t.dataDir) + if err != nil { + return snippetListLoadedMsg{err: err} + } + snippets, err := store.ListSnippets(context.Background()) + if err != nil { + return snippetListLoadedMsg{err: err} + } + return snippetListLoadedMsg{snippets: snippets} + } +} + +func (t *SnippetListTab) Update(msg tea.Msg) (Tab, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + t.mu.Lock() + t.width = msg.Width + t.height = msg.Height + t.mu.Unlock() + + case snippetListLoadedMsg: + t.mu.Lock() + if msg.err != nil { + t.state = snippetListError + t.err = msg.err + } else { + t.state = snippetListReady + t.snippets = msg.snippets + } + t.mu.Unlock() + + case saveSnippetResultMsg: + return t, t.Init() + + case deleteSnippetResultMsg: + if msg.err != nil { + t.mu.Lock() + t.err = msg.err + t.mu.Unlock() + } + return t, t.Init() + + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + t.mu.Lock() + if t.selected > 0 { + t.selected-- + } + t.mu.Unlock() + + case "down", "j": + t.mu.Lock() + if t.selected < len(t.snippets)-1 { + t.selected++ + } + t.mu.Unlock() + + case "ctrl+n": + return t, func() tea.Msg { return openSnippetFormMsg{} } + + case "ctrl+e": + t.mu.Lock() + snippets := t.snippets + idx := t.selected + t.mu.Unlock() + if len(snippets) > 0 && idx >= 0 && idx < len(snippets) { + return t, func() tea.Msg { return openSnippetFormMsg{editing: snippets[idx]} } + } + + case "delete", "d": + t.mu.Lock() + snippets := t.snippets + idx := t.selected + t.mu.Unlock() + if len(snippets) > 0 && idx >= 0 && idx < len(snippets) { + return t, deleteSnippetCmd(snippets[idx].ID, t.dataDir) + } + + case "esc": + return t, func() tea.Msg { return closeFormMsg{} } + } + } + + return t, nil +} + +func (t *SnippetListTab) View() string { + t.mu.Lock() + defer t.mu.Unlock() + + var b strings.Builder + + b.WriteString(AppTitleStyle.Render("Command Snippets")) + b.WriteString("\n\n") + + switch t.state { + case snippetListLoading: + b.WriteString(SubtitleStyle.Render("Loading snippets...")) + return b.String() + case snippetListError: + b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) + b.WriteString("\n\n") + b.WriteString(SubtitleStyle.Render("Press Esc to go back")) + return b.String() + } + + if t.err != nil { + b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) + b.WriteString("\n\n") + } + + if len(t.snippets) == 0 { + b.WriteString(SubtitleStyle.Render("No snippets stored.")) + b.WriteString("\n") + b.WriteString(SubtitleStyle.Render("Press Ctrl+N to add a new snippet.")) + } else { + for i, sn := range t.snippets { + var line string + desc := sn.Description + if desc != "" { + line = fmt.Sprintf(" %s — %s", sn.Name, desc) + } else { + line = fmt.Sprintf(" %s", sn.Name) + } + + if i == t.selected { + b.WriteString(SelectedStyle.Render(line)) + } else { + b.WriteString(HostNameStyle.Render(line)) + } + b.WriteString("\n") + } + } + + b.WriteString("\n") + b.WriteString(SubtitleStyle.Render("Ctrl+N:add Ctrl+E:edit D:delete Esc:back")) + + return b.String() +} + +func (t *SnippetListTab) Close() {} + +// snippetListLoadedMsg carries the loaded snippet list +type snippetListLoadedMsg struct { + snippets []*models.Snippet + err error +} diff --git a/pkg/tui/tabs.go b/pkg/tui/tabs.go index bec1a1e..4382555 100644 --- a/pkg/tui/tabs.go +++ b/pkg/tui/tabs.go @@ -196,6 +196,6 @@ func renderTabBar(tm *TabManager) string { // renderStatusBar renders the bottom status bar func renderStatusBar(tm *TabManager) string { - hints := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Enter:select q:quit" + hints := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Enter:select Ctrl+N:add Ctrl+F:SFTP Ctrl+K:keys Ctrl+P:snippets q:quit" return StatusBarStyle.Render(hints) } diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index fe45622..3c8740b 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -81,6 +81,36 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := m.tabs.Add(tab) return m, cmd + case openKeyListMsg: + tab := NewKeyListTab(m.dataDir) + cmd := m.tabs.Add(tab) + return m, cmd + + case openSnippetListMsg: + tab := NewSnippetListTab(m.dataDir) + cmd := m.tabs.Add(tab) + return m, cmd + + case openKeyFormMsg: + var tab Tab + if msg.editing != nil { + tab = NewEditKeyFormTab(msg.editing, m.dataDir) + } else { + tab = NewAddKeyFormTab(m.dataDir) + } + cmd := m.tabs.Add(tab) + return m, cmd + + case openSnippetFormMsg: + var tab Tab + if msg.editing != nil { + tab = NewEditSnippetFormTab(msg.editing, m.dataDir) + } else { + tab = NewAddSnippetFormTab(m.dataDir) + } + cmd := m.tabs.Add(tab) + return m, cmd + case closeFormMsg: if m.tabs.Len() > 1 { m.tabs.CloseActive() @@ -112,6 +142,39 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case loadedHostsMsg: m.LoadHosts(msg.hosts) + case saveKeyResultMsg: + if msg.err != nil { + m.Error = msg.err + return m, nil + } + // Close form tab and return to list (which auto-refreshes via Init) + if m.tabs.Len() > 1 { + m.tabs.CloseActive() + } + return m, nil + + case deleteKeyResultMsg: + if msg.err != nil { + m.Error = msg.err + } + return m, nil + + case saveSnippetResultMsg: + if msg.err != nil { + m.Error = msg.err + return m, nil + } + if m.tabs.Len() > 1 { + m.tabs.CloseActive() + } + return m, nil + + case deleteSnippetResultMsg: + if msg.err != nil { + m.Error = msg.err + } + return m, nil + case sessionDoneMsg: if msg.err != nil { m.Error = msg.err