tri-cli 0.0.2
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/dist/Treemap.js +393 -0
- package/dist/app.js +9 -0
- package/dist/cli.js +380 -0
- package/package.json +77 -0
- package/readme.md +25 -0
package/dist/Treemap.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Text, Box } from 'ink';
|
|
3
|
+
import { hierarchy, treemap, treemapBinary } from 'd3-hierarchy';
|
|
4
|
+
|
|
5
|
+
// One Hunter color scheme
|
|
6
|
+
const oneHunter = {
|
|
7
|
+
bg: '#282c34',
|
|
8
|
+
bgAlt: '#21252b',
|
|
9
|
+
text: '#abb2bf',
|
|
10
|
+
textAlt: '#5c6370',
|
|
11
|
+
red: '#e06c75',
|
|
12
|
+
orange: '#d19a66',
|
|
13
|
+
yellow: '#e5c07b',
|
|
14
|
+
green: '#98c379',
|
|
15
|
+
cyan: '#56b6c2',
|
|
16
|
+
blue: '#61afef',
|
|
17
|
+
purple: '#c678dd',
|
|
18
|
+
magenta: '#c678dd'
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Expanded color palette for directories
|
|
22
|
+
const dirColors = [oneHunter.blue, oneHunter.cyan, oneHunter.green, oneHunter.yellow, oneHunter.orange, oneHunter.red, oneHunter.purple, oneHunter.magenta, '#4a9eff', '#7ec699', '#e8b339', '#ff7eb6'];
|
|
23
|
+
|
|
24
|
+
// Function to format bytes to human readable
|
|
25
|
+
const formatBytes = bytes => {
|
|
26
|
+
if (bytes === 0) return '0 B';
|
|
27
|
+
const k = 1024;
|
|
28
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
29
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
30
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Get color based on file extension
|
|
34
|
+
const getFileColor = filename => {
|
|
35
|
+
const ext = filename.match(/\.[^.]+$/)?.[0]?.toLowerCase();
|
|
36
|
+
const colorMap = {
|
|
37
|
+
'.js': oneHunter.yellow,
|
|
38
|
+
'.jsx': oneHunter.yellow,
|
|
39
|
+
'.ts': oneHunter.blue,
|
|
40
|
+
'.tsx': oneHunter.blue,
|
|
41
|
+
'.py': oneHunter.green,
|
|
42
|
+
'.rb': oneHunter.red,
|
|
43
|
+
'.json': oneHunter.purple,
|
|
44
|
+
'.html': oneHunter.cyan,
|
|
45
|
+
'.css': oneHunter.cyan,
|
|
46
|
+
'.md': oneHunter.text,
|
|
47
|
+
'.txt': oneHunter.textAlt,
|
|
48
|
+
'.sh': oneHunter.green,
|
|
49
|
+
'.yml': oneHunter.purple,
|
|
50
|
+
'.yaml': oneHunter.purple,
|
|
51
|
+
'.xml': oneHunter.orange,
|
|
52
|
+
'.svg': oneHunter.cyan,
|
|
53
|
+
'.png': oneHunter.blue,
|
|
54
|
+
'.jpg': oneHunter.blue,
|
|
55
|
+
'.gif': oneHunter.blue,
|
|
56
|
+
'.pdf': oneHunter.red
|
|
57
|
+
};
|
|
58
|
+
return colorMap[ext] || oneHunter.text;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Simple hash function for consistent color assignment
|
|
62
|
+
const hashString = str => {
|
|
63
|
+
let hash = 0;
|
|
64
|
+
for (let i = 0; i < str.length; i++) {
|
|
65
|
+
const char = str.charCodeAt(i);
|
|
66
|
+
hash = (hash << 5) - hash + char;
|
|
67
|
+
hash = hash & hash;
|
|
68
|
+
}
|
|
69
|
+
return Math.abs(hash);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Assign colors to directory groups
|
|
73
|
+
const assignGroupColors = root => {
|
|
74
|
+
const colorMap = new Map();
|
|
75
|
+
if (root.children) {
|
|
76
|
+
root.children.forEach(child => {
|
|
77
|
+
if (!child.data.isFile) {
|
|
78
|
+
const colorIndex = hashString(child.data.name) % dirColors.length;
|
|
79
|
+
colorMap.set(child.data.name, dirColors[colorIndex]);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
root.descendants().forEach(node => {
|
|
84
|
+
if (!node.data.isFile && node !== root) {
|
|
85
|
+
let topAncestor = node;
|
|
86
|
+
while (topAncestor.parent && topAncestor.parent !== root) {
|
|
87
|
+
topAncestor = topAncestor.parent;
|
|
88
|
+
}
|
|
89
|
+
if (colorMap.has(topAncestor.data.name)) {
|
|
90
|
+
node.groupColor = colorMap.get(topAncestor.data.name);
|
|
91
|
+
} else {
|
|
92
|
+
const colorIndex = hashString(node.data.name) % dirColors.length;
|
|
93
|
+
node.groupColor = dirColors[colorIndex];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
return colorMap;
|
|
98
|
+
};
|
|
99
|
+
export const TreeMapView = ({
|
|
100
|
+
selectedNode,
|
|
101
|
+
stdout
|
|
102
|
+
}) => {
|
|
103
|
+
if (!selectedNode || !selectedNode.data) {
|
|
104
|
+
return /*#__PURE__*/React.createElement(Box, {
|
|
105
|
+
flexDirection: "column"
|
|
106
|
+
}, /*#__PURE__*/React.createElement(Text, {
|
|
107
|
+
bold: true,
|
|
108
|
+
color: oneHunter.red
|
|
109
|
+
}, "Error: Invalid node selected"), /*#__PURE__*/React.createElement(Text, {
|
|
110
|
+
dimColor: true,
|
|
111
|
+
color: oneHunter.textAlt
|
|
112
|
+
}, "Press 't' to go back to tree view"));
|
|
113
|
+
}
|
|
114
|
+
const width = Math.min(stdout?.columns || 80, 120) - 4;
|
|
115
|
+
const height = Math.min(stdout?.rows || 24, 40) - 8;
|
|
116
|
+
|
|
117
|
+
// If it's a file, show just that file as a big rectangle
|
|
118
|
+
if (selectedNode.data.isFile) {
|
|
119
|
+
const color = getFileColor(selectedNode.data.name);
|
|
120
|
+
const name = selectedNode.data.name;
|
|
121
|
+
const sizeStr = selectedNode.data.size ? formatBytes(selectedNode.data.size) : '';
|
|
122
|
+
const grid = Array(height).fill(null).map(() => Array(width).fill(null).map(() => ({
|
|
123
|
+
char: ' ',
|
|
124
|
+
color
|
|
125
|
+
})));
|
|
126
|
+
for (let y = 0; y < height; y++) {
|
|
127
|
+
for (let x = 0; x < width; x++) {
|
|
128
|
+
const isTop = y === 0;
|
|
129
|
+
const isBottom = y === height - 1;
|
|
130
|
+
const isLeft = x === 0;
|
|
131
|
+
const isRight = x === width - 1;
|
|
132
|
+
let char = ' ';
|
|
133
|
+
if (isTop && isLeft) char = '┌';else if (isTop && isRight) char = '┐';else if (isBottom && isLeft) char = '└';else if (isBottom && isRight) char = '┘';else if (isTop || isBottom) char = '─';else if (isLeft || isRight) char = '│';else char = ' ';
|
|
134
|
+
grid[y][x] = {
|
|
135
|
+
char,
|
|
136
|
+
color
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const midY = Math.floor(height / 2);
|
|
141
|
+
const nameX = Math.floor((width - name.length) / 2);
|
|
142
|
+
if (midY >= 2 && midY < height - 2) {
|
|
143
|
+
for (let i = 0; i < name.length && nameX + i < width - 2; i++) {
|
|
144
|
+
if (nameX + i > 0) {
|
|
145
|
+
grid[midY][nameX + i] = {
|
|
146
|
+
char: name[i],
|
|
147
|
+
color
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (sizeStr && midY + 2 < height - 2) {
|
|
152
|
+
const sizeX = Math.floor((width - sizeStr.length) / 2);
|
|
153
|
+
for (let i = 0; i < sizeStr.length && sizeX + i < width - 2; i++) {
|
|
154
|
+
if (sizeX + i > 0) {
|
|
155
|
+
grid[midY + 2][sizeX + i] = {
|
|
156
|
+
char: sizeStr[i],
|
|
157
|
+
color
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const lines = grid.map((row, y) => {
|
|
164
|
+
const segments = [];
|
|
165
|
+
let currentColor = null;
|
|
166
|
+
let currentText = '';
|
|
167
|
+
row.forEach(cell => {
|
|
168
|
+
if (cell.color === currentColor) {
|
|
169
|
+
currentText += cell.char;
|
|
170
|
+
} else {
|
|
171
|
+
if (currentText) {
|
|
172
|
+
segments.push(/*#__PURE__*/React.createElement(Text, {
|
|
173
|
+
key: segments.length,
|
|
174
|
+
color: currentColor
|
|
175
|
+
}, currentText));
|
|
176
|
+
}
|
|
177
|
+
currentColor = cell.color;
|
|
178
|
+
currentText = cell.char;
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
if (currentText) {
|
|
182
|
+
segments.push(/*#__PURE__*/React.createElement(Text, {
|
|
183
|
+
key: segments.length,
|
|
184
|
+
color: currentColor
|
|
185
|
+
}, currentText));
|
|
186
|
+
}
|
|
187
|
+
return /*#__PURE__*/React.createElement(Box, {
|
|
188
|
+
key: y
|
|
189
|
+
}, segments);
|
|
190
|
+
});
|
|
191
|
+
return /*#__PURE__*/React.createElement(Box, {
|
|
192
|
+
flexDirection: "column"
|
|
193
|
+
}, /*#__PURE__*/React.createElement(Text, {
|
|
194
|
+
bold: true,
|
|
195
|
+
color: oneHunter.cyan
|
|
196
|
+
}, "TreeMap View: ", name, " (File)"), /*#__PURE__*/React.createElement(Text, {
|
|
197
|
+
dimColor: true,
|
|
198
|
+
color: oneHunter.textAlt
|
|
199
|
+
}, "Press 't' to toggle back to tree view | Ctrl+C: Exit"), /*#__PURE__*/React.createElement(Box, {
|
|
200
|
+
borderStyle: "single",
|
|
201
|
+
borderColor: oneHunter.textAlt,
|
|
202
|
+
flexDirection: "column",
|
|
203
|
+
marginTop: 1
|
|
204
|
+
}, lines), /*#__PURE__*/React.createElement(Text, {
|
|
205
|
+
dimColor: true,
|
|
206
|
+
color: oneHunter.textAlt,
|
|
207
|
+
marginTop: 1
|
|
208
|
+
}, "File: ", name, " | Size: ", sizeStr || 'Unknown'));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// It's a directory - show treemap of its children
|
|
212
|
+
if (!selectedNode.children || selectedNode.children.length === 0) {
|
|
213
|
+
return /*#__PURE__*/React.createElement(Box, {
|
|
214
|
+
flexDirection: "column"
|
|
215
|
+
}, /*#__PURE__*/React.createElement(Text, {
|
|
216
|
+
bold: true,
|
|
217
|
+
color: oneHunter.cyan
|
|
218
|
+
}, "TreeMap View: ", selectedNode.data.name, " (Empty Directory)"), /*#__PURE__*/React.createElement(Text, {
|
|
219
|
+
dimColor: true,
|
|
220
|
+
color: oneHunter.textAlt
|
|
221
|
+
}, "Press 't' to toggle back to tree view | Ctrl+C: Exit"), /*#__PURE__*/React.createElement(Box, {
|
|
222
|
+
borderStyle: "single",
|
|
223
|
+
borderColor: oneHunter.textAlt,
|
|
224
|
+
marginTop: 1,
|
|
225
|
+
padding: 1
|
|
226
|
+
}, /*#__PURE__*/React.createElement(Text, {
|
|
227
|
+
color: oneHunter.textAlt
|
|
228
|
+
}, "This directory is empty or collapsed.")));
|
|
229
|
+
}
|
|
230
|
+
const root = hierarchy(selectedNode.data).sum(d => d.value || 0).sort((a, b) => (b.value || 0) - (a.value || 0));
|
|
231
|
+
assignGroupColors(root);
|
|
232
|
+
|
|
233
|
+
// Apply treemap layout
|
|
234
|
+
treemap().tile(treemapBinary).size([width, height]).paddingOuter(0.5).paddingTop(0.5).paddingInner(0.5).round(true)(root);
|
|
235
|
+
|
|
236
|
+
// Only show direct children (first level)
|
|
237
|
+
const firstLevelNodes = root.children || [];
|
|
238
|
+
const grid = Array(height).fill(null).map(() => Array(width).fill(null).map(() => ({
|
|
239
|
+
char: ' ',
|
|
240
|
+
color: oneHunter.textAlt
|
|
241
|
+
})));
|
|
242
|
+
|
|
243
|
+
// Draw each first-level child
|
|
244
|
+
firstLevelNodes.forEach(node => {
|
|
245
|
+
const x0 = Math.floor(node.x0);
|
|
246
|
+
const y0 = Math.floor(node.y0);
|
|
247
|
+
const x1 = Math.ceil(node.x1);
|
|
248
|
+
const y1 = Math.ceil(node.y1);
|
|
249
|
+
const isFile = node.data.isFile;
|
|
250
|
+
const boxWidth = x1 - x0;
|
|
251
|
+
const boxHeight = y1 - y0;
|
|
252
|
+
if (boxWidth < 1 || boxHeight < 1) return;
|
|
253
|
+
|
|
254
|
+
// Color based on type
|
|
255
|
+
const color = isFile ? getFileColor(node.data.name) : node.groupColor || oneHunter.blue;
|
|
256
|
+
|
|
257
|
+
// Draw border
|
|
258
|
+
for (let y = y0; y < y1 && y < height; y++) {
|
|
259
|
+
for (let x = x0; x < x1 && x < width; x++) {
|
|
260
|
+
const isTop = y === y0;
|
|
261
|
+
const isBottom = y === y1 - 1;
|
|
262
|
+
const isLeft = x === x0;
|
|
263
|
+
const isRight = x === x1 - 1;
|
|
264
|
+
|
|
265
|
+
// Only draw borders
|
|
266
|
+
if (isTop || isBottom || isLeft || isRight) {
|
|
267
|
+
let char = ' ';
|
|
268
|
+
if (isTop && isLeft) char = '┌';else if (isTop && isRight) char = '┐';else if (isBottom && isLeft) char = '└';else if (isBottom && isRight) char = '┘';else if (isTop || isBottom) char = '─';else if (isLeft || isRight) char = '│';
|
|
269
|
+
grid[y][x] = {
|
|
270
|
+
char,
|
|
271
|
+
color
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Draw label if there's room
|
|
278
|
+
const name = node.data.name;
|
|
279
|
+
const sizeStr = node.value ? ` ${formatBytes(node.value)}` : '';
|
|
280
|
+
if (boxWidth >= 8 && boxHeight >= 2) {
|
|
281
|
+
const labelY = y0 + 1;
|
|
282
|
+
const labelX = x0 + 1;
|
|
283
|
+
const maxLength = boxWidth - 2;
|
|
284
|
+
|
|
285
|
+
// Combine name and size
|
|
286
|
+
let label = name + sizeStr;
|
|
287
|
+
if (label.length > maxLength) {
|
|
288
|
+
// Try just name
|
|
289
|
+
if (name.length <= maxLength) {
|
|
290
|
+
label = name;
|
|
291
|
+
} else {
|
|
292
|
+
label = name.slice(0, maxLength - 1) + '…';
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (labelY >= 0 && labelY < height && labelY < y1) {
|
|
296
|
+
for (let i = 0; i < label.length && labelX + i < x1 - 1 && labelX + i < width; i++) {
|
|
297
|
+
grid[labelY][labelX + i] = {
|
|
298
|
+
char: label[i],
|
|
299
|
+
color
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
} else if (boxWidth >= 4 && boxHeight >= 2) {
|
|
304
|
+
// Show abbreviated label
|
|
305
|
+
const labelY = y0 + 1;
|
|
306
|
+
const labelX = x0 + 1;
|
|
307
|
+
const maxLength = boxWidth - 2;
|
|
308
|
+
let label;
|
|
309
|
+
if (isFile) {
|
|
310
|
+
// Show extension for files
|
|
311
|
+
const ext = name.match(/\.([^.]{1,3})$/)?.[1];
|
|
312
|
+
label = ext || name.slice(0, maxLength);
|
|
313
|
+
} else {
|
|
314
|
+
// Show first few chars for directories
|
|
315
|
+
label = name.slice(0, maxLength);
|
|
316
|
+
}
|
|
317
|
+
if (labelY >= 0 && labelY < height && label) {
|
|
318
|
+
for (let i = 0; i < label.length && labelX + i < x1 - 1 && labelX + i < width; i++) {
|
|
319
|
+
grid[labelY][labelX + i] = {
|
|
320
|
+
char: label[i],
|
|
321
|
+
color
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
} else if (boxWidth >= 2 && boxHeight >= 2) {
|
|
326
|
+
// Just first character
|
|
327
|
+
const centerY = Math.floor((y0 + y1) / 2);
|
|
328
|
+
const centerX = Math.floor((x0 + x1) / 2);
|
|
329
|
+
if (centerY >= 0 && centerY < height && centerX >= 0 && centerX < width) {
|
|
330
|
+
grid[centerY][centerX] = {
|
|
331
|
+
char: name[0] || '•',
|
|
332
|
+
color
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
} else if (boxWidth === 1 && boxHeight === 1) {
|
|
336
|
+
// Tiny dot
|
|
337
|
+
if (y0 >= 0 && y0 < height && x0 >= 0 && x0 < width) {
|
|
338
|
+
grid[y0][x0] = {
|
|
339
|
+
char: '·',
|
|
340
|
+
color
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Convert grid to Ink components
|
|
347
|
+
const lines = grid.map((row, y) => {
|
|
348
|
+
const segments = [];
|
|
349
|
+
let currentColor = null;
|
|
350
|
+
let currentText = '';
|
|
351
|
+
row.forEach(cell => {
|
|
352
|
+
if (cell.color === currentColor) {
|
|
353
|
+
currentText += cell.char;
|
|
354
|
+
} else {
|
|
355
|
+
if (currentText) {
|
|
356
|
+
segments.push(/*#__PURE__*/React.createElement(Text, {
|
|
357
|
+
key: segments.length,
|
|
358
|
+
color: currentColor
|
|
359
|
+
}, currentText));
|
|
360
|
+
}
|
|
361
|
+
currentColor = cell.color;
|
|
362
|
+
currentText = cell.char;
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
if (currentText) {
|
|
366
|
+
segments.push(/*#__PURE__*/React.createElement(Text, {
|
|
367
|
+
key: segments.length,
|
|
368
|
+
color: currentColor
|
|
369
|
+
}, currentText));
|
|
370
|
+
}
|
|
371
|
+
return /*#__PURE__*/React.createElement(Box, {
|
|
372
|
+
key: y
|
|
373
|
+
}, segments);
|
|
374
|
+
});
|
|
375
|
+
return /*#__PURE__*/React.createElement(Box, {
|
|
376
|
+
flexDirection: "column"
|
|
377
|
+
}, /*#__PURE__*/React.createElement(Text, {
|
|
378
|
+
bold: true,
|
|
379
|
+
color: oneHunter.cyan
|
|
380
|
+
}, "TreeMap View: ", selectedNode.data.name), /*#__PURE__*/React.createElement(Text, {
|
|
381
|
+
dimColor: true,
|
|
382
|
+
color: oneHunter.textAlt
|
|
383
|
+
}, "Press 't' to toggle back to tree view | \u2191/\u2193: Navigate in tree | Ctrl+C: Exit"), /*#__PURE__*/React.createElement(Box, {
|
|
384
|
+
borderStyle: "single",
|
|
385
|
+
borderColor: oneHunter.textAlt,
|
|
386
|
+
flexDirection: "column",
|
|
387
|
+
marginTop: 1
|
|
388
|
+
}, lines), /*#__PURE__*/React.createElement(Text, {
|
|
389
|
+
dimColor: true,
|
|
390
|
+
color: oneHunter.textAlt,
|
|
391
|
+
marginTop: 1
|
|
392
|
+
}, "Showing ", firstLevelNodes.length, " items | Total: ", formatBytes(root.value || 0)));
|
|
393
|
+
};
|
package/dist/app.js
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import React, { useState, useEffect, useMemo } from 'react';
|
|
3
|
+
import { render, Text, Box, useInput, useStdout } from 'ink';
|
|
4
|
+
import { hierarchy } from 'd3-hierarchy';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { TreeMapView } from './Treemap.js';
|
|
8
|
+
const oneHunter = {
|
|
9
|
+
bg: '#282c34',
|
|
10
|
+
bgAlt: '#21252b',
|
|
11
|
+
text: '#abb2bf',
|
|
12
|
+
textAlt: 'white',
|
|
13
|
+
red: '#e06c75',
|
|
14
|
+
orange: 'rgb(218, 112, 44)',
|
|
15
|
+
yellow: '#e5c07b',
|
|
16
|
+
green: '#98c379',
|
|
17
|
+
cyan: 'rgb(58, 169, 159)',
|
|
18
|
+
blue: 'rgb(67, 133, 190)',
|
|
19
|
+
purple: 'rgb(139, 126, 200)',
|
|
20
|
+
magenta: 'rgb(206, 93, 151)'
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Function to format bytes to human readable
|
|
24
|
+
const formatBytes = bytes => {
|
|
25
|
+
if (bytes === 0) return '0 B';
|
|
26
|
+
const k = 1024;
|
|
27
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
28
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
29
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Function to get directory size recursively
|
|
33
|
+
const getDirSize = dirPath => {
|
|
34
|
+
let size = 0;
|
|
35
|
+
try {
|
|
36
|
+
const items = fs.readdirSync(dirPath);
|
|
37
|
+
for (const item of items) {
|
|
38
|
+
const itemPath = path.join(dirPath, item);
|
|
39
|
+
try {
|
|
40
|
+
const stats = fs.statSync(itemPath);
|
|
41
|
+
if (stats.isFile()) {
|
|
42
|
+
size += stats.size;
|
|
43
|
+
} else if (stats.isDirectory()) {
|
|
44
|
+
size += getDirSize(itemPath);
|
|
45
|
+
}
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
} catch {}
|
|
49
|
+
return size;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Function to read directory structure (unbounded depth)
|
|
53
|
+
const readDirTree = dirPath => {
|
|
54
|
+
try {
|
|
55
|
+
const stats = fs.statSync(dirPath);
|
|
56
|
+
const name = path.basename(dirPath) || dirPath;
|
|
57
|
+
if (!stats.isDirectory()) {
|
|
58
|
+
const size = stats.size;
|
|
59
|
+
return {
|
|
60
|
+
name,
|
|
61
|
+
path: dirPath,
|
|
62
|
+
isFile: true,
|
|
63
|
+
size,
|
|
64
|
+
value: size > 0 ? size : 100
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const children = fs.readdirSync(dirPath).filter(file => !file.startsWith('.')).map(file => readDirTree(path.join(dirPath, file))).filter(Boolean);
|
|
68
|
+
return {
|
|
69
|
+
name,
|
|
70
|
+
path: dirPath,
|
|
71
|
+
isFile: false,
|
|
72
|
+
size: getDirSize(dirPath),
|
|
73
|
+
children: children.length > 0 ? children : undefined
|
|
74
|
+
};
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Get color based on file extension
|
|
81
|
+
const getFileColor = filename => {
|
|
82
|
+
const ext = path.extname(filename).toLowerCase();
|
|
83
|
+
const colorMap = {
|
|
84
|
+
'.js': oneHunter.yellow,
|
|
85
|
+
'.jsx': oneHunter.yellow,
|
|
86
|
+
'.ts': oneHunter.blue,
|
|
87
|
+
'.tsx': oneHunter.blue,
|
|
88
|
+
'.py': oneHunter.green,
|
|
89
|
+
'.rb': oneHunter.red,
|
|
90
|
+
'.json': oneHunter.purple,
|
|
91
|
+
'.html': oneHunter.cyan,
|
|
92
|
+
'.css': oneHunter.cyan,
|
|
93
|
+
'.md': oneHunter.text,
|
|
94
|
+
'.txt': oneHunter.textAlt,
|
|
95
|
+
'.sh': oneHunter.green,
|
|
96
|
+
'.yml': oneHunter.purple,
|
|
97
|
+
'.yaml': oneHunter.purple,
|
|
98
|
+
'.xml': oneHunter.orange,
|
|
99
|
+
'.svg': oneHunter.cyan,
|
|
100
|
+
'.png': oneHunter.blue,
|
|
101
|
+
'.jpg': oneHunter.blue,
|
|
102
|
+
'.gif': oneHunter.blue,
|
|
103
|
+
'.pdf': oneHunter.red
|
|
104
|
+
};
|
|
105
|
+
return colorMap[ext] || oneHunter.text;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Generate shortcut label
|
|
109
|
+
const generateShortcut = (index, isFile, parentShortcut = '') => {
|
|
110
|
+
const letters = 'abcdefghijklmnoprsuvwxyz'; // omitting q, t
|
|
111
|
+
const base = letters.length;
|
|
112
|
+
if (isFile) return `${parentShortcut}${index + 1}`;
|
|
113
|
+
let label = '';
|
|
114
|
+
let num = index;
|
|
115
|
+
while (num >= 0) {
|
|
116
|
+
label = letters[num % base] + label;
|
|
117
|
+
num = Math.floor(num / base) - 1;
|
|
118
|
+
}
|
|
119
|
+
return parentShortcut + label;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// TreeRow
|
|
123
|
+
const TreeRow = /*#__PURE__*/React.memo(({
|
|
124
|
+
node,
|
|
125
|
+
isSelected,
|
|
126
|
+
collapsed,
|
|
127
|
+
shortcutInput
|
|
128
|
+
}) => {
|
|
129
|
+
const isCollapsed = collapsed.has(node.data.path);
|
|
130
|
+
const hasChildren = node.children && node.children.length > 0;
|
|
131
|
+
let prefix = '';
|
|
132
|
+
let current = node;
|
|
133
|
+
const segments = [];
|
|
134
|
+
while (current.parent) {
|
|
135
|
+
const parent = current.parent;
|
|
136
|
+
const siblings = parent.children || [];
|
|
137
|
+
const isLastChild = siblings[siblings.length - 1] === current;
|
|
138
|
+
segments.unshift(isLastChild ? ' ' : '│ ');
|
|
139
|
+
current = parent;
|
|
140
|
+
}
|
|
141
|
+
if (node.parent) {
|
|
142
|
+
const siblings = node.parent.children || [];
|
|
143
|
+
const isLastChild = siblings[siblings.length - 1] === node;
|
|
144
|
+
prefix = segments.join('').slice(0, -2) + (isLastChild ? '└─' : '├─');
|
|
145
|
+
}
|
|
146
|
+
let icon = hasChildren ? isCollapsed ? '▶ ' : '▼ ' : ' ';
|
|
147
|
+
const fileColor = node.data.isFile ? getFileColor(node.data.name) : oneHunter.magenta;
|
|
148
|
+
const renderShortcut = () => {
|
|
149
|
+
if (!shortcutInput) return /*#__PURE__*/React.createElement(Text, {
|
|
150
|
+
color: oneHunter.textAlt,
|
|
151
|
+
dimColor: true
|
|
152
|
+
}, ' ', "[", node.shortcut, "]");
|
|
153
|
+
if (node.shortcut.startsWith(shortcutInput)) {
|
|
154
|
+
const matched = node.shortcut.slice(0, shortcutInput.length);
|
|
155
|
+
const rest = node.shortcut.slice(shortcutInput.length);
|
|
156
|
+
return /*#__PURE__*/React.createElement(Text, {
|
|
157
|
+
color: oneHunter.textAlt
|
|
158
|
+
}, ' [', /*#__PURE__*/React.createElement(Text, {
|
|
159
|
+
underline: true,
|
|
160
|
+
color: oneHunter.yellow
|
|
161
|
+
}, matched), rest, ']');
|
|
162
|
+
}
|
|
163
|
+
return /*#__PURE__*/React.createElement(Text, {
|
|
164
|
+
color: oneHunter.textAlt,
|
|
165
|
+
dimColor: true
|
|
166
|
+
}, ' ', "[", node.shortcut, "]");
|
|
167
|
+
};
|
|
168
|
+
return /*#__PURE__*/React.createElement(Box, null, /*#__PURE__*/React.createElement(Text, null, /*#__PURE__*/React.createElement(Text, {
|
|
169
|
+
color: oneHunter.textAlt
|
|
170
|
+
}, prefix), /*#__PURE__*/React.createElement(Text, {
|
|
171
|
+
color: hasChildren ? oneHunter.cyan : oneHunter.textAlt
|
|
172
|
+
}, icon), /*#__PURE__*/React.createElement(Text, {
|
|
173
|
+
bold: isSelected,
|
|
174
|
+
color: isSelected ? oneHunter.bg : fileColor,
|
|
175
|
+
backgroundColor: isSelected ? oneHunter.green : undefined
|
|
176
|
+
}, node.data.name), hasChildren && !isCollapsed && /*#__PURE__*/React.createElement(Text, {
|
|
177
|
+
color: oneHunter.textAlt
|
|
178
|
+
}, " (", node.children.length, ")"), node.data.size > 0 && /*#__PURE__*/React.createElement(Text, {
|
|
179
|
+
color: oneHunter.green
|
|
180
|
+
}, " ", formatBytes(node.data.size)), renderShortcut()));
|
|
181
|
+
}, (p, n) => p.isSelected === n.isSelected && p.collapsed === n.collapsed && p.shortcutInput === n.shortcutInput);
|
|
182
|
+
const TreeVisualization = ({
|
|
183
|
+
dirPath = '.',
|
|
184
|
+
maxLevel = 1
|
|
185
|
+
}) => {
|
|
186
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
187
|
+
const [collapsed, setCollapsed] = useState(new Set());
|
|
188
|
+
const [shortcutInput, setShortcutInput] = useState('');
|
|
189
|
+
const [showTreeMap, setShowTreeMap] = useState(false);
|
|
190
|
+
const [initialized, setInitialized] = useState(false);
|
|
191
|
+
const {
|
|
192
|
+
stdout
|
|
193
|
+
} = useStdout();
|
|
194
|
+
const terminalHeight = stdout?.rows || 24;
|
|
195
|
+
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
|
+
|
|
202
|
+
// --- Initial collapse based on -L ---
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
const newCollapsed = new Set();
|
|
205
|
+
const applyLevel = (node, level = 0) => {
|
|
206
|
+
if (node.children) {
|
|
207
|
+
if (level >= maxLevel) newCollapsed.add(node.data.path);
|
|
208
|
+
node.children.forEach(child => applyLevel(child, level + 1));
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
applyLevel(root);
|
|
212
|
+
setCollapsed(newCollapsed);
|
|
213
|
+
setInitialized(true);
|
|
214
|
+
}, [maxLevel, root]);
|
|
215
|
+
|
|
216
|
+
// Assign shortcuts
|
|
217
|
+
const rootWithShortcuts = useMemo(() => {
|
|
218
|
+
const assignShortcuts = (node, parentShortcut = '', siblingIndex = 0) => {
|
|
219
|
+
const isFile = node.data.isFile;
|
|
220
|
+
node.shortcut = generateShortcut(siblingIndex, isFile, parentShortcut);
|
|
221
|
+
if (node.children) {
|
|
222
|
+
node.children.forEach((child, i) => assignShortcuts(child, node.shortcut, i));
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
const cloned = root.copy();
|
|
226
|
+
assignShortcuts(cloned);
|
|
227
|
+
return cloned;
|
|
228
|
+
}, [root]);
|
|
229
|
+
|
|
230
|
+
// Flatten visible nodes
|
|
231
|
+
const visibleNodes = useMemo(() => {
|
|
232
|
+
const nodes = [];
|
|
233
|
+
const traverse = node => {
|
|
234
|
+
nodes.push(node);
|
|
235
|
+
if (node.children && !collapsed.has(node.data.path)) {
|
|
236
|
+
node.children.forEach(traverse);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
traverse(rootWithShortcuts);
|
|
240
|
+
return nodes;
|
|
241
|
+
}, [rootWithShortcuts, collapsed]);
|
|
242
|
+
const shortcutMap = useMemo(() => {
|
|
243
|
+
const map = new Map();
|
|
244
|
+
visibleNodes.forEach((node, index) => map.set(node.shortcut, index));
|
|
245
|
+
return map;
|
|
246
|
+
}, [visibleNodes]);
|
|
247
|
+
useInput((input, key) => {
|
|
248
|
+
if (input === 'q') process.exit(0);
|
|
249
|
+
if (input === 't') {
|
|
250
|
+
setShowTreeMap(p => !p);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (showTreeMap) return;
|
|
254
|
+
if (key.upArrow) {
|
|
255
|
+
setSelectedIndex(p => Math.max(0, p - 1));
|
|
256
|
+
setShortcutInput('');
|
|
257
|
+
return;
|
|
258
|
+
} else if (key.downArrow) {
|
|
259
|
+
setSelectedIndex(p => Math.min(visibleNodes.length - 1, p + 1));
|
|
260
|
+
setShortcutInput('');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ✅ ENTER behavior
|
|
265
|
+
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
|
+
const node = visibleNodes[selectedIndex];
|
|
285
|
+
if (node.children) {
|
|
286
|
+
setCollapsed(prev => {
|
|
287
|
+
const next = new Set(prev);
|
|
288
|
+
if (next.has(node.data.path)) next.delete(node.data.path);else next.add(node.data.path);
|
|
289
|
+
return next;
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
setShortcutInput('');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (key.escape) {
|
|
296
|
+
setShortcutInput('');
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Typing a shortcut
|
|
301
|
+
if (input && /^[a-z0-9]$/.test(input)) {
|
|
302
|
+
const newInput = shortcutInput + input;
|
|
303
|
+
setShortcutInput(newInput);
|
|
304
|
+
|
|
305
|
+
// Move highlight immediately
|
|
306
|
+
if (shortcutMap.has(newInput)) {
|
|
307
|
+
setSelectedIndex(shortcutMap.get(newInput));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
if (showTreeMap) {
|
|
312
|
+
const node = visibleNodes[selectedIndex];
|
|
313
|
+
if (!node) return /*#__PURE__*/React.createElement(Text, {
|
|
314
|
+
color: oneHunter.red
|
|
315
|
+
}, "Error: No node selected");
|
|
316
|
+
return /*#__PURE__*/React.createElement(TreeMapView, {
|
|
317
|
+
selectedNode: node,
|
|
318
|
+
stdout: stdout
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// --- Viewport logic ---
|
|
323
|
+
const getViewportWindow = () => {
|
|
324
|
+
let start = Math.max(0, selectedIndex - Math.floor(viewportHeight / 2));
|
|
325
|
+
let end = start + viewportHeight;
|
|
326
|
+
if (end > visibleNodes.length) {
|
|
327
|
+
end = visibleNodes.length;
|
|
328
|
+
start = Math.max(0, end - viewportHeight);
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
start,
|
|
332
|
+
end
|
|
333
|
+
};
|
|
334
|
+
};
|
|
335
|
+
const {
|
|
336
|
+
start,
|
|
337
|
+
end
|
|
338
|
+
} = getViewportWindow();
|
|
339
|
+
const viewportNodes = visibleNodes.slice(start, end);
|
|
340
|
+
if (!initialized) return null;
|
|
341
|
+
return /*#__PURE__*/React.createElement(Box, {
|
|
342
|
+
flexDirection: "column"
|
|
343
|
+
}, /*#__PURE__*/React.createElement(Text, {
|
|
344
|
+
color: oneHunter.textAlt,
|
|
345
|
+
dimColor: true
|
|
346
|
+
}, "\u2191/\u2193: Navigate | Enter: Toggle | t: TreeMap | Esc: Clear | q: Quit"), start > 0 && /*#__PURE__*/React.createElement(Text, {
|
|
347
|
+
color: oneHunter.yellow
|
|
348
|
+
}, "\u22EE (", start, " more above)"), viewportNodes.map((node, i) => /*#__PURE__*/React.createElement(TreeRow, {
|
|
349
|
+
key: start + i,
|
|
350
|
+
node: node,
|
|
351
|
+
isSelected: start + i === selectedIndex,
|
|
352
|
+
collapsed: collapsed,
|
|
353
|
+
shortcutInput: shortcutInput
|
|
354
|
+
})), end < visibleNodes.length && /*#__PURE__*/React.createElement(Text, {
|
|
355
|
+
color: oneHunter.yellow
|
|
356
|
+
}, "\u22EE (", visibleNodes.length - end, " more below)"));
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// --- CLI args ---
|
|
360
|
+
const args = process.argv.slice(2);
|
|
361
|
+
|
|
362
|
+
// Dir flag
|
|
363
|
+
const dirIndex = args.indexOf('--dir');
|
|
364
|
+
let dirPath;
|
|
365
|
+
const firstNonFlagArg = args.find(arg => !arg.startsWith('-'));
|
|
366
|
+
if (dirIndex !== -1 && args[dirIndex + 1]) {
|
|
367
|
+
dirPath = args[dirIndex + 1];
|
|
368
|
+
} else if (firstNonFlagArg) {
|
|
369
|
+
dirPath = firstNonFlagArg;
|
|
370
|
+
} else {
|
|
371
|
+
dirPath = '.';
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Handle -L flag
|
|
375
|
+
const levelIndex = args.indexOf('-L');
|
|
376
|
+
const maxLevel = levelIndex !== -1 && args[levelIndex + 1] ? parseInt(args[levelIndex + 1], 10) : 1;
|
|
377
|
+
render(/*#__PURE__*/React.createElement(TreeVisualization, {
|
|
378
|
+
dirPath: dirPath,
|
|
379
|
+
maxLevel: maxLevel
|
|
380
|
+
}));
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tri-cli",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Interactive CLI directory tree visualizer with treemap view",
|
|
5
|
+
"author": "Jared Wilber",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"cli",
|
|
9
|
+
"tree",
|
|
10
|
+
"directory",
|
|
11
|
+
"treemap",
|
|
12
|
+
"visualization",
|
|
13
|
+
"interactive"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/jwilber/tri-cli.git"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"tri": "dist/cli.js"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=16"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "babel --out-dir=dist source",
|
|
28
|
+
"postbuild": "chmod +x dist/cli.js",
|
|
29
|
+
"dev": "babel --out-dir=dist --watch source",
|
|
30
|
+
"test": "prettier --check . && xo && ava",
|
|
31
|
+
"test:local": "npm run build && tri --dir . -L 2"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist"
|
|
35
|
+
],
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"d3-hierarchy": "^3.1.2",
|
|
38
|
+
"ink": "^4.1.0",
|
|
39
|
+
"meow": "^11.0.0",
|
|
40
|
+
"react": "^18.2.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@babel/cli": "^7.21.0",
|
|
44
|
+
"@babel/preset-react": "^7.28.5",
|
|
45
|
+
"@vdemedes/prettier-config": "^2.0.1",
|
|
46
|
+
"ava": "^5.2.0",
|
|
47
|
+
"chalk": "^5.2.0",
|
|
48
|
+
"eslint-config-xo-react": "^0.27.0",
|
|
49
|
+
"eslint-plugin-react": "^7.32.2",
|
|
50
|
+
"eslint-plugin-react-hooks": "^4.6.0",
|
|
51
|
+
"import-jsx": "^5.0.0",
|
|
52
|
+
"ink-testing-library": "^3.0.0",
|
|
53
|
+
"prettier": "^2.8.7",
|
|
54
|
+
"xo": "^0.53.1"
|
|
55
|
+
},
|
|
56
|
+
"ava": {
|
|
57
|
+
"environmentVariables": {
|
|
58
|
+
"NODE_NO_WARNINGS": "1"
|
|
59
|
+
},
|
|
60
|
+
"nodeArguments": [
|
|
61
|
+
"--loader=import-jsx"
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
"xo": {
|
|
65
|
+
"extends": "xo-react",
|
|
66
|
+
"prettier": true,
|
|
67
|
+
"rules": {
|
|
68
|
+
"react/prop-types": "off"
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"prettier": "@vdemedes/prettier-config",
|
|
72
|
+
"babel": {
|
|
73
|
+
"presets": [
|
|
74
|
+
"@babel/preset-react"
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
```
|