tri-cli 0.0.2 → 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 ADDED
@@ -0,0 +1,67 @@
1
+ # tri-cli
2
+
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)
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install -g tri-cli
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ tri [directory] [options]
17
+ ```
18
+
19
+ ### Examples
20
+
21
+ ```bash
22
+ tri . # visualize current directory
23
+ tri ../bionemo -L 2 # show 2 levels deep
24
+ tri --dir src # specify directory with flag
25
+ tri --dir src -ns # create tree without dir/file size (faster, but no treemap)
26
+ ```
27
+
28
+ ### Interactive Controls
29
+
30
+ | Key | Action |
31
+ | ----- | ------------------------------ |
32
+ | ↑ / ↓ | Navigate files and folders |
33
+ | Enter | Expand or collapse a directory |
34
+ | t | Toggle Treemap view |
35
+ | Esc | Clear typed shortcut |
36
+ | q | Quit |
37
+
38
+ ### Options
39
+
40
+ | Option | Description |
41
+ | -------------- | ----------------------------------------------- |
42
+ | `--dir <path>` | Directory to visualize (optional if positional) |
43
+ | `-L <level>` | Depth level to expand initially (default: 1) |
44
+ | `-ns` | Don't calculate sizes (faster, but no treemap) |
45
+ | `-h, --help` | Show help message |
46
+
47
+ **Example:**
48
+
49
+ ```bash
50
+ tri ../bionemo -L 2
51
+ ```
52
+
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,13 +4,17 @@ 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',
11
- text: '#abb2bf',
14
+ text: 'rgb(206, 205, 195)',
15
+ text2: 'rgb(135, 133, 128)',
12
16
  textAlt: 'white',
13
- red: '#e06c75',
17
+ red: 'rgb(209, 77, 65)',
14
18
  orange: 'rgb(218, 112, 44)',
15
19
  yellow: '#e5c07b',
16
20
  green: '#98c379',
@@ -19,8 +23,6 @@ const oneHunter = {
19
23
  purple: 'rgb(139, 126, 200)',
20
24
  magenta: 'rgb(206, 93, 151)'
21
25
  };
22
-
23
- // Function to format bytes to human readable
24
26
  const formatBytes = bytes => {
25
27
  if (bytes === 0) return '0 B';
26
28
  const k = 1024;
@@ -29,55 +31,62 @@ const formatBytes = bytes => {
29
31
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
30
32
  };
31
33
 
32
- // Function to get directory size recursively
33
- const getDirSize = dirPath => {
34
- let size = 0;
34
+ // Async non-blocking directory size
35
+ const getDirSizeAsync = async dirPath => {
35
36
  try {
36
- const items = fs.readdirSync(dirPath);
37
- for (const item of items) {
38
- const itemPath = path.join(dirPath, item);
39
- try {
40
- const stats = fs.statSync(itemPath);
41
- if (stats.isFile()) {
42
- size += stats.size;
43
- } else if (stats.isDirectory()) {
44
- size += getDirSize(itemPath);
45
- }
46
- } 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;
47
45
  }
48
- } catch {}
49
- return size;
46
+ return 0;
47
+ } catch {
48
+ return 0;
49
+ }
50
50
  };
51
-
52
- // Function to read directory structure (unbounded depth)
53
- const readDirTree = dirPath => {
51
+ const readDirTree = (dirPath, noSize = true, currentLevel = 0, maxLevel = Infinity) => {
54
52
  try {
55
53
  const stats = fs.statSync(dirPath);
56
- 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
+ }
57
60
  if (!stats.isDirectory()) {
58
- const size = stats.size;
61
+ const size = noSize ? 0 : stats.size;
59
62
  return {
60
63
  name,
61
64
  path: dirPath,
62
65
  isFile: true,
63
- size,
64
- value: size > 0 ? size : 100
66
+ size
65
67
  };
66
68
  }
67
- const children = fs.readdirSync(dirPath).filter(file => !file.startsWith('.')).map(file => readDirTree(path.join(dirPath, file))).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);
68
79
  return {
69
80
  name,
70
81
  path: dirPath,
71
82
  isFile: false,
72
- size: getDirSize(dirPath),
73
- children: children.length > 0 ? children : undefined
83
+ size: 0,
84
+ children
74
85
  };
75
86
  } catch {
76
87
  return null;
77
88
  }
78
89
  };
79
-
80
- // Get color based on file extension
81
90
  const getFileColor = filename => {
82
91
  const ext = path.extname(filename).toLowerCase();
83
92
  const colorMap = {
@@ -90,7 +99,7 @@ const getFileColor = filename => {
90
99
  '.json': oneHunter.purple,
91
100
  '.html': oneHunter.cyan,
92
101
  '.css': oneHunter.cyan,
93
- '.md': oneHunter.text,
102
+ '.md': oneHunter.orange,
94
103
  '.txt': oneHunter.textAlt,
95
104
  '.sh': oneHunter.green,
96
105
  '.yml': oneHunter.purple,
@@ -100,14 +109,15 @@ const getFileColor = filename => {
100
109
  '.png': oneHunter.blue,
101
110
  '.jpg': oneHunter.blue,
102
111
  '.gif': oneHunter.blue,
103
- '.pdf': oneHunter.red
112
+ '.pdf': oneHunter.red,
113
+ '.toml': oneHunter.text2
104
114
  };
105
115
  return colorMap[ext] || oneHunter.text;
106
116
  };
107
117
 
108
- // Generate shortcut label
118
+ // Exclude m, t, d
109
119
  const generateShortcut = (index, isFile, parentShortcut = '') => {
110
- const letters = 'abcdefghijklmnoprsuvwxyz'; // omitting q, t
120
+ const letters = 'abcefgijklnopqrsuvwxyz';
111
121
  const base = letters.length;
112
122
  if (isFile) return `${parentShortcut}${index + 1}`;
113
123
  let label = '';
@@ -118,177 +128,341 @@ const generateShortcut = (index, isFile, parentShortcut = '') => {
118
128
  }
119
129
  return parentShortcut + label;
120
130
  };
121
-
122
- // TreeRow
131
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
123
132
  const TreeRow = /*#__PURE__*/React.memo(({
124
133
  node,
125
134
  isSelected,
126
135
  collapsed,
127
- shortcutInput
136
+ shortcutInput,
137
+ noSize,
138
+ calculatedSizes,
139
+ loadingPaths,
140
+ spinnerFrame
128
141
  }) => {
129
142
  const isCollapsed = collapsed.has(node.data.path);
130
143
  const hasChildren = node.children && node.children.length > 0;
131
- let prefix = '';
132
- let current = node;
133
- const segments = [];
134
- while (current.parent) {
135
- const parent = current.parent;
136
- const siblings = parent.children || [];
137
- const isLastChild = siblings[siblings.length - 1] === current;
138
- segments.unshift(isLastChild ? ' ' : '│ ');
139
- current = parent;
140
- }
141
- if (node.parent) {
142
- const siblings = node.parent.children || [];
143
- const isLastChild = siblings[siblings.length - 1] === node;
144
- prefix = segments.join('').slice(0, -2) + (isLastChild ? '└─' : '├─');
145
- }
146
- let icon = hasChildren ? isCollapsed ? '▶ ' : '▼ ' : ' ';
144
+ const isLoading = loadingPaths.has(node.data.path);
147
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
148
155
  const renderShortcut = () => {
149
156
  if (!shortcutInput) return /*#__PURE__*/React.createElement(Text, {
150
157
  color: oneHunter.textAlt,
151
158
  dimColor: true
152
- }, ' ', "[", node.shortcut, "]");
159
+ }, ' ', "[", node.shortcut, "]");
153
160
  if (node.shortcut.startsWith(shortcutInput)) {
154
161
  const matched = node.shortcut.slice(0, shortcutInput.length);
155
162
  const rest = node.shortcut.slice(shortcutInput.length);
156
163
  return /*#__PURE__*/React.createElement(Text, {
157
164
  color: oneHunter.textAlt
158
- }, ' [', /*#__PURE__*/React.createElement(Text, {
165
+ }, ' ', "[", /*#__PURE__*/React.createElement(Text, {
159
166
  underline: true,
160
167
  color: oneHunter.yellow
161
- }, matched), rest, ']');
168
+ }, matched), rest, "]");
162
169
  }
163
170
  return /*#__PURE__*/React.createElement(Text, {
164
171
  color: oneHunter.textAlt,
165
172
  dimColor: true
166
- }, ' ', "[", node.shortcut, "]");
173
+ }, ' ', "[", node.shortcut, "]");
167
174
  };
168
175
  return /*#__PURE__*/React.createElement(Box, null, /*#__PURE__*/React.createElement(Text, null, /*#__PURE__*/React.createElement(Text, {
169
176
  color: oneHunter.textAlt
170
- }, prefix), /*#__PURE__*/React.createElement(Text, {
177
+ }, node.depth > 0 ? ' '.repeat(node.depth * 2) : ''), /*#__PURE__*/React.createElement(Text, {
171
178
  color: hasChildren ? oneHunter.cyan : oneHunter.textAlt
172
- }, icon), /*#__PURE__*/React.createElement(Text, {
179
+ }, hasChildren ? isCollapsed ? '▶ ' : '▼ ' : ' '), /*#__PURE__*/React.createElement(Text, {
173
180
  bold: isSelected,
174
181
  color: isSelected ? oneHunter.bg : fileColor,
175
182
  backgroundColor: isSelected ? oneHunter.green : undefined
176
183
  }, node.data.name), hasChildren && !isCollapsed && /*#__PURE__*/React.createElement(Text, {
177
184
  color: oneHunter.textAlt
178
- }, " (", node.children.length, ")"), node.data.size > 0 && /*#__PURE__*/React.createElement(Text, {
179
- color: oneHunter.green
180
- }, " ", formatBytes(node.data.size)), renderShortcut()));
181
- }, (p, n) => p.isSelected === n.isSelected && p.collapsed === n.collapsed && p.shortcutInput === n.shortcutInput);
185
+ }, " (", node.children.length, ")"), isLoading ? /*#__PURE__*/React.createElement(Text, {
186
+ color: oneHunter.yellow
187
+ }, " ", SPINNER_FRAMES[spinnerFrame]) : displaySize > 0 ? /*#__PURE__*/React.createElement(Text, {
188
+ color: oneHunter.cyan
189
+ }, " ", formatBytes(displaySize)) : /*#__PURE__*/React.createElement(Text, null, " "), renderShortcut()));
190
+ });
182
191
  const TreeVisualization = ({
183
192
  dirPath = '.',
184
- maxLevel = 1
193
+ maxLevel = 1,
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), [dirPath]);
197
- if (!treeData) return /*#__PURE__*/React.createElement(Text, {
198
- color: "red"
199
- }, "Error: Could not read directory");
200
- const root = useMemo(() => hierarchy(treeData), [treeData]);
201
211
 
202
- // --- Initial collapse based on -L ---
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]);
203
257
  useEffect(() => {
258
+ // Only initialize collapse state on first mount, not on every rootTree change
259
+ if (initialized) return;
204
260
  const newCollapsed = new Set();
205
261
  const applyLevel = (node, level = 0) => {
206
262
  if (node.children) {
207
- if (level >= maxLevel) newCollapsed.add(node.data.path);
208
- 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));
209
265
  }
210
266
  };
211
- applyLevel(root);
267
+ applyLevel(rootTree);
212
268
  setCollapsed(newCollapsed);
213
269
  setInitialized(true);
214
- }, [maxLevel, root]);
215
-
216
- // Assign shortcuts
217
- const rootWithShortcuts = useMemo(() => {
218
- const assignShortcuts = (node, parentShortcut = '', siblingIndex = 0) => {
219
- const isFile = node.data.isFile;
220
- node.shortcut = generateShortcut(siblingIndex, isFile, parentShortcut);
221
- if (node.children) {
222
- node.children.forEach((child, i) => assignShortcuts(child, node.shortcut, i));
223
- }
224
- };
225
- const cloned = root.copy();
226
- assignShortcuts(cloned);
227
- return cloned;
228
- }, [root]);
229
-
230
- // Flatten visible nodes
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);
231
276
  const visibleNodes = useMemo(() => {
232
277
  const nodes = [];
233
278
  const traverse = node => {
234
279
  nodes.push(node);
235
- if (node.children && !collapsed.has(node.data.path)) {
236
- node.children.forEach(traverse);
237
- }
280
+ if (node.children && !collapsed.has(node.data.path)) node.children.forEach(traverse);
238
281
  };
239
- traverse(rootWithShortcuts);
282
+ traverse(rootTree);
240
283
  return nodes;
241
- }, [rootWithShortcuts, collapsed]);
284
+ }, [rootTree, collapsed]);
242
285
  const shortcutMap = useMemo(() => {
243
286
  const map = new Map();
244
287
  visibleNodes.forEach((node, index) => map.set(node.shortcut, index));
245
288
  return map;
246
289
  }, [visibleNodes]);
247
- 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
+ }
248
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
+ }
249
428
  if (input === 't') {
250
- 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);
251
447
  return;
252
448
  }
253
- if (showTreeMap) return;
254
449
  if (key.upArrow) {
255
450
  setSelectedIndex(p => Math.max(0, p - 1));
256
451
  setShortcutInput('');
257
452
  return;
258
- } else if (key.downArrow) {
453
+ }
454
+ if (key.downArrow) {
259
455
  setSelectedIndex(p => Math.min(visibleNodes.length - 1, p + 1));
260
456
  setShortcutInput('');
261
457
  return;
262
458
  }
263
-
264
- // ✅ ENTER behavior
265
459
  if (key.return) {
266
- if (shortcutInput && shortcutMap.has(shortcutInput)) {
267
- const shortcutIndex = shortcutMap.get(shortcutInput);
268
- const shortcutNode = visibleNodes[shortcutIndex];
269
-
270
- // Jump and toggle collapse (in one go)
271
- setSelectedIndex(shortcutIndex);
272
- if (shortcutNode.children) {
273
- setCollapsed(prev => {
274
- const next = new Set(prev);
275
- if (next.has(shortcutNode.data.path)) next.delete(shortcutNode.data.path);else next.add(shortcutNode.data.path);
276
- return next;
277
- });
278
- }
279
- setShortcutInput('');
280
- return;
281
- }
282
-
283
- // Otherwise toggle current node
284
460
  const node = visibleNodes[selectedIndex];
285
- if (node.children) {
286
- setCollapsed(prev => {
287
- const next = new Set(prev);
288
- if (next.has(node.data.path)) next.delete(node.data.path);else next.add(node.data.path);
289
- return next;
290
- });
291
- }
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
+ });
292
466
  setShortcutInput('');
293
467
  return;
294
468
  }
@@ -296,85 +470,96 @@ const TreeVisualization = ({
296
470
  setShortcutInput('');
297
471
  return;
298
472
  }
299
-
300
- // Typing a shortcut
301
473
  if (input && /^[a-z0-9]$/.test(input)) {
302
474
  const newInput = shortcutInput + input;
303
475
  setShortcutInput(newInput);
304
-
305
- // Move highlight immediately
306
- if (shortcutMap.has(newInput)) {
307
- setSelectedIndex(shortcutMap.get(newInput));
308
- }
476
+ if (shortcutMap.has(newInput)) setSelectedIndex(shortcutMap.get(newInput));
309
477
  }
310
478
  });
311
479
  if (showTreeMap) {
312
480
  const node = visibleNodes[selectedIndex];
313
- if (!node) return /*#__PURE__*/React.createElement(Text, {
314
- color: oneHunter.red
315
- }, "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);
316
501
  return /*#__PURE__*/React.createElement(TreeMapView, {
317
502
  selectedNode: node,
318
- stdout: stdout
503
+ stdout: stdout,
504
+ calculatedSizes: calculatedSizes,
505
+ loadingPaths: loadingPaths
319
506
  });
320
507
  }
321
-
322
- // --- Viewport logic ---
323
- const getViewportWindow = () => {
324
- let start = Math.max(0, selectedIndex - Math.floor(viewportHeight / 2));
325
- let end = start + viewportHeight;
326
- if (end > visibleNodes.length) {
327
- end = visibleNodes.length;
328
- start = Math.max(0, end - viewportHeight);
329
- }
330
- return {
331
- start,
332
- end
333
- };
334
- };
335
- const {
336
- start,
337
- end
338
- } = getViewportWindow();
508
+ const start = Math.max(0, selectedIndex - Math.floor(viewportHeight / 2));
509
+ const end = Math.min(visibleNodes.length, start + viewportHeight);
339
510
  const viewportNodes = visibleNodes.slice(start, end);
340
511
  if (!initialized) return null;
341
512
  return /*#__PURE__*/React.createElement(Box, {
342
513
  flexDirection: "column"
514
+ }, /*#__PURE__*/React.createElement(Box, {
515
+ justifyContent: "space-between"
343
516
  }, /*#__PURE__*/React.createElement(Text, {
344
517
  color: oneHunter.textAlt,
345
518
  dimColor: true
346
- }, "\u2191/\u2193: Navigate | Enter: Toggle | t: TreeMap | 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, {
347
520
  color: oneHunter.yellow
348
- }, "\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, {
349
522
  key: start + i,
350
523
  node: node,
351
524
  isSelected: start + i === selectedIndex,
352
525
  collapsed: collapsed,
353
- shortcutInput: shortcutInput
354
- })), end < visibleNodes.length && /*#__PURE__*/React.createElement(Text, {
355
- color: oneHunter.yellow
356
- }, "\u22EE (", visibleNodes.length - end, " more below)"));
526
+ shortcutInput: shortcutInput,
527
+ noSize: noSize,
528
+ calculatedSizes: calculatedSizes,
529
+ loadingPaths: loadingPaths,
530
+ spinnerFrame: spinnerFrame
531
+ })));
357
532
  };
358
533
 
359
- // --- CLI args ---
534
+ // --- CLI ---
360
535
  const args = process.argv.slice(2);
536
+ if (args.includes('--help') || args.includes('-h')) {
537
+ console.log(`
538
+ Usage:
539
+ tri [directory] [options]
361
540
 
362
- // Dir flag
541
+ Options:
542
+ -L <level> Depth (default 1)
543
+ -s, --size Preload all sizes (default off)
544
+ -c, --collapse Start collapsed
545
+ -h, --help Show help
546
+ `);
547
+ process.exit(0);
548
+ }
363
549
  const dirIndex = args.indexOf('--dir');
364
- let dirPath;
365
550
  const firstNonFlagArg = args.find(arg => !arg.startsWith('-'));
366
- if (dirIndex !== -1 && args[dirIndex + 1]) {
367
- dirPath = args[dirIndex + 1];
368
- } else if (firstNonFlagArg) {
369
- dirPath = firstNonFlagArg;
370
- } else {
371
- dirPath = '.';
372
- }
373
-
374
- // Handle -L flag
551
+ const dirPath = dirIndex !== -1 && args[dirIndex + 1] ? args[dirIndex + 1] : firstNonFlagArg || '.';
375
552
  const levelIndex = args.indexOf('-L');
376
553
  const maxLevel = levelIndex !== -1 && args[levelIndex + 1] ? parseInt(args[levelIndex + 1], 10) : 1;
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');
377
560
  render(/*#__PURE__*/React.createElement(TreeVisualization, {
378
561
  dirPath: dirPath,
379
- maxLevel: maxLevel
562
+ maxLevel: maxLevel,
563
+ noSize: !preloadSizes,
564
+ collapseAll: collapseAll
380
565
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tri-cli",
3
- "version": "0.0.2",
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",
package/readme.md DELETED
@@ -1,25 +0,0 @@
1
- # tri
2
-
3
- > This readme is automatically generated by [create-ink-app](https://github.com/vadimdemedes/create-ink-app)
4
-
5
- ## Install
6
-
7
- ```bash
8
- $ npm install --global tri
9
- ```
10
-
11
- ## CLI
12
-
13
- ```
14
- $ tri --help
15
-
16
- Usage
17
- $ tri
18
-
19
- Options
20
- --name Your name
21
-
22
- Examples
23
- $ tri --name=Jane
24
- Hello, Jane
25
- ```