summaryrefslogtreecommitdiff
path: root/internal/tui/eventstream
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-08 22:03:01 +0200
committerPaul Buetow <paul@buetow.org>2026-03-08 22:03:01 +0200
commit0d1492291a3e20665d8a3a6b16d2eb4e13938cee (patch)
treeec09f7d660403478d23841cf541bdfa7f33aa70f /internal/tui/eventstream
parentd84902555621cc10b16a9641274b088e495f3714 (diff)
tui: restore global filter stack and anchored matches
Diffstat (limited to 'internal/tui/eventstream')
-rw-r--r--internal/tui/eventstream/filter_test.go17
-rw-r--r--internal/tui/eventstream/model.go132
-rw-r--r--internal/tui/eventstream/model_test.go50
-rw-r--r--internal/tui/eventstream/render.go9
-rw-r--r--internal/tui/eventstream/render_test.go19
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"},