/ utils / suggestions / directoryCompletion.ts
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  }