source dump of claude code
at main 263 lines 7.1 kB view raw
1import { LRUCache } from 'lru-cache' 2import { basename, dirname, join, sep } from 'path' 3import type { SuggestionItem } from 'src/components/PromptInput/PromptInputFooterSuggestions.js' 4import { getCwd } from 'src/utils/cwd.js' 5import { getFsImplementation } from 'src/utils/fsOperations.js' 6import { logError } from 'src/utils/log.js' 7import { expandPath } from 'src/utils/path.js' 8// Types 9export type DirectoryEntry = { 10 name: string 11 path: string 12 type: 'directory' 13} 14 15export type PathEntry = { 16 name: string 17 path: string 18 type: 'directory' | 'file' 19} 20 21export type CompletionOptions = { 22 basePath?: string 23 maxResults?: number 24} 25 26export type PathCompletionOptions = CompletionOptions & { 27 includeFiles?: boolean 28 includeHidden?: boolean 29} 30 31type ParsedPath = { 32 directory: string 33 prefix: string 34} 35 36// Cache configuration 37const CACHE_SIZE = 500 38const CACHE_TTL = 5 * 60 * 1000 // 5 minutes 39 40// Initialize LRU cache for directory scans 41const 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) 47const 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 */ 55export 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 */ 84export 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 */ 121export 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 */ 145export function clearDirectoryCache(): void { 146 directoryCache.clear() 147} 148 149/** 150 * Checks if a string looks like a path (starts with path-like prefixes) 151 */ 152export 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 */ 168export 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 */ 208export 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 */ 260export function clearPathCache(): void { 261 directoryCache.clear() 262 pathCache.clear() 263}