/** * VFS Tree Utilities * Provides safe tree operations that prevent common recursion issues */ import { VFSEntity, VFSDirent } from './types.js' export interface TreeNode { name: string path: string type: 'file' | 'directory' entityId?: string children?: TreeNode[] metadata?: any } export interface TreeOptions { maxDepth?: number includeHidden?: boolean filter?: (node: VFSEntity) => boolean sort?: 'name' | 'modified' | 'size' expandAll?: boolean } /** * Tree utility functions for VFS * These functions ensure proper tree structure without recursion issues */ export class VFSTreeUtils { /** * Build a safe tree structure from VFS entities * Guarantees no directory appears as its own child */ static buildTree( entities: VFSEntity[], rootPath: string = '/', options: TreeOptions = {} ): TreeNode { const pathToEntity = new Map() const pathToNode = new Map() // First pass: index all entities by path for (const entity of entities) { const path = entity.metadata.path // Critical: Skip if entity IS the root we're building from if (path === rootPath) { continue } pathToEntity.set(path, entity) } // Create root node const rootNode: TreeNode = { name: rootPath === '/' ? 'root' : rootPath.split('/').pop()!, path: rootPath, type: 'directory', children: [] } pathToNode.set(rootPath, rootNode) // Second pass: build tree structure const sortedPaths = Array.from(pathToEntity.keys()).sort() for (const path of sortedPaths) { const entity = pathToEntity.get(path)! // Apply filter if provided if (options.filter && !options.filter(entity)) { continue } // Skip hidden files if requested if (!options.includeHidden && entity.metadata.name.startsWith('.')) { continue } // Create node for this entity const node: TreeNode = { name: entity.metadata.name, path: entity.metadata.path, type: entity.metadata.vfsType === 'directory' ? 'directory' : 'file', entityId: entity.id, metadata: entity.metadata } if (entity.metadata.vfsType === 'directory') { node.children = [] } pathToNode.set(path, node) // Find parent and attach const parentPath = this.getParentPath(path) const parentNode = pathToNode.get(parentPath) if (parentNode && parentNode.children) { parentNode.children.push(node) } } // Sort children if requested if (options.sort) { this.sortTreeNodes(rootNode, options.sort) } // Apply depth limit if specified if (options.maxDepth !== undefined) { this.limitDepth(rootNode, options.maxDepth) } return rootNode } /** * Get direct children only - guaranteed no self-inclusion */ static getDirectChildren( entities: VFSEntity[], parentPath: string ): VFSEntity[] { const children: VFSEntity[] = [] const parentDepth = parentPath === '/' ? 0 : parentPath.split('/').length - 1 for (const entity of entities) { const path = entity.metadata.path // Critical check 1: Skip if this IS the parent if (path === parentPath) { continue } // Check if entity is a direct child if (path.startsWith(parentPath)) { const relativePath = parentPath === '/' ? path.substring(1) : path.substring(parentPath.length + 1) // Direct child has no additional slashes if (!relativePath.includes('/')) { children.push(entity) } } } return children } /** * Get all descendants (recursive children) */ static getDescendants( entities: VFSEntity[], ancestorPath: string, includeAncestor: boolean = false ): VFSEntity[] { const descendants: VFSEntity[] = [] for (const entity of entities) { const path = entity.metadata.path // Include ancestor only if explicitly requested if (path === ancestorPath) { if (includeAncestor) { descendants.push(entity) } continue } // Check if entity is under ancestor path const prefix = ancestorPath === '/' ? '/' : ancestorPath + '/' if (path.startsWith(prefix)) { descendants.push(entity) } } return descendants } /** * Flatten a tree structure back to a list */ static flattenTree(node: TreeNode): TreeNode[] { const result: TreeNode[] = [node] if (node.children) { for (const child of node.children) { result.push(...this.flattenTree(child)) } } return result } /** * Find a node in the tree by path */ static findNode(root: TreeNode, targetPath: string): TreeNode | null { if (root.path === targetPath) { return root } if (root.children) { for (const child of root.children) { const found = this.findNode(child, targetPath) if (found) return found } } return null } /** * Calculate tree statistics */ static getTreeStats(node: TreeNode): { totalNodes: number files: number directories: number maxDepth: number totalSize?: number } { let stats = { totalNodes: 0, files: 0, directories: 0, maxDepth: 0, totalSize: 0 } function traverse(n: TreeNode, depth: number) { stats.totalNodes++ stats.maxDepth = Math.max(stats.maxDepth, depth) if (n.type === 'file') { stats.files++ if (n.metadata?.size) { stats.totalSize += n.metadata.size } } else { stats.directories++ } if (n.children) { for (const child of n.children) { traverse(child, depth + 1) } } } traverse(node, 0) return stats } // Helper methods private static getParentPath(path: string): string { if (path === '/') return '/' const parts = path.split('/') parts.pop() return parts.length === 1 ? '/' : parts.join('/') } private static sortTreeNodes(node: TreeNode, sortBy: 'name' | 'modified' | 'size'): void { if (!node.children) return node.children.sort((a, b) => { // Directories first, then files if (a.type !== b.type) { return a.type === 'directory' ? -1 : 1 } switch (sortBy) { case 'name': return a.name.localeCompare(b.name) case 'modified': const aTime = a.metadata?.modified || 0 const bTime = b.metadata?.modified || 0 return bTime - aTime case 'size': const aSize = a.metadata?.size || 0 const bSize = b.metadata?.size || 0 return bSize - aSize default: return 0 } }) // Recursively sort children for (const child of node.children) { this.sortTreeNodes(child, sortBy) } } private static limitDepth(node: TreeNode, maxDepth: number, currentDepth: number = 0): void { if (currentDepth >= maxDepth) { delete node.children return } if (node.children) { for (const child of node.children) { this.limitDepth(child, maxDepth, currentDepth + 1) } } } /** * Validate tree structure - ensures no recursion */ static validateTree(node: TreeNode, visited: Set = new Set()): { valid: boolean errors: string[] } { const errors: string[] = [] // Check for cycles if (visited.has(node.path)) { errors.push(`Cycle detected at path: ${node.path}`) return { valid: false, errors } } visited.add(node.path) // Check children if (node.children) { const childPaths = new Set() for (const child of node.children) { // Check for duplicate children if (childPaths.has(child.path)) { errors.push(`Duplicate child path: ${child.path}`) } childPaths.add(child.path) // Check child is not parent if (child.path === node.path) { errors.push(`Directory contains itself: ${node.path}`) } // Check child is actually under parent if (node.path !== '/' && !child.path.startsWith(node.path + '/')) { errors.push(`Child ${child.path} not under parent ${node.path}`) } // Recursively validate children const childValidation = this.validateTree(child, new Set(visited)) errors.push(...childValidation.errors) } } return { valid: errors.length === 0, errors } } }