Files
ssh-netbox-wrapper/internal/tui/model.go
T
2026-10-27 23:14:42 +01:00

657 lines
17 KiB
Go

package tui
import (
"context"
"fmt"
"io"
"sort"
"strconv"
"strings"
"time"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
)
// SelectedHost is returned when the user confirms a host in the TUI.
type SelectedHost struct {
Kind string
Name string
IP string
User string // empty = use config default
Port string // empty = use default port
}
// --- bubbletea messages ---
type debounceMsg struct {
query string
seq int
}
type noteResultMsg struct {
note notes.Note
err error
saved bool
}
type searchResultMsg struct {
query string
seq int
entries []netbox.HostEntry
err error
recent bool // true when this is a recently-used list, not a search result
}
// --- list item ---
type hostItem struct {
name string
id int
ip string
kind string
tags []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.Fprint(w, line)
}
// --- filter ---
type filterOpts struct {
tags []string
kind string
}
func parseFilter(s string) filterOpts {
var f filterOpts
for _, part := range strings.Fields(s) {
if after, ok := strings.CutPrefix(part, "tag:"); ok {
f.tags = append(f.tags, after)
} else if after, ok := strings.CutPrefix(part, "kind:"); ok {
f.kind = after
}
}
return f
}
func applyFilter(entries []netbox.HostEntry, f filterOpts) []netbox.HostEntry {
if len(f.tags) == 0 && f.kind == "" {
return entries
}
out := make([]netbox.HostEntry, 0, len(entries))
for _, e := range entries {
if f.kind != "" && e.Kind != f.kind {
continue
}
if len(f.tags) > 0 {
tagSet := make(map[string]bool, len(e.Tags))
for _, t := range e.Tags {
tagSet[strings.ToLower(t)] = true
}
allMatch := true
for _, want := range f.tags {
if !tagSet[strings.ToLower(want)] {
allMatch = false
break
}
}
if !allMatch {
continue
}
}
out = append(out, e)
}
return out
}
// --- Model ---
type Model struct {
noteErr error
editHost *hostItem
noteInput textarea.Model
noteService *notes.Service
note notes.Note
noteMode, noteRemote, noteBusy bool
noteLoaded bool
defaultPort int
input textinput.Model
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
editInput textinput.Model // user@host:port inline editor
list list.Model
client *netbox.Client
cache *cache.Cache
defaultUser string
lastSent string // last query sent to NetBox (or served from cache)
lastResults []netbox.HostEntry // raw results before filter applied
seq int // sequence number to discard stale results
loading bool
err error
selected *SelectedHost
width int
height int
recentMode bool // true when showing recent hosts (empty search, initial state)
filterOpen bool // Ctrl+F toggles
editMode bool // Ctrl+E on the selected item
}
func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model {
ti := textinput.New()
ti.Placeholder = "Search hostname…"
ti.Focus()
fi := textinput.New()
fi.Placeholder = "tag:prod kind:vm"
fi.Prompt = "Filter: "
ei := textinput.New()
ei.Prompt = ""
l := list.New(nil, compactDelegate{}, 0, 0)
l.SetShowHelp(false)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
l.DisableQuitKeybindings()
port := 22
if len(defaultPorts) > 0 && defaultPorts[0] > 0 {
port = defaultPorts[0]
}
ni := textarea.New()
ni.CharLimit = 0
ni.SetHeight(10)
return &Model{
noteInput: ni,
defaultPort: port,
input: ti,
filterInput: fi,
editInput: ei,
list: l,
client: client,
cache: c,
defaultUser: defaultUser,
recentMode: true,
}
}
func (m *Model) Init() tea.Cmd {
return tea.Batch(textinput.Blink, m.loadRecent())
}
// loadRecent returns a Cmd that immediately resolves to the recently-used host list.
func (m *Model) loadRecent() tea.Cmd {
return func() tea.Msg {
if m.cache == nil {
return searchResultMsg{query: "", entries: nil, recent: true}
}
recent := m.cache.RecentlyUsed(10)
entries := make([]netbox.HostEntry, len(recent))
for i, e := range recent {
entries[i] = netbox.HostEntry{Name: e.Name, PrimaryIP4: e.IP, Kind: e.Kind, Tags: e.Tags}
}
return searchResultMsg{query: "", entries: entries, recent: true}
}
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
defer m.resizeList()
switch msg := msg.(type) {
case noteResultMsg:
m.noteBusy = false
m.noteErr = msg.err
if msg.err == nil {
m.noteLoaded = true
m.note = msg.note
m.noteInput.SetValue(msg.note.Text)
if msg.saved {
m.noteMode = false
m.noteInput.Blur()
m.input.Focus()
}
}
return m, nil
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.input.SetWidth(max(1, msg.Width-lipgloss.Width(m.input.Prompt)))
m.filterInput.SetWidth(max(1, msg.Width-lipgloss.Width(m.filterInput.Prompt)))
m.editInput.SetWidth(max(1, msg.Width-lipgloss.Width("Connect as: ")))
m.noteInput.SetWidth(max(10, msg.Width-4))
return m, nil
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
if m.noteMode {
if m.noteBusy {
return m, nil
}
switch msg.String() {
case "esc":
m.noteMode = false
m.noteInput.Blur()
m.input.Focus()
m.noteErr = nil
return m, nil
case "ctrl+s":
if !m.noteLoaded {
return m, nil
}
note := m.note
note.Text = m.noteInput.Value()
remote, service := m.noteRemote, m.noteService
m.noteBusy = true
return m, func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return noteResultMsg{note: note, saved: true, err: service.Write(ctx, note, remote)}
}
}
var cmd tea.Cmd
m.noteInput, cmd = m.noteInput.Update(msg)
return m, cmd
}
// --- Edit mode ---
if m.editMode {
switch msg.String() {
case "esc":
m.editMode = false
m.editInput.Blur()
m.input.Focus()
return m, nil
case "enter":
if m.editHost != nil {
item := *m.editHost
value := m.editInput.Value()
if idx := strings.LastIndex(value, ":"); idx >= 0 {
port, err := strconv.Atoi(strings.TrimSpace(value[idx+1:]))
if err != nil || port < 1 || port > 65535 {
m.err = fmt.Errorf("port must be between 1 and 65535")
return m, nil
}
}
m.selected = m.buildSelected(item)
}
return m, tea.Quit
}
var cmd tea.Cmd
m.editInput, cmd = m.editInput.Update(msg)
return m, cmd
}
// --- Filter focused ---
if m.filterOpen {
switch msg.String() {
case "ctrl+f", "esc":
m.filterOpen = false
m.filterInput.Blur()
m.filterInput.SetValue("")
m.input.Focus()
m.updateListItems(m.lastResults)
return m, nil
case "enter":
m.filterOpen = false
m.filterInput.Blur()
m.input.Focus()
m.updateListItems(m.lastResults)
return m, nil
}
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
m.updateListItems(m.lastResults)
return m, cmd
}
// --- Normal mode ---
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
case "ctrl+f":
m.filterOpen = true
m.input.Blur()
m.filterInput.Focus()
return m, nil
case "enter":
if item, ok := m.list.SelectedItem().(hostItem); ok {
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind}
return m, tea.Quit
}
case "ctrl+e":
if item, ok := m.list.SelectedItem().(hostItem); ok {
m.openEdit(item)
return m, nil
}
case "ctrl+n", "ctrl+b":
if item, ok := m.list.SelectedItem().(hostItem); ok && m.noteService != nil {
m.noteMode = true
m.noteLoaded = false
m.noteBusy = true
m.noteRemote = msg.String() == "ctrl+b"
m.input.Blur()
m.noteInput.Focus()
m.noteInput.SetValue("")
m.noteErr = nil
host := netbox.HostEntry{ID: item.id, Name: item.name, Kind: item.kind}
m.note = notes.Note{Host: host}
remote, service := m.noteRemote, m.noteService
return m, func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
note, err := service.Read(ctx, host, remote)
return noteResultMsg{note: note, err: err}
}
}
return m, nil
case "tab":
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()
m.seq++
return m, m.startDebounce()
}
}
return m, nil
}
case debounceMsg:
q := m.input.Value()
if msg.seq != m.seq || msg.query != q {
return m, nil
}
m.lastSent = q
m.recentMode = false
m.loading = true
seq := m.seq
return m, m.doSearch(q, seq)
case searchResultMsg:
if msg.recent {
if m.input.Value() != "" || m.seq > 0 {
return m, nil
}
m.recentMode = true
m.lastResults = msg.entries
m.updateListItems(msg.entries)
return m, nil
}
if msg.seq != m.seq || msg.query != m.input.Value() {
return m, nil // discard stale result
}
m.loading = false
m.err = msg.err
m.lastResults = msg.entries
m.updateListItems(msg.entries)
return m, nil
}
if m.noteMode {
if m.noteBusy {
return m, nil
}
var cmd tea.Cmd
m.noteInput, cmd = m.noteInput.Update(msg)
return m, cmd
}
// Paste and cursor events are separate from key presses in Bubble Tea v2.
// Route them to the focused editor as well.
if m.editMode {
var cmd tea.Cmd
m.editInput, cmd = m.editInput.Update(msg)
return m, cmd
}
if m.filterOpen {
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
m.updateListItems(m.lastResults)
return m, cmd
}
// Forward to search input and restart debounce timer.
var cmds []tea.Cmd
var inputCmd tea.Cmd
oldQuery := m.input.Value()
m.input, inputCmd = m.input.Update(msg)
cmds = append(cmds, inputCmd)
if m.input.Value() != oldQuery {
m.seq++
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() tea.View {
view := tea.NewView(m.viewContent())
view.AltScreen = true
return view
}
func (m *Model) viewContent() string {
if m.noteMode {
target := "local"
if m.noteRemote {
target = "NetBox comments"
}
status := "ctrl+s save esc cancel"
if m.noteBusy {
status = "Loading / saving…"
}
errText := ""
if m.noteErr != nil {
errText = "\nError: " + m.noteErr.Error()
}
return fmt.Sprintf("Notes [%s] — %s\n\n%s\n%s%s\n", target, m.note.Host.Name, m.noteInput.View(), status, errText)
}
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.filterOpen {
sb.WriteString(m.filterInput.View() + "\n")
}
if m.loading {
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" searching…") + "\n")
} else {
if m.err != nil {
warning := " warning: " + strings.ReplaceAll(m.err.Error(), "\n", "; ")
style := lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
if m.width > 0 {
style = style.MaxWidth(m.width)
}
sb.WriteString(style.Render(warning) + "\n")
}
if m.recentMode && m.input.Value() == "" {
if len(m.list.Items()) > 0 {
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" Recent connections") + "\n")
}
}
sb.WriteString(m.list.View())
}
if m.editMode {
sb.WriteString("\n")
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Connect as: "))
sb.WriteString(m.editInput.View() + "\n")
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
} else {
hint := "ctrl+c quit enter connect ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
}
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 {
query, seq := m.input.Value(), m.seq
return tea.Tick(300*time.Millisecond, func(_ time.Time) tea.Msg {
return debounceMsg{query: query, seq: seq}
})
}
func (m *Model) doSearch(query string, seq int) tea.Cmd {
opts := m.netboxSearchOpts()
return func() tea.Msg {
var cached []netbox.HostEntry
if m.cache != nil {
for _, entry := range m.cache.Search(query) {
source, name := netbox.SplitTarget(entry.Name)
cached = append(cached, netbox.HostEntry{Source: source, Name: name, PrimaryIP4: entry.IP, Kind: entry.Kind, Tags: entry.Tags})
}
}
if m.client == nil || (len(cached) > 0 && !m.client.MultipleSources()) {
return searchResultMsg{query: query, seq: seq, entries: cached}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
entries, err := m.client.Search(ctx, query, opts)
// A cached hit in one instance must not hide live hosts in other instances.
seen := make(map[string]bool)
for _, e := range entries {
seen[e.Target()+"\x00"+e.Kind] = true
}
for _, e := range cached {
if !seen[e.Target()+"\x00"+e.Kind] {
entries = append(entries, e)
}
}
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Target() < entries[j].Target() })
return searchResultMsg{query: query, seq: seq, entries: entries, err: err}
}
}
// netboxSearchOpts derives SearchOptions from the current filter input.
func (m *Model) netboxSearchOpts() netbox.SearchOptions {
f := parseFilter(m.filterInput.Value())
var tag string
if len(f.tags) > 0 {
tag = f.tags[0]
}
return netbox.SearchOptions{Tag: tag, Kind: f.kind}
}
// updateListItems applies the active filter and sets the list items.
func (m *Model) updateListItems(entries []netbox.HostEntry) {
f := parseFilter(m.filterInput.Value())
filtered := applyFilter(entries, f)
items := make([]list.Item, len(filtered))
for i, e := range filtered {
ip := e.PrimaryIP4
if ip == "" {
ip = e.PrimaryIP6
}
items[i] = hostItem{id: e.ID, name: e.Target(), ip: ip, kind: e.Kind, tags: e.Tags}
}
m.list.SetItems(items)
m.resizeList()
}
// resizeList keeps the footer visible when the recent heading or editors change.
func (m *Model) resizeList() {
if m.width <= 0 || m.height <= 0 {
return
}
extraRows := 5 // title, blank line, search, footer separator, footer
if m.err != nil {
extraRows++
}
if m.recentMode && m.input.Value() == "" && len(m.list.Items()) > 0 {
extraRows++
}
if m.filterOpen {
extraRows++
}
if m.editMode {
extraRows++
}
height := max(1, m.height-extraRows)
if m.list.Width() != m.width || m.list.Height() != height {
m.list.SetSize(m.width, height)
}
}
// openEdit switches to edit mode for the given list item.
func (m *Model) openEdit(item hostItem) {
m.editMode = true
m.editHost = &item
m.input.Blur()
user := m.defaultUser
prefix := ""
if user != "" {
prefix = user + "@"
}
m.editInput.SetValue(fmt.Sprintf("%s%s:%d", prefix, item.name, m.defaultPort))
m.editInput.CursorEnd()
m.editInput.Focus()
}
// buildSelected parses the editInput to extract user/port overrides.
func (m *Model) buildSelected(item hostItem) *SelectedHost {
s := strings.TrimSpace(m.editInput.Value())
sel := &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind}
// Extract user: everything before @
if idx := strings.Index(s, "@"); idx != -1 {
sel.User = strings.TrimSpace(s[:idx])
s = s[idx+1:]
}
// Extract port: after the last colon, if it's a number
if idx := strings.LastIndex(s, ":"); idx != -1 {
portStr := strings.TrimSpace(s[idx+1:])
if port, err := strconv.Atoi(portStr); err == nil && port > 0 && port <= 65535 {
sel.Port = portStr
}
}
return sel
}
func (m *Model) SetNotes(service *notes.Service) { m.noteService = service }