sshkeeper/internal/tui/app.go

731 lines
16 KiB
Go

package tui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mirivlad/sshkeeper/internal/model"
)
// --- 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"))
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)
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
}
// connectRequestMsg — TUI requests a connect action to be handled outside
type connectRequestMsg struct {
server *model.Server
}
// --- Server list item ---
type serverItem struct {
server *model.Server
}
func (i serverItem) Title() string { return i.server.Alias }
func (i serverItem) Description() string { return fmt.Sprintf("%s@%s:%d %s", i.server.User, i.server.Host, i.server.Port, i.server.AuthMethod) }
func (i serverItem) FilterValue() string { return i.server.Alias + " " + i.server.DisplayName + " " + i.server.Host + " " + i.server.User }
// --- 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) error
GetGroups func() ([]string, error) // Returns existing group names
RenameGroup func(oldName, newName string) error // Rename group for all servers
DeleteGroup func(name string) error // Remove group from all servers
)
// --- Screen type ---
type screen int
const (
screenList screen = iota
screenForm
screenSearch
)
// --- Result type — returned from TUI to caller ---
type TUIResult struct {
Server *model.Server
Action string // "connect"
}
// --- Main TUI model ---
type tuiModel struct {
screen screen
list list.Model
servers []*model.Server
searchInput textinput.Model
form *formModel
err error
success string
width int
height int
result *TUIResult
}
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
return &tuiModel{
screen: screenList,
list: l,
servers: servers,
searchInput: search,
}
}
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
}
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 connectRequestMsg:
// Store result and quit TUI — caller will handle the connect
m.result = &TUIResult{
Server: msg.server,
Action: "connect",
}
return m, tea.Quit
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
case saveDoneMsg:
if m.form != nil {
m.form.saving = false
if msg.err != nil {
m.form.err = msg.err
m.form.saved = false
} else {
m.form.saved = true
m.form.savedTime = time.Now()
m.form.err = nil
}
}
return m, nil
case tea.KeyMsg:
switch m.screen {
case screenList:
return m.updateList(msg)
case screenForm:
return m.updateForm(msg)
case screenSearch:
return m.updateSearch(msg)
}
}
return m, nil
}
func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyEnter:
// Connect to selected server
if item, ok := m.list.SelectedItem().(serverItem); ok {
return m, func() tea.Msg {
return connectRequestMsg{server: item.server}
}
}
case tea.KeyCtrlC, tea.KeyCtrlQ:
return m, tea.Quit
case tea.KeyCtrlA:
// Add server
m.form = newFormModel(m.width, m.height)
m.screen = screenForm
return m, nil
case tea.KeyCtrlE:
// Edit selected server
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:
// Delete selected server
if item, ok := m.list.SelectedItem().(serverItem); ok {
return m, func() tea.Msg {
err := DeleteServer(item.server.Alias)
if err != nil {
return saveDoneMsg{err: err}
}
servers, err := ListServers()
return serversLoadedMsg{servers: servers, err: err}
}
}
case tea.KeyCtrlT:
// Test connection
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:
// Search
m.screen = screenSearch
m.searchInput.Focus()
return m, nil
default:
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
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) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.Type == tea.KeyEsc {
m.screen = screenList
m.form = nil
m.err = nil
m.success = ""
return m, nil
}
updated, cmd := m.form.Update(msg)
if fm, ok := updated.(*formModel); ok {
m.form = fm
}
return m, cmd
}
func (m *tuiModel) View() string {
var b strings.Builder
switch m.screen {
case screenList:
b.WriteString(m.list.View())
b.WriteString("\n")
b.WriteString(helpStyle.Render("Enter connect | Ctrl+A add | Ctrl+E edit | Ctrl+D del | Ctrl+T test | Ctrl+F search | Ctrl+Q quit"))
case screenSearch:
b.WriteString("Search: " + m.searchInput.View() + "\n")
b.WriteString(helpStyle.Render("Type to search | Enter confirm | Esc cancel"))
case screenForm:
b.WriteString(m.form.View())
}
if m.err != nil {
b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
m.err = nil
}
if m.success != "" {
b.WriteString("\n" + successStyle.Render(m.success))
m.success = ""
}
return b.String()
}
// --- Form model ---
type formModel struct {
edit bool
server *model.Server
inputs []textinput.Model
password textinput.Model
focusIdx int
testResult string
testOK bool
testResultTime time.Time
testing bool
saving bool
saved bool
savedTime time.Time
err error
spinner spinner.Model
width int
height int
groups string // cached groups list (comma-separated for display)
showGroups bool // show group dropdown
}
func newFormModel(w, h int) *formModel {
inputs := make([]textinput.Model, 10)
labels := []string{
"Alias",
"Display Name",
"Host",
"Port",
"User",
"Auth Method (password/key/key_passphrase/agent)",
"Identity File",
"ProxyJump",
"Group (type new or pick from list)",
"Notes",
}
for i, label := range labels {
inputs[i] = textinput.New()
inputs[i].Placeholder = label
inputs[i].CharLimit = 128
}
pw := textinput.New()
pw.Placeholder = "Password / Passphrase (stored in vault)"
pw.CharLimit = 256
pw.EchoMode = textinput.EchoPassword
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("12"))
inputs[0].Focus()
fm := &formModel{
inputs: inputs,
password: pw,
focusIdx: 0,
spinner: s,
width: w,
height: h,
}
// Load existing groups
if GetGroups != nil {
if groups, err := GetGroups(); err == nil && len(groups) > 0 {
fm.groups = strings.Join(groups, ", ")
}
}
return fm
}
func newEditFormModel(s *model.Server, w, h int) *formModel {
fm := newFormModel(w, h)
fm.edit = true
fm.server = s
fm.inputs[0].SetValue(s.Alias)
fm.inputs[1].SetValue(s.DisplayName)
fm.inputs[2].SetValue(s.Host)
fm.inputs[3].SetValue(fmt.Sprintf("%d", s.Port))
fm.inputs[4].SetValue(s.User)
fm.inputs[5].SetValue(string(s.AuthMethod))
fm.inputs[6].SetValue(s.IdentityFile)
fm.inputs[7].SetValue(s.ProxyJump)
fm.inputs[8].SetValue(s.GroupName)
fm.inputs[9].SetValue(s.Notes)
return fm
}
func (fm *formModel) Init() tea.Cmd {
return nil
}
func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Handle test/save completion
switch msg := msg.(type) {
case testDoneMsg:
fm.testing = false
if msg.ok {
fm.testResult = "Connection OK."
fm.testOK = true
} else {
fm.testResult = fmt.Sprintf("Connection failed:\n%s", msg.err)
fm.testOK = false
}
fm.testResultTime = time.Now()
fm.err = nil
return fm, nil
case saveDoneMsg:
fm.saving = false
if msg.err != nil {
fm.err = msg.err
fm.saved = false
} else {
fm.saved = true
fm.savedTime = time.Now()
fm.err = nil
}
return fm, nil
}
// Handle spinner tick while testing/saving
if fm.testing || fm.saving {
var cmd tea.Cmd
fm.spinner, cmd = fm.spinner.Update(msg)
if _, ok := msg.(tea.KeyMsg); ok {
return fm, cmd
}
return fm, cmd
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyTab:
fm.focusIdx++
total := len(fm.inputs) + 3
if fm.focusIdx >= total {
fm.focusIdx = 0
}
fm.updateFocus()
return fm, nil
case tea.KeyShiftTab:
fm.focusIdx--
if fm.focusIdx < 0 {
total := len(fm.inputs) + 3
fm.focusIdx = total - 1
}
fm.updateFocus()
return fm, nil
case tea.KeyEnter:
switch {
case fm.focusIdx == len(fm.inputs)+1:
return fm, fm.runTest()
case fm.focusIdx == len(fm.inputs)+2:
return fm, fm.runSave()
default:
fm.focusIdx++
total := len(fm.inputs) + 3
if fm.focusIdx >= total {
fm.focusIdx = 0
}
fm.updateFocus()
return fm, nil
}
case tea.KeyEsc:
return fm, nil
case tea.KeyDown:
fm.focusIdx++
total := len(fm.inputs) + 3
if fm.focusIdx >= total {
fm.focusIdx = 0
}
fm.updateFocus()
return fm, nil
case tea.KeyUp:
fm.focusIdx--
if fm.focusIdx < 0 {
total := len(fm.inputs) + 3
fm.focusIdx = total - 1
}
fm.updateFocus()
return fm, nil
}
}
if fm.focusIdx < len(fm.inputs) {
var cmd tea.Cmd
fm.inputs[fm.focusIdx], cmd = fm.inputs[fm.focusIdx].Update(msg)
return fm, cmd
}
if fm.focusIdx == len(fm.inputs) {
var cmd tea.Cmd
fm.password, cmd = fm.password.Update(msg)
return fm, cmd
}
return fm, nil
}
func (fm *formModel) updateFocus() {
for i := range fm.inputs {
fm.inputs[i].Blur()
fm.inputs[i].Prompt = blurredStyle.Render(fm.inputs[i].Placeholder + ": ")
}
fm.password.Blur()
fm.password.Prompt = blurredStyle.Render(fm.password.Placeholder + ": ")
if fm.focusIdx < len(fm.inputs) {
fm.inputs[fm.focusIdx].Focus()
fm.inputs[fm.focusIdx].Prompt = focusedStyle.Render(fm.inputs[fm.focusIdx].Placeholder + "> ")
} else if fm.focusIdx == len(fm.inputs) {
fm.password.Focus()
fm.password.Prompt = focusedStyle.Render(fm.password.Placeholder + "> ")
}
}
func (fm *formModel) runTest() tea.Cmd {
fm.testing = true
fm.testResult = ""
fm.err = nil
fm.saved = false
s := fm.buildServer()
pw := fm.password.Value()
return tea.Batch(
fm.spinner.Tick,
func() tea.Msg {
// Use direct password test if available (for form test before save)
if TestConnectionWithPassword != nil {
ok, testErr := TestConnectionWithPassword(s, pw)
return testDoneMsg{ok: ok, err: testErr}
}
// Fallback to vault-based test
if s.AuthMethod == model.AuthPassword && pw == "" {
return testDoneMsg{ok: false, err: "Password is required for password auth."}
}
ok, testErr := TestConnection(s)
return testDoneMsg{ok: ok, err: testErr}
},
)
}
func (fm *formModel) runSave() tea.Cmd {
fm.saving = true
fm.err = nil
fm.saved = false
fm.testResult = ""
s := fm.buildServer()
pw := fm.password.Value()
return tea.Batch(
fm.spinner.Tick,
func() tea.Msg {
if s.Alias == "" {
return saveDoneMsg{err: fmt.Errorf("alias is required")}
}
if s.Host == "" {
return saveDoneMsg{err: fmt.Errorf("host is required")}
}
err := SaveServer(s, pw)
return saveDoneMsg{err: err}
},
)
}
func (fm *formModel) buildServer() *model.Server {
port := 22
fmt.Sscanf(fm.inputs[3].Value(), "%d", &port)
authMethod := model.AuthMethod(fm.inputs[5].Value())
if authMethod == "" {
authMethod = model.AuthKey
}
return &model.Server{
Alias: fm.inputs[0].Value(),
DisplayName: fm.inputs[1].Value(),
Host: fm.inputs[2].Value(),
Port: port,
User: fm.inputs[4].Value(),
AuthMethod: authMethod,
IdentityFile: fm.inputs[6].Value(),
ProxyJump: fm.inputs[7].Value(),
GroupName: fm.inputs[8].Value(),
Notes: fm.inputs[9].Value(),
}
}
func (fm *formModel) View() string {
var b strings.Builder
title := "Add Server"
if fm.edit {
title = "Edit Server: " + fm.server.Alias
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
for i := range fm.inputs {
b.WriteString(fm.inputs[i].View())
b.WriteString("\n")
// Show existing groups hint under Group field
if i == 8 && fm.groups != "" {
b.WriteString(helpStyle.Render(" Groups: " + fm.groups + "\n"))
}
}
b.WriteString(fm.password.View())
b.WriteString("\n")
showResults := time.Since(fm.testResultTime) < 10*time.Second || time.Since(fm.savedTime) < 10*time.Second
if fm.testing {
b.WriteString("\n" + fm.spinner.View() + " Testing connection...\n")
} else if fm.saving {
b.WriteString("\n" + fm.spinner.View() + " Saving...\n")
} else if showResults {
if fm.testResult != "" {
b.WriteString("\n")
if fm.testOK {
b.WriteString(testOKStyle.Render("✓ " + fm.testResult))
} else {
b.WriteString(testFailStyle.Render("✗ " + fm.testResult))
}
b.WriteString("\n")
}
if fm.saved {
b.WriteString("\n" + successStyle.Render("✓ Saved.") + "\n")
}
if fm.err != nil {
b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) + "\n")
}
}
testBtn := "[ Test ]"
saveBtn := "[ Save ]"
if fm.focusIdx == len(fm.inputs)+1 {
testBtn = selectedStyle.Render(testBtn)
} else {
testBtn = normalStyle.Render(testBtn)
}
if fm.focusIdx == len(fm.inputs)+2 {
saveBtn = selectedStyle.Render(saveBtn)
} else {
saveBtn = normalStyle.Render(saveBtn)
}
b.WriteString("\n" + testBtn + " " + saveBtn + "\n\n")
b.WriteString(helpStyle.Render("Tab/↓ next | ↑ prev | Enter select | Esc back"))
return b.String()
}