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.
This commit is contained in:
Evan Senter
2025-04-19 19:45:42 +01:00
committed by GitHub
parent 0c9e1ef61b
commit 3fce6cea27
46 changed files with 3946 additions and 3403 deletions

View File

@@ -0,0 +1,24 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error;
}
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
} else {
// Attempt to convert the non-Error value to a string for logging
try {
const errorMessage = String(error);
return errorMessage;
} catch {
// If String() itself fails (highly unlikely)
return 'Failed to get error details';
}
}
}

View File

@@ -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)}`;
}
}

View File

@@ -0,0 +1,102 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path'; // Import the 'path' module
/**
* Shortens a path string if it exceeds maxLen, prioritizing the start and end segments.
* Example: /path/to/a/very/long/file.txt -> /path/.../long/file.txt
*/
export function shortenPath(filePath: string, maxLen: number = 35): string {
if (filePath.length <= maxLen) {
return filePath;
}
const parsedPath = path.parse(filePath);
const root = parsedPath.root;
const separator = path.sep;
// Get segments of the path *after* the root
const relativePath = filePath.substring(root.length);
const segments = relativePath.split(separator).filter((s) => s !== ''); // Filter out empty segments
// Handle cases with no segments after root (e.g., "/", "C:\") or only one segment
if (segments.length <= 1) {
// Fallback to simple start/end truncation for very short paths or single segments
const keepLen = Math.floor((maxLen - 3) / 2);
// Ensure keepLen is not negative if maxLen is very small
if (keepLen <= 0) {
return filePath.substring(0, maxLen - 3) + '...';
}
const start = filePath.substring(0, keepLen);
const end = filePath.substring(filePath.length - keepLen);
return `${start}...${end}`;
}
const firstDir = segments[0];
const startComponent = root + firstDir;
const endPartSegments: string[] = [];
// Base length: startComponent + separator + "..."
let currentLength = startComponent.length + separator.length + 3;
// Iterate backwards through segments (excluding the first one)
for (let i = segments.length - 1; i >= 1; i--) {
const segment = segments[i];
// Length needed if we add this segment: current + separator + segment
const lengthWithSegment = currentLength + separator.length + segment.length;
if (lengthWithSegment <= maxLen) {
endPartSegments.unshift(segment); // Add to the beginning of the end part
currentLength = lengthWithSegment;
} else {
// Adding this segment would exceed maxLen
break;
}
}
// Construct the final path
let result = startComponent + separator + '...';
if (endPartSegments.length > 0) {
result += separator + endPartSegments.join(separator);
}
// As a final check, if the result is somehow still too long (e.g., startComponent + ... is too long)
// fallback to simple truncation of the original path
if (result.length > maxLen) {
const keepLen = Math.floor((maxLen - 3) / 2);
if (keepLen <= 0) {
return filePath.substring(0, maxLen - 3) + '...';
}
const start = filePath.substring(0, keepLen);
const end = filePath.substring(filePath.length - keepLen);
return `${start}...${end}`;
}
return result;
}
/**
* Calculates the relative path from a root directory to a target path.
* Ensures both paths are resolved before calculating.
* Returns '.' if the target path is the same as the root directory.
*
* @param targetPath The absolute or relative path to make relative.
* @param rootDirectory The absolute path of the directory to make the target path relative to.
* @returns The relative path from rootDirectory to targetPath.
*/
export function makeRelative(
targetPath: string,
rootDirectory: string,
): string {
const resolvedTargetPath = path.resolve(targetPath);
const resolvedRootDirectory = path.resolve(rootDirectory);
const relativePath = path.relative(resolvedRootDirectory, resolvedTargetPath);
// If the paths are the same, path.relative returns '', return '.' instead
return relativePath || '.';
}

View File

@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Simple utility to validate objects against JSON Schemas
* In a real implementation, you would use a library like Ajv
*/
export class SchemaValidator {
/**
* Validates data against a JSON schema
* @param schema JSON Schema to validate against
* @param data Data to validate
* @returns True if valid, false otherwise
*/
static validate(schema: Record<string, unknown>, data: unknown): boolean {
// This is a simplified implementation
// In a real application, you would use a library like Ajv for proper validation
// Check for required fields
if (schema.required && Array.isArray(schema.required)) {
const required = schema.required as string[];
const dataObj = data as Record<string, unknown>;
for (const field of required) {
if (dataObj[field] === undefined) {
console.error(`Missing required field: ${field}`);
return false;
}
}
}
// Check property types if properties are defined
if (schema.properties && typeof schema.properties === 'object') {
const properties = schema.properties as Record<string, { type?: string }>;
const dataObj = data as Record<string, unknown>;
for (const [key, prop] of Object.entries(properties)) {
if (dataObj[key] !== undefined && prop.type) {
const expectedType = prop.type;
const actualType = Array.isArray(dataObj[key])
? 'array'
: typeof dataObj[key];
if (expectedType !== actualType) {
console.error(
`Type mismatch for property "${key}": expected ${expectedType}, got ${actualType}`,
);
return false;
}
}
}
}
return true;
}
}