summaryrefslogtreecommitdiff
path: root/packages/server/src/utils/getFolderStructure.ts
diff options
context:
space:
mode:
authorEvan Senter <[email protected]>2025-04-19 19:45:42 +0100
committerGitHub <[email protected]>2025-04-19 19:45:42 +0100
commit3fce6cea27d3e6129d6c06e528b62e1b11bf7094 (patch)
tree244b8e9ab94f902d65d4bda8739a6538e377ed17 /packages/server/src/utils/getFolderStructure.ts
parent0c9e1ef61be7db53e6e73b7208b649cd8cbed6c3 (diff)
Starting to modularize into separate cli / server packages. (#55)
* Starting to move a lot of code into packages/server * More of the massive refactor, builds and runs, some issues though. * Fixing outstanding issue with double messages. * Fixing a minor UI issue. * Fixing the build post-merge. * Running formatting. * Addressing comments.
Diffstat (limited to 'packages/server/src/utils/getFolderStructure.ts')
-rw-r--r--packages/server/src/utils/getFolderStructure.ts389
1 files changed, 389 insertions, 0 deletions
diff --git a/packages/server/src/utils/getFolderStructure.ts b/packages/server/src/utils/getFolderStructure.ts
new file mode 100644
index 00000000..0caabe0f
--- /dev/null
+++ b/packages/server/src/utils/getFolderStructure.ts
@@ -0,0 +1,389 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as fs from 'fs/promises';
+import * as path from 'path';
+import { getErrorMessage, isNodeError } from './errors.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;
+}
+
+// Define a type for the merged options where fileIncludePattern remains optional
+type MergedFolderStructureOptions = Required<
+ Omit<FolderStructureOptions, 'fileIncludePattern'>
+> & {
+ fileIncludePattern?: RegExp;
+};
+
+/** Represents the full, unfiltered information about a folder and its contents. */
+interface FullFolderInfo {
+ name: string;
+ path: string;
+ files: string[];
+ subFolders: FullFolderInfo[];
+ totalChildren: number; // Total files + subfolders recursively
+ totalFiles: number; // Total files recursively
+ isIgnored?: boolean; // Flag to easily identify ignored folders later
+}
+
+/** Represents the potentially truncated structure used for display. */
+interface ReducedFolderNode {
+ name: string; // Folder name
+ isRoot?: boolean;
+ files: string[]; // File names, might end with '...'
+ subFolders: ReducedFolderNode[]; // Subfolders, might be truncated
+ hasMoreFiles?: boolean; // Indicates if files were truncated for this specific folder
+ hasMoreSubfolders?: boolean; // Indicates if subfolders were truncated for this specific folder
+}
+
+// --- Helper Functions ---
+
+/**
+ * Recursively reads the full directory structure without truncation.
+ * Ignored folders are included but not recursed into.
+ * @param folderPath The absolute path to the folder.
+ * @param options Configuration options.
+ * @returns A promise resolving to the FullFolderInfo or null if access denied/not found.
+ */
+async function readFullStructure(
+ folderPath: string,
+ options: MergedFolderStructureOptions,
+): Promise<FullFolderInfo | null> {
+ const name = path.basename(folderPath);
+ // Initialize with isIgnored: false
+ const folderInfo: Omit<FullFolderInfo, 'totalChildren' | 'totalFiles'> = {
+ name,
+ path: folderPath,
+ files: [],
+ subFolders: [],
+ isIgnored: false,
+ };
+
+ let totalChildrenCount = 0;
+ let totalFileCount = 0;
+
+ try {
+ const entries = await fs.readdir(folderPath, { withFileTypes: true });
+
+ // Process directories first
+ for (const entry of entries) {
+ if (entry.isDirectory()) {
+ const subFolderName = entry.name;
+ const subFolderPath = path.join(folderPath, subFolderName);
+
+ // Check if the folder should be ignored
+ if (options.ignoredFolders.has(subFolderName)) {
+ // Add ignored folder node but don't recurse
+ const ignoredFolderInfo: FullFolderInfo = {
+ name: subFolderName,
+ path: subFolderPath,
+ files: [],
+ subFolders: [],
+ totalChildren: 0, // No children explored
+ totalFiles: 0, // No files explored
+ isIgnored: true, // Mark as ignored
+ };
+ folderInfo.subFolders.push(ignoredFolderInfo);
+ // Skip recursion for this folder
+ continue;
+ }
+
+ // If not ignored, recurse as before
+ const subFolderInfo = await readFullStructure(subFolderPath, options);
+ // Add non-empty folders OR explicitly ignored folders
+ if (
+ subFolderInfo &&
+ (subFolderInfo.totalChildren > 0 ||
+ subFolderInfo.files.length > 0 ||
+ subFolderInfo.isIgnored)
+ ) {
+ folderInfo.subFolders.push(subFolderInfo);
+ }
+ }
+ }
+
+ // Then process files (only if the current folder itself isn't marked as ignored)
+ for (const entry of entries) {
+ if (entry.isFile()) {
+ const fileName = entry.name;
+ // Include if no pattern or if pattern matches
+ if (
+ !options.fileIncludePattern ||
+ options.fileIncludePattern.test(fileName)
+ ) {
+ folderInfo.files.push(fileName);
+ }
+ }
+ }
+
+ // Calculate totals *after* processing children
+ // Ignored folders contribute 0 to counts here because we didn't look inside.
+ totalFileCount =
+ folderInfo.files.length +
+ folderInfo.subFolders.reduce((sum, sf) => sum + sf.totalFiles, 0);
+ // Count the ignored folder itself as one child item in the parent's count.
+ totalChildrenCount =
+ folderInfo.files.length +
+ folderInfo.subFolders.length +
+ folderInfo.subFolders.reduce((sum, sf) => sum + sf.totalChildren, 0);
+ } catch (error: unknown) {
+ if (
+ isNodeError(error) &&
+ (error.code === 'EACCES' || error.code === 'ENOENT')
+ ) {
+ console.warn(
+ `Warning: Could not read directory ${folderPath}: ${error.message}`,
+ );
+ return null;
+ }
+ throw error;
+ }
+
+ return {
+ ...(folderInfo as FullFolderInfo), // Cast needed after conditional assignment check
+ totalChildren: totalChildrenCount,
+ totalFiles: totalFileCount,
+ };
+}
+
+/**
+ * Reduces the full folder structure based on the maxItems limit using BFS.
+ * Handles explicitly ignored folders by showing them with a truncation indicator.
+ * @param fullInfo The complete folder structure info.
+ * @param maxItems The maximum number of items (files + folders) to include.
+ * @param ignoredFolders The set of folder names that were ignored during the read phase.
+ * @returns The root node of the reduced structure.
+ */
+function reduceStructure(
+ fullInfo: FullFolderInfo,
+ maxItems: number,
+): ReducedFolderNode {
+ const rootReducedNode: ReducedFolderNode = {
+ name: fullInfo.name,
+ files: [],
+ subFolders: [],
+ isRoot: true,
+ };
+ const queue: Array<{
+ original: FullFolderInfo;
+ reduced: ReducedFolderNode;
+ }> = [];
+
+ // Don't count the root itself towards the limit initially
+ queue.push({ original: fullInfo, reduced: rootReducedNode });
+ let itemCount = 0; // Count folders + files added to the reduced structure
+
+ while (queue.length > 0) {
+ const { original: originalFolder, reduced: reducedFolder } = queue.shift()!;
+
+ // If the folder being processed was itself marked as ignored (shouldn't happen for root)
+ if (originalFolder.isIgnored) {
+ continue;
+ }
+
+ // Process Files
+ let fileLimitReached = false;
+ for (const file of originalFolder.files) {
+ // Check limit *before* adding the file
+ if (itemCount >= maxItems) {
+ if (!fileLimitReached) {
+ reducedFolder.files.push(TRUNCATION_INDICATOR);
+ reducedFolder.hasMoreFiles = true;
+ fileLimitReached = true;
+ }
+ break;
+ }
+ reducedFolder.files.push(file);
+ itemCount++;
+ }
+
+ // Process Subfolders
+ let subfolderLimitReached = false;
+ for (const subFolder of originalFolder.subFolders) {
+ // Count the folder itself towards the limit
+ itemCount++;
+ if (itemCount > maxItems) {
+ if (!subfolderLimitReached) {
+ // Add a placeholder node ONLY if we haven't already added one
+ const truncatedSubfolderNode: ReducedFolderNode = {
+ name: subFolder.name,
+ files: [TRUNCATION_INDICATOR], // Generic truncation
+ subFolders: [],
+ hasMoreFiles: true,
+ };
+ reducedFolder.subFolders.push(truncatedSubfolderNode);
+ reducedFolder.hasMoreSubfolders = true;
+ subfolderLimitReached = true;
+ }
+ continue; // Stop processing further subfolders for this parent
+ }
+
+ // Handle explicitly ignored folders identified during the read phase
+ if (subFolder.isIgnored) {
+ const ignoredReducedNode: ReducedFolderNode = {
+ name: subFolder.name,
+ files: [TRUNCATION_INDICATOR], // Indicate contents ignored/truncated
+ subFolders: [],
+ hasMoreFiles: true, // Mark as truncated
+ };
+ reducedFolder.subFolders.push(ignoredReducedNode);
+ // DO NOT add the ignored folder to the queue for further processing
+ } else {
+ // If not ignored and within limit, create the reduced node and add to queue
+ const reducedSubFolder: ReducedFolderNode = {
+ name: subFolder.name,
+ files: [],
+ subFolders: [],
+ };
+ reducedFolder.subFolders.push(reducedSubFolder);
+ queue.push({ original: subFolder, reduced: reducedSubFolder });
+ }
+ }
+ }
+
+ return rootReducedNode;
+}
+
+/** Calculates the total number of items present in the reduced structure. */
+function countReducedItems(node: ReducedFolderNode): number {
+ let count = 0;
+ // Count files, treating '...' as one item if present
+ count += node.files.length;
+
+ // Count subfolders and recursively count their contents
+ count += node.subFolders.length;
+ for (const sub of node.subFolders) {
+ // Check if it's a placeholder ignored/truncated node
+ const isTruncatedPlaceholder =
+ sub.files.length === 1 &&
+ sub.files[0] === TRUNCATION_INDICATOR &&
+ sub.subFolders.length === 0;
+
+ if (!isTruncatedPlaceholder) {
+ count += countReducedItems(sub);
+ }
+ // Don't add count for items *inside* the placeholder node itself.
+ }
+ return count;
+}
+
+/**
+ * Formats the reduced folder structure into a tree-like string.
+ * (No changes needed in this function)
+ * @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 formatReducedStructure(
+ node: ReducedFolderNode,
+ indent: string,
+ isLast: boolean,
+ builder: string[],
+): void {
+ const connector = isLast ? '└───' : '├───';
+ const linePrefix = indent + connector;
+
+ // Don't print the root node's name directly, only its contents
+ if (!node.isRoot) {
+ builder.push(`${linePrefix}${node.name}/`);
+ }
+
+ const childIndent = indent + (isLast || node.isRoot ? ' ' : '│ '); // Use " " if last, "│" otherwise
+
+ // Render files
+ const fileCount = node.files.length;
+ for (let i = 0; i < fileCount; i++) {
+ const isLastFile = i === fileCount - 1 && node.subFolders.length === 0;
+ const fileConnector = isLastFile ? '└───' : '├───';
+ builder.push(`${childIndent}${fileConnector}${node.files[i]}`);
+ }
+
+ // Render subfolders
+ const subFolderCount = node.subFolders.length;
+ for (let i = 0; i < subFolderCount; i++) {
+ const isLastSub = i === subFolderCount - 1;
+ formatReducedStructure(node.subFolders[i], childIndent, isLastSub, builder);
+ }
+}
+
+// --- 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,
+ };
+
+ try {
+ // 1. Read the full structure (includes ignored folders marked as such)
+ const fullInfo = await readFullStructure(resolvedPath, mergedOptions);
+
+ if (!fullInfo) {
+ return `Error: Could not read directory "${resolvedPath}". Check path and permissions.`;
+ }
+
+ // 2. Reduce the structure (handles ignored folders specifically)
+ const reducedRoot = reduceStructure(fullInfo, mergedOptions.maxItems);
+
+ // 3. Count items in the *reduced* structure for the summary
+ const rootNodeItselfCount = 0; // Don't count the root node in the items summary
+ const reducedItemCount =
+ countReducedItems(reducedRoot) - rootNodeItselfCount;
+
+ // 4. Format the reduced structure into a string
+ const structureLines: string[] = [];
+ formatReducedStructure(reducedRoot, '', true, structureLines);
+
+ // 5. Build the final output string
+ const displayPath = resolvedPath.replace(/\\/g, '/');
+ const totalOriginalChildren = fullInfo.totalChildren;
+
+ let disclaimer = '';
+ // Check if any truncation happened OR if ignored folders were present
+ if (
+ reducedItemCount < totalOriginalChildren ||
+ fullInfo.subFolders.some((sf) => sf.isIgnored)
+ ) {
+ disclaimer = `Folders or files indicated with ${TRUNCATION_INDICATOR} contain more items not shown or were ignored.`;
+ }
+
+ const summary =
+ `Showing ${reducedItemCount} of ${totalOriginalChildren} items (files + folders). ${disclaimer}`.trim();
+
+ return `${summary}\n\n${displayPath}/\n${structureLines.join('\n')}`;
+ } catch (error: unknown) {
+ console.error(`Error getting folder structure for ${resolvedPath}:`, error);
+ return `Error processing directory "${resolvedPath}": ${getErrorMessage(error)}`;
+ }
+}