modscan
Version:
A CLI tool to scan and manage node_modules directories
1,314 lines (1,153 loc) • 33.7 kB
JavaScript
#!/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