diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-25 21:47:11 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-25 21:47:11 +0200 |
| commit | 59f3d951dd221b21bb6459476352de61984c4c2f (patch) | |
| tree | 4a1083487b6368569b48bcb7192ddcfd297b6313 /internal/tui | |
| parent | 329543c14c07776a9a1de1284ab1f825cd4fa496 (diff) | |
Add runtime probes modal model for TUI
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/probes/model.go | 315 | ||||
| -rw-r--r-- | internal/tui/probes/model_test.go | 80 |
2 files changed, 395 insertions, 0 deletions
diff --git a/internal/tui/probes/model.go b/internal/tui/probes/model.go new file mode 100644 index 0000000..5715d03 --- /dev/null +++ b/internal/tui/probes/model.go @@ -0,0 +1,315 @@ +package probes + +import ( + "fmt" + "ior/internal/probemanager" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Manager defines the probe operations used by the modal. +type Manager interface { + States() []probemanager.ProbeState + Toggle(syscall string) error + ActiveCount() (int, int) +} + +// ProbeToggledMsg reports completion of an async toggle operation. +type ProbeToggledMsg struct { + Syscall string + Err error +} + +// Model is the probe toggle modal state. +type Model struct { + visible bool + probes []probemanager.ProbeState + + cursor int + offset int + + search string + searching bool + textInput textinput.Model + + lastErr string + manager Manager + height int +} + +func NewModel(manager Manager) Model { + ti := textinput.New() + ti.Prompt = "/ " + ti.CharLimit = 0 + ti.Width = 28 + return Model{ + manager: manager, + textInput: ti, + } +} + +func (m Model) Visible() bool { return m.visible } + +func (m Model) Open() Model { + m.visible = true + m.searching = false + m.lastErr = "" + m.textInput.Blur() + m.reload() + m.clampCursor() + return m +} + +func (m Model) Close() Model { + m.visible = false + m.searching = false + m.textInput.Blur() + m.lastErr = "" + return m +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + if !m.visible { + return m, nil + } + + switch msg := msg.(type) { + case ProbeToggledMsg: + m.reload() + if msg.Err != nil { + m.lastErr = msg.Err.Error() + } else { + m.lastErr = "" + } + m.clampCursor() + return m, nil + case tea.KeyMsg: + if m.searching { + return m.updateSearch(msg) + } + switch msg.String() { + case "esc": + return m.Close(), nil + case "j", "down": + if m.cursor < len(m.filtered())-1 { + m.cursor++ + } + return m, nil + case "k", "up": + if m.cursor > 0 { + m.cursor-- + } + return m, nil + case "/", "f": + m.searching = true + m.textInput.SetValue(m.search) + m.textInput.CursorEnd() + m.textInput.Focus() + return m, nil + case " ", "enter": + selected := m.selectedSyscall() + if selected == "" { + return m, nil + } + return m, toggleCmd(m.manager, selected) + case "a": + return m, bulkToggleCmd(m.manager, m.filtered(), false) + case "n": + return m, bulkToggleCmd(m.manager, m.filtered(), true) + } + } + return m, nil +} + +func (m Model) updateSearch(msg tea.KeyMsg) (Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.searching = false + m.textInput.Blur() + return m, nil + case "enter": + m.search = strings.TrimSpace(m.textInput.Value()) + m.searching = false + m.textInput.Blur() + m.clampCursor() + return m, nil + default: + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + m.search = strings.TrimSpace(m.textInput.Value()) + m.clampCursor() + return m, cmd + } +} + +func (m *Model) reload() { + if m.manager == nil { + m.probes = nil + return + } + m.probes = m.manager.States() +} + +func (m *Model) clampCursor() { + items := m.filtered() + if len(items) == 0 { + m.cursor = 0 + m.offset = 0 + return + } + if m.cursor >= len(items) { + m.cursor = len(items) - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } + rows := m.visibleRows() + if m.cursor < m.offset { + m.offset = m.cursor + } + if rows > 0 && m.cursor >= m.offset+rows { + m.offset = m.cursor - rows + 1 + } + if m.offset < 0 { + m.offset = 0 + } +} + +func (m Model) filtered() []probemanager.ProbeState { + if m.search == "" { + return m.probes + } + needle := strings.ToLower(m.search) + out := make([]probemanager.ProbeState, 0, len(m.probes)) + for _, p := range m.probes { + if strings.Contains(strings.ToLower(p.Syscall), needle) { + out = append(out, p) + } + } + return out +} + +func (m Model) selectedSyscall() string { + items := m.filtered() + if len(items) == 0 || m.cursor < 0 || m.cursor >= len(items) { + return "" + } + return items[m.cursor].Syscall +} + +func (m Model) visibleRows() int { + if m.height <= 0 { + return 10 + } + rows := m.height - 9 + if rows < 3 { + return 3 + } + return rows +} + +func (m Model) View(width, height int) string { + if !m.visible { + return "" + } + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 24 + } + m.height = height + + active, total := 0, len(m.probes) + if m.manager != nil { + active, total = m.manager.ActiveCount() + } + + rows := m.visibleRows() + items := m.filtered() + modalWidth := 66 + if width < modalWidth+4 { + modalWidth = width - 4 + if modalWidth < 44 { + modalWidth = 44 + } + } + + lines := []string{fmt.Sprintf("Probes (%d/%d active)", active, total)} + if m.searching { + lines = append(lines, m.textInput.View()) + } else if m.search != "" { + lines = append(lines, "Filter: "+m.search) + } + lines = append(lines, "") + + start := m.offset + if start > len(items) { + start = len(items) + } + end := start + rows + if end > len(items) { + end = len(items) + } + for i := start; i < end; i++ { + p := items[i] + prefix := " " + if i == m.cursor { + prefix = "> " + } + check := "[ ]" + if p.Active { + check = "[x]" + } + line := fmt.Sprintf("%s%s %-24s", prefix, check, p.Syscall) + if p.Error != "" { + line += " ! " + p.Error + } + lines = append(lines, line) + } + if len(items) == 0 { + lines = append(lines, " (no probes)") + } + if m.lastErr != "" { + lines = append(lines, "", "Error: "+m.lastErr) + } + lines = append(lines, "", "j/k move • space|enter toggle • a all-on • n all-off • / search • esc close") + + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Padding(1, 2). + Width(modalWidth). + Render(strings.Join(lines, "\n")) + + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box) +} + +func toggleCmd(manager Manager, syscall string) tea.Cmd { + return func() tea.Msg { + if manager == nil { + return ProbeToggledMsg{Syscall: syscall, Err: fmt.Errorf("probe manager unavailable")} + } + return ProbeToggledMsg{Syscall: syscall, Err: manager.Toggle(syscall)} + } +} + +func bulkToggleCmd(manager Manager, probes []probemanager.ProbeState, sourceActive bool) tea.Cmd { + return func() tea.Msg { + if manager == nil { + return ProbeToggledMsg{Err: fmt.Errorf("probe manager unavailable")} + } + var firstErr error + for _, p := range probes { + if p.Active != sourceActive { + continue + } + if err := manager.Toggle(p.Syscall); err != nil && firstErr == nil { + firstErr = err + } + } + return ProbeToggledMsg{Err: firstErr} + } +} diff --git a/internal/tui/probes/model_test.go b/internal/tui/probes/model_test.go new file mode 100644 index 0000000..74f6a6b --- /dev/null +++ b/internal/tui/probes/model_test.go @@ -0,0 +1,80 @@ +package probes + +import ( + "testing" + + "ior/internal/probemanager" + + tea "github.com/charmbracelet/bubbletea" +) + +type fakeManager struct { + states []probemanager.ProbeState + toggles []string +} + +func (f *fakeManager) States() []probemanager.ProbeState { + out := make([]probemanager.ProbeState, len(f.states)) + copy(out, f.states) + return out +} + +func (f *fakeManager) Toggle(syscall string) error { + f.toggles = append(f.toggles, syscall) + for i := range f.states { + if f.states[i].Syscall == syscall { + f.states[i].Active = !f.states[i].Active + } + } + return nil +} + +func (f *fakeManager) ActiveCount() (int, int) { + active := 0 + for _, s := range f.states { + if s.Active { + active++ + } + } + return active, len(f.states) +} + +func TestOpenRefreshesFromManager(t *testing.T) { + fm := &fakeManager{ + states: []probemanager.ProbeState{{Syscall: "read", Active: true}}, + } + m := NewModel(fm) + m = m.Open() + if len(m.probes) != 1 || m.probes[0].Syscall != "read" { + t.Fatalf("unexpected probes after first open: %+v", m.probes) + } + + fm.states = append(fm.states, probemanager.ProbeState{Syscall: "write", Active: true}) + m = m.Close().Open() + if len(m.probes) != 2 { + t.Fatalf("expected probes refreshed on open, got %+v", m.probes) + } +} + +func TestToggleEmitsProbeToggledMsg(t *testing.T) { + fm := &fakeManager{ + states: []probemanager.ProbeState{{Syscall: "read", Active: true}}, + } + m := NewModel(fm).Open() + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + if cmd == nil { + t.Fatalf("expected toggle command") + } + msg := cmd() + toggled, ok := msg.(ProbeToggledMsg) + if !ok { + t.Fatalf("expected ProbeToggledMsg, got %T", msg) + } + if toggled.Err != nil { + t.Fatalf("unexpected toggle err: %v", toggled.Err) + } + if len(fm.toggles) != 1 || fm.toggles[0] != "read" { + t.Fatalf("expected read toggle, got %+v", fm.toggles) + } + _ = next +} |
