package tui import ( "fmt" "github.com/karol-broda/snitch/internal/collector" "github.com/karol-broda/snitch/internal/theme" "time" tea "github.com/charmbracelet/bubbletea" ) type model struct { connections []collector.Connection cursor int width int height int // filtering showTCP bool showUDP bool showListening bool showEstablished bool showOther bool searchQuery string searchActive bool // sorting sortField collector.SortField sortReverse bool // display options resolveAddrs bool // when true, resolve IP addresses to hostnames resolvePorts bool // when true, resolve port numbers to service names // ui state theme *theme.Theme showHelp bool showDetail bool selected *collector.Connection interval time.Duration lastRefresh time.Time err error // watched processes watchedPIDs map[int]bool // kill confirmation showKillConfirm bool killTarget *collector.Connection // status message (temporary feedback) statusMessage string statusExpiry time.Time } type Options struct { Theme string Interval time.Duration TCP bool UDP bool Listening bool Established bool Other bool FilterSet bool // true if user specified any filter flags ResolveAddrs bool // when true, resolve IP addresses to hostnames ResolvePorts bool // when true, resolve port numbers to service names NoCache bool // when true, disable DNS caching } func New(opts Options) model { interval := opts.Interval if interval == 0 { interval = time.Second } // default: show everything showTCP := true showUDP := true showListening := true showEstablished := true showOther := true // if user specified filters, use those instead if opts.FilterSet { showTCP = opts.TCP showUDP = opts.UDP showListening = opts.Listening showEstablished = opts.Established showOther = opts.Other // if only proto filters set, show all states if !opts.Listening && !opts.Established && !opts.Other { showListening = true showEstablished = true showOther = true } // if only state filters set, show all protos if !opts.TCP && !opts.UDP { showTCP = true showUDP = true } } return model{ connections: []collector.Connection{}, showTCP: showTCP, showUDP: showUDP, showListening: showListening, showEstablished: showEstablished, showOther: showOther, sortField: collector.SortByLport, resolveAddrs: opts.ResolveAddrs, resolvePorts: opts.ResolvePorts, theme: theme.GetTheme(opts.Theme), interval: interval, lastRefresh: time.Now(), watchedPIDs: make(map[int]bool), } } func (m model) Init() tea.Cmd { return tea.Batch( tea.HideCursor, m.fetchData(), m.tick(), ) } 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 return m, nil case tea.KeyMsg: return m.handleKey(msg) case tickMsg: return m, tea.Batch(m.fetchData(), m.tick()) case dataMsg: m.connections = msg.connections m.lastRefresh = time.Now() m.applySorting() m.clampCursor() return m, nil case errMsg: m.err = msg.err return m, nil case killResultMsg: if msg.success { m.statusMessage = fmt.Sprintf("killed %s (pid %d)", msg.process, msg.pid) } else { m.statusMessage = fmt.Sprintf("failed to kill pid %d: %v", msg.pid, msg.err) } m.statusExpiry = time.Now().Add(3 * time.Second) return m, tea.Batch(m.fetchData(), clearStatusAfter(3*time.Second)) case clearStatusMsg: if time.Now().After(m.statusExpiry) { m.statusMessage = "" } return m, nil } return m, nil } func (m model) View() string { if m.err != nil { return m.renderError() } if m.showHelp { return m.renderHelp() } if m.showDetail && m.selected != nil { return m.renderDetail() } main := m.renderMain() // overlay kill confirmation modal on top of main view if m.showKillConfirm && m.killTarget != nil { return m.overlayModal(main, m.renderKillModal()) } return main } func (m *model) applySorting() { direction := collector.SortAsc if m.sortReverse { direction = collector.SortDesc } collector.SortConnections(m.connections, collector.SortOptions{ Field: m.sortField, Direction: direction, }) } func (m *model) clampCursor() { visible := m.visibleConnections() if m.cursor >= len(visible) { m.cursor = len(visible) - 1 } if m.cursor < 0 { m.cursor = 0 } } func (m model) visibleConnections() []collector.Connection { var watched []collector.Connection var unwatched []collector.Connection for _, c := range m.connections { if !m.matchesFilters(c) { continue } if m.searchQuery != "" && !m.matchesSearch(c) { continue } if m.isWatched(c.PID) { watched = append(watched, c) } else { unwatched = append(unwatched, c) } } // watched connections appear first return append(watched, unwatched...) } func (m model) matchesFilters(c collector.Connection) bool { isTCP := c.Proto == "tcp" || c.Proto == "tcp6" isUDP := c.Proto == "udp" || c.Proto == "udp6" if isTCP && !m.showTCP { return false } if isUDP && !m.showUDP { return false } isListening := c.State == "LISTEN" isEstablished := c.State == "ESTABLISHED" isOther := !isListening && !isEstablished if isListening && !m.showListening { return false } if isEstablished && !m.showEstablished { return false } if isOther && !m.showOther { return false } return true } func (m model) matchesSearch(c collector.Connection) bool { return containsIgnoreCase(c.Process, m.searchQuery) || containsIgnoreCase(c.Laddr, m.searchQuery) || containsIgnoreCase(c.Raddr, m.searchQuery) || containsIgnoreCase(c.User, m.searchQuery) || containsIgnoreCase(c.Proto, m.searchQuery) || containsIgnoreCase(c.State, m.searchQuery) } func (m model) isWatched(pid int) bool { if pid <= 0 { return false } return m.watchedPIDs[pid] } func (m *model) toggleWatch(pid int) { if pid <= 0 { return } if m.watchedPIDs[pid] { delete(m.watchedPIDs, pid) } else { m.watchedPIDs[pid] = true } } func (m model) watchedCount() int { return len(m.watchedPIDs) }