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 +19 -3
- package/dist/Treemap.js +1 -1
- package/dist/cli.js +344 -156
- package/package.json +5 -3
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
|
|
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
|
+

|
|
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:
|
|
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
|
-
|
|
33
|
-
|
|
33
|
+
|
|
34
|
+
// Async non-blocking directory size
|
|
35
|
+
const getDirSizeAsync = async dirPath => {
|
|
34
36
|
try {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
72
|
-
children
|
|
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 = '
|
|
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
|
-
|
|
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
|
-
}, '
|
|
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
|
-
}, ' [
|
|
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
|
-
}, '
|
|
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
|
-
},
|
|
177
|
+
}, node.depth > 0 ? ' '.repeat(node.depth * 2) : ''), /*#__PURE__*/React.createElement(Text, {
|
|
170
178
|
color: hasChildren ? oneHunter.cyan : oneHunter.textAlt
|
|
171
|
-
},
|
|
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, ")"),
|
|
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(
|
|
180
|
-
}
|
|
189
|
+
}, " ", formatBytes(displaySize)) : /*#__PURE__*/React.createElement(Text, null, " "), renderShortcut()));
|
|
190
|
+
});
|
|
181
191
|
const TreeVisualization = ({
|
|
182
192
|
dirPath = '.',
|
|
183
193
|
maxLevel = 1,
|
|
184
|
-
noSize =
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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(
|
|
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(
|
|
267
|
+
applyLevel(rootTree);
|
|
210
268
|
setCollapsed(newCollapsed);
|
|
211
269
|
setInitialized(true);
|
|
212
|
-
}, [
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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(
|
|
282
|
+
traverse(rootTree);
|
|
234
283
|
return nodes;
|
|
235
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
306
|
-
|
|
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
|
-
},
|
|
326
|
-
|
|
327
|
-
|
|
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 |
|
|
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
|
-
},
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
527
|
+
noSize: noSize,
|
|
528
|
+
calculatedSizes: calculatedSizes,
|
|
529
|
+
loadingPaths: loadingPaths,
|
|
530
|
+
spinnerFrame: spinnerFrame
|
|
531
|
+
})));
|
|
343
532
|
};
|
|
344
533
|
|
|
345
|
-
// --- CLI
|
|
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
|
-
|
|
361
|
-
-
|
|
362
|
-
|
|
363
|
-
-h, --help Show
|
|
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
|
-
|
|
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:
|
|
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
|
+
"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",
|