From eb3b9b8dc037054dc5446859ee164d8afd088678 Mon Sep 17 00:00:00 2001 From: Jamie Wong Date: Thu, 11 Dec 2025 15:44:47 -0500 Subject: [PATCH 1/4] Copy summary text from a given node --- src/lib/tree-summary.ts | 292 +++++++++++++++++++++++++ src/views/context-menu.tsx | 114 ++++++++++ src/views/flamechart-pan-zoom-view.tsx | 104 +++++++-- 3 files changed, 494 insertions(+), 16 deletions(-) create mode 100644 src/lib/tree-summary.ts create mode 100644 src/views/context-menu.tsx diff --git a/src/lib/tree-summary.ts b/src/lib/tree-summary.ts new file mode 100644 index 00000000..5eda95ee --- /dev/null +++ b/src/lib/tree-summary.ts @@ -0,0 +1,292 @@ +import {CallTreeNode, Frame} from './profile' +import {formatPercent} from './utils' + +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') +} + +/** + * 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..b1203136 --- /dev/null +++ b/src/views/context-menu.tsx @@ -0,0 +1,114 @@ +import {h, Component, JSX} 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..1c304dfd 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,19 @@ 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 { + state: FlamechartPanZoomViewState = { + contextMenu: null, + } + private container: Element | null = null private containerRef = (element: Element | null) => { this.container = element || null @@ -695,6 +709,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 +866,12 @@ export class FlamechartPanZoomView extends Component - - + +
+ +
+ {contextMenu && ( + + )} +
) } } From fee028d0e9f07025c79cd50ae1033b8ed2defc46 Mon Sep 17 00:00:00 2001 From: Jamie Wong Date: Thu, 11 Dec 2025 16:09:19 -0500 Subject: [PATCH 2/4] Add Copy Summary button to toolbar Adds a toolbar button that generates a combined summary of all loaded profiles (including Bottoms Up and Call Tree views) and copies it to the clipboard. This makes it easy to share profile context with LLMs for performance analysis. --- src/lib/tree-summary.ts | 84 ++++++++++++++++++++++++++++++++++++++++- src/views/toolbar.tsx | 23 +++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/lib/tree-summary.ts b/src/lib/tree-summary.ts index 5eda95ee..9e3432c5 100644 --- a/src/lib/tree-summary.ts +++ b/src/lib/tree-summary.ts @@ -1,6 +1,11 @@ -import {CallTreeNode, Frame} from './profile' +import {CallTreeNode, Frame, Profile} from './profile' import {formatPercent} from './utils' +interface ProfileInfo { + name: string + profile: Profile +} + interface TreeSummaryOptions { node: CallTreeNode totalWeight: number @@ -278,6 +283,83 @@ export function generateTreeSummary(options: TreeSummaryOptions): string { 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. */ 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} From 2b57205a88af789d36e3a19d30155dac33f64c3a Mon Sep 17 00:00:00 2001 From: Jamie Wong Date: Thu, 11 Dec 2025 17:04:40 -0500 Subject: [PATCH 3/4] lint --- src/views/context-menu.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/views/context-menu.tsx b/src/views/context-menu.tsx index b1203136..753435b2 100644 --- a/src/views/context-menu.tsx +++ b/src/views/context-menu.tsx @@ -1,4 +1,4 @@ -import {h, Component, JSX} from 'preact' +import {h, Component} from 'preact' import {css, StyleSheet} from 'aphrodite' import {FontSize, FontFamily} from './style' import {Theme} from './themes/theme' @@ -111,4 +111,3 @@ export class ContextMenu extends Component { ) } } - From 0c9b1b2600a49ac4b8d7f231d1dfd2f6ecdebfc5 Mon Sep 17 00:00:00 2001 From: Jamie Wong Date: Thu, 11 Dec 2025 17:06:44 -0500 Subject: [PATCH 4/4] lint --- src/views/flamechart-pan-zoom-view.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/views/flamechart-pan-zoom-view.tsx b/src/views/flamechart-pan-zoom-view.tsx index 1c304dfd..2cf94c8b 100644 --- a/src/views/flamechart-pan-zoom-view.tsx +++ b/src/views/flamechart-pan-zoom-view.tsx @@ -73,7 +73,10 @@ interface FlamechartPanZoomViewState { } | null } -export class FlamechartPanZoomView extends Component { +export class FlamechartPanZoomView extends Component< + FlamechartPanZoomViewProps, + FlamechartPanZoomViewState +> { state: FlamechartPanZoomViewState = { contextMenu: null, }