tri-cli 0.0.4 → 0.0.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,15 +1,52 @@
1
1
  # tri-cli
2
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:
3
+ A terminal-based interactive directory visualizer, like `tree` but with keyboard navigation, shortcuts, colors, and [treemap](https://en.wikipedia.org/wiki/Treemapping) visuals:
4
4
 
5
5
  ![tri demo in gif](/assets/readme.gif)
6
6
 
7
+
7
8
  ## Installation
8
9
 
10
+ ### Global Installation (Recommended)
11
+
12
+ It's recommended to install globally:
13
+
9
14
  ```bash
10
15
  npm install -g tri-cli
11
16
  ```
12
17
 
18
+ After global installation, you can run `tri` directly from anywhere:
19
+
20
+ ```bash
21
+ tri .
22
+ ```
23
+
24
+ ### Local Installation
25
+
26
+ If you don't want to install globally, you can install locally and then run anywhere with `npx`:
27
+
28
+ ```bash
29
+ npm install tri-cli
30
+ ```
31
+
32
+ For local installations, use `npx` to run the command:
33
+
34
+ ```bash
35
+ npx tri .
36
+ ```
37
+
38
+ Or add it to your `package.json` scripts:
39
+
40
+ ```json
41
+ {
42
+ "scripts": {
43
+ "tree": "tri"
44
+ }
45
+ }
46
+ ```
47
+
48
+ Then run with `npm run tree`.
49
+
13
50
  ## Usage
14
51
 
15
52
  ```bash
@@ -22,46 +59,72 @@ tri [directory] [options]
22
59
  tri . # visualize current directory
23
60
  tri ../bionemo -L 2 # show 2 levels deep
24
61
  tri --dir src # specify directory with flag
25
- tri --dir src -ns # create tree without dir/file size (faster, but no treemap)
62
+ tri src -s # preload all sizes (enables immediate treemap)
63
+ tri --dir src -c # start with all directories collapsed
26
64
  ```
27
65
 
28
66
  ### Interactive Controls
29
67
 
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:**
68
+ | Key | Action |
69
+ | ------------- | ----------------------------------------- |
70
+ | ↑ / ↓ | Navigate files and folders |
71
+ | Enter | Expand or collapse a directory |
72
+ | [a-z]/[0-9] | Type shortcut to jump to item |
73
+ | d | Calculate and display sizes recursively |
74
+ | l | Show last modified dates (ls -l style) |
75
+ | m | Toggle treemap view |
76
+ | Esc | Clear typed shortcut |
77
+ | q | Quit |
78
+
79
+ ### Command Line Options
80
+
81
+ | Option | Description |
82
+ | ---------------- | ------------------------------------------------ |
83
+ | `[directory]` | Directory to visualize (default: current dir) |
84
+ | `-L <level>` | Maximum depth to load initially (default: 1) |
85
+ | `-s, --size` | Preload all file/directory sizes |
86
+ | `-c, --collapse` | Start with all directories collapsed |
87
+ | `-h, --help` | Show help message |
88
+
89
+ ## Features
90
+
91
+ ### Tree View
92
+ - **Keyboard shortcuts**: Each item gets a shortcut label - just type it to jump there instantly
93
+ - **Tree-style lines**: Visual hierarchy with Unicode box-drawing characters (├─, ╰─, │)
94
+ - **Color coding**: Different colors for different file types
95
+ - **Lazy loading**: Only loads directory contents when you expand them (fast for large directories)
96
+ - **Size calculation**: Press `d` on any item to calculate disk usage recursively
97
+ - **Date display**: Press `l` to show last modified dates in `ls -l` format
98
+
99
+ ### Treemap View
100
+ - Press `m` to visualize the selected directory as a treemap
101
+ - Visual representation of file/directory sizes
102
+ - Hierarchical rectangles sized proportionally to disk usage
103
+ - Color-coded by file type or directory group
104
+
105
+ ## Development
106
+
107
+ Clone the repository and install dependencies:
48
108
 
49
109
  ```bash
50
- tri ../bionemo -L 2
110
+ git clone https://github.com/jwilber/tri-cli.git
111
+ cd tri-cli
112
+ npm install
51
113
  ```
52
114
 
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:
115
+ Build and link for local development:
58
116
 
59
117
  ```bash
60
118
  npm run build
61
-
62
119
  npm link
63
120
  ```
64
121
 
65
- **License:**
122
+ Now you can run `tri` from anywhere and it will use your local development version.
123
+
124
+ ## License
66
125
 
67
126
  MIT
127
+
128
+ ## Author
129
+
130
+ Jared Wilber
package/dist/cli.js CHANGED
@@ -30,6 +30,24 @@ const formatBytes = bytes => {
30
30
  const i = Math.floor(Math.log(bytes) / Math.log(k));
31
31
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
32
32
  };
33
+ const formatDate = timestamp => {
34
+ const date = new Date(timestamp);
35
+ const now = new Date();
36
+ const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
37
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
38
+ const month = months[date.getMonth()];
39
+ const day = date.getDate().toString().padStart(2, ' ');
40
+
41
+ // If file is from this year, show time; otherwise show year
42
+ if (diffDays < 180) {
43
+ const hours = date.getHours().toString().padStart(2, '0');
44
+ const minutes = date.getMinutes().toString().padStart(2, '0');
45
+ return `${month} ${day} ${hours}:${minutes}`;
46
+ } else {
47
+ const year = date.getFullYear();
48
+ return `${month} ${day} ${year}`;
49
+ }
50
+ };
33
51
 
34
52
  // Async non-blocking directory size
35
53
  const getDirSizeAsync = async dirPath => {
@@ -117,7 +135,7 @@ const getFileColor = filename => {
117
135
 
118
136
  // Exclude m, t, d
119
137
  const generateShortcut = (index, isFile, parentShortcut = '') => {
120
- const letters = 'abcefgijklnopqrsuvwxyz';
138
+ const letters = 'abcefgijknopqrsuvwxyz';
121
139
  const base = letters.length;
122
140
  if (isFile) return `${parentShortcut}${index + 1}`;
123
141
  let label = '';
@@ -128,6 +146,34 @@ const generateShortcut = (index, isFile, parentShortcut = '') => {
128
146
  }
129
147
  return parentShortcut + label;
130
148
  };
149
+
150
+ // Generate tree lines (like the `tree` command)
151
+ const getTreeLines = node => {
152
+ if (node.depth === 0) return ''; // Root has no lines
153
+
154
+ const lines = [];
155
+ let current = node;
156
+
157
+ // Build the lines from current node up to (but not including) root
158
+ while (current.parent && current.parent.depth > 0) {
159
+ const parent = current.parent;
160
+ const isLastChild = parent.children && current === parent.children[parent.children.length - 1];
161
+ lines.unshift(isLastChild ? ' ' : '│ ');
162
+ current = parent;
163
+ }
164
+
165
+ // Add the final connector for this node
166
+ const parent = node.parent;
167
+ if (parent && parent.depth === 0) {
168
+ // Direct child of root
169
+ const isLastChild = parent.children && node === parent.children[parent.children.length - 1];
170
+ return isLastChild ? '╰─' : '├─';
171
+ } else if (parent) {
172
+ const isLastChild = parent.children && node === parent.children[parent.children.length - 1];
173
+ return lines.join('') + (isLastChild ? '╰─' : '├─');
174
+ }
175
+ return '';
176
+ };
131
177
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
132
178
  const TreeRow = /*#__PURE__*/React.memo(({
133
179
  node,
@@ -137,12 +183,15 @@ const TreeRow = /*#__PURE__*/React.memo(({
137
183
  noSize,
138
184
  calculatedSizes,
139
185
  loadingPaths,
140
- spinnerFrame
186
+ spinnerFrame,
187
+ lastModifiedDates
141
188
  }) => {
142
189
  const isCollapsed = collapsed.has(node.data.path);
143
190
  const hasChildren = node.children && node.children.length > 0;
144
191
  const isLoading = loadingPaths.has(node.data.path);
145
192
  const fileColor = node.data.isFile ? getFileColor(node.data.name) : oneHunter.magenta;
193
+ const isRoot = node.depth === 0;
194
+ const isDirectory = !node.data.isFile;
146
195
  let displaySize = 0;
147
196
  // Use calculated size if available, otherwise fall back to node.data.size
148
197
  if (calculatedSizes && calculatedSizes.has(node.data.path)) {
@@ -150,6 +199,8 @@ const TreeRow = /*#__PURE__*/React.memo(({
150
199
  } else {
151
200
  displaySize = node.data.size;
152
201
  }
202
+ const lastModified = lastModifiedDates.get(node.data.path);
203
+ const treeLines = getTreeLines(node);
153
204
 
154
205
  // ✅ Restore highlight logic
155
206
  const renderShortcut = () => {
@@ -172,21 +223,25 @@ const TreeRow = /*#__PURE__*/React.memo(({
172
223
  dimColor: true
173
224
  }, ' ', "[", node.shortcut, "]");
174
225
  };
175
- return /*#__PURE__*/React.createElement(Box, null, /*#__PURE__*/React.createElement(Text, null, /*#__PURE__*/React.createElement(Text, {
226
+ return /*#__PURE__*/React.createElement(Box, null, /*#__PURE__*/React.createElement(Text, null, !isRoot && /*#__PURE__*/React.createElement(Text, {
227
+ color: oneHunter.textAlt
228
+ }, treeLines), !isRoot && isDirectory && /*#__PURE__*/React.createElement(Text, {
176
229
  color: oneHunter.textAlt
177
- }, node.depth > 0 ? ' '.repeat(node.depth * 2) : ''), /*#__PURE__*/React.createElement(Text, {
178
- color: hasChildren ? oneHunter.cyan : oneHunter.textAlt
179
- }, hasChildren ? isCollapsed ? '▶ ' : '▼ ' : ' '), /*#__PURE__*/React.createElement(Text, {
230
+ }, hasChildren && !isCollapsed ? ' ' : ''), !isRoot && !isDirectory && /*#__PURE__*/React.createElement(Text, {
231
+ color: oneHunter.textAlt
232
+ }, "\u2500 "), /*#__PURE__*/React.createElement(Text, {
180
233
  bold: isSelected,
181
234
  color: isSelected ? oneHunter.bg : fileColor,
182
235
  backgroundColor: isSelected ? oneHunter.green : undefined
183
- }, node.data.name), hasChildren && !isCollapsed && /*#__PURE__*/React.createElement(Text, {
236
+ }, node.data.name, !node.data.isFile && !isRoot ? '/' : ''), hasChildren && !isCollapsed && /*#__PURE__*/React.createElement(Text, {
184
237
  color: oneHunter.textAlt
185
238
  }, " (", node.children.length, ")"), isLoading ? /*#__PURE__*/React.createElement(Text, {
186
239
  color: oneHunter.yellow
187
240
  }, " ", SPINNER_FRAMES[spinnerFrame]) : displaySize > 0 ? /*#__PURE__*/React.createElement(Text, {
188
241
  color: oneHunter.cyan
189
- }, " ", formatBytes(displaySize)) : /*#__PURE__*/React.createElement(Text, null, " "), renderShortcut()));
242
+ }, " ", formatBytes(displaySize)) : /*#__PURE__*/React.createElement(Text, null, " "), renderShortcut(), lastModified && /*#__PURE__*/React.createElement(Text, {
243
+ color: oneHunter.text2
244
+ }, " ", formatDate(lastModified))));
190
245
  });
191
246
  const TreeVisualization = ({
192
247
  dirPath = '.',
@@ -203,6 +258,7 @@ const TreeVisualization = ({
203
258
  const [loadingPaths, setLoadingPaths] = useState(new Set());
204
259
  const [spinnerFrame, setSpinnerFrame] = useState(0);
205
260
  const [rootTree, setRootTree] = useState(() => hierarchy(readDirTree(dirPath, noSize, 0, maxLevel)));
261
+ const [lastModifiedDates, setLastModifiedDates] = useState(new Map());
206
262
  const {
207
263
  stdout
208
264
  } = useStdout();
@@ -329,6 +385,44 @@ const TreeVisualization = ({
329
385
  await new Promise(r => setTimeout(r, 0));
330
386
  }
331
387
  };
388
+ const getLastModifiedAsync = async node => {
389
+ // Collect node and all its descendants
390
+ const nodesToProcess = [];
391
+ const collectNodes = n => {
392
+ // Skip if already have date
393
+ if (lastModifiedDates.has(n.data.path)) {
394
+ // Still recurse to children in case they need dates
395
+ if (n.children) {
396
+ n.children.forEach(collectNodes);
397
+ }
398
+ return;
399
+ }
400
+ nodesToProcess.push(n);
401
+ if (n.children) {
402
+ n.children.forEach(collectNodes);
403
+ }
404
+ };
405
+ collectNodes(node);
406
+ if (nodesToProcess.length === 0) return; // Nothing to process
407
+
408
+ const newMap = new Map(lastModifiedDates);
409
+
410
+ // Get mtime for each node
411
+ for (const targetNode of nodesToProcess) {
412
+ try {
413
+ const stats = fs.statSync(targetNode.data.path);
414
+ newMap.set(targetNode.data.path, stats.mtimeMs);
415
+ } catch {
416
+ // If we can't stat the file, skip it
417
+ }
418
+
419
+ // Stream the update immediately
420
+ React.startTransition(() => {
421
+ setLastModifiedDates(new Map(newMap));
422
+ });
423
+ await new Promise(r => setTimeout(r, 0));
424
+ }
425
+ };
332
426
  const traverseDir = async node => {
333
427
  if (node.data.isFile) return;
334
428
 
@@ -425,18 +519,11 @@ const TreeVisualization = ({
425
519
  });
426
520
  return;
427
521
  }
428
- if (input === 't') {
429
- const node = visibleNodes[selectedIndex];
430
- if (node) await traverseDir(node);
431
- return;
432
- }
433
522
  if (input === 'm') {
434
523
  const node = visibleNodes[selectedIndex];
435
524
  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
- }
525
+ // Always start calculating sizes in the background (calculateSizeAsync skips already-calculated nodes)
526
+ calculateSizeAsync(node);
440
527
  // Show treemap immediately
441
528
  setShowTreeMap(true);
442
529
  return;
@@ -446,6 +533,11 @@ const TreeVisualization = ({
446
533
  if (node) await calculateSizeAsync(node);
447
534
  return;
448
535
  }
536
+ if (input === 'l') {
537
+ const node = visibleNodes[selectedIndex];
538
+ if (node) await getLastModifiedAsync(node);
539
+ return;
540
+ }
449
541
  if (key.upArrow) {
450
542
  setSelectedIndex(p => Math.max(0, p - 1));
451
543
  setShortcutInput('');
@@ -458,11 +550,23 @@ const TreeVisualization = ({
458
550
  }
459
551
  if (key.return) {
460
552
  const node = visibleNodes[selectedIndex];
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
- });
553
+ if (!node) return;
554
+
555
+ // Don't allow collapsing/expanding the root
556
+ if (node.depth === 0) return;
557
+ if (node.data.isFile) return; // Do nothing for files
558
+
559
+ // If it's a directory with no children loaded yet, traverse into it
560
+ if (node.data.children === undefined) {
561
+ await traverseDir(node);
562
+ } else if (node.children) {
563
+ // Children already loaded, toggle collapse
564
+ setCollapsed(prev => {
565
+ const next = new Set(prev);
566
+ if (next.has(node.data.path)) next.delete(node.data.path);else next.add(node.data.path);
567
+ return next;
568
+ });
569
+ }
466
570
  setShortcutInput('');
467
571
  return;
468
572
  }
@@ -479,22 +583,44 @@ const TreeVisualization = ({
479
583
  if (showTreeMap) {
480
584
  const node = visibleNodes[selectedIndex];
481
585
 
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;
586
+ // Populate value property on DATA objects for treemap visualization
587
+ // This is important because TreeMapView creates a new hierarchy from node.data,
588
+ // so we need to set values on the data objects, not the hierarchy nodes
589
+ const populateValues = hierarchyNode => {
590
+ if (!hierarchyNode) return;
591
+
592
+ // Recursively populate children first (depth-first, bottom-up)
593
+ if (hierarchyNode.children) {
594
+ hierarchyNode.children.forEach(populateValues);
494
595
  }
495
- // Recursively populate children
496
- if (n.children) {
497
- n.children.forEach(populateValues);
596
+
597
+ // Set value on the DATA object
598
+ const data = hierarchyNode.data;
599
+ if (data.isFile) {
600
+ // Files: use calculatedSize or read from disk
601
+ const calcSize = calculatedSizes.get(data.path);
602
+ if (calcSize !== undefined) {
603
+ data.value = calcSize;
604
+ } else {
605
+ try {
606
+ data.value = fs.statSync(data.path).size;
607
+ } catch {
608
+ data.value = 0;
609
+ }
610
+ }
611
+ } else if (data.children === undefined || !hierarchyNode.children || hierarchyNode.children.length === 0) {
612
+ // Directory with no children loaded OR empty directory
613
+ // Use calculatedSize if available
614
+ const calcSize = calculatedSizes.get(data.path);
615
+ if (calcSize !== undefined) {
616
+ data.value = calcSize;
617
+ } else {
618
+ // For unloaded directories without calculated size, will show as 0
619
+ data.value = data.size || 0;
620
+ }
621
+ } else {
622
+ // Directory with loaded children - let d3's .sum() aggregate from children
623
+ data.value = undefined;
498
624
  }
499
625
  };
500
626
  populateValues(node);
@@ -516,7 +642,7 @@ const TreeVisualization = ({
516
642
  }, /*#__PURE__*/React.createElement(Text, {
517
643
  color: oneHunter.textAlt,
518
644
  dimColor: true
519
- }, "\u2191/\u2193: Navigate | Enter: Toggle | d: Size | t: Traverse | m: Map | q: Quit"), loadingPaths.size > 0 && /*#__PURE__*/React.createElement(Text, {
645
+ }, "\u2191/\u2193: Navigate | Enter: Expand/Traverse | d: Size | l: Date | m: Map | q: Quit"), loadingPaths.size > 0 && /*#__PURE__*/React.createElement(Text, {
520
646
  color: oneHunter.yellow
521
647
  }, ' ', SPINNER_FRAMES[spinnerFrame], " Loading ", loadingPaths.size, "...")), viewportNodes.map((node, i) => /*#__PURE__*/React.createElement(TreeRow, {
522
648
  key: start + i,
@@ -527,7 +653,8 @@ const TreeVisualization = ({
527
653
  noSize: noSize,
528
654
  calculatedSizes: calculatedSizes,
529
655
  loadingPaths: loadingPaths,
530
- spinnerFrame: spinnerFrame
656
+ spinnerFrame: spinnerFrame,
657
+ lastModifiedDates: lastModifiedDates
531
658
  })));
532
659
  };
533
660
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tri-cli",
3
- "version": "0.0.4",
3
+ "version": "0.0.51",
4
4
  "description": "Interactive CLI directory tree visualizer with treemap view",
5
5
  "author": "Jared Wilber",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "postbuild": "chmod +x dist/cli.js",
29
29
  "dev": "babel --out-dir=dist --watch source",
30
30
  "test": "prettier --check . && xo && ava",
31
- "test:local": "npm run build && tri --dir . -L 2"
31
+ "test:local": "npm run build && node dist/cli.js --dir . -L 2"
32
32
  },
33
33
  "files": [
34
34
  "dist"
@@ -37,8 +37,7 @@
37
37
  "d3-hierarchy": "^3.1.2",
38
38
  "ink": "^4.1.0",
39
39
  "meow": "^11.0.0",
40
- "react": "^18.2.0",
41
- "tri-cli": "^0.0.3"
40
+ "react": "^18.2.0"
42
41
  },
43
42
  "devDependencies": {
44
43
  "@babel/cli": "^7.21.0",
@@ -76,4 +75,4 @@
76
75
  "@babel/preset-react"
77
76
  ]
78
77
  }
79
- }
78
+ }
package/readme.tape ADDED
@@ -0,0 +1,78 @@
1
+ Output assets/readme.gif
2
+ Set FontSize 18
3
+ Set Width 1000
4
+ Set Height 600
5
+ Set Theme "Dracula"
6
+
7
+ Type "# tri: interactive tree + treemap visual"
8
+ Enter
9
+ Sleep 500ms
10
+
11
+ Type "tri ."
12
+ Enter
13
+ Sleep 2s
14
+
15
+ Down
16
+ Sleep 250ms
17
+ Down
18
+ Sleep 250ms
19
+ Enter
20
+ Sleep 500ms
21
+ Type "d"
22
+ Enter
23
+ Sleep 500ms
24
+ Down
25
+ Sleep 250ms
26
+ Down
27
+ Sleep 250ms
28
+ Down
29
+ Sleep 250ms
30
+ Type "l"
31
+ Sleep 1.5s
32
+
33
+ Enter
34
+ Sleep 1.5s
35
+ Type "m"
36
+ Sleep 1.5s
37
+ Type "m"
38
+ Sleep 500ms
39
+ Down
40
+ Sleep 250ms
41
+ Down
42
+ Sleep 250ms
43
+ Enter
44
+ Sleep 1.5s
45
+
46
+
47
+ # Toggle treemap view
48
+ Type "m"
49
+ Sleep 2s
50
+
51
+ # Return to tree view
52
+ Type "m"
53
+ Sleep 2s
54
+
55
+ Type "a"
56
+ Sleep 250ms
57
+ Type "c"
58
+ Sleep 500ms
59
+ Enter
60
+ Sleep 1s
61
+
62
+ Type "a"
63
+ Sleep 250ms
64
+ Type "h"
65
+ Sleep 500ms
66
+ Enter
67
+ Sleep 1s
68
+
69
+ Enter
70
+ Sleep 1s
71
+
72
+ # Quit
73
+ Type "q"
74
+ Sleep 1s
75
+
76
+ Type "# install with: npm i -g tri-cli"
77
+ Enter
78
+ Sleep 2s