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 | |
| parent | d84902555621cc10b16a9641274b088e495f3714 (diff) | |
tui: restore global filter stack and anchored matches
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/common/keys.go | 93 | ||||
| -rw-r--r-- | internal/tui/common/keys_test.go | 24 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 19 | ||||
| -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 | ||||
| -rw-r--r-- | internal/tui/messages/messages.go | 14 | ||||
| -rw-r--r-- | internal/tui/tracefilter/model.go | 38 | ||||
| -rw-r--r-- | internal/tui/tracefilter/model_test.go | 17 | ||||
| -rw-r--r-- | internal/tui/tui.go | 151 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 179 |
13 files changed, 667 insertions, 95 deletions
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go index fd7bef1..f2e1271 100644 --- a/internal/tui/common/keys.go +++ b/internal/tui/common/keys.go @@ -10,27 +10,28 @@ type HelpSection struct { // KeyMap groups all key bindings shared by TUI screens. type KeyMap struct { - Tab key.Binding - ShiftTab key.Binding - One key.Binding - Two key.Binding - Three key.Binding - Four key.Binding - Five key.Binding - Six key.Binding - Seven key.Binding - Visualize key.Binding - Metric key.Binding - DirGroup key.Binding - SelectPID key.Binding - SelectTID key.Binding - Probes key.Binding - Filter key.Binding - Export key.Binding - Quit key.Binding - Enter key.Binding - Esc key.Binding - Refresh key.Binding + Tab key.Binding + ShiftTab key.Binding + One key.Binding + Two key.Binding + Three key.Binding + Four key.Binding + Five key.Binding + Six key.Binding + Seven key.Binding + Visualize key.Binding + Metric key.Binding + DirGroup key.Binding + SelectPID key.Binding + SelectTID key.Binding + Probes key.Binding + Filter key.Binding + FilterUndo key.Binding + Export key.Binding + Quit key.Binding + Enter key.Binding + Esc key.Binding + Refresh key.Binding } // Keys contains the default shared key map. @@ -39,27 +40,28 @@ var Keys = DefaultKeyMap() // DefaultKeyMap builds the default key bindings used by models. func DefaultKeyMap() KeyMap { return KeyMap{ - Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next tab")), - ShiftTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev tab")), - One: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "flame")), - Two: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "overview")), - Three: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "syscalls")), - Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "files")), - Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "processes")), - Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "lat+gaps")), - Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "stream")), - Visualize: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "viz")), - Metric: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "metric")), - DirGroup: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "dir group")), - SelectPID: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "select pid")), - SelectTID: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "select tid")), - Probes: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "probes")), - Filter: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "filter")), - Export: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "snapshot export")), - Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), - Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), - Esc: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), - Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reset baseline")), + Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next tab")), + ShiftTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev tab")), + One: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "flame")), + Two: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "overview")), + Three: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "syscalls")), + Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "files")), + Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "processes")), + Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "lat+gaps")), + Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "stream")), + Visualize: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "viz")), + Metric: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "metric")), + DirGroup: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "dir group")), + SelectPID: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "select pid")), + SelectTID: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "select tid")), + Probes: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "probes")), + Filter: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "filter")), + FilterUndo: key.NewBinding(key.WithKeys("F"), key.WithHelp("F", "undo filter")), + Export: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "snapshot export")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), + Esc: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reset baseline")), } } @@ -93,6 +95,7 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection { k.Visualize, k.Metric, k.Filter, + k.FilterUndo, k.SelectPID, k.SelectTID, k.Probes, @@ -107,6 +110,8 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection { k.Visualize, k.Metric, helpTextBinding("space", "stream pause"), + helpTextBinding("enter", "stream push filter"), + helpTextBinding("esc", "stream undo filter"), helpTextBinding("g/G", "stream top/tail"), helpTextBinding("left/right", "stream col"), helpTextBinding("h/l", "stream col"), @@ -132,13 +137,15 @@ func (k KeyMap) DashboardFullHelp() [][]key.Binding { controls = append(controls, k.Export) } controls = append(controls, k.DirGroup, k.SelectPID, k.SelectTID, k.Probes, k.Refresh, k.Quit) - controls = append(controls, k.Visualize, k.Metric, k.Filter) + controls = append(controls, k.Visualize, k.Metric, k.Filter, k.FilterUndo) return [][]key.Binding{ {k.One, k.Two, k.Three, k.Four, k.Five, k.Six, k.Seven}, controls, { helpTextBinding("space", "stream pause"), + helpTextBinding("enter", "stream push filter"), + helpTextBinding("esc", "stream undo filter"), helpTextBinding("g/G", "stream top/tail"), helpTextBinding("left/right", "stream col"), helpTextBinding("h/l", "stream col"), diff --git a/internal/tui/common/keys_test.go b/internal/tui/common/keys_test.go index a2b5940..43f4b8b 100644 --- a/internal/tui/common/keys_test.go +++ b/internal/tui/common/keys_test.go @@ -38,6 +38,11 @@ func TestDefaultKeyMapIncludesDirGroupBinding(t *testing.T) { if metricHelp.Key != "b" || metricHelp.Desc != "metric" { t.Fatalf("unexpected metric binding help: key=%q desc=%q", metricHelp.Key, metricHelp.Desc) } + + undoHelp := keys.FilterUndo.Help() + if undoHelp.Key != "F" || undoHelp.Desc != "undo filter" { + t.Fatalf("unexpected filter undo binding help: key=%q desc=%q", undoHelp.Key, undoHelp.Desc) + } } func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) { @@ -130,6 +135,18 @@ func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) { if !found { t.Fatalf("expected metric binding in dashboard full help controls") } + + found = false + for _, binding := range groups[1] { + help := binding.Help() + if help.Key == "F" && help.Desc == "undo filter" { + found = true + break + } + } + if !found { + t.Fatalf("expected undo filter binding in dashboard full help controls") + } } func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) { @@ -138,6 +155,7 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) { found := false foundSelectTID := false foundOne := false + foundUndo := false for _, binding := range short { help := binding.Help() if help.Key == "o" && help.Desc == "probes" { @@ -149,6 +167,9 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) { if help.Key == "1" && help.Desc == "flame" { foundOne = true } + if help.Key == "F" && help.Desc == "undo filter" { + foundUndo = true + } } if !found { t.Fatalf("expected probes binding in dashboard short help") @@ -159,4 +180,7 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) { if !foundOne { t.Fatalf("expected flame tab binding in dashboard short help") } + if !foundUndo { + t.Fatalf("expected undo filter binding in dashboard short help") + } } diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index c5effe2..22d09c3 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -65,6 +65,7 @@ type Model struct { refreshEvery time.Duration keys common.KeyMap globalFilter globalfilter.Filter + filterStack []string pidFilter int syscallsOffset int syscallsTreemapSelection int @@ -418,6 +419,12 @@ func (m *Model) handleScrollKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { streamWidth, streamHeight := streamViewport(m.width, m.height) m.streamModel.SetViewport(streamWidth, streamHeight) handled := m.streamModel.HandleTeaKey(msg) + if m.streamModel.ConsumeGlobalFilterUndoRequest() { + return true, func() tea.Msg { return messages.GlobalFilterUndoRequestedMsg{} } + } + if filter, action, ok := m.streamModel.ConsumeGlobalFilterRequest(); ok { + return true, func() tea.Msg { return messages.GlobalFilterRequestedMsg{Filter: filter, Action: action} } + } if path, ok := m.streamModel.ConsumeOpenEditorRequest(); ok { editorCmd, err := eventstream.EditorCommandForPath(path) if err != nil { @@ -543,6 +550,12 @@ func (m *Model) SetGlobalFilter(filter globalfilter.Filter) { m.streamModel.SetFilter(eventstream.Filter(filter)) } +// SetFilterStack forwards the shared global filter stack into dashboard views. +func (m *Model) SetFilterStack(stack []string) { + m.filterStack = append(m.filterStack[:0], stack...) + m.streamModel.SetFilterStack(stack) +} + // SetLiveTrie updates the live trie source used by the flamegraph tab. func (m *Model) SetLiveTrie(liveTrie flamegraphtui.LiveTrieSource) { m.liveTrie = liveTrie @@ -612,7 +625,11 @@ func (m Model) View() tea.View { } func (m Model) filterSummary() string { - return "filter: " + m.globalFilter.Summary() + summary := "filter: " + m.globalFilter.Summary() + if len(m.filterStack) == 0 { + return summary + } + return summary + " | stack: " + strings.Join(m.filterStack, " | ") } func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventstream.Model) string { 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"}, diff --git a/internal/tui/messages/messages.go b/internal/tui/messages/messages.go index 7d0273b..9826e25 100644 --- a/internal/tui/messages/messages.go +++ b/internal/tui/messages/messages.go @@ -1,6 +1,9 @@ package messages -import "ior/internal/statsengine" +import ( + "ior/internal/globalfilter" + "ior/internal/statsengine" +) // PidSelectedMsg is emitted when the user selects a PID from the process table. type PidSelectedMsg struct { @@ -21,6 +24,15 @@ type StatsTickMsg struct { // ExportRequestMsg requests an export of the current UI state. type ExportRequestMsg struct{} +// GlobalFilterRequestedMsg requests applying a new shared TUI filter. +type GlobalFilterRequestedMsg struct { + Filter globalfilter.Filter + Action string +} + +// GlobalFilterUndoRequestedMsg requests popping the latest shared filter layer. +type GlobalFilterUndoRequestedMsg struct{} + // TracingStartedMsg signals that tracing started successfully. type TracingStartedMsg struct{} diff --git a/internal/tui/tracefilter/model.go b/internal/tui/tracefilter/model.go index a9f2c08..caef948 100644 --- a/internal/tui/tracefilter/model.go +++ b/internal/tui/tracefilter/model.go @@ -104,11 +104,32 @@ func (m Model) Update(msg tea.Msg) Model { } if keyMsg, ok := msg.(tea.KeyPressMsg); ok { - switch keyMsg.String() { - case "esc": - if m.editing { + if m.editing { + switch keyMsg.String() { + case "esc": + m.commitEdit() + m.filter = m.buildFilterFromFields() + return m.Close() + 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 + } m.commitEdit() + return m } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + _ = cmd + return m + } + + switch keyMsg.String() { + case "esc": m.filter = m.buildFilterFromFields() return m.Close() case "c": @@ -147,20 +168,10 @@ func (m Model) Update(msg tea.Msg) Model { } 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 } @@ -192,6 +203,7 @@ func (m Model) View(width, height int) string { 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") + lines = append(lines, "strings: substring by default, use ^prefix, suffix$, or ^exact$") box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). diff --git a/internal/tui/tracefilter/model_test.go b/internal/tui/tracefilter/model_test.go index 83754b5..5e03deb 100644 --- a/internal/tui/tracefilter/model_test.go +++ b/internal/tui/tracefilter/model_test.go @@ -100,3 +100,20 @@ func TestModelClearAll(t *testing.T) { t.Fatalf("expected cleared filter to be inactive: %+v", filter) } } + +func TestModelEditingAllowsPrintableHotkeyRunes(t *testing.T) { + model := NewModel().Open(globalfilter.Filter{}) + + model = model.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) + model = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + for _, r := range []rune("codexjk") { + model = model.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + model = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + + filter := model.Filter() + if filter.Comm == nil || filter.Comm.Pattern != "codexjk" { + t.Fatalf("expected printable runes preserved while editing, got %+v", filter.Comm) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 6972e98..fe23a49 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "strconv" "strings" "sync" "time" @@ -255,6 +256,8 @@ type Model struct { pidFilter int tidFilter int globalFilter globalfilter.Filter + filterHistory []globalfilter.Filter + filterStack []string pickerReturn *pickerReturnState exportEnabled bool isDark bool @@ -431,6 +434,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.attaching = false m.lastErr = msg.Err return m, nil + case messages.GlobalFilterRequestedMsg: + return m.applyGlobalFilter(msg.Filter, msg.Action) + case messages.GlobalFilterUndoRequestedMsg: + return m.undoGlobalFilter() } if next, cmd, handled := m.handleModalDispatch(msg); handled { @@ -531,6 +538,10 @@ func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo m.filterModal = m.filterModal.Open(m.globalFilter) return m, nil, true } + if m.canHandleDashboardShortcut(msg) && key.Matches(msg, m.keys.FilterUndo) { + next, cmd := m.undoGlobalFilter() + return next, cmd, true + } if m.canHandleDashboardShortcut(msg) && key.Matches(msg, m.keys.SelectPID) { next, cmd := m.reselectPID() return next, cmd, true @@ -563,7 +574,7 @@ func (m Model) updateFilterModal(msg tea.Msg) (tea.Model, tea.Cmd) { wasVisible := m.filterModal.Visible() m.filterModal = m.filterModal.Update(msg) if wasVisible && !m.filterModal.Visible() { - next, cmd := m.applyGlobalFilter(m.filterModal.Filter()) + next, cmd := m.applyGlobalFilter(m.filterModal.Filter(), "") return next, tea.Batch(dashboardCmd, cmd) } return m, dashboardCmd @@ -854,10 +865,11 @@ func eqNumericFilter(value int) *globalfilter.NumericFilter { func (m *Model) setProcessFilters(pid, tid int) { m.pidFilter = pid m.tidFilter = tid - m.globalFilter.PID = eqNumericFilter(pid) - m.globalFilter.TID = eqNumericFilter(tid) - m.dashboard.SetPidFilter(pid) - m.dashboard.SetGlobalFilter(m.globalFilter) + m.globalFilter = applyProcessFilters(m.globalFilter, pid, tid) + for i := range m.filterHistory { + m.filterHistory[i] = applyProcessFilters(m.filterHistory[i], pid, tid) + } + m.syncDashboardFilterState() } func (m *Model) setGlobalFilter(filter globalfilter.Filter) { @@ -866,13 +878,29 @@ func (m *Model) setGlobalFilter(filter globalfilter.Filter) { tid, _ := eqNumericFilterValue(m.globalFilter.TID) m.pidFilter = pid m.tidFilter = tid - m.dashboard.SetPidFilter(pid) + m.syncDashboardFilterState() +} + +func (m *Model) syncDashboardFilterState() { + m.dashboard.SetPidFilter(m.pidFilter) m.dashboard.SetGlobalFilter(m.globalFilter) + m.dashboard.SetFilterStack(m.filterStack) } -func (m Model) applyGlobalFilter(filter globalfilter.Filter) (tea.Model, tea.Cmd) { +func applyProcessFilters(filter globalfilter.Filter, pid, tid int) globalfilter.Filter { + out := filter.Clone() + out.PID = eqNumericFilter(pid) + out.TID = eqNumericFilter(tid) + return out +} + +func (m Model) applyGlobalFilter(filter globalfilter.Filter, action string) (tea.Model, tea.Cmd) { nextFilter := filter.Clone() changed := !m.globalFilter.Equal(nextFilter) + if changed { + m.filterHistory = append(m.filterHistory, m.globalFilter.Clone()) + m.filterStack = append(m.filterStack, globalFilterActionLabel(m.globalFilter, nextFilter, action)) + } m.setGlobalFilter(nextFilter) if !changed || m.screen != ScreenDashboard { return m, nil @@ -885,6 +913,27 @@ func (m Model) applyGlobalFilter(filter globalfilter.Filter) (tea.Model, tea.Cmd return m, tea.Batch(m.spin.Tick, m.beginTraceCmd()) } +func (m Model) undoGlobalFilter() (tea.Model, tea.Cmd) { + if len(m.filterHistory) == 0 { + return m, nil + } + prev := m.filterHistory[len(m.filterHistory)-1] + m.filterHistory = m.filterHistory[:len(m.filterHistory)-1] + if len(m.filterStack) > 0 { + m.filterStack = m.filterStack[:len(m.filterStack)-1] + } + m.setGlobalFilter(prev) + if m.screen != ScreenDashboard { + return m, nil + } + + m.stopTrace() + m.dashboard.PrepareForTraceRestart() + m.attaching = true + m.lastErr = nil + return m, tea.Batch(m.spin.Tick, m.beginTraceCmd()) +} + func eqNumericFilterValue(filter *globalfilter.NumericFilter) (int, bool) { if filter == nil || filter.Op != globalfilter.OpEq || filter.Value <= 0 { return -1, false @@ -892,6 +941,94 @@ func eqNumericFilterValue(filter *globalfilter.NumericFilter) (int, bool) { return int(filter.Value), true } +func globalFilterActionLabel(prev, next globalfilter.Filter, action string) string { + if strings.TrimSpace(action) != "" { + return action + } + parts := make([]string, 0, 10) + if prev.ErrorsOnly != next.ErrorsOnly { + if next.ErrorsOnly { + parts = append(parts, "errors") + } else { + parts = append(parts, "clear errors") + } + } + parts = appendStringFilterChange(parts, "syscall", prev.Syscall, next.Syscall) + parts = appendStringFilterChange(parts, "comm", prev.Comm, next.Comm) + parts = appendStringFilterChange(parts, "file", prev.File, next.File) + parts = appendNumericFilterChange(parts, "pid", prev.PID, next.PID, false) + parts = appendNumericFilterChange(parts, "tid", prev.TID, next.TID, false) + parts = appendNumericFilterChange(parts, "fd", prev.FD, next.FD, false) + parts = appendNumericFilterChange(parts, "latency", prev.LatencyNs, next.LatencyNs, true) + parts = appendNumericFilterChange(parts, "gap", prev.GapNs, next.GapNs, true) + parts = appendNumericFilterChange(parts, "bytes", prev.Bytes, next.Bytes, false) + parts = appendNumericFilterChange(parts, "ret", prev.RetVal, next.RetVal, false) + if len(parts) == 0 { + return next.Summary() + } + return strings.Join(parts, " ") +} + +func appendStringFilterChange(parts []string, name string, prev, next *globalfilter.StringFilter) []string { + if sameStringFilter(prev, next) { + return parts + } + if next == nil || strings.TrimSpace(next.Pattern) == "" { + return append(parts, "clear "+name) + } + return append(parts, fmt.Sprintf("%s~%s", name, strings.TrimSpace(next.Pattern))) +} + +func appendNumericFilterChange(parts []string, name string, prev, next *globalfilter.NumericFilter, duration bool) []string { + if sameNumericFilter(prev, next) { + return parts + } + if next == nil { + return append(parts, "clear "+name) + } + value := strconv.FormatInt(next.Value, 10) + if duration { + value = time.Duration(next.Value).String() + } + return append(parts, fmt.Sprintf("%s%s%s", name, compareOpSymbol(next.Op), value)) +} + +func sameStringFilter(a, b *globalfilter.StringFilter) bool { + if a == nil || strings.TrimSpace(a.Pattern) == "" { + return b == nil || strings.TrimSpace(b.Pattern) == "" + } + if b == nil { + return false + } + return strings.TrimSpace(a.Pattern) == strings.TrimSpace(b.Pattern) +} + +func sameNumericFilter(a, b *globalfilter.NumericFilter) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return a.Op == b.Op && a.Value == b.Value +} + +func compareOpSymbol(op globalfilter.CompareOp) string { + switch op { + case globalfilter.OpEq: + return "=" + case globalfilter.OpNeq: + return "!=" + case globalfilter.OpGt: + return ">" + case globalfilter.OpGte: + return ">=" + case globalfilter.OpLt: + return "<" + case globalfilter.OpLte: + return "<=" + default: + return "=" + } +} + func (m *Model) stopTrace() { if m.traceStop != nil { m.traceStop() diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 513de4c..ab8b5db 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1184,6 +1184,185 @@ func TestGlobalFilterCloseWithoutChangesDoesNotRestartTrace(t *testing.T) { } } +func TestPausedStreamEnterAppliesSelectedCellAsGlobalFilter(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.width = 120 + m.height = 30 + + stopped := false + m.traceStop = func() { stopped = true } + + rb := eventstream.NewRingBuffer() + rb.Push(eventstream.StreamEvent{ + Seq: 1, + Syscall: "write", + Comm: "systemd", + PID: 3655, + TID: 4862, + FileName: "/var/lib/clickhouse/data", + DurationNs: 1234, + GapNs: 44, + FD: 20, + }) + m.dashboard.SetStreamSource(rb) + + next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'7'}[0], Text: string([]rune{'7'})}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + m = next.(Model) + + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + if cmd == nil { + t.Fatalf("expected enter on paused stream selection to emit a global filter request") + } + + next, cmd = m.Update(cmd()) + m = next.(Model) + if cmd == nil { + t.Fatalf("expected applying selected-cell global filter to restart tracing") + } + if m.globalFilter.Comm == nil || m.globalFilter.Comm.Pattern != "systemd" { + t.Fatalf("expected selected comm applied globally, got %+v", m.globalFilter.Comm) + } + if !stopped { + t.Fatalf("expected selected-cell global filter to stop the active trace") + } + if !m.attaching { + t.Fatalf("expected selected-cell global filter to restart tracing") + } + if len(m.filterStack) != 1 || m.filterStack[0] != "comm~systemd" { + t.Fatalf("expected selected-cell action pushed to filter stack, got %+v", m.filterStack) + } +} + +func TestGlobalFilterUndoKeyPopsLatestStackEntry(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'f'}[0], Text: string([]rune{'f'})}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: []rune("read")[0], Text: string([]rune("read"))}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + m = next.(Model) + + m.attaching = false + stopped := false + m.traceStop = func() { stopped = true } + + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'F'}[0], Text: string([]rune{'F'})}) + m = next.(Model) + if cmd == nil { + t.Fatalf("expected F to trigger global filter undo") + } + if m.globalFilter.IsActive() { + t.Fatalf("expected undo to restore the previous all-filter state, got %+v", m.globalFilter) + } + if len(m.filterStack) != 0 || len(m.filterHistory) != 0 { + t.Fatalf("expected filter stack/history cleared after undo, got stack=%+v history=%d", m.filterStack, len(m.filterHistory)) + } + if !stopped { + t.Fatalf("expected undo to stop the active trace") + } + if !m.attaching { + t.Fatalf("expected undo to restart tracing") + } +} + +func TestPausedStreamEscUndoesLatestGlobalFilter(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.width = 120 + m.height = 30 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'f'}[0], Text: string([]rune{'f'})}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: []rune("read")[0], Text: string([]rune("read"))}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + m = next.(Model) + + rb := eventstream.NewRingBuffer() + rb.Push(eventstream.StreamEvent{Seq: 1, Syscall: "read", Comm: "systemd", PID: 1, TID: 2}) + m.dashboard.SetStreamSource(rb) + m.attaching = false + stopped := false + m.traceStop = func() { stopped = true } + + next, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'7'}[0], Text: string([]rune{'7'})}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + m = next.(Model) + + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + m = next.(Model) + if cmd == nil { + t.Fatalf("expected esc in paused stream to undo one global filter layer") + } + next, cmd = m.Update(cmd()) + m = next.(Model) + if cmd == nil { + t.Fatalf("expected esc undo to restart tracing") + } + if m.globalFilter.IsActive() { + t.Fatalf("expected esc undo to restore all-filter state, got %+v", m.globalFilter) + } + if len(m.filterStack) != 0 { + t.Fatalf("expected filter stack cleared after esc undo, got %+v", m.filterStack) + } + if !stopped { + t.Fatalf("expected esc undo to stop the active trace") + } + if !m.attaching { + t.Fatalf("expected esc undo to restart tracing") + } +} + +func TestDashboardFooterShowsGlobalFilterStack(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.width = 140 + m.height = 35 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'f'}[0], Text: string([]rune{'f'})}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: []rune("read")[0], Text: string([]rune("read"))}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + m = next.(Model) + + m.attaching = false + next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'4'}[0], Text: string([]rune{'4'})}) + m = next.(Model) + + view := m.View().Content + for _, want := range []string{"filter: syscall~read", "stack: syscall~read"} { + if !strings.Contains(view, want) { + t.Fatalf("expected dashboard footer to show %q\n%s", want, view) + } + } +} + func TestGlobalFilterApplyPreservesActiveDashboardTab(t *testing.T) { m := NewModel(-1, func(context.Context) error { return nil }) m.screen = ScreenDashboard |
