diff options
| -rw-r--r-- | internal/tui/dashboard/model.go | 11 | ||||
| -rw-r--r-- | internal/tui/eventstream/model.go | 24 | ||||
| -rw-r--r-- | internal/tui/eventstream/model_test.go | 48 | ||||
| -rw-r--r-- | internal/tui/tui.go | 26 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 60 |
5 files changed, 159 insertions, 10 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 5500369..407802f 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -228,6 +228,17 @@ func (m Model) LatestSnapshot() *statsengine.Snapshot { return m.latest } +// BlocksGlobalShortcuts reports whether modal UI in the active tab should +// suppress top-level shortcuts (for example global export key handling). +func (m Model) BlocksGlobalShortcuts() bool { + return m.activeTab == TabStream && m.streamModel.FilterModalVisible() +} + +// SetStreamSource updates the live stream source used by the stream tab. +func (m *Model) SetStreamSource(source *eventstream.RingBuffer) { + m.streamModel.SetSource(source) +} + // View renders the tab bar, active tab scaffold, and help bar. func (m Model) View() string { var b strings.Builder diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index fb4b88f..f51b7b5 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -17,6 +17,9 @@ type Model struct { filterModal FilterModal 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 @@ -33,13 +36,28 @@ func NewModel(source *RingBuffer) Model { } } +// SetSource updates the backing ring buffer and refreshes visible rows. +func (m *Model) SetSource(source *RingBuffer) { + m.source = source + m.Refresh() +} + +// FilterModalVisible reports whether the filter modal is currently open. +func (m Model) FilterModalVisible() bool { + return m.filterModal.Visible() +} + func (m *Model) HandleKey(keyStr string) bool { 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.paused = m.pauseBeforeFilter m.applyFilter() + if !m.paused { + m.Refresh() + } } return true } @@ -52,6 +70,8 @@ func (m *Model) HandleKey(keyStr string) bool { } return true case "f": + m.pauseBeforeFilter = m.paused + m.paused = true m.filterModal = m.filterModal.Open(m.filter) return true case "G": @@ -113,7 +133,9 @@ func (m *Model) View(width, height int) string { out := base + "\n" + status if m.filterModal.Visible() { - return m.filterModal.View(width, height) + "\n" + out + // While editing filters, show a dedicated modal screen to avoid + // visual mixing with the live stream table underneath. + return m.filterModal.View(width, height) } return out } diff --git a/internal/tui/eventstream/model_test.go b/internal/tui/eventstream/model_test.go index 69369d8..937bb33 100644 --- a/internal/tui/eventstream/model_test.go +++ b/internal/tui/eventstream/model_test.go @@ -122,3 +122,51 @@ func TestModelHandleKeyRouting(t *testing.T) { t.Fatalf("modal should close on esc") } } + +func TestFilterModalTemporarilyPausesAndRestoresState(t *testing.T) { + rb := NewRingBuffer() + m := NewModel(rb) + m.height = 20 + pushEvents(rb, 4) + 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") + } + + // 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") + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 84d8cab..7e77a81 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -174,7 +174,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.exporter.Visible() && m.showHelp { return m, nil } - if flags.Get().TUIExportEnable && m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.Export) && !m.exporter.Visible() { + if flags.Get().TUIExportEnable && m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.Export) && !m.exporter.Visible() && !m.dashboard.BlocksGlobalShortcuts() { m.exporter = m.exporter.Open() return m, nil } @@ -192,6 +192,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handlePidSelected(msg) case TracingStartedMsg: m.attaching = false + m.dashboard.SetStreamSource(getEventStreamSource()) return m, m.dashboard.Init() case TracingErrorMsg: m.attaching = false @@ -289,32 +290,32 @@ func (m Model) View() string { if m.attaching { line := fmt.Sprintf("%s Attaching tracepoints...", m.spin.View()) - return ScreenStyle.Render(PanelStyle.Render(line)) + return placeToViewport(m.width, m.height, ScreenStyle.Render(PanelStyle.Render(line))) } if m.lastErr != nil { - return ScreenStyle.Render(ErrorStyle.Render(m.lastErr.Error())) + return placeToViewport(m.width, m.height, ScreenStyle.Render(ErrorStyle.Render(m.lastErr.Error()))) } switch m.screen { case ScreenPIDPicker: base := m.pidPicker.View() if m.exporter.Visible() { - return m.exporter.View(m.width, m.height) + "\n" + base + return placeToViewport(m.width, m.height, m.exporter.View(m.width, m.height)+"\n"+base) } if m.showHelp { - return renderHelpOverlay(m.width, m.height, [][]key.Binding{m.keys.PickerShortHelp()}) + "\n" + base + return placeToViewport(m.width, m.height, renderHelpOverlay(m.width, m.height, [][]key.Binding{m.keys.PickerShortHelp()})+"\n"+base) } - return base + return placeToViewport(m.width, m.height, base) case ScreenDashboard: base := m.dashboard.View() if m.exporter.Visible() { - return m.exporter.View(m.width, m.height) + "\n" + base + return placeToViewport(m.width, m.height, m.exporter.View(m.width, m.height)+"\n"+base) } if m.showHelp { - return renderHelpOverlay(m.width, m.height, m.keys.DashboardFullHelp()) + "\n" + base + return placeToViewport(m.width, m.height, renderHelpOverlay(m.width, m.height, m.keys.DashboardFullHelp())+"\n"+base) } - return base + return placeToViewport(m.width, m.height, base) default: return "" } @@ -471,3 +472,10 @@ func renderHelpOverlay(width, height int, groups [][]key.Binding) string { return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box) } + +func placeToViewport(width, height int, content string) string { + if width <= 0 || height <= 0 { + return content + } + return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, content) +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index e69ff9b..e7f9a3f 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -4,7 +4,9 @@ import ( "context" "errors" "ior/internal/statsengine" + "ior/internal/tui/eventstream" tuiexport "ior/internal/tui/export" + "ior/internal/tui/messages" "os" "path/filepath" "strings" @@ -191,6 +193,33 @@ func TestDashboardRefreshPicksLateBoundSource(t *testing.T) { } } +func TestTracingStartedRebindsEventStreamSource(t *testing.T) { + orig := getEventStreamSource() + defer SetEventStreamSource(orig) + + rb := eventstream.NewRingBuffer() + rb.Push(eventstream.StreamEvent{Seq: 1, Syscall: "read", Comm: "proc", PID: 1, TID: 1}) + SetEventStreamSource(rb) + + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = true + + next, _ := m.Update(TracingStartedMsg{}) + m = next.(Model) + + next, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) + m = next.(Model) + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'7'}}) + m = next.(Model) + next, _ = m.Update(messages.StatsTickMsg{}) + m = next.(Model) + + if !strings.Contains(m.View(), "read") { + t.Fatalf("expected stream tab to render rebound stream event") + } +} + func TestExportKeyOpensModalOnDashboard(t *testing.T) { flags.SetTUIExportEnable(true) t.Cleanup(func() { flags.SetTUIExportEnable(true) }) @@ -221,6 +250,37 @@ func TestExportKeyIgnoredWhenExportDisabled(t *testing.T) { } } +func TestStreamFilterModalConsumesEKeyInsteadOfOpeningExport(t *testing.T) { + flags.SetTUIExportEnable(true) + t.Cleanup(func() { flags.SetTUIExportEnable(true) }) + + 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.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'7'}}) + m = next.(Model) + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}}) + m = next.(Model) + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = next.(Model) + for _, r := range []rune{'o', 'p', 'e'} { + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + m = next.(Model) + } + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = next.(Model) + + if m.exporter.Visible() { + t.Fatalf("expected export modal to remain closed while stream filter modal handles typing") + } + if !strings.Contains(m.View(), "syscall~ope") { + t.Fatalf("expected typed syscall filter to be applied") + } +} + func TestRunExportCmdCSVWritesFile(t *testing.T) { dir := t.TempDir() prev, err := os.Getwd() |
