Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
374 changes: 374 additions & 0 deletions src/lib/tree-summary.ts
Original file line number Diff line number Diff line change
@@ -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<Frame, {totalWeight: number; selfWeight: number}>()

// 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}`,
Comment on lines +180 to +182
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spacing calculation ' '.repeat(Math.max(0, name.length - stats.length)) appears to attempt alignment but may produce incorrect results when name.length is less than stats.length, resulting in no spacing. This logic seems intended to align stats but the calculation doesn't account for the indent width. Consider revising or removing this alignment logic if it's not producing the intended visual result.

Suggested change
return [
`${line.indent}${name}`,
`${line.indent}${' '.repeat(Math.max(0, name.length - stats.length))}${stats}`,
// Choose a fixed width for the name column (including indent), e.g., 40 chars
const NAME_COL_WIDTH = 40
const nameWithIndent = `${line.indent}${name}`
return [
nameWithIndent,
nameWithIndent.padEnd(NAME_COL_WIDTH, ' ') + stats,

Copilot uses AI. Check for mistakes.
]
}

/**
* 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')
}
Comment on lines +194 to +284
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new tree summary generation functions lack test coverage. Given the complexity of the bottoms-up and call tree generation logic, consider adding unit tests to verify correct behavior for edge cases like empty profiles, nodes with zero weight, and proper filtering/sorting of entries.

Copilot uses AI. Check for mistakes.

/**
* 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<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
} catch (err) {
console.error('Failed to copy to clipboard:', err)
return false
}
}
Comment on lines +366 to +374
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clipboard API requires a secure context (HTTPS or localhost). Consider adding a fallback mechanism or user-visible warning for non-secure contexts, as navigator.clipboard may be undefined or fail silently in some environments.

Copilot uses AI. Check for mistakes.
Loading