ftreeview 0.1.4 → 0.1.5
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 +6 -0
- package/dist/cli.js +124 -12
- package/package.json +1 -1
- package/src/App.jsx +24 -6
- package/src/cli.js +6 -0
- package/src/components/StatusBar.jsx +107 -5
- package/src/hooks/useNavigation.js +10 -1
- package/src/hooks/useWatcher.js +9 -1
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ A lightweight, interactive CLI file explorer with live monitoring and git awaren
|
|
|
13
13
|
- **Colored File Types** - Syntax-highlighted file extensions (JS, TS, Python, Go, Rust, etc.)
|
|
14
14
|
- **Smart Icons** - Nerd Font devicons (optional) with emoji fallback; ASCII mode supported
|
|
15
15
|
- **Keyboard Navigation** - Full keyboard control with arrow keys or vim bindings
|
|
16
|
+
- **Optional Size Display** - Show selected file size in status bar when needed
|
|
16
17
|
- **Handles Large Directories** - Efficient traversal with viewport scrolling
|
|
17
18
|
- **Terminal Resize Support** - Adapts to terminal window size changes
|
|
18
19
|
- **Clean Exit** - Graceful shutdown with proper terminal state restoration
|
|
@@ -70,6 +71,7 @@ ftreeview [path] [options]
|
|
|
70
71
|
| `--icon-set <emoji\|nerd\|ascii>` | Choose icon set (default: emoji). You can also set `FTREE_ICON_SET`. |
|
|
71
72
|
| `--theme <vscode>` | UI theme (default: vscode). You can also set `FTREE_THEME`. |
|
|
72
73
|
| `--no-watch` | Disable file watching for static view |
|
|
74
|
+
| `--show-size` | Show selected file size in status bar (non-recursive, lightweight) |
|
|
73
75
|
|
|
74
76
|
### Examples
|
|
75
77
|
|
|
@@ -94,6 +96,9 @@ ftreeview --no-git
|
|
|
94
96
|
|
|
95
97
|
# Static view (no live watching)
|
|
96
98
|
ftreeview --no-watch
|
|
99
|
+
|
|
100
|
+
# Show selected file size in status bar
|
|
101
|
+
ftreeview --show-size
|
|
97
102
|
```
|
|
98
103
|
|
|
99
104
|
## Environment Variables
|
|
@@ -116,6 +121,7 @@ ftreeview --no-git
|
|
|
116
121
|
| `g` | Jump to first item |
|
|
117
122
|
| `G` | Jump to last item |
|
|
118
123
|
| `r` | Refresh tree |
|
|
124
|
+
| `?` | Toggle keybinding hints |
|
|
119
125
|
| `q` / `Ctrl+C` | Quit |
|
|
120
126
|
|
|
121
127
|
## Screenshots
|
package/dist/cli.js
CHANGED
|
@@ -321,6 +321,50 @@ function formatPath(path, maxWidth) {
|
|
|
321
321
|
}
|
|
322
322
|
return truncatePath(ellipsisPath, maxWidth);
|
|
323
323
|
}
|
|
324
|
+
function getEventMessageColor(eventType) {
|
|
325
|
+
if (eventType === "add" || eventType === "addDir") {
|
|
326
|
+
return "green";
|
|
327
|
+
}
|
|
328
|
+
if (eventType === "change") {
|
|
329
|
+
return "yellow";
|
|
330
|
+
}
|
|
331
|
+
if (eventType === "unlink" || eventType === "unlinkDir") {
|
|
332
|
+
return "magenta";
|
|
333
|
+
}
|
|
334
|
+
return "magenta";
|
|
335
|
+
}
|
|
336
|
+
function formatBytes(bytes) {
|
|
337
|
+
if (!Number.isFinite(bytes) || bytes < 0) {
|
|
338
|
+
return "";
|
|
339
|
+
}
|
|
340
|
+
if (bytes < 1024) {
|
|
341
|
+
return `${bytes}B`;
|
|
342
|
+
}
|
|
343
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
344
|
+
let value = bytes / 1024;
|
|
345
|
+
let unitIndex = 0;
|
|
346
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
347
|
+
value /= 1024;
|
|
348
|
+
unitIndex++;
|
|
349
|
+
}
|
|
350
|
+
const decimals = value >= 10 ? 0 : 1;
|
|
351
|
+
return `${value.toFixed(decimals)}${units[unitIndex]}`;
|
|
352
|
+
}
|
|
353
|
+
function getWatchDisplay({ isWatching, watchMode, watchError, watchIcon }) {
|
|
354
|
+
if (watchMode === "off") {
|
|
355
|
+
return { text: `${watchIcon} Static`, tone: "muted" };
|
|
356
|
+
}
|
|
357
|
+
if (watchError) {
|
|
358
|
+
return { text: `${watchIcon} Paused`, tone: "warning" };
|
|
359
|
+
}
|
|
360
|
+
if (!isWatching) {
|
|
361
|
+
return { text: `${watchIcon} Starting`, tone: "muted" };
|
|
362
|
+
}
|
|
363
|
+
if (watchMode === "polling") {
|
|
364
|
+
return { text: `${watchIcon} Watching (poll)`, tone: "warning" };
|
|
365
|
+
}
|
|
366
|
+
return { text: `${watchIcon} Watching`, tone: "success" };
|
|
367
|
+
}
|
|
324
368
|
function calculateGitStatus(statusMap) {
|
|
325
369
|
if (!statusMap || statusMap.size === 0) {
|
|
326
370
|
return { isRepo: false, summary: "" };
|
|
@@ -346,11 +390,18 @@ function StatusBar({
|
|
|
346
390
|
fileCount = 0,
|
|
347
391
|
dirCount = 0,
|
|
348
392
|
isWatching = false,
|
|
393
|
+
watchMode = "native",
|
|
394
|
+
watchError = false,
|
|
349
395
|
gitEnabled = false,
|
|
350
396
|
isGitRepo = false,
|
|
351
397
|
gitSummary = "",
|
|
352
398
|
showHelp = true,
|
|
399
|
+
selectedPath = "",
|
|
400
|
+
showSize = false,
|
|
401
|
+
selectedIsDir = false,
|
|
402
|
+
selectedSize = void 0,
|
|
353
403
|
eventMessage = "",
|
|
404
|
+
eventType = "",
|
|
354
405
|
termWidth = 80,
|
|
355
406
|
iconSet = "emoji",
|
|
356
407
|
theme = VSCODE_THEME
|
|
@@ -384,9 +435,19 @@ function StatusBar({
|
|
|
384
435
|
const paddingWidth = 2;
|
|
385
436
|
const versionText = `ftreeview ${version}`;
|
|
386
437
|
const countsText = `${barIcons.file} ${fileCount} ${barIcons.folder} ${dirCount}`;
|
|
387
|
-
const
|
|
438
|
+
const watchDisplay = getWatchDisplay({
|
|
439
|
+
isWatching,
|
|
440
|
+
watchMode,
|
|
441
|
+
watchError,
|
|
442
|
+
watchIcon: barIcons.watch
|
|
443
|
+
});
|
|
444
|
+
const watchText = watchDisplay.text;
|
|
445
|
+
const watchColor = watchDisplay.tone === "success" ? finalTheme.colors.success : watchDisplay.tone === "warning" ? finalTheme.colors.warning : finalTheme.colors.muted;
|
|
388
446
|
const gitText = gitEnabled && isGitRepo ? `${barIcons.git} ${gitSummary || "clean"}` : "";
|
|
389
|
-
const
|
|
447
|
+
const sizeValueText = !showSize ? "" : selectedIsDir ? "--" : formatBytes(selectedSize);
|
|
448
|
+
const showSizeText = Boolean(showSize && sizeValueText);
|
|
449
|
+
const sizeText = showSizeText ? `size ${sizeValueText}` : "";
|
|
450
|
+
const fixedSegments = [versionText, countsText, watchText].concat(showSizeText ? [sizeText] : []).concat(gitText ? [gitText] : []);
|
|
390
451
|
const fixedWidth = fixedSegments.reduce((sum, part) => sum + part.length, 0) + fixedSegments.length * sep4.length + paddingWidth;
|
|
391
452
|
const pathMaxWidth = Math.max(8, termWidth - fixedWidth);
|
|
392
453
|
const displayPath = formatPath(rootPath, pathMaxWidth);
|
|
@@ -407,7 +468,11 @@ function StatusBar({
|
|
|
407
468
|
/* @__PURE__ */ jsx3(Text3, { ...finalTheme.statusBar.separatorStyle, children: sep4 }),
|
|
408
469
|
/* @__PURE__ */ jsx3(Text3, { color: finalTheme.colors.muted, children: countsText }),
|
|
409
470
|
/* @__PURE__ */ jsx3(Text3, { ...finalTheme.statusBar.separatorStyle, children: sep4 }),
|
|
410
|
-
/* @__PURE__ */ jsx3(Text3, { color:
|
|
471
|
+
/* @__PURE__ */ jsx3(Text3, { color: watchColor, children: watchText }),
|
|
472
|
+
showSizeText && /* @__PURE__ */ jsxs2(Fragment, { children: [
|
|
473
|
+
/* @__PURE__ */ jsx3(Text3, { ...finalTheme.statusBar.separatorStyle, children: sep4 }),
|
|
474
|
+
/* @__PURE__ */ jsx3(Text3, { color: finalTheme.colors.muted, children: sizeText })
|
|
475
|
+
] }),
|
|
411
476
|
gitEnabled && isGitRepo && /* @__PURE__ */ jsxs2(Fragment, { children: [
|
|
412
477
|
/* @__PURE__ */ jsx3(Text3, { ...finalTheme.statusBar.separatorStyle, children: sep4 }),
|
|
413
478
|
/* @__PURE__ */ jsx3(Text3, { color: gitSummary === "clean" ? finalTheme.colors.success : finalTheme.colors.warning, children: gitText })
|
|
@@ -420,7 +485,21 @@ function StatusBar({
|
|
|
420
485
|
{
|
|
421
486
|
width: termWidth,
|
|
422
487
|
paddingX: 1,
|
|
423
|
-
children: /* @__PURE__ */ jsx3(Text3, { ...finalTheme.statusBar.hintStyle, children: "q quit \u2191\u2193/jk move \u2190\u2192/hl expand space toggle r refresh" })
|
|
488
|
+
children: /* @__PURE__ */ jsx3(Text3, { ...finalTheme.statusBar.hintStyle, children: "q quit \u2191\u2193/jk move \u2190\u2192/hl expand space toggle r refresh ? help" })
|
|
489
|
+
}
|
|
490
|
+
),
|
|
491
|
+
selectedPath && /* @__PURE__ */ jsxs2(
|
|
492
|
+
Box3,
|
|
493
|
+
{
|
|
494
|
+
width: termWidth,
|
|
495
|
+
paddingX: 1,
|
|
496
|
+
children: [
|
|
497
|
+
/* @__PURE__ */ jsxs2(Text3, { color: finalTheme.colors.muted, children: [
|
|
498
|
+
"selected",
|
|
499
|
+
" "
|
|
500
|
+
] }),
|
|
501
|
+
/* @__PURE__ */ jsx3(Text3, { color: finalTheme.colors.fg, children: formatPath(selectedPath, Math.max(8, termWidth - 11)) })
|
|
502
|
+
]
|
|
424
503
|
}
|
|
425
504
|
),
|
|
426
505
|
eventMessage && /* @__PURE__ */ jsx3(
|
|
@@ -428,7 +507,7 @@ function StatusBar({
|
|
|
428
507
|
{
|
|
429
508
|
width: termWidth,
|
|
430
509
|
paddingX: 1,
|
|
431
|
-
children: /* @__PURE__ */ jsx3(Text3, { color:
|
|
510
|
+
children: /* @__PURE__ */ jsx3(Text3, { color: getEventMessageColor(eventType), bold: true, children: eventMessage })
|
|
432
511
|
}
|
|
433
512
|
)
|
|
434
513
|
] });
|
|
@@ -930,7 +1009,7 @@ function useTree(rootPath, options = {}) {
|
|
|
930
1009
|
import { useState as useState2, useEffect, useCallback as useCallback2 } from "react";
|
|
931
1010
|
import { useInput, useApp } from "ink";
|
|
932
1011
|
var VIEWPORT_MARGIN = 3;
|
|
933
|
-
function useNavigation(flatList = [], rebuildTree, refreshFlatList = null, ensureChildrenLoaded = null) {
|
|
1012
|
+
function useNavigation(flatList = [], rebuildTree, refreshFlatList = null, ensureChildrenLoaded = null, onToggleHelp = null) {
|
|
934
1013
|
const { exit } = useApp();
|
|
935
1014
|
const viewportHeight = process.stdout.rows - 2;
|
|
936
1015
|
const [cursor, setCursor] = useState2(0);
|
|
@@ -1034,6 +1113,8 @@ function useNavigation(flatList = [], rebuildTree, refreshFlatList = null, ensur
|
|
|
1034
1113
|
jumpToBottom();
|
|
1035
1114
|
} else if (input === "r") {
|
|
1036
1115
|
refresh();
|
|
1116
|
+
} else if (input === "?" && typeof onToggleHelp === "function") {
|
|
1117
|
+
onToggleHelp();
|
|
1037
1118
|
}
|
|
1038
1119
|
});
|
|
1039
1120
|
useEffect(() => {
|
|
@@ -1311,6 +1392,8 @@ function shouldUsePolling(rootPath) {
|
|
|
1311
1392
|
function useWatcher(rootPath, onTreeChange, options = {}) {
|
|
1312
1393
|
const debounceMs = options.debounceMs ?? DEFAULT_CONFIG.debounceDelay;
|
|
1313
1394
|
const [isWatching, setIsWatching] = useState4(false);
|
|
1395
|
+
const [watchMode, setWatchMode] = useState4(options.noWatch ? "off" : "native");
|
|
1396
|
+
const [watchError, setWatchError] = useState4(false);
|
|
1314
1397
|
const watcherRef = useRef3(null);
|
|
1315
1398
|
const onTreeChangeRef = useRef3(onTreeChange);
|
|
1316
1399
|
const mountedRef = useRef3(true);
|
|
@@ -1325,11 +1408,15 @@ function useWatcher(rootPath, onTreeChange, options = {}) {
|
|
|
1325
1408
|
mountedRef.current = true;
|
|
1326
1409
|
if (options.noWatch || !rootPath) {
|
|
1327
1410
|
setIsWatching(false);
|
|
1411
|
+
setWatchMode("off");
|
|
1412
|
+
setWatchError(false);
|
|
1328
1413
|
return () => {
|
|
1329
1414
|
mountedRef.current = false;
|
|
1330
1415
|
};
|
|
1331
1416
|
}
|
|
1332
1417
|
const usePolling = shouldUsePolling(rootPath);
|
|
1418
|
+
setWatchMode(usePolling ? "polling" : "native");
|
|
1419
|
+
setWatchError(false);
|
|
1333
1420
|
const watcher = chokidar.watch(rootPath, {
|
|
1334
1421
|
ignored: ignoredPathRef.current,
|
|
1335
1422
|
persistent: true,
|
|
@@ -1372,9 +1459,11 @@ function useWatcher(rootPath, onTreeChange, options = {}) {
|
|
|
1372
1459
|
debounceTimerRef.current = setTimeout(flushPendingChange, debounceMs);
|
|
1373
1460
|
});
|
|
1374
1461
|
watcher.on("error", () => {
|
|
1462
|
+
setWatchError(true);
|
|
1375
1463
|
setIsWatching(false);
|
|
1376
1464
|
});
|
|
1377
1465
|
watcher.on("ready", () => {
|
|
1466
|
+
setWatchError(false);
|
|
1378
1467
|
setIsWatching(true);
|
|
1379
1468
|
});
|
|
1380
1469
|
return () => {
|
|
@@ -1392,7 +1481,7 @@ function useWatcher(rootPath, onTreeChange, options = {}) {
|
|
|
1392
1481
|
setIsWatching(false);
|
|
1393
1482
|
};
|
|
1394
1483
|
}, [rootPath, options.noWatch, debounceMs]);
|
|
1395
|
-
return { isWatching };
|
|
1484
|
+
return { isWatching, watchMode, watchError };
|
|
1396
1485
|
}
|
|
1397
1486
|
|
|
1398
1487
|
// src/hooks/useChangedFiles.js
|
|
@@ -1500,17 +1589,23 @@ function App({ rootPath, options = {}, version }) {
|
|
|
1500
1589
|
const theme = getTheme(resolveThemeName({ cliTheme: options.theme }));
|
|
1501
1590
|
const [selectedPath, setSelectedPath] = useState6(null);
|
|
1502
1591
|
const [recentEventMessage, setRecentEventMessage] = useState6("");
|
|
1592
|
+
const [recentEventType, setRecentEventType] = useState6("");
|
|
1593
|
+
const [showHelp, setShowHelp] = useState6(options.help !== false);
|
|
1503
1594
|
useEffect5(() => setupCleanExit(), []);
|
|
1504
1595
|
const { tree, flatList, refreshFlatList, rebuildTree, ensureChildrenLoaded } = useTree(rootPath, options);
|
|
1505
1596
|
const { statusMap, pathStatusMap, isGitRepo, refresh: refreshGit } = useGitStatus(
|
|
1506
1597
|
rootPath,
|
|
1507
1598
|
!options.noGit
|
|
1508
1599
|
);
|
|
1600
|
+
const toggleHelp = useCallback5(() => {
|
|
1601
|
+
setShowHelp((prev) => !prev);
|
|
1602
|
+
}, []);
|
|
1509
1603
|
const { cursor, viewportStart, setCursor } = useNavigation(
|
|
1510
1604
|
flatList,
|
|
1511
1605
|
rebuildTree,
|
|
1512
1606
|
refreshFlatList,
|
|
1513
|
-
ensureChildrenLoaded
|
|
1607
|
+
ensureChildrenLoaded,
|
|
1608
|
+
toggleHelp
|
|
1514
1609
|
);
|
|
1515
1610
|
const cursorRef = useRef5(cursor);
|
|
1516
1611
|
cursorRef.current = cursor;
|
|
@@ -1576,6 +1671,7 @@ function App({ rootPath, options = {}, version }) {
|
|
|
1576
1671
|
const batch = Array.isArray(eventsOrEvent) ? eventsOrEvent : [{ event: eventsOrEvent, path: watchPath }];
|
|
1577
1672
|
let hasStructuralChange = false;
|
|
1578
1673
|
let latestEventMessage = "";
|
|
1674
|
+
let latestEventType = "";
|
|
1579
1675
|
for (const item of batch) {
|
|
1580
1676
|
if (!item) continue;
|
|
1581
1677
|
if (item.event === "add" || item.event === "addDir" || item.event === "unlink" || item.event === "unlinkDir") {
|
|
@@ -1586,6 +1682,7 @@ function App({ rootPath, options = {}, version }) {
|
|
|
1586
1682
|
const eventMessage = formatWatcherEventMessage(item.event, relativePath);
|
|
1587
1683
|
if (eventMessage) {
|
|
1588
1684
|
latestEventMessage = eventMessage;
|
|
1685
|
+
latestEventType = item.event;
|
|
1589
1686
|
}
|
|
1590
1687
|
if (changeStatus && relativePath) {
|
|
1591
1688
|
markChanged(relativePath, changeStatus);
|
|
@@ -1593,6 +1690,7 @@ function App({ rootPath, options = {}, version }) {
|
|
|
1593
1690
|
}
|
|
1594
1691
|
if (latestEventMessage) {
|
|
1595
1692
|
setRecentEventMessage(latestEventMessage);
|
|
1693
|
+
setRecentEventType(latestEventType);
|
|
1596
1694
|
}
|
|
1597
1695
|
if (hasStructuralChange) {
|
|
1598
1696
|
rebuildTree(true);
|
|
@@ -1605,11 +1703,12 @@ function App({ rootPath, options = {}, version }) {
|
|
|
1605
1703
|
}
|
|
1606
1704
|
}
|
|
1607
1705
|
}, [rebuildTree, isGitRepo, refreshGit, markChanged, toTreeRelativePath]);
|
|
1608
|
-
const { isWatching } = useWatcher(rootPath, handleTreeChange, {
|
|
1706
|
+
const { isWatching, watchMode, watchError } = useWatcher(rootPath, handleTreeChange, {
|
|
1609
1707
|
noWatch: options.noWatch
|
|
1610
1708
|
});
|
|
1611
|
-
const
|
|
1612
|
-
const
|
|
1709
|
+
const selectedNode = cursor >= 0 && cursor < flatList.length ? flatList[cursor] : null;
|
|
1710
|
+
const selectedContextPath = selectedNode?.path || selectedPath || "";
|
|
1711
|
+
const statusBarHeight = 1 + (showHelp ? 1 : 0) + (selectedContextPath ? 1 : 0) + (recentEventMessage ? 1 : 0);
|
|
1613
1712
|
const viewportHeight = termSize.height - statusBarHeight;
|
|
1614
1713
|
const termWidth = termSize.width;
|
|
1615
1714
|
const { fileCount, dirCount } = calculateCounts(flatList);
|
|
@@ -1635,11 +1734,18 @@ function App({ rootPath, options = {}, version }) {
|
|
|
1635
1734
|
fileCount,
|
|
1636
1735
|
dirCount,
|
|
1637
1736
|
isWatching,
|
|
1737
|
+
watchMode,
|
|
1738
|
+
watchError,
|
|
1638
1739
|
gitEnabled: !options.noGit,
|
|
1639
1740
|
isGitRepo,
|
|
1640
1741
|
gitSummary,
|
|
1641
|
-
showHelp
|
|
1742
|
+
showHelp,
|
|
1743
|
+
selectedPath: selectedContextPath,
|
|
1744
|
+
showSize: Boolean(options.showSize),
|
|
1745
|
+
selectedIsDir: Boolean(selectedNode?.isDir),
|
|
1746
|
+
selectedSize: selectedNode?.size,
|
|
1642
1747
|
eventMessage: recentEventMessage,
|
|
1748
|
+
eventType: recentEventType,
|
|
1643
1749
|
termWidth,
|
|
1644
1750
|
iconSet,
|
|
1645
1751
|
theme
|
|
@@ -1668,6 +1774,7 @@ OPTIONS:
|
|
|
1668
1774
|
--no-git Disable git integration
|
|
1669
1775
|
--no-icons Use ASCII icons (disable emoji/nerd icons)
|
|
1670
1776
|
--no-watch Disable file watching
|
|
1777
|
+
--show-size Show selected file size in the status bar (lightweight, non-recursive)
|
|
1671
1778
|
--icon-set <emoji|nerd|ascii> Icon set (default: emoji). You can also set FTREE_ICON_SET.
|
|
1672
1779
|
--theme <vscode> UI theme (default: vscode). You can also set FTREE_THEME.
|
|
1673
1780
|
|
|
@@ -1725,6 +1832,7 @@ function parseArgs() {
|
|
|
1725
1832
|
noIcons: false,
|
|
1726
1833
|
noGit: false,
|
|
1727
1834
|
noWatch: false,
|
|
1835
|
+
showSize: false,
|
|
1728
1836
|
help: true,
|
|
1729
1837
|
// show help line by default
|
|
1730
1838
|
iconSet: void 0,
|
|
@@ -1753,6 +1861,10 @@ function parseArgs() {
|
|
|
1753
1861
|
result.options.noWatch = true;
|
|
1754
1862
|
continue;
|
|
1755
1863
|
}
|
|
1864
|
+
if (arg === "--show-size") {
|
|
1865
|
+
result.options.showSize = true;
|
|
1866
|
+
continue;
|
|
1867
|
+
}
|
|
1756
1868
|
if (arg === "--no-help") {
|
|
1757
1869
|
result.options.help = false;
|
|
1758
1870
|
continue;
|
package/package.json
CHANGED
package/src/App.jsx
CHANGED
|
@@ -77,6 +77,8 @@ export default function App({ rootPath, options = {}, version }) {
|
|
|
77
77
|
// Track selected path to preserve cursor position across rebuilds
|
|
78
78
|
const [selectedPath, setSelectedPath] = useState(null);
|
|
79
79
|
const [recentEventMessage, setRecentEventMessage] = useState('');
|
|
80
|
+
const [recentEventType, setRecentEventType] = useState('');
|
|
81
|
+
const [showHelp, setShowHelp] = useState(options.help !== false);
|
|
80
82
|
|
|
81
83
|
// Setup clean exit handler - ensures proper terminal state on exit
|
|
82
84
|
useEffect(() => setupCleanExit(), []);
|
|
@@ -90,12 +92,17 @@ export default function App({ rootPath, options = {}, version }) {
|
|
|
90
92
|
!options.noGit
|
|
91
93
|
);
|
|
92
94
|
|
|
95
|
+
const toggleHelp = useCallback(() => {
|
|
96
|
+
setShowHelp((prev) => !prev);
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
93
99
|
// Get navigation state (cursor, viewport)
|
|
94
100
|
const { cursor, viewportStart, setCursor } = useNavigation(
|
|
95
101
|
flatList,
|
|
96
102
|
rebuildTree,
|
|
97
103
|
refreshFlatList,
|
|
98
|
-
ensureChildrenLoaded
|
|
104
|
+
ensureChildrenLoaded,
|
|
105
|
+
toggleHelp
|
|
99
106
|
);
|
|
100
107
|
const cursorRef = useRef(cursor);
|
|
101
108
|
cursorRef.current = cursor;
|
|
@@ -193,6 +200,7 @@ export default function App({ rootPath, options = {}, version }) {
|
|
|
193
200
|
|
|
194
201
|
let hasStructuralChange = false;
|
|
195
202
|
let latestEventMessage = '';
|
|
203
|
+
let latestEventType = '';
|
|
196
204
|
|
|
197
205
|
for (const item of batch) {
|
|
198
206
|
if (!item) continue;
|
|
@@ -204,6 +212,7 @@ export default function App({ rootPath, options = {}, version }) {
|
|
|
204
212
|
const eventMessage = formatWatcherEventMessage(item.event, relativePath);
|
|
205
213
|
if (eventMessage) {
|
|
206
214
|
latestEventMessage = eventMessage;
|
|
215
|
+
latestEventType = item.event;
|
|
207
216
|
}
|
|
208
217
|
|
|
209
218
|
if (changeStatus && relativePath) {
|
|
@@ -213,6 +222,7 @@ export default function App({ rootPath, options = {}, version }) {
|
|
|
213
222
|
|
|
214
223
|
if (latestEventMessage) {
|
|
215
224
|
setRecentEventMessage(latestEventMessage);
|
|
225
|
+
setRecentEventType(latestEventType);
|
|
216
226
|
}
|
|
217
227
|
|
|
218
228
|
if (hasStructuralChange) {
|
|
@@ -229,14 +239,15 @@ export default function App({ rootPath, options = {}, version }) {
|
|
|
229
239
|
}
|
|
230
240
|
}, [rebuildTree, isGitRepo, refreshGit, markChanged, toTreeRelativePath]);
|
|
231
241
|
|
|
232
|
-
const { isWatching } = useWatcher(rootPath, handleTreeChange, {
|
|
242
|
+
const { isWatching, watchMode, watchError } = useWatcher(rootPath, handleTreeChange, {
|
|
233
243
|
noWatch: options.noWatch,
|
|
234
244
|
});
|
|
235
245
|
|
|
236
246
|
// Calculate viewport dimensions
|
|
237
|
-
// Reserve space: 1 main status line + optional hints line + optional event line.
|
|
238
|
-
const
|
|
239
|
-
const
|
|
247
|
+
// Reserve space: 1 main status line + optional hints line + optional selection line + optional event line.
|
|
248
|
+
const selectedNode = (cursor >= 0 && cursor < flatList.length) ? flatList[cursor] : null;
|
|
249
|
+
const selectedContextPath = selectedNode?.path || selectedPath || '';
|
|
250
|
+
const statusBarHeight = 1 + (showHelp ? 1 : 0) + (selectedContextPath ? 1 : 0) + (recentEventMessage ? 1 : 0);
|
|
240
251
|
const viewportHeight = termSize.height - statusBarHeight;
|
|
241
252
|
const termWidth = termSize.width;
|
|
242
253
|
|
|
@@ -268,11 +279,18 @@ export default function App({ rootPath, options = {}, version }) {
|
|
|
268
279
|
fileCount={fileCount}
|
|
269
280
|
dirCount={dirCount}
|
|
270
281
|
isWatching={isWatching}
|
|
282
|
+
watchMode={watchMode}
|
|
283
|
+
watchError={watchError}
|
|
271
284
|
gitEnabled={!options.noGit}
|
|
272
285
|
isGitRepo={isGitRepo}
|
|
273
286
|
gitSummary={gitSummary}
|
|
274
|
-
showHelp={
|
|
287
|
+
showHelp={showHelp}
|
|
288
|
+
selectedPath={selectedContextPath}
|
|
289
|
+
showSize={Boolean(options.showSize)}
|
|
290
|
+
selectedIsDir={Boolean(selectedNode?.isDir)}
|
|
291
|
+
selectedSize={selectedNode?.size}
|
|
275
292
|
eventMessage={recentEventMessage}
|
|
293
|
+
eventType={recentEventType}
|
|
276
294
|
termWidth={termWidth}
|
|
277
295
|
iconSet={iconSet}
|
|
278
296
|
theme={theme}
|
package/src/cli.js
CHANGED
|
@@ -39,6 +39,7 @@ OPTIONS:
|
|
|
39
39
|
--no-git Disable git integration
|
|
40
40
|
--no-icons Use ASCII icons (disable emoji/nerd icons)
|
|
41
41
|
--no-watch Disable file watching
|
|
42
|
+
--show-size Show selected file size in the status bar (lightweight, non-recursive)
|
|
42
43
|
--icon-set <emoji|nerd|ascii> Icon set (default: emoji). You can also set FTREE_ICON_SET.
|
|
43
44
|
--theme <vscode> UI theme (default: vscode). You can also set FTREE_THEME.
|
|
44
45
|
|
|
@@ -118,6 +119,7 @@ function parseArgs() {
|
|
|
118
119
|
noIcons: false,
|
|
119
120
|
noGit: false,
|
|
120
121
|
noWatch: false,
|
|
122
|
+
showSize: false,
|
|
121
123
|
help: true, // show help line by default
|
|
122
124
|
iconSet: undefined,
|
|
123
125
|
theme: undefined,
|
|
@@ -148,6 +150,10 @@ function parseArgs() {
|
|
|
148
150
|
result.options.noWatch = true;
|
|
149
151
|
continue;
|
|
150
152
|
}
|
|
153
|
+
if (arg === '--show-size') {
|
|
154
|
+
result.options.showSize = true;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
151
157
|
if (arg === '--no-help') {
|
|
152
158
|
result.options.help = false;
|
|
153
159
|
continue;
|
|
@@ -77,6 +77,53 @@ function formatPath(path, maxWidth) {
|
|
|
77
77
|
return truncatePath(ellipsisPath, maxWidth);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
function getEventMessageColor(eventType) {
|
|
81
|
+
if (eventType === 'add' || eventType === 'addDir') {
|
|
82
|
+
return 'green';
|
|
83
|
+
}
|
|
84
|
+
if (eventType === 'change') {
|
|
85
|
+
return 'yellow';
|
|
86
|
+
}
|
|
87
|
+
if (eventType === 'unlink' || eventType === 'unlinkDir') {
|
|
88
|
+
return 'magenta';
|
|
89
|
+
}
|
|
90
|
+
return 'magenta';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatBytes(bytes) {
|
|
94
|
+
if (!Number.isFinite(bytes) || bytes < 0) {
|
|
95
|
+
return '';
|
|
96
|
+
}
|
|
97
|
+
if (bytes < 1024) {
|
|
98
|
+
return `${bytes}B`;
|
|
99
|
+
}
|
|
100
|
+
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
101
|
+
let value = bytes / 1024;
|
|
102
|
+
let unitIndex = 0;
|
|
103
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
104
|
+
value /= 1024;
|
|
105
|
+
unitIndex++;
|
|
106
|
+
}
|
|
107
|
+
const decimals = value >= 10 ? 0 : 1;
|
|
108
|
+
return `${value.toFixed(decimals)}${units[unitIndex]}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getWatchDisplay({ isWatching, watchMode, watchError, watchIcon }) {
|
|
112
|
+
if (watchMode === 'off') {
|
|
113
|
+
return { text: `${watchIcon} Static`, tone: 'muted' };
|
|
114
|
+
}
|
|
115
|
+
if (watchError) {
|
|
116
|
+
return { text: `${watchIcon} Paused`, tone: 'warning' };
|
|
117
|
+
}
|
|
118
|
+
if (!isWatching) {
|
|
119
|
+
return { text: `${watchIcon} Starting`, tone: 'muted' };
|
|
120
|
+
}
|
|
121
|
+
if (watchMode === 'polling') {
|
|
122
|
+
return { text: `${watchIcon} Watching (poll)`, tone: 'warning' };
|
|
123
|
+
}
|
|
124
|
+
return { text: `${watchIcon} Watching`, tone: 'success' };
|
|
125
|
+
}
|
|
126
|
+
|
|
80
127
|
/**
|
|
81
128
|
* Calculate git status summary from statusMap
|
|
82
129
|
* Returns a summary string like "clean" or "3M 1A"
|
|
@@ -116,11 +163,18 @@ export function calculateGitStatus(statusMap) {
|
|
|
116
163
|
* @property {number} fileCount - Total number of files
|
|
117
164
|
* @property {number} dirCount - Total number of directories
|
|
118
165
|
* @property {boolean} isWatching - File watcher active status
|
|
166
|
+
* @property {'native'|'polling'|'off'} watchMode - File watcher mode
|
|
167
|
+
* @property {boolean} watchError - Watcher error state
|
|
119
168
|
* @property {boolean} gitEnabled - Git integration enabled
|
|
120
169
|
* @property {boolean} isGitRepo - Whether current directory is a git repo
|
|
121
170
|
* @property {string} gitSummary - Git status summary (e.g., "clean" or "3M 1A")
|
|
122
171
|
* @property {boolean} showHelp - Show keybind hints
|
|
172
|
+
* @property {string} selectedPath - Currently selected tree path
|
|
173
|
+
* @property {boolean} showSize - Show selected file size in status bar
|
|
174
|
+
* @property {boolean} selectedIsDir - Whether selected node is a directory
|
|
175
|
+
* @property {number|undefined} selectedSize - Selected node size in bytes
|
|
123
176
|
* @property {string} eventMessage - Latest filesystem event message
|
|
177
|
+
* @property {string} eventType - Latest filesystem event type
|
|
124
178
|
* @property {number} termWidth - Terminal width in columns
|
|
125
179
|
*/
|
|
126
180
|
export function StatusBar({
|
|
@@ -129,11 +183,18 @@ export function StatusBar({
|
|
|
129
183
|
fileCount = 0,
|
|
130
184
|
dirCount = 0,
|
|
131
185
|
isWatching = false,
|
|
186
|
+
watchMode = 'native',
|
|
187
|
+
watchError = false,
|
|
132
188
|
gitEnabled = false,
|
|
133
189
|
isGitRepo = false,
|
|
134
190
|
gitSummary = '',
|
|
135
191
|
showHelp = true,
|
|
192
|
+
selectedPath = '',
|
|
193
|
+
showSize = false,
|
|
194
|
+
selectedIsDir = false,
|
|
195
|
+
selectedSize = undefined,
|
|
136
196
|
eventMessage = '',
|
|
197
|
+
eventType = '',
|
|
137
198
|
termWidth = 80,
|
|
138
199
|
iconSet = 'emoji',
|
|
139
200
|
theme = VSCODE_THEME,
|
|
@@ -171,10 +232,26 @@ export function StatusBar({
|
|
|
171
232
|
|
|
172
233
|
const versionText = `ftreeview ${version}`;
|
|
173
234
|
const countsText = `${barIcons.file} ${fileCount} ${barIcons.folder} ${dirCount}`;
|
|
174
|
-
const
|
|
235
|
+
const watchDisplay = getWatchDisplay({
|
|
236
|
+
isWatching,
|
|
237
|
+
watchMode,
|
|
238
|
+
watchError,
|
|
239
|
+
watchIcon: barIcons.watch,
|
|
240
|
+
});
|
|
241
|
+
const watchText = watchDisplay.text;
|
|
242
|
+
const watchColor = watchDisplay.tone === 'success'
|
|
243
|
+
? finalTheme.colors.success
|
|
244
|
+
: watchDisplay.tone === 'warning'
|
|
245
|
+
? finalTheme.colors.warning
|
|
246
|
+
: finalTheme.colors.muted;
|
|
175
247
|
const gitText = gitEnabled && isGitRepo ? `${barIcons.git} ${gitSummary || 'clean'}` : '';
|
|
248
|
+
const sizeValueText = !showSize ? '' : (selectedIsDir ? '--' : formatBytes(selectedSize));
|
|
249
|
+
const showSizeText = Boolean(showSize && sizeValueText);
|
|
250
|
+
const sizeText = showSizeText ? `size ${sizeValueText}` : '';
|
|
176
251
|
|
|
177
|
-
const fixedSegments = [versionText, countsText, watchText]
|
|
252
|
+
const fixedSegments = [versionText, countsText, watchText]
|
|
253
|
+
.concat(showSizeText ? [sizeText] : [])
|
|
254
|
+
.concat(gitText ? [gitText] : []);
|
|
178
255
|
const fixedWidth = fixedSegments.reduce((sum, part) => sum + part.length, 0)
|
|
179
256
|
+ (fixedSegments.length /* separators count incl. path */) * sep.length
|
|
180
257
|
+ paddingWidth;
|
|
@@ -216,10 +293,21 @@ export function StatusBar({
|
|
|
216
293
|
<Text {...finalTheme.statusBar.separatorStyle}>
|
|
217
294
|
{sep}
|
|
218
295
|
</Text>
|
|
219
|
-
<Text color={
|
|
296
|
+
<Text color={watchColor}>
|
|
220
297
|
{watchText}
|
|
221
298
|
</Text>
|
|
222
299
|
|
|
300
|
+
{showSizeText && (
|
|
301
|
+
<>
|
|
302
|
+
<Text {...finalTheme.statusBar.separatorStyle}>
|
|
303
|
+
{sep}
|
|
304
|
+
</Text>
|
|
305
|
+
<Text color={finalTheme.colors.muted}>
|
|
306
|
+
{sizeText}
|
|
307
|
+
</Text>
|
|
308
|
+
</>
|
|
309
|
+
)}
|
|
310
|
+
|
|
223
311
|
{gitEnabled && isGitRepo && (
|
|
224
312
|
<>
|
|
225
313
|
<Text {...finalTheme.statusBar.separatorStyle}>
|
|
@@ -238,7 +326,21 @@ export function StatusBar({
|
|
|
238
326
|
paddingX={1}
|
|
239
327
|
>
|
|
240
328
|
<Text {...finalTheme.statusBar.hintStyle}>
|
|
241
|
-
q quit ↑↓/jk move ←→/hl expand space toggle r refresh
|
|
329
|
+
q quit ↑↓/jk move ←→/hl expand space toggle r refresh ? help
|
|
330
|
+
</Text>
|
|
331
|
+
</Box>
|
|
332
|
+
)}
|
|
333
|
+
|
|
334
|
+
{selectedPath && (
|
|
335
|
+
<Box
|
|
336
|
+
width={termWidth}
|
|
337
|
+
paddingX={1}
|
|
338
|
+
>
|
|
339
|
+
<Text color={finalTheme.colors.muted}>
|
|
340
|
+
selected{' '}
|
|
341
|
+
</Text>
|
|
342
|
+
<Text color={finalTheme.colors.fg}>
|
|
343
|
+
{formatPath(selectedPath, Math.max(8, termWidth - 11))}
|
|
242
344
|
</Text>
|
|
243
345
|
</Box>
|
|
244
346
|
)}
|
|
@@ -248,7 +350,7 @@ export function StatusBar({
|
|
|
248
350
|
width={termWidth}
|
|
249
351
|
paddingX={1}
|
|
250
352
|
>
|
|
251
|
-
<Text color=
|
|
353
|
+
<Text color={getEventMessageColor(eventType)} bold>
|
|
252
354
|
{eventMessage}
|
|
253
355
|
</Text>
|
|
254
356
|
</Box>
|
|
@@ -20,9 +20,16 @@ const VIEWPORT_MARGIN = 3; // Keep cursor 3 lines from viewport edges
|
|
|
20
20
|
* @param {Function} rebuildTree - Callback to rebuild tree with modified nodes
|
|
21
21
|
* @param {Function|null} refreshFlatList - Callback to refresh visible list without full rebuild
|
|
22
22
|
* @param {Function|null} ensureChildrenLoaded - Callback to lazy-load directory children when expanding
|
|
23
|
+
* @param {Function|null} onToggleHelp - Callback to toggle help hints
|
|
23
24
|
* @returns {{cursor: number, viewportStart: number, exit: Function, setCursorByPath: Function, setCursor: Function}} Navigation state
|
|
24
25
|
*/
|
|
25
|
-
export function useNavigation(
|
|
26
|
+
export function useNavigation(
|
|
27
|
+
flatList = [],
|
|
28
|
+
rebuildTree,
|
|
29
|
+
refreshFlatList = null,
|
|
30
|
+
ensureChildrenLoaded = null,
|
|
31
|
+
onToggleHelp = null
|
|
32
|
+
) {
|
|
26
33
|
const { exit } = useApp();
|
|
27
34
|
|
|
28
35
|
// Calculate viewport height (terminal height minus header and status bar)
|
|
@@ -202,6 +209,8 @@ export function useNavigation(flatList = [], rebuildTree, refreshFlatList = null
|
|
|
202
209
|
jumpToBottom();
|
|
203
210
|
} else if (input === 'r') {
|
|
204
211
|
refresh();
|
|
212
|
+
} else if (input === '?' && typeof onToggleHelp === 'function') {
|
|
213
|
+
onToggleHelp();
|
|
205
214
|
}
|
|
206
215
|
});
|
|
207
216
|
|
package/src/hooks/useWatcher.js
CHANGED
|
@@ -31,6 +31,8 @@ function shouldUsePolling(rootPath) {
|
|
|
31
31
|
export function useWatcher(rootPath, onTreeChange, options = {}) {
|
|
32
32
|
const debounceMs = options.debounceMs ?? DEFAULT_CONFIG.debounceDelay;
|
|
33
33
|
const [isWatching, setIsWatching] = useState(false);
|
|
34
|
+
const [watchMode, setWatchMode] = useState(options.noWatch ? 'off' : 'native');
|
|
35
|
+
const [watchError, setWatchError] = useState(false);
|
|
34
36
|
const watcherRef = useRef(null);
|
|
35
37
|
const onTreeChangeRef = useRef(onTreeChange);
|
|
36
38
|
const mountedRef = useRef(true);
|
|
@@ -50,12 +52,16 @@ export function useWatcher(rootPath, onTreeChange, options = {}) {
|
|
|
50
52
|
|
|
51
53
|
if (options.noWatch || !rootPath) {
|
|
52
54
|
setIsWatching(false);
|
|
55
|
+
setWatchMode('off');
|
|
56
|
+
setWatchError(false);
|
|
53
57
|
return () => {
|
|
54
58
|
mountedRef.current = false;
|
|
55
59
|
};
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
const usePolling = shouldUsePolling(rootPath);
|
|
63
|
+
setWatchMode(usePolling ? 'polling' : 'native');
|
|
64
|
+
setWatchError(false);
|
|
59
65
|
const watcher = chokidar.watch(rootPath, {
|
|
60
66
|
ignored: ignoredPathRef.current,
|
|
61
67
|
persistent: true,
|
|
@@ -107,10 +113,12 @@ export function useWatcher(rootPath, onTreeChange, options = {}) {
|
|
|
107
113
|
});
|
|
108
114
|
|
|
109
115
|
watcher.on('error', () => {
|
|
116
|
+
setWatchError(true);
|
|
110
117
|
setIsWatching(false);
|
|
111
118
|
});
|
|
112
119
|
|
|
113
120
|
watcher.on('ready', () => {
|
|
121
|
+
setWatchError(false);
|
|
114
122
|
setIsWatching(true);
|
|
115
123
|
});
|
|
116
124
|
|
|
@@ -132,7 +140,7 @@ export function useWatcher(rootPath, onTreeChange, options = {}) {
|
|
|
132
140
|
};
|
|
133
141
|
}, [rootPath, options.noWatch, debounceMs]);
|
|
134
142
|
|
|
135
|
-
return { isWatching };
|
|
143
|
+
return { isWatching, watchMode, watchError };
|
|
136
144
|
}
|
|
137
145
|
|
|
138
146
|
// Only default export to avoid conflicts with bundling
|