|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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']); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
interface FolderStructureOptions { |
|
|
|
|
|
maxItems?: number; |
|
|
|
|
|
ignoredFolders?: Set<string>; |
|
|
|
|
|
fileIncludePattern?: RegExp; |
|
|
|
|
|
fileService?: FileDiscoveryService; |
|
|
|
|
|
respectGitIgnore?: boolean; |
|
|
} |
|
|
|
|
|
|
|
|
type MergedFolderStructureOptions = Required< |
|
|
Omit<FolderStructureOptions, 'fileIncludePattern' | 'fileService'> |
|
|
> & { |
|
|
fileIncludePattern?: RegExp; |
|
|
fileService?: FileDiscoveryService; |
|
|
}; |
|
|
|
|
|
|
|
|
interface FullFolderInfo { |
|
|
name: string; |
|
|
path: string; |
|
|
files: string[]; |
|
|
subFolders: FullFolderInfo[]; |
|
|
totalChildren: number; |
|
|
totalFiles: number; |
|
|
isIgnored?: boolean; |
|
|
hasMoreFiles?: boolean; |
|
|
hasMoreSubfolders?: boolean; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
const processedPaths = new Set<string>(); |
|
|
|
|
|
while (queue.length > 0) { |
|
|
const { folderInfo, currentPath } = queue.shift()!; |
|
|
|
|
|
if (processedPaths.has(currentPath)) { |
|
|
continue; |
|
|
} |
|
|
processedPaths.add(currentPath); |
|
|
|
|
|
if (currentItemCount >= options.maxItems) { |
|
|
|
|
|
|
|
|
|
|
|
continue; |
|
|
} |
|
|
|
|
|
let entries: Dirent[]; |
|
|
try { |
|
|
const rawEntries = await fs.readdir(currentPath, { withFileTypes: true }); |
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
continue; |
|
|
} |
|
|
throw error; |
|
|
} |
|
|
|
|
|
const filesInCurrentDir: string[] = []; |
|
|
const subFoldersInCurrentDir: FullFolderInfo[] = []; |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
for (const entry of entries) { |
|
|
if (entry.isDirectory()) { |
|
|
|
|
|
|
|
|
if (currentItemCount >= options.maxItems) { |
|
|
folderInfo.hasMoreSubfolders = true; |
|
|
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++; |
|
|
folderInfo.totalChildren++; |
|
|
continue; |
|
|
} |
|
|
|
|
|
const subFolderNode: FullFolderInfo = { |
|
|
name: subFolderName, |
|
|
path: subFolderPath, |
|
|
files: [], |
|
|
subFolders: [], |
|
|
totalChildren: 0, |
|
|
totalFiles: 0, |
|
|
}; |
|
|
subFoldersInCurrentDir.push(subFolderNode); |
|
|
currentItemCount++; |
|
|
folderInfo.totalChildren++; |
|
|
|
|
|
|
|
|
queue.push({ folderInfo: subFolderNode, currentPath: subFolderPath }); |
|
|
} |
|
|
} |
|
|
folderInfo.subFolders = subFoldersInCurrentDir; |
|
|
} |
|
|
|
|
|
return rootNode; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function formatStructure( |
|
|
node: FullFolderInfo, |
|
|
currentIndent: string, |
|
|
isLastChildOfParent: boolean, |
|
|
isProcessingRootNode: boolean, |
|
|
builder: string[], |
|
|
): void { |
|
|
const connector = isLastChildOfParent ? 'ββββ' : 'ββββ'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!isProcessingRootNode || node.isIgnored) { |
|
|
builder.push( |
|
|
`${currentIndent}${connector}${node.name}/${node.isIgnored ? TRUNCATION_INDICATOR : ''}`, |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const indentForChildren = isProcessingRootNode |
|
|
? '' |
|
|
: currentIndent + (isLastChildOfParent ? ' ' : 'β '); |
|
|
|
|
|
|
|
|
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}`); |
|
|
} |
|
|
|
|
|
|
|
|
const subFolderCount = node.subFolders.length; |
|
|
for (let i = 0; i < subFolderCount; i++) { |
|
|
const isLastSubfolderAmongSiblings = |
|
|
i === subFolderCount - 1 && !node.hasMoreSubfolders; |
|
|
|
|
|
formatStructure( |
|
|
node.subFolders[i], |
|
|
indentForChildren, |
|
|
isLastSubfolderAmongSiblings, |
|
|
false, |
|
|
builder, |
|
|
); |
|
|
} |
|
|
if (node.hasMoreSubfolders) { |
|
|
builder.push(`${indentForChildren}ββββ${TRUNCATION_INDICATOR}`); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 { |
|
|
|
|
|
const structureRoot = await readFullStructure(resolvedPath, mergedOptions); |
|
|
|
|
|
if (!structureRoot) { |
|
|
return `Error: Could not read directory "${resolvedPath}". Check path and permissions.`; |
|
|
} |
|
|
|
|
|
|
|
|
const structureLines: string[] = []; |
|
|
|
|
|
formatStructure(structureRoot, '', true, true, structureLines); |
|
|
|
|
|
|
|
|
const displayPath = resolvedPath.replace(/\\/g, '/'); |
|
|
|
|
|
let disclaimer = ''; |
|
|
|
|
|
|
|
|
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)}`; |
|
|
} |
|
|
} |
|
|
|