diff --git a/src/lib/tree-summary.ts b/src/lib/tree-summary.ts new file mode 100644 index 00000000..9e3432c5 --- /dev/null +++ b/src/lib/tree-summary.ts @@ -0,0 +1,374 @@ +import {CallTreeNode, Frame, Profile} from './profile' +import {formatPercent} from './utils' + +interface ProfileInfo { + name: string + profile: Profile +} + +interface TreeSummaryOptions { + node: CallTreeNode + totalWeight: number + formatValue: (v: number) => string +} + +interface TreeLine { + indent: string + name: string + file?: string + line?: number + col?: number + totalWeight: number + selfWeight: number + totalPercent: number + selfPercent: number +} + +// Minimum threshold as a fraction (1%) +const MIN_WEIGHT_THRESHOLD = 0.01 + +function buildTreeLines( + node: CallTreeNode, + totalWeight: number, + minWeight: number, + lines: TreeLine[], + prefix: string, + isLast: boolean, + isRoot: boolean, +): void { + const {frame} = node + + // Skip the speedscope root node + if (node.isRoot()) { + // Process children of root directly + const children = [...node.children] + .filter(child => child.getTotalWeight() >= minWeight) + .sort((a, b) => b.getTotalWeight() - a.getTotalWeight()) + children.forEach((child, index) => { + buildTreeLines(child, totalWeight, minWeight, lines, '', index === children.length - 1, true) + }) + return + } + + const connector = isRoot ? '' : isLast ? '└─ ' : '├─ ' + const indent = prefix + connector + + lines.push({ + indent, + name: frame.name, + file: frame.file, + line: frame.line, + col: frame.col, + totalWeight: node.getTotalWeight(), + selfWeight: node.getSelfWeight(), + totalPercent: (node.getTotalWeight() / totalWeight) * 100, + selfPercent: (node.getSelfWeight() / totalWeight) * 100, + }) + + // Sort children by total weight descending, filtering out those below threshold + const children = [...node.children] + .filter(child => child.getTotalWeight() >= minWeight) + .sort((a, b) => b.getTotalWeight() - a.getTotalWeight()) + const childPrefix = prefix + (isRoot ? '' : isLast ? ' ' : '│ ') + + children.forEach((child, index) => { + buildTreeLines( + child, + totalWeight, + minWeight, + lines, + childPrefix, + index === children.length - 1, + false, + ) + }) +} + +interface BottomsUpEntry { + frame: Frame + totalWeight: number + selfWeight: number + totalPercent: number + selfPercent: number +} + +/** + * Builds the bottoms-up view: a flat list of unique frames in the subtree. + * Walks the entire subtree rooted at the given node, aggregating weights per frame. + * This is similar to how the sandwich view table works. + * Only includes frames whose self weight exceeds minSelfWeight. + * Sorted by self weight descending. + */ +function buildBottomsUpEntries( + node: CallTreeNode, + totalWeight: number, + minSelfWeight: number, +): BottomsUpEntry[] { + // Map from frame to aggregated weights within this subtree + const frameWeights = new Map() + + // Walk the subtree and aggregate weights per frame + function walkSubtree(n: CallTreeNode): void { + if (n.isRoot()) { + // Process children of root + for (const child of n.children) { + walkSubtree(child) + } + return + } + + const frame = n.frame + const existing = frameWeights.get(frame) + if (existing) { + existing.totalWeight += n.getTotalWeight() + existing.selfWeight += n.getSelfWeight() + } else { + frameWeights.set(frame, { + totalWeight: n.getTotalWeight(), + selfWeight: n.getSelfWeight(), + }) + } + + // Process children + for (const child of n.children) { + walkSubtree(child) + } + } + + walkSubtree(node) + + // Convert to entries, filter by self weight, and sort by self weight + const entries: BottomsUpEntry[] = [] + for (const [frame, weights] of frameWeights) { + if (weights.selfWeight >= minSelfWeight) { + entries.push({ + frame, + totalWeight: weights.totalWeight, + selfWeight: weights.selfWeight, + totalPercent: (weights.totalWeight / totalWeight) * 100, + selfPercent: (weights.selfWeight / totalWeight) * 100, + }) + } + } + + // Sort by self weight descending + entries.sort((a, b) => b.selfWeight - a.selfWeight) + + return entries +} + +/** + * Formats a tree line for output. + */ +function formatTreeLine(line: TreeLine, formatValue: (v: number) => string): string[] { + const stats = `[${formatValue(line.totalWeight)} (${formatPercent( + line.totalPercent, + )}), self: ${formatValue(line.selfWeight)} (${formatPercent(line.selfPercent)})]` + + let name = line.name + if (line.file) { + let location = line.file + if (line.line != null) { + location += `:${line.line}` + if (line.col != null) { + location += `:${line.col}` + } + } + name += ` (${location})` + } + + return [ + `${line.indent}${name}`, + `${line.indent}${' '.repeat(Math.max(0, name.length - stats.length))}${stats}`, + ] +} + +/** + * Generates an ASCII tree summary of a call tree node and its descendants. + * This is useful for providing performance context to an LLM for analysis. + * + * Includes two views: + * - Call Tree: Shows the tree structure of callees, filtered to nodes >= 1% of the selection's weight + * - Bottoms Up: Shows all unique frames in the subtree aggregated by function, filtered/sorted by self weight + */ +export function generateTreeSummary(options: TreeSummaryOptions): string { + const {node, totalWeight, formatValue} = options + + // Build the output + const output: string[] = [] + + // Header + output.push('Performance Summary') + output.push('='.repeat(60)) + output.push('') + + // Get the node's weight for thresholds + const nodeWeight = node.isRoot() ? totalWeight : node.getTotalWeight() + + // Root node info + if (!node.isRoot()) { + output.push(`Selected: ${node.frame.name}`) + if (node.frame.file) { + let location = node.frame.file + if (node.frame.line != null) { + location += `:${node.frame.line}` + if (node.frame.col != null) { + location += `:${node.frame.col}` + } + } + output.push(`Location: ${location}`) + } + const totalPercent = (node.getTotalWeight() / totalWeight) * 100 + const selfPercent = (node.getSelfWeight() / totalWeight) * 100 + output.push(`Total: ${formatValue(node.getTotalWeight())} (${formatPercent(totalPercent)})`) + output.push(`Self: ${formatValue(node.getSelfWeight())} (${formatPercent(selfPercent)})`) + output.push('') + } + + // Bottoms Up view (all unique frames in subtree, aggregated) + // Filter to frames with self weight >= 1% of total profile weight + const bottomsUpMinSelfWeight = totalWeight * MIN_WEIGHT_THRESHOLD + const bottomsUpEntries = buildBottomsUpEntries(node, totalWeight, bottomsUpMinSelfWeight) + + if (bottomsUpEntries.length > 0) { + output.push('Bottoms Up (by self time, >=1% of total):') + output.push('-'.repeat(60)) + output.push('') + + for (const entry of bottomsUpEntries) { + let name = entry.frame.name + if (entry.frame.file) { + let location = entry.frame.file + if (entry.frame.line != null) { + location += `:${entry.frame.line}` + if (entry.frame.col != null) { + location += `:${entry.frame.col}` + } + } + name += ` (${location})` + } + const stats = `[self: ${formatValue(entry.selfWeight)} (${formatPercent( + entry.selfPercent, + )}), total: ${formatValue(entry.totalWeight)} (${formatPercent(entry.totalPercent)})]` + output.push(`${name}`) + output.push(`${stats}`) + output.push('') + } + } + + // Call Tree view (children of this node) + // Filter to nodes >= 1% of the copied node's weight + const callTreeMinWeight = nodeWeight * MIN_WEIGHT_THRESHOLD + const callTreeLines: TreeLine[] = [] + buildTreeLines(node, totalWeight, callTreeMinWeight, callTreeLines, '', true, true) + + if (callTreeLines.length > 0) { + output.push('Call Tree (callees, >=1% of selection):') + output.push('-'.repeat(60)) + output.push('') + + for (const line of callTreeLines) { + output.push(...formatTreeLine(line, formatValue)) + } + output.push('') + } + + if (bottomsUpEntries.length === 0 && callTreeLines.length === 0) { + return 'No data available' + } + + output.push('-'.repeat(60)) + output.push(`Total weight of profile: ${formatValue(totalWeight)}`) + + return output.join('\n') +} + +/** + * Generates a combined summary of all profiles' left-heavy call graphs. + * This is useful for sending performance context to an LLM for analysis. + */ +export function generateAllProfilesSummary(profiles: ProfileInfo[]): string { + const output: string[] = [] + + output.push('Performance Profile Summary') + output.push('='.repeat(60)) + output.push('') + output.push(`Total profiles: ${profiles.length}`) + output.push('') + + for (let i = 0; i < profiles.length; i++) { + const {name, profile} = profiles[i] + const root = profile.getGroupedCalltreeRoot() + const totalWeight = profile.getTotalNonIdleWeight() + const formatValue = profile.formatValue.bind(profile) + + if (profiles.length > 1) { + output.push('='.repeat(60)) + output.push(`Profile ${i + 1}/${profiles.length}: ${name}`) + output.push(`Total: ${formatValue(totalWeight)}`) + output.push('='.repeat(60)) + output.push('') + } + + // Bottoms Up view + const bottomsUpMinSelfWeight = totalWeight * MIN_WEIGHT_THRESHOLD + const bottomsUpEntries = buildBottomsUpEntries(root, totalWeight, bottomsUpMinSelfWeight) + + if (bottomsUpEntries.length > 0) { + output.push('Bottoms Up (by self time, >=1% of total):') + output.push('-'.repeat(60)) + output.push('') + + for (const entry of bottomsUpEntries) { + let entryName = entry.frame.name + if (entry.frame.file) { + let location = entry.frame.file + if (entry.frame.line != null) { + location += `:${entry.frame.line}` + if (entry.frame.col != null) { + location += `:${entry.frame.col}` + } + } + entryName += ` (${location})` + } + const stats = `[self: ${formatValue(entry.selfWeight)} (${formatPercent( + entry.selfPercent, + )}), total: ${formatValue(entry.totalWeight)} (${formatPercent(entry.totalPercent)})]` + output.push(`${entryName}`) + output.push(`${stats}`) + output.push('') + } + } + + // Call Tree view + const callTreeMinWeight = totalWeight * MIN_WEIGHT_THRESHOLD + const callTreeLines: TreeLine[] = [] + buildTreeLines(root, totalWeight, callTreeMinWeight, callTreeLines, '', true, true) + + if (callTreeLines.length > 0) { + output.push('Call Tree (>=1% of total):') + output.push('-'.repeat(60)) + output.push('') + + for (const line of callTreeLines) { + output.push(...formatTreeLine(line, formatValue)) + } + output.push('') + } + } + + return output.join('\n') +} + +/** + * Copies text to the clipboard. + */ +export async function copyToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text) + return true + } catch (err) { + console.error('Failed to copy to clipboard:', err) + return false + } +} diff --git a/src/views/context-menu.tsx b/src/views/context-menu.tsx new file mode 100644 index 00000000..753435b2 --- /dev/null +++ b/src/views/context-menu.tsx @@ -0,0 +1,113 @@ +import {h, Component} from 'preact' +import {css, StyleSheet} from 'aphrodite' +import {FontSize, FontFamily} from './style' +import {Theme} from './themes/theme' + +export interface ContextMenuItem { + label: string + onClick: () => void +} + +interface ContextMenuProps { + items: ContextMenuItem[] + x: number + y: number + theme: Theme + onClose: () => void +} + +interface ContextMenuState {} + +export class ContextMenu extends Component { + private menuRef: HTMLDivElement | null = null + + componentDidMount() { + // Add listeners to close the menu when clicking outside + document.addEventListener('mousedown', this.handleClickOutside) + document.addEventListener('keydown', this.handleKeyDown) + window.addEventListener('blur', this.handleClose) + } + + componentWillUnmount() { + document.removeEventListener('mousedown', this.handleClickOutside) + document.removeEventListener('keydown', this.handleKeyDown) + window.removeEventListener('blur', this.handleClose) + } + + private handleClickOutside = (ev: MouseEvent) => { + if (this.menuRef && !this.menuRef.contains(ev.target as Node)) { + this.props.onClose() + } + } + + private handleKeyDown = (ev: KeyboardEvent) => { + if (ev.key === 'Escape') { + this.props.onClose() + } + } + + private handleClose = () => { + this.props.onClose() + } + + private handleItemClick = (item: ContextMenuItem) => { + item.onClick() + this.props.onClose() + } + + private getStyle() { + const {theme} = this.props + return StyleSheet.create({ + menu: { + position: 'fixed', + zIndex: 10000, + backgroundColor: theme.bgPrimaryColor, + border: `1px solid ${theme.fgSecondaryColor}`, + borderRadius: 4, + boxShadow: '0 2px 8px rgba(0, 0, 0, 0.25)', + padding: '4px 0', + minWidth: 180, + fontFamily: FontFamily.MONOSPACE, + fontSize: FontSize.LABEL, + }, + menuItem: { + padding: '6px 12px', + cursor: 'pointer', + color: theme.fgPrimaryColor, + ':hover': { + backgroundColor: theme.selectionPrimaryColor, + color: theme.altFgPrimaryColor, + }, + }, + }) + } + + render() { + const {items, x, y} = this.props + const style = this.getStyle() + + // Adjust position to keep menu in viewport + const menuWidth = 180 + const menuHeight = items.length * 30 + 8 + const adjustedX = Math.min(x, window.innerWidth - menuWidth - 10) + const adjustedY = Math.min(y, window.innerHeight - menuHeight - 10) + + return ( +
(this.menuRef = el)} + className={css(style.menu)} + style={{left: adjustedX, top: adjustedY}} + > + {items.map((item, index) => ( +
this.handleItemClick(item)} + > + {item.label} +
+ ))} +
+ ) + } +} diff --git a/src/views/flamechart-pan-zoom-view.tsx b/src/views/flamechart-pan-zoom-view.tsx index 78e7cf16..2cf94c8b 100644 --- a/src/views/flamechart-pan-zoom-view.tsx +++ b/src/views/flamechart-pan-zoom-view.tsx @@ -11,13 +11,15 @@ import { remapRangesToTrimmedText, } from '../lib/text-utils' import {getFlamechartStyle} from './flamechart-style' -import {h, Component} from 'preact' +import {h, Component, Fragment} from 'preact' import {css} from 'aphrodite' import {ProfileSearchResults} from '../lib/profile-search' import {BatchCanvasTextRenderer, BatchCanvasRectRenderer} from '../lib/canvas-2d-batch-renderers' import {Color} from '../lib/color' import {Theme} from './themes/theme' import {minimapMousePositionAtom} from '../app-state' +import {ContextMenu, ContextMenuItem} from './context-menu' +import {generateTreeSummary, copyToClipboard} from '../lib/tree-summary' interface FlamechartFrameLabel { configSpaceBounds: Rect @@ -63,7 +65,22 @@ export interface FlamechartPanZoomViewProps { searchResults: ProfileSearchResults | null } -export class FlamechartPanZoomView extends Component { +interface FlamechartPanZoomViewState { + contextMenu: { + x: number + y: number + node: CallTreeNode + } | null +} + +export class FlamechartPanZoomView extends Component< + FlamechartPanZoomViewProps, + FlamechartPanZoomViewState +> { + state: FlamechartPanZoomViewState = { + contextMenu: null, + } + private container: Element | null = null private containerRef = (element: Element | null) => { this.container = element || null @@ -695,6 +712,40 @@ export class FlamechartPanZoomView extends Component { + ev.preventDefault() + + if (this.hoveredLabel) { + this.setState({ + contextMenu: { + x: ev.clientX, + y: ev.clientY, + node: this.hoveredLabel.node, + }, + }) + } + } + + private closeContextMenu = () => { + this.setState({contextMenu: null}) + } + + private handleCopySummary = async () => { + const {contextMenu} = this.state + if (!contextMenu) return + + const summary = generateTreeSummary({ + node: contextMenu.node, + totalWeight: this.props.flamechart.getTotalWeight(), + formatValue: this.props.flamechart.formatValue.bind(this.props.flamechart), + }) + + const success = await copyToClipboard(summary) + if (!success) { + console.error('Failed to copy summary to clipboard') + } + } + private onWheel = (ev: WheelEvent) => { ev.preventDefault() this.frameHadWheelEvent = true @@ -818,8 +869,12 @@ export class FlamechartPanZoomView extends Component - - + +
+ +
+ {contextMenu && ( + + )} +
) } } diff --git a/src/views/toolbar.tsx b/src/views/toolbar.tsx index a47372ca..29ab4362 100644 --- a/src/views/toolbar.tsx +++ b/src/views/toolbar.tsx @@ -12,6 +12,7 @@ import {viewModeAtom} from '../app-state' import {ProfileGroupState} from '../app-state/profile-group' import {colorSchemeAtom} from '../app-state/color-scheme' import {useAtom} from '../lib/atom' +import {generateAllProfilesSummary, copyToClipboard} from '../lib/tree-summary' interface ToolbarProps extends ApplicationProps { browseForFile(): void @@ -160,6 +161,27 @@ function ToolbarRightContent(props: ToolbarProps) { const style = getStyle(useTheme()) const colorScheme = useAtom(colorSchemeAtom) + const handleCopySummary = useCallback(async () => { + if (!props.profileGroup) return + + const profiles = props.profileGroup.profiles.map(p => ({ + name: p.profile.getName(), + profile: p.profile, + })) + + const summary = generateAllProfilesSummary(profiles) + const success = await copyToClipboard(summary) + if (!success) { + console.error('Failed to copy summary to clipboard') + } + }, [props.profileGroup]) + + const copySummary = ( +
+ 📋Copy Summary +
+ ) + const exportFile = (
⤴️Export @@ -194,6 +216,7 @@ function ToolbarRightContent(props: ToolbarProps) { return (
+ {props.activeProfileState && copySummary} {props.activeProfileState && exportFile} {importFile} {colorSchemeToggle}