diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-05 22:42:08 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-05 22:42:08 +0200 |
| commit | 5d6b2cff5fa13700fdfcc30d7e30f5cece2e6d38 (patch) | |
| tree | 1ba853dc4bd0505d2fbae69662bc9a9ca9ffb404 /internal/tui/flamegraph | |
| parent | 4e464d082e0c83f33f4b4659859b8a9be58987e1 (diff) | |
task 360: add flamegraph search and match navigation
Diffstat (limited to 'internal/tui/flamegraph')
| -rw-r--r-- | internal/tui/flamegraph/model.go | 38 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 50 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 28 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer_test.go | 6 | ||||
| -rw-r--r-- | internal/tui/flamegraph/search.go | 97 |
5 files changed, 208 insertions, 11 deletions
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index c4ca94a..0363b58 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -9,6 +9,7 @@ import ( "sort" "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" ) @@ -65,6 +66,7 @@ type Model struct { zoomPath string searchActive bool + searchInput textinput.Model searchQuery string matchIndices map[int]bool subtreeSet map[int]bool @@ -93,10 +95,17 @@ type tuiFrame struct { // NewModel constructs a flamegraph tab model with default state. func NewModel(liveTrie *coreflamegraph.LiveTrie) Model { + searchInput := textinput.New() + searchInput.Prompt = "/" + searchInput.CharLimit = 0 + searchInput.SetWidth(32) + searchInput.SetStyles(textinput.DefaultStyles(true)) + return Model{ liveTrie: liveTrie, matchIndices: make(map[int]bool), subtreeSet: make(map[int]bool), + searchInput: searchInput, fieldPresets: [][]string{ {"comm", "path"}, {"tracepoint", "comm", "path"}, @@ -116,8 +125,31 @@ func (m Model) Init() tea.Cmd { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: + if m.searchActive { + switch msg.String() { + case "esc": + m.clearSearch() + return m, nil + case "enter": + m.applySearchQuery(m.searchInput.Value()) + m.searchActive = false + m.searchInput.Blur() + return m, nil + } + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + _ = cmd + return m, nil + } + prev := m.selectedIdx switch { + case msg.String() == "/": + m.openSearch() + case msg.String() == "n": + m.jumpMatch(1) + case msg.String() == "N": + m.jumpMatch(-1) case key.Matches(msg, m.keys.ZoomIn): m.zoomIn() case key.Matches(msg, m.keys.ZoomUndo): @@ -142,7 +174,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View renders the flamegraph viewport. func (m Model) View() tea.View { - content := RenderTerminalView(m.frames, m.width, m.height, m.selectedIdx, m.subtreeSet, m.matchIndices, m.isDark) + content := RenderTerminalView(m.frames, m.width, m.height, m.selectedIdx, m.subtreeSet, m.matchIndices, m.isDark, m.searchActive, m.searchQuery) + if m.searchActive { + content = replaceFooterLine(content, m.searchFooter()) + } if m.snapshot != nil && len(m.frames) == 0 { content = common.PanelStyle.Render(fmt.Sprintf("Flame: snapshot v%d has no visible frames", m.lastVersion)) } @@ -204,6 +239,7 @@ func (m *Model) SetViewport(width, height int) { // SetDarkMode sets the active color theme mode. func (m *Model) SetDarkMode(isDark bool) { m.isDark = isDark + m.searchInput.SetStyles(textinput.DefaultStyles(isDark)) } func (m *Model) rebuildFrames() { diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go index f79b095..413b571 100644 --- a/internal/tui/flamegraph/model_test.go +++ b/internal/tui/flamegraph/model_test.go @@ -164,6 +164,56 @@ func TestZoomInUndoResetAndNestedZoom(t *testing.T) { } } +func TestSearchLifecycleAndMatchNavigation(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "alpha", Path: "root" + pathSeparator + "alpha"}, + {Name: "beta", Path: "root" + pathSeparator + "beta"}, + {Name: "alphabet", Path: "root" + pathSeparator + "alphabet"}, + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"}) + if !m.searchActive { + t.Fatalf("expected search mode to activate on '/'") + } + for _, r := range []rune{'a', 'l', 'p'} { + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: r, Text: string(r)}) + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + + if m.searchActive { + t.Fatalf("expected search mode to close on enter") + } + if got := len(m.matchIndices); got != 2 { + t.Fatalf("expected 2 matches for 'alp', got %d", got) + } + first := m.selectedIdx + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'n'}[0], Text: "n"}) + if m.selectedIdx == first { + t.Fatalf("expected 'n' to jump to next match") + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'N'}[0], Text: "N"}) + if m.selectedIdx != first { + t.Fatalf("expected 'N' to jump back to previous match") + } +} + +func TestSearchEscapeClearsState(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{{Name: "alpha", Path: "root" + pathSeparator + "alpha"}} + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"}) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'a'}[0], Text: "a"}) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) + + if m.searchActive { + t.Fatalf("expected search mode to close on escape") + } + if m.searchQuery != "" || len(m.matchIndices) != 0 { + t.Fatalf("expected search state to reset on escape, got query=%q matches=%d", m.searchQuery, len(m.matchIndices)) + } +} + func newZoomModel() Model { m := NewModel(nil) m.width = 120 diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go index 5e223a6..d68fc21 100644 --- a/internal/tui/flamegraph/renderer.go +++ b/internal/tui/flamegraph/renderer.go @@ -113,7 +113,7 @@ func terminalFrameColor(name string) color.Color { } // RenderTerminalView renders a terminal flamegraph viewport from laid out frames. -func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtreeSet, matchSet map[int]bool, isDark bool) string { +func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtreeSet, matchSet map[int]bool, isDark, searchActive bool, searchQuery string) string { if width < minFlameWidth { return common.PanelStyle.Render("Flame: terminal too narrow (need >= 60 columns)") } @@ -147,9 +147,19 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr } toolbar = padOrTrim(toolbar, width) status := fmt.Sprintf("Selected: %s %.2f%% total=%d depth=%d", selected.Name, selected.Percent, selected.Total, selected.Depth) + if searchQuery != "" { + matches := orderedMatchIndices(matchSet) + pos := 0 + if len(matches) > 0 { + if idx := indexOf(matches, selectedIdx); idx >= 0 { + pos = idx + 1 + } + } + status = fmt.Sprintf("Search %q %d/%d matches", searchQuery, pos, len(matches)) + } status = padOrTrim(status, width) - rows := buildRenderRows(frames, width, rowOffset, maxRow, selected.Path, subtreeSet, matchSet, selectedIdx, isDark) + rows := buildRenderRows(frames, width, rowOffset, maxRow, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive) var b strings.Builder b.Grow((width + 1) * (len(rows) + 2)) @@ -168,7 +178,7 @@ type indexedFrame struct { frame tuiFrame } -func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark bool) []string { +func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive bool) []string { rowsByDepth := make(map[int][]indexedFrame) for idx, frame := range frames { if frame.Row < rowOffset || frame.Row > maxRow { @@ -183,12 +193,12 @@ func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow int, selectedPa sort.Slice(framesAtRow, func(i, j int) bool { return framesAtRow[i].frame.Col < framesAtRow[j].frame.Col }) - rows = append(rows, renderRow(framesAtRow, width, selectedPath, subtreeSet, matchSet, selectedIdx, isDark)) + rows = append(rows, renderRow(framesAtRow, width, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive)) } return rows } -func renderRow(frames []indexedFrame, width int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark bool) string { +func renderRow(frames []indexedFrame, width int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive bool) string { if len(frames) == 0 { return strings.Repeat(" ", width) } @@ -214,7 +224,7 @@ func renderRow(frames []indexedFrame, width int, selectedPath string, subtreeSet continue } label := padOrTrim(frame.Name, cellWidth) - style := styleForFrame(item.idx, frame, selectedPath, subtreeSet, matchSet, selectedIdx, isDark).Width(cellWidth) + style := styleForFrame(item.idx, frame, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive).Width(cellWidth) cell := style.Render(label) b.WriteString(cell) cursor = frame.Col + cellWidth @@ -242,7 +252,7 @@ func computeSubtreeSet(frames []tuiFrame, selectedIdx int) map[int]bool { return subtree } -func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark bool) lipgloss.Style { +func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive bool) lipgloss.Style { base := lipgloss.NewStyle(). Foreground(common.ColorBackground). Background(frame.Fill) @@ -268,6 +278,10 @@ func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, mat return style.Faint(true) } + if searchActive { + return base.Background(common.ColorPanel).Foreground(common.ColorMuted).Faint(true) + } + if inSubtree { if frameRelation(frame.Path, selectedPath) == relationAncestor { return base.BorderLeft(true).BorderForeground(common.ColorAccent) diff --git a/internal/tui/flamegraph/renderer_test.go b/internal/tui/flamegraph/renderer_test.go index f1f8c69..ca837fe 100644 --- a/internal/tui/flamegraph/renderer_test.go +++ b/internal/tui/flamegraph/renderer_test.go @@ -109,7 +109,7 @@ func TestBuildTerminalLayoutUsesPathSeparatorAndColor(t *testing.T) { } func TestRenderTerminalViewShowsNarrowMessage(t *testing.T) { - out := RenderTerminalView(nil, 50, 10, 0, nil, nil, true) + out := RenderTerminalView(nil, 50, 10, 0, nil, nil, true, false, "") if !strings.Contains(out, "terminal too narrow") { t.Fatalf("expected narrow terminal warning, got %q", out) } @@ -125,7 +125,7 @@ func TestRenderTerminalViewIncludesToolbarAndStatus(t *testing.T) { } frames := BuildTerminalLayout(snapshot, 80, 6) - out := RenderTerminalView(frames, 80, 6, 1, nil, nil, true) + out := RenderTerminalView(frames, 80, 6, 1, nil, nil, true, false, "") if !strings.Contains(out, "Flame | frames:2") { t.Fatalf("expected toolbar to include frame count, got %q", out) } @@ -161,7 +161,7 @@ func TestRenderTerminalViewShowsDeepLevelTruncationHint(t *testing.T) { }, } frames := BuildTerminalLayout(snapshot, 80, 10) - out := RenderTerminalView(frames, 80, 4, 0, nil, nil, true) + out := RenderTerminalView(frames, 80, 4, 0, nil, nil, true, false, "") if !strings.Contains(out, "showing deepest levels") { t.Fatalf("expected truncation hint in toolbar, got %q", out) } diff --git a/internal/tui/flamegraph/search.go b/internal/tui/flamegraph/search.go new file mode 100644 index 0000000..c1d4294 --- /dev/null +++ b/internal/tui/flamegraph/search.go @@ -0,0 +1,97 @@ +package flamegraph + +import ( + "fmt" + "sort" + "strings" +) + +func (m *Model) openSearch() { + m.searchActive = true + m.searchInput.SetValue(m.searchQuery) + m.searchInput.CursorEnd() + m.searchInput.Focus() +} + +func (m *Model) clearSearch() { + m.searchActive = false + m.searchQuery = "" + m.matchIndices = make(map[int]bool) + m.searchInput.SetValue("") + m.searchInput.Blur() +} + +func (m *Model) applySearchQuery(raw string) { + query := strings.ToLower(strings.TrimSpace(raw)) + m.searchQuery = query + m.matchIndices = make(map[int]bool) + if query == "" { + return + } + + for idx, frame := range m.frames { + if strings.Contains(strings.ToLower(frame.Name), query) { + m.matchIndices[idx] = true + } + } + if len(m.matchIndices) > 0 { + m.jumpMatch(1) + } +} + +func (m *Model) jumpMatch(direction int) { + matches := orderedMatchIndices(m.matchIndices) + if len(matches) == 0 { + return + } + currentPos := indexOf(matches, m.selectedIdx) + if currentPos == -1 { + if direction < 0 { + m.selectedIdx = matches[len(matches)-1] + } else { + m.selectedIdx = matches[0] + } + m.subtreeSet = computeSubtreeSet(m.frames, m.selectedIdx) + return + } + + next := currentPos + direction + if next < 0 { + next = len(matches) - 1 + } + if next >= len(matches) { + next = 0 + } + m.selectedIdx = matches[next] + m.subtreeSet = computeSubtreeSet(m.frames, m.selectedIdx) +} + +func orderedMatchIndices(matchSet map[int]bool) []int { + matches := make([]int, 0, len(matchSet)) + for idx := range matchSet { + matches = append(matches, idx) + } + sort.Ints(matches) + return matches +} + +func (m Model) searchFooter() string { + matches := orderedMatchIndices(m.matchIndices) + pos := 0 + if len(matches) > 0 { + idx := indexOf(matches, m.selectedIdx) + if idx >= 0 { + pos = idx + 1 + } + } + return fmt.Sprintf("%s %d/%d matches", m.searchInput.View(), pos, len(matches)) +} + +func replaceFooterLine(content, footer string) string { + lines := strings.Split(content, "\n") + if len(lines) == 0 { + return footer + } + lines[len(lines)-1] = footer + return strings.Join(lines, "\n") +} |
