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
48 changes: 46 additions & 2 deletions packages/client/src/components/graph/GraphNavbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,61 @@ const selectableItems = [
] as const

const filterId = graphFilterNodeId

// Pathfinding mode
const pathfindingMode = graphPathfindingMode
const pathfindingStart = graphPathfindingStart
const pathfindingEnd = graphPathfindingEnd
const nodesCount = graphNodeCount
const edgesCount = graphEdgeCount

function togglePathfindingMode() {
pathfindingMode.value = !pathfindingMode.value
if (!pathfindingStart.value && text.value) {
pathfindingStart.value = text.value
}
}

function swapStartAndEnd() {
const start = pathfindingStart.value
pathfindingStart.value = pathfindingEnd.value
pathfindingEnd.value = start
}
</script>

<template>
<div flex="~ items-center gap-4 nowrap" class="[&_>*]:flex-[0_0_auto]" absolute left-0 top-0 z-10 navbar-base w-full overflow-x-auto glass-effect px4 text-sm>
<VueInput v-model="text" placeholder="Search modules..." />
<!-- Toggle Pathfinding Mode Button -->
<button
rounded-full px3 py1 text-xs hover:op100
:class="pathfindingMode ? 'bg-primary-500 text-white op100' : 'bg-gray:20 op50'"
@click="togglePathfindingMode"
>
<div flex="~ items-center gap-1">
<div i-carbon-tree-view-alt />
<span>Pathfinding</span>
</div>
</button>

<!-- Pathfinding Mode Inputs -->
<template v-if="pathfindingMode">
<VueInput v-model="pathfindingStart" placeholder="Start module..." />
<button i-carbon-arrow-right rounded-full op50 hover:text-primary-500 hover:op100 @click="swapStartAndEnd" />
<VueInput v-model="pathfindingEnd" placeholder="End module..." />
</template>

<!-- Normal Search Mode Input -->
<VueInput v-else v-model="text" placeholder="Search modules..." />

<div v-for="item in selectableItems" :key="item[0]" flex="~ gap-2 items-center">
<VueCheckbox v-model="settings[item[0]]" />
<span :class="{ 'text-gray-400 dark:text-gray-600': !settings[item[0]] }">Show {{ item[1] ?? item[0] }}</span>
</div>
<div flex-auto />
<button v-if="filterId" rounded-full bg-gray:20 py1 pl3 pr2 text-xs op50 hover:op100 @click="filterId = ''">
<div>
nodes: {{ nodesCount }} | edges: {{ edgesCount }}
</div>
<button v-if="!pathfindingMode && filterId" rounded-full bg-gray:20 py1 pl3 pr2 text-xs op50 hover:op100 @click="filterId = ''">
Clear filter
<div i-carbon-close mb2px />
</button>
Expand Down
270 changes: 270 additions & 0 deletions packages/client/src/composables/__test__/graph-pathfinding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import type { ModuleInfo } from '@vue/devtools-core'
import type { Edge } from 'vis-network'
import { beforeEach, describe, expect, it } from 'vitest'
import { dfs } from '../graph'

// Mock test data
interface GraphNodesTotalData {
mod: ModuleInfo
info: {
displayName: string
displayPath: string
}
node: any
edges: Edge[]
}

function createMockModule(id: string, displayName: string, deps: string[]): GraphNodesTotalData {
return {
mod: {
id,
deps,
virtual: false,
} as ModuleInfo,
info: {
displayName,
displayPath: id,
},
node: {
id,
label: displayName,
},
edges: deps.map(dep => ({
from: id,
to: dep,
arrows: { to: { enabled: true } },
})),
}
}

describe('dfs - graph pathfinding', () => {
let modulesMap: Map<string, GraphNodesTotalData>

beforeEach(() => {
// Build dependency graph:
// main.ts → App.vue → Header.vue → utils.ts
// └→ router.ts → routes.ts → utils.ts
modulesMap = new Map()

modulesMap.set('/src/main.ts', createMockModule(
'/src/main.ts',
'main.ts',
['/src/App.vue', '/src/router.ts'],
))

modulesMap.set('/src/App.vue', createMockModule(
'/src/App.vue',
'App.vue',
['/src/components/Header.vue'],
))

modulesMap.set('/src/components/Header.vue', createMockModule(
'/src/components/Header.vue',
'Header.vue',
['/src/utils/utils.ts'],
))

modulesMap.set('/src/router.ts', createMockModule(
'/src/router.ts',
'router.ts',
['/src/routes.ts'],
))

modulesMap.set('/src/routes.ts', createMockModule(
'/src/routes.ts',
'routes.ts',
['/src/utils/utils.ts'],
))

modulesMap.set('/src/utils/utils.ts', createMockModule(
'/src/utils/utils.ts',
'utils.ts',
[],
))
})

it('should find all nodes and edges in a single path', () => {
const startNode = modulesMap.get('/src/main.ts')!
const targetIds = new Set(['/src/components/Header.vue'])
const result: [Set<GraphNodesTotalData>, Set<Edge>] = [new Set(), new Set()]

const found = dfs(startNode, targetIds, new Set(), modulesMap, result)

expect(found).toBe(true)

const [nodes, edges] = result
const nodeIds = Array.from(nodes).map(n => n.mod.id).sort()

// Should contain path: main.ts → App.vue → Header.vue
expect(nodeIds).toEqual([
'/src/App.vue',
'/src/components/Header.vue',
'/src/main.ts',
])

// Should contain 2 edges
const edgeDescriptions = Array.from(edges).map(e => `${e.from}→${e.to}`).sort()
expect(edgeDescriptions).toEqual([
'/src/App.vue→/src/components/Header.vue',
'/src/main.ts→/src/App.vue',
])
})

it('should find all nodes and edges across multiple paths without duplicates', () => {
const startNode = modulesMap.get('/src/main.ts')!
const targetIds = new Set(['/src/utils/utils.ts'])
const result: [Set<GraphNodesTotalData>, Set<Edge>] = [new Set(), new Set()]

const found = dfs(startNode, targetIds, new Set(), modulesMap, result)

expect(found).toBe(true)

const [nodes, edges] = result
const nodeIds = Array.from(nodes).map(n => n.mod.id).sort()

// Should contain nodes from both paths:
// Path 1: main.ts → App.vue → Header.vue → utils.ts
// Path 2: main.ts → router.ts → routes.ts → utils.ts
expect(nodeIds).toEqual([
'/src/App.vue',
'/src/components/Header.vue',
'/src/main.ts',
'/src/router.ts',
'/src/routes.ts',
'/src/utils/utils.ts',
])

// Should contain edges from both paths
const edgeDescriptions = Array.from(edges).map(e => `${e.from}→${e.to}`).sort()
expect(edgeDescriptions).toEqual([
'/src/App.vue→/src/components/Header.vue',
'/src/components/Header.vue→/src/utils/utils.ts',
'/src/main.ts→/src/App.vue',
'/src/main.ts→/src/router.ts',
'/src/router.ts→/src/routes.ts',
'/src/routes.ts→/src/utils/utils.ts',
])
})

it('should return empty result when no path exists', () => {
// utils.ts has no dependencies, cannot reach main.ts
const startNode = modulesMap.get('/src/utils/utils.ts')!
const targetIds = new Set(['/src/main.ts'])
const result: [Set<GraphNodesTotalData>, Set<Edge>] = [new Set(), new Set()]

const found = dfs(startNode, targetIds, new Set(), modulesMap, result)

expect(found).toBe(false)

const [nodes, edges] = result
expect(nodes.size).toBe(0)
expect(edges.size).toBe(0)
})

it('should return only the start node when it is also the target', () => {
const startNode = modulesMap.get('/src/main.ts')!
const targetIds = new Set(['/src/main.ts'])
const result: [Set<GraphNodesTotalData>, Set<Edge>] = [new Set(), new Set()]

const found = dfs(startNode, targetIds, new Set(), modulesMap, result)

expect(found).toBe(true)

const [nodes, edges] = result
expect(nodes.size).toBe(1)
expect(Array.from(nodes)[0].mod.id).toBe('/src/main.ts')
expect(edges.size).toBe(0) // No edges, no traversal needed
})

it('should handle circular dependencies without infinite loop', () => {
// Build circular dependency: a → b → c → a
const circularMap = new Map<string, GraphNodesTotalData>()
circularMap.set('/src/a.ts', createMockModule('/src/a.ts', 'a.ts', ['/src/b.ts']))
circularMap.set('/src/b.ts', createMockModule('/src/b.ts', 'b.ts', ['/src/c.ts']))
circularMap.set('/src/c.ts', createMockModule('/src/c.ts', 'c.ts', ['/src/a.ts']))

const startNode = circularMap.get('/src/a.ts')!
const targetIds = new Set(['/src/c.ts'])
const result: [Set<GraphNodesTotalData>, Set<Edge>] = [new Set(), new Set()]

const found = dfs(startNode, targetIds, new Set(), circularMap, result)

expect(found).toBe(true)

const [nodes, edges] = result
const nodeIds = Array.from(nodes).map(n => n.mod.id).sort()

// Should contain a → b → c
expect(nodeIds).toEqual(['/src/a.ts', '/src/b.ts', '/src/c.ts'])

// Should not loop infinitely
const edgeDescriptions = Array.from(edges).map(e => `${e.from}→${e.to}`).sort()
expect(edgeDescriptions).toEqual([
'/src/a.ts→/src/b.ts',
'/src/b.ts→/src/c.ts',
])
})

it('should support multiple target nodes', () => {
const startNode = modulesMap.get('/src/main.ts')!
// Set two targets: Header.vue and routes.ts
const targetIds = new Set(['/src/components/Header.vue', '/src/routes.ts'])
const result: [Set<GraphNodesTotalData>, Set<Edge>] = [new Set(), new Set()]

const found = dfs(startNode, targetIds, new Set(), modulesMap, result)

expect(found).toBe(true)

const [nodes, edges] = result
const nodeIds = Array.from(nodes).map(n => n.mod.id).sort()

// Should contain paths to both targets
expect(nodeIds).toEqual([
'/src/App.vue',
'/src/components/Header.vue',
'/src/main.ts',
'/src/router.ts',
'/src/routes.ts',
])

const edgeDescriptions = Array.from(edges).map(e => `${e.from}→${e.to}`).sort()
expect(edgeDescriptions).toEqual([
'/src/App.vue→/src/components/Header.vue',
'/src/main.ts→/src/App.vue',
'/src/main.ts→/src/router.ts',
'/src/router.ts→/src/routes.ts',
])
})

it('should return false when node is null', () => {
const targetIds = new Set(['/src/main.ts'])
const result: [Set<GraphNodesTotalData>, Set<Edge>] = [new Set(), new Set()]

const found = dfs(null as any, targetIds, new Set(), modulesMap, result)

expect(found).toBe(false)
expect(result[0].size).toBe(0)
expect(result[1].size).toBe(0)
})

it('should return cached result for already visited nodes', () => {
const startNode = modulesMap.get('/src/main.ts')!
const targetIds = new Set(['/src/utils/utils.ts'])
const existingNodeIds = new Set<GraphNodesTotalData>()
const result: [Set<GraphNodesTotalData>, Set<Edge>] = [new Set(), new Set()]

// First call
dfs(startNode, targetIds, existingNodeIds, modulesMap, result)

const firstCallNodeCount = result[0].size
const firstCallEdgeCount = result[1].size

// Second call with same start node
const found = dfs(startNode, targetIds, existingNodeIds, modulesMap, result)

// Should return cached result without adding duplicates
expect(found).toBe(true)
expect(result[0].size).toBe(firstCallNodeCount)
expect(result[1].size).toBe(firstCallEdgeCount)
})
})
Loading
Loading