feat: TUI Key & Snippet Management - Task 19
- KeyListTab: view/add/edit/delete SSH keys (Ctrl+K from host list) - KeyFormTab: add/edit key form with name, type, private key, passphrase - SnippetListTab: view/add/edit/delete snippets (Ctrl+P from host list) - SnippetFormTab: add/edit snippet form with name, command, description, tags - Updated status bar with new hints - Save/delete commands for both key pairs and snippets
This commit is contained in:
@@ -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{}
|
||||
|
||||
@@ -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() {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {}
|
||||
@@ -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
|
||||
}
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user