From 2ecc1d52289c9fd61cf237175dc9e644019ed347 Mon Sep 17 00:00:00 2001 From: Shorty Date: Wed, 20 May 2026 12:31:57 +0200 Subject: [PATCH] Add two-stage trace filtering --- shared/src/messages.ts | 2 ++ src/handleMessages.ts | 2 +- src/traceTree.ts | 38 ++++++++++++++++++++------ src/treeFilters.ts | 50 ++++++++++++++++++++++++++++++++++ test/index.test.ts | 48 ++++++++++++++++++++++++++++++++ ui/app.vue | 62 +++++++++++++++++++++++++++++++++++++----- 6 files changed, 186 insertions(+), 16 deletions(-) create mode 100644 src/treeFilters.ts diff --git a/shared/src/messages.ts b/shared/src/messages.ts index 70c04e7..cb610cc 100644 --- a/shared/src/messages.ts +++ b/shared/src/messages.ts @@ -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 diff --git a/src/handleMessages.ts b/src/handleMessages.ts index 4f321c3..8bbed95 100644 --- a/src/handleMessages.ts +++ b/src/handleMessages.ts @@ -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': { diff --git a/src/traceTree.ts b/src/traceTree.ts index 0618d33..1af6c50 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 { 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 { @@ -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))) @@ -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() let showTreeInterval: undefined | ReturnType -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' }) @@ -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 } diff --git a/src/treeFilters.ts b/src/treeFilters.ts new file mode 100644 index 0000000..0939767 --- /dev/null +++ b/src/treeFilters.ts @@ -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(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), + ) +} diff --git a/test/index.test.ts b/test/index.test.ts index 401553c..f40fab6 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -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']) + }) }) diff --git a/ui/app.vue b/ui/app.vue index bbd026b..c2be8bd 100644 --- a/ui/app.vue +++ b/ui/app.vue @@ -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 @@ -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) { 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() @@ -59,6 +95,18 @@ onMounted(() => { + + + Filter Trace