From 388583f3de08468f5cef557ba7b160bbcd45eeee Mon Sep 17 00:00:00 2001 From: Cody Ward Date: Thu, 2 Apr 2026 09:05:49 -0700 Subject: [PATCH 1/4] Quick implementation of profiler scopes Quick implementation of profiler scopes flame stream test flame stream relative current iteration, needs some cleanup various cleanup cleanup and visual tweaks midline options Adjustments to normalization pin the lane timings Rename to ClientCPUProfiler minor cleanup detangling changes detangling changes --- webview-ui/src/diagnostics_panel/App.css | 113 ++ .../MinecraftProfilerFlameStreamChart.tsx | 1311 +++++++++++++++++ .../src/diagnostics_panel/prefabs/index.tsx | 2 + .../prefabs/tabs/ClientCPUProfiler.tsx | 37 + 4 files changed, 1463 insertions(+) create mode 100644 webview-ui/src/diagnostics_panel/controls/MinecraftProfilerFlameStreamChart.tsx create mode 100644 webview-ui/src/diagnostics_panel/prefabs/tabs/ClientCPUProfiler.tsx diff --git a/webview-ui/src/diagnostics_panel/App.css b/webview-ui/src/diagnostics_panel/App.css index 7e98e13..5702ef9 100644 --- a/webview-ui/src/diagnostics_panel/App.css +++ b/webview-ui/src/diagnostics_panel/App.css @@ -217,3 +217,116 @@ main { #multi-column-grid vscode-data-grid-cell:not([grid-column="1"]) { text-align: right; } + +.minecraft-profiler-flame-stream-root { + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; +} + +.minecraft-profiler-flame-stream-toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.minecraft-profiler-flame-stream-toolbar-group { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 360px; +} + +.minecraft-profiler-flame-stream-toolbar-group-scale { + min-width: 280px; +} + +.minecraft-profiler-flame-stream-toolbar-group-unit { + min-width: 220px; +} + +.minecraft-profiler-flame-stream-toolbar-group-lane-display { + min-width: 260px; +} + +.minecraft-profiler-flame-stream-range-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +} + +.minecraft-profiler-flame-stream-range-row input[type="range"] { + flex: 1; +} + +.minecraft-profiler-flame-stream-select { + min-width: 180px; + height: 28px; + padding: 2px 8px; + color: var(--vscode-input-foreground); + background: var(--vscode-dropdown-background); + border: 1px solid var(--vscode-dropdown-border); +} + +.minecraft-profiler-flame-stream-toolbar-caption { + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.minecraft-profiler-flame-stream-surface { + width: 100%; + border: 1px solid var(--vscode-editorGroup-border); + border-radius: 6px; + display: grid; + grid-template-columns: minmax(220px, 320px) 8px minmax(0, 1fr); + overflow: visible; + min-height: 260px; +} + +.minecraft-profiler-flame-stream-label-pane { + overflow: visible; + max-height: none; +} + +.minecraft-profiler-flame-stream-pane-resizer { + cursor: col-resize; + background: color-mix(in srgb, var(--vscode-editorGroup-border) 72%, transparent); + border-left: 1px solid var(--vscode-editorGroup-border); + border-right: 1px solid var(--vscode-editorGroup-border); +} + +.minecraft-profiler-flame-stream-pane-resizer:hover { + background: color-mix(in srgb, var(--vscode-focusBorder) 55%, transparent); +} + +.minecraft-profiler-flame-stream-label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + box-sizing: border-box; + border-bottom: 1px solid var(--vscode-editorGroup-border); + font-size: 12px; +} + +.minecraft-profiler-flame-stream-label-text { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.minecraft-profiler-flame-stream-chart-pane { + position: relative; + min-width: 0; + overflow: visible; + max-height: none; +} + +.minecraft-profiler-flame-stream-empty { + font-size: 12px; + color: var(--vscode-descriptionForeground); +} diff --git a/webview-ui/src/diagnostics_panel/controls/MinecraftProfilerFlameStreamChart.tsx b/webview-ui/src/diagnostics_panel/controls/MinecraftProfilerFlameStreamChart.tsx new file mode 100644 index 0000000..a61d451 --- /dev/null +++ b/webview-ui/src/diagnostics_panel/controls/MinecraftProfilerFlameStreamChart.tsx @@ -0,0 +1,1311 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import * as Plot from '@observablehq/plot'; +import { VSCodeButton } from '@vscode/webview-ui-toolkit/react'; +import { MultipleStatisticProvider, StatisticUpdatedMessage } from '../StatisticProvider'; +import { removeAllStyleElements } from '../../util/CSPUtilities'; + +type ScopeDescriptor = { + pathKey: string; + label: string; + displayPath: string; + depth: number; + order: number; + rawIndent: string; +}; + +type ScopeSample = { + time: number; + low: number; + mid: number; + high: number; +}; + +type ProfilerState = { + scopesByPath: Record; + order: string[]; + historyByPath: Record; + latestTick: number; + lastIndentTick: number; + indentCursor: number; +}; + +type TimeRange = { + start: number; + end: number; +}; + +type ValueScaleMode = 'normalized' | 'absolute'; +type TimeUnit = 'ms' | 'us' | 'ns'; +type LaneDisplayMode = 'range-and-midline' | 'midline-only'; + +type FlameChartPoint = { + time: number; + pathKey: string; + label: string; + displayPath: string; + depth: number; + low: number; + mid: number; + high: number; + yLow: number; + yMid: number; + yHigh: number; + depthBand: string; + laneFill: string; + midStroke: string; + midNormalized: number; + laneRelativeRatio: number; +}; + +type LaneMetric = { + pathKey: string; + laneMaxValue: number; + latestLowValue: number; + latestMidValue: number; + latestHighValue: number; + relativeRatio: number; + contributionToParentRatio: number; + childrenBurdenRatio: number; + dominantChildLabel?: string; +}; + +type MinecraftProfilerFlameStreamChartProps = { + title: string; + statisticDataProvider: MultipleStatisticProvider; + tickRange?: number; + defaultWindowTicks?: number; +}; + +const DEFAULT_TICK_RANGE = 20 * 60; +const DEFAULT_WINDOW_TICKS = 20 * 15; +const ROW_HEIGHT = 72; +const ROW_PADDING = 10; +const MAX_DEPTH_ALL = Number.MAX_SAFE_INTEGER; +const LANE_TICK_MIN_GAP = 12; +const MIN_NORMALIZED_LANE_SPAN = 1; +const DEFAULT_LABEL_PANE_WIDTH = 300; +const MIN_LABEL_PANE_WIDTH = 220; +const MIN_CHART_PANE_WIDTH = 460; + +const DEPTH_FILL_PALETTE = [ + 'var(--vscode-charts-blue)', + 'var(--vscode-charts-green)', + 'var(--vscode-charts-orange)', + 'var(--vscode-charts-purple)', + 'var(--vscode-charts-red)', +]; + +// Using a lighter variant of the base color for the midline to ensure visibility +// when the low and high values are close together +const DEPTH_MIDLINE_PALETTE = [ + 'color-mix(in srgb, var(--vscode-charts-blue) 50%, white)', + 'color-mix(in srgb, var(--vscode-charts-green) 50%, white)', + 'color-mix(in srgb, var(--vscode-charts-orange) 50%, white)', + 'color-mix(in srgb, var(--vscode-charts-purple) 50%, white)', + 'color-mix(in srgb, var(--vscode-charts-red) 50%, white)', +]; + +const INITIAL_STATE: ProfilerState = { + scopesByPath: {}, + order: [], + historyByPath: {}, + latestTick: 0, + lastIndentTick: -1, + indentCursor: 0, +}; + +function parseIndentDepth(rawIndent: string): number { + const normalizedIndent = rawIndent.replace(/\t/g, ' '); + const trimmedIndent = normalizedIndent.trim(); + + if (/^-?\d+$/.test(trimmedIndent)) { + return Math.max(0, Number.parseInt(trimmedIndent, 10)); + } + + return Math.max(0, normalizedIndent.length); +} + +function splitLeadingIndent(label: string): { label: string; impliedDepth: number | undefined } { + const match = label.match(/^(\s+)(.*)$/); + if (!match) { + return { label, impliedDepth: undefined }; + } + + return { + label: match[2], + impliedDepth: parseIndentDepth(match[1]), + }; +} + +function parseMetricValue(rawValue: string | undefined): number | undefined { + if (rawValue === undefined) { + return undefined; + } + + const parsedValue = Number.parseFloat(rawValue); + if (!Number.isFinite(parsedValue)) { + return undefined; + } + + return parsedValue; +} + +function createFallbackPathKey(label: string, index: number): string { + return `${label}#fallback_${index}`; +} + +function buildScopeDescriptorsFromIndents(rows: string[][]): ScopeDescriptor[] { + const descriptors: ScopeDescriptor[] = []; + const siblingCountsByParent = new Map>(); + const pathStack: string[] = []; + const displayPathStack: string[] = []; + + rows.forEach((row, index) => { + const label = row[0] || `Scope ${index + 1}`; + const rawIndent = row[1] ?? ''; + const depth = rawIndent.length; + + while (pathStack.length > depth) { + pathStack.pop(); + displayPathStack.pop(); + } + + const parentPath = depth > 0 ? (pathStack[depth - 1] ?? '') : ''; + const parentDisplayPath = depth > 0 ? (displayPathStack[depth - 1] ?? '') : ''; + + let siblingCounts = siblingCountsByParent.get(parentPath); + if (siblingCounts === undefined) { + siblingCounts = new Map(); + siblingCountsByParent.set(parentPath, siblingCounts); + } + + const nextCount = (siblingCounts.get(label) ?? 0) + 1; + siblingCounts.set(label, nextCount); + + const pathSegment = `${label}#${nextCount}`; + const pathKey = parentPath === '' ? pathSegment : `${parentPath}/${pathSegment}`; + const displayPath = parentDisplayPath === '' ? label : `${parentDisplayPath} > ${label}`; + + pathStack[depth] = pathKey; + displayPathStack[depth] = displayPath; + + descriptors.push({ + pathKey, + label, + displayPath, + depth, + order: index, + rawIndent, + }); + }); + + return descriptors; +} + +function clampTimeRange(range: TimeRange, minTime: number, maxTime: number): TimeRange { + if (maxTime <= minTime) { + return { start: minTime, end: maxTime }; + } + + const clampedStart = Math.max(minTime, Math.min(range.start, maxTime - 1)); + const clampedEnd = Math.min(maxTime, Math.max(range.end, clampedStart + 1)); + + return { + start: clampedStart, + end: clampedEnd, + }; +} + +type EventTypes = 'low' | 'mid' | 'high' | 'indents'; +function resolveEventType(groupName: string): EventTypes | undefined { + const normalizedGroup = groupName.toLowerCase(); + if ( + normalizedGroup === 'low' || + normalizedGroup === 'mid' || + normalizedGroup === 'high' || + normalizedGroup === 'indents' + ) { + return normalizedGroup as EventTypes; + } + return undefined; +} + +function updateSeriesValue( + series: ScopeSample[], + eventTime: number, + group: Exclude, + value: number, +) { + const latestEntry = series.length > 0 ? series[series.length - 1] : undefined; + + if (latestEntry && latestEntry.time === eventTime) { + latestEntry[group] = value; + return; + } + + const nextEntry: ScopeSample = { + time: eventTime, + low: latestEntry?.low ?? 0, + mid: latestEntry?.mid ?? 0, + high: latestEntry?.high ?? 0, + }; + nextEntry[group] = value; + series.push(nextEntry); +} + +function trimHistory(historyByPath: Record, cutoffTick: number): Record { + let nextHistoryByPath = historyByPath; + + Object.keys(historyByPath).forEach(pathKey => { + const currentSeries = historyByPath[pathKey]; + const trimmedSeries = currentSeries.filter(sample => sample.time >= cutoffTick); + if (trimmedSeries.length !== currentSeries.length) { + if (nextHistoryByPath === historyByPath) { + nextHistoryByPath = { ...historyByPath }; + } + nextHistoryByPath[pathKey] = trimmedSeries; + } + }); + + return nextHistoryByPath; +} + +function rebuildDisplayPaths( + scopesByPath: Record, + order: string[], +): Record { + const labelStack: string[] = []; + let nextScopesByPath = scopesByPath; + + order.forEach(pathKey => { + const currentScope = nextScopesByPath[pathKey]; + if (!currentScope) { + return; + } + + const normalizedDepth = Math.max(0, currentScope.depth); + labelStack.length = normalizedDepth; + const displayPath = [...labelStack, currentScope.label].join(' > '); + labelStack[normalizedDepth] = currentScope.label; + + if (currentScope.displayPath !== displayPath) { + if (nextScopesByPath === scopesByPath) { + nextScopesByPath = { ...scopesByPath }; + } + + nextScopesByPath[pathKey] = { + ...currentScope, + displayPath, + }; + } + }); + + return nextScopesByPath; +} + +function ensureScopePathKey( + previousState: ProfilerState, + nextScopesByPath: Record, + nextOrder: string[], + label: string, + fallbackIndex: number, +): { + pathKey: string; + nextScopesByPath: Record; + nextOrder: string[]; +} { + const indexedPathKey = nextOrder[fallbackIndex]; + + if (indexedPathKey !== undefined) { + return { pathKey: indexedPathKey, nextScopesByPath, nextOrder }; + } + + const matchedPathKey = nextOrder.find(candidatePathKey => nextScopesByPath[candidatePathKey]?.label === label); + if (matchedPathKey !== undefined) { + return { pathKey: matchedPathKey, nextScopesByPath, nextOrder }; + } + + if (nextOrder === previousState.order) { + nextOrder = [...previousState.order]; + } + if (nextScopesByPath === previousState.scopesByPath) { + nextScopesByPath = { ...previousState.scopesByPath }; + } + + const pathKey = createFallbackPathKey(label, fallbackIndex); + nextOrder.push(pathKey); + nextScopesByPath[pathKey] = { + pathKey, + label, + displayPath: label, + depth: 0, + order: nextOrder.length - 1, + rawIndent: '', + }; + + return { pathKey, nextScopesByPath, nextOrder }; +} + +function applyEvent(previousState: ProfilerState, event: StatisticUpdatedMessage, tickRange: number): ProfilerState { + if (event.children_string_values.length === 0 && event.values.length === 0) { + return previousState; + } + + const group = resolveEventType(event.group); + if (group === undefined) { + return previousState; + } + + const nextLatestTick = Math.max(previousState.latestTick, event.time); + + let nextScopesByPath = previousState.scopesByPath; + let nextOrder = previousState.order; + let nextHistoryByPath = previousState.historyByPath; + let nextLastIndentTick = previousState.lastIndentTick; + let nextIndentCursor = previousState.indentCursor; + let didChange = false; + + if (group === 'indents') { + if (event.children_string_values.length > 0) { + const nextDescriptors = buildScopeDescriptorsFromIndents(event.children_string_values); + nextScopesByPath = { ...previousState.scopesByPath }; + nextOrder = []; + nextDescriptors.forEach((descriptor, index) => { + nextScopesByPath[descriptor.pathKey] = { ...descriptor, order: index }; + nextOrder.push(descriptor.pathKey); + }); + nextLastIndentTick = event.time; + nextIndentCursor = nextDescriptors.length; + didChange = true; + } else { + const rawIndentValue = event.values.length > 0 ? String(event.values[event.values.length - 1]) : ''; + const label = event.name || event.id; + + if (event.time !== nextLastIndentTick) { + nextLastIndentTick = event.time; + nextIndentCursor = 0; + } + + const ensuredScope = ensureScopePathKey( + previousState, + nextScopesByPath, + nextOrder, + label, + nextIndentCursor, + ); + + const scopeDescriptor = ensuredScope.nextScopesByPath[ensuredScope.pathKey]; + nextScopesByPath = ensuredScope.nextScopesByPath; + nextOrder = ensuredScope.nextOrder; + nextIndentCursor += 1; + + if (scopeDescriptor !== undefined) { + const parsedDepth = parseIndentDepth(rawIndentValue); + if (scopeDescriptor.depth !== parsedDepth || scopeDescriptor.rawIndent !== rawIndentValue) { + if (nextScopesByPath === previousState.scopesByPath) { + nextScopesByPath = { ...previousState.scopesByPath }; + } + + nextScopesByPath[ensuredScope.pathKey] = { + ...scopeDescriptor, + depth: parsedDepth, + rawIndent: rawIndentValue, + }; + didChange = true; + } + } + } + } else if (event.children_string_values.length > 0) { + event.children_string_values.forEach((row, index) => { + const labelWithIndent = row[0] || `Scope ${index + 1}`; + const parsedLabel = splitLeadingIndent(labelWithIndent); + const label = parsedLabel.label; + const value = parseMetricValue(row[1]); + if (value === undefined) { + return; + } + + const ensuredScope = ensureScopePathKey(previousState, nextScopesByPath, nextOrder, label, index); + const pathKey = ensuredScope.pathKey; + nextScopesByPath = ensuredScope.nextScopesByPath; + nextOrder = ensuredScope.nextOrder; + + const scopeDescriptor = nextScopesByPath[pathKey]; + if (scopeDescriptor !== undefined && scopeDescriptor.label !== label) { + if (nextScopesByPath === previousState.scopesByPath) { + nextScopesByPath = { ...previousState.scopesByPath }; + } + + nextScopesByPath[pathKey] = { + ...scopeDescriptor, + label, + }; + } + + if (parsedLabel.impliedDepth !== undefined) { + const currentScopeDescriptor = nextScopesByPath[pathKey]; + if (currentScopeDescriptor !== undefined && currentScopeDescriptor.depth !== parsedLabel.impliedDepth) { + if (nextScopesByPath === previousState.scopesByPath) { + nextScopesByPath = { ...previousState.scopesByPath }; + } + + nextScopesByPath[pathKey] = { + ...currentScopeDescriptor, + depth: parsedLabel.impliedDepth, + }; + } + } + + if (nextHistoryByPath === previousState.historyByPath) { + nextHistoryByPath = { ...previousState.historyByPath }; + } + + const currentSeries = nextHistoryByPath[pathKey] ?? []; + const nextSeries = [...currentSeries]; + updateSeriesValue(nextSeries, event.time, group, value); + + if ( + nextSeries.length >= 2 && + nextSeries[nextSeries.length - 1].time < nextSeries[nextSeries.length - 2].time + ) { + nextSeries.sort((left, right) => left.time - right.time); + } + + nextHistoryByPath[pathKey] = nextSeries; + didChange = true; + }); + } else { + const parsedLabel = splitLeadingIndent(event.name || event.id); + const label = parsedLabel.label; + const ensuredScope = ensureScopePathKey(previousState, nextScopesByPath, nextOrder, label, nextOrder.length); + const pathKey = ensuredScope.pathKey; + nextScopesByPath = ensuredScope.nextScopesByPath; + nextOrder = ensuredScope.nextOrder; + + if (nextHistoryByPath === previousState.historyByPath) { + nextHistoryByPath = { ...previousState.historyByPath }; + } + + const currentSeries = nextHistoryByPath[pathKey] ?? []; + const nextSeries = [...currentSeries]; + + event.values.forEach((rawValue, valueIndex) => { + const value = Number(rawValue); + if (!Number.isFinite(value)) { + return; + } + + const tickOffset = event.values.length - valueIndex - 1; + const eventTime = event.time - tickOffset; + updateSeriesValue(nextSeries, eventTime, group, value); + }); + + if (parsedLabel.impliedDepth !== undefined) { + const scopeDescriptor = nextScopesByPath[pathKey]; + if (scopeDescriptor !== undefined && scopeDescriptor.depth !== parsedLabel.impliedDepth) { + if (nextScopesByPath === previousState.scopesByPath) { + nextScopesByPath = { ...previousState.scopesByPath }; + } + + nextScopesByPath[pathKey] = { + ...scopeDescriptor, + depth: parsedLabel.impliedDepth, + }; + } + } + + if (nextSeries.length >= 2 && nextSeries[nextSeries.length - 1].time < nextSeries[nextSeries.length - 2].time) { + nextSeries.sort((left, right) => left.time - right.time); + } + + nextHistoryByPath[pathKey] = nextSeries; + didChange = true; + } + + const cutoffTick = nextLatestTick - tickRange; + const trimmedHistory = trimHistory(nextHistoryByPath, cutoffTick); + const scopesWithDisplayPaths = rebuildDisplayPaths(nextScopesByPath, nextOrder); + + if ( + !didChange && + trimmedHistory === previousState.historyByPath && + scopesWithDisplayPaths === previousState.scopesByPath && + nextLatestTick === previousState.latestTick && + nextLastIndentTick === previousState.lastIndentTick && + nextIndentCursor === previousState.indentCursor + ) { + return previousState; + } + + return { + scopesByPath: scopesWithDisplayPaths, + order: nextOrder, + historyByPath: trimmedHistory, + latestTick: nextLatestTick, + lastIndentTick: nextLastIndentTick, + indentCursor: nextIndentCursor, + }; +} + +function formatTickDifference(tick: number, latestTick: number): string { + const tickDifference = latestTick - tick; + if (tickDifference < 20) { + return 'now'; + } + + return `${Math.floor(tickDifference / 20)}s`; +} + +function ceilToThreeDecimalPlaces(value: number): number { + return Math.ceil(value * 1000) / 1000; +} + +function clampRatio(value: number): number { + return Math.max(0, Math.min(value, 1)); +} + +function getParentPath(pathKey: string): string | undefined { + const lastSeparatorIndex = pathKey.lastIndexOf('/'); + if (lastSeparatorIndex === -1) { + return undefined; + } + + return pathKey.substring(0, lastSeparatorIndex); +} + +function formatTimingValue(value: number, unit: TimeUnit): string { + if (!Number.isFinite(value)) { + return unit === 'ns' ? '0 ns' : `0.000 ${unit}`; + } + + if (unit === 'ms') { + return `${ceilToThreeDecimalPlaces(value / 1_000_000).toFixed(3)} ms`; + } + + if (unit === 'us') { + return `${ceilToThreeDecimalPlaces(value / 1_000).toFixed(3)} us`; + } + + return `${value} ns`; +} + +function getDepthPaletteColor(depth: number, palette: readonly string[]): string { + return palette[Math.abs(depth) % palette.length]; +} + +function enforceOrderedTickPositions( + lowY: number, + midY: number, + highY: number, + laneBottomY: number, + laneTopY: number, +): { low: number; mid: number; high: number } { + const clampedLow = Math.max(laneBottomY, Math.min(lowY, laneTopY)); + const clampedMid = Math.max(laneBottomY, Math.min(midY, laneTopY)); + const clampedHigh = Math.max(laneBottomY, Math.min(highY, laneTopY)); + + let low = clampedLow; + let mid = Math.max(clampedMid, low + LANE_TICK_MIN_GAP); + let high = Math.max(clampedHigh, mid + LANE_TICK_MIN_GAP); + + if (high > laneTopY) { + const overflow = high - laneTopY; + high -= overflow; + mid -= overflow; + low -= overflow; + } + + if (low < laneBottomY) { + const underflow = laneBottomY - low; + low += underflow; + mid += underflow; + high += underflow; + } + + return { + low, + mid, + high, + }; +} + +function minecraftProfilerFlameStreamChart({ + title, + statisticDataProvider, + tickRange = DEFAULT_TICK_RANGE, + defaultWindowTicks = DEFAULT_WINDOW_TICKS, +}: MinecraftProfilerFlameStreamChartProps): JSX.Element { + const [state, setState] = useState(INITIAL_STATE); + const [maxVisibleDepth, setMaxVisibleDepth] = useState(MAX_DEPTH_ALL); + const [followLatest, setFollowLatest] = useState(true); + const [selectedRange, setSelectedRange] = useState(undefined); + const [chartWidth, setChartWidth] = useState(900); + const [valueScaleMode, setValueScaleMode] = useState('normalized'); + const [timeUnit, setTimeUnit] = useState('us'); + const [laneDisplayMode, setLaneDisplayMode] = useState('midline-only'); + const [labelPaneWidth, setLabelPaneWidth] = useState(DEFAULT_LABEL_PANE_WIDTH); + + const chartHostRef = useRef(null); + const plotContainerRef = useRef(null); + const chartPaneRef = useRef(null); + + useEffect(() => { + const eventHandler = (event: StatisticUpdatedMessage): void => { + setState(previousState => applyEvent(previousState, event, tickRange)); + }; + + statisticDataProvider.registerWindowListener(window); + statisticDataProvider.addSubscriber(eventHandler); + + return () => { + statisticDataProvider.removeSubscriber(eventHandler); + statisticDataProvider.unregisterWindowListener(window); + }; + }, [statisticDataProvider, tickRange]); + + useEffect(() => { + if (typeof ResizeObserver === 'undefined') { + return; + } + + const chartWidthTarget = chartPaneRef.current ?? chartHostRef.current; + if (chartWidthTarget === null) { + return; + } + + const observer = new ResizeObserver(entries => { + const nextWidth = Math.floor(entries[0].contentRect.width); + if (nextWidth > 0) { + setChartWidth(nextWidth); + } + }); + + observer.observe(chartWidthTarget); + + return () => { + observer.disconnect(); + }; + }, []); + + const allScopesInOrder = useMemo(() => { + return state.order + .map(pathKey => state.scopesByPath[pathKey]) + .filter((scope): scope is ScopeDescriptor => scope !== undefined) + .sort((left, right) => left.order - right.order); + }, [state.order, state.scopesByPath]); + + const maxDepth = useMemo(() => { + return allScopesInOrder.reduce((currentMax, scope) => Math.max(currentMax, scope.depth), 0); + }, [allScopesInOrder]); + + const effectiveDepthLimit = useMemo(() => { + if (maxVisibleDepth === MAX_DEPTH_ALL) { + return maxDepth; + } + + return Math.min(maxVisibleDepth, maxDepth); + }, [maxDepth, maxVisibleDepth]); + + const visibleScopes = useMemo(() => { + return allScopesInOrder.filter(scope => scope.depth <= effectiveDepthLimit); + }, [allScopesInOrder, effectiveDepthLimit]); + + const timeDomain = useMemo(() => { + let minTime = Number.POSITIVE_INFINITY; + let maxTime = Number.NEGATIVE_INFINITY; + + visibleScopes.forEach(scope => { + const series = state.historyByPath[scope.pathKey] ?? []; + series.forEach(sample => { + minTime = Math.min(minTime, sample.time); + maxTime = Math.max(maxTime, sample.time); + }); + }); + + if (!Number.isFinite(minTime) || !Number.isFinite(maxTime)) { + return undefined; + } + + return { + min: minTime, + max: maxTime, + }; + }, [state.historyByPath, visibleScopes]); + + useEffect(() => { + if (timeDomain === undefined) { + setSelectedRange(undefined); + return; + } + + const defaultRange: TimeRange = { + start: Math.max(timeDomain.min, timeDomain.max - defaultWindowTicks), + end: timeDomain.max, + }; + + setSelectedRange(previousRange => { + if (previousRange === undefined || followLatest) { + return defaultRange; + } + + return clampTimeRange(previousRange, timeDomain.min, timeDomain.max); + }); + }, [defaultWindowTicks, followLatest, timeDomain]); + + const plotModel = useMemo(() => { + if (selectedRange === undefined || timeDomain === undefined || visibleScopes.length === 0) { + return undefined; + } + + const resolvedRange = clampTimeRange(selectedRange, timeDomain.min, timeDomain.max); + + const filteredSeriesByPath: Record = {}; + const laneMinByPath: Record = {}; + const laneMaxByPath: Record = {}; + const laneNormalizedSpanByPath: Record = {}; + const latestValuesByPath: Record = {}; + const laneLabelByPath: Record = {}; + const useMidlineOnlyNormalization = laneDisplayMode === 'midline-only' && valueScaleMode === 'normalized'; + let globalLaneMaxValue = 0; + + visibleScopes.forEach(scope => { + const series = (state.historyByPath[scope.pathKey] ?? []).filter( + sample => sample.time >= resolvedRange.start && sample.time <= resolvedRange.end, + ); + filteredSeriesByPath[scope.pathKey] = series; + laneLabelByPath[scope.pathKey] = scope.label; + + let laneMin = Number.POSITIVE_INFINITY; + let laneMax = Number.NEGATIVE_INFINITY; + series.forEach(sample => { + if (useMidlineOnlyNormalization) { + laneMin = Math.min(laneMin, sample.mid); + laneMax = Math.max(laneMax, sample.mid); + } else { + const sampleLow = Math.min(sample.low, sample.mid, sample.high); + const sampleHigh = Math.max(sample.low, sample.mid, sample.high); + laneMin = Math.min(laneMin, sampleLow); + laneMax = Math.max(laneMax, sampleHigh); + } + }); + + const resolvedLaneMin = Number.isFinite(laneMin) ? laneMin : 0; + const resolvedLaneMax = Number.isFinite(laneMax) ? laneMax : 0; + + const boundedLaneMax = resolvedLaneMax > 0 ? resolvedLaneMax : 1; + const laneSpan = Math.max(resolvedLaneMax - resolvedLaneMin, MIN_NORMALIZED_LANE_SPAN); + laneMinByPath[scope.pathKey] = resolvedLaneMin; + laneMaxByPath[scope.pathKey] = boundedLaneMax; + laneNormalizedSpanByPath[scope.pathKey] = laneSpan; + globalLaneMaxValue = Math.max(globalLaneMaxValue, boundedLaneMax); + + const latestSample = series.length > 0 ? series[series.length - 1] : undefined; + const latestLow = latestSample ? Math.min(latestSample.low, latestSample.mid, latestSample.high) : 0; + const latestHigh = latestSample ? Math.max(latestSample.low, latestSample.mid, latestSample.high) : 0; + const latestMid = latestSample ? Math.min(latestHigh, Math.max(latestLow, latestSample.mid)) : 0; + + latestValuesByPath[scope.pathKey] = { + low: latestLow, + mid: latestMid, + high: latestHigh, + }; + }); + + const normalizedMaxValue = globalLaneMaxValue > 0 ? globalLaneMaxValue : 1; + const isAbsoluteScale = valueScaleMode === 'absolute'; + const yAxisTimingBands = laneDisplayMode === 'midline-only' ? 'M' : 'L/M/H'; + const rowUsableHeight = ROW_HEIGHT - ROW_PADDING * 2; + const rowCount = visibleScopes.length; + const rowsHeight = rowCount * ROW_HEIGHT; + const childLatestMidSumByParent: Record = {}; + const topChildByParent: Record = {}; + + visibleScopes.forEach(scope => { + const parentPath = getParentPath(scope.pathKey); + if (parentPath === undefined) { + return; + } + + const childLatestMid = latestValuesByPath[scope.pathKey]?.mid ?? 0; + childLatestMidSumByParent[parentPath] = (childLatestMidSumByParent[parentPath] ?? 0) + childLatestMid; + + const currentTopChild = topChildByParent[parentPath]; + if (currentTopChild === undefined || childLatestMid > currentTopChild.mid) { + topChildByParent[parentPath] = { + label: laneLabelByPath[scope.pathKey] ?? scope.label, + mid: childLatestMid, + }; + } + }); + + const laneMetricsByPath: Record = {}; + visibleScopes.forEach(scope => { + const parentPath = getParentPath(scope.pathKey); + const latestValues = latestValuesByPath[scope.pathKey] ?? { low: 0, mid: 0, high: 0 }; + const parentLatestMid = parentPath ? (latestValuesByPath[parentPath]?.mid ?? 0) : 0; + const relativeRatio = clampRatio((laneMaxByPath[scope.pathKey] ?? 1) / normalizedMaxValue); + + laneMetricsByPath[scope.pathKey] = { + pathKey: scope.pathKey, + laneMaxValue: laneMaxByPath[scope.pathKey] ?? 0, + latestLowValue: latestValues.low, + latestMidValue: latestValues.mid, + latestHighValue: latestValues.high, + relativeRatio, + contributionToParentRatio: parentLatestMid > 0 ? clampRatio(latestValues.mid / parentLatestMid) : 0, + childrenBurdenRatio: + latestValues.mid > 0 + ? clampRatio((childLatestMidSumByParent[scope.pathKey] ?? 0) / latestValues.mid) + : 0, + dominantChildLabel: topChildByParent[scope.pathKey]?.label, + }; + }); + + const points: FlameChartPoint[] = []; + const rowTicks: { y: number; label: string }[] = []; + visibleScopes.forEach((scope, rowIndex) => { + const series = filteredSeriesByPath[scope.pathKey] ?? []; + const rowLowerEdge = (rowCount - rowIndex - 1) * ROW_HEIGHT; + const rowBase = rowLowerEdge + ROW_PADDING; + const laneScaleMin = isAbsoluteScale ? 0 : (laneMinByPath[scope.pathKey] ?? 0); + const laneScaleSpan = isAbsoluteScale + ? normalizedMaxValue + : (laneNormalizedSpanByPath[scope.pathKey] ?? MIN_NORMALIZED_LANE_SPAN); + const valueScale = rowUsableHeight / laneScaleSpan; + const laneBottom = rowBase; + const laneTop = rowBase + rowUsableHeight; + const laneFill = getDepthPaletteColor(scope.depth, DEPTH_FILL_PALETTE); + const midStroke = getDepthPaletteColor(scope.depth, DEPTH_MIDLINE_PALETTE); + const latestSample = series.length > 0 ? series[series.length - 1] : undefined; + + const latestLow = latestSample ? Math.min(latestSample.low, latestSample.mid, latestSample.high) : 0; + const latestHigh = latestSample ? Math.max(latestSample.low, latestSample.mid, latestSample.high) : 0; + const latestMid = latestSample ? Math.min(latestHigh, Math.max(latestLow, latestSample.mid)) : 0; + + // Pin L/M/H label anchors to stable lane positions so text doesn't jump as values move. + const pinnedLowY = laneBottom + LANE_TICK_MIN_GAP; + const pinnedMidY = rowBase + rowUsableHeight / 2; + const pinnedHighY = laneTop - LANE_TICK_MIN_GAP; + const orderedTickPositions = enforceOrderedTickPositions( + pinnedLowY, + pinnedMidY, + pinnedHighY, + laneBottom, + laneTop, + ); + + if (laneDisplayMode === 'range-and-midline') { + rowTicks.push({ y: orderedTickPositions.low, label: `L ${formatTimingValue(latestLow, timeUnit)}` }); + rowTicks.push({ y: orderedTickPositions.high, label: `H ${formatTimingValue(latestHigh, timeUnit)}` }); + } + rowTicks.push({ y: orderedTickPositions.mid, label: `M ${formatTimingValue(latestMid, timeUnit)}` }); + + const laneRelativeRatio = laneMetricsByPath[scope.pathKey]?.relativeRatio ?? 0; + + series.forEach(sample => { + const sortedLow = Math.min(sample.low, sample.mid, sample.high); + const sortedHigh = Math.max(sample.low, sample.mid, sample.high); + const sortedMid = Math.min(sortedHigh, Math.max(sortedLow, sample.mid)); + + points.push({ + time: sample.time, + pathKey: scope.pathKey, + label: scope.label, + displayPath: scope.displayPath, + depth: scope.depth, + low: sortedLow, + mid: sortedMid, + high: sortedHigh, + yLow: rowBase + (sortedLow - laneScaleMin) * valueScale, + yMid: rowBase + (sortedMid - laneScaleMin) * valueScale, + yHigh: rowBase + (sortedHigh - laneScaleMin) * valueScale, + depthBand: `Depth ${scope.depth}`, + laneFill, + midStroke, + midNormalized: sortedMid / normalizedMaxValue, + laneRelativeRatio, + }); + }); + }); + + return { + points, + rowsHeight, + rowCount, + range: resolvedRange, + rowGuides: Array.from({ length: rowCount + 1 }, (_, index) => index * ROW_HEIGHT), + normalizedMaxValue, + rowTicks, + laneMetricsByPath, + yAxisLabel: isAbsoluteScale + ? `Lane timings (absolute global scale, ${yAxisTimingBands}, ${timeUnit})` + : `Lane timings (normalized per lane, ${yAxisTimingBands}, ${timeUnit})`, + yAxisWidth: Math.min( + 220, + Math.max( + 96, + rowTicks.reduce((maxWidth, tick) => Math.max(maxWidth, tick.label.length * 7 + 16), 96), + ), + ), + }; + }, [selectedRange, state.historyByPath, timeDomain, timeUnit, valueScaleMode, laneDisplayMode, visibleScopes]); + + useEffect(() => { + const plotContainer = plotContainerRef.current; + if (plotContainer === null) { + return; + } + + if (plotModel === undefined || plotModel.points.length === 0) { + plotContainer.innerHTML = ''; + return; + } + + const rowTickLabelByY = new Map(); + plotModel.rowTicks.forEach(tick => { + const key = Math.round(tick.y * 1000) / 1000; + const existingLabels = rowTickLabelByY.get(key) ?? []; + if (existingLabels.indexOf(tick.label) === -1) { + existingLabels.push(tick.label); + rowTickLabelByY.set(key, existingLabels); + } + }); + + const plot = Plot.plot({ + className: 'minecraft-profiler-flame-stream-chart', + width: Math.max(600, chartWidth), + height: Math.max(220, plotModel.rowsHeight + 48), + marginTop: 8, + marginBottom: 40, + marginLeft: plotModel.yAxisWidth, + marginRight: 12, + x: { + label: 'Time', + domain: [plotModel.range.start, plotModel.range.end], + grid: true, + tickFormat: (tickValue: number) => formatTickDifference(tickValue, state.latestTick), + }, + y: { + axis: 'left', + domain: [0, plotModel.rowsHeight], + ticks: plotModel.rowTicks.map(tick => tick.y), + tickSize: 0, + label: plotModel.yAxisLabel, + tickFormat: (tickValue: number) => { + const key = Math.round(Number(tickValue) * 1000) / 1000; + return (rowTickLabelByY.get(key) ?? []).join(' '); + }, + }, + marks: [ + Plot.ruleY(plotModel.rowGuides, { + y: guide => guide, + stroke: 'var(--vscode-editorGroup-border)', + strokeOpacity: 0.35, + }), + ...(laneDisplayMode === 'range-and-midline' + ? [ + Plot.areaY(plotModel.points, { + x: 'time', + y: 'yHigh', + y1: 'yLow', + z: 'pathKey', + fill: (point: FlameChartPoint) => point.laneFill, + fillOpacity: (point: FlameChartPoint) => 0.1 + Math.sqrt(point.laneRelativeRatio) * 0.5, + }), + ] + : []), + Plot.lineY(plotModel.points, { + x: 'time', + y: 'yMid', + z: 'pathKey', + stroke: 'var(--vscode-editor-background)', + strokeOpacity: 1, + strokeWidth: 3, + }), + Plot.lineY(plotModel.points, { + x: 'time', + y: 'yMid', + z: 'pathKey', + stroke: (point: FlameChartPoint) => point.midStroke, + strokeOpacity: (point: FlameChartPoint) => 0.5 + point.laneRelativeRatio * 0.5, + strokeWidth: 3, + }), + ], + }); + + removeAllStyleElements(plot); + plotContainer.replaceChildren(plot); + + return () => { + plot.remove(); + }; + }, [chartWidth, laneDisplayMode, plotModel, state.latestTick]); + + const onDepthSliderChanged = useCallback((event: React.FormEvent) => { + const depthValue = Number.parseInt(event.currentTarget.value, 10); + setMaxVisibleDepth(Number.isFinite(depthValue) ? depthValue : MAX_DEPTH_ALL); + }, []); + + const onRangeStartChanged = useCallback( + (event: React.FormEvent) => { + if (timeDomain === undefined) { + return; + } + + const startValue = Number.parseInt(event.currentTarget.value, 10); + if (!Number.isFinite(startValue)) { + return; + } + + setFollowLatest(false); + setSelectedRange(previousRange => { + const baseline = previousRange ?? { start: timeDomain.min, end: timeDomain.max }; + return clampTimeRange({ start: startValue, end: baseline.end }, timeDomain.min, timeDomain.max); + }); + }, + [timeDomain], + ); + + const onRangeEndChanged = useCallback( + (event: React.FormEvent) => { + if (timeDomain === undefined) { + return; + } + + const endValue = Number.parseInt(event.currentTarget.value, 10); + if (!Number.isFinite(endValue)) { + return; + } + + setFollowLatest(false); + setSelectedRange(previousRange => { + const baseline = previousRange ?? { start: timeDomain.min, end: timeDomain.max }; + return clampTimeRange({ start: baseline.start, end: endValue }, timeDomain.min, timeDomain.max); + }); + }, + [timeDomain], + ); + + const onFollowLatestClicked = useCallback(() => { + setFollowLatest(true); + }, []); + + const onShowAllDepthsClicked = useCallback(() => { + setMaxVisibleDepth(MAX_DEPTH_ALL); + }, []); + + const onShowRootDepthClicked = useCallback(() => { + setMaxVisibleDepth(0); + }, []); + + const onNormalizedScaleClicked = useCallback(() => { + setValueScaleMode('normalized'); + }, []); + + const onAbsoluteScaleClicked = useCallback(() => { + setValueScaleMode('absolute'); + }, []); + + const onTimeUnitChanged = useCallback((event: React.ChangeEvent) => { + const nextUnit = event.currentTarget.value; + if (nextUnit === 'ms' || nextUnit === 'us' || nextUnit === 'ns') { + setTimeUnit(nextUnit); + } + }, []); + + const onLaneDisplayModeChanged = useCallback((event: React.ChangeEvent) => { + const nextMode = event.currentTarget.value; + if (nextMode === 'range-and-midline' || nextMode === 'midline-only') { + setLaneDisplayMode(nextMode); + } + }, []); + + const onLabelPaneResizePointerDown = useCallback( + (event: React.PointerEvent) => { + const hostElement = chartHostRef.current; + if (hostElement === null) { + return; + } + + event.preventDefault(); + + const surfaceRect = hostElement.getBoundingClientRect(); + const startX = event.clientX; + const startWidth = labelPaneWidth; + const maxLabelPaneWidth = Math.max(MIN_LABEL_PANE_WIDTH, surfaceRect.width - MIN_CHART_PANE_WIDTH); + + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + + const onPointerMove = (moveEvent: PointerEvent): void => { + const deltaX = moveEvent.clientX - startX; + const nextWidth = Math.max( + MIN_LABEL_PANE_WIDTH, + Math.min(startWidth + deltaX, Math.floor(maxLabelPaneWidth)), + ); + setLabelPaneWidth(nextWidth); + }; + + const onPointerUp = (): void => { + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + window.removeEventListener('pointermove', onPointerMove); + }; + + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp, { once: true }); + }, + [labelPaneWidth], + ); + + const surfaceColumns = useMemo(() => { + return `${Math.round(labelPaneWidth)}px 8px minmax(0, 1fr)`; + }, [labelPaneWidth]); + + return ( +
+

{title}

+
+
+ +
+ + + Follow Latest +
+ + {selectedRange === undefined + ? 'Waiting for profiler scope data' + : `Ticks ${selectedRange.start} - ${selectedRange.end}${followLatest ? ' (auto)' : ''}`} + +
+ +
+ +
+ + All + Root +
+ + {`Depth 0 - ${effectiveDepthLimit} of ${maxDepth}`} + +
+ +
+ +
+ + Normalized + + + Absolute + +
+ + {valueScaleMode === 'normalized' + ? 'Each lane scales to its visible min-max window.' + : 'All lanes share one global vertical scale.'} + +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+ +
+
+ {visibleScopes.map(scope => ( +
+ {scope.label} +
+ ))} +
+
+
+
+
+
+ + {visibleScopes.length === 0 && ( +
+ Waiting for profiler scopes from the selected client. +
+ )} +
+ ); +} + +export default minecraftProfilerFlameStreamChart; diff --git a/webview-ui/src/diagnostics_panel/prefabs/index.tsx b/webview-ui/src/diagnostics_panel/prefabs/index.tsx index e41baff..7781210 100644 --- a/webview-ui/src/diagnostics_panel/prefabs/index.tsx +++ b/webview-ui/src/diagnostics_panel/prefabs/index.tsx @@ -12,6 +12,7 @@ import serverScriptSubscriberCountsTab from './tabs/ServerScriptSubscriberCounts import clientTimingTab from './tabs/ClientTiming'; import clientMemoryTab from './tabs/ClientMemory'; import clientEntitySystemTab from './tabs/ClientEntitySystems'; +import clientProfilerScopesTab from './tabs/ClientCPUProfiler'; import editorNetworkStatsTab from './tabs/EditorNetworkStats'; @@ -26,6 +27,7 @@ export default [ clientTimingTab, clientMemoryTab, clientEntitySystemTab, + clientProfilerScopesTab, dynamicPropertyTab, editorNetworkStatsTab, ]; diff --git a/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientCPUProfiler.tsx b/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientCPUProfiler.tsx new file mode 100644 index 0000000..6fb6888 --- /dev/null +++ b/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientCPUProfiler.tsx @@ -0,0 +1,37 @@ +import { useMemo } from 'react'; +import { MultipleStatisticProvider, StatisticUpdatedMessage } from '../../StatisticProvider'; +import { TabPrefab, TabPrefabDataSource, TabPrefabParams } from '../TabPrefab'; +import MinecraftProfilerFlameStreamChart from '../../controls/MinecraftProfilerFlameStreamChart'; + +function isWhiskerEvent(event: StatisticUpdatedMessage): boolean { + return event.group === 'low' || event.group === 'mid' || event.group === 'high' || event.group === 'indents'; +} + +const StatsTab: TabPrefab = { + name: 'Client - CPU Profiler', + dataSource: TabPrefabDataSource.Client, + content: ({ selectedClient }: TabPrefabParams) => { + const statisticDataProvider = useMemo( + () => + new MultipleStatisticProvider({ + statisticParentId: new RegExp(`.*${selectedClient}.*whisker.*`), + valuesFilter: event => + isWhiskerEvent(event) && (event.children_string_values.length > 0 || event.values.length > 0), + }), + [selectedClient], + ); + + return ( +
+ +
+ ); + }, +}; + +export default StatsTab; From 79c0c7364ef267fc2e0a3c24aeb879474906cbbc Mon Sep 17 00:00:00 2001 From: Cody Ward Date: Tue, 28 Apr 2026 11:22:07 -0600 Subject: [PATCH 2/4] Hook up the bidirectional commands --- .../prefabs/tabs/ClientCPUProfiler.tsx | 79 +++++++++++++++++-- 1 file changed, 71 insertions(+), 8 deletions(-) diff --git a/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientCPUProfiler.tsx b/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientCPUProfiler.tsx index 6fb6888..723a5fe 100644 --- a/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientCPUProfiler.tsx +++ b/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientCPUProfiler.tsx @@ -1,7 +1,34 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { MultipleStatisticProvider, StatisticUpdatedMessage } from '../../StatisticProvider'; import { TabPrefab, TabPrefabDataSource, TabPrefabParams } from '../TabPrefab'; import MinecraftProfilerFlameStreamChart from '../../controls/MinecraftProfilerFlameStreamChart'; +import { + DebuggerRequestResultMessage, + getDebuggerRequestResult, + isDebuggerRequestInFlight, + sendDebuggerRequest, + useDebuggerRequestUpdates, +} from '../../utilities/useDebuggerRequests'; +import { VSCodeButton } from '@vscode/webview-ui-toolkit/react'; + +const DEBUGGER_REQUEST_COMMANDS = [ + { command: 'Start CPU Profiler', label: 'Start' }, + { command: 'Stop CPU Profiler', label: 'Stop' }, +]; + +function lastResultToUserFriendlyString(lastResult: DebuggerRequestResultMessage): string { + if (lastResult.error) { + return `Error: ${lastResult.error}`; + } else if (lastResult.response) { + if (lastResult.response.success) { + return `${lastResult.response.response_message}`; + } else { + return `Failed: ${lastResult.response.response_message}`; + } + } else { + return 'Press Start to Begin Profiling'; + } +} function isWhiskerEvent(event: StatisticUpdatedMessage): boolean { return event.group === 'low' || event.group === 'mid' || event.group === 'high' || event.group === 'indents'; @@ -11,6 +38,12 @@ const StatsTab: TabPrefab = { name: 'Client - CPU Profiler', dataSource: TabPrefabDataSource.Client, content: ({ selectedClient }: TabPrefabParams) => { + useDebuggerRequestUpdates(); + const [lastRequestedCommand, setLastRequestedCommand] = useState(''); + const lastResult: DebuggerRequestResultMessage | undefined = lastRequestedCommand + ? getDebuggerRequestResult(lastRequestedCommand) + : undefined; + const statisticDataProvider = useMemo( () => new MultipleStatisticProvider({ @@ -22,13 +55,43 @@ const StatsTab: TabPrefab = { ); return ( -
- +
+
+
+

CPU Profiler Controls

+ {DEBUGGER_REQUEST_COMMANDS.map(command => { + const inFlight = isDebuggerRequestInFlight(command.command); + return ( + { + setLastRequestedCommand(command.command); + sendDebuggerRequest(command.command); + }} + style={{ margin: '5px' }} + > + {command.label} + + ); + })} +
+ + {lastResult + ? lastResultToUserFriendlyString(lastResult) + : 'Press Start to Begin Profiling'} + +
+
+
+
+ +
); }, From 0f09f6fbc5d8c76f27d2c193938de5c70cb58104 Mon Sep 17 00:00:00 2001 From: Cody Ward Date: Tue, 28 Apr 2026 11:30:29 -0600 Subject: [PATCH 3/4] Reduce the width of the flame chart slightly to keep it at a reasonable viewing size --- .../src/diagnostics_panel/prefabs/tabs/ClientCPUProfiler.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientCPUProfiler.tsx b/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientCPUProfiler.tsx index 723a5fe..8be956f 100644 --- a/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientCPUProfiler.tsx +++ b/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientCPUProfiler.tsx @@ -84,7 +84,7 @@ const StatsTab: TabPrefab = {
-
+
Date: Fri, 1 May 2026 15:46:00 -0600 Subject: [PATCH 4/4] PR Feedback: remove some unused variables --- .../controls/MinecraftProfilerFlameStreamChart.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/webview-ui/src/diagnostics_panel/controls/MinecraftProfilerFlameStreamChart.tsx b/webview-ui/src/diagnostics_panel/controls/MinecraftProfilerFlameStreamChart.tsx index a61d451..ea1e103 100644 --- a/webview-ui/src/diagnostics_panel/controls/MinecraftProfilerFlameStreamChart.tsx +++ b/webview-ui/src/diagnostics_panel/controls/MinecraftProfilerFlameStreamChart.tsx @@ -46,16 +46,12 @@ type FlameChartPoint = { label: string; displayPath: string; depth: number; - low: number; - mid: number; - high: number; yLow: number; yMid: number; yHigh: number; depthBand: string; laneFill: string; midStroke: string; - midNormalized: number; laneRelativeRatio: number; }; @@ -915,16 +911,12 @@ function minecraftProfilerFlameStreamChart({ label: scope.label, displayPath: scope.displayPath, depth: scope.depth, - low: sortedLow, - mid: sortedMid, - high: sortedHigh, yLow: rowBase + (sortedLow - laneScaleMin) * valueScale, yMid: rowBase + (sortedMid - laneScaleMin) * valueScale, yHigh: rowBase + (sortedHigh - laneScaleMin) * valueScale, depthBand: `Depth ${scope.depth}`, laneFill, midStroke, - midNormalized: sortedMid / normalizedMaxValue, laneRelativeRatio, }); }); @@ -933,12 +925,9 @@ function minecraftProfilerFlameStreamChart({ return { points, rowsHeight, - rowCount, range: resolvedRange, rowGuides: Array.from({ length: rowCount + 1 }, (_, index) => index * ROW_HEIGHT), - normalizedMaxValue, rowTicks, - laneMetricsByPath, yAxisLabel: isAbsoluteScale ? `Lane timings (absolute global scale, ${yAxisTimingBands}, ${timeUnit})` : `Lane timings (normalized per lane, ${yAxisTimingBands}, ${timeUnit})`,