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 +67 -0
- package/dist/Treemap.js +1 -1
- package/dist/cli.js +361 -176
- package/package.json +5 -3
- package/readme.md +0 -25
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
|
+

|
|
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:
|
|
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: '
|
|
14
|
+
text: 'rgb(206, 205, 195)',
|
|
15
|
+
text2: 'rgb(135, 133, 128)',
|
|
12
16
|
textAlt: 'white',
|
|
13
|
-
red: '
|
|
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
|
-
//
|
|
33
|
-
const
|
|
34
|
-
let size = 0;
|
|
34
|
+
// Async non-blocking directory size
|
|
35
|
+
const getDirSizeAsync = async dirPath => {
|
|
35
36
|
try {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
73
|
-
children
|
|
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.
|
|
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
|
-
//
|
|
118
|
+
// Exclude m, t, d
|
|
109
119
|
const generateShortcut = (index, isFile, parentShortcut = '') => {
|
|
110
|
-
const letters = '
|
|
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
|
-
|
|
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
|
-
}, '
|
|
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
|
-
}, ' [
|
|
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
|
-
}, '
|
|
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
|
-
},
|
|
177
|
+
}, node.depth > 0 ? ' '.repeat(node.depth * 2) : ''), /*#__PURE__*/React.createElement(Text, {
|
|
171
178
|
color: hasChildren ? oneHunter.cyan : oneHunter.textAlt
|
|
172
|
-
},
|
|
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, ")"),
|
|
179
|
-
color: oneHunter.
|
|
180
|
-
}, " ",
|
|
181
|
-
|
|
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
|
-
//
|
|
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(
|
|
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(
|
|
267
|
+
applyLevel(rootTree);
|
|
212
268
|
setCollapsed(newCollapsed);
|
|
213
269
|
setInitialized(true);
|
|
214
|
-
}, [
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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(
|
|
282
|
+
traverse(rootTree);
|
|
240
283
|
return nodes;
|
|
241
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
},
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
526
|
+
shortcutInput: shortcutInput,
|
|
527
|
+
noSize: noSize,
|
|
528
|
+
calculatedSizes: calculatedSizes,
|
|
529
|
+
loadingPaths: loadingPaths,
|
|
530
|
+
spinnerFrame: spinnerFrame
|
|
531
|
+
})));
|
|
357
532
|
};
|
|
358
533
|
|
|
359
|
-
// --- CLI
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
```
|