package tui import ( "context" "fmt" "io" "strings" "time" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox" ) // SelectedHost is returned when the user confirms a host in the TUI. type SelectedHost struct { Name string IP string } // --- bubbletea messages --- type debounceMsg struct{ query string } type searchResultMsg struct { query string entries []netbox.HostEntry err error } // --- list item --- type hostItem struct { name string ip string kind string } func (h hostItem) Title() string { return h.name } func (h hostItem) Description() string { return fmt.Sprintf("%s [%s]", h.ip, h.kind) } func (h hostItem) FilterValue() string { return h.name } // --- compact list delegate --- type compactDelegate struct{} func (d compactDelegate) Height() int { return 1 } func (d compactDelegate) Spacing() int { return 0 } func (d compactDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } func (d compactDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { h, ok := item.(hostItem) if !ok { return } line := fmt.Sprintf(" %s %s", h.name, lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(h.ip)) if index == m.Index() { line = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("> " + strings.TrimPrefix(line, " ")) } fmt.Fprintln(w, line) } // --- Model --- type Model struct { input textinput.Model list list.Model client *netbox.Client cache *cache.Cache lastSent string // last query sent to NetBox (or served from cache) seq int // sequence number to discard stale results loading bool err error selected *SelectedHost width int height int } func New(client *netbox.Client, c *cache.Cache) *Model { ti := textinput.New() ti.Placeholder = "Search hostname…" ti.Focus() l := list.New(nil, compactDelegate{}, 0, 0) l.SetShowHelp(false) l.SetShowTitle(false) l.SetShowStatusBar(false) l.SetFilteringEnabled(false) return &Model{ input: ti, list: l, client: client, cache: c, } } func (m *Model) Init() tea.Cmd { return textinput.Blink } func (m *Model) 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) return m, nil case tea.KeyMsg: switch msg.String() { case "ctrl+c", "esc": return m, tea.Quit case "enter": if item, ok := m.list.SelectedItem().(hostItem); ok { m.selected = &SelectedHost{Name: item.name, IP: item.ip} return m, tea.Quit } case "tab": // Copy the top result into the search field. if m.list.Items() != nil && len(m.list.Items()) > 0 { if item, ok := m.list.Items()[0].(hostItem); ok { m.input.SetValue(item.name) m.input.CursorEnd() } } return m, nil } case debounceMsg: // Only query if the input has changed since the last request. q := m.input.Value() if q == m.lastSent { return m, nil } m.lastSent = q m.loading = true m.seq++ seq := m.seq return m, m.doSearch(q, seq) case searchResultMsg: if msg.query != m.lastSent { return m, nil // discard stale result } m.loading = false if msg.err != nil { m.err = msg.err return m, nil } items := make([]list.Item, len(msg.entries)) for i, e := range msg.entries { ip := e.PrimaryIP4 if ip == "" { ip = e.PrimaryIP6 } items[i] = hostItem{name: e.Name, ip: ip, kind: e.Kind} } m.list.SetItems(items) m.err = nil return m, nil } // Forward to text input and restart the debounce timer. var cmds []tea.Cmd var inputCmd tea.Cmd m.input, inputCmd = m.input.Update(msg) cmds = append(cmds, inputCmd) cmds = append(cmds, m.startDebounce()) var listCmd tea.Cmd m.list, listCmd = m.list.Update(msg) cmds = append(cmds, listCmd) return m, tea.Batch(cmds...) } func (m *Model) View() string { var sb strings.Builder title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("netssh") sb.WriteString(title + "\n\n") sb.WriteString(m.input.View() + "\n") if m.loading { sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" searching…") + "\n") } else if m.err != nil { sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(" error: "+m.err.Error()) + "\n") } else { sb.WriteString(m.list.View()) } return sb.String() } // Selected returns the host chosen by the user, or nil if none was selected. func (m *Model) Selected() *SelectedHost { return m.selected } func (m *Model) startDebounce() tea.Cmd { return tea.Tick(300*time.Millisecond, func(_ time.Time) tea.Msg { return debounceMsg{query: m.input.Value()} }) } func (m *Model) doSearch(query string, seq int) tea.Cmd { return func() tea.Msg { // Return cache hits immediately without a network round-trip. if m.cache != nil { if cached := m.cache.Search(query); len(cached) > 0 { entries := make([]netbox.HostEntry, len(cached)) for i, c := range cached { entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind} } return searchResultMsg{query: query, entries: entries} } } if m.client == nil { return searchResultMsg{query: query, entries: nil} } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() entries, err := m.client.Search(ctx, query) _ = seq return searchResultMsg{query: query, entries: entries, err: err} } }