@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.
- package/dist/commands/analyze.js +432 -149
- package/dist/commands/clean.js +23 -2
- package/dist/commands/config.js +16 -46
- package/dist/commands/status.js +39 -211
- package/dist/commands/uninstall.js +51 -28
- package/dist/index.js +10 -9
- package/dist/types/index.js +0 -1
- package/dist/ui/prompts.js +21 -11
- package/dist/utils/icon.js +67 -0
- package/package.json +8 -4
package/dist/commands/analyze.js
CHANGED
|
@@ -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 {
|
|
10
|
-
import {
|
|
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,
|
|
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
|
-
//
|
|
38
|
-
if (currentDepth
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
const
|
|
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:
|
|
78
|
+
isDirectory: false,
|
|
61
79
|
});
|
|
62
80
|
}
|
|
63
|
-
catch {
|
|
64
|
-
// Skip if cannot access
|
|
65
|
-
}
|
|
66
81
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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
|
|
85
|
-
const percentage =
|
|
86
|
-
const
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +=
|
|
150
|
+
bar += '{white-fg}░{/white-fg}';
|
|
107
151
|
}
|
|
108
152
|
}
|
|
109
|
-
|
|
110
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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,
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
.
|
|
366
|
-
|
|
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
|
}
|