summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-08 20:21:06 +0200
committerPaul Buetow <paul@buetow.org>2026-03-08 20:21:06 +0200
commitcfd1319f06725df4e2501cdfc67983b1a44e7e16 (patch)
treec8d382af0850738fb3080704154173b390363399 /internal
parent83ff18252be5ad4d667084a3a6edbf7cd5271e6b (diff)
task 370: extract reusable trace filter modal
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/eventstream/filtermodal.go398
-rw-r--r--internal/tui/eventstream/filtermodal_test.go108
-rw-r--r--internal/tui/tracefilter/model.go419
-rw-r--r--internal/tui/tracefilter/model_test.go102
4 files changed, 524 insertions, 503 deletions
diff --git a/internal/tui/eventstream/filtermodal.go b/internal/tui/eventstream/filtermodal.go
index bd20a03..b5f67ee 100644
--- a/internal/tui/eventstream/filtermodal.go
+++ b/internal/tui/eventstream/filtermodal.go
@@ -1,401 +1,9 @@
package eventstream
-import (
- "fmt"
- "strconv"
- "strings"
+import tracefilter "ior/internal/tui/tracefilter"
- "charm.land/bubbles/v2/textinput"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
-)
-
-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{">", "<", "=", ">=", "<=", "!="}
+type FilterModal = tracefilter.Model
func NewFilterModal() FilterModal {
- input := textinput.New()
- input.Prompt = ""
- input.CharLimit = 0
- input.SetWidth(24)
- input.SetStyles(textinput.DefaultStyles(true))
-
- 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
-}
-
-// SetDarkMode updates filter modal text input styles.
-func (m FilterModal) SetDarkMode(isDark bool) FilterModal {
- m.textInput.SetStyles(textinput.DefaultStyles(isDark))
- return m
-}
-
-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.KeyPressMsg); 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 " ", "space":
- 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
+ return tracefilter.NewModel()
}
diff --git a/internal/tui/eventstream/filtermodal_test.go b/internal/tui/eventstream/filtermodal_test.go
deleted file mode 100644
index a33cbb1..0000000
--- a/internal/tui/eventstream/filtermodal_test.go
+++ /dev/null
@@ -1,108 +0,0 @@
-package eventstream
-
-import (
- "testing"
-
- tea "charm.land/bubbletea/v2"
-)
-
-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.KeyPressMsg{Code: 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.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))})
- if m.activeField != 1 {
- t.Fatalf("activeField=%d, want 1", m.activeField)
- }
- m = m.Update(tea.KeyPressMsg{Code: []rune("k")[0], Text: string([]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.KeyPressMsg{Code: tea.KeyEnter})
- m = m.Update(tea.KeyPressMsg{Code: []rune("read")[0], Text: string([]rune("read"))})
- m = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
-
- // PID >= 123
- m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))})
- m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))})
- m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))})
- m = m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // '=' -> '>'
- m = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
- m = m.Update(tea.KeyPressMsg{Code: []rune("123")[0], Text: string([]rune("123"))})
- m = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
-
- // Latency >= 1ms
- m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))})
- m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))})
- m = m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // '=' -> '>='
- m = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
- m = m.Update(tea.KeyPressMsg{Code: []rune("1ms")[0], Text: string([]rune("1ms"))})
- m = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
-
- // ErrorsOnly = true
- for m.activeField < len(m.fields)-1 {
- m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))})
- }
- m = m.Update(tea.KeyPressMsg{Code: tea.KeySpace})
-
- m = m.Update(tea.KeyPressMsg{Code: 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.KeyPressMsg{Code: []rune("c")[0], Text: string([]rune("c"))})
- m = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
-
- f := m.Filter()
- if f.IsActive() {
- t.Fatalf("expected cleared filter to be inactive: %+v", f)
- }
-}
diff --git a/internal/tui/tracefilter/model.go b/internal/tui/tracefilter/model.go
new file mode 100644
index 0000000..a9f2c08
--- /dev/null
+++ b/internal/tui/tracefilter/model.go
@@ -0,0 +1,419 @@
+package tracefilter
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "ior/internal/globalfilter"
+
+ "charm.land/bubbles/v2/textinput"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+)
+
+type fieldKey int
+
+const (
+ fieldSyscall fieldKey = iota
+ fieldComm
+ fieldFile
+ fieldPID
+ fieldTID
+ fieldFD
+ fieldLatency
+ fieldGap
+ fieldBytes
+ fieldReturn
+ fieldErrorsOnly
+)
+
+type filterField struct {
+ label string
+ fieldKey fieldKey
+ value string
+ opIndex int
+}
+
+type Model struct {
+ visible bool
+ fields []filterField
+ activeField int
+ editing bool
+ textInput textinput.Model
+ filter globalfilter.Filter
+}
+
+var compareOps = []globalfilter.CompareOp{
+ globalfilter.OpGt,
+ globalfilter.OpLt,
+ globalfilter.OpEq,
+ globalfilter.OpGte,
+ globalfilter.OpLte,
+ globalfilter.OpNeq,
+}
+
+var compareOpLabels = []string{">", "<", "=", ">=", "<=", "!="}
+
+func NewModel() Model {
+ input := textinput.New()
+ input.Prompt = ""
+ input.CharLimit = 0
+ input.SetWidth(24)
+ input.SetStyles(textinput.DefaultStyles(true))
+
+ model := Model{textInput: input}
+ model.fields = defaultFilterFields()
+ return model
+}
+
+func (m Model) Visible() bool {
+ return m.visible
+}
+
+func (m Model) Filter() globalfilter.Filter {
+ return m.filter
+}
+
+func (m Model) SetDarkMode(isDark bool) Model {
+ m.textInput.SetStyles(textinput.DefaultStyles(isDark))
+ return m
+}
+
+func (m Model) Open(initial globalfilter.Filter) Model {
+ m.visible = true
+ m.activeField = 0
+ m.editing = false
+ m.textInput.Blur()
+ m.fields = defaultFilterFields()
+ m.applyFilterToFields(initial)
+ m.filter = initial.Clone()
+ return m
+}
+
+func (m Model) Close() Model {
+ m.visible = false
+ m.editing = false
+ m.textInput.Blur()
+ return m
+}
+
+func (m Model) Update(msg tea.Msg) Model {
+ if !m.visible {
+ return m
+ }
+
+ if keyMsg, ok := msg.(tea.KeyPressMsg); 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 " ", "space":
+ 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 Model) 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, field := range m.fields {
+ prefix := " "
+ if i == m.activeField {
+ prefix = "> "
+ }
+ lines = append(lines, prefix+m.renderField(field, 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 *Model) 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 *Model) startEdit() {
+ m.editing = true
+ m.textInput.SetValue(m.fields[m.activeField].value)
+ m.textInput.CursorEnd()
+ m.textInput.Focus()
+}
+
+func (m *Model) commitEdit() {
+ m.fields[m.activeField].value = strings.TrimSpace(m.textInput.Value())
+ m.editing = false
+ m.textInput.Blur()
+}
+
+func (m Model) renderField(field filterField, active bool) string {
+ if field.fieldKey == fieldErrorsOnly {
+ checked := "[ ]"
+ if strings.TrimSpace(field.value) == "true" {
+ checked = "[x]"
+ }
+ return fmt.Sprintf("%-8s %s", field.label+":", checked)
+ }
+
+ value := field.value
+ if active && m.editing {
+ value = m.textInput.View()
+ }
+ if field.fieldKey == fieldLatency || field.fieldKey == fieldGap || m.isNumericFieldByKey(field.fieldKey) {
+ return fmt.Sprintf("%-8s [%2s] %s", field.label+":", compareOpLabels[field.opIndex], value)
+ }
+ return fmt.Sprintf("%-8s %s", field.label+":", value)
+}
+
+func (m Model) buildFilterFromFields() globalfilter.Filter {
+ var out globalfilter.Filter
+ for _, field := range m.fields {
+ value := strings.TrimSpace(field.value)
+ switch field.fieldKey {
+ case fieldSyscall:
+ if value != "" {
+ out.Syscall = &globalfilter.StringFilter{Pattern: value}
+ }
+ case fieldComm:
+ if value != "" {
+ out.Comm = &globalfilter.StringFilter{Pattern: value}
+ }
+ case fieldFile:
+ if value != "" {
+ out.File = &globalfilter.StringFilter{Pattern: value}
+ }
+ case fieldPID:
+ if filter, ok := parseNumericFilter(value, field.opIndex, false); ok {
+ out.PID = filter
+ }
+ case fieldTID:
+ if filter, ok := parseNumericFilter(value, field.opIndex, false); ok {
+ out.TID = filter
+ }
+ case fieldFD:
+ if filter, ok := parseNumericFilter(value, field.opIndex, false); ok {
+ out.FD = filter
+ }
+ case fieldLatency:
+ if filter, ok := parseNumericFilter(value, field.opIndex, true); ok {
+ out.LatencyNs = filter
+ }
+ case fieldGap:
+ if filter, ok := parseNumericFilter(value, field.opIndex, true); ok {
+ out.GapNs = filter
+ }
+ case fieldBytes:
+ if filter, ok := parseNumericFilter(value, field.opIndex, false); ok {
+ out.Bytes = filter
+ }
+ case fieldReturn:
+ if filter, ok := parseNumericFilter(value, field.opIndex, false); ok {
+ out.RetVal = filter
+ }
+ case fieldErrorsOnly:
+ out.ErrorsOnly = strings.EqualFold(value, "true")
+ }
+ }
+ return out
+}
+
+func parseNumericFilter(value string, opIndex int, duration bool) (*globalfilter.NumericFilter, bool) {
+ if value == "" {
+ return nil, false
+ }
+ if opIndex < 0 || opIndex >= len(compareOps) {
+ opIndex = 2
+ }
+
+ var (
+ number int64
+ err error
+ )
+ if duration {
+ number, err = globalfilter.ParseDurationNs(value)
+ } else {
+ number, err = strconv.ParseInt(value, 10, 64)
+ }
+ if err != nil {
+ return nil, false
+ }
+ return &globalfilter.NumericFilter{Op: compareOps[opIndex], Value: number}, true
+}
+
+func (m *Model) applyFilterToFields(filter globalfilter.Filter) {
+ m.setStringField(fieldSyscall, filter.Syscall)
+ m.setStringField(fieldComm, filter.Comm)
+ m.setStringField(fieldFile, filter.File)
+ m.setNumericField(fieldPID, filter.PID, false)
+ m.setNumericField(fieldTID, filter.TID, false)
+ m.setNumericField(fieldFD, filter.FD, false)
+ m.setNumericField(fieldLatency, filter.LatencyNs, true)
+ m.setNumericField(fieldGap, filter.GapNs, true)
+ m.setNumericField(fieldBytes, filter.Bytes, false)
+ m.setNumericField(fieldReturn, filter.RetVal, false)
+ if filter.ErrorsOnly {
+ m.fields[fieldErrorsOnly].value = "true"
+ } else {
+ m.fields[fieldErrorsOnly].value = "false"
+ }
+}
+
+func (m *Model) setStringField(key fieldKey, filter *globalfilter.StringFilter) {
+ if filter == nil {
+ m.fields[key].value = ""
+ return
+ }
+ m.fields[key].value = filter.Pattern
+}
+
+func (m *Model) setNumericField(key fieldKey, filter *globalfilter.NumericFilter, duration bool) {
+ if filter == nil {
+ m.fields[key].value = ""
+ m.fields[key].opIndex = 2
+ return
+ }
+ m.fields[key].opIndex = opToIndex(filter.Op)
+ if duration {
+ m.fields[key].value = formatDurationField(filter.Value)
+ return
+ }
+ m.fields[key].value = strconv.FormatInt(filter.Value, 10)
+}
+
+func formatDurationField(value int64) string {
+ if value%1_000_000 == 0 {
+ return fmt.Sprintf("%dms", value/1_000_000)
+ }
+ if value%1_000 == 0 {
+ return fmt.Sprintf("%dus", value/1_000)
+ }
+ return fmt.Sprintf("%dns", value)
+}
+
+func opToIndex(op globalfilter.CompareOp) int {
+ for i, candidate := range compareOps {
+ if candidate == op {
+ return i
+ }
+ }
+ return 2
+}
+
+func (m Model) isNumericField(index int) bool {
+ if index < 0 || index >= len(m.fields) {
+ return false
+ }
+ return m.isNumericFieldByKey(m.fields[index].fieldKey)
+}
+
+func (m Model) isNumericFieldByKey(key fieldKey) bool {
+ switch key {
+ case fieldPID, fieldTID, fieldFD, 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: "FD", fieldKey: fieldFD},
+ {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/tracefilter/model_test.go b/internal/tui/tracefilter/model_test.go
new file mode 100644
index 0000000..83754b5
--- /dev/null
+++ b/internal/tui/tracefilter/model_test.go
@@ -0,0 +1,102 @@
+package tracefilter
+
+import (
+ "testing"
+
+ "ior/internal/globalfilter"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+func TestModelOpenClose(t *testing.T) {
+ model := NewModel()
+ if model.Visible() {
+ t.Fatalf("new modal should not be visible")
+ }
+
+ model = model.Open(globalfilter.Filter{})
+ if !model.Visible() {
+ t.Fatalf("modal should be visible after open")
+ }
+
+ model = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
+ if model.Visible() {
+ t.Fatalf("modal should close on esc")
+ }
+}
+
+func TestModelNavigateFields(t *testing.T) {
+ model := NewModel().Open(globalfilter.Filter{})
+ if model.activeField != 0 {
+ t.Fatalf("activeField=%d, want 0", model.activeField)
+ }
+
+ model = model.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))})
+ if model.activeField != 1 {
+ t.Fatalf("activeField=%d, want 1", model.activeField)
+ }
+ model = model.Update(tea.KeyPressMsg{Code: []rune("k")[0], Text: string([]rune("k"))})
+ if model.activeField != 0 {
+ t.Fatalf("activeField=%d, want 0", model.activeField)
+ }
+}
+
+func TestModelEditAndBuildFilter(t *testing.T) {
+ model := NewModel().Open(globalfilter.Filter{})
+
+ model = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ model = model.Update(tea.KeyPressMsg{Code: []rune("read")[0], Text: string([]rune("read"))})
+ model = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+
+ for model.activeField < 3 {
+ model = model.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))})
+ }
+ model = model.Update(tea.KeyPressMsg{Code: tea.KeyTab})
+ model = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ model = model.Update(tea.KeyPressMsg{Code: []rune("123")[0], Text: string([]rune("123"))})
+ model = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+
+ model = model.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))})
+ model = model.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))})
+ model = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ model = model.Update(tea.KeyPressMsg{Code: []rune("7")[0], Text: string([]rune("7"))})
+ model = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+
+ for model.activeField < len(model.fields)-1 {
+ model = model.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))})
+ }
+ model = model.Update(tea.KeyPressMsg{Code: tea.KeySpace})
+ model = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
+
+ filter := model.Filter()
+ if filter.Syscall == nil || filter.Syscall.Pattern != "read" {
+ t.Fatalf("syscall filter not applied: %+v", filter.Syscall)
+ }
+ if filter.PID == nil || filter.PID.Op != globalfilter.OpGte || filter.PID.Value != 123 {
+ t.Fatalf("pid filter mismatch: %+v", filter.PID)
+ }
+ if filter.FD == nil || filter.FD.Value != 7 {
+ t.Fatalf("fd filter mismatch: %+v", filter.FD)
+ }
+ if !filter.ErrorsOnly {
+ t.Fatalf("errors-only expected true")
+ }
+}
+
+func TestModelClearAll(t *testing.T) {
+ initial := globalfilter.Filter{
+ Syscall: &globalfilter.StringFilter{Pattern: "open"},
+ PID: &globalfilter.NumericFilter{Op: globalfilter.OpEq, Value: 42},
+ FD: &globalfilter.NumericFilter{Op: globalfilter.OpEq, Value: 7},
+ ErrorsOnly: true,
+ }
+ model := NewModel().Open(initial)
+
+ model = model.Update(tea.KeyPressMsg{Code: []rune("c")[0], Text: string([]rune("c"))})
+ model = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
+
+ filter := model.Filter()
+ if filter.IsActive() {
+ t.Fatalf("expected cleared filter to be inactive: %+v", filter)
+ }
+}