summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/tui/flamegraph/model.go38
-rw-r--r--internal/tui/flamegraph/model_test.go50
-rw-r--r--internal/tui/flamegraph/renderer.go28
-rw-r--r--internal/tui/flamegraph/renderer_test.go6
-rw-r--r--internal/tui/flamegraph/search.go97
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")
+}