// Name: Delete Orphaned Files // Description: Find and delete files that are no longer referenced or needed // Author: BrickyOnYT import "@johnlindquist/kit" interface OrphanedFile { path: string reason: string size: number } const scanDirectory = async (dirPath: string): Promise<OrphanedFile[]> => { const orphanedFiles: OrphanedFile[] = [] try { const items = await readdir(dirPath, { withFileTypes: true }) for (const item of items) { const fullPath = path.join(dirPath, item.name) if (item.isDirectory()) { // Skip system directories and common important folders const skipDirs = ['.git', 'node_modules', '.next', 'dist', 'build', '.cache', 'Library', 'System'] if (!skipDirs.includes(item.name)) { const subOrphans = await scanDirectory(fullPath) orphanedFiles.push(...subOrphans) } } else if (item.isFile()) { const stats = await stat(fullPath) // Check for common orphaned file patterns const isOrphaned = // Temporary files item.name.startsWith('.') && (item.name.endsWith('.tmp') || item.name.endsWith('.temp')) || // Log files older than 30 days (item.name.endsWith('.log') && Date.now() - stats.mtime.getTime() > 30 * 24 * 60 * 60 * 1000) || // Cache files item.name.includes('cache') && item.name.endsWith('.cache') || // Backup files item.name.endsWith('.bak') || item.name.endsWith('.backup') || // macOS metadata files item.name === '.DS_Store' || // Thumbnail cache item.name === 'Thumbs.db' || // Empty files older than 7 days (stats.size === 0 && Date.now() - stats.mtime.getTime() > 7 * 24 * 60 * 60 * 1000) if (isOrphaned) { let reason = 'Unknown' if (item.name.startsWith('.') && (item.name.endsWith('.tmp') || item.name.endsWith('.temp'))) { reason = 'Temporary file' } else if (item.name.endsWith('.log')) { reason = 'Old log file' } else if (item.name.includes('cache')) { reason = 'Cache file' } else if (item.name.endsWith('.bak') || item.name.endsWith('.backup')) { reason = 'Backup file' } else if (item.name === '.DS_Store' || item.name === 'Thumbs.db') { reason = 'System metadata' } else if (stats.size === 0) { reason = 'Empty file' } orphanedFiles.push({ path: fullPath, reason, size: stats.size }) } } } } catch (error) { // Skip directories we can't access } return orphanedFiles } const formatFileSize = (bytes: number): string => { if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] } // Get directory to scan const targetDir = await path({ hint: 'Select directory to scan for orphaned files', onlyDirs: true, startPath: home() }) await div(md('## Scanning for orphaned files...\nThis may take a moment depending on directory size.')) const orphanedFiles = await scanDirectory(targetDir) if (orphanedFiles.length === 0) { await div(md('## ✅ No orphaned files found!\n\nYour directory is clean.')) exit() } // Calculate total size const totalSize = orphanedFiles.reduce((sum, file) => sum + file.size, 0) // Group files by reason const groupedFiles = orphanedFiles.reduce((groups, file) => { if (!groups[file.reason]) { groups[file.reason] = [] } groups[file.reason].push(file) return groups }, {} as Record<string, OrphanedFile[]>) // Create choices for selection const choices = [] for (const [reason, files] of Object.entries(groupedFiles)) { const groupSize = files.reduce((sum, file) => sum + file.size, 0) choices.push({ name: `${reason} (${files.length} files, ${formatFileSize(groupSize)})`, value: reason, description: `Select all ${files.length} ${reason.toLowerCase()} files` }) } choices.push({ name: `🗑️ All orphaned files (${orphanedFiles.length} files, ${formatFileSize(totalSize)})`, value: 'all', description: 'Delete all found orphaned files' }) const selectedCategories = await select( { placeholder: 'Select file categories to delete', hint: `Found ${orphanedFiles.length} orphaned files (${formatFileSize(totalSize)} total)` }, choices ) // Get files to delete based on selection let filesToDelete: OrphanedFile[] = [] if (selectedCategories.includes('all')) { filesToDelete = orphanedFiles } else { for (const category of selectedCategories) { filesToDelete.push(...groupedFiles[category]) } } if (filesToDelete.length === 0) { await div(md('## No files selected for deletion.')) exit() } // Show preview and confirm const previewText = filesToDelete .slice(0, 20) .map(file => `- ${file.path} (${file.reason}, ${formatFileSize(file.size)})`) .join('\n') const moreText = filesToDelete.length > 20 ? `\n... and ${filesToDelete.length - 20} more files` : '' const confirmDelete = await div({ html: md(` ## ⚠️ Confirm Deletion **About to delete ${filesToDelete.length} files:** ${previewText}${moreText} **Total size:** ${formatFileSize(filesToDelete.reduce((sum, file) => sum + file.size, 0))} `), shortcuts: [ { name: 'Delete Files', key: 'cmd+d', onPress: () => submit('delete'), bar: 'right' }, { name: 'Cancel', key: 'escape', onPress: () => submit('cancel'), bar: 'right' } ] }) if (confirmDelete !== 'delete') { await div(md('## Deletion cancelled.')) exit() } // Delete files let deletedCount = 0 let deletedSize = 0 let errors: string[] = [] await div(md('## Deleting files...\nPlease wait...')) for (const file of filesToDelete) { try { await trash(file.path) deletedCount++ deletedSize += file.size } catch (error) { errors.push(`Failed to delete ${file.path}: ${error.message}`) } } // Show results let resultMessage = `## ✅ Deletion Complete\n\n**Successfully deleted:** ${deletedCount} files (${formatFileSize(deletedSize)})` if (errors.length > 0) { resultMessage += `\n\n**Errors:** ${errors.length} files could not be deleted\n\n${errors.slice(0, 5).join('\n')}` if (errors.length > 5) { resultMessage += `\n... and ${errors.length - 5} more errors` } } await div(md(resultMessage))