directoryCompletion.ts
1 import { LRUCache } from 'lru-cache' 2 import { basename, dirname, join, sep } from 'path' 3 import type { SuggestionItem } from 'src/components/PromptInput/PromptInputFooterSuggestions.js' 4 import { getCwd } from 'src/utils/cwd.js' 5 import { getFsImplementation } from 'src/utils/fsOperations.js' 6 import { logError } from 'src/utils/log.js' 7 import { expandPath } from 'src/utils/path.js' 8 // Types 9 export type DirectoryEntry = { 10 name: string 11 path: string 12 type: 'directory' 13 } 14 15 export type PathEntry = { 16 name: string 17 path: string 18 type: 'directory' | 'file' 19 } 20 21 export type CompletionOptions = { 22 basePath?: string 23 maxResults?: number 24 } 25 26 export type PathCompletionOptions = CompletionOptions & { 27 includeFiles?: boolean 28 includeHidden?: boolean 29 } 30 31 type ParsedPath = { 32 directory: string 33 prefix: string 34 } 35 36 // Cache configuration 37 const CACHE_SIZE = 500 38 const CACHE_TTL = 5 * 60 * 1000 // 5 minutes 39 40 // Initialize LRU cache for directory scans 41 const directoryCache = new LRUCache<string, DirectoryEntry[]>({ 42 max: CACHE_SIZE, 43 ttl: CACHE_TTL, 44 }) 45 46 // Initialize LRU cache for path scans (files and directories) 47 const pathCache = new LRUCache<string, PathEntry[]>({ 48 max: CACHE_SIZE, 49 ttl: CACHE_TTL, 50 }) 51 52 /** 53 * Parses a partial path into directory and prefix components 54 */ 55 export function parsePartialPath( 56 partialPath: string, 57 basePath?: string, 58 ): ParsedPath { 59 // Handle empty input 60 if (!partialPath) { 61 const directory = basePath || getCwd() 62 return { directory, prefix: '' } 63 } 64 65 const resolved = expandPath(partialPath, basePath) 66 67 // If path ends with separator, treat as directory with no prefix 68 // Handle both forward slash and platform-specific separator 69 if (partialPath.endsWith('/') || partialPath.endsWith(sep)) { 70 return { directory: resolved, prefix: '' } 71 } 72 73 // Split into directory and prefix 74 const directory = dirname(resolved) 75 const prefix = basename(partialPath) 76 77 return { directory, prefix } 78 } 79 80 /** 81 * Scans a directory and returns subdirectories 82 * Uses LRU cache to avoid repeated filesystem calls 83 */ 84 export async function scanDirectory( 85 dirPath: string, 86 ): Promise<DirectoryEntry[]> { 87 // Check cache first 88 const cached = directoryCache.get(dirPath) 89 if (cached) { 90 return cached 91 } 92 93 try { 94 // Read directory contents 95 const fs = getFsImplementation() 96 const entries = await fs.readdir(dirPath) 97 98 // Filter for directories only, exclude hidden directories 99 const directories = entries 100 .filter(entry => entry.isDirectory() && !entry.name.startsWith('.')) 101 .map(entry => ({ 102 name: entry.name, 103 path: join(dirPath, entry.name), 104 type: 'directory' as const, 105 })) 106 .slice(0, 100) // Limit results for MVP 107 108 // Cache the results 109 directoryCache.set(dirPath, directories) 110 111 return directories 112 } catch (error) { 113 logError(error) 114 return [] 115 } 116 } 117 118 /** 119 * Main function to get directory completion suggestions 120 */ 121 export async function getDirectoryCompletions( 122 partialPath: string, 123 options: CompletionOptions = {}, 124 ): Promise<SuggestionItem[]> { 125 const { basePath = getCwd(), maxResults = 10 } = options 126 127 const { directory, prefix } = parsePartialPath(partialPath, basePath) 128 const entries = await scanDirectory(directory) 129 const prefixLower = prefix.toLowerCase() 130 const matches = entries 131 .filter(entry => entry.name.toLowerCase().startsWith(prefixLower)) 132 .slice(0, maxResults) 133 134 return matches.map(entry => ({ 135 id: entry.path, 136 displayText: entry.name + '/', 137 description: 'directory', 138 metadata: { type: 'directory' as const }, 139 })) 140 } 141 142 /** 143 * Clears the directory cache 144 */ 145 export function clearDirectoryCache(): void { 146 directoryCache.clear() 147 } 148 149 /** 150 * Checks if a string looks like a path (starts with path-like prefixes) 151 */ 152 export function isPathLikeToken(token: string): boolean { 153 return ( 154 token.startsWith('~/') || 155 token.startsWith('/') || 156 token.startsWith('./') || 157 token.startsWith('../') || 158 token === '~' || 159 token === '.' || 160 token === '..' 161 ) 162 } 163 164 /** 165 * Scans a directory and returns both files and subdirectories 166 * Uses LRU cache to avoid repeated filesystem calls 167 */ 168 export async function scanDirectoryForPaths( 169 dirPath: string, 170 includeHidden = false, 171 ): Promise<PathEntry[]> { 172 const cacheKey = `${dirPath}:${includeHidden}` 173 const cached = pathCache.get(cacheKey) 174 if (cached) { 175 return cached 176 } 177 178 try { 179 const fs = getFsImplementation() 180 const entries = await fs.readdir(dirPath) 181 182 const paths = entries 183 .filter(entry => includeHidden || !entry.name.startsWith('.')) 184 .map(entry => ({ 185 name: entry.name, 186 path: join(dirPath, entry.name), 187 type: entry.isDirectory() ? ('directory' as const) : ('file' as const), 188 })) 189 .sort((a, b) => { 190 // Sort directories first, then alphabetically 191 if (a.type === 'directory' && b.type !== 'directory') return -1 192 if (a.type !== 'directory' && b.type === 'directory') return 1 193 return a.name.localeCompare(b.name) 194 }) 195 .slice(0, 100) 196 197 pathCache.set(cacheKey, paths) 198 return paths 199 } catch (error) { 200 logError(error) 201 return [] 202 } 203 } 204 205 /** 206 * Get path completion suggestions for files and directories 207 */ 208 export async function getPathCompletions( 209 partialPath: string, 210 options: PathCompletionOptions = {}, 211 ): Promise<SuggestionItem[]> { 212 const { 213 basePath = getCwd(), 214 maxResults = 10, 215 includeFiles = true, 216 includeHidden = false, 217 } = options 218 219 const { directory, prefix } = parsePartialPath(partialPath, basePath) 220 const entries = await scanDirectoryForPaths(directory, includeHidden) 221 const prefixLower = prefix.toLowerCase() 222 223 const matches = entries 224 .filter(entry => { 225 if (!includeFiles && entry.type === 'file') return false 226 return entry.name.toLowerCase().startsWith(prefixLower) 227 }) 228 .slice(0, maxResults) 229 230 // Construct relative path based on original partialPath 231 // e.g., if partialPath is "src/c", directory portion is "src/" 232 // Strip leading "./" since it's just used for cwd search 233 // Handle both forward slash and platform separator for Windows compatibility 234 const hasSeparator = partialPath.includes('/') || partialPath.includes(sep) 235 let dirPortion = '' 236 if (hasSeparator) { 237 // Find the last separator (either / or platform-specific) 238 const lastSlash = partialPath.lastIndexOf('/') 239 const lastSep = partialPath.lastIndexOf(sep) 240 const lastSeparatorPos = Math.max(lastSlash, lastSep) 241 dirPortion = partialPath.substring(0, lastSeparatorPos + 1) 242 } 243 if (dirPortion.startsWith('./') || dirPortion.startsWith('.' + sep)) { 244 dirPortion = dirPortion.slice(2) 245 } 246 247 return matches.map(entry => { 248 const fullPath = dirPortion + entry.name 249 return { 250 id: fullPath, 251 displayText: entry.type === 'directory' ? fullPath + '/' : fullPath, 252 metadata: { type: entry.type }, 253 } 254 }) 255 } 256 257 /** 258 * Clears both directory and path caches 259 */ 260 export function clearPathCache(): void { 261 directoryCache.clear() 262 pathCache.clear() 263 }