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
2 changes: 1 addition & 1 deletion renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
"ignoreDeps": [
"@types/vscode"
]
}
}
22 changes: 22 additions & 0 deletions shared/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,27 @@ export const fileStats = z.object({
})
export type FileStats = z.infer<typeof fileStats>

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<typeof hotPathNode>

export const hotPathById = z.object({
message: z.literal('hotPathById'),
id: z.number(),
nodes: z.array(hotPathNode).optional(),
})
export type HotPathById = z.infer<typeof hotPathById>

export const traceStart = z.object({
message: z.literal('traceStart'),
})
Expand Down Expand Up @@ -167,6 +188,7 @@ export const message = z.union([
gotoLocation,
gotoPosition,
gotoTracePosition,
hotPathById,
positionTypeCounts,
projectNames,
projectOpen,
Expand Down
6 changes: 5 additions & 1 deletion src/handleMessages.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions src/hotPath.ts
Original file line number Diff line number Diff line change
@@ -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<number>()
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
}
6 changes: 6 additions & 0 deletions src/traceTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()

Expand Down
60 changes: 60 additions & 0 deletions test/hotPath.test.ts
Original file line number Diff line number Diff line change
@@ -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,
})
})
})
85 changes: 85 additions & 0 deletions ui/components/HotPathChart.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<script setup lang="ts">
import type { HotPathNode } from '../../shared/src/messages'

const props = defineProps<{ nodes: HotPathNode[] }>()
const emit = defineEmits<{ (e: 'goto-position', node: HotPathNode): void }>()

const maxDur = computed(() => Math.max(1, ...props.nodes.map(node => node.dur)))

function barWidth(node: HotPathNode) {
return `${Math.max(2, (node.dur / maxDur.value) * 100)}%`
}

function typeWidth(count: number, total: number) {
if (total <= 0)
return '0%'

return `${(count / total) * 100}%`
}

function durationMs(dur: number) {
return `${(dur / 1000).toLocaleString(undefined, { maximumFractionDigits: 2 })}ms`
}

function locationText(node: HotPathNode) {
if (!node.path)
return ''

if (node.pos === undefined)
return node.path

return `${node.path}:${node.pos}`
}
</script>

<template>
<div class="ml-6 mr-4 my-1 border-l border-[var(--vscode-tree-indentGuidesStroke)] pl-3 text-xs">
<div v-if="nodes.length === 0" class="py-2 opacity-70">
Loading hot path...
</div>
<div v-else class="flex flex-col gap-1 py-1">
<div class="flex flex-row gap-4 opacity-70">
<span>Longest child path</span>
<span>blue: duration</span>
<span>yellow: local types</span>
<span>purple: child types</span>
</div>
<div v-for="(node, index) of nodes" :key="node.id" class="flex flex-row items-center gap-2 min-h-8">
<span class="w-12 text-right tabular-nums opacity-80">#{{ index + 1 }}</span>
<span class="w-20 text-right tabular-nums">{{ durationMs(node.dur) }}</span>
<div class="min-w-0 grow">
<div class="relative h-6 overflow-hidden border border-[var(--vscode-panel-border)]">
<div
class="absolute left-0 top-0 h-full bg-[var(--vscode-charts-blue,#3794ff)] opacity-40"
:style="{ width: barWidth(node) }"
/>
<div class="relative z-10 flex h-full flex-row items-center gap-3 px-2">
<span class="min-w-0 grow truncate" :title="node.name">{{ node.name }}</span>
<span class="shrink-0 tabular-nums">{{ node.totalTypeCnt }} types</span>
<span class="shrink-0 tabular-nums">{{ node.childCnt }} children</span>
</div>
</div>
<div v-if="node.totalTypeCnt > 0" class="mt-0.5 flex h-1 overflow-hidden">
<div
class="bg-[var(--vscode-charts-yellow,#cca700)]"
:style="{ width: typeWidth(node.typeCnt, node.totalTypeCnt) }"
/>
<div
class="bg-[var(--vscode-charts-purple,#b180d7)]"
:style="{ width: typeWidth(node.childTypeCnt, node.totalTypeCnt) }"
/>
</div>
</div>
<span class="w-56 truncate opacity-70" :title="locationText(node)">{{ locationText(node) }}</span>
<button
v-if="node.path && node.pos !== undefined"
class="mb-1 shrink-0 bg-[var(--vscode-button-background,green)] rounded-sm focus:ring-[var(--vscode-focusBorder,blue)] focus:outline-none focus:ring-1"
title="Go to source position"
@click="emit('goto-position', node)"
>
<UIcon primary name="i-heroicons-arrow-left-on-rectangle" class="relative top-1 hover:backdrop-invert-[10%] hover:invert-[20%] bg-[var(--vscode-button-foreground,white)]" />
</button>
</div>
</div>
</div>
</template>
29 changes: 28 additions & 1 deletion ui/components/TreeNode.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
<script setup lang="ts">
import type { Tree } from '../../src/traceTree'
import { childrenById, typesById } from '~/src/appState'
import type { HotPathNode } from '../../shared/src/messages'
import { childrenById, hotPathById, typesById } from '~/src/appState'

const props = defineProps<{ tree: Tree, depth: number }>()

const sendMessage = useNuxtApp().$sendMessage

const children = computed(() => childrenById.get(props.tree.id) ?? [])
const types = computed(() => typesById.get(props.tree.id) ?? [])
const hotPath = computed(() => hotPathById.get(props.tree.id) ?? [])
const showHotPath = ref(false)

function fetchChildren() {
if (children.value.length === 0)
Expand All @@ -19,6 +22,12 @@ function fetchTypes() {
sendMessage('typesById', { id: props.tree.id })
}

function toggleHotPath() {
showHotPath.value = !showHotPath.value
if (showHotPath.value && hotPath.value.length === 0)
sendMessage('hotPathById', { id: props.tree.id })
}

function gotoPosition() {
if ('name' in props.tree.line) {
const { path, pos } = props.tree.line.args ?? { path: undefined, pos: undefined }
Expand All @@ -29,6 +38,13 @@ function gotoPosition() {
}
}

function gotoHotPathPosition(node: HotPathNode) {
if (!node.path || node.pos === undefined)
return

sendMessage('gotoPosition', { fileName: node.path, pos: node.pos })
}

const insetClass = `border-e min-w-2 border-[var(--vscode-tree-inactiveIndentGuidesStroke)] hover:border-[var(--vscode-tree-indentGuidesStroke)]`
</script>

Expand Down Expand Up @@ -65,6 +81,14 @@ const insetClass = `border-e min-w-2 border-[var(--vscode-tree-inactiveIndentGui
</div>

<div class="flex flex-row justify-self-end justify-evenly">
<button
v-if="props.tree.childCnt > 0"
class="mr-2 pb-1 mb-1 bg-[var(--vscode-button-background,green)] rounded-sm focus:ring-[var(--vscode-focusBorder,blue)] focus:outline-none focus:ring-1"
title="Show longest-running child path"
@click.stop="toggleHotPath"
>
<UIcon primary name="i-heroicons-chart-bar-square" class="relative top-1 hover:backdrop-invert-[10%] hover:invert-[20%] bg-[var(--vscode-button-foreground,white)]" />
</button>
<UExpand v-if="props.tree.typeCnt > 0" class="min-w-40" @expand="fetchTypes">
<template #label>
<span class="pl-1">{{ `Types: ${props.tree.typeCnt}` }} {{ `${props.tree.childTypeCnt || props.tree.typeCnt ? `/ ${props.tree.childTypeCnt + props.tree.typeCnt}` : ''}` }}</span>
Expand All @@ -75,6 +99,9 @@ const insetClass = `border-e min-w-2 border-[var(--vscode-tree-inactiveIndentGui
</div>
</div>
</template>
<template #panel>
<HotPathChart v-if="showHotPath" :nodes="hotPath" @goto-position="gotoHotPathPosition" />
</template>
<template v-for="(node, idx) of children" :key="idx">
<TreeNode :depth="depth + 1" :tree="node" />
</template>
Expand Down
1 change: 1 addition & 0 deletions ui/components/UExpand.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function iconName() {
</button>
<slot name="label" />
</div>
<slot name="panel" />
<div v-if="expanded">
<slot />
</div>
Expand Down
15 changes: 15 additions & 0 deletions ui/src/appState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as Messages from '../../shared/src/messages'

export const childrenById = shallowReactive(new Map<number, Tree[]>())
export const typesById = shallowReactive(new Map<number, TypeLine[]>())
export const hotPathById = shallowReactive(new Map<number, Messages.HotPathNode[]>())
export const nodes = ref([] as Tree[])
export const sortBy = ref('Timestamp' as keyof typeof sortValue)
export const projectName = ref('')
Expand All @@ -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<unknown>) {
const parsed = Messages.message.safeParse(e.data)
if (!parsed.success)
Expand All @@ -50,9 +57,16 @@ function handleMessage(e: MessageEvent<unknown>) {
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':
Expand Down Expand Up @@ -84,6 +98,7 @@ function handleMessage(e: MessageEvent<unknown>) {
case 'traceFileLoaded': {
const data = parsed.data
if (data.resetFileList) {
clearTreeCaches()
files.value = []
nodes.value = []
}
Expand Down