summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/tui/dashboard/model.go11
-rw-r--r--internal/tui/eventstream/model.go24
-rw-r--r--internal/tui/eventstream/model_test.go48
-rw-r--r--internal/tui/tui.go26
-rw-r--r--internal/tui/tui_test.go60
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()