diff options
| -rw-r--r-- | internal/tui/eventstream/filtermodal.go | 394 | ||||
| -rw-r--r-- | internal/tui/eventstream/filtermodal_test.go | 108 |
2 files changed, 502 insertions, 0 deletions
diff --git a/internal/tui/eventstream/filtermodal.go b/internal/tui/eventstream/filtermodal.go new file mode 100644 index 0000000..f98db7f --- /dev/null +++ b/internal/tui/eventstream/filtermodal.go @@ -0,0 +1,394 @@ +package eventstream + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type fieldKey int + +const ( + fieldSyscall fieldKey = iota + fieldComm + fieldFile + fieldPID + fieldTID + fieldLatency + fieldGap + fieldBytes + fieldReturn + fieldErrorsOnly +) + +type filterField struct { + label string + fieldKey fieldKey + value string + opIndex int +} + +type FilterModal struct { + visible bool + fields []filterField + activeField int + editing bool + textInput textinput.Model + filter Filter +} + +var compareOps = []CompareOp{OpGt, OpLt, OpEq, OpGte, OpLte, OpNeq} +var compareOpLabels = []string{">", "<", "=", ">=", "<=", "!="} + +func NewFilterModal() FilterModal { + input := textinput.New() + input.Prompt = "" + input.CharLimit = 0 + input.Width = 24 + + m := FilterModal{textInput: input} + m.fields = defaultFilterFields() + return m +} + +func (m FilterModal) Visible() bool { + return m.visible +} + +func (m FilterModal) Filter() Filter { + return m.filter +} + +func (m FilterModal) Open(initial Filter) FilterModal { + m.visible = true + m.activeField = 0 + m.editing = false + m.textInput.Blur() + m.fields = defaultFilterFields() + m.applyFilterToFields(initial) + m.filter = initial + return m +} + +func (m FilterModal) Close() FilterModal { + m.visible = false + m.editing = false + m.textInput.Blur() + return m +} + +func (m FilterModal) Update(msg tea.Msg) FilterModal { + if !m.visible { + return m + } + + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "esc": + if m.editing { + m.commitEdit() + } + m.filter = m.buildFilterFromFields() + return m.Close() + case "c": + m.clearAll() + return m + case "j", "down": + if !m.editing && m.activeField < len(m.fields)-1 { + m.activeField++ + } + return m + case "k", "up": + if !m.editing && m.activeField > 0 { + m.activeField-- + } + return m + case "tab": + if !m.editing && m.isNumericField(m.activeField) { + m.fields[m.activeField].opIndex = (m.fields[m.activeField].opIndex + 1) % len(compareOps) + } + return m + case " ": + if !m.editing && m.fields[m.activeField].fieldKey == fieldErrorsOnly { + if strings.TrimSpace(m.fields[m.activeField].value) == "true" { + m.fields[m.activeField].value = "false" + } else { + m.fields[m.activeField].value = "true" + } + } + return m + case "enter": + if m.fields[m.activeField].fieldKey == fieldErrorsOnly { + if strings.TrimSpace(m.fields[m.activeField].value) == "true" { + m.fields[m.activeField].value = "false" + } else { + m.fields[m.activeField].value = "true" + } + return m + } + if m.editing { + m.commitEdit() + return m + } + m.startEdit() + return m + } + } + + if m.editing { + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + _ = cmd + } + return m +} + +func (m FilterModal) View(width, height int) string { + if !m.visible { + return "" + } + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 24 + } + + modalWidth := 64 + if width < modalWidth+4 { + modalWidth = width - 4 + if modalWidth < 40 { + modalWidth = 40 + } + } + + lines := []string{"Filter"} + for i, f := range m.fields { + prefix := " " + if i == m.activeField { + prefix = "> " + } + lines = append(lines, prefix+m.renderField(f, i == m.activeField)) + } + lines = append(lines, "", "j/k move • Enter edit/apply • Tab op • Space toggle errors • c clear • Esc apply+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 (m *FilterModal) clearAll() { + for i := range m.fields { + m.fields[i].value = "" + if m.isNumericField(i) { + m.fields[i].opIndex = 2 + } + } + m.fields[len(m.fields)-1].value = "false" + m.editing = false + m.textInput.Blur() +} + +func (m *FilterModal) startEdit() { + m.editing = true + m.textInput.SetValue(m.fields[m.activeField].value) + m.textInput.CursorEnd() + m.textInput.Focus() +} + +func (m *FilterModal) commitEdit() { + m.fields[m.activeField].value = strings.TrimSpace(m.textInput.Value()) + m.editing = false + m.textInput.Blur() +} + +func (m FilterModal) renderField(f filterField, active bool) string { + if f.fieldKey == fieldErrorsOnly { + checked := "[ ]" + if strings.TrimSpace(f.value) == "true" { + checked = "[x]" + } + return fmt.Sprintf("%-8s %s", f.label+":", checked) + } + + value := f.value + if active && m.editing { + value = m.textInput.View() + } + if f.fieldKey == fieldLatency || f.fieldKey == fieldGap || m.isNumericFieldByKey(f.fieldKey) { + return fmt.Sprintf("%-8s [%2s] %s", f.label+":", compareOpLabels[f.opIndex], value) + } + return fmt.Sprintf("%-8s %s", f.label+":", value) +} + +func (m FilterModal) buildFilterFromFields() Filter { + out := Filter{} + for _, f := range m.fields { + value := strings.TrimSpace(f.value) + switch f.fieldKey { + case fieldSyscall: + if value != "" { + out.Syscall = &StringFilter{Pattern: value} + } + case fieldComm: + if value != "" { + out.Comm = &StringFilter{Pattern: value} + } + case fieldFile: + if value != "" { + out.File = &StringFilter{Pattern: value} + } + case fieldPID: + if nf, ok := parseNumericFilter(value, f.opIndex, false); ok { + out.PID = nf + } + case fieldTID: + if nf, ok := parseNumericFilter(value, f.opIndex, false); ok { + out.TID = nf + } + case fieldLatency: + if nf, ok := parseNumericFilter(value, f.opIndex, true); ok { + out.LatencyNs = nf + } + case fieldGap: + if nf, ok := parseNumericFilter(value, f.opIndex, true); ok { + out.GapNs = nf + } + case fieldBytes: + if nf, ok := parseNumericFilter(value, f.opIndex, false); ok { + out.Bytes = nf + } + case fieldReturn: + if nf, ok := parseNumericFilter(value, f.opIndex, false); ok { + out.RetVal = nf + } + case fieldErrorsOnly: + out.ErrorsOnly = strings.EqualFold(value, "true") + } + } + return out +} + +func parseNumericFilter(value string, opIndex int, duration bool) (*NumericFilter, bool) { + if value == "" { + return nil, false + } + if opIndex < 0 || opIndex >= len(compareOps) { + opIndex = 2 + } + + var n int64 + var err error + if duration { + n, err = ParseDurationNs(value) + } else { + n, err = strconv.ParseInt(value, 10, 64) + } + if err != nil { + return nil, false + } + return &NumericFilter{Op: compareOps[opIndex], Value: n}, true +} + +func (m *FilterModal) applyFilterToFields(f Filter) { + m.setStringField(fieldSyscall, f.Syscall) + m.setStringField(fieldComm, f.Comm) + m.setStringField(fieldFile, f.File) + m.setNumericField(fieldPID, f.PID, false) + m.setNumericField(fieldTID, f.TID, false) + m.setNumericField(fieldLatency, f.LatencyNs, true) + m.setNumericField(fieldGap, f.GapNs, true) + m.setNumericField(fieldBytes, f.Bytes, false) + m.setNumericField(fieldReturn, f.RetVal, false) + if f.ErrorsOnly { + m.fields[fieldErrorsOnly].value = "true" + } else { + m.fields[fieldErrorsOnly].value = "false" + } +} + +func (m *FilterModal) setStringField(k fieldKey, sf *StringFilter) { + if sf == nil { + m.fields[k].value = "" + return + } + m.fields[k].value = sf.Pattern +} + +func (m *FilterModal) setNumericField(k fieldKey, nf *NumericFilter, duration bool) { + if nf == nil { + m.fields[k].value = "" + m.fields[k].opIndex = 2 + return + } + m.fields[k].opIndex = opToIndex(nf.Op) + if duration { + m.fields[k].value = formatDurationField(nf.Value) + return + } + m.fields[k].value = strconv.FormatInt(nf.Value, 10) +} + +func formatDurationField(v int64) string { + if v%1_000_000 == 0 { + return fmt.Sprintf("%dms", v/1_000_000) + } + if v%1_000 == 0 { + return fmt.Sprintf("%dus", v/1_000) + } + return fmt.Sprintf("%dns", v) +} + +func opToIndex(op CompareOp) int { + for i, o := range compareOps { + if o == op { + return i + } + } + return 2 +} + +func (m FilterModal) isNumericField(i int) bool { + if i < 0 || i >= len(m.fields) { + return false + } + return m.isNumericFieldByKey(m.fields[i].fieldKey) +} + +func (m FilterModal) isNumericFieldByKey(k fieldKey) bool { + switch k { + case fieldPID, fieldTID, fieldLatency, fieldGap, fieldBytes, fieldReturn: + return true + default: + return false + } +} + +func defaultFilterFields() []filterField { + fields := []filterField{ + {label: "Syscall", fieldKey: fieldSyscall}, + {label: "Comm", fieldKey: fieldComm}, + {label: "File", fieldKey: fieldFile}, + {label: "PID", fieldKey: fieldPID}, + {label: "TID", fieldKey: fieldTID}, + {label: "Latency", fieldKey: fieldLatency}, + {label: "Gap", fieldKey: fieldGap}, + {label: "Bytes", fieldKey: fieldBytes}, + {label: "Return", fieldKey: fieldReturn}, + {label: "Errors", fieldKey: fieldErrorsOnly, value: "false"}, + } + for i := range fields { + if fields[i].fieldKey != fieldErrorsOnly { + fields[i].opIndex = 2 + } + } + return fields +} diff --git a/internal/tui/eventstream/filtermodal_test.go b/internal/tui/eventstream/filtermodal_test.go new file mode 100644 index 0000000..ee53c82 --- /dev/null +++ b/internal/tui/eventstream/filtermodal_test.go @@ -0,0 +1,108 @@ +package eventstream + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestFilterModalOpenClose(t *testing.T) { + m := NewFilterModal() + if m.Visible() { + t.Fatalf("new modal should not be visible") + } + + m = m.Open(Filter{}) + if !m.Visible() { + t.Fatalf("modal should be visible after open") + } + + m = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + if m.Visible() { + t.Fatalf("modal should close on esc") + } +} + +func TestFilterModalNavigateFields(t *testing.T) { + m := NewFilterModal().Open(Filter{}) + if m.activeField != 0 { + t.Fatalf("activeField=%d, want 0", m.activeField) + } + + m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + if m.activeField != 1 { + t.Fatalf("activeField=%d, want 1", m.activeField) + } + m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")}) + if m.activeField != 0 { + t.Fatalf("activeField=%d, want 0", m.activeField) + } +} + +func TestFilterModalEditAndBuildFilter(t *testing.T) { + m := NewFilterModal().Open(Filter{}) + + // Syscall = read + m = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("read")}) + m = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + // PID >= 123 + m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + m = m.Update(tea.KeyMsg{Type: tea.KeyTab}) // '=' -> '>' + m = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("123")}) + m = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + // Latency >= 1ms + m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + m = m.Update(tea.KeyMsg{Type: tea.KeyTab}) // '=' -> '>=' + m = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("1ms")}) + m = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + // ErrorsOnly = true + for m.activeField < len(m.fields)-1 { + m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + } + m = m.Update(tea.KeyMsg{Type: tea.KeySpace}) + + m = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + if m.Visible() { + t.Fatalf("modal should close on esc") + } + + f := m.Filter() + if f.Syscall == nil || f.Syscall.Pattern != "read" { + t.Fatalf("syscall filter not applied: %+v", f.Syscall) + } + if f.PID == nil || f.PID.Op != OpGte || f.PID.Value != 123 { + t.Fatalf("pid filter mismatch: %+v", f.PID) + } + if f.LatencyNs == nil || f.LatencyNs.Op != OpGte || f.LatencyNs.Value != 1_000_000 { + t.Fatalf("latency filter mismatch: %+v", f.LatencyNs) + } + if !f.ErrorsOnly { + t.Fatalf("errors-only expected true") + } +} + +func TestFilterModalClearAll(t *testing.T) { + initial := Filter{ + Syscall: &StringFilter{Pattern: "open"}, + PID: &NumericFilter{Op: OpEq, Value: 42}, + ErrorsOnly: true, + } + m := NewFilterModal().Open(initial) + + m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}) + m = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + + f := m.Filter() + if f.IsActive() { + t.Fatalf("expected cleared filter to be inactive: %+v", f) + } +} |
