From 47889f1cbb8b355ab950fe664230c61effa94176 Mon Sep 17 00:00:00 2001 From: Omer Celik Date: Mon, 11 May 2026 02:45:47 +0100 Subject: [PATCH] Add hot path trace visualization --- renovate.json | 2 +- shared/src/messages.ts | 22 +++++++++ src/handleMessages.ts | 6 ++- src/hotPath.ts | 39 ++++++++++++++++ src/traceTree.ts | 6 +++ test/hotPath.test.ts | 60 ++++++++++++++++++++++++ ui/components/HotPathChart.vue | 85 ++++++++++++++++++++++++++++++++++ ui/components/TreeNode.vue | 29 +++++++++++- ui/components/UExpand.vue | 1 + ui/src/appState.ts | 15 ++++++ 10 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 src/hotPath.ts create mode 100644 test/hotPath.test.ts create mode 100644 ui/components/HotPathChart.vue diff --git a/renovate.json b/renovate.json index 974dca3..6f62da5 100644 --- a/renovate.json +++ b/renovate.json @@ -6,4 +6,4 @@ "ignoreDeps": [ "@types/vscode" ] -} \ No newline at end of file +} diff --git a/shared/src/messages.ts b/shared/src/messages.ts index 70c04e7..58704ca 100644 --- a/shared/src/messages.ts +++ b/shared/src/messages.ts @@ -72,6 +72,27 @@ export const fileStats = z.object({ }) export type FileStats = z.infer +export const hotPathNode = z.object({ + id: z.number(), + name: z.string(), + dur: z.number(), + typeCnt: z.number(), + childTypeCnt: z.number(), + totalTypeCnt: z.number(), + childCnt: z.number(), + path: z.string().optional(), + pos: z.number().optional(), + end: z.number().optional(), +}) +export type HotPathNode = z.infer + +export const hotPathById = z.object({ + message: z.literal('hotPathById'), + id: z.number(), + nodes: z.array(hotPathNode).optional(), +}) +export type HotPathById = z.infer + export const traceStart = z.object({ message: z.literal('traceStart'), }) @@ -167,6 +188,7 @@ export const message = z.union([ gotoLocation, gotoPosition, gotoTracePosition, + hotPathById, positionTypeCounts, projectNames, projectOpen, diff --git a/src/handleMessages.ts b/src/handleMessages.ts index 4f321c3..c706ad8 100644 --- a/src/handleMessages.ts +++ b/src/handleMessages.ts @@ -1,7 +1,7 @@ import { join } from 'node:path' import * as vscode from 'vscode' import * as Messages from '../shared/src/messages' -import { getChildrenById, getTypesById, showTree } from './traceTree' +import { getChildrenById, getHotPathById, getTypesById, showTree } from './traceTree' import { log } from './logger' import { postMessage } from './webview' import { deleteTraceFiles, setLastMessageTrigger } from './storage' @@ -50,6 +50,10 @@ export function handleMessage(panel: vscode.WebviewPanel, message: unknown): voi postMessage({ ...data, types: getTypesById(data.id) }) break } + case 'hotPathById': { + postMessage({ ...data, nodes: getHotPathById(data.id) }) + break + } case 'deletTraceFile': { deleteTraceFiles(data.fileName, data.dirName) diff --git a/src/hotPath.ts b/src/hotPath.ts new file mode 100644 index 0000000..33236f0 --- /dev/null +++ b/src/hotPath.ts @@ -0,0 +1,39 @@ +import type { HotPathNode } from '../shared/src/messages' +import type { Tree } from './traceTree' + +export function getHotPathFromTree(startNode: Tree | undefined): HotPathNode[] { + if (!startNode) + return [] + + const nodes: HotPathNode[] = [] + const visited = new Set() + let current: Tree | undefined = startNode.line.ph === 'root' ? getLongestRunningChild(startNode) : startNode + + while (current && !visited.has(current.id)) { + visited.add(current.id) + nodes.push({ + id: current.id, + name: current.line.name, + dur: current.line.dur ?? 0, + typeCnt: current.typeCnt, + childTypeCnt: current.childTypeCnt, + totalTypeCnt: current.typeCnt + current.childTypeCnt, + childCnt: current.childCnt, + path: current.line.args?.path, + pos: current.line.args?.pos, + end: current.line.args?.end, + }) + current = getLongestRunningChild(current) + } + + return nodes +} + +function getLongestRunningChild(node: Tree) { + let longest: Tree | undefined + for (const child of node.children) { + if (!longest || (child.line.dur ?? 0) > (longest.line.dur ?? 0)) + longest = child + } + return longest +} diff --git a/src/traceTree.ts b/src/traceTree.ts index 0618d33..c778f8f 100644 --- a/src/traceTree.ts +++ b/src/traceTree.ts @@ -4,6 +4,7 @@ import type { TraceData, TraceLine, TypeLine } from '../shared/src/traceData' import { getWorkspacePath } from './storage' import { postMessage } from './webview' import { traceFiles } from './appState' +import { getHotPathFromTree } from './hotPath' export interface Tree { id: number, line: TraceLine, children: Tree[], types: TypeLine[], childCnt: number, childTypeCnt: number, typeCnt: number } function getRoot(): Tree { @@ -147,6 +148,11 @@ export function getTypesById(id: number) { return treeIdNodes.get(id)?.types ?? [] } +export function getHotPathById(id: number) { + const startNode = treeIdNodes.get(id) ?? (id === 0 ? traceTree : undefined) + return getHotPathFromTree(startNode) +} + export function getStatsFromTree(fileName: string) { const workspacePath = getWorkspacePath() diff --git a/test/hotPath.test.ts b/test/hotPath.test.ts new file mode 100644 index 0000000..d98b321 --- /dev/null +++ b/test/hotPath.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { getHotPathFromTree } from '../src/hotPath' +import type { Tree } from '../src/traceTree' + +function treeNode(id: number, name: string, dur: number, children: Tree[] = [], typeCnt = 0, childTypeCnt = 0): Tree { + return { + id, + line: { + cat: id === 0 ? 'root' : 'check', + name, + ph: id === 0 ? 'root' : 'X', + pid: 1, + tid: 1, + ts: id, + dur, + args: { + path: `src/${name}.ts`, + pos: id * 10, + end: id * 10 + 5, + }, + }, + children, + types: [], + childCnt: children.length, + childTypeCnt, + typeCnt, + } +} + +describe('getHotPathFromTree', () => { + it('follows the longest-running child recursively', () => { + const shortChild = treeNode(1, 'shortChild', 50) + const deeperShortChild = treeNode(3, 'deeperShortChild', 10) + const deeperLongChild = treeNode(4, 'deeperLongChild', 500, [], 2, 7) + const longChild = treeNode(2, 'longChild', 100, [deeperShortChild, deeperLongChild], 3, 9) + const root = treeNode(0, 'root', Number.MAX_SAFE_INTEGER, [shortChild, longChild]) + + const hotPath = getHotPathFromTree(root) + + expect(hotPath.map(node => node.id)).toEqual([2, 4]) + expect(hotPath[0]).toMatchObject({ + childCnt: 2, + childTypeCnt: 9, + dur: 100, + name: 'longChild', + path: 'src/longChild.ts', + pos: 20, + totalTypeCnt: 12, + typeCnt: 3, + }) + expect(hotPath[1]).toMatchObject({ + childCnt: 0, + childTypeCnt: 7, + dur: 500, + name: 'deeperLongChild', + totalTypeCnt: 9, + typeCnt: 2, + }) + }) +}) diff --git a/ui/components/HotPathChart.vue b/ui/components/HotPathChart.vue new file mode 100644 index 0000000..452f733 --- /dev/null +++ b/ui/components/HotPathChart.vue @@ -0,0 +1,85 @@ + + + diff --git a/ui/components/TreeNode.vue b/ui/components/TreeNode.vue index 997a094..adeb734 100644 --- a/ui/components/TreeNode.vue +++ b/ui/components/TreeNode.vue @@ -1,6 +1,7 @@ @@ -65,6 +81,14 @@ const insetClass = `border-e min-w-2 border-[var(--vscode-tree-inactiveIndentGui
+ + diff --git a/ui/components/UExpand.vue b/ui/components/UExpand.vue index aab511a..b7c824e 100644 --- a/ui/components/UExpand.vue +++ b/ui/components/UExpand.vue @@ -33,6 +33,7 @@ function iconName() {
+
diff --git a/ui/src/appState.ts b/ui/src/appState.ts index 2ca7851..b27211a 100644 --- a/ui/src/appState.ts +++ b/ui/src/appState.ts @@ -4,6 +4,7 @@ import * as Messages from '../../shared/src/messages' export const childrenById = shallowReactive(new Map()) export const typesById = shallowReactive(new Map()) +export const hotPathById = shallowReactive(new Map()) export const nodes = ref([] as Tree[]) export const sortBy = ref('Timestamp' as keyof typeof sortValue) export const projectName = ref('') @@ -28,6 +29,12 @@ function doSort(arr: Tree[]) { watch(sortBy, () => nodes.value = doSort(nodes.value ?? [])) +function clearTreeCaches() { + childrenById.clear() + typesById.clear() + hotPathById.clear() +} + function handleMessage(e: MessageEvent) { const parsed = Messages.message.safeParse(e.data) if (!parsed.success) @@ -50,9 +57,16 @@ function handleMessage(e: MessageEvent) { typesById.set(id, [...types, ...parsed.data.types]) break } + case 'hotPathById': { + if (!parsed.data.nodes) + return + hotPathById.set(parsed.data.id, parsed.data.nodes) + break + } case 'showTree': { switch (parsed.data.step) { case 'start': + clearTreeCaches() nodes.value = [] break case 'add': @@ -84,6 +98,7 @@ function handleMessage(e: MessageEvent) { case 'traceFileLoaded': { const data = parsed.data if (data.resetFileList) { + clearTreeCaches() files.value = [] nodes.value = [] }