2025-04-18 17:44:24 -07:00
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
2025-04-15 21:41:08 -07:00
import * as fs from 'fs/promises' ;
2025-05-22 10:47:21 -07:00
import { Dirent } from 'fs' ;
2025-04-15 21:41:08 -07:00
import * as path from 'path' ;
2025-04-18 17:47:49 -04:00
import { getErrorMessage , isNodeError } from './errors.js' ;
2025-06-13 14:26:31 -04:00
import { FileDiscoveryService } from '../services/fileDiscoveryService.js' ;
2025-07-20 00:55:33 -07:00
import { FileFilteringOptions } from '../config/config.js' ;
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js' ;
2025-04-15 21:41:08 -07:00
const MAX_ITEMS = 200 ;
const TRUNCATION_INDICATOR = '...' ;
const DEFAULT_IGNORED_FOLDERS = new Set ( [ 'node_modules' , '.git' , 'dist' ] ) ;
// --- Interfaces ---
/** Options for customizing folder structure retrieval. */
interface FolderStructureOptions {
/** Maximum number of files and folders combined to display. Defaults to 200. */
maxItems? : number ;
/** Set of folder names to ignore completely. Case-sensitive. */
ignoredFolders? : Set < string > ;
/** Optional regex to filter included files by name. */
fileIncludePattern? : RegExp ;
2025-06-13 14:26:31 -04:00
/** For filtering files. */
fileService? : FileDiscoveryService ;
2025-07-20 00:55:33 -07:00
/** File filtering ignore options. */
fileFilteringOptions? : FileFilteringOptions ;
2025-04-15 21:41:08 -07:00
}
// Define a type for the merged options where fileIncludePattern remains optional
2025-04-17 18:06:21 -04:00
type MergedFolderStructureOptions = Required <
2025-06-13 14:26:31 -04:00
Omit < FolderStructureOptions , 'fileIncludePattern' | 'fileService' >
2025-04-17 18:06:21 -04:00
> & {
fileIncludePattern? : RegExp ;
2025-06-13 14:26:31 -04:00
fileService? : FileDiscoveryService ;
2025-07-20 00:55:33 -07:00
fileFilteringOptions? : FileFilteringOptions ;
2025-04-15 21:41:08 -07:00
} ;
/** Represents the full, unfiltered information about a folder and its contents. */
interface FullFolderInfo {
name : string ;
path : string ;
files : string [ ] ;
subFolders : FullFolderInfo [ ] ;
2025-05-22 10:47:21 -07:00
totalChildren : number ; // Number of files and subfolders included from this folder during BFS scan
totalFiles : number ; // Number of files included from this folder during BFS scan
2025-04-15 21:41:08 -07:00
isIgnored? : boolean ; // Flag to easily identify ignored folders later
hasMoreFiles? : boolean ; // Indicates if files were truncated for this specific folder
hasMoreSubfolders? : boolean ; // Indicates if subfolders were truncated for this specific folder
}
2025-05-22 10:47:21 -07:00
// --- Interfaces ---
2025-04-15 21:41:08 -07:00
// --- Helper Functions ---
async function readFullStructure (
2025-05-22 10:47:21 -07:00
rootPath : string ,
2025-04-17 18:06:21 -04:00
options : MergedFolderStructureOptions ,
2025-04-15 21:41:08 -07:00
) : Promise < FullFolderInfo | null > {
2025-05-22 10:47:21 -07:00
const rootName = path . basename ( rootPath ) ;
const rootNode : FullFolderInfo = {
name : rootName ,
path : rootPath ,
2025-04-15 21:41:08 -07:00
files : [ ] ,
subFolders : [ ] ,
2025-05-22 10:47:21 -07:00
totalChildren : 0 ,
totalFiles : 0 ,
2025-04-15 21:41:08 -07:00
} ;
2025-05-22 10:47:21 -07:00
const queue : Array < { folderInfo : FullFolderInfo ; currentPath : string } > = [
{ folderInfo : rootNode , currentPath : rootPath } ,
] ;
let currentItemCount = 0 ;
// Count the root node itself as one item if we are not just listing its content
2025-04-15 21:41:08 -07:00
2025-05-22 10:47:21 -07:00
const processedPaths = new Set < string > ( ) ; // To avoid processing same path if symlinks create loops
2025-04-15 21:41:08 -07:00
2025-05-22 10:47:21 -07:00
while ( queue . length > 0 ) {
const { folderInfo , currentPath } = queue . shift ( ) ! ;
2025-04-15 21:41:08 -07:00
2025-05-22 10:47:21 -07:00
if ( processedPaths . has ( currentPath ) ) {
continue ;
}
processedPaths . add ( currentPath ) ;
2025-04-15 21:41:08 -07:00
2025-05-22 10:47:21 -07:00
if ( currentItemCount >= options . maxItems ) {
// If the root itself caused us to exceed, we can't really show anything.
// Otherwise, this folder won't be processed further.
// The parent that queued this would have set its own hasMoreSubfolders flag.
continue ;
}
let entries : Dirent [ ] ;
try {
const rawEntries = await fs . readdir ( currentPath , { withFileTypes : true } ) ;
// Sort entries alphabetically by name for consistent processing order
entries = rawEntries . sort ( ( a , b ) = > a . name . localeCompare ( b . name ) ) ;
} catch ( error : unknown ) {
if (
isNodeError ( error ) &&
( error . code === 'EACCES' || error . code === 'ENOENT' )
) {
console . warn (
` Warning: Could not read directory ${ currentPath } : ${ error . message } ` ,
) ;
if ( currentPath === rootPath && error . code === 'ENOENT' ) {
return null ; // Root directory itself not found
2025-04-15 21:41:08 -07:00
}
2025-05-22 10:47:21 -07:00
// For other EACCES/ENOENT on subdirectories, just skip them.
continue ;
2025-04-15 21:41:08 -07:00
}
2025-05-22 10:47:21 -07:00
throw error ;
2025-04-15 21:41:08 -07:00
}
2025-05-22 10:47:21 -07:00
const filesInCurrentDir : string [ ] = [ ] ;
const subFoldersInCurrentDir : FullFolderInfo [ ] = [ ] ;
// Process files first in the current directory
2025-04-15 21:41:08 -07:00
for ( const entry of entries ) {
2025-04-17 18:06:21 -04:00
if ( entry . isFile ( ) ) {
2025-05-22 10:47:21 -07:00
if ( currentItemCount >= options . maxItems ) {
folderInfo . hasMoreFiles = true ;
break ;
}
2025-04-17 18:06:21 -04:00
const fileName = entry . name ;
2025-06-08 18:42:38 -07:00
const filePath = path . join ( currentPath , fileName ) ;
2025-07-20 00:55:33 -07:00
if ( options . fileService ) {
const shouldIgnore =
( options . fileFilteringOptions . respectGitIgnore &&
options . fileService . shouldGitIgnoreFile ( filePath ) ) ||
( options . fileFilteringOptions . respectGeminiIgnore &&
options . fileService . shouldGeminiIgnoreFile ( filePath ) ) ;
if ( shouldIgnore ) {
2025-06-08 18:42:38 -07:00
continue ;
}
}
2025-04-17 18:06:21 -04:00
if (
! options . fileIncludePattern ||
options . fileIncludePattern . test ( fileName )
) {
2025-05-22 10:47:21 -07:00
filesInCurrentDir . push ( fileName ) ;
currentItemCount ++ ;
folderInfo . totalFiles ++ ;
folderInfo . totalChildren ++ ;
2025-04-17 18:06:21 -04:00
}
}
2025-04-15 21:41:08 -07:00
}
2025-05-22 10:47:21 -07:00
folderInfo . files = filesInCurrentDir ;
2025-04-15 21:41:08 -07:00
2025-05-22 10:47:21 -07:00
// Then process directories and queue them
for ( const entry of entries ) {
if ( entry . isDirectory ( ) ) {
// Check if adding this directory ITSELF would meet or exceed maxItems
// (currentItemCount refers to items *already* added before this one)
if ( currentItemCount >= options . maxItems ) {
folderInfo . hasMoreSubfolders = true ;
break ; // Already at limit, cannot add this folder or any more
2025-04-15 21:41:08 -07:00
}
2025-05-22 10:47:21 -07:00
// If adding THIS folder makes us hit the limit exactly, and it might have children,
// it's better to show '...' for the parent, unless this is the very last item slot.
// This logic is tricky. Let's try a simpler: if we can't add this item, mark and break.
const subFolderName = entry . name ;
const subFolderPath = path . join ( currentPath , subFolderName ) ;
2025-04-15 21:41:08 -07:00
2025-07-20 00:55:33 -07:00
let isIgnored = false ;
if ( options . fileService ) {
isIgnored =
( options . fileFilteringOptions . respectGitIgnore &&
options . fileService . shouldGitIgnoreFile ( subFolderPath ) ) ||
( options . fileFilteringOptions . respectGeminiIgnore &&
options . fileService . shouldGeminiIgnoreFile ( subFolderPath ) ) ;
2025-06-08 18:42:38 -07:00
}
2025-07-20 00:55:33 -07:00
if ( options . ignoredFolders . has ( subFolderName ) || isIgnored ) {
2025-05-22 10:47:21 -07:00
const ignoredSubFolder : FullFolderInfo = {
name : subFolderName ,
path : subFolderPath ,
files : [ ] ,
2025-04-17 18:06:21 -04:00
subFolders : [ ] ,
2025-05-22 10:47:21 -07:00
totalChildren : 0 ,
totalFiles : 0 ,
isIgnored : true ,
2025-04-15 21:41:08 -07:00
} ;
2025-05-22 10:47:21 -07:00
subFoldersInCurrentDir . push ( ignoredSubFolder ) ;
currentItemCount ++ ; // Count the ignored folder itself
folderInfo . totalChildren ++ ; // Also counts towards parent's children
continue ;
2025-04-17 18:06:21 -04:00
}
2025-05-22 10:47:21 -07:00
const subFolderNode : FullFolderInfo = {
name : subFolderName ,
path : subFolderPath ,
2025-04-17 18:06:21 -04:00
files : [ ] ,
subFolders : [ ] ,
2025-05-22 10:47:21 -07:00
totalChildren : 0 ,
totalFiles : 0 ,
2025-04-17 18:06:21 -04:00
} ;
2025-05-22 10:47:21 -07:00
subFoldersInCurrentDir . push ( subFolderNode ) ;
currentItemCount ++ ;
folderInfo . totalChildren ++ ; // Counts towards parent's children
// Add to queue for processing its children later
queue . push ( { folderInfo : subFolderNode , currentPath : subFolderPath } ) ;
2025-04-17 18:06:21 -04:00
}
2025-04-15 21:41:08 -07:00
}
2025-05-22 10:47:21 -07:00
folderInfo . subFolders = subFoldersInCurrentDir ;
2025-04-15 21:41:08 -07:00
}
2025-05-22 10:47:21 -07:00
return rootNode ;
2025-04-15 21:41:08 -07:00
}
/**
2025-05-22 10:47:21 -07:00
* Reads the directory structure using BFS, respecting maxItems.
2025-04-15 21:41:08 -07:00
* @param node The current node in the reduced structure.
* @param indent The current indentation string.
* @param isLast Sibling indicator.
* @param builder Array to build the string lines.
*/
2025-05-22 10:47:21 -07:00
function formatStructure (
node : FullFolderInfo ,
currentIndent : string ,
isLastChildOfParent : boolean ,
isProcessingRootNode : boolean ,
2025-04-17 18:06:21 -04:00
builder : string [ ] ,
2025-04-15 21:41:08 -07:00
) : void {
2025-05-22 10:47:21 -07:00
const connector = isLastChildOfParent ? '└───' : '├───' ;
// The root node of the structure (the one passed initially to getFolderStructure)
// is not printed with a connector line itself, only its name as a header.
// Its children are printed relative to that conceptual root.
// Ignored root nodes ARE printed with a connector.
if ( ! isProcessingRootNode || node . isIgnored ) {
builder . push (
2025-07-21 22:21:37 -07:00
` ${ currentIndent } ${ connector } ${ node . name } ${ path . sep } ${ node . isIgnored ? TRUNCATION_INDICATOR : '' } ` ,
2025-05-22 10:47:21 -07:00
) ;
2025-04-15 21:41:08 -07:00
}
2025-05-22 10:47:21 -07:00
// Determine the indent for the children of *this* node.
// If *this* node was the root of the whole structure, its children start with no indent before their connectors.
// Otherwise, children's indent extends from the current node's indent.
const indentForChildren = isProcessingRootNode
? ''
: currentIndent + ( isLastChildOfParent ? ' ' : '│ ' ) ;
2025-04-15 21:41:08 -07:00
2025-05-22 10:47:21 -07:00
// Render files of the current node
2025-04-15 21:41:08 -07:00
const fileCount = node . files . length ;
for ( let i = 0 ; i < fileCount ; i ++ ) {
2025-05-22 10:47:21 -07:00
const isLastFileAmongSiblings =
i === fileCount - 1 &&
node . subFolders . length === 0 &&
! node . hasMoreSubfolders ;
const fileConnector = isLastFileAmongSiblings ? '└───' : '├───' ;
builder . push ( ` ${ indentForChildren } ${ fileConnector } ${ node . files [ i ] } ` ) ;
}
if ( node . hasMoreFiles ) {
const isLastIndicatorAmongSiblings =
node . subFolders . length === 0 && ! node . hasMoreSubfolders ;
const fileConnector = isLastIndicatorAmongSiblings ? '└───' : '├───' ;
builder . push ( ` ${ indentForChildren } ${ fileConnector } ${ TRUNCATION_INDICATOR } ` ) ;
2025-04-15 21:41:08 -07:00
}
2025-05-22 10:47:21 -07:00
// Render subfolders of the current node
2025-04-15 21:41:08 -07:00
const subFolderCount = node . subFolders . length ;
for ( let i = 0 ; i < subFolderCount ; i ++ ) {
2025-05-22 10:47:21 -07:00
const isLastSubfolderAmongSiblings =
i === subFolderCount - 1 && ! node . hasMoreSubfolders ;
// Children are never the root node being processed initially.
formatStructure (
node . subFolders [ i ] ,
indentForChildren ,
isLastSubfolderAmongSiblings ,
false ,
builder ,
) ;
}
if ( node . hasMoreSubfolders ) {
builder . push ( ` ${ indentForChildren } └─── ${ TRUNCATION_INDICATOR } ` ) ;
2025-04-15 21:41:08 -07:00
}
}
// --- Main Exported Function ---
/**
* Generates a string representation of a directory's structure,
* limiting the number of items displayed. Ignored folders are shown
* followed by '...' instead of their contents.
*
* @param directory The absolute or relative path to the directory.
* @param options Optional configuration settings.
* @returns A promise resolving to the formatted folder structure string.
*/
export async function getFolderStructure (
directory : string ,
2025-04-17 18:06:21 -04:00
options? : FolderStructureOptions ,
2025-04-15 21:41:08 -07:00
) : Promise < string > {
const resolvedPath = path . resolve ( directory ) ;
const mergedOptions : MergedFolderStructureOptions = {
maxItems : options?.maxItems ? ? MAX_ITEMS ,
ignoredFolders : options?.ignoredFolders ? ? DEFAULT_IGNORED_FOLDERS ,
fileIncludePattern : options?.fileIncludePattern ,
2025-06-13 14:26:31 -04:00
fileService : options?.fileService ,
2025-07-20 00:55:33 -07:00
fileFilteringOptions :
options?.fileFilteringOptions ? ? DEFAULT_FILE_FILTERING_OPTIONS ,
2025-04-15 21:41:08 -07:00
} ;
try {
2025-05-22 10:47:21 -07:00
// 1. Read the structure using BFS, respecting maxItems
2025-06-13 14:26:31 -04:00
const structureRoot = await readFullStructure ( resolvedPath , mergedOptions ) ;
2025-04-15 21:41:08 -07:00
2025-05-22 10:47:21 -07:00
if ( ! structureRoot ) {
2025-04-15 21:41:08 -07:00
return ` Error: Could not read directory " ${ resolvedPath } ". Check path and permissions. ` ;
}
2025-05-22 10:47:21 -07:00
// 2. Format the structure into a string
2025-04-15 21:41:08 -07:00
const structureLines : string [ ] = [ ] ;
2025-05-22 10:47:21 -07:00
// Pass true for isRoot for the initial call
formatStructure ( structureRoot , '' , true , true , structureLines ) ;
2025-04-15 21:41:08 -07:00
2025-05-22 10:47:21 -07:00
// 3. Build the final output string
2025-07-21 22:21:37 -07:00
function isTruncated ( node : FullFolderInfo ) : boolean {
2025-05-22 10:47:21 -07:00
if ( node . hasMoreFiles || node . hasMoreSubfolders || node . isIgnored ) {
2025-07-21 22:21:37 -07:00
return true ;
2025-05-22 10:47:21 -07:00
}
2025-07-21 22:21:37 -07:00
for ( const sub of node . subFolders ) {
if ( isTruncated ( sub ) ) {
return true ;
2025-05-22 10:47:21 -07:00
}
}
2025-07-21 22:21:37 -07:00
return false ;
2025-05-22 10:47:21 -07:00
}
2025-07-21 22:21:37 -07:00
let summary = ` Showing up to ${ mergedOptions . maxItems } items (files + folders). ` ;
2025-04-15 21:41:08 -07:00
2025-07-21 22:21:37 -07:00
if ( isTruncated ( structureRoot ) ) {
summary += ` Folders or files indicated with ${ TRUNCATION_INDICATOR } contain more items not shown, were ignored, or the display limit ( ${ mergedOptions . maxItems } items) was reached. ` ;
}
2025-04-15 21:41:08 -07:00
2025-07-21 22:21:37 -07:00
return ` ${ summary } \ n \ n ${ resolvedPath } ${ path . sep } \ n ${ structureLines . join ( '\n' ) } ` ;
2025-04-18 17:47:49 -04:00
} catch ( error : unknown ) {
2025-04-15 21:41:08 -07:00
console . error ( ` Error getting folder structure for ${ resolvedPath } : ` , error ) ;
2025-04-18 17:47:49 -04:00
return ` Error processing directory " ${ resolvedPath } ": ${ getErrorMessage ( error ) } ` ;
2025-04-15 21:41:08 -07:00
}
}