diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 19:35:08 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 19:35:08 +0200 |
| commit | 013e46d7856a604d4890a880b8bbfb4b8c58202b (patch) | |
| tree | f8b100ccd04a30b212f0fe728c91736087c60fc1 | |
| parent | 0b2d40cf7ff9b26bfd020488b537bdfdd6f852ae (diff) | |
feat(tui): add flamegraph click lineage undo and scope quit key
| -rw-r--r-- | internal/tui/dashboard/model.go | 25 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 23 | ||||
| -rw-r--r-- | internal/tui/flamegraph/controls.go | 5 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 185 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 122 | ||||
| -rw-r--r-- | internal/tui/tui.go | 43 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 92 |
7 files changed, 482 insertions, 13 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index aae98b1..fb509b0 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -214,7 +214,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if m.activeTab == TabFlame { - next, cmd := m.flamegraphModel.Update(msg) + next, cmd := m.flamegraphModel.Update(translateFlamegraphMsg(msg)) m.flamegraphModel = next.(flamegraphtui.Model) return m, cmd } @@ -835,3 +835,26 @@ func flameViewport(width, height int, showHelp bool) (int, int) { } return width, height } + +func translateFlamegraphMsg(msg tea.Msg) tea.Msg { + switch mouse := msg.(type) { + case tea.MouseClickMsg: + m := mouse.Mouse() + m.Y -= dashboardTabBarRows + return tea.MouseClickMsg(m) + case tea.MouseReleaseMsg: + m := mouse.Mouse() + m.Y -= dashboardTabBarRows + return tea.MouseReleaseMsg(m) + case tea.MouseMotionMsg: + m := mouse.Mouse() + m.Y -= dashboardTabBarRows + return tea.MouseMotionMsg(m) + case tea.MouseWheelMsg: + m := mouse.Mouse() + m.Y -= dashboardTabBarRows + return tea.MouseWheelMsg(m) + default: + return msg + } +} diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index fafcad3..2e1ca17 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -810,3 +810,26 @@ func TestHelpToggleWithH(t *testing.T) { t.Fatalf("expected help hint after pressing h again") } } + +func TestTranslateFlamegraphMouseMsgOffsetsTabBarRow(t *testing.T) { + translated := translateFlamegraphMsg(tea.MouseClickMsg{ + X: 17, + Y: 9, + Button: tea.MouseLeft, + }) + click, ok := translated.(tea.MouseClickMsg) + if !ok { + t.Fatalf("expected translated message to stay mouse click, got %T", translated) + } + if click.X != 17 || click.Y != 8 { + t.Fatalf("expected click coordinates (17,8), got (%d,%d)", click.X, click.Y) + } +} + +func TestTranslateFlamegraphMsgLeavesNonMouseUnchanged(t *testing.T) { + msg := messages.StatsTickMsg{} + translated := translateFlamegraphMsg(msg) + if _, ok := translated.(messages.StatsTickMsg); !ok { + t.Fatalf("expected non-mouse message to remain unchanged, got %T", translated) + } +} diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go index 06e6d0d..e69d845 100644 --- a/internal/tui/flamegraph/controls.go +++ b/internal/tui/flamegraph/controls.go @@ -17,6 +17,7 @@ func (m *Model) clearSnapshotState(clearSearch bool) { m.zoomRoot = nil m.zoomPath = "" m.zoomStack = nil + m.zoomLineWidth = 0 m.selectedIdx = 0 m.snapshot = nil m.globalTotal = 0 @@ -81,7 +82,7 @@ func (m Model) toolbarLine() string { state = lipgloss.NewStyle().Foreground(common.ColorDanger).Bold(true).Render("[PAUSED]") } order := m.currentFieldPresetLabel() - line := fmt.Sprintf("%s | view:%s | o:order(%s) | b:metric(%s) | /:search | enter:zoom | u/esc:undo | r:reset | space/p:pause", state, compactFramePath(m.currentRootPath()), order, m.countFieldLabel()) + line := fmt.Sprintf("%s | view:%s | o:order(%s) | b:metric(%s) | /:search | enter/click:zoom | click ancestor:undo | u/esc:undo | r:reset | space/p:pause", state, compactFramePath(m.currentRootPath()), order, m.countFieldLabel()) if m.searchQuery != "" { line += " | filter:" + m.searchQuery } @@ -103,7 +104,7 @@ func (m Model) helpOverlay() string { if width <= 0 { width = 80 } - help := "Flame help: j/k depth h/l sibling pgup top pgdn root enter zoom u/backspace/esc undo / search n/N matches space/p pause r reset baseline o order b metric ? help" + help := "Flame help: j/k depth h/l sibling pgup top pgdn root enter/click zoom click ancestor undo u/backspace/esc undo / search n/N matches space/p pause r reset baseline o order b metric ? help" return common.HelpBarStyle.Width(width).Render(padOrTrim(help, width)) } diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index cc208ae..1d01f66 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -41,6 +41,7 @@ type LiveTrieSource interface { type zoomState struct { path string previousSelectedIdx int + lineWidth int } type flameKeyMap struct { @@ -81,10 +82,11 @@ type Model struct { width int height int - selectedIdx int - zoomStack []zoomState - zoomRoot *snapshotNode - zoomPath string + selectedIdx int + zoomStack []zoomState + zoomRoot *snapshotNode + zoomPath string + zoomLineWidth int searchActive bool searchInput textinput.Model @@ -181,6 +183,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, animTickCmd() } return m, nil + case tea.MouseClickMsg: + _ = m.handleMouseClick(msg) + return m, nil case tea.KeyPressMsg: if m.searchActive { handled := false @@ -339,6 +344,7 @@ func (m *Model) SetLiveTrie(liveTrie LiveTrieSource) { m.zoomStack = nil m.zoomRoot = nil m.zoomPath = "" + m.zoomLineWidth = 0 m.subtreeSet = make(map[int]bool) m.filterVisible = make(map[int]bool) m.animation = NewAnimationState(30, 6.0, 1.0) @@ -461,7 +467,11 @@ func (m *Model) rebuildFrames(animate bool) { } else { root = m.snapshot } - m.targetFrames = buildTerminalLayoutWithPath(root, m.width, m.height, rootPath) + targetFrames := buildTerminalLayoutWithPath(root, m.width, m.height, rootPath) + if m.zoomPath != "" { + targetFrames = m.withZoomLineage(targetFrames) + } + m.targetFrames = targetFrames m.animation.SetTargets(m.targetFrames) if animate && len(m.frames) > 0 && !m.animation.Settled() { m.animating = true @@ -522,13 +532,18 @@ func (m *Model) zoomIn() { m.statusMessage = "Zoom failed: selected node is unavailable" return } + selectedWidth := m.frames[m.selectedIdx].Width + if selectedWidth < 1 { + selectedWidth = 1 + } m.zoomStack = append(m.zoomStack, zoomState{ path: m.zoomPath, previousSelectedIdx: m.selectedIdx, + lineWidth: m.zoomLineWidth, }) m.zoomRoot = target m.zoomPath = selectedPath - m.selectedIdx = 0 + m.zoomLineWidth = selectedWidth m.rebuildFrames(true) m.statusMessage = "Zoom: " + compactFramePath(selectedPath) } @@ -543,8 +558,10 @@ func (m *Model) zoomUndo() { m.zoomPath = last.path if m.zoomPath == "" { m.zoomRoot = nil + m.zoomLineWidth = 0 } else { m.zoomRoot = findNodeByPath(m.snapshot, m.zoomPath) + m.zoomLineWidth = last.lineWidth } m.selectedIdx = last.previousSelectedIdx m.rebuildFrames(true) @@ -563,6 +580,7 @@ func (m *Model) zoomReset() { m.zoomRoot = nil m.zoomPath = "" m.zoomStack = nil + m.zoomLineWidth = 0 m.rebuildFrames(false) m.statusMessage = "Zoom reset to root" } @@ -1025,3 +1043,158 @@ func (m *Model) ensureSelectionVisible() { m.selectedIdx = bestIdx } } + +func (m *Model) handleMouseClick(msg tea.MouseClickMsg) bool { + if msg.Button != tea.MouseLeft { + return false + } + idx := m.frameIndexAt(msg.X, msg.Y) + if idx < 0 { + return false + } + clickedPath := m.frames[idx].Path + currentRoot := m.currentRootPath() + if m.zoomPath != "" && (clickedPath == currentRoot || hasPathBoundaryPrefix(currentRoot, clickedPath)) { + for steps := 0; steps < len(m.zoomStack)+1 && m.currentRootPath() != clickedPath; steps++ { + m.zoomUndo() + } + if sel := m.frameIndexByPath(clickedPath); sel >= 0 { + m.selectedIdx = sel + } + m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) + return true + } + m.selectedIdx = idx + m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) + m.zoomIn() + return true +} + +func (m Model) frameIndexAt(x, y int) int { + if len(m.frames) == 0 || m.width <= 0 || m.height <= 0 { + return -1 + } + if x < 0 || x >= m.width || y < 0 { + return -1 + } + + extraLines := 1 // selection status line + if m.showHelp { + extraLines++ + } + renderHeight := m.height - extraLines + if renderHeight < 3 { + renderHeight = 3 + } + availableRows := renderHeight - 2 // flame toolbar + frame-status line + if availableRows < 1 { + return -1 + } + + // Row 0 is flame toolbar, rows 1..availableRows are bars, last row is status. + if y < 1 || y > availableRows { + return -1 + } + dataRow := y - 1 + + maxRow := maxFrameRowForSet(m.frames, nil) + barHeight := computeBarHeight(availableRows, maxRow+1, maxBarVisualHeight) + visibleDepthRows := availableRows / barHeight + if visibleDepthRows < 1 { + visibleDepthRows = 1 + } + rowOffset := 0 + if maxRow+1 > visibleDepthRows { + rowOffset = maxRow + 1 - visibleDepthRows + } + renderedRows := (maxRow - rowOffset + 1) * barHeight + padTop := 0 + if renderedRows < availableRows { + padTop = availableRows - renderedRows + } + if dataRow < padTop { + return -1 + } + + depthFromTop := (dataRow - padTop) / barHeight + targetRow := maxRow - depthFromTop + + best := -1 + bestWidth := int(^uint(0) >> 1) + for idx, frame := range m.frames { + if frame.Row != targetRow || frame.Col >= m.width { + continue + } + right := min(m.width, frame.Col+frame.Width) + if x < frame.Col || x >= right { + continue + } + if frame.Width < bestWidth { + best = idx + bestWidth = frame.Width + } + } + return best +} + +func (m Model) withZoomLineage(frames []tuiFrame) []tuiFrame { + if len(frames) == 0 || m.snapshot == nil { + return frames + } + parts := strings.Split(m.zoomPath, pathSeparator) + if len(parts) <= 1 { + return frames + } + + lineWidth := m.zoomLineWidth + if lineWidth <= 0 { + lineWidth = frames[0].Width + } + lineWidth = min(max(lineWidth, 3), max(3, m.width/3)) + if lineWidth >= m.width-2 { + return frames + } + gutter := lineWidth + 1 + if m.width-gutter < minFlameWidth/2 { + return frames + } + + rowShift := len(parts) - 1 + out := make([]tuiFrame, 0, len(frames)+len(parts)) + for _, frame := range frames { + if frame.Path == m.zoomPath { + continue + } + frame.Col += gutter + frame.Row += rowShift + frame.Depth += rowShift + out = append(out, frame) + } + + rootTotal := snapshotTotal(m.snapshot) + for depth := range parts { + path := strings.Join(parts[:depth+1], pathSeparator) + node := findNodeByPath(m.snapshot, path) + total := uint64(0) + if node != nil { + total = snapshotTotal(node) + } + percent := 0.0 + if rootTotal > 0 { + percent = 100 * float64(total) / float64(rootTotal) + } + name := parts[depth] + out = append(out, tuiFrame{ + Name: name, + Col: 0, + Row: depth, + Width: lineWidth, + Total: total, + Percent: percent, + Fill: terminalFrameColor(name), + Depth: depth, + Path: path, + }) + } + return out +} diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go index 74ce8d9..bbd2005 100644 --- a/internal/tui/flamegraph/model_test.go +++ b/internal/tui/flamegraph/model_test.go @@ -239,6 +239,93 @@ func TestPausedStateStillAllowsNavigation(t *testing.T) { } } +func TestMouseClickSelectsFrameAndZooms(t *testing.T) { + m := newZoomModel() + targetPath := "root" + pathSeparator + "A" + targetIdx := mustFrameIndex(t, m.frames, targetPath) + + x, y, ok := firstClickablePointForFrame(m, targetIdx) + if !ok { + t.Fatalf("expected clickable point for %q", targetPath) + } + + next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + m = next.(Model) + + if got := m.zoomPath; got != targetPath { + t.Fatalf("expected mouse click to zoom into %q, got %q", targetPath, got) + } + if got := m.frames[m.selectedIdx].Path; got != targetPath { + t.Fatalf("expected clicked frame to remain selected after zoom, got %q", got) + } +} + +func TestMouseClickOutsideBarsDoesNotChangeSelectionOrZoom(t *testing.T) { + m := newZoomModel() + beforeSelection := m.selectedIdx + beforeZoom := m.zoomPath + + next, _ := m.Update(tea.MouseClickMsg{X: 1, Y: 0, Button: tea.MouseLeft}) // toolbar row + m = next.(Model) + + if m.selectedIdx != beforeSelection { + t.Fatalf("expected toolbar click to preserve selection, got idx %d want %d", m.selectedIdx, beforeSelection) + } + if m.zoomPath != beforeZoom { + t.Fatalf("expected toolbar click to preserve zoom path, got %q want %q", m.zoomPath, beforeZoom) + } +} + +func TestZoomKeepsNarrowLineageRail(t *testing.T) { + m := newZoomModel() + targetPath := "root" + pathSeparator + "A" + targetIdx := mustFrameIndex(t, m.frames, targetPath) + selectedWidth := m.frames[targetIdx].Width + expectedRailWidth := min(max(selectedWidth, 3), max(3, m.width/3)) + + m.selectedIdx = targetIdx + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + m = settleFlameAnimation(t, m) + + rootIdx := mustFrameIndex(t, m.frames, "root") + zoomIdx := mustFrameIndex(t, m.frames, targetPath) + if m.frames[rootIdx].Width != expectedRailWidth { + t.Fatalf("expected root lineage width %d, got %d", expectedRailWidth, m.frames[rootIdx].Width) + } + if m.frames[zoomIdx].Width != expectedRailWidth { + t.Fatalf("expected zoom lineage width %d, got %d", expectedRailWidth, m.frames[zoomIdx].Width) + } + if m.frames[rootIdx].Col != 0 || m.frames[zoomIdx].Col != 0 { + t.Fatalf("expected lineage rail at column 0, got root=%d zoom=%d", m.frames[rootIdx].Col, m.frames[zoomIdx].Col) + } +} + +func TestMouseClickOnLineageAncestorUndoesToThatZoomLevel(t *testing.T) { + m := newZoomModel() + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A") + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + m = settleFlameAnimation(t, m) + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A"+pathSeparator+"A1") + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + m = settleFlameAnimation(t, m) + if got, want := m.zoomPath, "root"+pathSeparator+"A"+pathSeparator+"A1"; got != want { + t.Fatalf("expected nested zoom path %q, got %q", want, got) + } + + ancestorPath := "root" + pathSeparator + "A" + ancestorIdx := mustFrameIndex(t, m.frames, ancestorPath) + x, y, ok := firstClickablePointForFrame(m, ancestorIdx) + if !ok { + t.Fatalf("expected clickable lineage point for ancestor %q", ancestorPath) + } + + next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + m = next.(Model) + if got := m.zoomPath; got != ancestorPath { + t.Fatalf("expected click on lineage ancestor to undo zoom to %q, got %q", ancestorPath, got) + } +} + func TestStaticFixtureArrowTraversalVisitsAllFrames(t *testing.T) { trie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") coreflamegraph.SeedTestFlameData(trie) @@ -985,3 +1072,38 @@ func pressFlameKey(t *testing.T, m Model, keyMsg tea.KeyPressMsg) Model { next, _ := m.Update(keyMsg) return next.(Model) } + +func firstClickablePointForFrame(m Model, frameIdx int) (x, y int, ok bool) { + if frameIdx < 0 || frameIdx >= len(m.frames) { + return 0, 0, false + } + frame := m.frames[frameIdx] + left := frame.Col + right := min(m.width, frame.Col+frame.Width) + if left < 0 { + left = 0 + } + if right <= left { + return 0, 0, false + } + for row := 0; row < m.height; row++ { + for col := left; col < right; col++ { + if m.frameIndexAt(col, row) == frameIdx { + return col, row, true + } + } + } + return 0, 0, false +} + +func settleFlameAnimation(t *testing.T, m Model) Model { + t.Helper() + for i := 0; i < 240 && m.animating; i++ { + next, _ := m.Update(animTickMsg{}) + m = next.(Model) + } + if m.animating { + t.Fatalf("expected flame animation to settle within 240 ticks") + } + return m +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 6b58ab3..deb6150 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -420,6 +420,23 @@ func (m Model) canHandleDashboardShortcut(msg tea.KeyPressMsg) bool { !m.dashboard.BlocksGlobalShortcuts(msg) } +func (m Model) canQuitFromMainDashboard(msg tea.KeyPressMsg) bool { + return m.screen == ScreenDashboard && + !m.attaching && + m.lastErr == nil && + !m.exporter.Visible() && + !m.probeModal.Visible() && + !m.dashboard.BlocksGlobalShortcuts(msg) +} + +func (m Model) shouldRouteQuitToEsc(msg tea.KeyPressMsg) bool { + if m.helpOverlayVisible { + return false + } + return m.screen == ScreenDashboard && + (m.exporter.Visible() || m.probeModal.Visible() || m.dashboard.BlocksGlobalShortcuts(msg)) +} + func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) { if m.helpOverlayVisible { if isHelpOverlayQuitKey(msg) || isHelpOverlayCloseKey(msg) || isHelpOverlayOpenKey(msg) { @@ -428,9 +445,26 @@ func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo return m, nil, true } if key.Matches(msg, m.keys.Quit) { - m.quitting = true - m.stopTrace() - return m, tea.Quit, true + if m.canQuitFromMainDashboard(msg) { + m.quitting = true + m.stopTrace() + return m, tea.Quit, true + } + if m.shouldRouteQuitToEsc(msg) { + esc := tea.KeyPressMsg{Code: tea.KeyEsc} + if m.probeModal.Visible() { + next, cmd := m.updateProbeModal(esc) + return next, cmd, true + } + if m.exporter.Visible() { + next, cmd := m.updateExportModal(esc) + return next, cmd, true + } + next, cmd := m.dashboard.Update(esc) + m.dashboard = next.(dashboardui.Model) + return m, cmd, true + } + return m, nil, true } if isHelpOverlayOpenKey(msg) && !m.attaching && m.lastErr == nil { m.helpOverlayVisible = true @@ -887,7 +921,7 @@ func (m Model) helpSections() []helpSection { "sys/files/proc tables: j/k or up/down scroll", "sys/proc: v bubbles b metric events/bytes", "files: d dirs toggle v bubbles (dirs only) b metric", - "flame: arrows/hjkl nav enter zoom u/bs/esc undo o order", + "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", @@ -974,6 +1008,7 @@ func altScreenView(content, title string) tea.View { view := tea.NewView(content) view.AltScreen = true view.ReportFocus = true + view.MouseMode = tea.MouseModeCellMotion view.WindowTitle = title view.KeyboardEnhancements.ReportEventTypes = true return view diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 84d3632..e4bdbff 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -117,6 +117,8 @@ func TestViewShowsAttachingAndErrorStates(t *testing.T) { func TestQuitKeySetsQuittingState(t *testing.T) { m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'q'}[0], Text: string([]rune{'q'})}) if cmd == nil { @@ -135,6 +137,8 @@ func TestQuitKeySetsQuittingState(t *testing.T) { func TestQuitKeyMatchesSingleBindingWithoutPanic(t *testing.T) { m := NewModel(-1, func(context.Context) error { return nil }) m.keys.Quit = key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "quit")) + m.screen = ScreenDashboard + m.attaching = false _, _ = m.Update(tea.KeyPressMsg{Code: []rune{'z'}[0], Text: string([]rune{'z'})}) @@ -170,6 +174,8 @@ func TestStartTraceCmdEmitsErrorMsg(t *testing.T) { func TestQuitInvokesTraceStop(t *testing.T) { m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false done := make(chan struct{}) m.traceStop = func() { close(done) @@ -187,6 +193,85 @@ func TestQuitInvokesTraceStop(t *testing.T) { } } +func TestQuitKeyDoesNotExitOnPIDPickerScreen(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + if m.screen != ScreenPIDPicker { + t.Fatalf("expected default screen to be PID picker") + } + + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'q'}[0], Text: string([]rune{'q'})}) + updated := next.(Model) + if cmd != nil { + t.Fatalf("expected no quit command outside main dashboard") + } + if updated.quitting { + t.Fatalf("expected q outside main dashboard not to set quitting state") + } +} + +func TestQuitKeyClosesProbeModalLikeEsc(t *testing.T) { + 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, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'q'}[0], Text: string([]rune{'q'})}) + updated := next.(Model) + if cmd != nil { + _ = cmd() + } + if updated.probeModal.Visible() { + t.Fatalf("expected q to close probe modal like esc") + } + if updated.quitting { + t.Fatalf("expected q in probe modal not to quit app") + } +} + +func TestQuitKeyClosesExportModalLikeEsc(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.exporter = m.exporter.Open() + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'q'}[0], Text: string([]rune{'q'})}) + updated := next.(Model) + if updated.exporter.Visible() { + t.Fatalf("expected q to close export modal like esc") + } + if updated.quitting { + t.Fatalf("expected q in export modal not to quit app") + } +} + +func TestQuitKeyClosesFlameSearchLikeEsc(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{'/'}[0], Text: string([]rune{'/'})}) + m = next.(Model) + if !strings.Contains(m.View().Content, "0/0 matches") { + t.Fatalf("expected flame search footer to open on /") + } + + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'q'}[0], Text: string([]rune{'q'})}) + m = next.(Model) + if cmd != nil { + t.Fatalf("expected q in flame search to close search, not quit") + } + if m.quitting { + t.Fatalf("expected q in flame search not to set quitting state") + } + if strings.Contains(m.View().Content, "0/0 matches") { + t.Fatalf("expected q to close flame search like esc") + } +} + type fakeDashboardSource struct { snap *statsengine.Snapshot } @@ -998,6 +1083,13 @@ func TestViewSetsDynamicWindowTitle(t *testing.T) { } } +func TestAltScreenViewEnablesMouseCellMotion(t *testing.T) { + view := altScreenView("test", "ior") + if view.MouseMode != tea.MouseModeCellMotion { + t.Fatalf("expected mouse mode cell motion, got %v", view.MouseMode) + } +} + func TestRenderHelpOverlayUsesWideViewport(t *testing.T) { groups := [][]key.Binding{{key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help"))}} out := renderHelpOverlay(160, 40, groups) |
