diff options
| -rw-r--r-- | internal/tui/dashboard/model.go | 2 | ||||
| -rw-r--r-- | internal/tui/eventstream/model.go | 163 | ||||
| -rw-r--r-- | internal/tui/eventstream/model_test.go | 203 | ||||
| -rw-r--r-- | internal/tui/tui.go | 4 |
4 files changed, 47 insertions, 325 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 5c3f690..5b13642 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -522,7 +522,7 @@ func (m Model) ActiveTab() Tab { // top-level shortcut for the given key press. func (m Model) BlocksGlobalShortcuts(msg tea.KeyPressMsg) bool { if m.activeTab == TabStream { - return m.streamModel.FilterModalVisible() || m.streamModel.ExportModalVisible() || m.streamModel.SearchModalVisible() + return m.streamModel.ExportModalVisible() || m.streamModel.SearchModalVisible() } if m.activeTab == TabFlame { return m.flamegraphModel.ConsumesKey(msg) diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index 3dc774b..467c137 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -36,31 +36,25 @@ type Model struct { allEvents []StreamEvent filtered []StreamEvent - filter Filter - filterModal FilterModal + filter Filter paused bool - // pauseBeforeFilter keeps the pre-modal pause state so opening the filter - // can pause refresh temporarily without losing the user's prior state. - pauseBeforeFilter bool - - scrollOffset int - autoScroll bool - selectedIdx int - selectedCol int - fdTraceView fdTraceViewState - filterStack []Filter - filterActionStack []string - 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 + statusMessage string + exportDir string + isDark bool width int height int @@ -80,7 +74,6 @@ type fdTraceViewState struct { func NewModel(source Source) Model { m := Model{ source: source, - filterModal: NewFilterModal(), exportModal: NewExportModal(), searchModal: NewSearchModal(), autoScroll: true, @@ -145,14 +138,13 @@ func (m *Model) SetFilter(filter Filter) { // SetDarkMode updates stream modal text input styles for the active theme. func (m *Model) SetDarkMode(isDark bool) { m.isDark = isDark - m.filterModal = m.filterModal.SetDarkMode(isDark) m.exportModal = m.exportModal.SetDarkMode(isDark) m.searchModal = m.searchModal.SetDarkMode(isDark) } // FilterModalVisible reports whether the filter modal is currently open. func (m Model) FilterModalVisible() bool { - return m.filterModal.Visible() + return false } // ExportModalVisible reports whether the stream export modal is currently open. @@ -202,21 +194,6 @@ func (m *Model) HandleKey(keyStr string) bool { m.statusMessage = "Exported: " + path return true } - if m.filterModal.Visible() { - wasVisible := m.filterModal.Visible() - m.filterModal = m.filterModal.Update(keyMsgFromString(keyStr)) - if wasVisible && !m.filterModal.Visible() { - m.filter = m.filterModal.Filter() - m.filterStack = nil - m.filterActionStack = nil - m.paused = m.pauseBeforeFilter - m.applyFilter() - if !m.paused { - m.Refresh() - } - } - return true - } if m.fdTraceView.visible { switch keyStr { case "enter", " ", "space": @@ -260,11 +237,6 @@ func (m *Model) HandleKey(keyStr string) bool { case "?": m.openSearch(SearchBackward) return true - case "enter": - if m.paused { - return m.applyFilterFromSelectedCell() - } - return false case "n": if m.searchRegex == nil { return false @@ -320,11 +292,6 @@ func (m *Model) HandleKey(keyStr string) bool { m.centerSelection() } return true - case "f", "F": - m.pauseBeforeFilter = m.paused - m.paused = true - m.filterModal = m.filterModal.Open(m.filter) - return true case "G": if m.paused { m.moveSelectionTo(len(m.filtered) - 1) @@ -343,12 +310,6 @@ func (m *Model) HandleKey(keyStr string) bool { m.scrollOffset = 0 } return true - case "c": - m.filter = Filter{} - m.filterStack = nil - m.filterActionStack = nil - m.applyFilter() - return true case "j", "down": if m.paused { m.moveSelectionBy(1) @@ -389,11 +350,6 @@ func (m *Model) HandleKey(keyStr string) bool { m.handleViewportUpdate(keyMsgFromString("pgup")) } return true - case "esc": - if m.paused { - return m.popFilter() - } - return false default: return false } @@ -437,7 +393,7 @@ func (m *Model) HandleTeaKey(msg tea.KeyPressMsg) bool { } func (m *Model) handleViewportUpdate(msg tea.KeyPressMsg) bool { - if m.paused || m.fdTraceView.visible || m.filterModal.Visible() || m.exportModal.Visible() || m.searchModal.Visible() { + if m.paused || m.fdTraceView.visible || m.exportModal.Visible() || m.searchModal.Visible() { return false } @@ -504,9 +460,6 @@ func (m *Model) View(width, height int) string { } base := RenderStreamTable(width, m.paused, len(m.allEvents), len(m.filtered), bufferLen, ringBufferCapacity, m.filter, visible, selectedVisibleIdx, selectedCol) if !m.showFooter { - if m.filterModal.Visible() { - return m.filterModal.View(width, height) - } if m.exportModal.Visible() { return m.exportModal.View(width, height) } @@ -518,21 +471,13 @@ 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 | Filters %d", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered), m.selectedCol+1, streamColumnCount, len(m.filterStack)) + 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) } out := base + "\n" + status - if len(m.filterActionStack) > 0 { - out += "\n" + "Stack: " + strings.Join(m.filterActionStack, " | ") - } if m.statusMessage != "" { out += "\n" + m.statusMessage } - if m.filterModal.Visible() { - // While editing filters, show a dedicated modal screen to avoid - // visual mixing with the live stream table underneath. - return m.filterModal.View(width, height) - } if m.exportModal.Visible() { return m.exportModal.View(width, height) } @@ -754,74 +699,6 @@ func (m *Model) moveSelectedColBy(delta int) { m.selectedCol = clamp(m.selectedCol+delta, 0, streamColumnCount-1) } -func (m *Model) applyFilterFromSelectedCell() bool { - if m.selectedIdx < 0 || m.selectedIdx >= len(m.filtered) { - return false - } - ev := m.filtered[m.selectedIdx] - targetSeq := ev.Seq - next := m.filter.Clone() - action := "" - - switch m.selectedCol { - case streamColGap: - next.GapNs = &NumericFilter{Op: OpGte, Value: int64(ev.GapNs)} - action = fmt.Sprintf("gap>=%dns", ev.GapNs) - case streamColLatency: - next.LatencyNs = &NumericFilter{Op: OpGte, Value: int64(ev.DurationNs)} - action = fmt.Sprintf("latency>=%dns", ev.DurationNs) - case streamColComm: - next.Comm = &StringFilter{Pattern: ev.Comm} - action = fmt.Sprintf("comm~%s", ev.Comm) - case streamColPID: - next.PID = &NumericFilter{Op: OpEq, Value: int64(ev.PID)} - action = fmt.Sprintf("pid=%d", ev.PID) - case streamColTID: - next.TID = &NumericFilter{Op: OpEq, Value: int64(ev.TID)} - action = fmt.Sprintf("tid=%d", ev.TID) - case streamColSyscall: - next.Syscall = &StringFilter{Pattern: ev.Syscall} - action = fmt.Sprintf("syscall~%s", ev.Syscall) - case streamColFD: - next.FD = &NumericFilter{Op: OpEq, Value: int64(ev.FD)} - action = fmt.Sprintf("fd=%d", ev.FD) - case streamColRet: - next.RetVal = &NumericFilter{Op: OpEq, Value: ev.RetVal} - action = fmt.Sprintf("ret=%d", ev.RetVal) - case streamColBytes: - next.Bytes = &NumericFilter{Op: OpEq, Value: int64(ev.Bytes)} - action = fmt.Sprintf("bytes=%d", ev.Bytes) - case streamColFile: - next.File = &StringFilter{Pattern: ev.FileName} - action = fmt.Sprintf("file~%s", ev.FileName) - default: - return false - } - - m.filterStack = append(m.filterStack, m.filter.Clone()) - m.filterActionStack = append(m.filterActionStack, action) - m.filter = next - m.applyFilter() - m.restoreSelectionBySeq(targetSeq) - return true -} - -func (m *Model) popFilter() bool { - if len(m.filterStack) == 0 { - return false - } - targetSeq := m.currentSelectedSeq() - last := m.filterStack[len(m.filterStack)-1] - m.filterStack = m.filterStack[:len(m.filterStack)-1] - if len(m.filterActionStack) > 0 { - m.filterActionStack = m.filterActionStack[:len(m.filterActionStack)-1] - } - m.filter = last.Clone() - m.applyFilter() - m.restoreSelectionBySeq(targetSeq) - return true -} - func (m *Model) currentSelectedSeq() uint64 { if m.selectedIdx < 0 || m.selectedIdx >= len(m.filtered) { return 0 diff --git a/internal/tui/eventstream/model_test.go b/internal/tui/eventstream/model_test.go index 3925d26..9848042 100644 --- a/internal/tui/eventstream/model_test.go +++ b/internal/tui/eventstream/model_test.go @@ -185,65 +185,26 @@ func TestModelHandleKeyRouting(t *testing.T) { if m.HandleKey("x") { t.Fatalf("unknown key should not be handled") } - if !m.HandleKey("f") { - t.Fatalf("f should be handled") - } - if !m.filterModal.Visible() { - t.Fatalf("modal should be visible after f") - } - if !m.HandleKey("esc") { - t.Fatalf("esc should route to modal") - } - if m.filterModal.Visible() { - t.Fatalf("modal should close on esc") + if m.HandleKey("f") { + t.Fatalf("stream-local filter shortcut should no longer be handled here") } } -func TestFilterModalTemporarilyPausesAndRestoresState(t *testing.T) { +func TestSetFilterReappliesCurrentBufferedRows(t *testing.T) { rb := NewRingBuffer() m := NewModel(rb) m.height = 20 - pushEvents(rb, 4) + pushEvents(rb, 6) m.Refresh() - if m.paused { - t.Fatalf("expected model to start unpaused") - } - if !m.HandleKey("f") { - t.Fatalf("f should be handled") - } - if !m.paused { - t.Fatalf("expected model paused while filter modal is open") - } - if !m.filterModal.Visible() { - t.Fatalf("expected filter modal visible after f") - } - if !m.HandleKey("esc") { - t.Fatalf("esc should be routed to filter modal") - } - if m.filterModal.Visible() { - t.Fatalf("expected filter modal closed after esc") - } - if m.paused { - t.Fatalf("expected pause state restored to unpaused after modal close") + m.SetFilter(Filter{Syscall: &StringFilter{Pattern: "read"}}) + if len(m.filtered) != 3 { + t.Fatalf("expected 3 matching rows after filter, got %d", len(m.filtered)) } - // If the user was already paused before opening the filter modal, - // that pause state should remain after closing. - if !m.HandleKey("space") { - t.Fatalf("space should toggle pause") - } - if !m.paused { - t.Fatalf("expected paused=true after space") - } - if !m.HandleKey("f") { - t.Fatalf("f should be handled while paused") - } - if !m.HandleKey("esc") { - t.Fatalf("esc should close modal") - } - if !m.paused { - t.Fatalf("expected paused state preserved after modal close") + m.SetFilter(Filter{}) + if len(m.filtered) != 6 { + t.Fatalf("expected clearing filter to restore all rows, got %d", len(m.filtered)) } } @@ -416,113 +377,31 @@ func TestPausedSelectionMovesAcrossColumnsWithLeftRightAndHL(t *testing.T) { } } -func TestPausedEnterAddsStackableFiltersAndEscUndoes(t *testing.T) { - rb := NewRingBuffer() - rb.Push(StreamEvent{Seq: 1, PID: 10, TID: 101, Comm: "firefox", Syscall: "read", FD: 5, RetVal: 10, Bytes: 10, GapNs: 50, DurationNs: 100, FileName: "/a"}) - rb.Push(StreamEvent{Seq: 2, PID: 10, TID: 102, Comm: "firefox", Syscall: "write", FD: 6, RetVal: 11, Bytes: 11, GapNs: 51, DurationNs: 101, FileName: "/b"}) - rb.Push(StreamEvent{Seq: 3, PID: 11, TID: 201, Comm: "bash", Syscall: "read", FD: 7, RetVal: 12, Bytes: 12, GapNs: 52, DurationNs: 102, FileName: "/c"}) - - m := NewModel(rb) - m.height = 20 - m.Refresh() - if !m.HandleKey("space") { - t.Fatalf("space should toggle pause") - } - - m.selectedIdx = 0 - m.selectedCol = streamColComm - if !m.HandleKey("enter") { - t.Fatalf("enter should add filter from selected comm cell") - } - if m.filter.Comm == nil || m.filter.Comm.Pattern != "firefox" { - t.Fatalf("expected comm filter firefox, got %+v", m.filter.Comm) - } - if len(m.filtered) != 2 { - t.Fatalf("expected 2 rows after comm filter, got %d", len(m.filtered)) - } - if len(m.filterStack) != 1 { - t.Fatalf("expected filter stack len 1, got %d", len(m.filterStack)) - } - if got := m.View(120, 24); !strings.Contains(got, "Stack: comm~firefox") { - t.Fatalf("expected stack status line with comm filter, got:\n%s", got) - } - - m.selectedIdx = 1 - m.selectedCol = streamColTID - if !m.HandleKey("enter") { - t.Fatalf("enter should add filter from selected tid cell") - } - if m.filter.TID == nil || m.filter.TID.Value != 102 { - t.Fatalf("expected tid filter 102, got %+v", m.filter.TID) - } - if m.filter.Comm == nil || m.filter.Comm.Pattern != "firefox" { - t.Fatalf("expected comm filter preserved, got %+v", m.filter.Comm) - } - if len(m.filtered) != 1 { - t.Fatalf("expected 1 row after comm+tid filters, got %d", len(m.filtered)) - } - - if !m.HandleKey("esc") { - t.Fatalf("esc should undo last filter") - } - if m.filter.TID != nil { - t.Fatalf("expected tid filter cleared after undo") - } - if m.filter.Comm == nil || m.filter.Comm.Pattern != "firefox" { - t.Fatalf("expected comm filter kept after first undo") - } - if len(m.filtered) != 2 { - t.Fatalf("expected 2 rows after undo tid filter, got %d", len(m.filtered)) - } - - if !m.HandleKey("esc") { - t.Fatalf("esc should undo previous filter") - } - if m.filter.IsActive() { - t.Fatalf("expected no active filters after second undo, got summary=%q", m.filter.Summary()) - } - if len(m.filtered) != 3 { - t.Fatalf("expected all rows after second undo, got %d", len(m.filtered)) - } - if got := m.View(120, 24); strings.Contains(got, "Stack:") { - t.Fatalf("did not expect stack status line after all undos, got:\n%s", got) - } -} - -func TestPausedEnterCanFilterLatencyAndGapColumns(t *testing.T) { +func TestPausedEnterDoesNotMutateGlobalFilter(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.HandleKey("space") + if !m.HandleKey("space") { + t.Fatalf("space should pause") + } m.selectedIdx = 0 m.selectedCol = streamColLatency - if !m.HandleKey("enter") { - t.Fatalf("expected enter to filter by latency") - } - if m.filter.LatencyNs == nil || m.filter.LatencyNs.Op != OpGte || m.filter.LatencyNs.Value != 100 { - t.Fatalf("expected latency filter >=100ns, got %+v", m.filter.LatencyNs) + if m.HandleKey("enter") { + t.Fatalf("expected enter not to mutate filters in paused stream mode") } - if len(m.filtered) != 2 { - t.Fatalf("expected both rows after latency >=100 filter, got %d", len(m.filtered)) - } - - m.selectedCol = streamColGap - if !m.HandleKey("enter") { - t.Fatalf("expected enter to filter by gap") + if m.filter.IsActive() { + t.Fatalf("expected enter to leave global filter unchanged") } - if m.filter.GapNs == nil || m.filter.GapNs.Op != OpGte || m.filter.GapNs.Value != 5 { - t.Fatalf("expected gap filter >=5ns, got %+v", m.filter.GapNs) - } - if len(m.filtered) != 2 { - t.Fatalf("expected both rows after gap >=5 filter, got %d", len(m.filtered)) + if m.HandleKey("esc") { + t.Fatalf("expected esc not to act as local filter undo anymore") } } -func TestPausedEnterKeepsSelectedRowCenteredAfterFilter(t *testing.T) { +func TestSetFilterKeepsPausedSelectionCentered(t *testing.T) { rb := NewRingBuffer() for i := 0; i < 300; i++ { comm := "other" @@ -549,40 +428,10 @@ func TestPausedEnterKeepsSelectedRowCenteredAfterFilter(t *testing.T) { t.Fatalf("expected initial selected row near middle, got relative idx %d", before) } - m.selectedCol = streamColComm - if !m.HandleKey("enter") { - t.Fatalf("expected enter to apply filter") - } + m.SetFilter(Filter{Comm: &StringFilter{Pattern: "match"}}) after := m.selectedIdx - m.scrollOffset if after < 4 || after > 8 { - t.Fatalf("expected selected row to stay near middle after filter, got relative idx %d", after) - } -} - -func TestPausedEnterCanFilterByFDColumn(t *testing.T) { - rb := NewRingBuffer() - rb.Push(StreamEvent{Seq: 1, PID: 10, TID: 101, FD: 3, Syscall: "read", FileName: "/a"}) - rb.Push(StreamEvent{Seq: 2, PID: 10, TID: 102, FD: 3, Syscall: "write", FileName: "/a"}) - rb.Push(StreamEvent{Seq: 3, PID: 10, TID: 103, FD: 4, Syscall: "read", FileName: "/b"}) - rb.Push(StreamEvent{Seq: 4, PID: 11, TID: 104, FD: 3, Syscall: "read", FileName: "/c"}) - - m := NewModel(rb) - m.height = 20 - m.Refresh() - if !m.HandleKey("space") { - t.Fatalf("space should pause") - } - - m.selectedIdx = 0 - m.selectedCol = streamColFD - if !m.HandleKey("enter") { - t.Fatalf("enter should add fd filter") - } - if m.filter.FD == nil || m.filter.FD.Value != 3 { - t.Fatalf("expected fd filter 3, got %+v", m.filter.FD) - } - if len(m.filtered) != 3 { - t.Fatalf("expected 3 rows with fd=3, got %d", len(m.filtered)) + t.Fatalf("expected selected row to stay near middle after global refilter, got relative idx %d", after) } } @@ -600,11 +449,7 @@ func TestPausedQuickExportWritesFilteredRows(t *testing.T) { t.Fatalf("space should pause") } - m.selectedIdx = 0 - m.selectedCol = streamColComm - if !m.HandleKey("enter") { - t.Fatalf("enter should apply comm filter") - } + m.SetFilter(Filter{Comm: &StringFilter{Pattern: "firefox"}}) if len(m.filtered) != 2 { t.Fatalf("expected 2 filtered rows before export, got %d", len(m.filtered)) } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index a1dd8fb..6972e98 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1091,8 +1091,8 @@ func (m Model) helpSections() []helpSection { "files: d dirs toggle v bubbles (dirs only) b metric", "flame: arrows/hjkl nav enter/click zoom click ancestor undo u/bs/esc undo o order", "flame: / filter n/N match next/prev space/p pause b metric", - "stream: space pause f filter enter apply esc undo /? n/N", - "stream: j/k/pg scroll g/G top/tail h/l cols c x/X E open", + "stream: space pause /? n/N search", + "stream: j/k/pg scroll g/G top/tail h/l cols x/X export E open", }, }, { |
