c7568e0bf4
- tea.Batch(tea.ClearScreen, sshConnectCmd) before SSH starts so TUI output doesn't bleed into SSH terminal - tea.ClearScreen after SSH exits so SSH output doesn't ghost in TUI - Host list now paginates: only (term_height - 8) / 5 cards shown, viewport follows selectedIndex with centered scrolling - Shows 'N of M hosts' indicator when content is clipped - Added 'e' key alias for editing host (Ctrl+E or E)
241 lines
4.6 KiB
Go
241 lines
4.6 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
|
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
|
|
)
|
|
|
|
// Screen represents different TUI screens (deprecated, use tabs)
|
|
type Screen int
|
|
|
|
const (
|
|
ScreenHostList Screen = iota
|
|
ScreenConnection
|
|
ScreenSettings
|
|
)
|
|
|
|
// Model represents the main TUI model
|
|
type Model struct {
|
|
tabs *TabManager
|
|
CurrentScreen Screen // deprecated, kept for backward compat
|
|
Hosts []*models.Host // deprecated
|
|
SelectedIndex int // deprecated
|
|
Error error
|
|
Quit bool
|
|
dataDir string
|
|
}
|
|
|
|
// New creates a new TUI model
|
|
func New() *Model {
|
|
hostList := NewHostListTab()
|
|
tm := NewTabManager(hostList)
|
|
|
|
return &Model{
|
|
tabs: tm,
|
|
CurrentScreen: ScreenHostList,
|
|
SelectedIndex: 0,
|
|
Quit: false,
|
|
}
|
|
}
|
|
|
|
// Init initializes the TUI
|
|
func (m *Model) Init() tea.Cmd {
|
|
return m.tabs.Init()
|
|
}
|
|
|
|
// Update handles messages and updates the model
|
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
// Only hard quit on ctrl+c (let tabs handle q)
|
|
if msg.String() == "ctrl+c" {
|
|
m.Quit = true
|
|
return m, tea.Quit
|
|
}
|
|
|
|
case quitMsg:
|
|
m.Quit = true
|
|
return m, tea.Quit
|
|
|
|
case sshConnectToMsg:
|
|
return m, tea.Batch(tea.ClearScreen, sshConnectCmd(msg.host, m.dataDir))
|
|
|
|
case sshExitMsg:
|
|
if msg.err != nil {
|
|
m.Error = msg.err
|
|
}
|
|
return m, tea.ClearScreen
|
|
|
|
case openHostFormMsg:
|
|
var tab Tab
|
|
if msg.editing != nil {
|
|
tab = NewEditHostFormTab(msg.editing, m.dataDir)
|
|
} else {
|
|
tab = NewAddHostFormTab(m.dataDir)
|
|
}
|
|
cmd := m.tabs.Add(tab)
|
|
return m, cmd
|
|
|
|
case openSFTPMsg:
|
|
tab := NewSFTPBrowserTab(msg.host, m.dataDir)
|
|
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()
|
|
}
|
|
return m, nil
|
|
|
|
case saveHostResultMsg:
|
|
if msg.err != nil {
|
|
m.Error = msg.err
|
|
return m, nil
|
|
}
|
|
// Close the form tab, switch back to host list
|
|
if m.tabs.Len() > 1 {
|
|
m.tabs.CloseActive()
|
|
}
|
|
// Reload hosts
|
|
return m, func() tea.Msg {
|
|
store, err := storage.NewJSONStorage(m.dataDir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
hosts, err := store.ListHosts(context.Background())
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return loadedHostsMsg{hosts: hosts}
|
|
}
|
|
|
|
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
|
|
|
|
}
|
|
|
|
cmd, err := m.tabs.Update(msg)
|
|
if err != nil {
|
|
m.Error = err
|
|
}
|
|
|
|
// Sync deprecated fields
|
|
if ht := FindHostListTab(m.tabs.tabs); ht != nil {
|
|
m.Hosts = ht.Hosts()
|
|
m.SelectedIndex = ht.SelectedIndex()
|
|
}
|
|
|
|
return m, cmd
|
|
}
|
|
|
|
// View renders the TUI
|
|
func (m *Model) View() string {
|
|
if m.Quit {
|
|
m.tabs = nil
|
|
return "Thanks for using hostkeeper!\n"
|
|
}
|
|
|
|
if m.tabs == nil || m.tabs.Len() == 0 {
|
|
return "No tabs open. Press 'q' to quit.\n"
|
|
}
|
|
|
|
// Pass error to host list tab for display
|
|
if m.Error != nil {
|
|
if ht := FindHostListTab(m.tabs.tabs); ht != nil {
|
|
ht.err = m.Error
|
|
}
|
|
m.Error = nil
|
|
}
|
|
|
|
return m.tabs.View()
|
|
}
|
|
|
|
// LoadHosts loads hosts into the TUI model
|
|
func (m *Model) LoadHosts(hosts []*models.Host) {
|
|
if m.tabs == nil {
|
|
return
|
|
}
|
|
|
|
if ht := FindHostListTab(m.tabs.tabs); ht != nil {
|
|
ht.SetHosts(hosts)
|
|
m.Hosts = hosts
|
|
}
|
|
}
|
|
|
|
// SetDataDir sets the data directory for SSH connections
|
|
func (m *Model) SetDataDir(dir string) {
|
|
m.dataDir = dir
|
|
}
|
|
|
|
// TabManager returns the underlying tab manager
|
|
func (m *Model) TabManager() *TabManager {
|
|
return m.tabs
|
|
}
|