diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-08 22:03:01 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-08 22:03:01 +0200 |
| commit | 0d1492291a3e20665d8a3a6b16d2eb4e13938cee (patch) | |
| tree | ec09f7d660403478d23841cf541bdfa7f33aa70f /internal/tui/eventstream | |
| parent | d84902555621cc10b16a9641274b088e495f3714 (diff) | |
tui: restore global filter stack and anchored matches
Diffstat (limited to 'internal/tui/eventstream')
| -rw-r--r-- | internal/tui/eventstream/filter_test.go | 17 | ||||
| -rw-r--r-- | internal/tui/eventstream/model.go | 132 | ||||
| -rw-r--r-- | internal/tui/eventstream/model_test.go | 50 | ||||
| -rw-r--r-- | internal/tui/eventstream/render.go | 9 | ||||
| -rw-r--r-- | internal/tui/eventstream/render_test.go | 19 |
5 files changed, 197 insertions, 30 deletions
diff --git a/internal/tui/eventstream/filter_test.go b/internal/tui/eventstream/filter_test.go index 556d4ef..0413904 100644 --- a/internal/tui/eventstream/filter_test.go +++ b/internal/tui/eventstream/filter_test.go @@ -55,6 +55,23 @@ func TestFilterStringDimensions(t *testing.T) { } } +func TestFilterStringDimensionsSupportAnchors(t *testing.T) { + ev := sampleEvent() + if !(Filter{Syscall: &StringFilter{Pattern: "^read$"}}).Matches(&ev) { + t.Fatalf("expected ^read$ to exactly match read") + } + ev.Syscall = "readlink" + if (Filter{Syscall: &StringFilter{Pattern: "^read$"}}).Matches(&ev) { + t.Fatalf("expected ^read$ not to match readlink") + } + if !(Filter{Syscall: &StringFilter{Pattern: "^read"}}).Matches(&ev) { + t.Fatalf("expected ^read to match readlink") + } + if !(Filter{Syscall: &StringFilter{Pattern: "link$"}}).Matches(&ev) { + t.Fatalf("expected link$ to match readlink") + } +} + func TestFilterNumericDimensions(t *testing.T) { ev := sampleEvent() cases := []struct { diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index 467c137..600d40d 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -36,25 +36,30 @@ type Model struct { allEvents []StreamEvent filtered []StreamEvent - filter Filter + filter Filter + filterStack []string paused bool - scrollOffset int - autoScroll bool - selectedIdx int - selectedCol int - fdTraceView fdTraceViewState - exportModal ExportModal - searchModal SearchModal - searchPattern string - searchRegex *regexp.Regexp - searchDirection SearchDirection - lastExportPath string - pendingOpenPath string - statusMessage string - exportDir string - isDark bool + scrollOffset int + autoScroll bool + selectedIdx int + selectedCol int + fdTraceView fdTraceViewState + exportModal ExportModal + searchModal SearchModal + searchPattern string + searchRegex *regexp.Regexp + searchDirection SearchDirection + lastExportPath string + pendingOpenPath string + pendingFilter Filter + pendingFilterAction string + hasPendingFilter bool + pendingUndo bool + statusMessage string + exportDir string + isDark bool width int height int @@ -135,6 +140,11 @@ func (m *Model) SetFilter(filter Filter) { m.restoreSelectionBySeq(targetSeq) } +// SetFilterStack updates the visible shared filter stack summary. +func (m *Model) SetFilterStack(stack []string) { + m.filterStack = append(m.filterStack[:0], stack...) +} + // SetDarkMode updates stream modal text input styles for the active theme. func (m *Model) SetDarkMode(isDark bool) { m.isDark = isDark @@ -231,6 +241,23 @@ func (m *Model) HandleKey(keyStr string) bool { } switch keyStr { + case "enter": + if m.paused { + return m.requestGlobalFilterFromSelectedCell() + } + return false + case "F": + if len(m.filterStack) == 0 { + return false + } + m.pendingUndo = true + return true + case "esc": + if m.paused && len(m.filterStack) > 0 { + m.pendingUndo = true + return true + } + return false case "/": m.openSearch(SearchForward) return true @@ -458,7 +485,7 @@ func (m *Model) View(width, height int) string { if m.paused && selectedVisibleIdx >= 0 { selectedCol = m.selectedCol } - base := RenderStreamTable(width, m.paused, len(m.allEvents), len(m.filtered), bufferLen, ringBufferCapacity, m.filter, visible, selectedVisibleIdx, selectedCol) + base := RenderStreamTable(width, m.paused, len(m.allEvents), len(m.filtered), bufferLen, ringBufferCapacity, m.filter, m.filterStack, visible, selectedVisibleIdx, selectedCol) if !m.showFooter { if m.exportModal.Visible() { return m.exportModal.View(width, height) @@ -471,7 +498,7 @@ func (m *Model) View(width, height int) string { status := fmt.Sprintf("Row %d/%d", rowNumber(start, len(m.filtered)), len(m.filtered)) if m.paused && m.selectedIdx >= 0 { - status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered), m.selectedCol+1, streamColumnCount) + status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d | Enter push-filter | Esc/F undo", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered), m.selectedCol+1, streamColumnCount) } out := base + "\n" + status if m.statusMessage != "" { @@ -699,6 +726,53 @@ func (m *Model) moveSelectedColBy(delta int) { m.selectedCol = clamp(m.selectedCol+delta, 0, streamColumnCount-1) } +func (m *Model) requestGlobalFilterFromSelectedCell() bool { + if m.fdTraceView.visible || m.selectedIdx < 0 || m.selectedIdx >= len(m.filtered) { + return false + } + ev := m.filtered[m.selectedIdx] + next := m.filter.Clone() + + switch m.selectedCol { + case streamColGap: + next.GapNs = &NumericFilter{Op: OpGte, Value: int64(ev.GapNs)} + m.pendingFilterAction = fmt.Sprintf("gap>=%s", formatDurationNs(ev.GapNs)) + case streamColLatency: + next.LatencyNs = &NumericFilter{Op: OpGte, Value: int64(ev.DurationNs)} + m.pendingFilterAction = fmt.Sprintf("latency>=%s", formatDurationNs(ev.DurationNs)) + case streamColComm: + next.Comm = &StringFilter{Pattern: ev.Comm} + m.pendingFilterAction = "comm~" + ev.Comm + case streamColPID: + next.PID = &NumericFilter{Op: OpEq, Value: int64(ev.PID)} + m.pendingFilterAction = fmt.Sprintf("pid=%d", ev.PID) + case streamColTID: + next.TID = &NumericFilter{Op: OpEq, Value: int64(ev.TID)} + m.pendingFilterAction = fmt.Sprintf("tid=%d", ev.TID) + case streamColSyscall: + next.Syscall = &StringFilter{Pattern: ev.Syscall} + m.pendingFilterAction = "syscall~" + ev.Syscall + case streamColFD: + next.FD = &NumericFilter{Op: OpEq, Value: int64(ev.FD)} + m.pendingFilterAction = fmt.Sprintf("fd=%d", ev.FD) + case streamColRet: + next.RetVal = &NumericFilter{Op: OpEq, Value: ev.RetVal} + m.pendingFilterAction = fmt.Sprintf("ret=%d", ev.RetVal) + case streamColBytes: + next.Bytes = &NumericFilter{Op: OpEq, Value: int64(ev.Bytes)} + m.pendingFilterAction = fmt.Sprintf("bytes=%d", ev.Bytes) + case streamColFile: + next.File = &StringFilter{Pattern: ev.FileName} + m.pendingFilterAction = "file~" + ev.FileName + default: + return false + } + + m.pendingFilter = next + m.hasPendingFilter = true + return true +} + func (m *Model) currentSelectedSeq() uint64 { if m.selectedIdx < 0 || m.selectedIdx >= len(m.filtered) { return 0 @@ -790,6 +864,28 @@ func (m *Model) ConsumeOpenEditorRequest() (string, bool) { return path, true } +// ConsumeGlobalFilterRequest returns the pending global-filter request once. +func (m *Model) ConsumeGlobalFilterRequest() (Filter, string, bool) { + if !m.hasPendingFilter { + return Filter{}, "", false + } + filter := m.pendingFilter.Clone() + action := m.pendingFilterAction + m.pendingFilter = Filter{} + m.pendingFilterAction = "" + m.hasPendingFilter = false + return filter, action, true +} + +// ConsumeGlobalFilterUndoRequest returns the pending undo request once. +func (m *Model) ConsumeGlobalFilterUndoRequest() bool { + if !m.pendingUndo { + return false + } + m.pendingUndo = false + return true +} + // SetStatusMessage updates the stream footer status line. func (m *Model) SetStatusMessage(message string) { m.statusMessage = message diff --git a/internal/tui/eventstream/model_test.go b/internal/tui/eventstream/model_test.go index 9848042..a97fbea 100644 --- a/internal/tui/eventstream/model_test.go +++ b/internal/tui/eventstream/model_test.go @@ -377,30 +377,68 @@ func TestPausedSelectionMovesAcrossColumnsWithLeftRightAndHL(t *testing.T) { } } -func TestPausedEnterDoesNotMutateGlobalFilter(t *testing.T) { +func TestPausedEnterQueuesGlobalFilterRequestFromSelectedCell(t *testing.T) { rb := NewRingBuffer() rb.Push(StreamEvent{Seq: 1, PID: 1, TID: 1, Comm: "a", DurationNs: 100, GapNs: 5}) rb.Push(StreamEvent{Seq: 2, PID: 1, TID: 2, Comm: "b", DurationNs: 200, GapNs: 6}) m := NewModel(rb) m.height = 20 m.Refresh() + m.SetFilter(Filter{PID: &NumericFilter{Op: OpEq, Value: 1}}) if !m.HandleKey("space") { t.Fatalf("space should pause") } m.selectedIdx = 0 - m.selectedCol = streamColLatency - if m.HandleKey("enter") { - t.Fatalf("expected enter not to mutate filters in paused stream mode") + m.selectedCol = streamColComm + if !m.HandleKey("enter") { + t.Fatalf("expected enter to queue a global filter request in paused stream mode") + } + if m.filter.Comm != nil { + t.Fatalf("expected local stream filter state to remain unchanged until parent applies it") + } + req, action, ok := m.ConsumeGlobalFilterRequest() + if !ok { + t.Fatalf("expected pending global filter request") + } + if action != "comm~a" { + t.Fatalf("expected action label comm~a, got %q", action) } - if m.filter.IsActive() { - t.Fatalf("expected enter to leave global filter unchanged") + if req.PID == nil || req.PID.Op != OpEq || req.PID.Value != 1 { + t.Fatalf("expected existing pid filter preserved, got %+v", req.PID) + } + if req.Comm == nil || req.Comm.Pattern != "a" { + t.Fatalf("expected selected comm folded into global filter, got %+v", req.Comm) + } + if _, _, ok := m.ConsumeGlobalFilterRequest(); ok { + t.Fatalf("expected global filter request to be one-shot") } if m.HandleKey("esc") { t.Fatalf("expected esc not to act as local filter undo anymore") } } +func TestPausedEscQueuesGlobalFilterUndoWhenStackPresent(t *testing.T) { + rb := NewRingBuffer() + rb.Push(StreamEvent{Seq: 1, PID: 1, TID: 1, Comm: "a"}) + m := NewModel(rb) + m.height = 20 + m.Refresh() + m.SetFilterStack([]string{"comm~a"}) + if !m.HandleKey("space") { + t.Fatalf("space should pause") + } + if !m.HandleKey("esc") { + t.Fatalf("expected esc to queue undo when a global filter stack exists") + } + if !m.ConsumeGlobalFilterUndoRequest() { + t.Fatalf("expected pending global filter undo request") + } + if m.ConsumeGlobalFilterUndoRequest() { + t.Fatalf("expected global filter undo request to be one-shot") + } +} + func TestSetFilterKeepsPausedSelectionCentered(t *testing.T) { rb := NewRingBuffer() for i := 0; i < 300; i++ { diff --git a/internal/tui/eventstream/render.go b/internal/tui/eventstream/render.go index 3ec4d65..819b6f4 100644 --- a/internal/tui/eventstream/render.go +++ b/internal/tui/eventstream/render.go @@ -33,7 +33,7 @@ var selectedCellStyle = lipgloss.NewStyle(). Foreground(common.ColorBackground). Background(common.ColorAccent) -func RenderStreamTable(width int, paused bool, totalCount, filteredCount, bufferLen, bufferCap int, filter Filter, events []StreamEvent, selectedVisibleIdx int, selectedCol int) string { +func RenderStreamTable(width int, paused bool, totalCount, filteredCount, bufferLen, bufferCap int, filter Filter, filterStack []string, events []StreamEvent, selectedVisibleIdx int, selectedCol int) string { if width <= 0 { width = 100 } @@ -42,6 +42,9 @@ func RenderStreamTable(width int, paused bool, totalCount, filteredCount, buffer lines := make([]string, 0, len(events)+3) lines = append(lines, renderStatusLine(paused, totalCount, filteredCount, bufferLen, bufferCap)) lines = append(lines, renderFilterLine(filter)) + if len(filterStack) > 0 { + lines = append(lines, renderFilterStackLine(filterStack)) + } lines = append(lines, renderColumnHeader(contentWidth)) for i, ev := range events { col := -1 @@ -91,6 +94,10 @@ func renderFilterLine(filter Filter) string { return common.HeaderStyle.Render("Filter:") + " " + summary } +func renderFilterStackLine(filterStack []string) string { + return common.HeaderStyle.Render("Stack:") + " " + strings.Join(filterStack, " | ") +} + func renderColumnHeader(width int) string { cols := computeColumnLayout(width) header := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s %-*s %-*s %-*s %s", diff --git a/internal/tui/eventstream/render_test.go b/internal/tui/eventstream/render_test.go index 6240c69..d5b2af5 100644 --- a/internal/tui/eventstream/render_test.go +++ b/internal/tui/eventstream/render_test.go @@ -10,7 +10,7 @@ import ( func TestRenderStatusAndFilterLines(t *testing.T) { events := []StreamEvent{{Syscall: "read", Comm: "nginx", PID: 1, TID: 2, DurationNs: 1200, GapNs: 300, Bytes: 64, FileName: "/tmp/a", RetVal: 64}} f := Filter{Syscall: &StringFilter{Pattern: "read"}, PID: &NumericFilter{Op: OpEq, Value: 1}} - out := RenderStreamTable(120, false, 100, 1, 100, 10000, f, events, -1, -1) + out := RenderStreamTable(120, false, 100, 1, 100, 10000, f, nil, events, -1, -1) for _, want := range []string{"LIVE", "total:100", "filtered:1", "buffer:100/10000", "Filter:", "syscall~read", "pid=1"} { if !strings.Contains(out, want) { @@ -21,7 +21,7 @@ func TestRenderStatusAndFilterLines(t *testing.T) { func TestRenderPausedAndErrorRow(t *testing.T) { events := []StreamEvent{{Syscall: "write", Comm: "worker", PID: 1, TID: 2, DurationNs: 1000000, GapNs: 5000, Bytes: 32, FileName: "/tmp/b", RetVal: -1, IsError: true}} - out := RenderStreamTable(120, true, 10, 1, 10, 10000, Filter{}, events, -1, -1) + out := RenderStreamTable(120, true, 10, 1, 10, 10000, Filter{}, nil, events, -1, -1) if !strings.Contains(out, "PAUSED") { t.Fatalf("expected PAUSED indicator\n%s", out) @@ -36,7 +36,7 @@ func TestRenderPausedAndErrorRow(t *testing.T) { func TestRenderShowsFDWhenPresent(t *testing.T) { events := []StreamEvent{{Syscall: "read", Comm: "worker", PID: 1, TID: 2, FD: 9, DurationNs: 10, GapNs: 1, Bytes: 8, FileName: "/tmp/b", RetVal: 8}} - out := RenderStreamTable(120, false, 1, 1, 1, 10000, Filter{}, events, -1, -1) + out := RenderStreamTable(120, false, 1, 1, 1, 10000, Filter{}, nil, events, -1, -1) if !strings.Contains(out, "FD") || !strings.Contains(out, " 9 ") { t.Fatalf("expected FD column/value in output\n%s", out) } @@ -54,7 +54,7 @@ func TestRenderHeaderAndTruncate(t *testing.T) { FileName: "/very/long/path/that/should/be/truncated/for/narrow/views/file.log", RetVal: 1, }} - out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, events, -1, -1) + out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, nil, events, -1, -1) for _, col := range []string{"Gap", "Latency", "Comm", "PID", "TID", "Syscall", "FD", "Ret", "Bytes", "File"} { if !strings.Contains(out, col) { @@ -112,7 +112,7 @@ func TestComputeColumnLayoutGivesFileMoreSpace(t *testing.T) { } func TestRenderStreamTableFitsRequestedWidth(t *testing.T) { - out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, []StreamEvent{ + out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, nil, []StreamEvent{ { Syscall: "read", Comm: "worker", @@ -133,6 +133,15 @@ func TestRenderStreamTableFitsRequestedWidth(t *testing.T) { } } +func TestRenderShowsFilterStackLine(t *testing.T) { + out := RenderStreamTable(100, true, 3, 1, 3, 10000, Filter{Comm: &StringFilter{Pattern: "system"}}, []string{"comm~system", "fd=20"}, []StreamEvent{{Comm: "systemd", Syscall: "write"}}, -1, -1) + for _, want := range []string{"Stack:", "comm~system", "fd=20"} { + if !strings.Contains(out, want) { + t.Fatalf("expected stack output missing %q\n%s", want, out) + } + } +} + func TestRenderFDTraceTableShowsHeaderAndScope(t *testing.T) { out := RenderFDTraceTable(100, 123, 7, 2, []StreamEvent{ {Syscall: "read", PID: 123, TID: 1, FD: 7, FileName: "/tmp/a"}, |
