tri-cli 0.0.3 → 0.0.51

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,13 +1,52 @@
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 navigation, shortcuts, colors, and [treemap](https://en.wikipedia.org/wiki/Treemapping) visuals:
4
+
5
+ ![tri demo in gif](/assets/readme.gif)
6
+
4
7
 
5
8
  ## Installation
6
9
 
10
+ ### Global Installation (Recommended)
11
+
12
+ It's recommended to install globally:
13
+
7
14
  ```bash
8
15
  npm install -g tri-cli
9
16
  ```
10
17
 
18
+ After global installation, you can run `tri` directly from anywhere:
19
+
20
+ ```bash
21
+ tri .
22
+ ```
23
+
24
+ ### Local Installation
25
+
26
+ If you don't want to install globally, you can install locally and then run anywhere with `npx`:
27
+
28
+ ```bash
29
+ npm install tri-cli
30
+ ```
31
+
32
+ For local installations, use `npx` to run the command:
33
+
34
+ ```bash
35
+ npx tri .
36
+ ```
37
+
38
+ Or add it to your `package.json` scripts:
39
+
40
+ ```json
41
+ {
42
+ "scripts": {
43
+ "tree": "tri"
44
+ }
45
+ }
46
+ ```
47
+
48
+ Then run with `npm run tree`.
49
+
11
50
  ## Usage
12
51
 
13
52
  ```bash
@@ -20,32 +59,72 @@ tri [directory] [options]
20
59
  tri . # visualize current directory
21
60
  tri ../bionemo -L 2 # show 2 levels deep
22
61
  tri --dir src # specify directory with flag
62
+ tri src -s # preload all sizes (enables immediate treemap)
63
+ tri --dir src -c # start with all directories collapsed
23
64
  ```
24
65
 
25
66
  ### Interactive Controls
26
67
 
27
- | Key | Action |
28
- | ----- | ------------------------------ |
29
- | ↑ / ↓ | Navigate files and folders |
30
- | Enter | Expand or collapse a directory |
31
- | t | Toggle Treemap view |
32
- | Esc | Clear typed shortcut |
33
- | q | Quit |
68
+ | Key | Action |
69
+ | ------------- | ----------------------------------------- |
70
+ | ↑ / ↓ | Navigate files and folders |
71
+ | Enter | Expand or collapse a directory |
72
+ | [a-z]/[0-9] | Type shortcut to jump to item |
73
+ | d | Calculate and display sizes recursively |
74
+ | l | Show last modified dates (ls -l style) |
75
+ | m | Toggle treemap view |
76
+ | Esc | Clear typed shortcut |
77
+ | q | Quit |
78
+
79
+ ### Command Line Options
80
+
81
+ | Option | Description |
82
+ | ---------------- | ------------------------------------------------ |
83
+ | `[directory]` | Directory to visualize (default: current dir) |
84
+ | `-L <level>` | Maximum depth to load initially (default: 1) |
85
+ | `-s, --size` | Preload all file/directory sizes |
86
+ | `-c, --collapse` | Start with all directories collapsed |
87
+ | `-h, --help` | Show help message |
88
+
89
+ ## Features
34
90
 
35
- ### Options
91
+ ### Tree View
92
+ - **Keyboard shortcuts**: Each item gets a shortcut label - just type it to jump there instantly
93
+ - **Tree-style lines**: Visual hierarchy with Unicode box-drawing characters (├─, ╰─, │)
94
+ - **Color coding**: Different colors for different file types
95
+ - **Lazy loading**: Only loads directory contents when you expand them (fast for large directories)
96
+ - **Size calculation**: Press `d` on any item to calculate disk usage recursively
97
+ - **Date display**: Press `l` to show last modified dates in `ls -l` format
36
98
 
37
- | Option | Description |
38
- | -------------- | ----------------------------------------------- |
39
- | `--dir <path>` | Directory to visualize (optional if positional) |
40
- | `-L <level>` | Depth level to expand initially (default: 1) |
41
- | `-h, --help` | Show help message |
99
+ ### Treemap View
100
+ - Press `m` to visualize the selected directory as a treemap
101
+ - Visual representation of file/directory sizes
102
+ - Hierarchical rectangles sized proportionally to disk usage
103
+ - Color-coded by file type or directory group
42
104
 
43
- ---
105
+ ## Development
44
106
 
45
- **Example:**
107
+ Clone the repository and install dependencies:
46
108
 
47
109
  ```bash
48
- tri ../bionemo -L 2
110
+ git clone https://github.com/jwilber/tri-cli.git
111
+ cd tri-cli
112
+ npm install
49
113
  ```
50
114
 
51
- Type `t` to open (and close) the treemap view, navigate with arrow keys, and press `q` to quit.
115
+ Build and link for local development:
116
+
117
+ ```bash
118
+ npm run build
119
+ npm link
120
+ ```
121
+
122
+ Now you can run `tri` from anywhere and it will use your local development version.
123
+
124
+ ## License
125
+
126
+ MIT
127
+
128
+ ## Author
129
+
130
+ Jared Wilber
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,82 @@ 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
+ const formatDate = timestamp => {
34
+ const date = new Date(timestamp);
35
+ const now = new Date();
36
+ const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
37
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
38
+ const month = months[date.getMonth()];
39
+ const day = date.getDate().toString().padStart(2, ' ');
40
+
41
+ // If file is from this year, show time; otherwise show year
42
+ if (diffDays < 180) {
43
+ const hours = date.getHours().toString().padStart(2, '0');
44
+ const minutes = date.getMinutes().toString().padStart(2, '0');
45
+ return `${month} ${day} ${hours}:${minutes}`;
46
+ } else {
47
+ const year = date.getFullYear();
48
+ return `${month} ${day} ${year}`;
49
+ }
50
+ };
51
+
52
+ // Async non-blocking directory size
53
+ const getDirSizeAsync = async dirPath => {
34
54
  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 {}
55
+ const escapedPath = dirPath.replace(/"/g, '\\"');
56
+ const {
57
+ stdout
58
+ } = await execAsync(`du -sk "${escapedPath}" 2>/dev/null`);
59
+ const match = stdout.trim().split(/\s+/);
60
+ if (match.length >= 1) {
61
+ const sizeInKB = parseInt(match[0], 10);
62
+ return isNaN(sizeInKB) ? 0 : sizeInKB * 1024;
46
63
  }
47
- } catch {}
48
- return size;
64
+ return 0;
65
+ } catch {
66
+ return 0;
67
+ }
49
68
  };
50
-
51
- // ✅ Updated: allow skipping size computation
52
- const readDirTree = (dirPath, noSize = false) => {
69
+ const readDirTree = (dirPath, noSize = true, currentLevel = 0, maxLevel = Infinity) => {
53
70
  try {
54
71
  const stats = fs.statSync(dirPath);
55
- const name = path.basename(dirPath) || dirPath;
72
+ let name = path.basename(dirPath);
73
+ // If basename is empty (root) or '.', use the absolute path's last component or full path
74
+ if (!name || name === '.') {
75
+ const resolved = path.resolve(dirPath);
76
+ name = path.basename(resolved) || resolved;
77
+ }
56
78
  if (!stats.isDirectory()) {
57
79
  const size = noSize ? 0 : stats.size;
58
80
  return {
59
81
  name,
60
82
  path: dirPath,
61
83
  isFile: true,
62
- size,
63
- value: size > 0 ? size : 100
84
+ size
64
85
  };
65
86
  }
66
- const children = fs.readdirSync(dirPath).filter(file => !file.startsWith('.')).map(file => readDirTree(path.join(dirPath, file), noSize)).filter(Boolean);
87
+ if (currentLevel >= maxLevel) {
88
+ return {
89
+ name,
90
+ path: dirPath,
91
+ isFile: false,
92
+ size: 0,
93
+ children: undefined
94
+ };
95
+ }
96
+ const children = fs.readdirSync(dirPath).filter(f => !f.startsWith('.')).map(f => readDirTree(path.join(dirPath, f), noSize, currentLevel + 1, maxLevel)).filter(Boolean);
67
97
  return {
68
98
  name,
69
99
  path: dirPath,
70
100
  isFile: false,
71
- size: noSize ? 0 : getDirSize(dirPath),
72
- children: children.length > 0 ? children : undefined
101
+ size: 0,
102
+ children
73
103
  };
74
104
  } catch {
75
105
  return null;
76
106
  }
77
107
  };
78
108
  const getFileColor = filename => {
79
- if (filename === 'VERSION') return oneHunter.purple;
80
- if (filename === 'Dockerfile') return oneHunter.blue;
81
109
  const ext = path.extname(filename).toLowerCase();
82
110
  const colorMap = {
83
111
  '.js': oneHunter.yellow,
@@ -104,8 +132,10 @@ const getFileColor = filename => {
104
132
  };
105
133
  return colorMap[ext] || oneHunter.text;
106
134
  };
135
+
136
+ // Exclude m, t, d
107
137
  const generateShortcut = (index, isFile, parentShortcut = '') => {
108
- const letters = 'abcdefghijklmnoprsuvwxyz'; // omitting q, t
138
+ const letters = 'abcefgijknopqrsuvwxyz';
109
139
  const base = letters.length;
110
140
  if (isFile) return `${parentShortcut}${index + 1}`;
111
141
  let label = '';
@@ -117,160 +147,420 @@ const generateShortcut = (index, isFile, parentShortcut = '') => {
117
147
  return parentShortcut + label;
118
148
  };
119
149
 
120
- // --- Components ---
150
+ // Generate tree lines (like the `tree` command)
151
+ const getTreeLines = node => {
152
+ if (node.depth === 0) return ''; // Root has no lines
153
+
154
+ const lines = [];
155
+ let current = node;
156
+
157
+ // Build the lines from current node up to (but not including) root
158
+ while (current.parent && current.parent.depth > 0) {
159
+ const parent = current.parent;
160
+ const isLastChild = parent.children && current === parent.children[parent.children.length - 1];
161
+ lines.unshift(isLastChild ? ' ' : '│ ');
162
+ current = parent;
163
+ }
164
+
165
+ // Add the final connector for this node
166
+ const parent = node.parent;
167
+ if (parent && parent.depth === 0) {
168
+ // Direct child of root
169
+ const isLastChild = parent.children && node === parent.children[parent.children.length - 1];
170
+ return isLastChild ? '╰─' : '├─';
171
+ } else if (parent) {
172
+ const isLastChild = parent.children && node === parent.children[parent.children.length - 1];
173
+ return lines.join('') + (isLastChild ? '╰─' : '├─');
174
+ }
175
+ return '';
176
+ };
177
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
121
178
  const TreeRow = /*#__PURE__*/React.memo(({
122
179
  node,
123
180
  isSelected,
124
181
  collapsed,
125
182
  shortcutInput,
126
- noSize
183
+ noSize,
184
+ calculatedSizes,
185
+ loadingPaths,
186
+ spinnerFrame,
187
+ lastModifiedDates
127
188
  }) => {
128
189
  const isCollapsed = collapsed.has(node.data.path);
129
190
  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 ? '▶ ' : '▼ ' : ' ';
191
+ const isLoading = loadingPaths.has(node.data.path);
146
192
  const fileColor = node.data.isFile ? getFileColor(node.data.name) : oneHunter.magenta;
193
+ const isRoot = node.depth === 0;
194
+ const isDirectory = !node.data.isFile;
195
+ let displaySize = 0;
196
+ // Use calculated size if available, otherwise fall back to node.data.size
197
+ if (calculatedSizes && calculatedSizes.has(node.data.path)) {
198
+ displaySize = calculatedSizes.get(node.data.path);
199
+ } else {
200
+ displaySize = node.data.size;
201
+ }
202
+ const lastModified = lastModifiedDates.get(node.data.path);
203
+ const treeLines = getTreeLines(node);
204
+
205
+ // ✅ Restore highlight logic
147
206
  const renderShortcut = () => {
148
207
  if (!shortcutInput) return /*#__PURE__*/React.createElement(Text, {
149
208
  color: oneHunter.textAlt,
150
209
  dimColor: true
151
- }, ' ', "[", node.shortcut, "]");
210
+ }, ' ', "[", node.shortcut, "]");
152
211
  if (node.shortcut.startsWith(shortcutInput)) {
153
212
  const matched = node.shortcut.slice(0, shortcutInput.length);
154
213
  const rest = node.shortcut.slice(shortcutInput.length);
155
214
  return /*#__PURE__*/React.createElement(Text, {
156
215
  color: oneHunter.textAlt
157
- }, ' [', /*#__PURE__*/React.createElement(Text, {
216
+ }, ' ', "[", /*#__PURE__*/React.createElement(Text, {
158
217
  underline: true,
159
218
  color: oneHunter.yellow
160
- }, matched), rest, ']');
219
+ }, matched), rest, "]");
161
220
  }
162
221
  return /*#__PURE__*/React.createElement(Text, {
163
222
  color: oneHunter.textAlt,
164
223
  dimColor: true
165
- }, ' ', "[", node.shortcut, "]");
224
+ }, ' ', "[", node.shortcut, "]");
166
225
  };
167
- return /*#__PURE__*/React.createElement(Box, null, /*#__PURE__*/React.createElement(Text, null, /*#__PURE__*/React.createElement(Text, {
226
+ return /*#__PURE__*/React.createElement(Box, null, /*#__PURE__*/React.createElement(Text, null, !isRoot && /*#__PURE__*/React.createElement(Text, {
227
+ color: oneHunter.textAlt
228
+ }, treeLines), !isRoot && isDirectory && /*#__PURE__*/React.createElement(Text, {
168
229
  color: oneHunter.textAlt
169
- }, prefix), /*#__PURE__*/React.createElement(Text, {
170
- color: hasChildren ? oneHunter.cyan : oneHunter.textAlt
171
- }, icon), /*#__PURE__*/React.createElement(Text, {
230
+ }, hasChildren && !isCollapsed ? '▼ ' : '▶ '), !isRoot && !isDirectory && /*#__PURE__*/React.createElement(Text, {
231
+ color: oneHunter.textAlt
232
+ }, "\u2500 "), /*#__PURE__*/React.createElement(Text, {
172
233
  bold: isSelected,
173
234
  color: isSelected ? oneHunter.bg : fileColor,
174
235
  backgroundColor: isSelected ? oneHunter.green : undefined
175
- }, node.data.name), hasChildren && !isCollapsed && /*#__PURE__*/React.createElement(Text, {
236
+ }, node.data.name, !node.data.isFile && !isRoot ? '/' : ''), hasChildren && !isCollapsed && /*#__PURE__*/React.createElement(Text, {
176
237
  color: oneHunter.textAlt
177
- }, " (", node.children.length, ")"), !noSize && node.data.size > 0 && /*#__PURE__*/React.createElement(Text, {
238
+ }, " (", node.children.length, ")"), isLoading ? /*#__PURE__*/React.createElement(Text, {
239
+ color: oneHunter.yellow
240
+ }, " ", SPINNER_FRAMES[spinnerFrame]) : displaySize > 0 ? /*#__PURE__*/React.createElement(Text, {
178
241
  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);
242
+ }, " ", formatBytes(displaySize)) : /*#__PURE__*/React.createElement(Text, null, " "), renderShortcut(), lastModified && /*#__PURE__*/React.createElement(Text, {
243
+ color: oneHunter.text2
244
+ }, " ", formatDate(lastModified))));
245
+ });
181
246
  const TreeVisualization = ({
182
247
  dirPath = '.',
183
248
  maxLevel = 1,
184
- noSize = false
249
+ noSize = true,
250
+ collapseAll = false
185
251
  }) => {
186
252
  const [selectedIndex, setSelectedIndex] = useState(0);
187
253
  const [collapsed, setCollapsed] = useState(new Set());
188
254
  const [shortcutInput, setShortcutInput] = useState('');
189
255
  const [showTreeMap, setShowTreeMap] = useState(false);
190
256
  const [initialized, setInitialized] = useState(false);
257
+ const [calculatedSizes, setCalculatedSizes] = useState(new Map());
258
+ const [loadingPaths, setLoadingPaths] = useState(new Set());
259
+ const [spinnerFrame, setSpinnerFrame] = useState(0);
260
+ const [rootTree, setRootTree] = useState(() => hierarchy(readDirTree(dirPath, noSize, 0, maxLevel)));
261
+ const [lastModifiedDates, setLastModifiedDates] = useState(new Map());
191
262
  const {
192
263
  stdout
193
264
  } = useStdout();
194
265
  const terminalHeight = stdout?.rows || 24;
195
266
  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]);
267
+
268
+ // Spinner animation loop
269
+ useEffect(() => {
270
+ const interval = setInterval(() => setSpinnerFrame(f => (f + 1) % SPINNER_FRAMES.length), 80);
271
+ return () => clearInterval(interval);
272
+ }, []);
273
+
274
+ // Preload all sizes when -s flag is used (noSize is false)
275
+ useEffect(() => {
276
+ if (noSize) return; // Only preload when sizes are requested
277
+
278
+ const preloadAllSizes = async () => {
279
+ const newMap = new Map();
280
+ const nodesToCalculate = [];
281
+
282
+ // Collect all nodes
283
+ const traverse = node => {
284
+ nodesToCalculate.push(node);
285
+ if (node.children) {
286
+ node.children.forEach(traverse);
287
+ }
288
+ };
289
+ traverse(rootTree);
290
+
291
+ // Set all nodes as loading
292
+ setLoadingPaths(new Set(nodesToCalculate.map(n => n.data.path)));
293
+
294
+ // Calculate sizes for all nodes with streaming updates
295
+ for (const node of nodesToCalculate) {
296
+ const sz = node.data.isFile ? fs.statSync(node.data.path).size : await getDirSizeAsync(node.data.path);
297
+ newMap.set(node.data.path, sz);
298
+
299
+ // Stream update immediately (using startTransition to deprioritize render)
300
+ React.startTransition(() => {
301
+ setCalculatedSizes(new Map(newMap));
302
+ setLoadingPaths(prev => {
303
+ const next = new Set(prev);
304
+ next.delete(node.data.path);
305
+ return next;
306
+ });
307
+ });
308
+ await new Promise(r => setTimeout(r, 0));
309
+ }
310
+ };
311
+ preloadAllSizes();
312
+ }, [noSize, rootTree]);
201
313
  useEffect(() => {
314
+ // Only initialize collapse state on first mount, not on every rootTree change
315
+ if (initialized) return;
202
316
  const newCollapsed = new Set();
203
317
  const applyLevel = (node, level = 0) => {
204
318
  if (node.children) {
205
- if (level >= maxLevel) newCollapsed.add(node.data.path);
206
- node.children.forEach(child => applyLevel(child, level + 1));
319
+ if (collapseAll && level >= 1 || level >= maxLevel) newCollapsed.add(node.data.path);
320
+ node.children.forEach(c => applyLevel(c, level + 1));
207
321
  }
208
322
  };
209
- applyLevel(root);
323
+ applyLevel(rootTree);
210
324
  setCollapsed(newCollapsed);
211
325
  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]);
326
+ }, []);
327
+ const assignShortcuts = (node, parentShortcut = '', siblingIndex = 0) => {
328
+ node.shortcut = generateShortcut(siblingIndex, node.data.isFile, parentShortcut);
329
+ if (node.children) node.children.forEach((child, i) => assignShortcuts(child, node.shortcut, i));
330
+ };
331
+ assignShortcuts(rootTree);
225
332
  const visibleNodes = useMemo(() => {
226
333
  const nodes = [];
227
334
  const traverse = node => {
228
335
  nodes.push(node);
229
- if (node.children && !collapsed.has(node.data.path)) {
230
- node.children.forEach(traverse);
231
- }
336
+ if (node.children && !collapsed.has(node.data.path)) node.children.forEach(traverse);
232
337
  };
233
- traverse(rootWithShortcuts);
338
+ traverse(rootTree);
234
339
  return nodes;
235
- }, [rootWithShortcuts, collapsed]);
340
+ }, [rootTree, collapsed]);
236
341
  const shortcutMap = useMemo(() => {
237
342
  const map = new Map();
238
343
  visibleNodes.forEach((node, index) => map.set(node.shortcut, index));
239
344
  return map;
240
345
  }, [visibleNodes]);
241
- useInput((input, key) => {
346
+ const calculateSizeAsync = async node => {
347
+ // Collect node and all its descendants (not just visible ones)
348
+ const nodesToCalculate = [];
349
+ const collectNodes = n => {
350
+ // Skip if already calculated
351
+ if (calculatedSizes.has(n.data.path)) {
352
+ // Still recurse to children in case they need calculation
353
+ if (n.children) {
354
+ n.children.forEach(collectNodes);
355
+ }
356
+ return;
357
+ }
358
+ nodesToCalculate.push(n);
359
+ if (n.children) {
360
+ n.children.forEach(collectNodes);
361
+ }
362
+ };
363
+ collectNodes(node);
364
+ if (nodesToCalculate.length === 0) return; // Nothing to calculate
365
+
366
+ // Mark all nodes as loading
367
+ const pathsToLoad = nodesToCalculate.map(n => n.data.path);
368
+ setLoadingPaths(prev => new Set([...prev, ...pathsToLoad]));
369
+ const newMap = new Map(calculatedSizes);
370
+
371
+ // Calculate and stream updates for each node individually
372
+ for (const targetNode of nodesToCalculate) {
373
+ const sz = targetNode.data.isFile ? fs.statSync(targetNode.data.path).size : await getDirSizeAsync(targetNode.data.path);
374
+ newMap.set(targetNode.data.path, sz);
375
+
376
+ // Stream the update immediately (using startTransition to deprioritize render)
377
+ React.startTransition(() => {
378
+ setCalculatedSizes(new Map(newMap));
379
+ setLoadingPaths(prev => {
380
+ const next = new Set(prev);
381
+ next.delete(targetNode.data.path);
382
+ return next;
383
+ });
384
+ });
385
+ await new Promise(r => setTimeout(r, 0));
386
+ }
387
+ };
388
+ const getLastModifiedAsync = async node => {
389
+ // Collect node and all its descendants
390
+ const nodesToProcess = [];
391
+ const collectNodes = n => {
392
+ // Skip if already have date
393
+ if (lastModifiedDates.has(n.data.path)) {
394
+ // Still recurse to children in case they need dates
395
+ if (n.children) {
396
+ n.children.forEach(collectNodes);
397
+ }
398
+ return;
399
+ }
400
+ nodesToProcess.push(n);
401
+ if (n.children) {
402
+ n.children.forEach(collectNodes);
403
+ }
404
+ };
405
+ collectNodes(node);
406
+ if (nodesToProcess.length === 0) return; // Nothing to process
407
+
408
+ const newMap = new Map(lastModifiedDates);
409
+
410
+ // Get mtime for each node
411
+ for (const targetNode of nodesToProcess) {
412
+ try {
413
+ const stats = fs.statSync(targetNode.data.path);
414
+ newMap.set(targetNode.data.path, stats.mtimeMs);
415
+ } catch {
416
+ // If we can't stat the file, skip it
417
+ }
418
+
419
+ // Stream the update immediately
420
+ React.startTransition(() => {
421
+ setLastModifiedDates(new Map(newMap));
422
+ });
423
+ await new Promise(r => setTimeout(r, 0));
424
+ }
425
+ };
426
+ const traverseDir = async node => {
427
+ if (node.data.isFile) return;
428
+
429
+ // If children are undefined (not loaded yet), load them
430
+ if (node.data.children === undefined) {
431
+ // Load the children
432
+ const newTree = readDirTree(node.data.path, true, 0, 1);
433
+ if (newTree && newTree.children) {
434
+ node.data.children = newTree.children;
435
+ // Update both states - remove from collapsed and rebuild tree
436
+ setCollapsed(prev => {
437
+ const next = new Set(prev);
438
+ next.delete(node.data.path);
439
+ return next;
440
+ });
441
+ const newRootData = rootTree.data;
442
+ setRootTree(hierarchy(newRootData));
443
+ }
444
+ } else {
445
+ // Children already loaded, just toggle expand
446
+ setCollapsed(prev => {
447
+ const next = new Set(prev);
448
+ if (next.has(node.data.path)) {
449
+ next.delete(node.data.path);
450
+ } else {
451
+ next.add(node.data.path);
452
+ }
453
+ return next;
454
+ });
455
+ }
456
+ };
457
+ useInput(async (input, key) => {
458
+ if (showTreeMap) {
459
+ setShowTreeMap(false);
460
+ return;
461
+ }
242
462
  if (input === 'q') process.exit(0);
243
- if (input === 't') {
244
- if (!noSize) setShowTreeMap(p => !p);
463
+ if (input === 'C') {
464
+ // Toggle collapse/expand all children of the selected node's PARENT
465
+ const selectedNode = visibleNodes[selectedIndex];
466
+ if (!selectedNode || !selectedNode.parent) return;
467
+ const parentNode = selectedNode.parent;
468
+
469
+ // Get all descendant nodes with children
470
+ const descendants = [];
471
+ const collectDescendants = node => {
472
+ if (node.children && node.children.length > 0) {
473
+ descendants.push(node);
474
+ node.children.forEach(collectDescendants);
475
+ }
476
+ };
477
+ collectDescendants(parentNode);
478
+ if (descendants.length === 0) return; // No children to collapse
479
+
480
+ // Check if all descendants are collapsed
481
+ const allCollapsed = descendants.every(n => collapsed.has(n.data.path));
482
+ setCollapsed(prev => {
483
+ const next = new Set(prev);
484
+ descendants.forEach(n => {
485
+ if (allCollapsed) {
486
+ next.delete(n.data.path);
487
+ } else {
488
+ next.add(n.data.path);
489
+ }
490
+ });
491
+ return next;
492
+ });
493
+ return;
494
+ }
495
+ if (input === 'O') {
496
+ // Toggle collapse/expand ALL nodes in the entire tree
497
+ const allNodesWithChildren = [];
498
+ const collectAll = node => {
499
+ if (node.children && node.children.length > 0) {
500
+ allNodesWithChildren.push(node);
501
+ node.children.forEach(collectAll);
502
+ }
503
+ };
504
+ collectAll(rootTree);
505
+ if (allNodesWithChildren.length === 0) return;
506
+
507
+ // Check if all nodes are collapsed
508
+ const allCollapsed = allNodesWithChildren.every(n => collapsed.has(n.data.path));
509
+ setCollapsed(prev => {
510
+ const next = new Set(prev);
511
+ allNodesWithChildren.forEach(n => {
512
+ if (allCollapsed) {
513
+ next.delete(n.data.path);
514
+ } else {
515
+ next.add(n.data.path);
516
+ }
517
+ });
518
+ return next;
519
+ });
520
+ return;
521
+ }
522
+ if (input === 'm') {
523
+ const node = visibleNodes[selectedIndex];
524
+ if (!node) return;
525
+ // Always start calculating sizes in the background (calculateSizeAsync skips already-calculated nodes)
526
+ calculateSizeAsync(node);
527
+ // Show treemap immediately
528
+ setShowTreeMap(true);
529
+ return;
530
+ }
531
+ if (input === 'd') {
532
+ const node = visibleNodes[selectedIndex];
533
+ if (node) await calculateSizeAsync(node);
534
+ return;
535
+ }
536
+ if (input === 'l') {
537
+ const node = visibleNodes[selectedIndex];
538
+ if (node) await getLastModifiedAsync(node);
245
539
  return;
246
540
  }
247
- if (showTreeMap) return;
248
541
  if (key.upArrow) {
249
542
  setSelectedIndex(p => Math.max(0, p - 1));
250
543
  setShortcutInput('');
251
544
  return;
252
- } else if (key.downArrow) {
545
+ }
546
+ if (key.downArrow) {
253
547
  setSelectedIndex(p => Math.min(visibleNodes.length - 1, p + 1));
254
548
  setShortcutInput('');
255
549
  return;
256
550
  }
257
551
  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
552
  const node = visibleNodes[selectedIndex];
273
- if (node.children) {
553
+ if (!node) return;
554
+
555
+ // Don't allow collapsing/expanding the root
556
+ if (node.depth === 0) return;
557
+ if (node.data.isFile) return; // Do nothing for files
558
+
559
+ // If it's a directory with no children loaded yet, traverse into it
560
+ if (node.data.children === undefined) {
561
+ await traverseDir(node);
562
+ } else if (node.children) {
563
+ // Children already loaded, toggle collapse
274
564
  setCollapsed(prev => {
275
565
  const next = new Set(prev);
276
566
  if (next.has(node.data.path)) next.delete(node.data.path);else next.add(node.data.path);
@@ -287,80 +577,99 @@ const TreeVisualization = ({
287
577
  if (input && /^[a-z0-9]$/.test(input)) {
288
578
  const newInput = shortcutInput + input;
289
579
  setShortcutInput(newInput);
290
- if (shortcutMap.has(newInput)) {
291
- setSelectedIndex(shortcutMap.get(newInput));
292
- }
580
+ if (shortcutMap.has(newInput)) setSelectedIndex(shortcutMap.get(newInput));
293
581
  }
294
582
  });
295
583
  if (showTreeMap) {
296
584
  const node = visibleNodes[selectedIndex];
297
- if (!node) return /*#__PURE__*/React.createElement(Text, {
298
- color: oneHunter.red
299
- }, "Error: No node selected");
585
+
586
+ // Populate value property on DATA objects for treemap visualization
587
+ // This is important because TreeMapView creates a new hierarchy from node.data,
588
+ // so we need to set values on the data objects, not the hierarchy nodes
589
+ const populateValues = hierarchyNode => {
590
+ if (!hierarchyNode) return;
591
+
592
+ // Recursively populate children first (depth-first, bottom-up)
593
+ if (hierarchyNode.children) {
594
+ hierarchyNode.children.forEach(populateValues);
595
+ }
596
+
597
+ // Set value on the DATA object
598
+ const data = hierarchyNode.data;
599
+ if (data.isFile) {
600
+ // Files: use calculatedSize or read from disk
601
+ const calcSize = calculatedSizes.get(data.path);
602
+ if (calcSize !== undefined) {
603
+ data.value = calcSize;
604
+ } else {
605
+ try {
606
+ data.value = fs.statSync(data.path).size;
607
+ } catch {
608
+ data.value = 0;
609
+ }
610
+ }
611
+ } else if (data.children === undefined || !hierarchyNode.children || hierarchyNode.children.length === 0) {
612
+ // Directory with no children loaded OR empty directory
613
+ // Use calculatedSize if available
614
+ const calcSize = calculatedSizes.get(data.path);
615
+ if (calcSize !== undefined) {
616
+ data.value = calcSize;
617
+ } else {
618
+ // For unloaded directories without calculated size, will show as 0
619
+ data.value = data.size || 0;
620
+ }
621
+ } else {
622
+ // Directory with loaded children - let d3's .sum() aggregate from children
623
+ data.value = undefined;
624
+ }
625
+ };
626
+ populateValues(node);
300
627
  return /*#__PURE__*/React.createElement(TreeMapView, {
301
628
  selectedNode: node,
302
- stdout: stdout
629
+ stdout: stdout,
630
+ calculatedSizes: calculatedSizes,
631
+ loadingPaths: loadingPaths
303
632
  });
304
633
  }
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();
634
+ const start = Math.max(0, selectedIndex - Math.floor(viewportHeight / 2));
635
+ const end = Math.min(visibleNodes.length, start + viewportHeight);
321
636
  const viewportNodes = visibleNodes.slice(start, end);
322
637
  if (!initialized) return null;
323
638
  return /*#__PURE__*/React.createElement(Box, {
324
639
  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, {
640
+ }, /*#__PURE__*/React.createElement(Box, {
641
+ justifyContent: "space-between"
642
+ }, /*#__PURE__*/React.createElement(Text, {
329
643
  color: oneHunter.textAlt,
330
644
  dimColor: true
331
- }, "\u2191/\u2193: Navigate | Enter: Toggle | Esc: Clear | q: Quit"), start > 0 && /*#__PURE__*/React.createElement(Text, {
645
+ }, "\u2191/\u2193: Navigate | Enter: Expand/Traverse | d: Size | l: Date | m: Map | q: Quit"), loadingPaths.size > 0 && /*#__PURE__*/React.createElement(Text, {
332
646
  color: oneHunter.yellow
333
- }, "\u22EE (", start, " more above)"), viewportNodes.map((node, i) => /*#__PURE__*/React.createElement(TreeRow, {
647
+ }, ' ', SPINNER_FRAMES[spinnerFrame], " Loading ", loadingPaths.size, "...")), viewportNodes.map((node, i) => /*#__PURE__*/React.createElement(TreeRow, {
334
648
  key: start + i,
335
649
  node: node,
336
650
  isSelected: start + i === selectedIndex,
337
651
  collapsed: collapsed,
338
652
  shortcutInput: shortcutInput,
339
- noSize: noSize
340
- })), end < visibleNodes.length && /*#__PURE__*/React.createElement(Text, {
341
- color: oneHunter.yellow
342
- }, "\u22EE (", visibleNodes.length - end, " more below)"));
653
+ noSize: noSize,
654
+ calculatedSizes: calculatedSizes,
655
+ loadingPaths: loadingPaths,
656
+ spinnerFrame: spinnerFrame,
657
+ lastModifiedDates: lastModifiedDates
658
+ })));
343
659
  };
344
660
 
345
- // --- CLI args ---
661
+ // --- CLI ---
346
662
  const args = process.argv.slice(2);
347
-
348
- // Help
349
663
  if (args.includes('--help') || args.includes('-h')) {
350
664
  console.log(`
351
665
  Usage:
352
666
  tri [directory] [options]
353
667
 
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
668
  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
669
+ -L <level> Depth (default 1)
670
+ -s, --size Preload all sizes (default off)
671
+ -c, --collapse Start collapsed
672
+ -h, --help Show help
364
673
  `);
365
674
  process.exit(0);
366
675
  }
@@ -369,9 +678,15 @@ const firstNonFlagArg = args.find(arg => !arg.startsWith('-'));
369
678
  const dirPath = dirIndex !== -1 && args[dirIndex + 1] ? args[dirIndex + 1] : firstNonFlagArg || '.';
370
679
  const levelIndex = args.indexOf('-L');
371
680
  const maxLevel = levelIndex !== -1 && args[levelIndex + 1] ? parseInt(args[levelIndex + 1], 10) : 1;
372
- const noSize = args.includes('--no-size') || args.includes('-ns');
681
+ if (isNaN(maxLevel) || maxLevel < 1) {
682
+ console.error('tri: Invalid level, must be greater than 0.');
683
+ process.exit(1);
684
+ }
685
+ const collapseAll = args.includes('--collapse') || args.includes('-c');
686
+ const preloadSizes = args.includes('--size') || args.includes('-s');
373
687
  render(/*#__PURE__*/React.createElement(TreeVisualization, {
374
688
  dirPath: dirPath,
375
689
  maxLevel: maxLevel,
376
- noSize: noSize
690
+ noSize: !preloadSizes,
691
+ collapseAll: collapseAll
377
692
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tri-cli",
3
- "version": "0.0.3",
3
+ "version": "0.0.51",
4
4
  "description": "Interactive CLI directory tree visualizer with treemap view",
5
5
  "author": "Jared Wilber",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "postbuild": "chmod +x dist/cli.js",
29
29
  "dev": "babel --out-dir=dist --watch source",
30
30
  "test": "prettier --check . && xo && ava",
31
- "test:local": "npm run build && tri --dir . -L 2"
31
+ "test:local": "npm run build && node dist/cli.js --dir . -L 2"
32
32
  },
33
33
  "files": [
34
34
  "dist"
@@ -65,7 +65,8 @@
65
65
  "extends": "xo-react",
66
66
  "prettier": true,
67
67
  "rules": {
68
- "react/prop-types": "off"
68
+ "react/prop-types": "off",
69
+ "unicorn/expiring-todo-comments": "off"
69
70
  }
70
71
  },
71
72
  "prettier": "@vdemedes/prettier-config",
@@ -74,4 +75,4 @@
74
75
  "@babel/preset-react"
75
76
  ]
76
77
  }
77
- }
78
+ }
package/readme.tape ADDED
@@ -0,0 +1,78 @@
1
+ Output assets/readme.gif
2
+ Set FontSize 18
3
+ Set Width 1000
4
+ Set Height 600
5
+ Set Theme "Dracula"
6
+
7
+ Type "# tri: interactive tree + treemap visual"
8
+ Enter
9
+ Sleep 500ms
10
+
11
+ Type "tri ."
12
+ Enter
13
+ Sleep 2s
14
+
15
+ Down
16
+ Sleep 250ms
17
+ Down
18
+ Sleep 250ms
19
+ Enter
20
+ Sleep 500ms
21
+ Type "d"
22
+ Enter
23
+ Sleep 500ms
24
+ Down
25
+ Sleep 250ms
26
+ Down
27
+ Sleep 250ms
28
+ Down
29
+ Sleep 250ms
30
+ Type "l"
31
+ Sleep 1.5s
32
+
33
+ Enter
34
+ Sleep 1.5s
35
+ Type "m"
36
+ Sleep 1.5s
37
+ Type "m"
38
+ Sleep 500ms
39
+ Down
40
+ Sleep 250ms
41
+ Down
42
+ Sleep 250ms
43
+ Enter
44
+ Sleep 1.5s
45
+
46
+
47
+ # Toggle treemap view
48
+ Type "m"
49
+ Sleep 2s
50
+
51
+ # Return to tree view
52
+ Type "m"
53
+ Sleep 2s
54
+
55
+ Type "a"
56
+ Sleep 250ms
57
+ Type "c"
58
+ Sleep 500ms
59
+ Enter
60
+ Sleep 1s
61
+
62
+ Type "a"
63
+ Sleep 250ms
64
+ Type "h"
65
+ Sleep 500ms
66
+ Enter
67
+ Sleep 1s
68
+
69
+ Enter
70
+ Sleep 1s
71
+
72
+ # Quit
73
+ Type "q"
74
+ Sleep 1s
75
+
76
+ Type "# install with: npm i -g tri-cli"
77
+ Enter
78
+ Sleep 2s