File size: 12,516 Bytes
40e575e |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 |
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs/promises';
import { Dirent } from 'fs';
import * as path from 'path';
import { getErrorMessage, isNodeError } from './errors.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
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;
/** For filtering files. */
fileService?: FileDiscoveryService;
/** Whether to use .gitignore patterns. */
respectGitIgnore?: boolean;
}
// Define a type for the merged options where fileIncludePattern remains optional
type MergedFolderStructureOptions = Required<
Omit<FolderStructureOptions, 'fileIncludePattern' | 'fileService'>
> & {
fileIncludePattern?: RegExp;
fileService?: FileDiscoveryService;
};
/** Represents the full, unfiltered information about a folder and its contents. */
interface FullFolderInfo {
name: string;
path: string;
files: string[];
subFolders: FullFolderInfo[];
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
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
}
// --- Interfaces ---
// --- Helper Functions ---
async function readFullStructure(
rootPath: string,
options: MergedFolderStructureOptions,
): Promise<FullFolderInfo | null> {
const rootName = path.basename(rootPath);
const rootNode: FullFolderInfo = {
name: rootName,
path: rootPath,
files: [],
subFolders: [],
totalChildren: 0,
totalFiles: 0,
};
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
const processedPaths = new Set<string>(); // To avoid processing same path if symlinks create loops
while (queue.length > 0) {
const { folderInfo, currentPath } = queue.shift()!;
if (processedPaths.has(currentPath)) {
continue;
}
processedPaths.add(currentPath);
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
}
// For other EACCES/ENOENT on subdirectories, just skip them.
continue;
}
throw error;
}
const filesInCurrentDir: string[] = [];
const subFoldersInCurrentDir: FullFolderInfo[] = [];
// Process files first in the current directory
for (const entry of entries) {
if (entry.isFile()) {
if (currentItemCount >= options.maxItems) {
folderInfo.hasMoreFiles = true;
break;
}
const fileName = entry.name;
const filePath = path.join(currentPath, fileName);
if (options.respectGitIgnore && options.fileService) {
if (options.fileService.shouldGitIgnoreFile(filePath)) {
continue;
}
}
if (
!options.fileIncludePattern ||
options.fileIncludePattern.test(fileName)
) {
filesInCurrentDir.push(fileName);
currentItemCount++;
folderInfo.totalFiles++;
folderInfo.totalChildren++;
}
}
}
folderInfo.files = filesInCurrentDir;
// 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
}
// 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);
let isIgnoredByGit = false;
if (options.respectGitIgnore && options.fileService) {
if (options.fileService.shouldGitIgnoreFile(subFolderPath)) {
isIgnoredByGit = true;
}
}
if (options.ignoredFolders.has(subFolderName) || isIgnoredByGit) {
const ignoredSubFolder: FullFolderInfo = {
name: subFolderName,
path: subFolderPath,
files: [],
subFolders: [],
totalChildren: 0,
totalFiles: 0,
isIgnored: true,
};
subFoldersInCurrentDir.push(ignoredSubFolder);
currentItemCount++; // Count the ignored folder itself
folderInfo.totalChildren++; // Also counts towards parent's children
continue;
}
const subFolderNode: FullFolderInfo = {
name: subFolderName,
path: subFolderPath,
files: [],
subFolders: [],
totalChildren: 0,
totalFiles: 0,
};
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 });
}
}
folderInfo.subFolders = subFoldersInCurrentDir;
}
return rootNode;
}
/**
* Reads the directory structure using BFS, respecting maxItems.
* @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.
*/
function formatStructure(
node: FullFolderInfo,
currentIndent: string,
isLastChildOfParent: boolean,
isProcessingRootNode: boolean,
builder: string[],
): void {
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(
`${currentIndent}${connector}${node.name}/${node.isIgnored ? TRUNCATION_INDICATOR : ''}`,
);
}
// 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 ? ' ' : 'β ');
// Render files of the current node
const fileCount = node.files.length;
for (let i = 0; i < fileCount; i++) {
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}`);
}
// Render subfolders of the current node
const subFolderCount = node.subFolders.length;
for (let i = 0; i < subFolderCount; i++) {
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}`);
}
}
// --- 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,
options?: FolderStructureOptions,
): Promise<string> {
const resolvedPath = path.resolve(directory);
const mergedOptions: MergedFolderStructureOptions = {
maxItems: options?.maxItems ?? MAX_ITEMS,
ignoredFolders: options?.ignoredFolders ?? DEFAULT_IGNORED_FOLDERS,
fileIncludePattern: options?.fileIncludePattern,
fileService: options?.fileService,
respectGitIgnore: options?.respectGitIgnore ?? true,
};
try {
// 1. Read the structure using BFS, respecting maxItems
const structureRoot = await readFullStructure(resolvedPath, mergedOptions);
if (!structureRoot) {
return `Error: Could not read directory "${resolvedPath}". Check path and permissions.`;
}
// 2. Format the structure into a string
const structureLines: string[] = [];
// Pass true for isRoot for the initial call
formatStructure(structureRoot, '', true, true, structureLines);
// 3. Build the final output string
const displayPath = resolvedPath.replace(/\\/g, '/');
let disclaimer = '';
// Check if truncation occurred anywhere or if ignored folders are present.
// A simple check: if any node indicates more files/subfolders, or is ignored.
let truncationOccurred = false;
function checkForTruncation(node: FullFolderInfo) {
if (node.hasMoreFiles || node.hasMoreSubfolders || node.isIgnored) {
truncationOccurred = true;
}
if (!truncationOccurred) {
for (const sub of node.subFolders) {
checkForTruncation(sub);
if (truncationOccurred) break;
}
}
}
checkForTruncation(structureRoot);
if (truncationOccurred) {
disclaimer = `Folders or files indicated with ${TRUNCATION_INDICATOR} contain more items not shown, were ignored, or the display limit (${mergedOptions.maxItems} items) was reached.`;
}
const summary =
`Showing up to ${mergedOptions.maxItems} items (files + folders). ${disclaimer}`.trim();
const output = `${summary}\n\n${displayPath}/\n${structureLines.join('\n')}`;
return output;
} catch (error: unknown) {
console.error(`Error getting folder structure for ${resolvedPath}:`, error);
return `Error processing directory "${resolvedPath}": ${getErrorMessage(error)}`;
}
}
|