package tui import ( "context" "fmt" "io" "strconv" "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 User string // empty = use config default Port string // empty = use default port } // --- bubbletea messages --- type debounceMsg struct{ query string } type searchResultMsg struct { query string 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 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.Fprintln(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 { 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 // 'e' on selected item } func New(client *netbox.Client, c *cache.Cache, defaultUser string) *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) return &Model{ 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) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height extraRows := 4 if m.filterOpen { extraRows++ } if m.editMode { extraRows += 2 } m.list.SetSize(msg.Width, msg.Height-extraRows) return m, nil case tea.KeyMsg: // --- 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 item, ok := m.list.SelectedItem().(hostItem); ok { 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} return m, tea.Quit } case "e": if item, ok := m.list.SelectedItem().(hostItem); ok { m.openEdit(item) 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() } } return m, nil } case debounceMsg: q := m.input.Value() if q == m.lastSent { return m, nil } m.lastSent = q m.recentMode = false m.loading = true m.seq++ seq := m.seq return m, m.doSearch(q, seq) case searchResultMsg: if msg.recent { m.recentMode = true m.lastResults = msg.entries m.updateListItems(msg.entries) return m, nil } 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 } m.lastResults = msg.entries m.updateListItems(msg.entries) m.err = nil return m, nil } // Forward to search input and restart 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.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 { sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(" error: "+m.err.Error()) + "\n") } else { 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 e edit ctrl+f filter" 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 { 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 { opts := m.netboxSearchOpts() 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, Tags: c.Tags} } 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, opts) _ = seq return searchResultMsg{query: query, 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{name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags} } m.list.SetItems(items) } // openEdit switches to edit mode for the given list item. func (m *Model) openEdit(item hostItem) { m.editMode = true m.input.Blur() user := m.defaultUser if user == "" { user = item.name } m.editInput.SetValue(fmt.Sprintf("%s@%s:22", user, item.name)) 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} // 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 _, err := strconv.Atoi(portStr); err == nil && portStr != "22" { sel.Port = portStr } } return sel }