@tukuyomil032/broom 1.0.0 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,18 +3,22 @@
3
3
  */
4
4
  import chalk from 'chalk';
5
5
  import { Command } from 'commander';
6
+ import blessed from 'blessed';
6
7
  import { enhanceCommandHelp } from '../utils/help.js';
7
8
  import { readdir, stat } from 'fs/promises';
8
- import { join, basename } from 'path';
9
- import { exists, getSize, formatSize, expandPath } from '../utils/fs.js';
10
- import { printHeader, warning, info, separator, createSpinner, succeedSpinner, failSpinner, } from '../ui/output.js';
9
+ import { join, basename, dirname } from 'path';
10
+ import { getSize, formatSize, expandPath } from '../utils/fs.js';
11
+ import { exec } from 'child_process';
12
+ import { promisify } from 'util';
13
+ import { createSpinner, succeedSpinner, failSpinner, } from '../ui/output.js';
14
+ const execAsync = promisify(exec);
11
15
  // Fixed column widths for aligned display
12
16
  const NAME_WIDTH = 25;
13
17
  const BAR_WIDTH = 30;
14
18
  /**
15
- * Scan directory for sizes
19
+ * Scan directory for sizes - recursive with optional depth limit
16
20
  */
17
- async function scanDirectory(dirPath, depth, currentDepth = 0) {
21
+ async function scanDirectory(dirPath, currentDepth = 0, maxDepth) {
18
22
  try {
19
23
  const stats = await stat(dirPath);
20
24
  const name = basename(dirPath) || dirPath;
@@ -24,6 +28,7 @@ async function scanDirectory(dirPath, depth, currentDepth = 0) {
24
28
  name,
25
29
  size: stats.size,
26
30
  isDirectory: false,
31
+ mtime: stats.mtime,
27
32
  };
28
33
  }
29
34
  // Get total size
@@ -33,44 +38,57 @@ async function scanDirectory(dirPath, depth, currentDepth = 0) {
33
38
  name,
34
39
  size,
35
40
  isDirectory: true,
41
+ mtime: stats.mtime,
36
42
  };
37
- // Get children if within depth
38
- if (currentDepth < depth) {
39
- try {
40
- const entries = await readdir(dirPath);
41
- const children = [];
42
- for (const entry of entries) {
43
- // Skip hidden files at top level
44
- if (entry.startsWith('.') && currentDepth === 0) {
45
- continue;
46
- }
47
- const childPath = join(dirPath, entry);
48
- // Skip excluded paths (iCloud Drive, etc.)
49
- const { isExcludedPath } = await import('../utils/fs.js');
50
- if (isExcludedPath(childPath)) {
51
- continue;
43
+ // Check depth limit
44
+ if (maxDepth !== undefined && currentDepth >= maxDepth) {
45
+ info.children = [];
46
+ return info;
47
+ }
48
+ // Recursively scan subdirectories
49
+ try {
50
+ const entries = await readdir(dirPath);
51
+ const children = [];
52
+ for (const entry of entries) {
53
+ // Skip hidden files at top level
54
+ if (entry.startsWith('.') && currentDepth === 0) {
55
+ continue;
56
+ }
57
+ const childPath = join(dirPath, entry);
58
+ // Skip excluded paths (iCloud Drive, etc.)
59
+ const { isExcludedPath } = await import('../utils/fs.js');
60
+ if (isExcludedPath(childPath)) {
61
+ continue;
62
+ }
63
+ try {
64
+ const childStats = await stat(childPath);
65
+ if (childStats.isDirectory()) {
66
+ // Recursively scan subdirectories
67
+ const childInfo = await scanDirectory(childPath, currentDepth + 1, maxDepth);
68
+ if (childInfo) {
69
+ children.push(childInfo);
70
+ }
52
71
  }
53
- try {
54
- const childStats = await stat(childPath);
55
- const childSize = childStats.isDirectory() ? await getSize(childPath) : childStats.size;
72
+ else {
73
+ const childSize = childStats.size;
56
74
  children.push({
57
75
  path: childPath,
58
76
  name: entry,
59
77
  size: childSize,
60
- isDirectory: childStats.isDirectory(),
78
+ isDirectory: false,
61
79
  });
62
80
  }
63
- catch {
64
- // Skip if cannot access
65
- }
66
81
  }
67
- // Sort by size descending
68
- children.sort((a, b) => b.size - a.size);
69
- info.children = children;
70
- }
71
- catch {
72
- // Cannot read directory
82
+ catch {
83
+ // Skip if cannot access
84
+ }
73
85
  }
86
+ // Sort by size descending
87
+ children.sort((a, b) => b.size - a.size);
88
+ info.children = children;
89
+ }
90
+ catch {
91
+ // Cannot read directory
74
92
  }
75
93
  return info;
76
94
  }
@@ -79,35 +97,95 @@ async function scanDirectory(dirPath, depth, currentDepth = 0) {
79
97
  }
80
98
  }
81
99
  /**
82
- * Generate size bar for Quick Analysis - with borders and gridlines
100
+ * Generate disk usage bar graph with chalk colors.
101
+ * Safe to use in listBox with tags:false (ANSI codes rendered by terminal directly).
83
102
  */
84
- function generateQuickAnalysisBar(size, maxSize, width = 20) {
85
- const percentage = maxSize > 0 ? size / maxSize : 0;
86
- const filledWidth = Math.round(percentage * width);
103
+ function generateUsageBar(size, parentSize, width = 20) {
104
+ const percentage = parentSize > 0 ? size / parentSize : 0;
105
+ const clamped = Math.min(percentage, 1);
106
+ const filledWidth = Math.round(clamped * width);
87
107
  let bar = '';
88
- // Color based on relative size
89
- let color;
90
- if (percentage > 0.7) {
91
- color = chalk.red;
92
- }
93
- else if (percentage > 0.3) {
94
- color = chalk.hex('#FFA500'); // Orange
95
- }
96
- else {
97
- color = chalk.gray;
108
+ for (let i = 0; i < width; i++) {
109
+ if (i < filledWidth) {
110
+ const ratio = i / width;
111
+ if (ratio < 0.5) {
112
+ bar += chalk.green('█');
113
+ }
114
+ else if (ratio < 0.75) {
115
+ bar += chalk.yellow('█');
116
+ }
117
+ else {
118
+ bar += chalk.red('█');
119
+ }
120
+ }
121
+ else {
122
+ bar += chalk.gray('░');
123
+ }
98
124
  }
99
- // Build bar with gridlines every 20%
125
+ const pct = (clamped * 100).toFixed(1);
126
+ return `${bar} ${pct}%`;
127
+ }
128
+ /**
129
+ * Generate disk usage bar using blessed tags (for info box display)
130
+ */
131
+ function generateUsageBarBlessed(size, parentSize, width = 28) {
132
+ const percentage = parentSize > 0 ? size / parentSize : 0;
133
+ const clamped = Math.min(percentage, 1);
134
+ const filledWidth = Math.round(clamped * width);
135
+ let bar = '';
100
136
  for (let i = 0; i < width; i++) {
101
- const isGridline = i > 0 && i % (width / 5) === 0;
102
137
  if (i < filledWidth) {
103
- bar += isGridline ? chalk.white('│') : color('█');
138
+ const ratio = i / width;
139
+ if (ratio < 0.5) {
140
+ bar += '{green-fg}█{/green-fg}';
141
+ }
142
+ else if (ratio < 0.75) {
143
+ bar += '{yellow-fg}█{/yellow-fg}';
144
+ }
145
+ else {
146
+ bar += '{red-fg}█{/red-fg}';
147
+ }
104
148
  }
105
149
  else {
106
- bar += isGridline ? chalk.gray('│') : chalk.gray('░');
150
+ bar += '{white-fg}{/white-fg}';
107
151
  }
108
152
  }
109
- // Add horizontal borders
110
- return chalk.gray('│') + bar + chalk.gray('│');
153
+ const pct = (clamped * 100).toFixed(1);
154
+ // NOTE: percentage is plain text, NOT wrapped in blessed tags
155
+ return `${bar} ${pct}%`;
156
+ }
157
+ /**
158
+ * Normalize path for comparison
159
+ */
160
+ function normalizePath(p) {
161
+ return p.replace(/\/+$/, '') || '/'; // Remove trailing slashes
162
+ }
163
+ /**
164
+ * Get folder at path from DirInfo tree
165
+ */
166
+ function getFolderAtPath(root, targetPath) {
167
+ const normalizedTarget = normalizePath(targetPath);
168
+ const normalizedRoot = normalizePath(root.path);
169
+ if (normalizedRoot === normalizedTarget) {
170
+ return root;
171
+ }
172
+ if (!root.children) {
173
+ return null;
174
+ }
175
+ for (const child of root.children) {
176
+ const result = getFolderAtPath(child, targetPath);
177
+ if (result) {
178
+ return result;
179
+ }
180
+ }
181
+ return null;
182
+ }
183
+ /**
184
+ * Get immediate children of a folder
185
+ */
186
+ function getChildrenAtPath(root, targetPath) {
187
+ const folder = getFolderAtPath(root, targetPath);
188
+ return folder?.children || [];
111
189
  }
112
190
  /**
113
191
  * Generate size bar for tree display with gridlines
@@ -210,7 +288,7 @@ function generateDiskBar(used, total, width = 40) {
210
288
  /**
211
289
  * Print directory tree with aligned columns
212
290
  */
213
- function printTree(items, maxSize, limit, indent = '') {
291
+ function printTree(items, parentSize, limit, indent = '') {
214
292
  const displayed = items.slice(0, limit);
215
293
  const remaining = items.length - limit;
216
294
  // Calculate maximum name width from displayed items (with reasonable limit)
@@ -218,6 +296,8 @@ function printTree(items, maxSize, limit, indent = '') {
218
296
  const maxNameLength = Math.min(Math.max(...displayed.map((item) => item.name.length), NAME_WIDTH // Minimum width
219
297
  ), MAX_NAME_WIDTH // Maximum width
220
298
  );
299
+ // Find max size for bar visualization (visual reference only)
300
+ const maxSizeForBar = Math.max(...displayed.map((item) => item.size), 1);
221
301
  for (let i = 0; i < displayed.length; i++) {
222
302
  const item = displayed[i];
223
303
  const isLast = i === displayed.length - 1 && remaining <= 0;
@@ -233,9 +313,11 @@ function printTree(items, maxSize, limit, indent = '') {
233
313
  displayName.substring(displayName.length - keepLength);
234
314
  }
235
315
  displayName = displayName.padEnd(maxNameLength);
236
- const bar = generateTreeBar(item.size, maxSize);
316
+ // Use parent size as reference for bar visualization
317
+ const bar = generateTreeBar(item.size, parentSize);
237
318
  const sizeStr = formatSize(item.size).padStart(10);
238
- const percentage = maxSize > 0 ? ((item.size / maxSize) * 100).toFixed(1).padStart(5) + '%' : ' 0.0%';
319
+ // Calculate percentage relative to parent directory total size
320
+ const percentage = parentSize > 0 ? ((item.size / parentSize) * 100).toFixed(1).padStart(5) + '%' : ' 0.0%';
239
321
  console.log(`${indent}${prefix}${icon} ${chalk.bold(displayName)} ${bar} ${chalk.cyan(sizeStr)} ${chalk.dim(percentage)}`);
240
322
  }
241
323
  if (remaining > 0) {
@@ -257,103 +339,305 @@ async function getDiskUsage() {
257
339
  }
258
340
  }
259
341
  /**
260
- * Execute analyze command
342
+ * Execute analyze command with interactive UI and optimized loading
261
343
  */
262
344
  export async function analyzeCommand(options) {
263
- const targetPath = options.path ? expandPath(options.path) : expandPath('~');
264
- const depth = options.depth ?? 1;
265
- const limit = options.limit ?? 15;
266
- printHeader(`📊 Disk Space Analysis`);
267
- // Show disk usage with improved bar
268
- const diskUsage = await getDiskUsage();
269
- if (diskUsage) {
270
- console.log(chalk.bold('💾 Disk Usage:'));
271
- const usedPercent = (diskUsage.used / diskUsage.total) * 100;
272
- console.log(generateDiskBar(diskUsage.used, diskUsage.total));
273
- console.log();
274
- console.log(` Used: ${chalk.yellow(formatSize(diskUsage.used))} / ${formatSize(diskUsage.total)} (${usedPercent.toFixed(1)}%)`);
275
- console.log(` Free: ${chalk.green(formatSize(diskUsage.free))}`);
276
- console.log();
277
- }
278
- // Scan target directory
279
- info(`Analyzing system disk...`);
280
- console.log(chalk.bold(`Target: ${chalk.cyan(targetPath)}`));
281
- console.log();
282
- const spinner = createSpinner('Scanning directory sizes...');
283
- const dirInfo = await scanDirectory(targetPath, depth);
284
- if (!dirInfo) {
345
+ const rawPath = options.positionalPath || options.path;
346
+ const targetPath = rawPath ? expandPath(rawPath) : expandPath('~');
347
+ // First, show initial message in console
348
+ console.clear();
349
+ console.log(chalk.bold.cyan('\n🧹 Broom - Disk Space Analyzer\n'));
350
+ const spinner = createSpinner('Scanning directory structure (initial scan)...');
351
+ // First pass: shallow scan for quick display
352
+ let rootInfo = await scanDirectory(targetPath, 0, 2);
353
+ if (!rootInfo) {
285
354
  failSpinner(spinner, 'Failed to scan directory');
286
355
  return;
287
356
  }
288
- succeedSpinner(spinner, `Scanned ${dirInfo.children?.length ?? 0} items`);
289
- // Print results
290
- console.log();
291
- console.log(chalk.bold(`📁 ${dirInfo.name}`));
292
- console.log(` Total size: ${chalk.yellow(formatSize(dirInfo.size))}`);
293
- console.log();
294
- // Calculate maximum name width for header alignment (with reasonable limit)
295
- const MAX_NAME_WIDTH = 40;
296
- const maxNameLength = dirInfo.children && dirInfo.children.length > 0
297
- ? Math.min(Math.max(...dirInfo.children.slice(0, limit).map((item) => item.name.length), NAME_WIDTH), MAX_NAME_WIDTH)
298
- : NAME_WIDTH;
299
- // Print header for aligned columns
300
- const headerName = 'Name'.padEnd(maxNameLength);
301
- console.log(chalk.dim(` ${headerName} ${'0% 20% 40% 60% 80% 100%'.padStart(BAR_WIDTH + 2)} Size Ratio`));
302
- console.log();
303
- if (dirInfo.children && dirInfo.children.length > 0) {
304
- const maxSize = dirInfo.children[0]?.size ?? 1;
305
- printTree(dirInfo.children, maxSize, limit);
306
- }
307
- else {
308
- warning('No items found in directory');
357
+ succeedSpinner(spinner, 'Initial scan complete');
358
+ if (!rootInfo.children || rootInfo.children.length === 0) {
359
+ console.log(chalk.yellow('No items found in directory'));
360
+ return;
309
361
  }
310
- // Show common large directories
311
- console.log();
312
- separator();
313
- console.log();
314
- console.log(chalk.bold('💡 Quick Analysis:'));
315
- console.log();
316
- // Header for Quick Analysis with scale
317
- console.log(chalk.dim(' Location 0% 20% 40% 60% 80% 100% Size'));
318
- console.log();
319
- const quickPaths = [
320
- { path: '~/Library/Caches', label: 'User Caches' },
321
- { path: '~/Library/Application Support', label: 'App Support' },
322
- { path: '~/.Trash', label: 'Trash' },
323
- { path: '~/Downloads', label: 'Downloads' },
324
- { path: '~/Library/Developer', label: 'Developer Data' },
325
- ];
326
- const quickResults = [];
327
- for (const item of quickPaths) {
328
- const fullPath = expandPath(item.path);
329
- if (exists(fullPath)) {
330
- const size = await getSize(fullPath);
331
- quickResults.push({ label: item.label, size });
362
+ // Start UI display
363
+ let currentPath = targetPath;
364
+ function displayDirectory() {
365
+ // Normalize current path for consistency
366
+ const normalizedPath = normalizePath(currentPath);
367
+ // Get current folder and its direct children
368
+ const currentFolder = getFolderAtPath(rootInfo, normalizedPath);
369
+ if (!currentFolder) {
370
+ console.log(chalk.red(`Error: Could not find folder at ${normalizedPath}. Current root: ${normalizePath(rootInfo.path)}`));
371
+ return;
332
372
  }
373
+ const items = currentFolder.children || [];
374
+ // Create blessed screen for interactive UI
375
+ const screen = blessed.screen({
376
+ smartCSR: true,
377
+ title: 'Broom - Disk Space Analyzer',
378
+ fullUnicode: true,
379
+ });
380
+ // Header box
381
+ const headerBox = blessed.box({
382
+ parent: screen,
383
+ top: 0,
384
+ left: 0,
385
+ width: '100%',
386
+ height: 3,
387
+ tags: true,
388
+ border: { type: 'line' },
389
+ style: { border: { fg: 'cyan' } },
390
+ content: ` {bold}📊 ${normalizedPath}{/bold}\n {dim}Total: ${formatSize(currentFolder.size)} | Items: ${items.length}{/dim}`,
391
+ });
392
+ // Main list box - folders/files in current directory
393
+ // tags: false to prevent ANSI codes in item text from being misinterpreted
394
+ const listBox = blessed.list({
395
+ parent: screen,
396
+ top: 3,
397
+ left: 0,
398
+ width: '60%',
399
+ height: '100%-6',
400
+ label: ' Contents ',
401
+ tags: false,
402
+ border: { type: 'line' },
403
+ style: {
404
+ border: { fg: 'cyan' },
405
+ selected: { bg: 'blue', fg: 'white', bold: true },
406
+ },
407
+ mouse: true,
408
+ keys: true,
409
+ });
410
+ // Info box - details of selected item
411
+ const infoBox = blessed.box({
412
+ parent: screen,
413
+ top: 3,
414
+ left: '60%',
415
+ width: '40%',
416
+ height: '100%-6',
417
+ label: ' {cyan-fg}↕{/cyan-fg} Details ',
418
+ tags: true,
419
+ border: { type: 'line' },
420
+ style: { border: { fg: 'cyan' } },
421
+ scrollable: true,
422
+ mouse: true,
423
+ });
424
+ // Footer box
425
+ blessed.box({
426
+ parent: screen,
427
+ bottom: 0,
428
+ left: 0,
429
+ width: '100%',
430
+ height: 3,
431
+ tags: true,
432
+ border: { type: 'line' },
433
+ style: { border: { fg: 'cyan' } },
434
+ content: '{cyan-fg}↑↓{/cyan-fg} Navigate | {green-fg}Enter{/green-fg} Open folder | {red-fg}o{/red-fg} Open in Finder | {yellow-fg}b{/yellow-fg} Back | {red-fg}q/Esc{/red-fg} Quit',
435
+ });
436
+ // Populate list with items (plain text only - no ANSI/tags to avoid index corruption)
437
+ let selectedIndex = 0;
438
+ items.forEach((item) => {
439
+ const icon = item.isDirectory ? '>' : ' ';
440
+ const name = item.name.substring(0, 24).padEnd(24);
441
+ const bar = generateUsageBar(item.size, currentFolder.size, 18);
442
+ listBox.addItem(`${icon} ${name} ${bar}`);
443
+ });
444
+ // Update info box when selection changes
445
+ const updateInfoBox = (index) => {
446
+ if (index >= items.length || index < 0)
447
+ return;
448
+ selectedIndex = index;
449
+ const selectedItem = items[index];
450
+ if (!selectedItem)
451
+ return;
452
+ const usageBar = generateUsageBarBlessed(selectedItem.size, currentFolder.size, 24);
453
+ const pct = currentFolder.size > 0
454
+ ? ((selectedItem.size / currentFolder.size) * 100).toFixed(1)
455
+ : '0.0';
456
+ // Rank in parent by size
457
+ const rank = items.indexOf(selectedItem) + 1;
458
+ // Children breakdown
459
+ const childDirs = selectedItem.children?.filter((c) => c.isDirectory).length ?? 0;
460
+ const childFiles = selectedItem.children?.filter((c) => !c.isDirectory).length ?? 0;
461
+ const hasChildren = childDirs + childFiles > 0;
462
+ // Last modified
463
+ const mtimeStr = selectedItem.mtime
464
+ ? selectedItem.mtime.toLocaleString('ja-JP', {
465
+ year: 'numeric',
466
+ month: '2-digit',
467
+ day: '2-digit',
468
+ hour: '2-digit',
469
+ minute: '2-digit',
470
+ })
471
+ : 'Unknown';
472
+ // Size comparison label
473
+ let sizeLabel = '';
474
+ if (pct !== '0.0') {
475
+ const p = parseFloat(pct);
476
+ if (p >= 30)
477
+ sizeLabel = ' {red-fg}(Very large){/red-fg}';
478
+ else if (p >= 10)
479
+ sizeLabel = ' {yellow-fg}(Large){/yellow-fg}';
480
+ else if (p >= 1)
481
+ sizeLabel = ' {green-fg}(Medium){/green-fg}';
482
+ else
483
+ sizeLabel = ' {cyan-fg}(Small){/cyan-fg}';
484
+ }
485
+ // Icon
486
+ const icon = selectedItem.isDirectory ? '📁' : '📄';
487
+ const typeLabel = selectedItem.isDirectory ? 'Directory' : 'File';
488
+ // Path - wrap at 30 chars
489
+ const pathParts = [];
490
+ let remaining = selectedItem.path;
491
+ while (remaining.length > 30) {
492
+ pathParts.push(remaining.slice(0, 30));
493
+ remaining = remaining.slice(30);
494
+ }
495
+ pathParts.push(remaining);
496
+ const wrappedPath = pathParts.join('\n ');
497
+ const divider = '{cyan-fg}' + '─'.repeat(28) + '{/cyan-fg}';
498
+ const infoText =
499
+ // Title
500
+ `${icon} {bold}{underline}${selectedItem.name}{/underline}{/bold}\n` +
501
+ `${divider}\n\n` +
502
+ // Usage
503
+ `{yellow-fg}📊 Usage{/yellow-fg}\n` +
504
+ ` ${usageBar}\n` +
505
+ ` {bold}${pct}%{/bold} of parent${sizeLabel}\n\n` +
506
+ // Rank
507
+ `{yellow-fg}🏆 Rank{/yellow-fg}\n` +
508
+ ` {bold}#${rank}{/bold} of ${items.length} items\n\n` +
509
+ // Size
510
+ `{yellow-fg}💾 Size{/yellow-fg}\n` +
511
+ ` {bold}${formatSize(selectedItem.size)}{/bold}\n\n` +
512
+ // Type
513
+ `{yellow-fg}📂 Type{/yellow-fg}\n` +
514
+ ` ${typeLabel}\n\n` +
515
+ // Contents (directories only)
516
+ (selectedItem.isDirectory
517
+ ? `{yellow-fg}📋 Contents{/yellow-fg}\n` +
518
+ (hasChildren
519
+ ? ` {green-fg}${childDirs} folders{/green-fg}, {cyan-fg}${childFiles} files{/cyan-fg}\n\n`
520
+ : ` {gray-fg}(Enter to scan){/gray-fg}\n\n`)
521
+ : '') +
522
+ // Last modified
523
+ `{yellow-fg}🕐 Modified{/yellow-fg}\n` +
524
+ ` ${mtimeStr}\n\n` +
525
+ // Path
526
+ `{yellow-fg}📍 Path{/yellow-fg}\n` +
527
+ ` {gray-fg}${wrappedPath}{/gray-fg}\n\n` +
528
+ `${divider}\n` +
529
+ // Actions
530
+ `{yellow-fg}⌨️ Actions{/yellow-fg}\n` +
531
+ (selectedItem.isDirectory ? ` {green-fg}[Enter]{/green-fg} Open folder\n` : '') +
532
+ ` {magenta-fg}[o]{/magenta-fg} Open in Finder\n` +
533
+ (normalizedPath !== normalizePath(targetPath) ? ` {cyan-fg}[b]{/cyan-fg} Go back\n` : '') +
534
+ ` {red-fg}[q]{/red-fg} Quit`;
535
+ infoBox.setContent(infoText);
536
+ screen.render();
537
+ };
538
+ // 'select item' fires on arrow key navigation in blessed List
539
+ // 'select' fires only on Enter - so use 'select item' for live updates
540
+ listBox.on('select item', (_item, index) => {
541
+ updateInfoBox(index);
542
+ });
543
+ // Open folder (navigate into it)
544
+ const openFolder = async () => {
545
+ const selectedItem = items[selectedIndex];
546
+ if (selectedItem && selectedItem.isDirectory) {
547
+ currentPath = selectedItem.path;
548
+ screen.destroy();
549
+ // If folder children are empty (not yet scanned due to shallow initial scan),
550
+ // scan this specific folder on-demand
551
+ if (!selectedItem.children || selectedItem.children.length === 0) {
552
+ const subSpinner = createSpinner(`Scanning ${selectedItem.name}...`);
553
+ const scanned = await scanDirectory(selectedItem.path, 0, 2);
554
+ if (scanned?.children && scanned.children.length > 0) {
555
+ selectedItem.children = scanned.children;
556
+ selectedItem.size = scanned.size;
557
+ }
558
+ succeedSpinner(subSpinner, `Scanned ${selectedItem.name}`);
559
+ }
560
+ displayDirectory();
561
+ }
562
+ };
563
+ // Open in Finder - keep screen, show result in info box
564
+ const openInFinder = async () => {
565
+ const selectedItem = items[selectedIndex];
566
+ if (selectedItem) {
567
+ try {
568
+ await execAsync(`open "${selectedItem.path}"`);
569
+ infoBox.setContent(infoBox.content + '\n\n{green-fg}✓ Opened in Finder{/green-fg}');
570
+ }
571
+ catch (err) {
572
+ infoBox.setContent(infoBox.content + '\n\n{red-fg}✗ Failed to open{/red-fg}');
573
+ }
574
+ screen.render();
575
+ }
576
+ };
577
+ // Go back to parent directory (never navigate above scan root)
578
+ const goBack = () => {
579
+ const normalizedPath = normalizePath(currentPath);
580
+ const normalizedTarget = normalizePath(targetPath);
581
+ // Already at scan root - do nothing
582
+ if (normalizedPath === normalizedTarget) {
583
+ return;
584
+ }
585
+ const parent = dirname(normalizedPath);
586
+ if (parent !== normalizedPath) {
587
+ currentPath = parent;
588
+ screen.destroy();
589
+ displayDirectory();
590
+ }
591
+ };
592
+ // Keyboard handlers
593
+ listBox.key(['enter'], () => {
594
+ void openFolder();
595
+ });
596
+ listBox.key(['o'], () => {
597
+ void openInFinder();
598
+ });
599
+ listBox.key(['b'], () => {
600
+ goBack();
601
+ });
602
+ listBox.key(['q', 'escape', 'C-c'], () => {
603
+ screen.destroy();
604
+ process.exit(0);
605
+ });
606
+ // Mouse double-click to open folder
607
+ let lastClickTime = 0;
608
+ listBox.on('click', () => {
609
+ const selectedItem = items[selectedIndex];
610
+ if (selectedItem?.isDirectory) {
611
+ const now = Date.now();
612
+ if (now - lastClickTime < 300) {
613
+ void openFolder();
614
+ }
615
+ lastClickTime = now;
616
+ }
617
+ });
618
+ // Focus on list initially
619
+ listBox.focus();
620
+ // Set initial selection
621
+ if (items.length > 0) {
622
+ listBox.select(0);
623
+ updateInfoBox(0);
624
+ }
625
+ // Render the screen
626
+ screen.render();
333
627
  }
334
- // Sort by size
335
- quickResults.sort((a, b) => b.size - a.size);
336
- const maxQuickSize = quickResults[0]?.size ?? 1;
337
- for (const result of quickResults) {
338
- const bar = generateQuickAnalysisBar(result.size, maxQuickSize, 30);
339
- const sizeStr = formatSize(result.size).padStart(10);
340
- console.log(` ${result.label.padEnd(15)} ${bar} ${chalk.cyan(sizeStr)}`);
341
- }
342
- // Recommendations
343
- console.log();
344
- console.log(chalk.bold('📋 Recommendations:'));
345
- const totalQuickSize = quickResults.reduce((sum, r) => sum + r.size, 0);
346
- if (totalQuickSize > 5 * 1024 * 1024 * 1024) {
347
- // > 5GB
348
- console.log(chalk.yellow(` ⚠️ You have ${formatSize(totalQuickSize)} in common cleanup locations`));
349
- console.log(chalk.dim(' Run "broom clean" to free up space'));
350
- }
351
- else {
352
- console.log(chalk.green(' ✓ Your disk looks reasonably clean'));
353
- }
354
- console.log();
355
- console.log(chalk.dim('Tip: Use "broom analyze --path /path/to/dir" to analyze a specific directory'));
356
- console.log(chalk.dim(' Use "broom analyze --depth 2" to scan deeper'));
628
+ displayDirectory();
629
+ // Background: Continue deeper scan to fill in missing data
630
+ setImmediate(async () => {
631
+ try {
632
+ const fullInfo = await scanDirectory(targetPath);
633
+ if (fullInfo) {
634
+ rootInfo = fullInfo;
635
+ }
636
+ }
637
+ catch {
638
+ // Silently fail - user can still use shallow scan
639
+ }
640
+ });
357
641
  }
358
642
  /**
359
643
  * Create analyze command
@@ -361,11 +645,10 @@ export async function analyzeCommand(options) {
361
645
  export function createAnalyzeCommand() {
362
646
  const cmd = new Command('analyze')
363
647
  .description('Analyze disk space usage')
648
+ .argument('[path]', 'Path to analyze (default: home directory)')
364
649
  .option('-p, --path <path>', 'Path to analyze (default: home directory)')
365
- .option('-d, --depth <number>', 'Scan depth (default: 1)', parseInt)
366
- .option('-l, --limit <number>', 'Max items to show (default: 15)', parseInt)
367
- .action(async (options) => {
368
- await analyzeCommand(options);
650
+ .action(async (positionalPath, options) => {
651
+ await analyzeCommand({ ...options, positionalPath });
369
652
  });
370
653
  return enhanceCommandHelp(cmd);
371
654
  }