ftreeview 0.1.3 → 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 +153 -11
- package/package.json +1 -1
- package/src/App.jsx +47 -6
- package/src/cli.js +6 -0
- package/src/components/StatusBar.jsx +119 -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,10 +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,
|
|
403
|
+
eventMessage = "",
|
|
404
|
+
eventType = "",
|
|
353
405
|
termWidth = 80,
|
|
354
406
|
iconSet = "emoji",
|
|
355
407
|
theme = VSCODE_THEME
|
|
@@ -383,9 +435,19 @@ function StatusBar({
|
|
|
383
435
|
const paddingWidth = 2;
|
|
384
436
|
const versionText = `ftreeview ${version}`;
|
|
385
437
|
const countsText = `${barIcons.file} ${fileCount} ${barIcons.folder} ${dirCount}`;
|
|
386
|
-
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;
|
|
387
446
|
const gitText = gitEnabled && isGitRepo ? `${barIcons.git} ${gitSummary || "clean"}` : "";
|
|
388
|
-
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] : []);
|
|
389
451
|
const fixedWidth = fixedSegments.reduce((sum, part) => sum + part.length, 0) + fixedSegments.length * sep4.length + paddingWidth;
|
|
390
452
|
const pathMaxWidth = Math.max(8, termWidth - fixedWidth);
|
|
391
453
|
const displayPath = formatPath(rootPath, pathMaxWidth);
|
|
@@ -406,7 +468,11 @@ function StatusBar({
|
|
|
406
468
|
/* @__PURE__ */ jsx3(Text3, { ...finalTheme.statusBar.separatorStyle, children: sep4 }),
|
|
407
469
|
/* @__PURE__ */ jsx3(Text3, { color: finalTheme.colors.muted, children: countsText }),
|
|
408
470
|
/* @__PURE__ */ jsx3(Text3, { ...finalTheme.statusBar.separatorStyle, children: sep4 }),
|
|
409
|
-
/* @__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
|
+
] }),
|
|
410
476
|
gitEnabled && isGitRepo && /* @__PURE__ */ jsxs2(Fragment, { children: [
|
|
411
477
|
/* @__PURE__ */ jsx3(Text3, { ...finalTheme.statusBar.separatorStyle, children: sep4 }),
|
|
412
478
|
/* @__PURE__ */ jsx3(Text3, { color: gitSummary === "clean" ? finalTheme.colors.success : finalTheme.colors.warning, children: gitText })
|
|
@@ -419,7 +485,29 @@ function StatusBar({
|
|
|
419
485
|
{
|
|
420
486
|
width: termWidth,
|
|
421
487
|
paddingX: 1,
|
|
422
|
-
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
|
+
]
|
|
503
|
+
}
|
|
504
|
+
),
|
|
505
|
+
eventMessage && /* @__PURE__ */ jsx3(
|
|
506
|
+
Box3,
|
|
507
|
+
{
|
|
508
|
+
width: termWidth,
|
|
509
|
+
paddingX: 1,
|
|
510
|
+
children: /* @__PURE__ */ jsx3(Text3, { color: getEventMessageColor(eventType), bold: true, children: eventMessage })
|
|
423
511
|
}
|
|
424
512
|
)
|
|
425
513
|
] });
|
|
@@ -921,7 +1009,7 @@ function useTree(rootPath, options = {}) {
|
|
|
921
1009
|
import { useState as useState2, useEffect, useCallback as useCallback2 } from "react";
|
|
922
1010
|
import { useInput, useApp } from "ink";
|
|
923
1011
|
var VIEWPORT_MARGIN = 3;
|
|
924
|
-
function useNavigation(flatList = [], rebuildTree, refreshFlatList = null, ensureChildrenLoaded = null) {
|
|
1012
|
+
function useNavigation(flatList = [], rebuildTree, refreshFlatList = null, ensureChildrenLoaded = null, onToggleHelp = null) {
|
|
925
1013
|
const { exit } = useApp();
|
|
926
1014
|
const viewportHeight = process.stdout.rows - 2;
|
|
927
1015
|
const [cursor, setCursor] = useState2(0);
|
|
@@ -1025,6 +1113,8 @@ function useNavigation(flatList = [], rebuildTree, refreshFlatList = null, ensur
|
|
|
1025
1113
|
jumpToBottom();
|
|
1026
1114
|
} else if (input === "r") {
|
|
1027
1115
|
refresh();
|
|
1116
|
+
} else if (input === "?" && typeof onToggleHelp === "function") {
|
|
1117
|
+
onToggleHelp();
|
|
1028
1118
|
}
|
|
1029
1119
|
});
|
|
1030
1120
|
useEffect(() => {
|
|
@@ -1302,6 +1392,8 @@ function shouldUsePolling(rootPath) {
|
|
|
1302
1392
|
function useWatcher(rootPath, onTreeChange, options = {}) {
|
|
1303
1393
|
const debounceMs = options.debounceMs ?? DEFAULT_CONFIG.debounceDelay;
|
|
1304
1394
|
const [isWatching, setIsWatching] = useState4(false);
|
|
1395
|
+
const [watchMode, setWatchMode] = useState4(options.noWatch ? "off" : "native");
|
|
1396
|
+
const [watchError, setWatchError] = useState4(false);
|
|
1305
1397
|
const watcherRef = useRef3(null);
|
|
1306
1398
|
const onTreeChangeRef = useRef3(onTreeChange);
|
|
1307
1399
|
const mountedRef = useRef3(true);
|
|
@@ -1316,11 +1408,15 @@ function useWatcher(rootPath, onTreeChange, options = {}) {
|
|
|
1316
1408
|
mountedRef.current = true;
|
|
1317
1409
|
if (options.noWatch || !rootPath) {
|
|
1318
1410
|
setIsWatching(false);
|
|
1411
|
+
setWatchMode("off");
|
|
1412
|
+
setWatchError(false);
|
|
1319
1413
|
return () => {
|
|
1320
1414
|
mountedRef.current = false;
|
|
1321
1415
|
};
|
|
1322
1416
|
}
|
|
1323
1417
|
const usePolling = shouldUsePolling(rootPath);
|
|
1418
|
+
setWatchMode(usePolling ? "polling" : "native");
|
|
1419
|
+
setWatchError(false);
|
|
1324
1420
|
const watcher = chokidar.watch(rootPath, {
|
|
1325
1421
|
ignored: ignoredPathRef.current,
|
|
1326
1422
|
persistent: true,
|
|
@@ -1363,9 +1459,11 @@ function useWatcher(rootPath, onTreeChange, options = {}) {
|
|
|
1363
1459
|
debounceTimerRef.current = setTimeout(flushPendingChange, debounceMs);
|
|
1364
1460
|
});
|
|
1365
1461
|
watcher.on("error", () => {
|
|
1462
|
+
setWatchError(true);
|
|
1366
1463
|
setIsWatching(false);
|
|
1367
1464
|
});
|
|
1368
1465
|
watcher.on("ready", () => {
|
|
1466
|
+
setWatchError(false);
|
|
1369
1467
|
setIsWatching(true);
|
|
1370
1468
|
});
|
|
1371
1469
|
return () => {
|
|
@@ -1383,7 +1481,7 @@ function useWatcher(rootPath, onTreeChange, options = {}) {
|
|
|
1383
1481
|
setIsWatching(false);
|
|
1384
1482
|
};
|
|
1385
1483
|
}, [rootPath, options.noWatch, debounceMs]);
|
|
1386
|
-
return { isWatching };
|
|
1484
|
+
return { isWatching, watchMode, watchError };
|
|
1387
1485
|
}
|
|
1388
1486
|
|
|
1389
1487
|
// src/hooks/useChangedFiles.js
|
|
@@ -1474,22 +1572,40 @@ function setupCleanExit() {
|
|
|
1474
1572
|
process.off("SIGINT", onSigint);
|
|
1475
1573
|
};
|
|
1476
1574
|
}
|
|
1575
|
+
function formatWatcherEventMessage(event, relativePath) {
|
|
1576
|
+
if (!relativePath) {
|
|
1577
|
+
return "";
|
|
1578
|
+
}
|
|
1579
|
+
if (event === "add") return `=> File created at ${relativePath}`;
|
|
1580
|
+
if (event === "change") return `=> File modified at ${relativePath}`;
|
|
1581
|
+
if (event === "addDir") return `=> Folder created at ${relativePath}`;
|
|
1582
|
+
if (event === "unlink") return `=> File deleted at ${relativePath}`;
|
|
1583
|
+
if (event === "unlinkDir") return `=> Folder deleted at ${relativePath}`;
|
|
1584
|
+
return "";
|
|
1585
|
+
}
|
|
1477
1586
|
function App({ rootPath, options = {}, version }) {
|
|
1478
1587
|
const rootAbsPath = resolve2(rootPath);
|
|
1479
1588
|
const iconSet = resolveIconSet({ cliIconSet: options.iconSet, noIcons: options.noIcons });
|
|
1480
1589
|
const theme = getTheme(resolveThemeName({ cliTheme: options.theme }));
|
|
1481
1590
|
const [selectedPath, setSelectedPath] = useState6(null);
|
|
1591
|
+
const [recentEventMessage, setRecentEventMessage] = useState6("");
|
|
1592
|
+
const [recentEventType, setRecentEventType] = useState6("");
|
|
1593
|
+
const [showHelp, setShowHelp] = useState6(options.help !== false);
|
|
1482
1594
|
useEffect5(() => setupCleanExit(), []);
|
|
1483
1595
|
const { tree, flatList, refreshFlatList, rebuildTree, ensureChildrenLoaded } = useTree(rootPath, options);
|
|
1484
1596
|
const { statusMap, pathStatusMap, isGitRepo, refresh: refreshGit } = useGitStatus(
|
|
1485
1597
|
rootPath,
|
|
1486
1598
|
!options.noGit
|
|
1487
1599
|
);
|
|
1600
|
+
const toggleHelp = useCallback5(() => {
|
|
1601
|
+
setShowHelp((prev) => !prev);
|
|
1602
|
+
}, []);
|
|
1488
1603
|
const { cursor, viewportStart, setCursor } = useNavigation(
|
|
1489
1604
|
flatList,
|
|
1490
1605
|
rebuildTree,
|
|
1491
1606
|
refreshFlatList,
|
|
1492
|
-
ensureChildrenLoaded
|
|
1607
|
+
ensureChildrenLoaded,
|
|
1608
|
+
toggleHelp
|
|
1493
1609
|
);
|
|
1494
1610
|
const cursorRef = useRef5(cursor);
|
|
1495
1611
|
cursorRef.current = cursor;
|
|
@@ -1554,6 +1670,8 @@ function App({ rootPath, options = {}, version }) {
|
|
|
1554
1670
|
const handleTreeChange = useCallback5((eventsOrEvent, watchPath) => {
|
|
1555
1671
|
const batch = Array.isArray(eventsOrEvent) ? eventsOrEvent : [{ event: eventsOrEvent, path: watchPath }];
|
|
1556
1672
|
let hasStructuralChange = false;
|
|
1673
|
+
let latestEventMessage = "";
|
|
1674
|
+
let latestEventType = "";
|
|
1557
1675
|
for (const item of batch) {
|
|
1558
1676
|
if (!item) continue;
|
|
1559
1677
|
if (item.event === "add" || item.event === "addDir" || item.event === "unlink" || item.event === "unlinkDir") {
|
|
@@ -1561,10 +1679,19 @@ function App({ rootPath, options = {}, version }) {
|
|
|
1561
1679
|
}
|
|
1562
1680
|
const changeStatus = mapWatcherEventToChangeStatus(item.event);
|
|
1563
1681
|
const relativePath = toTreeRelativePath(item.path);
|
|
1682
|
+
const eventMessage = formatWatcherEventMessage(item.event, relativePath);
|
|
1683
|
+
if (eventMessage) {
|
|
1684
|
+
latestEventMessage = eventMessage;
|
|
1685
|
+
latestEventType = item.event;
|
|
1686
|
+
}
|
|
1564
1687
|
if (changeStatus && relativePath) {
|
|
1565
1688
|
markChanged(relativePath, changeStatus);
|
|
1566
1689
|
}
|
|
1567
1690
|
}
|
|
1691
|
+
if (latestEventMessage) {
|
|
1692
|
+
setRecentEventMessage(latestEventMessage);
|
|
1693
|
+
setRecentEventType(latestEventType);
|
|
1694
|
+
}
|
|
1568
1695
|
if (hasStructuralChange) {
|
|
1569
1696
|
rebuildTree(true);
|
|
1570
1697
|
}
|
|
@@ -1576,11 +1703,12 @@ function App({ rootPath, options = {}, version }) {
|
|
|
1576
1703
|
}
|
|
1577
1704
|
}
|
|
1578
1705
|
}, [rebuildTree, isGitRepo, refreshGit, markChanged, toTreeRelativePath]);
|
|
1579
|
-
const { isWatching } = useWatcher(rootPath, handleTreeChange, {
|
|
1706
|
+
const { isWatching, watchMode, watchError } = useWatcher(rootPath, handleTreeChange, {
|
|
1580
1707
|
noWatch: options.noWatch
|
|
1581
1708
|
});
|
|
1582
|
-
const
|
|
1583
|
-
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);
|
|
1584
1712
|
const viewportHeight = termSize.height - statusBarHeight;
|
|
1585
1713
|
const termWidth = termSize.width;
|
|
1586
1714
|
const { fileCount, dirCount } = calculateCounts(flatList);
|
|
@@ -1606,10 +1734,18 @@ function App({ rootPath, options = {}, version }) {
|
|
|
1606
1734
|
fileCount,
|
|
1607
1735
|
dirCount,
|
|
1608
1736
|
isWatching,
|
|
1737
|
+
watchMode,
|
|
1738
|
+
watchError,
|
|
1609
1739
|
gitEnabled: !options.noGit,
|
|
1610
1740
|
isGitRepo,
|
|
1611
1741
|
gitSummary,
|
|
1612
|
-
showHelp
|
|
1742
|
+
showHelp,
|
|
1743
|
+
selectedPath: selectedContextPath,
|
|
1744
|
+
showSize: Boolean(options.showSize),
|
|
1745
|
+
selectedIsDir: Boolean(selectedNode?.isDir),
|
|
1746
|
+
selectedSize: selectedNode?.size,
|
|
1747
|
+
eventMessage: recentEventMessage,
|
|
1748
|
+
eventType: recentEventType,
|
|
1613
1749
|
termWidth,
|
|
1614
1750
|
iconSet,
|
|
1615
1751
|
theme
|
|
@@ -1638,6 +1774,7 @@ OPTIONS:
|
|
|
1638
1774
|
--no-git Disable git integration
|
|
1639
1775
|
--no-icons Use ASCII icons (disable emoji/nerd icons)
|
|
1640
1776
|
--no-watch Disable file watching
|
|
1777
|
+
--show-size Show selected file size in the status bar (lightweight, non-recursive)
|
|
1641
1778
|
--icon-set <emoji|nerd|ascii> Icon set (default: emoji). You can also set FTREE_ICON_SET.
|
|
1642
1779
|
--theme <vscode> UI theme (default: vscode). You can also set FTREE_THEME.
|
|
1643
1780
|
|
|
@@ -1695,6 +1832,7 @@ function parseArgs() {
|
|
|
1695
1832
|
noIcons: false,
|
|
1696
1833
|
noGit: false,
|
|
1697
1834
|
noWatch: false,
|
|
1835
|
+
showSize: false,
|
|
1698
1836
|
help: true,
|
|
1699
1837
|
// show help line by default
|
|
1700
1838
|
iconSet: void 0,
|
|
@@ -1723,6 +1861,10 @@ function parseArgs() {
|
|
|
1723
1861
|
result.options.noWatch = true;
|
|
1724
1862
|
continue;
|
|
1725
1863
|
}
|
|
1864
|
+
if (arg === "--show-size") {
|
|
1865
|
+
result.options.showSize = true;
|
|
1866
|
+
continue;
|
|
1867
|
+
}
|
|
1726
1868
|
if (arg === "--no-help") {
|
|
1727
1869
|
result.options.help = false;
|
|
1728
1870
|
continue;
|
package/package.json
CHANGED
package/src/App.jsx
CHANGED
|
@@ -52,6 +52,18 @@ function setupCleanExit() {
|
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
function formatWatcherEventMessage(event, relativePath) {
|
|
56
|
+
if (!relativePath) {
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
if (event === 'add') return `=> File created at ${relativePath}`;
|
|
60
|
+
if (event === 'change') return `=> File modified at ${relativePath}`;
|
|
61
|
+
if (event === 'addDir') return `=> Folder created at ${relativePath}`;
|
|
62
|
+
if (event === 'unlink') return `=> File deleted at ${relativePath}`;
|
|
63
|
+
if (event === 'unlinkDir') return `=> Folder deleted at ${relativePath}`;
|
|
64
|
+
return '';
|
|
65
|
+
}
|
|
66
|
+
|
|
55
67
|
/**
|
|
56
68
|
* App Component Props
|
|
57
69
|
* @property {string} rootPath - Root directory path to explore
|
|
@@ -64,6 +76,9 @@ export default function App({ rootPath, options = {}, version }) {
|
|
|
64
76
|
|
|
65
77
|
// Track selected path to preserve cursor position across rebuilds
|
|
66
78
|
const [selectedPath, setSelectedPath] = useState(null);
|
|
79
|
+
const [recentEventMessage, setRecentEventMessage] = useState('');
|
|
80
|
+
const [recentEventType, setRecentEventType] = useState('');
|
|
81
|
+
const [showHelp, setShowHelp] = useState(options.help !== false);
|
|
67
82
|
|
|
68
83
|
// Setup clean exit handler - ensures proper terminal state on exit
|
|
69
84
|
useEffect(() => setupCleanExit(), []);
|
|
@@ -77,12 +92,17 @@ export default function App({ rootPath, options = {}, version }) {
|
|
|
77
92
|
!options.noGit
|
|
78
93
|
);
|
|
79
94
|
|
|
95
|
+
const toggleHelp = useCallback(() => {
|
|
96
|
+
setShowHelp((prev) => !prev);
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
80
99
|
// Get navigation state (cursor, viewport)
|
|
81
100
|
const { cursor, viewportStart, setCursor } = useNavigation(
|
|
82
101
|
flatList,
|
|
83
102
|
rebuildTree,
|
|
84
103
|
refreshFlatList,
|
|
85
|
-
ensureChildrenLoaded
|
|
104
|
+
ensureChildrenLoaded,
|
|
105
|
+
toggleHelp
|
|
86
106
|
);
|
|
87
107
|
const cursorRef = useRef(cursor);
|
|
88
108
|
cursorRef.current = cursor;
|
|
@@ -179,6 +199,8 @@ export default function App({ rootPath, options = {}, version }) {
|
|
|
179
199
|
: [{ event: eventsOrEvent, path: watchPath }];
|
|
180
200
|
|
|
181
201
|
let hasStructuralChange = false;
|
|
202
|
+
let latestEventMessage = '';
|
|
203
|
+
let latestEventType = '';
|
|
182
204
|
|
|
183
205
|
for (const item of batch) {
|
|
184
206
|
if (!item) continue;
|
|
@@ -187,12 +209,22 @@ export default function App({ rootPath, options = {}, version }) {
|
|
|
187
209
|
}
|
|
188
210
|
const changeStatus = mapWatcherEventToChangeStatus(item.event);
|
|
189
211
|
const relativePath = toTreeRelativePath(item.path);
|
|
212
|
+
const eventMessage = formatWatcherEventMessage(item.event, relativePath);
|
|
213
|
+
if (eventMessage) {
|
|
214
|
+
latestEventMessage = eventMessage;
|
|
215
|
+
latestEventType = item.event;
|
|
216
|
+
}
|
|
190
217
|
|
|
191
218
|
if (changeStatus && relativePath) {
|
|
192
219
|
markChanged(relativePath, changeStatus);
|
|
193
220
|
}
|
|
194
221
|
}
|
|
195
222
|
|
|
223
|
+
if (latestEventMessage) {
|
|
224
|
+
setRecentEventMessage(latestEventMessage);
|
|
225
|
+
setRecentEventType(latestEventType);
|
|
226
|
+
}
|
|
227
|
+
|
|
196
228
|
if (hasStructuralChange) {
|
|
197
229
|
rebuildTree(true);
|
|
198
230
|
}
|
|
@@ -207,14 +239,15 @@ export default function App({ rootPath, options = {}, version }) {
|
|
|
207
239
|
}
|
|
208
240
|
}, [rebuildTree, isGitRepo, refreshGit, markChanged, toTreeRelativePath]);
|
|
209
241
|
|
|
210
|
-
const { isWatching } = useWatcher(rootPath, handleTreeChange, {
|
|
242
|
+
const { isWatching, watchMode, watchError } = useWatcher(rootPath, handleTreeChange, {
|
|
211
243
|
noWatch: options.noWatch,
|
|
212
244
|
});
|
|
213
245
|
|
|
214
246
|
// Calculate viewport dimensions
|
|
215
|
-
// Reserve space: 1 line
|
|
216
|
-
const
|
|
217
|
-
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);
|
|
218
251
|
const viewportHeight = termSize.height - statusBarHeight;
|
|
219
252
|
const termWidth = termSize.width;
|
|
220
253
|
|
|
@@ -246,10 +279,18 @@ export default function App({ rootPath, options = {}, version }) {
|
|
|
246
279
|
fileCount={fileCount}
|
|
247
280
|
dirCount={dirCount}
|
|
248
281
|
isWatching={isWatching}
|
|
282
|
+
watchMode={watchMode}
|
|
283
|
+
watchError={watchError}
|
|
249
284
|
gitEnabled={!options.noGit}
|
|
250
285
|
isGitRepo={isGitRepo}
|
|
251
286
|
gitSummary={gitSummary}
|
|
252
|
-
showHelp={
|
|
287
|
+
showHelp={showHelp}
|
|
288
|
+
selectedPath={selectedContextPath}
|
|
289
|
+
showSize={Boolean(options.showSize)}
|
|
290
|
+
selectedIsDir={Boolean(selectedNode?.isDir)}
|
|
291
|
+
selectedSize={selectedNode?.size}
|
|
292
|
+
eventMessage={recentEventMessage}
|
|
293
|
+
eventType={recentEventType}
|
|
253
294
|
termWidth={termWidth}
|
|
254
295
|
iconSet={iconSet}
|
|
255
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,10 +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
|
|
176
|
+
* @property {string} eventMessage - Latest filesystem event message
|
|
177
|
+
* @property {string} eventType - Latest filesystem event type
|
|
123
178
|
* @property {number} termWidth - Terminal width in columns
|
|
124
179
|
*/
|
|
125
180
|
export function StatusBar({
|
|
@@ -128,10 +183,18 @@ export function StatusBar({
|
|
|
128
183
|
fileCount = 0,
|
|
129
184
|
dirCount = 0,
|
|
130
185
|
isWatching = false,
|
|
186
|
+
watchMode = 'native',
|
|
187
|
+
watchError = false,
|
|
131
188
|
gitEnabled = false,
|
|
132
189
|
isGitRepo = false,
|
|
133
190
|
gitSummary = '',
|
|
134
191
|
showHelp = true,
|
|
192
|
+
selectedPath = '',
|
|
193
|
+
showSize = false,
|
|
194
|
+
selectedIsDir = false,
|
|
195
|
+
selectedSize = undefined,
|
|
196
|
+
eventMessage = '',
|
|
197
|
+
eventType = '',
|
|
135
198
|
termWidth = 80,
|
|
136
199
|
iconSet = 'emoji',
|
|
137
200
|
theme = VSCODE_THEME,
|
|
@@ -169,10 +232,26 @@ export function StatusBar({
|
|
|
169
232
|
|
|
170
233
|
const versionText = `ftreeview ${version}`;
|
|
171
234
|
const countsText = `${barIcons.file} ${fileCount} ${barIcons.folder} ${dirCount}`;
|
|
172
|
-
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;
|
|
173
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}` : '';
|
|
174
251
|
|
|
175
|
-
const fixedSegments = [versionText, countsText, watchText]
|
|
252
|
+
const fixedSegments = [versionText, countsText, watchText]
|
|
253
|
+
.concat(showSizeText ? [sizeText] : [])
|
|
254
|
+
.concat(gitText ? [gitText] : []);
|
|
176
255
|
const fixedWidth = fixedSegments.reduce((sum, part) => sum + part.length, 0)
|
|
177
256
|
+ (fixedSegments.length /* separators count incl. path */) * sep.length
|
|
178
257
|
+ paddingWidth;
|
|
@@ -214,10 +293,21 @@ export function StatusBar({
|
|
|
214
293
|
<Text {...finalTheme.statusBar.separatorStyle}>
|
|
215
294
|
{sep}
|
|
216
295
|
</Text>
|
|
217
|
-
<Text color={
|
|
296
|
+
<Text color={watchColor}>
|
|
218
297
|
{watchText}
|
|
219
298
|
</Text>
|
|
220
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
|
+
|
|
221
311
|
{gitEnabled && isGitRepo && (
|
|
222
312
|
<>
|
|
223
313
|
<Text {...finalTheme.statusBar.separatorStyle}>
|
|
@@ -230,14 +320,38 @@ export function StatusBar({
|
|
|
230
320
|
)}
|
|
231
321
|
</Box>
|
|
232
322
|
|
|
233
|
-
{/* Keybind hints line (optional) */}
|
|
234
323
|
{showHelp && (
|
|
235
324
|
<Box
|
|
236
325
|
width={termWidth}
|
|
237
326
|
paddingX={1}
|
|
238
327
|
>
|
|
239
328
|
<Text {...finalTheme.statusBar.hintStyle}>
|
|
240
|
-
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))}
|
|
344
|
+
</Text>
|
|
345
|
+
</Box>
|
|
346
|
+
)}
|
|
347
|
+
|
|
348
|
+
{eventMessage && (
|
|
349
|
+
<Box
|
|
350
|
+
width={termWidth}
|
|
351
|
+
paddingX={1}
|
|
352
|
+
>
|
|
353
|
+
<Text color={getEventMessageColor(eventType)} bold>
|
|
354
|
+
{eventMessage}
|
|
241
355
|
</Text>
|
|
242
356
|
</Box>
|
|
243
357
|
)}
|
|
@@ -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
|