diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-25 22:58:40 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-25 22:58:40 +0200 |
| commit | 4c34b9efcd539c819648c927d7e3f53220df8ad2 (patch) | |
| tree | f9de9fd650a2d16316ba2c159990d891c9de5189 /internal/tui | |
| parent | 67e10f34c92e93343adbd690b3b21e455e863bd3 (diff) | |
Fix stream paused scrolling and apply pending TUI/probe updates
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/dashboard/model.go | 13 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 85 | ||||
| -rw-r--r-- | internal/tui/eventstream/model.go | 88 | ||||
| -rw-r--r-- | internal/tui/eventstream/model_test.go | 137 | ||||
| -rw-r--r-- | internal/tui/eventstream/render.go | 2 | ||||
| -rw-r--r-- | internal/tui/eventstream/render_test.go | 24 | ||||
| -rw-r--r-- | internal/tui/probes/model.go | 4 | ||||
| -rw-r--r-- | internal/tui/probes/model_test.go | 41 | ||||
| -rw-r--r-- | internal/tui/tui.go | 21 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 37 |
10 files changed, 435 insertions, 17 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 8b2c814..9b425b1 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -73,6 +73,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.streamModel.SetViewport(msg.Width, msg.Height) return m, nil case refreshTickMsg: snap := m.snapshot() @@ -104,7 +105,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { prevActiveTab := m.activeTab var cmd tea.Cmd keyStr := msg.String() - handled := m.handleArrowTabKey(keyStr) || m.handleScrollKey(keyStr) + handled := m.handleArrowTabKey(keyStr) || m.handleScrollKey(msg) + if handled && m.activeTab == TabStream && (keyStr == " " || keyStr == "space") && !m.streamModel.Paused() { + cmd = streamTickCmd() + } if !handled { switch { @@ -171,7 +175,8 @@ func (m *Model) handleArrowTabKey(keyStr string) bool { } } -func (m *Model) handleScrollKey(keyStr string) bool { +func (m *Model) handleScrollKey(msg tea.KeyMsg) bool { + keyStr := msg.String() switch m.activeTab { case TabSyscalls: return scrollOffset(keyStr, &m.syscallsOffset, m.maxSyscallsRows()) @@ -183,7 +188,9 @@ func (m *Model) handleScrollKey(keyStr string) bool { case TabProcesses: return scrollOffset(keyStr, &m.processesOffset, m.maxProcessesRows()) case TabStream: - return m.streamModel.HandleKey(keyStr) + streamWidth, streamHeight := common.EffectiveViewport(m.width, m.height) + m.streamModel.SetViewport(streamWidth, streamHeight) + return m.streamModel.HandleTeaKey(msg) default: return false } diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index c1b2e1d..1e54b27 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -1,11 +1,15 @@ package dashboard import ( + "fmt" + "regexp" + "strconv" "strings" "testing" "ior/internal/statsengine" common "ior/internal/tui/common" + "ior/internal/tui/eventstream" "ior/internal/tui/messages" tea "github.com/charmbracelet/bubbletea" @@ -161,6 +165,87 @@ func TestFilesTabGroupedScrollUsesDirectoryOffset(t *testing.T) { } } +func TestStreamSpaceUnpauseSchedulesStreamTick(t *testing.T) { + rb := eventstream.NewRingBuffer() + m := NewModelWithConfig(nil, rb, 250, common.DefaultKeyMap()) + m.activeTab = TabStream + m.streamModel.HandleKey("space") // pause + + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace}) + _ = next + if cmd == nil { + t.Fatalf("expected stream tick command when unpausing stream") + } +} + +func TestStreamPausedSupportsJKArrowsAndPageKeys(t *testing.T) { + rb := eventstream.NewRingBuffer() + for i := 0; i < 300; i++ { + rb.Push(eventstream.StreamEvent{ + Seq: uint64(i + 1), + Syscall: "read", + Comm: "proc", + PID: 1000, + TID: uint32(2000 + i), + FileName: fmt.Sprintf("/tmp/file-%03d", i), + }) + } + + m := NewModelWithConfig(nil, rb, 250, common.DefaultKeyMap()) + m.activeTab = TabStream + next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) + m = next.(Model) + + m.streamModel.Refresh() + _ = m.View() + + next, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace}) // pause + m = next.(Model) + before := rowFromStreamView(t, m.View()) + + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + m = next.(Model) + afterK := rowFromStreamView(t, m.View()) + if afterK >= before { + t.Fatalf("expected k to scroll up while paused: before=%d afterK=%d", before, afterK) + } + + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = next.(Model) + afterDown := rowFromStreamView(t, m.View()) + if afterDown <= afterK { + t.Fatalf("expected down arrow to scroll down while paused: afterK=%d afterDown=%d", afterK, afterDown) + } + + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyPgUp}) + m = next.(Model) + afterPgUp := rowFromStreamView(t, m.View()) + if afterPgUp >= afterDown { + t.Fatalf("expected pgup to scroll up while paused: afterDown=%d afterPgUp=%d", afterDown, afterPgUp) + } + + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyPgDown}) + m = next.(Model) + afterPgDown := rowFromStreamView(t, m.View()) + if afterPgDown <= afterPgUp { + t.Fatalf("expected pgdown to scroll down while paused: afterPgUp=%d afterPgDown=%d", afterPgUp, afterPgDown) + } +} + +func rowFromStreamView(t *testing.T, view string) int { + t.Helper() + re := regexp.MustCompile(`Row ([0-9]+)/([0-9]+)`) + m := re.FindStringSubmatch(view) + if len(m) != 3 { + t.Fatalf("stream row status not found in view") + } + row, err := strconv.Atoi(m[1]) + if err != nil { + t.Fatalf("invalid row value %q: %v", m[1], err) + } + return row +} + func TestDirGroupKeyTogglesOnlyOnFilesTab(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabFiles diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index f51b7b5..0c50d0c 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -36,6 +36,17 @@ func NewModel(source *RingBuffer) Model { } } +// SetViewport updates the render/scroll viewport dimensions used for +// max-scroll and page-step calculations during key handling. +func (m *Model) SetViewport(width, height int) { + if width > 0 { + m.width = width + } + if height > 0 { + m.height = height + } +} + // SetSource updates the backing ring buffer and refreshes visible rows. func (m *Model) SetSource(source *RingBuffer) { m.source = source @@ -47,6 +58,11 @@ func (m Model) FilterModalVisible() bool { return m.filterModal.Visible() } +// Paused reports whether stream refresh is currently paused. +func (m Model) Paused() bool { + return m.paused +} + func (m *Model) HandleKey(keyStr string) bool { if m.filterModal.Visible() { wasVisible := m.filterModal.Visible() @@ -66,10 +82,12 @@ func (m *Model) HandleKey(keyStr string) bool { case " ", "space": m.paused = !m.paused if !m.paused { + // Resuming should return to live-tail behavior immediately. + m.autoScroll = true m.Refresh() } return true - case "f": + case "f", "F": m.pauseBeforeFilter = m.paused m.paused = true m.filterModal = m.filterModal.Open(m.filter) @@ -87,24 +105,44 @@ func (m *Model) HandleKey(keyStr string) bool { m.applyFilter() return true case "j", "down": - if m.scrollOffset < m.maxScrollOffset() { - m.scrollOffset++ - } - if m.scrollOffset < m.maxScrollOffset() { - m.autoScroll = false - } + m.scrollByLines(1) return true case "k", "up": - if m.scrollOffset > 0 { - m.scrollOffset-- - } - m.autoScroll = false + m.scrollByLines(-1) + return true + case "pgdown", "pgdn", "pagedown": + m.scrollByLines(m.pageStep()) + return true + case "pgup", "pageup": + m.scrollByLines(-m.pageStep()) return true default: return false } } +// HandleTeaKey handles stream keys based on Bubble Tea key message types first, +// then falls back to string matching for rune-driven shortcuts. +func (m *Model) HandleTeaKey(msg tea.KeyMsg) bool { + switch msg.Type { + case tea.KeyUp: + return m.HandleKey("up") + case tea.KeyDown: + return m.HandleKey("down") + case tea.KeyPgUp: + return m.HandleKey("pgup") + case tea.KeyPgDown: + return m.HandleKey("pgdown") + case tea.KeySpace: + return m.HandleKey("space") + case tea.KeyRunes: + if len(msg.Runes) == 1 { + return m.HandleKey(string(msg.Runes[0])) + } + } + return m.HandleKey(msg.String()) +} + func (m *Model) View(width, height int) string { if width <= 0 { width = 100 @@ -198,6 +236,34 @@ func (m *Model) visibleRows() int { return rows } +func (m *Model) pageStep() int { + rows := m.visibleRows() + if rows <= 1 { + return 1 + } + return rows - 1 +} + +func (m *Model) scrollByLines(delta int) { + if delta == 0 { + return + } + max := m.maxScrollOffset() + next := m.scrollOffset + delta + if next < 0 { + next = 0 + } + if next > max { + next = max + } + if next != m.scrollOffset { + m.scrollOffset = next + } + if m.scrollOffset < max { + m.autoScroll = false + } +} + func keyMsgFromString(keyStr string) tea.KeyMsg { switch keyStr { case "esc": diff --git a/internal/tui/eventstream/model_test.go b/internal/tui/eventstream/model_test.go index 937bb33..bfcbca7 100644 --- a/internal/tui/eventstream/model_test.go +++ b/internal/tui/eventstream/model_test.go @@ -62,6 +62,76 @@ func TestModelScrollClamp(t *testing.T) { } } +func TestModelPageScrollWithPgUpPgDown(t *testing.T) { + rb := NewRingBuffer() + m := NewModel(rb) + m.height = 12 // visibleRows=4, pageStep=3 + pushEvents(rb, 30) + m.Refresh() + m.HandleKey("g") + + if !m.HandleKey("pgdown") { + t.Fatalf("pgdown should be handled") + } + if m.scrollOffset != 3 { + t.Fatalf("expected page down to move by 3, got %d", m.scrollOffset) + } + + if !m.HandleKey("pagedown") { + t.Fatalf("pagedown should be handled") + } + if m.scrollOffset != 6 { + t.Fatalf("expected pagedown alias to move by 3, got %d", m.scrollOffset) + } + + if !m.HandleKey("pgup") { + t.Fatalf("pgup should be handled") + } + if m.scrollOffset != 3 { + t.Fatalf("expected page up to move up by 3, got %d", m.scrollOffset) + } + if !m.HandleKey("pageup") { + t.Fatalf("pageup should be handled") + } + if m.scrollOffset != 0 { + t.Fatalf("expected pageup alias to return to top, got %d", m.scrollOffset) + } +} + +func TestModelArrowAndJKScroll(t *testing.T) { + rb := NewRingBuffer() + m := NewModel(rb) + m.height = 12 + pushEvents(rb, 30) + m.Refresh() + m.HandleKey("g") + + if !m.HandleKey("down") { + t.Fatalf("down should be handled") + } + if m.scrollOffset != 1 { + t.Fatalf("expected down to increment offset, got %d", m.scrollOffset) + } + if !m.HandleKey("j") { + t.Fatalf("j should be handled") + } + if m.scrollOffset != 2 { + t.Fatalf("expected j to increment offset, got %d", m.scrollOffset) + } + if !m.HandleKey("up") { + t.Fatalf("up should be handled") + } + if m.scrollOffset != 1 { + t.Fatalf("expected up to decrement offset, got %d", m.scrollOffset) + } + if !m.HandleKey("k") { + t.Fatalf("k should be handled") + } + if m.scrollOffset != 0 { + t.Fatalf("expected k to decrement offset, got %d", m.scrollOffset) + } +} + func TestModelFilterReducesVisibleRows(t *testing.T) { rb := NewRingBuffer() m := NewModel(rb) @@ -170,3 +240,70 @@ func TestFilterModalTemporarilyPausesAndRestoresState(t *testing.T) { t.Fatalf("expected paused state preserved after modal close") } } + +func TestUnpauseRestoresLiveTailAndRefresh(t *testing.T) { + rb := NewRingBuffer() + m := NewModel(rb) + m.height = 10 + pushEvents(rb, 20) + m.Refresh() + + // Move off tail, then pause. + m.HandleKey("g") + if m.autoScroll { + t.Fatalf("expected autoScroll disabled at top") + } + m.HandleKey("space") + if !m.paused { + t.Fatalf("expected paused") + } + + // New events arrive while paused. + pushEvents(rb, 5) + m.Refresh() + + // Resume: should auto-tail and refresh immediately. + m.HandleKey("space") + if m.paused { + t.Fatalf("expected unpaused") + } + if !m.autoScroll { + t.Fatalf("expected autoScroll restored on resume") + } + if m.scrollOffset != m.maxScrollOffset() { + t.Fatalf("expected tail offset after resume, got offset=%d max=%d", m.scrollOffset, m.maxScrollOffset()) + } +} + +func TestPausedScrollWithJKAndPageKeys(t *testing.T) { + rb := NewRingBuffer() + m := NewModel(rb) + m.height = 20 + pushEvents(rb, 100) + m.Refresh() + if !m.HandleKey("space") { + t.Fatalf("space should toggle pause") + } + before := rowNumber(m.scrollOffset, len(m.filtered)) + if !m.HandleKey("k") { + t.Fatalf("k should be handled while paused") + } + afterK := rowNumber(m.scrollOffset, len(m.filtered)) + if afterK >= before { + t.Fatalf("expected k to scroll up while paused: before=%d after=%d", before, afterK) + } + if !m.HandleKey("pgup") { + t.Fatalf("pgup should be handled while paused") + } + afterPgUp := rowNumber(m.scrollOffset, len(m.filtered)) + if afterPgUp >= afterK { + t.Fatalf("expected pgup to scroll up while paused: afterK=%d afterPgUp=%d", afterK, afterPgUp) + } + if !m.HandleKey("pgdown") { + t.Fatalf("pgdown should be handled while paused") + } + afterPgDown := rowNumber(m.scrollOffset, len(m.filtered)) + if afterPgDown <= afterPgUp { + t.Fatalf("expected pgdown to scroll down while paused: afterPgUp=%d afterPgDown=%d", afterPgUp, afterPgDown) + } +} diff --git a/internal/tui/eventstream/render.go b/internal/tui/eventstream/render.go index e1781f8..24864b9 100644 --- a/internal/tui/eventstream/render.go +++ b/internal/tui/eventstream/render.go @@ -32,7 +32,7 @@ func RenderStreamTable(width int, paused bool, totalCount, filteredCount, buffer lines = append(lines, renderEventRow(ev, contentWidth)) } - return common.PanelStyle.Width(width).Render(strings.Join(lines, "\n")) + return common.PanelStyle.Width(contentWidth).Render(strings.Join(lines, "\n")) } func renderStatusLine(paused bool, totalCount, filteredCount, bufferLen, bufferCap int) string { diff --git a/internal/tui/eventstream/render_test.go b/internal/tui/eventstream/render_test.go index c7f32cd..65d7a61 100644 --- a/internal/tui/eventstream/render_test.go +++ b/internal/tui/eventstream/render_test.go @@ -3,6 +3,8 @@ package eventstream import ( "strings" "testing" + + "github.com/charmbracelet/lipgloss" ) func TestRenderStatusAndFilterLines(t *testing.T) { @@ -100,3 +102,25 @@ func TestComputeColumnLayoutGivesFileMoreSpace(t *testing.T) { t.Fatalf("expected file column to get most width, got %d", cols.file) } } + +func TestRenderStreamTableFitsRequestedWidth(t *testing.T) { + out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, []StreamEvent{ + { + Syscall: "read", + Comm: "worker", + PID: 1, + TID: 2, + DurationNs: 2000, + GapNs: 100, + Bytes: 64, + FileName: "/very/long/path/that/should/be/truncated/for/narrow/views/file.log", + RetVal: 1, + }, + }) + + for _, line := range strings.Split(out, "\n") { + if lipgloss.Width(line) > 80 { + t.Fatalf("line exceeds width 80: %d %q", lipgloss.Width(line), line) + } + } +} diff --git a/internal/tui/probes/model.go b/internal/tui/probes/model.go index dffa30f..5cec2c7 100644 --- a/internal/tui/probes/model.go +++ b/internal/tui/probes/model.go @@ -117,9 +117,9 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } return m, toggleCmd(m.manager, selected) case "a": - return m, bulkToggleCmd(m.manager, m.filtered(), false) + return m, bulkToggleCmd(m.manager, m.probes, false) case "n": - return m, bulkToggleCmd(m.manager, m.filtered(), true) + return m, bulkToggleCmd(m.manager, m.probes, true) } } return m, nil diff --git a/internal/tui/probes/model_test.go b/internal/tui/probes/model_test.go index 74f6a6b..73a83bc 100644 --- a/internal/tui/probes/model_test.go +++ b/internal/tui/probes/model_test.go @@ -78,3 +78,44 @@ func TestToggleEmitsProbeToggledMsg(t *testing.T) { } _ = next } + +func TestBulkKeysApplyGloballyNotOnlyFiltered(t *testing.T) { + fm := &fakeManager{ + states: []probemanager.ProbeState{ + {Syscall: "read", Active: true}, + {Syscall: "write", Active: true}, + {Syscall: "openat", Active: true}, + }, + } + m := NewModel(fm).Open() + m.search = "read" + + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + if cmd == nil { + t.Fatalf("expected bulk off command") + } + msg := cmd() + if toggled, ok := msg.(ProbeToggledMsg); !ok || toggled.Err != nil { + t.Fatalf("unexpected bulk off msg: %#v", msg) + } + if len(fm.toggles) != 3 { + t.Fatalf("expected all probes toggled off despite filter, got toggles=%+v", fm.toggles) + } + + // Re-open with all inactive and filtered search still present; "a" should + // toggle all probes back on. + m = NewModel(fm).Open() + m.search = "read" + fm.toggles = nil + _, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + if cmd == nil { + t.Fatalf("expected bulk on command") + } + msg = cmd() + if toggled, ok := msg.(ProbeToggledMsg); !ok || toggled.Err != nil { + t.Fatalf("unexpected bulk on msg: %#v", msg) + } + if len(fm.toggles) != 3 { + t.Fatalf("expected all probes toggled on despite filter, got toggles=%+v", fm.toggles) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 5dba75c..5a8c14b 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -12,6 +12,7 @@ import ( dashboardui "ior/internal/tui/dashboard" "ior/internal/tui/eventstream" tuiexport "ior/internal/tui/export" + "ior/internal/tui/messages" "ior/internal/tui/pidpicker" "ior/internal/tui/probes" "os" @@ -104,6 +105,21 @@ func getProbeManager() ProbeManager { return probeManagerState.manager } +func resetDashboardSnapshotSource() *statsengine.Snapshot { + src := getDashboardSnapshotSource() + if src == nil { + return nil + } + if resettable, ok := src.(interface { + Reset() + Snapshot() *statsengine.Snapshot + }); ok { + resettable.Reset() + return resettable.Snapshot() + } + return nil +} + // Run starts the TUI program in alternate screen mode. func Run() error { return RunWithTraceStarter(defaultTraceStarter) @@ -233,6 +249,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case probes.ProbeToggledMsg: var cmd tea.Cmd m.probeModal, cmd = m.probeModal.Update(msg) + if snap := resetDashboardSnapshotSource(); snap != nil { + next, dashboardCmd := m.dashboard.Update(messages.StatsTickMsg{Snap: snap}) + m.dashboard = next.(dashboardui.Model) + return m, tea.Batch(dashboardCmd, cmd) + } return m, cmd case PidSelectedMsg: return m.handlePidSelected(msg) diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 0f55b40..31a2e94 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -15,6 +15,7 @@ import ( "time" "ior/internal/flags" + "ior/internal/tui/probes" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" @@ -186,6 +187,20 @@ func (f fakeDashboardSource) Snapshot() *statsengine.Snapshot { return f.snap } +type fakeResettableDashboardSource struct { + snap *statsengine.Snapshot + resetCalls int +} + +func (f *fakeResettableDashboardSource) Snapshot() *statsengine.Snapshot { + return f.snap +} + +func (f *fakeResettableDashboardSource) Reset() { + f.resetCalls++ + f.snap = &statsengine.Snapshot{TotalSyscalls: 0} +} + func TestDashboardRefreshPicksLateBoundSource(t *testing.T) { orig := getDashboardSnapshotSource() defer SetDashboardSnapshotSource(orig) @@ -202,6 +217,28 @@ func TestDashboardRefreshPicksLateBoundSource(t *testing.T) { } } +func TestProbeToggledMsgResetsDashboardStatsSource(t *testing.T) { + src := &fakeResettableDashboardSource{snap: &statsengine.Snapshot{TotalSyscalls: 99}} + SetDashboardSnapshotSource(src) + t.Cleanup(func() { SetDashboardSnapshotSource(nil) }) + + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.probeModal = probes.NewModel(fakeProbeManager{states: []probemanager.ProbeState{{Syscall: "read", Active: true}}}).Open() + + next, _ := m.Update(probes.ProbeToggledMsg{Syscall: "read"}) + updated := next.(Model) + + if src.resetCalls != 1 { + t.Fatalf("expected one reset call, got %d", src.resetCalls) + } + snap := updated.dashboard.LatestSnapshot() + if snap == nil || snap.TotalSyscalls != 0 { + t.Fatalf("expected dashboard snapshot refreshed from reset source, got %+v", snap) + } +} + func TestTracingStartedRebindsEventStreamSource(t *testing.T) { orig := getEventStreamSource() defer SetEventStreamSource(orig) |
