UNPKG

modscan

Version:

A CLI tool to scan and manage node_modules directories

1,314 lines (1,153 loc) 33.7 kB
#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const blessed = require('blessed'); const os = require('os'); // Parse command line arguments const args = process.argv.slice(2); // Show help if requested if (args.includes('--help') || args.includes('-h')) { console.log(` ModScan - Find and manage node_modules directories USAGE: modscan [OPTIONS] OPTIONS: -g, --global Perform a system-wide scan for node_modules directories -h, --help Display this help message EXAMPLES: modscan Scan the current directory and its subdirectories modscan -g Scan the entire system for node_modules directories KEYBOARD SHORTCUTS: ↑/↓ Navigate between entries ENTER or R Start/restart scanning D or dd Delete the selected node_modules directory S Sort modules by size, date, name, or path F Filter modules (all, active, deleted, large, old) Q or q Quit the application `); process.exit(0); } const isGlobalScan = args.includes('--global') || args.includes('-g'); // Create a screen object const screen = blessed.screen({ smartCSR: true, title: isGlobalScan ? 'ModScan (Global)' : 'ModScan', transparent: true, fullUnicode: true }); // Get terminal dimensions const terminalWidth = process.stdout.columns || 80; const terminalHeight = process.stdout.rows || 24; // Function to calculate column widths based on terminal size function calculateColumnWidths() { const availableWidth = screen.width; // Account for 1-space separators between columns (4 spaces total for 4 separators) const usableWidth = availableWidth - 4; // Set percentages for each column (should sum to 100) const widths = { status: Math.floor(usableWidth * 0.12) + 1, // Add 1 for separator project: Math.floor(usableWidth * 0.20) + 1, path: Math.floor(usableWidth * 0.45) + 1, size: Math.floor(usableWidth * 0.10) + 1, modified: Math.floor(usableWidth * 0.13) }; return widths; } // Initial column widths let columnWidths = calculateColumnWidths(); // Create UI elements with minimalist design const mainBox = blessed.box({ top: 0, left: 0, width: '100%', height: '100%', style: { fg: 'default', bg: 'transparent' } }); // Compact header with stats const headerBox = blessed.box({ parent: mainBox, top: 0, left: 0, width: '100%', height: 2, tags: true, style: { fg: 'default', bg: 'transparent' } }); // First row: title and key stats const titleText = blessed.text({ parent: headerBox, top: 0, left: 0, content: isGlobalScan ? 'MODSCAN (GLOBAL)' : 'MODSCAN', style: { fg: 'default', bold: true } }); // Stats in single row with compact format const statsText = blessed.text({ parent: headerBox, top: 0, right: 0, tags: true, content: 'NODE_MODULES: 0 | Size: 0 B | Freed: 0 B', style: { fg: 'default' } }); // Status message text (one line only) const statusText = blessed.text({ parent: mainBox, top: 1, left: 0, width: '100%', content: 'Press ENTER to start scanning...', tags: true, style: { fg: 'default' } }); // Table header const tableHeader = blessed.box({ parent: mainBox, top: 2, left: 0, width: '100%', height: 1, tags: true, style: { fg: 'default', bg: 'transparent' } }); const statusHeader = blessed.text({ parent: tableHeader, left: 0, width: columnWidths.status, content: 'STATUS', style: { fg: 'default', bold: true } }); const projectHeader = blessed.text({ parent: tableHeader, left: columnWidths.status, width: columnWidths.project, content: 'PROJECT', style: { fg: 'default', bold: true } }); const pathHeader = blessed.text({ parent: tableHeader, left: columnWidths.status + columnWidths.project, width: columnWidths.path, content: 'PATH', style: { fg: 'default', bold: true } }); const sizeHeader = blessed.text({ parent: tableHeader, left: columnWidths.status + columnWidths.project + columnWidths.path, width: columnWidths.size, content: 'SIZE', style: { fg: 'default', bold: true } }); const modifiedHeader = blessed.text({ parent: tableHeader, left: columnWidths.status + columnWidths.project + columnWidths.path + columnWidths.size, width: columnWidths.modified, content: 'MODIFIED', style: { fg: 'default', bold: true } }); // Thin separator line blessed.line({ parent: mainBox, top: 3, left: 0, width: '100%', orientation: 'horizontal', type: 'line', ch: '─', style: { fg: 'default' } }); // Table/list for node_modules - more space for content const list = blessed.list({ parent: mainBox, top: 4, left: 0, width: '100%', height: '100%-5', tags: true, keys: true, vi: true, mouse: true, style: { fg: 'default', bg: 'transparent', selected: { bg: '#4a90e2', // Light blue background fg: 'default', // White text for contrast bold: true }, item: { fg: 'default', bg: 'transparent' } }, scrollbar: { ch: '┃', style: { fg: 'default' } } }); // Minimal help text const helpText = blessed.text({ parent: mainBox, bottom: 0, left: 0, width: '100%', height: 1, content: isGlobalScan ? '↑/↓: Navigate | D: Delete | R: Rescan | S: Sort | F: Filter | Q: Quit | GLOBAL' : '↑/↓: Navigate | D: Delete | R: Rescan | S: Sort | F: Filter | Q: Quit | LOCAL', tags: true, style: { fg: 'default' } }); // Function to update column widths on resize function updateColumnWidths() { columnWidths = calculateColumnWidths(); // Update header positions statusHeader.width = columnWidths.status; projectHeader.left = columnWidths.status; projectHeader.width = columnWidths.project; pathHeader.left = columnWidths.status + columnWidths.project; pathHeader.width = columnWidths.path; sizeHeader.left = columnWidths.status + columnWidths.project + columnWidths.path; sizeHeader.width = columnWidths.size; modifiedHeader.left = columnWidths.status + columnWidths.project + columnWidths.path + columnWidths.size; modifiedHeader.width = columnWidths.modified; // Update all list items for (let i = 0; i < foundModules.length; i++) { if (i < list.items.length) { updateListItem(i); } } screen.render(); } // Handle screen resize screen.on('resize', function() { updateColumnWidths(); }); // Append main box to the screen screen.append(mainBox); // Focus on the list list.focus(); // Global variables let startDirs = []; if (isGlobalScan) { // For global scan, start with common directories if (process.platform === 'win32') { // Windows: scan user profile and drives startDirs.push(os.homedir()); // Add mounted drives (C:, D:, etc.) const driveLetters = 'CDEFGHIJKLMNOPQRSTUVWXYZ'; for (let i = 0; i < driveLetters.length; i++) { const drivePath = `${driveLetters[i]}:\\`; try { if (fs.existsSync(drivePath)) { startDirs.push(drivePath); } } catch (error) { // Skip inaccessible drives } } } else { // Unix-based systems: scan home and selected system directories startDirs.push(os.homedir()); // Common locations for projects const commonDirs = [ '/usr/local/lib', '/opt', '/var/www', '/srv', path.join(os.homedir(), 'Projects'), path.join(os.homedir(), 'projects'), path.join(os.homedir(), 'dev'), path.join(os.homedir(), 'development'), path.join(os.homedir(), 'src'), path.join(os.homedir(), 'source'), path.join(os.homedir(), 'code'), path.join(os.homedir(), 'Desktop'), path.join(os.homedir(), 'Documents') ]; // Only add directories that exist for (const dir of commonDirs) { try { if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) { startDirs.push(dir); } } catch (error) { // Skip inaccessible directories } } } } else { // For local scan, just use current directory startDirs.push(process.cwd()); } let foundModules = []; let foundCount = 0; let totalSize = 0; let projectsCount = 0; let spaceFreed = 0; let largestModule = { path: 'N/A', size: 0 }; let uniqueProjects = new Set(); let keyBuffer = ''; let keyBufferTimeout; let skippedCacheCount = 0; // Track number of skipped cache directories let scannedDirsCount = 0; // Track number of directories scanned let dirsToScan = startDirs.length; // Initial directories to scan let currentSortBy = 'size'; // Default sort by size let currentSortOrder = 'desc'; // Default sort order descending let currentFilter = 'all'; // Default filter show all let currentMenu = null; // Define cache directories to skip based on operating system const cacheDirectories = [ // Common cache directories across platforms '.cache', 'cache', 'tmp', 'temp', // npm/yarn specific caches '.npm', '.yarn', '.pnpm-store', // Node.js specific '.node-gyp', // OS specific caches - macOS 'Library/Caches', // Windows AppData caches are typically handled by path check ]; // Get user's home directory const homeDir = process.env.HOME || process.env.USERPROFILE; // Check if a path is a cache directory function isCacheDirectory(dirPath) { // Check if path contains any of the cache directory names for (const cacheDir of cacheDirectories) { if (dirPath.includes(`/${cacheDir}/`) || dirPath.includes(`\\${cacheDir}\\`)) { return true; } // Check if the directory ends with a cache name if (dirPath.endsWith(`/${cacheDir}`) || dirPath.endsWith(`\\${cacheDir}`)) { return true; } } // Special case for OS-specific cache locations // macOS if (homeDir && dirPath.startsWith(`${homeDir}/Library/Caches`)) { return true; } // Windows if (process.env.LOCALAPPDATA && dirPath.startsWith(process.env.LOCALAPPDATA + '\\Temp')) { return true; } if (process.env.APPDATA && dirPath.includes('\\AppData\\Local\\Temp')) { return true; } if (dirPath.includes('\\Windows\\Temp')) { return true; } // Linux if (homeDir && dirPath.startsWith(`${homeDir}/.cache`)) { return true; } if (dirPath.startsWith('/tmp') || dirPath.startsWith('/var/cache')) { return true; } // Chrome/browser caches if (dirPath.includes('CachedData') || dirPath.includes('GPUCache')) { return true; } return false; } /** * Gets the size of a directory in bytes * @param {string} dirPath Directory path * @returns {number} Size in bytes */ function getDirSize(dirPath) { let size = 0; try { const files = fs.readdirSync(dirPath); for (const file of files) { const filePath = path.join(dirPath, file); const stats = fs.statSync(filePath); if (stats.isDirectory()) { size += getDirSize(filePath); } else { size += stats.size; } } } catch (error) { // Skip if can't read } return size; } /** * Format bytes to human-readable size * @param {number} bytes Number of bytes * @returns {string} Formatted size */ function formatSize(bytes) { if (bytes === 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i]; } /** * Format date to readable string * @param {Date} date Date object * @returns {string} Formatted date */ function formatDate(date) { const now = new Date(); const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24)); if (diffDays === 0) { return 'Today'; } else if (diffDays === 1) { return 'Yesterday'; } else if (diffDays < 7) { return `${diffDays} days ago`; } else if (diffDays < 30) { return `${Math.floor(diffDays / 7)} weeks ago`; } else { return `${Math.floor(diffDays / 30)} months ago`; } } /** * Truncate text with ellipsis if it exceeds maxLength * @param {string} text Text to truncate * @param {number} maxLength Maximum length before truncation * @returns {string} Truncated text */ function truncateText(text, maxLength) { if (!text || text.length <= maxLength) { return text; } return text.substring(0, maxLength - 3) + '...'; } /** * Get project name from node_modules path * @param {string} modulePath Path to node_modules directory * @returns {string} Project name */ function getProjectName(modulePath) { // Get the parent directory of node_modules const parentDir = path.dirname(modulePath); try { const packageJsonPath = path.join(parentDir, 'package.json'); if (fs.existsSync(packageJsonPath)) { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); return packageJson.name || path.basename(parentDir); } } catch (error) { // If package.json can't be read or parsed } return path.basename(parentDir); } /** * Count number of dependencies in a node_modules folder * @param {string} modulePath Path to node_modules directory * @returns {number} Number of dependencies */ function countDependencies(modulePath) { try { return fs.readdirSync(modulePath).filter(item => !item.startsWith('.') && fs.statSync(path.join(modulePath, item)).isDirectory() ).length; } catch (error) { return 0; } } /** * Checks if a node_modules directory belongs to a project * @param {string} modulePath Path to the node_modules directory * @returns {boolean} True if it's a project node_modules (not global) */ function isProjectNodeModule(modulePath) { // Get the parent directory of node_modules const parentDir = path.dirname(modulePath); // Check if there's a package.json in the parent directory return fs.existsSync(path.join(parentDir, 'package.json')); } /** * Check if a path is a system directory we should exclude from global scan * @param {string} dirPath Directory path * @returns {boolean} True if the directory should be excluded */ function isSystemDirectory(dirPath) { // Skip system directories during global scan to avoid permission issues and irrelevant paths const systemDirs = [ '/System', '/bin', '/boot', '/dev', '/etc', '/lib', '/lib64', '/proc', '/run', '/sbin', '/sys', '/tmp', '/usr/bin', '/usr/lib', '/usr/sbin', '/var/log', '/var/run', 'C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)', 'C:\\ProgramData\\Microsoft', 'C:\\$Recycle.Bin' ]; for (const sysDir of systemDirs) { if (dirPath.startsWith(sysDir)) { return true; } } return false; } /** * Update all stats displays */ function updateStats() { // Count only non-deleted modules for active count const activeModules = foundModules.filter(m => m.status !== 'Deleted'); const activeCount = activeModules.length; const activeTotalSize = activeModules.reduce((total, m) => total + m.size, 0); // Format all info in a single compact stats line statsText.setContent(`NODE_MODULES: ${activeCount} | Size: ${formatSize(activeTotalSize)} | Freed: ${formatSize(spaceFreed)}`); if (isGlobalScan) { statusText.setContent(`Scanning ${scannedDirsCount}/${dirsToScan} dirs | Found: ${foundCount} | Skipped: ${skippedCacheCount}`); } // Display current sort and filter const sortInfo = `Sort: ${currentSortBy}(${currentSortOrder === 'desc' ? '↓' : '↑'})`; const filterInfo = ` | Filter: ${currentFilter}`; titleText.setContent( isGlobalScan ? `MODSCAN (GLOBAL) | ${sortInfo}${filterInfo}` : `MODSCAN | ${sortInfo}${filterInfo}` ); screen.render(); } // Add a module to the list when found function addModuleToList(moduleInfo) { const index = foundModules.length - 1; list.addItem(''); // Add empty item first updateListItem(index); // Then update with proper formatting // Auto-scroll if the list is long if (index > list.height - 1) { list.scrollTo(index); } screen.render(); } /** * Recursively scans directories for node_modules folders * @param {string} dir Directory to scan * @param {number} depth Current recursion depth */ function scanDirectory(dir, depth = 0) { // For global scan, we need to limit depth to avoid too deep recursion if (isGlobalScan && depth > 8) { return; } try { // Skip cache directories to improve performance if (isCacheDirectory(dir)) { skippedCacheCount++; return; } // Skip system directories during global scan if (isGlobalScan && isSystemDirectory(dir)) { skippedCacheCount++; return; } // Track directories when doing global scan if (isGlobalScan && depth === 0) { scannedDirsCount++; statusText.setContent(`Scanning: ${dir} (${scannedDirsCount}/${dirsToScan} directories)`); screen.render(); } const items = fs.readdirSync(dir, { withFileTypes: true }); // Check if this directory is a node_modules folder if (path.basename(dir) === 'node_modules') { // Skip .cache inside node_modules if (dir.endsWith('node_modules/.cache')) { return; } // Only add if it's a project node_modules if (isProjectNodeModule(dir)) { // Calculate directory size and stats const size = getDirSize(dir); const stats = fs.statSync(dir); const lastModified = stats.mtime; const projectName = getProjectName(dir); const dependencyCount = countDependencies(dir); // Track project count const projectDir = path.dirname(dir); if (!uniqueProjects.has(projectDir)) { uniqueProjects.add(projectDir); projectsCount++; } // Track largest module if (size > largestModule.size) { largestModule = { path: dir, size: size }; } foundModules.push({ path: dir, size: size, lastModified: lastModified, projectName: projectName, dependencies: dependencyCount, status: 'Available' }); // Add the module to the UI as soon as it's found addModuleToList(foundModules[foundModules.length - 1]); foundCount++; totalSize += size; // Update status while scanning if (!isGlobalScan) { statusText.setContent(`Scanning: Found ${foundCount} module(s) (${formatSize(totalSize)})`); } else { statusText.setContent(`Scanning: ${scannedDirsCount}/${dirsToScan} dirs | Found: ${foundCount}`); } updateStats(); } // In global scan, continue scanning inside node_modules to catch nested ones if (!isGlobalScan) { return; // Don't scan inside node_modules for local scans } } // Process each item in the directory for (const item of items) { // Skip hidden directories (like .git) if (item.name.startsWith('.')) continue; const itemPath = path.join(dir, item.name); // Recursively process directories if (item.isDirectory()) { scanDirectory(itemPath, depth + 1); } } } catch (error) { // Silently skip directories we can't access return; } } /** * Update the displayed list item for a module * @param {number} index The index of the module in the list */ function updateListItem(index) { const moduleInfo = foundModules[index]; const formattedSize = formatSize(moduleInfo.size); const formattedDate = formatDate(moduleInfo.lastModified); // Determine row color based on status let rowColor = ''; let rowEndColor = ''; // Set colors for different statuses if (moduleInfo.status === 'Deleted') { rowColor = '{red-fg}'; rowEndColor = '{/red-fg}'; } else if (moduleInfo.status === 'Deleting') { rowColor = '{yellow-fg}'; rowEndColor = '{/yellow-fg}'; } else if (moduleInfo.status === 'Error') { rowColor = '{#ff6600-fg}'; // Orange rowEndColor = '{/#ff6600-fg}'; } // Truncate texts to fit their column widths const truncatedProject = truncateText(moduleInfo.projectName, columnWidths.project - 1); const truncatedPath = truncateText(moduleInfo.path, columnWidths.path - 1); // Create column content with fixed widths, using minimal spacing const statusCol = moduleInfo.status.padEnd(columnWidths.status - 1); const projectCol = truncatedProject.padEnd(columnWidths.project - 1); const pathCol = truncatedPath.padEnd(columnWidths.path - 1); const sizeCol = formattedSize.padEnd(columnWidths.size - 1); const dateCol = formattedDate; // Apply row coloring to the entire row with minimal separators const displayText = `${rowColor}${statusCol} ${projectCol} ${pathCol} ${sizeCol} ${dateCol}${rowEndColor}`; list.setItem(index, displayText); } /** * Delete a node_modules folder * @param {object} moduleInfo Object with module information */ function deleteNodeModule(moduleInfo) { const index = foundModules.findIndex(m => m.path === moduleInfo.path); if (index === -1) return; // Update status to deleting foundModules[index].status = 'Deleting'; updateListItem(index); statusText.setContent(`{red-fg}Deleting ${moduleInfo.path}...{/red-fg}`); screen.render(); try { // Recursive function to delete directory const deleteFolderRecursive = function(dirPath) { if (fs.existsSync(dirPath)) { fs.readdirSync(dirPath).forEach((file) => { const curPath = path.join(dirPath, file); if (fs.lstatSync(curPath).isDirectory()) { // Recursive call deleteFolderRecursive(curPath); } else { // Delete file fs.unlinkSync(curPath); } }); // Delete directory fs.rmdirSync(dirPath); } }; deleteFolderRecursive(moduleInfo.path); // Update status instead of removing from list const deletedSize = foundModules[index].size; spaceFreed += deletedSize; // Update the module status foundModules[index].status = 'Deleted'; updateListItem(index); // Find new largest module if we deleted the largest if (moduleInfo.path === largestModule.path) { largestModule = { path: 'N/A', size: 0 }; for (const module of foundModules) { if (module.status !== 'Deleted' && module.size > largestModule.size) { largestModule = { path: module.path, size: module.size }; } } } // Update stats updateStats(); statusText.setContent(`{red-fg}Deleted ${moduleInfo.projectName} (${formatSize(deletedSize)}){/red-fg}`); } catch (error) { foundModules[index].status = 'Error'; updateListItem(index); statusText.setContent(`{red-fg}Error deleting ${moduleInfo.path}: ${error.message}{/red-fg}`); } screen.render(); } /** * Sort the modules list based on current sort settings */ function sortModules() { foundModules.sort((a, b) => { let comparison = 0; // Apply appropriate sort logic based on sort field switch (currentSortBy) { case 'size': comparison = a.size - b.size; break; case 'date': comparison = a.lastModified - b.lastModified; break; case 'name': comparison = a.projectName.localeCompare(b.projectName); break; case 'path': comparison = a.path.localeCompare(b.path); break; } // Apply sort direction return currentSortOrder === 'desc' ? -comparison : comparison; }); } /** * Filter modules based on current filter * @returns {Array} Filtered module list */ function getFilteredModules() { // Start with all modules let result = [...foundModules]; // Apply appropriate filter switch (currentFilter) { case 'all': // No filtering needed break; case 'active': result = result.filter(m => m.status !== 'Deleted'); break; case 'deleted': result = result.filter(m => m.status === 'Deleted'); break; case 'large': // Modules larger than 100MB result = result.filter(m => m.status !== 'Deleted' && m.size > 100 * 1024 * 1024); break; case 'old': // Modules older than 30 days const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); result = result.filter(m => m.status !== 'Deleted' && m.lastModified < thirtyDaysAgo); break; } return result; } /** * Refresh the displayed list with sorted and filtered modules */ function refreshModulesList() { // Clear the current list list.clearItems(); // Sort modules first sortModules(); // Get filtered modules const displayModules = getFilteredModules(); // Re-populate the list displayModules.forEach((moduleInfo, index) => { list.addItem(''); // Add empty item first // Find the original index in the foundModules array const originalIndex = foundModules.findIndex(m => m.path === moduleInfo.path); updateListItem(originalIndex); }); // Make sure we have a selected item if the list isn't empty if (displayModules.length > 0 && list.selected === undefined) { list.select(0); } // Update status with filter count statusText.setContent(`Showing ${displayModules.length} of ${foundModules.length} modules (${currentFilter} filter)`); screen.render(); } // Function to show sort menu function showSortMenu() { // If menu is already open, close it if (currentMenu) { currentMenu.detach(); currentMenu = null; list.focus(); screen.render(); return; } // Create a simple context menu const menu = blessed.list({ parent: screen, top: 'center', left: 'center', width: 30, height: 10, border: { type: 'line' }, style: { border: { fg: 'blue' }, selected: { bg: 'blue', fg: 'white' } }, label: ' Sort By ', tags: true, keys: true, vi: true, items: [ 'Size (Largest First)', 'Size (Smallest First)', 'Date (Newest First)', 'Date (Oldest First)', 'Name (A-Z)', 'Name (Z-A)', 'Path (A-Z)', 'Path (Z-A)', 'Cancel' ] }); currentMenu = menu; menu.focus(); screen.render(); // Handle menu selection menu.on('select', function(item, index) { switch(index) { case 0: // Size (Largest First) currentSortBy = 'size'; currentSortOrder = 'desc'; break; case 1: // Size (Smallest First) currentSortBy = 'size'; currentSortOrder = 'asc'; break; case 2: // Date (Newest First) currentSortBy = 'date'; currentSortOrder = 'desc'; break; case 3: // Date (Oldest First) currentSortBy = 'date'; currentSortOrder = 'asc'; break; case 4: // Name (A-Z) currentSortBy = 'name'; currentSortOrder = 'asc'; break; case 5: // Name (Z-A) currentSortBy = 'name'; currentSortOrder = 'desc'; break; case 6: // Path (A-Z) currentSortBy = 'path'; currentSortOrder = 'asc'; break; case 7: // Path (Z-A) currentSortBy = 'path'; currentSortOrder = 'desc'; break; case 8: // Cancel // Do nothing break; } // Remove menu and refresh if not cancelled menu.detach(); currentMenu = null; list.focus(); if (index !== 8) { refreshModulesList(); updateStats(); } screen.render(); }); // Close menu on escape menu.key(['escape'], function() { menu.detach(); currentMenu = null; list.focus(); screen.render(); }); } // Function to show filter menu function showFilterMenu() { // If menu is already open, close it if (currentMenu) { currentMenu.detach(); currentMenu = null; list.focus(); screen.render(); return; } // Create a simple context menu const menu = blessed.list({ parent: screen, top: 'center', left: 'center', width: 30, height: 9, border: { type: 'line' }, style: { border: { fg: 'green' }, selected: { bg: 'green', fg: 'white' } }, label: ' Filter ', tags: true, keys: true, vi: true, items: [ 'All Modules', 'Active Only', 'Deleted Only', 'Large Modules (>100MB)', 'Old Modules (>30 days)', 'Cancel' ] }); currentMenu = menu; menu.focus(); screen.render(); // Handle menu selection menu.on('select', function(item, index) { switch(index) { case 0: // All Modules currentFilter = 'all'; break; case 1: // Active Only currentFilter = 'active'; break; case 2: // Deleted Only currentFilter = 'deleted'; break; case 3: // Large Modules currentFilter = 'large'; break; case 4: // Old Modules currentFilter = 'old'; break; case 5: // Cancel // Do nothing break; } // Remove menu and refresh if not cancelled menu.detach(); currentMenu = null; list.focus(); if (index !== 5) { refreshModulesList(); updateStats(); } screen.render(); }); // Close menu on escape menu.key(['escape'], function() { menu.detach(); currentMenu = null; list.focus(); screen.render(); }); } // Function to start scanning function startScan() { // Clear any existing entries if restarting scan list.clearItems(); foundModules = []; foundCount = 0; totalSize = 0; projectsCount = 0; uniqueProjects = new Set(); skippedCacheCount = 0; scannedDirsCount = 0; largestModule = { path: 'N/A', size: 0 }; // Start the scan statusText.setContent(isGlobalScan ? `Starting global scan from ${startDirs.length} locations...` : 'Scanning for node_modules folders...'); screen.render(); const startTime = Date.now(); // Start scan in next event loop tick to allow UI to update setTimeout(() => { // Start scan from each root directory for (const startDir of startDirs) { scanDirectory(startDir); } const scanDuration = (Date.now() - startTime) / 1000; // Update stats with final results updateStats(); statusText.setContent(isGlobalScan ? `Global scan complete! Found ${foundCount} module(s) in ${scanDuration.toFixed(2)}s` : `Found ${foundCount} module(s) in ${scanDuration.toFixed(2)}s | Skipped ${skippedCacheCount} caches`); // Sort and filter modules before finalizing refreshModulesList(); screen.render(); }, 100); } // Setup keyboard handling screen.on('keypress', function(ch, key) { // Capture exit commands if (key && (key.name === 'q' && key.ctrl) || (ch === 'q') || (ch === 'Q') || (key && key.name === 'escape')) { return process.exit(0); } // Capital R for rescan if (ch === 'R') { statusText.setContent('{green-fg}Starting scan (R pressed)...{/green-fg}'); screen.render(); setTimeout(() => { startScan(); }, 100); return; } // Capital D for delete if (ch === 'D') { if (list.selected !== undefined && list.selected < foundModules.length) { const moduleIndex = list.selected; const moduleToDelete = foundModules[moduleIndex]; statusText.setContent(`{yellow-fg}Deleting selected module (D pressed)...{/yellow-fg}`); screen.render(); setTimeout(() => { deleteNodeModule(moduleToDelete); }, 100); } else { statusText.setContent('{red-fg}No module selected to delete{/red-fg}'); screen.render(); } return; } // Capital S for sorting if (ch === 'S') { statusText.setContent('{blue-fg}Opening sort menu (S pressed)...{/blue-fg}'); screen.render(); setTimeout(() => { showSortMenu(); }, 100); return; } // Capital F for filtering if (ch === 'F') { statusText.setContent('{green-fg}Opening filter menu (F pressed)...{/green-fg}'); screen.render(); setTimeout(() => { showFilterMenu(); }, 100); return; } // ENTER key for starting scan if (key && key.name === 'return') { statusText.setContent('{green-fg}Starting scan (Enter pressed)...{/green-fg}'); screen.render(); setTimeout(() => { startScan(); }, 100); return; } }); // Keep the traditional key handlers as fallbacks screen.key(['escape', 'q', 'C-c'], function() { return process.exit(0); }); // Handle 'dd' (double-d sequence) for deletion screen.key(['d'], function() { keyBuffer += 'd'; // Clear previous timeout if (keyBufferTimeout) { clearTimeout(keyBufferTimeout); } // Check for "DD" keypress if (keyBuffer === 'dd') { if (list.selected !== undefined && foundModules[list.selected]) { // Delete the node_modules folder directly without confirmation deleteNodeModule(foundModules[list.selected]); } keyBuffer = ''; } else { // Reset after 500ms if second D is not pressed keyBufferTimeout = setTimeout(() => { keyBuffer = ''; }, 500); } }); // Initialize the screen first screen.render(); // Don't start scanning automatically - wait for user to press ENTER