diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/tui/flamegraph/model.go | 96 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 72 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 7 | ||||
| -rw-r--r-- | internal/tui/flamegraph/zoom.go | 39 |
4 files changed, 204 insertions, 10 deletions
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index 8c8d434..c4ca94a 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -20,8 +20,8 @@ type snapshotNode struct { } type zoomState struct { - path string - selectedIdx int + path string + previousSelectedIdx int } type frameSpring struct{} @@ -31,6 +31,9 @@ type flameKeyMap struct { MoveDeeper key.Binding PrevSibling key.Binding NextSibling key.Binding + ZoomIn key.Binding + ZoomUndo key.Binding + ZoomReset key.Binding } func defaultFlameKeyMap() flameKeyMap { @@ -39,6 +42,9 @@ func defaultFlameKeyMap() flameKeyMap { MoveDeeper: key.NewBinding(key.WithKeys("k", "up")), PrevSibling: key.NewBinding(key.WithKeys("h", "left")), NextSibling: key.NewBinding(key.WithKeys("l", "right")), + ZoomIn: key.NewBinding(key.WithKeys("enter")), + ZoomUndo: key.NewBinding(key.WithKeys("backspace", "u")), + ZoomReset: key.NewBinding(key.WithKeys("esc")), } } @@ -56,6 +62,7 @@ type Model struct { selectedIdx int zoomStack []zoomState zoomRoot *snapshotNode + zoomPath string searchActive bool searchQuery string @@ -111,6 +118,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: prev := m.selectedIdx switch { + case key.Matches(msg, m.keys.ZoomIn): + m.zoomIn() + case key.Matches(msg, m.keys.ZoomUndo): + m.zoomUndo() + case key.Matches(msg, m.keys.ZoomReset): + m.zoomReset() case key.Matches(msg, m.keys.MoveShallower): m.moveVertical(-1) case key.Matches(msg, m.keys.MoveDeeper): @@ -144,6 +157,9 @@ func (m *Model) SetLiveTrie(liveTrie *coreflamegraph.LiveTrie) { m.selectedIdx = 0 m.frames = nil m.targetFrames = nil + m.zoomStack = nil + m.zoomRoot = nil + m.zoomPath = "" m.subtreeSet = make(map[int]bool) } @@ -162,11 +178,13 @@ func (m *Model) RefreshFromLiveTrie() bool { if err := json.Unmarshal(payload, &snapshot); err != nil { return false } - m.targetFrames = BuildTerminalLayout(&snapshot, m.width, m.height) - m.frames = append(m.frames[:0], m.targetFrames...) - m.clampSelection() - m.subtreeSet = computeSubtreeSet(m.frames, m.selectedIdx) m.snapshot = &snapshot + if m.zoomPath != "" { + m.zoomRoot = findNodeByPath(m.snapshot, m.zoomPath) + } else { + m.zoomRoot = nil + } + m.rebuildFrames() m.lastVersion = version return true } @@ -180,10 +198,7 @@ func (m Model) LastVersion() uint64 { func (m *Model) SetViewport(width, height int) { m.width = width m.height = height - m.targetFrames = BuildTerminalLayout(m.snapshot, width, height) - m.frames = append(m.frames[:0], m.targetFrames...) - m.clampSelection() - m.subtreeSet = computeSubtreeSet(m.frames, m.selectedIdx) + m.rebuildFrames() } // SetDarkMode sets the active color theme mode. @@ -191,6 +206,67 @@ func (m *Model) SetDarkMode(isDark bool) { m.isDark = isDark } +func (m *Model) rebuildFrames() { + var root *snapshotNode + rootPath := "" + if m.zoomRoot != nil { + root = m.zoomRoot + rootPath = m.zoomPath + } else { + root = m.snapshot + } + m.targetFrames = buildTerminalLayoutWithPath(root, m.width, m.height, rootPath) + m.frames = append(m.frames[:0], m.targetFrames...) + m.clampSelection() + m.subtreeSet = computeSubtreeSet(m.frames, m.selectedIdx) +} + +func (m *Model) zoomIn() { + if len(m.frames) == 0 || m.snapshot == nil { + return + } + m.clampSelection() + selectedPath := m.frames[m.selectedIdx].Path + target := findNodeByPath(m.snapshot, selectedPath) + if target == nil { + return + } + m.zoomStack = append(m.zoomStack, zoomState{ + path: m.zoomPath, + previousSelectedIdx: m.selectedIdx, + }) + m.zoomRoot = target + m.zoomPath = selectedPath + m.selectedIdx = 0 + m.rebuildFrames() +} + +func (m *Model) zoomUndo() { + if len(m.zoomStack) == 0 || m.snapshot == nil { + return + } + last := m.zoomStack[len(m.zoomStack)-1] + m.zoomStack = m.zoomStack[:len(m.zoomStack)-1] + m.zoomPath = last.path + if m.zoomPath == "" { + m.zoomRoot = nil + } else { + m.zoomRoot = findNodeByPath(m.snapshot, m.zoomPath) + } + m.selectedIdx = last.previousSelectedIdx + m.rebuildFrames() +} + +func (m *Model) zoomReset() { + if m.zoomRoot == nil && len(m.zoomStack) == 0 { + return + } + m.zoomRoot = nil + m.zoomPath = "" + m.zoomStack = nil + m.rebuildFrames() +} + func (m *Model) moveVertical(delta int) { if len(m.frames) == 0 { return diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go index 4569296..f79b095 100644 --- a/internal/tui/flamegraph/model_test.go +++ b/internal/tui/flamegraph/model_test.go @@ -126,6 +126,78 @@ func TestKeyboardNavigationSingleNodeClamped(t *testing.T) { } } +func TestZoomInUndoResetAndNestedZoom(t *testing.T) { + m := newZoomModel() + + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A") + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if got, want := m.zoomPath, "root"+pathSeparator+"A"; got != want { + t.Fatalf("expected zoomPath %q, got %q", want, got) + } + if len(m.zoomStack) != 1 || m.zoomStack[0].path != "" { + t.Fatalf("expected one zoom stack entry from root, got %#v", m.zoomStack) + } + if m.zoomRoot == nil || m.zoomRoot.Name != "A" { + t.Fatalf("expected zoomRoot A, got %+v", m.zoomRoot) + } + + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A"+pathSeparator+"A1") + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if got, want := m.zoomPath, "root"+pathSeparator+"A"+pathSeparator+"A1"; got != want { + t.Fatalf("expected nested zoomPath %q, got %q", want, got) + } + if len(m.zoomStack) != 2 || m.zoomStack[1].path != "root"+pathSeparator+"A" { + t.Fatalf("expected nested zoom stack to preserve parent path, got %#v", m.zoomStack) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyBackspace}) + if got, want := m.zoomPath, "root"+pathSeparator+"A"; got != want { + t.Fatalf("expected zoomPath after undo %q, got %q", want, got) + } + if len(m.zoomStack) != 1 { + t.Fatalf("expected one stack entry after undo, got %d", len(m.zoomStack)) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) + if m.zoomPath != "" || m.zoomRoot != nil || len(m.zoomStack) != 0 { + t.Fatalf("expected zoom reset to root state, got path=%q root=%+v stack=%d", m.zoomPath, m.zoomRoot, len(m.zoomStack)) + } +} + +func newZoomModel() Model { + m := NewModel(nil) + m.width = 120 + m.height = 30 + m.snapshot = &snapshotNode{ + Name: "root", + Total: 100, + Children: []*snapshotNode{ + { + Name: "A", + Total: 60, + Children: []*snapshotNode{ + {Name: "A1", Total: 30}, + {Name: "A2", Total: 30}, + }, + }, + {Name: "B", Total: 40}, + }, + } + m.rebuildFrames() + return m +} + +func mustFrameIndex(t *testing.T, frames []tuiFrame, path string) int { + t.Helper() + for idx, frame := range frames { + if frame.Path == path { + return idx + } + } + t.Fatalf("frame path %q not found", path) + return -1 +} + func pressFlameKey(t *testing.T, m Model, keyMsg tea.KeyPressMsg) Model { t.Helper() next, _ := m.Update(keyMsg) diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go index 8b2aff0..5e223a6 100644 --- a/internal/tui/flamegraph/renderer.go +++ b/internal/tui/flamegraph/renderer.go @@ -18,6 +18,10 @@ const minFlameWidth = 60 // BuildTerminalLayout converts a live trie snapshot into terminal frame cells. func BuildTerminalLayout(snapshot *snapshotNode, width, height int) []tuiFrame { + return buildTerminalLayoutWithPath(snapshot, width, height, "") +} + +func buildTerminalLayoutWithPath(snapshot *snapshotNode, width, height int, rootPath string) []tuiFrame { if snapshot == nil || width <= 0 || height <= 0 { return nil } @@ -27,6 +31,9 @@ func BuildTerminalLayout(snapshot *snapshotNode, width, height int) []tuiFrame { } rootName := frameName(snapshot.Name, 0) + if rootPath != "" { + rootName = rootPath + } frames := make([]tuiFrame, 0, len(snapshot.Children)+1) collectTerminalLayout(&frames, snapshot, rootTotal, width, height, 0, 0, rootName) return frames diff --git a/internal/tui/flamegraph/zoom.go b/internal/tui/flamegraph/zoom.go new file mode 100644 index 0000000..7a3aa42 --- /dev/null +++ b/internal/tui/flamegraph/zoom.go @@ -0,0 +1,39 @@ +package flamegraph + +import "strings" + +func findNodeByPath(root *snapshotNode, path string) *snapshotNode { + if root == nil { + return nil + } + if path == "" { + return root + } + parts := strings.Split(path, pathSeparator) + if len(parts) == 0 { + return root + } + rootName := frameName(root.Name, 0) + if parts[0] == rootName { + parts = parts[1:] + } + + node := root + for _, part := range parts { + next := findChildByName(node, part) + if next == nil { + return nil + } + node = next + } + return node +} + +func findChildByName(node *snapshotNode, name string) *snapshotNode { + for _, child := range node.Children { + if child.Name == name || frameName(child.Name, 1) == name { + return child + } + } + return nil +} |
