tri-cli 0.0.3 → 0.0.4

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/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # tri-cli
2
2
 
3
- A terminal-based interactive directory visualizer, like `tree` but with interactivity and treemaps:
3
+ A terminal-based interactive directory visualizer, like `tree` but with keyboard navgiation, shortcuts, colors, and [treemap](https://en.wikipedia.org/wiki/Treemapping) visuals:
4
+
5
+ ![tri demo in gif](/assets/readme.gif)
4
6
 
5
7
  ## Installation
6
8
 
@@ -20,6 +22,7 @@ tri [directory] [options]
20
22
  tri . # visualize current directory
21
23
  tri ../bionemo -L 2 # show 2 levels deep
22
24
  tri --dir src # specify directory with flag
25
+ tri --dir src -ns # create tree without dir/file size (faster, but no treemap)
23
26
  ```
24
27
 
25
28
  ### Interactive Controls
@@ -38,10 +41,9 @@ tri --dir src # specify directory with flag
38
41
  | -------------- | ----------------------------------------------- |
39
42
  | `--dir <path>` | Directory to visualize (optional if positional) |
40
43
  | `-L <level>` | Depth level to expand initially (default: 1) |
44
+ | `-ns` | Don't calculate sizes (faster, but no treemap) |
41
45
  | `-h, --help` | Show help message |
42
46
 
43
- ---
44
-
45
47
  **Example:**
46
48
 
47
49
  ```bash
@@ -49,3 +51,17 @@ tri ../bionemo -L 2
49
51
  ```
50
52
 
51
53
  Type `t` to open (and close) the treemap view, navigate with arrow keys, and press `q` to quit.
54
+
55
+ **Example:**
56
+
57
+ It's probably easiest to just uninstall and link:
58
+
59
+ ```bash
60
+ npm run build
61
+
62
+ npm link
63
+ ```
64
+
65
+ **License:**
66
+
67
+ MIT
package/dist/Treemap.js CHANGED
@@ -389,5 +389,5 @@ export const TreeMapView = ({
389
389
  dimColor: true,
390
390
  color: oneHunter.textAlt,
391
391
  marginTop: 1
392
- }, "Showing ", firstLevelNodes.length, " items | Total: ", formatBytes(root.value || 0)));
392
+ }, "Showing ", firstLevelNodes.length, " items | Total:", ' ', formatBytes(root.value || 0)));
393
393
  };
package/dist/cli.js CHANGED
@@ -4,7 +4,10 @@ import { render, Text, Box, useInput, useStdout } from 'ink';
4
4
  import { hierarchy } from 'd3-hierarchy';
5
5
  import fs from 'fs';
6
6
  import path from 'path';
7
+ import { exec } from 'child_process';
8
+ import util from 'util';
7
9
  import { TreeMapView } from './Treemap.js';
10
+ const execAsync = util.promisify(exec);
8
11
  const oneHunter = {
9
12
  bg: '#282c34',
10
13
  bgAlt: '#21252b',
@@ -20,8 +23,6 @@ const oneHunter = {
20
23
  purple: 'rgb(139, 126, 200)',
21
24
  magenta: 'rgb(206, 93, 151)'
22
25
  };
23
-
24
- // --- Helpers ---
25
26
  const formatBytes = bytes => {
26
27
  if (bytes === 0) return '0 B';
27
28
  const k = 1024;
@@ -29,55 +30,64 @@ const formatBytes = bytes => {
29
30
  const i = Math.floor(Math.log(bytes) / Math.log(k));
30
31
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
31
32
  };
32
- const getDirSize = dirPath => {
33
- let size = 0;
33
+
34
+ // Async non-blocking directory size
35
+ const getDirSizeAsync = async dirPath => {
34
36
  try {
35
- const items = fs.readdirSync(dirPath);
36
- for (const item of items) {
37
- const itemPath = path.join(dirPath, item);
38
- try {
39
- const stats = fs.statSync(itemPath);
40
- if (stats.isFile()) {
41
- size += stats.size;
42
- } else if (stats.isDirectory()) {
43
- size += getDirSize(itemPath);
44
- }
45
- } catch {}
37
+ const escapedPath = dirPath.replace(/"/g, '\\"');
38
+ const {
39
+ stdout
40
+ } = await execAsync(`du -sk "${escapedPath}" 2>/dev/null`);
41
+ const match = stdout.trim().split(/\s+/);
42
+ if (match.length >= 1) {
43
+ const sizeInKB = parseInt(match[0], 10);
44
+ return isNaN(sizeInKB) ? 0 : sizeInKB * 1024;
46
45
  }
47
- } catch {}
48
- return size;
46
+ return 0;
47
+ } catch {
48
+ return 0;
49
+ }
49
50
  };
50
-
51
- // ✅ Updated: allow skipping size computation
52
- const readDirTree = (dirPath, noSize = false) => {
51
+ const readDirTree = (dirPath, noSize = true, currentLevel = 0, maxLevel = Infinity) => {
53
52
  try {
54
53
  const stats = fs.statSync(dirPath);
55
- const name = path.basename(dirPath) || dirPath;
54
+ let name = path.basename(dirPath);
55
+ // If basename is empty (root) or '.', use the absolute path's last component or full path
56
+ if (!name || name === '.') {
57
+ const resolved = path.resolve(dirPath);
58
+ name = path.basename(resolved) || resolved;
59
+ }
56
60
  if (!stats.isDirectory()) {
57
61
  const size = noSize ? 0 : stats.size;
58
62
  return {
59
63
  name,
60
64
  path: dirPath,
61
65
  isFile: true,
62
- size,
63
- value: size > 0 ? size : 100
66
+ size
64
67
  };
65
68
  }
66
- const children = fs.readdirSync(dirPath).filter(file => !file.startsWith('.')).map(file => readDirTree(path.join(dirPath, file), noSize)).filter(Boolean);
69
+ if (currentLevel >= maxLevel) {
70
+ return {
71
+ name,
72
+ path: dirPath,
73
+ isFile: false,
74
+ size: 0,
75
+ children: undefined
76
+ };
77
+ }
78
+ const children = fs.readdirSync(dirPath).filter(f => !f.startsWith('.')).map(f => readDirTree(path.join(dirPath, f), noSize, currentLevel + 1, maxLevel)).filter(Boolean);
67
79
  return {
68
80
  name,
69
81
  path: dirPath,
70
82
  isFile: false,
71
- size: noSize ? 0 : getDirSize(dirPath),
72
- children: children.length > 0 ? children : undefined
83
+ size: 0,
84
+ children
73
85
  };
74
86
  } catch {
75
87
  return null;
76
88
  }
77
89
  };
78
90
  const getFileColor = filename => {
79
- if (filename === 'VERSION') return oneHunter.purple;
80
- if (filename === 'Dockerfile') return oneHunter.blue;
81
91
  const ext = path.extname(filename).toLowerCase();
82
92
  const colorMap = {
83
93
  '.js': oneHunter.yellow,
@@ -104,8 +114,10 @@ const getFileColor = filename => {
104
114
  };
105
115
  return colorMap[ext] || oneHunter.text;
106
116
  };
117
+
118
+ // Exclude m, t, d
107
119
  const generateShortcut = (index, isFile, parentShortcut = '') => {
108
- const letters = 'abcdefghijklmnoprsuvwxyz'; // omitting q, t
120
+ const letters = 'abcefgijklnopqrsuvwxyz';
109
121
  const base = letters.length;
110
122
  if (isFile) return `${parentShortcut}${index + 1}`;
111
123
  let label = '';
@@ -116,167 +128,341 @@ const generateShortcut = (index, isFile, parentShortcut = '') => {
116
128
  }
117
129
  return parentShortcut + label;
118
130
  };
119
-
120
- // --- Components ---
131
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
121
132
  const TreeRow = /*#__PURE__*/React.memo(({
122
133
  node,
123
134
  isSelected,
124
135
  collapsed,
125
136
  shortcutInput,
126
- noSize
137
+ noSize,
138
+ calculatedSizes,
139
+ loadingPaths,
140
+ spinnerFrame
127
141
  }) => {
128
142
  const isCollapsed = collapsed.has(node.data.path);
129
143
  const hasChildren = node.children && node.children.length > 0;
130
- let prefix = '';
131
- let current = node;
132
- const segments = [];
133
- while (current.parent) {
134
- const parent = current.parent;
135
- const siblings = parent.children || [];
136
- const isLastChild = siblings[siblings.length - 1] === current;
137
- segments.unshift(isLastChild ? ' ' : '│ ');
138
- current = parent;
139
- }
140
- if (node.parent) {
141
- const siblings = node.parent.children || [];
142
- const isLastChild = siblings[siblings.length - 1] === node;
143
- prefix = segments.join('').slice(0, -2) + (isLastChild ? '└─' : '├─');
144
- }
145
- let icon = hasChildren ? isCollapsed ? '▶ ' : '▼ ' : ' ';
144
+ const isLoading = loadingPaths.has(node.data.path);
146
145
  const fileColor = node.data.isFile ? getFileColor(node.data.name) : oneHunter.magenta;
146
+ let displaySize = 0;
147
+ // Use calculated size if available, otherwise fall back to node.data.size
148
+ if (calculatedSizes && calculatedSizes.has(node.data.path)) {
149
+ displaySize = calculatedSizes.get(node.data.path);
150
+ } else {
151
+ displaySize = node.data.size;
152
+ }
153
+
154
+ // ✅ Restore highlight logic
147
155
  const renderShortcut = () => {
148
156
  if (!shortcutInput) return /*#__PURE__*/React.createElement(Text, {
149
157
  color: oneHunter.textAlt,
150
158
  dimColor: true
151
- }, ' ', "[", node.shortcut, "]");
159
+ }, ' ', "[", node.shortcut, "]");
152
160
  if (node.shortcut.startsWith(shortcutInput)) {
153
161
  const matched = node.shortcut.slice(0, shortcutInput.length);
154
162
  const rest = node.shortcut.slice(shortcutInput.length);
155
163
  return /*#__PURE__*/React.createElement(Text, {
156
164
  color: oneHunter.textAlt
157
- }, ' [', /*#__PURE__*/React.createElement(Text, {
165
+ }, ' ', "[", /*#__PURE__*/React.createElement(Text, {
158
166
  underline: true,
159
167
  color: oneHunter.yellow
160
- }, matched), rest, ']');
168
+ }, matched), rest, "]");
161
169
  }
162
170
  return /*#__PURE__*/React.createElement(Text, {
163
171
  color: oneHunter.textAlt,
164
172
  dimColor: true
165
- }, ' ', "[", node.shortcut, "]");
173
+ }, ' ', "[", node.shortcut, "]");
166
174
  };
167
175
  return /*#__PURE__*/React.createElement(Box, null, /*#__PURE__*/React.createElement(Text, null, /*#__PURE__*/React.createElement(Text, {
168
176
  color: oneHunter.textAlt
169
- }, prefix), /*#__PURE__*/React.createElement(Text, {
177
+ }, node.depth > 0 ? ' '.repeat(node.depth * 2) : ''), /*#__PURE__*/React.createElement(Text, {
170
178
  color: hasChildren ? oneHunter.cyan : oneHunter.textAlt
171
- }, icon), /*#__PURE__*/React.createElement(Text, {
179
+ }, hasChildren ? isCollapsed ? '▶ ' : '▼ ' : ' '), /*#__PURE__*/React.createElement(Text, {
172
180
  bold: isSelected,
173
181
  color: isSelected ? oneHunter.bg : fileColor,
174
182
  backgroundColor: isSelected ? oneHunter.green : undefined
175
183
  }, node.data.name), hasChildren && !isCollapsed && /*#__PURE__*/React.createElement(Text, {
176
184
  color: oneHunter.textAlt
177
- }, " (", node.children.length, ")"), !noSize && node.data.size > 0 && /*#__PURE__*/React.createElement(Text, {
185
+ }, " (", node.children.length, ")"), isLoading ? /*#__PURE__*/React.createElement(Text, {
186
+ color: oneHunter.yellow
187
+ }, " ", SPINNER_FRAMES[spinnerFrame]) : displaySize > 0 ? /*#__PURE__*/React.createElement(Text, {
178
188
  color: oneHunter.cyan
179
- }, " ", formatBytes(node.data.size)), renderShortcut()));
180
- }, (p, n) => p.isSelected === n.isSelected && p.collapsed === n.collapsed && p.shortcutInput === n.shortcutInput && p.noSize === n.noSize);
189
+ }, " ", formatBytes(displaySize)) : /*#__PURE__*/React.createElement(Text, null, " "), renderShortcut()));
190
+ });
181
191
  const TreeVisualization = ({
182
192
  dirPath = '.',
183
193
  maxLevel = 1,
184
- noSize = false
194
+ noSize = true,
195
+ collapseAll = false
185
196
  }) => {
186
197
  const [selectedIndex, setSelectedIndex] = useState(0);
187
198
  const [collapsed, setCollapsed] = useState(new Set());
188
199
  const [shortcutInput, setShortcutInput] = useState('');
189
200
  const [showTreeMap, setShowTreeMap] = useState(false);
190
201
  const [initialized, setInitialized] = useState(false);
202
+ const [calculatedSizes, setCalculatedSizes] = useState(new Map());
203
+ const [loadingPaths, setLoadingPaths] = useState(new Set());
204
+ const [spinnerFrame, setSpinnerFrame] = useState(0);
205
+ const [rootTree, setRootTree] = useState(() => hierarchy(readDirTree(dirPath, noSize, 0, maxLevel)));
191
206
  const {
192
207
  stdout
193
208
  } = useStdout();
194
209
  const terminalHeight = stdout?.rows || 24;
195
210
  const viewportHeight = Math.max(5, terminalHeight - 7);
196
- const treeData = useMemo(() => readDirTree(dirPath, noSize), [dirPath, noSize]);
197
- if (!treeData) return /*#__PURE__*/React.createElement(Text, {
198
- color: "red"
199
- }, "Error: Could not read directory");
200
- const root = useMemo(() => hierarchy(treeData), [treeData]);
211
+
212
+ // Spinner animation loop
213
+ useEffect(() => {
214
+ const interval = setInterval(() => setSpinnerFrame(f => (f + 1) % SPINNER_FRAMES.length), 80);
215
+ return () => clearInterval(interval);
216
+ }, []);
217
+
218
+ // Preload all sizes when -s flag is used (noSize is false)
219
+ useEffect(() => {
220
+ if (noSize) return; // Only preload when sizes are requested
221
+
222
+ const preloadAllSizes = async () => {
223
+ const newMap = new Map();
224
+ const nodesToCalculate = [];
225
+
226
+ // Collect all nodes
227
+ const traverse = node => {
228
+ nodesToCalculate.push(node);
229
+ if (node.children) {
230
+ node.children.forEach(traverse);
231
+ }
232
+ };
233
+ traverse(rootTree);
234
+
235
+ // Set all nodes as loading
236
+ setLoadingPaths(new Set(nodesToCalculate.map(n => n.data.path)));
237
+
238
+ // Calculate sizes for all nodes with streaming updates
239
+ for (const node of nodesToCalculate) {
240
+ const sz = node.data.isFile ? fs.statSync(node.data.path).size : await getDirSizeAsync(node.data.path);
241
+ newMap.set(node.data.path, sz);
242
+
243
+ // Stream update immediately (using startTransition to deprioritize render)
244
+ React.startTransition(() => {
245
+ setCalculatedSizes(new Map(newMap));
246
+ setLoadingPaths(prev => {
247
+ const next = new Set(prev);
248
+ next.delete(node.data.path);
249
+ return next;
250
+ });
251
+ });
252
+ await new Promise(r => setTimeout(r, 0));
253
+ }
254
+ };
255
+ preloadAllSizes();
256
+ }, [noSize, rootTree]);
201
257
  useEffect(() => {
258
+ // Only initialize collapse state on first mount, not on every rootTree change
259
+ if (initialized) return;
202
260
  const newCollapsed = new Set();
203
261
  const applyLevel = (node, level = 0) => {
204
262
  if (node.children) {
205
- if (level >= maxLevel) newCollapsed.add(node.data.path);
206
- node.children.forEach(child => applyLevel(child, level + 1));
263
+ if (collapseAll && level >= 1 || level >= maxLevel) newCollapsed.add(node.data.path);
264
+ node.children.forEach(c => applyLevel(c, level + 1));
207
265
  }
208
266
  };
209
- applyLevel(root);
267
+ applyLevel(rootTree);
210
268
  setCollapsed(newCollapsed);
211
269
  setInitialized(true);
212
- }, [maxLevel, root]);
213
- const rootWithShortcuts = useMemo(() => {
214
- const assignShortcuts = (node, parentShortcut = '', siblingIndex = 0) => {
215
- const isFile = node.data.isFile;
216
- node.shortcut = generateShortcut(siblingIndex, isFile, parentShortcut);
217
- if (node.children) {
218
- node.children.forEach((child, i) => assignShortcuts(child, node.shortcut, i));
219
- }
220
- };
221
- const cloned = root.copy();
222
- assignShortcuts(cloned);
223
- return cloned;
224
- }, [root]);
270
+ }, []);
271
+ const assignShortcuts = (node, parentShortcut = '', siblingIndex = 0) => {
272
+ node.shortcut = generateShortcut(siblingIndex, node.data.isFile, parentShortcut);
273
+ if (node.children) node.children.forEach((child, i) => assignShortcuts(child, node.shortcut, i));
274
+ };
275
+ assignShortcuts(rootTree);
225
276
  const visibleNodes = useMemo(() => {
226
277
  const nodes = [];
227
278
  const traverse = node => {
228
279
  nodes.push(node);
229
- if (node.children && !collapsed.has(node.data.path)) {
230
- node.children.forEach(traverse);
231
- }
280
+ if (node.children && !collapsed.has(node.data.path)) node.children.forEach(traverse);
232
281
  };
233
- traverse(rootWithShortcuts);
282
+ traverse(rootTree);
234
283
  return nodes;
235
- }, [rootWithShortcuts, collapsed]);
284
+ }, [rootTree, collapsed]);
236
285
  const shortcutMap = useMemo(() => {
237
286
  const map = new Map();
238
287
  visibleNodes.forEach((node, index) => map.set(node.shortcut, index));
239
288
  return map;
240
289
  }, [visibleNodes]);
241
- useInput((input, key) => {
290
+ const calculateSizeAsync = async node => {
291
+ // Collect node and all its descendants (not just visible ones)
292
+ const nodesToCalculate = [];
293
+ const collectNodes = n => {
294
+ // Skip if already calculated
295
+ if (calculatedSizes.has(n.data.path)) {
296
+ // Still recurse to children in case they need calculation
297
+ if (n.children) {
298
+ n.children.forEach(collectNodes);
299
+ }
300
+ return;
301
+ }
302
+ nodesToCalculate.push(n);
303
+ if (n.children) {
304
+ n.children.forEach(collectNodes);
305
+ }
306
+ };
307
+ collectNodes(node);
308
+ if (nodesToCalculate.length === 0) return; // Nothing to calculate
309
+
310
+ // Mark all nodes as loading
311
+ const pathsToLoad = nodesToCalculate.map(n => n.data.path);
312
+ setLoadingPaths(prev => new Set([...prev, ...pathsToLoad]));
313
+ const newMap = new Map(calculatedSizes);
314
+
315
+ // Calculate and stream updates for each node individually
316
+ for (const targetNode of nodesToCalculate) {
317
+ const sz = targetNode.data.isFile ? fs.statSync(targetNode.data.path).size : await getDirSizeAsync(targetNode.data.path);
318
+ newMap.set(targetNode.data.path, sz);
319
+
320
+ // Stream the update immediately (using startTransition to deprioritize render)
321
+ React.startTransition(() => {
322
+ setCalculatedSizes(new Map(newMap));
323
+ setLoadingPaths(prev => {
324
+ const next = new Set(prev);
325
+ next.delete(targetNode.data.path);
326
+ return next;
327
+ });
328
+ });
329
+ await new Promise(r => setTimeout(r, 0));
330
+ }
331
+ };
332
+ const traverseDir = async node => {
333
+ if (node.data.isFile) return;
334
+
335
+ // If children are undefined (not loaded yet), load them
336
+ if (node.data.children === undefined) {
337
+ // Load the children
338
+ const newTree = readDirTree(node.data.path, true, 0, 1);
339
+ if (newTree && newTree.children) {
340
+ node.data.children = newTree.children;
341
+ // Update both states - remove from collapsed and rebuild tree
342
+ setCollapsed(prev => {
343
+ const next = new Set(prev);
344
+ next.delete(node.data.path);
345
+ return next;
346
+ });
347
+ const newRootData = rootTree.data;
348
+ setRootTree(hierarchy(newRootData));
349
+ }
350
+ } else {
351
+ // Children already loaded, just toggle expand
352
+ setCollapsed(prev => {
353
+ const next = new Set(prev);
354
+ if (next.has(node.data.path)) {
355
+ next.delete(node.data.path);
356
+ } else {
357
+ next.add(node.data.path);
358
+ }
359
+ return next;
360
+ });
361
+ }
362
+ };
363
+ useInput(async (input, key) => {
364
+ if (showTreeMap) {
365
+ setShowTreeMap(false);
366
+ return;
367
+ }
242
368
  if (input === 'q') process.exit(0);
369
+ if (input === 'C') {
370
+ // Toggle collapse/expand all children of the selected node's PARENT
371
+ const selectedNode = visibleNodes[selectedIndex];
372
+ if (!selectedNode || !selectedNode.parent) return;
373
+ const parentNode = selectedNode.parent;
374
+
375
+ // Get all descendant nodes with children
376
+ const descendants = [];
377
+ const collectDescendants = node => {
378
+ if (node.children && node.children.length > 0) {
379
+ descendants.push(node);
380
+ node.children.forEach(collectDescendants);
381
+ }
382
+ };
383
+ collectDescendants(parentNode);
384
+ if (descendants.length === 0) return; // No children to collapse
385
+
386
+ // Check if all descendants are collapsed
387
+ const allCollapsed = descendants.every(n => collapsed.has(n.data.path));
388
+ setCollapsed(prev => {
389
+ const next = new Set(prev);
390
+ descendants.forEach(n => {
391
+ if (allCollapsed) {
392
+ next.delete(n.data.path);
393
+ } else {
394
+ next.add(n.data.path);
395
+ }
396
+ });
397
+ return next;
398
+ });
399
+ return;
400
+ }
401
+ if (input === 'O') {
402
+ // Toggle collapse/expand ALL nodes in the entire tree
403
+ const allNodesWithChildren = [];
404
+ const collectAll = node => {
405
+ if (node.children && node.children.length > 0) {
406
+ allNodesWithChildren.push(node);
407
+ node.children.forEach(collectAll);
408
+ }
409
+ };
410
+ collectAll(rootTree);
411
+ if (allNodesWithChildren.length === 0) return;
412
+
413
+ // Check if all nodes are collapsed
414
+ const allCollapsed = allNodesWithChildren.every(n => collapsed.has(n.data.path));
415
+ setCollapsed(prev => {
416
+ const next = new Set(prev);
417
+ allNodesWithChildren.forEach(n => {
418
+ if (allCollapsed) {
419
+ next.delete(n.data.path);
420
+ } else {
421
+ next.add(n.data.path);
422
+ }
423
+ });
424
+ return next;
425
+ });
426
+ return;
427
+ }
243
428
  if (input === 't') {
244
- if (!noSize) setShowTreeMap(p => !p);
429
+ const node = visibleNodes[selectedIndex];
430
+ if (node) await traverseDir(node);
431
+ return;
432
+ }
433
+ if (input === 'm') {
434
+ const node = visibleNodes[selectedIndex];
435
+ if (!node) return;
436
+ // Start calculating sizes in the background if not already done (don't await)
437
+ if (!calculatedSizes.has(node.data.path)) {
438
+ calculateSizeAsync(node);
439
+ }
440
+ // Show treemap immediately
441
+ setShowTreeMap(true);
442
+ return;
443
+ }
444
+ if (input === 'd') {
445
+ const node = visibleNodes[selectedIndex];
446
+ if (node) await calculateSizeAsync(node);
245
447
  return;
246
448
  }
247
- if (showTreeMap) return;
248
449
  if (key.upArrow) {
249
450
  setSelectedIndex(p => Math.max(0, p - 1));
250
451
  setShortcutInput('');
251
452
  return;
252
- } else if (key.downArrow) {
453
+ }
454
+ if (key.downArrow) {
253
455
  setSelectedIndex(p => Math.min(visibleNodes.length - 1, p + 1));
254
456
  setShortcutInput('');
255
457
  return;
256
458
  }
257
459
  if (key.return) {
258
- if (shortcutInput && shortcutMap.has(shortcutInput)) {
259
- const shortcutIndex = shortcutMap.get(shortcutInput);
260
- const shortcutNode = visibleNodes[shortcutIndex];
261
- setSelectedIndex(shortcutIndex);
262
- if (shortcutNode.children) {
263
- setCollapsed(prev => {
264
- const next = new Set(prev);
265
- if (next.has(shortcutNode.data.path)) next.delete(shortcutNode.data.path);else next.add(shortcutNode.data.path);
266
- return next;
267
- });
268
- }
269
- setShortcutInput('');
270
- return;
271
- }
272
460
  const node = visibleNodes[selectedIndex];
273
- if (node.children) {
274
- setCollapsed(prev => {
275
- const next = new Set(prev);
276
- if (next.has(node.data.path)) next.delete(node.data.path);else next.add(node.data.path);
277
- return next;
278
- });
279
- }
461
+ if (node && node.children) setCollapsed(prev => {
462
+ const next = new Set(prev);
463
+ if (next.has(node.data.path)) next.delete(node.data.path);else next.add(node.data.path);
464
+ return next;
465
+ });
280
466
  setShortcutInput('');
281
467
  return;
282
468
  }
@@ -287,80 +473,76 @@ const TreeVisualization = ({
287
473
  if (input && /^[a-z0-9]$/.test(input)) {
288
474
  const newInput = shortcutInput + input;
289
475
  setShortcutInput(newInput);
290
- if (shortcutMap.has(newInput)) {
291
- setSelectedIndex(shortcutMap.get(newInput));
292
- }
476
+ if (shortcutMap.has(newInput)) setSelectedIndex(shortcutMap.get(newInput));
293
477
  }
294
478
  });
295
479
  if (showTreeMap) {
296
480
  const node = visibleNodes[selectedIndex];
297
- if (!node) return /*#__PURE__*/React.createElement(Text, {
298
- color: oneHunter.red
299
- }, "Error: No node selected");
481
+
482
+ // Populate value property for treemap visualization from calculatedSizes
483
+ const populateValues = n => {
484
+ if (!n) return;
485
+ // Set value from calculatedSizes if available
486
+ const calcSize = calculatedSizes.get(n.data.path);
487
+ if (calcSize !== undefined) {
488
+ n.data.value = calcSize;
489
+ } else if (n.data.isFile) {
490
+ n.data.value = n.data.size;
491
+ } else {
492
+ // For directories without calculated size, use 0 (will be updated as data streams in)
493
+ n.data.value = 0;
494
+ }
495
+ // Recursively populate children
496
+ if (n.children) {
497
+ n.children.forEach(populateValues);
498
+ }
499
+ };
500
+ populateValues(node);
300
501
  return /*#__PURE__*/React.createElement(TreeMapView, {
301
502
  selectedNode: node,
302
- stdout: stdout
503
+ stdout: stdout,
504
+ calculatedSizes: calculatedSizes,
505
+ loadingPaths: loadingPaths
303
506
  });
304
507
  }
305
- const getViewportWindow = () => {
306
- let start = Math.max(0, selectedIndex - Math.floor(viewportHeight / 2));
307
- let end = start + viewportHeight;
308
- if (end > visibleNodes.length) {
309
- end = visibleNodes.length;
310
- start = Math.max(0, end - viewportHeight);
311
- }
312
- return {
313
- start,
314
- end
315
- };
316
- };
317
- const {
318
- start,
319
- end
320
- } = getViewportWindow();
508
+ const start = Math.max(0, selectedIndex - Math.floor(viewportHeight / 2));
509
+ const end = Math.min(visibleNodes.length, start + viewportHeight);
321
510
  const viewportNodes = visibleNodes.slice(start, end);
322
511
  if (!initialized) return null;
323
512
  return /*#__PURE__*/React.createElement(Box, {
324
513
  flexDirection: "column"
325
- }, !noSize ? /*#__PURE__*/React.createElement(Text, {
326
- color: oneHunter.textAlt,
327
- dimColor: true
328
- }, "\u2191/\u2193: Navigate | Enter: Toggle | t: TreeMap | Esc: Clear | q: Quit") : /*#__PURE__*/React.createElement(Text, {
514
+ }, /*#__PURE__*/React.createElement(Box, {
515
+ justifyContent: "space-between"
516
+ }, /*#__PURE__*/React.createElement(Text, {
329
517
  color: oneHunter.textAlt,
330
518
  dimColor: true
331
- }, "\u2191/\u2193: Navigate | Enter: Toggle | Esc: Clear | q: Quit"), start > 0 && /*#__PURE__*/React.createElement(Text, {
519
+ }, "\u2191/\u2193: Navigate | Enter: Toggle | d: Size | t: Traverse | m: Map | q: Quit"), loadingPaths.size > 0 && /*#__PURE__*/React.createElement(Text, {
332
520
  color: oneHunter.yellow
333
- }, "\u22EE (", start, " more above)"), viewportNodes.map((node, i) => /*#__PURE__*/React.createElement(TreeRow, {
521
+ }, ' ', SPINNER_FRAMES[spinnerFrame], " Loading ", loadingPaths.size, "...")), viewportNodes.map((node, i) => /*#__PURE__*/React.createElement(TreeRow, {
334
522
  key: start + i,
335
523
  node: node,
336
524
  isSelected: start + i === selectedIndex,
337
525
  collapsed: collapsed,
338
526
  shortcutInput: shortcutInput,
339
- noSize: noSize
340
- })), end < visibleNodes.length && /*#__PURE__*/React.createElement(Text, {
341
- color: oneHunter.yellow
342
- }, "\u22EE (", visibleNodes.length - end, " more below)"));
527
+ noSize: noSize,
528
+ calculatedSizes: calculatedSizes,
529
+ loadingPaths: loadingPaths,
530
+ spinnerFrame: spinnerFrame
531
+ })));
343
532
  };
344
533
 
345
- // --- CLI args ---
534
+ // --- CLI ---
346
535
  const args = process.argv.slice(2);
347
-
348
- // Help
349
536
  if (args.includes('--help') || args.includes('-h')) {
350
537
  console.log(`
351
538
  Usage:
352
539
  tri [directory] [options]
353
540
 
354
- Examples:
355
- tri . # visualize current directory
356
- tri ../bionemo -L 2 # show 2 levels deep
357
- tri --dir src # specify directory with flag
358
-
359
541
  Options:
360
- --dir <path> Directory to visualize (optional if positional)
361
- -L <level> Depth level to expand initially (default: 1)
362
- --no-size, -ns Skip file size calculation (faster, disables treemap)
363
- -h, --help Show this help message
542
+ -L <level> Depth (default 1)
543
+ -s, --size Preload all sizes (default off)
544
+ -c, --collapse Start collapsed
545
+ -h, --help Show help
364
546
  `);
365
547
  process.exit(0);
366
548
  }
@@ -369,9 +551,15 @@ const firstNonFlagArg = args.find(arg => !arg.startsWith('-'));
369
551
  const dirPath = dirIndex !== -1 && args[dirIndex + 1] ? args[dirIndex + 1] : firstNonFlagArg || '.';
370
552
  const levelIndex = args.indexOf('-L');
371
553
  const maxLevel = levelIndex !== -1 && args[levelIndex + 1] ? parseInt(args[levelIndex + 1], 10) : 1;
372
- const noSize = args.includes('--no-size') || args.includes('-ns');
554
+ if (isNaN(maxLevel) || maxLevel < 1) {
555
+ console.error('tri: Invalid level, must be greater than 0.');
556
+ process.exit(1);
557
+ }
558
+ const collapseAll = args.includes('--collapse') || args.includes('-c');
559
+ const preloadSizes = args.includes('--size') || args.includes('-s');
373
560
  render(/*#__PURE__*/React.createElement(TreeVisualization, {
374
561
  dirPath: dirPath,
375
562
  maxLevel: maxLevel,
376
- noSize: noSize
563
+ noSize: !preloadSizes,
564
+ collapseAll: collapseAll
377
565
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tri-cli",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Interactive CLI directory tree visualizer with treemap view",
5
5
  "author": "Jared Wilber",
6
6
  "license": "MIT",
@@ -37,7 +37,8 @@
37
37
  "d3-hierarchy": "^3.1.2",
38
38
  "ink": "^4.1.0",
39
39
  "meow": "^11.0.0",
40
- "react": "^18.2.0"
40
+ "react": "^18.2.0",
41
+ "tri-cli": "^0.0.3"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@babel/cli": "^7.21.0",
@@ -65,7 +66,8 @@
65
66
  "extends": "xo-react",
66
67
  "prettier": true,
67
68
  "rules": {
68
- "react/prop-types": "off"
69
+ "react/prop-types": "off",
70
+ "unicorn/expiring-todo-comments": "off"
69
71
  }
70
72
  },
71
73
  "prettier": "@vdemedes/prettier-config",