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: 2 additions & 0 deletions shared/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ export const filterTree = z.object({
startsWith: z.string(),
sourceFileName: z.string(),
position: z.literal('').or(z.number()),
childStartsWith: z.string().default(''),
excludePathIncludes: z.string().default(''),
})
export type FilterTree = z.infer<typeof filterTree>

Expand Down
2 changes: 1 addition & 1 deletion src/handleMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function handleMessage(panel: vscode.WebviewPanel, message: unknown): voi
log(...data.value)
break
case 'filterTree': {
showTree(data.startsWith, data.sourceFileName, data.position, false)
showTree(data.startsWith, data.sourceFileName, data.position, false, data.childStartsWith, data.excludePathIncludes)
break
}
case 'saveOpen': {
Expand Down
38 changes: 30 additions & 8 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 { getVisibleChildren, isPathExcluded, parseFilterList } from './treeFilters'

export interface Tree { id: number, line: TraceLine, children: Tree[], types: TypeLine[], childCnt: number, childTypeCnt: number, typeCnt: number }
function getRoot(): Tree {
Expand Down Expand Up @@ -80,13 +81,32 @@ export async function processTraceFiles() {
traceTree = toTree(Object.values(traceFiles.value).flat(1), workspacePath)
}

export function filterTree(startsWith: string, sourceFileName: string, position: number | '', tree = traceTree): Tree[] {
let activeChildFilters = { childStartsWith: '', excludePathIncludes: '' }

function getSkinnyNode(node: Tree): Tree {
return {
...node,
children: [],
types: [],
childCnt: getVisibleChildren(node, activeChildFilters).length,
}
}

export function filterTree(startsWith: string, sourceFileName: string, position: number | '', _childStartsWith = '', excludePathIncludes = '', tree = traceTree): Tree[] {
if (position === '')
position = 0

const pathExcludes = parseFilterList(excludePathIncludes)
return filterTreeInner(startsWith, sourceFileName, position, pathExcludes, tree)
}

function filterTreeInner(startsWith: string, sourceFileName: string, position: number, pathExcludes: readonly string[], tree = traceTree): Tree[] {
if (!tree)
return []

if (isPathExcluded(tree.line.args?.path, pathExcludes))
return []

if (
('name' in tree.line && tree.line.name.startsWith(startsWith))
&& (!sourceFileName || ((tree.line.args?.path ?? '').endsWith(sourceFileName)))
Expand All @@ -95,21 +115,22 @@ export function filterTree(startsWith: string, sourceFileName: string, position:
return [tree]
}

return tree.children.map(child => filterTree(startsWith, sourceFileName, position, child)).flat()
return tree.children.map(child => filterTreeInner(startsWith, sourceFileName, position, pathExcludes, child)).flat()
}

export const treeIdNodes = new Map<number, Tree>()
let showTreeInterval: undefined | ReturnType<typeof setInterval>
export function showTree(startsWith: string, sourceFileName: string, position: number | '', updateUi = true, tree = traceTree) {
export function showTree(startsWith: string, sourceFileName: string, position: number | '', updateUi = true, childStartsWith = '', excludePathIncludes = '', tree = traceTree) {
if (showTreeInterval) {
clearInterval(showTreeInterval)
showTreeInterval = undefined
}

const nodes = filterTree(startsWith, sourceFileName, position, tree)
const skinnyNodes = nodes.map(x => ({ ...x, children: [], types: [] }))
const nodes = filterTree(startsWith, sourceFileName, position, childStartsWith, excludePathIncludes, tree)
activeChildFilters = { childStartsWith, excludePathIncludes }
const skinnyNodes = nodes.map(getSkinnyNode)
if (updateUi)
postMessage({ message: 'filterTree', startsWith, sourceFileName, position })
postMessage({ message: 'filterTree', startsWith, sourceFileName, position, childStartsWith, excludePathIncludes })

postMessage({ message: 'showTree', nodes: [], step: 'start' })

Expand All @@ -134,11 +155,12 @@ export function showTree(startsWith: string, sourceFileName: string, position: n
}

export function getChildrenById(id: number) {
const nodes = treeIdNodes.get(id)?.children ?? []
const node = treeIdNodes.get(id)
const nodes = node ? getVisibleChildren(node, activeChildFilters) : []
const ret: typeof nodes = []
nodes.forEach((node) => {
treeIdNodes.set(node.id, node)
ret.push({ ...node, children: [], types: [] })
ret.push(getSkinnyNode(node))
})
return ret
}
Expand Down
50 changes: 50 additions & 0 deletions src/treeFilters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export interface FilterTreeNode {
line: {
name?: string
args?: {
path?: string
}
}
children: FilterTreeNode[]
}

export interface TreeChildFilters {
childStartsWith: string
excludePathIncludes: string
}

export function parseFilterList(value: string): string[] {
return value
.split(/[,\n]/)
.map(x => x.trim().toLowerCase())
.filter(Boolean)
}

export function isPathExcluded(path: string | undefined, excludes: readonly string[]): boolean {
if (!path || excludes.length === 0)
return false

const normalizedPath = path.toLowerCase()
return excludes.some(exclude => normalizedPath.includes(exclude))
}

function matchesChildName(node: FilterTreeNode, childStartsWith: string): boolean {
return !childStartsWith || !!node.line.name?.startsWith(childStartsWith)
}

export function hasVisibleChildBranch(node: FilterTreeNode, childStartsWith: string, excludes: readonly string[]): boolean {
if (isPathExcluded(node.line.args?.path, excludes))
return false

return matchesChildName(node, childStartsWith)
|| node.children.some(child => hasVisibleChildBranch(child, childStartsWith, excludes))
}

export function getVisibleChildren<T extends FilterTreeNode>(node: T, filters: TreeChildFilters): T[] {
const excludes = parseFilterList(filters.excludePathIncludes)
const children = node.children as T[]

return children.filter(child =>
hasVisibleChildBranch(child, filters.childStartsWith, excludes),
)
}
48 changes: 48 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,55 @@
import { describe, expect, it } from 'vitest'
import type { FilterTreeNode } from '../src/treeFilters'
import { getVisibleChildren, hasVisibleChildBranch, isPathExcluded, parseFilterList } from '../src/treeFilters'

describe('should', () => {
it('exported', () => {
expect(1).toEqual(1)
})

it('parses multiline and comma separated filter lists', () => {
expect(parseFilterList('node_modules, src/generated\nlib.dom.d.ts'))
.toEqual(['node_modules', 'src/generated', 'lib.dom.d.ts'])
})

it('matches excluded paths case-insensitively', () => {
const excludes = parseFilterList('node_modules,lib.dom.d.ts')

expect(isPathExcluded('packages/app/node_modules/typescript/lib/lib.dom.d.ts', excludes))
.toBe(true)
expect(isPathExcluded('src/components/Button.tsx', excludes))
.toBe(false)
})

it('keeps ancestors for child trace name matches', () => {
const tree = {
line: { name: 'checkSourceFile', args: { path: 'src/app.ts' } },
children: [
{
line: { name: 'bindSourceFile', args: { path: 'src/app.ts' } },
children: [
{ line: { name: 'checkExpression', args: { path: 'src/app.ts' } }, children: [] },
],
},
],
}

expect(hasVisibleChildBranch(tree.children[0], 'check', [])).toBe(true)
})

it('filters visible children by trace name and excluded path', () => {
const tree: FilterTreeNode = {
line: { name: 'root' },
children: [
{ line: { name: 'checkSourceFile', args: { path: 'src/app.ts' } }, children: [] },
{ line: { name: 'checkSourceFile', args: { path: 'node_modules/lib/index.d.ts' } }, children: [] },
{ line: { name: 'bindSourceFile', args: { path: 'src/other.ts' } }, children: [] },
],
}

expect(getVisibleChildren(tree, {
childStartsWith: 'check',
excludePathIncludes: 'node_modules',
}).map(node => node.line.args?.path)).toEqual(['src/app.ts'])
})
})
62 changes: 55 additions & 7 deletions ui/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,20 @@ const Messages = useNuxtApp().$Messages
const sendMesage = useNuxtApp().$sendMessage

const sortOptions = ['Timestamp', 'Duration', 'Types', 'Total Types'] as const

const filters = useState('treeFilters', () => ({ startsWith: 'check', sourceFileName: '', position: 0 as number | '' }))
const pathFilterPresets = [
{ label: 'No preset', value: '' },
{ label: 'Dependencies', value: 'node_modules' },
{ label: 'TypeScript libs', value: 'node_modules/typescript/lib\nlib.dom.d.ts\nlib.es' },
{ label: 'Generated output', value: 'dist\nbuild\ncoverage\n.generated\ngenerated' },
] as const

const filters = useState('treeFilters', () => ({
startsWith: 'check',
sourceFileName: '',
position: 0 as number | '',
childStartsWith: '',
excludePathIncludes: '',
}))

function setStartsWith(event: any) {
filters.value.startsWith = event.target.value
Expand All @@ -21,26 +33,50 @@ function setPosition(event: any) {
filters.value.position = +event.target.value
}

function setChildStartsWith(event: any) {
filters.value.childStartsWith = event.target.value
}

function setExcludePathIncludes(event: any) {
filters.value.excludePathIncludes = event.target.value
}

function handleMessage(e: MessageEvent<unknown>) {
const message = Messages.message.safeParse(e.data)
if (!message.success)
return

if (message.data.message === 'gotoTracePosition')
filters.value = { startsWith: '', position: message.data.position, sourceFileName: message.data.fileName }

else if (message.data.message === 'filterTree')
if (message.data.message === 'gotoTracePosition') {
filters.value = {
startsWith: '',
position: message.data.position,
sourceFileName: message.data.fileName,
childStartsWith: filters.value.childStartsWith,
excludePathIncludes: filters.value.excludePathIncludes,
}
}

else if (message.data.message === 'filterTree') {
filters.value = message.data
}
}

function updateSort(event: any) {
if (event?.target?.value)
if (event?.target?.value) {
sortBy.value = event.target.value
}
}
function doFilters() {
sendMesage('filterTree', filters.value)
}

function updatePathFilterPreset(event: any) {
const value = event?.target?.value
if (value !== undefined) {
filters.value.excludePathIncludes = value
}
}

onMounted(() => {
useNuxtApp().$initAppState()
useNuxtApp().$initClient()
Expand All @@ -59,6 +95,18 @@ onMounted(() => {
<VTextField v-model="filters.startsWith" label="Trace Name" @change="setStartsWith" />
<VTextField v-model="filters.sourceFileName" label="Source File" @change="setSourceFileName" />
<VTextField v-model="filters.position" label="Position" type="number" @change="setPosition" />
<VTextField v-model="filters.childStartsWith" label="Child Trace Name" @change="setChildStartsWith" />
<VTextField v-model="filters.excludePathIncludes" label="Exclude Paths" @change="setExcludePathIncludes" />
<div class="dropdown-container">
<label for="path-filter-preset">Path Preset</label>
<vscode-dropdown id="path-filter-preset" @change="updatePathFilterPreset">
<template v-for="preset of pathFilterPresets" :key="preset.label">
<vscode-option :value="preset.value">
{{ preset.label }}
</vscode-option>
</template>
</vscode-dropdown>
</div>
<vscode-button class="w-full" @click="doFilters">
Filter Trace <UIcon name="heroicons:magnifying-glass-circle" :dynamic="true" size="20" />
</vscode-button>
Expand Down