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 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 watchText = `${barIcons.watch} ${isWatching ? "Watching" : "Static"}`;
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 fixedSegments = [versionText, countsText, watchText].concat(gitText ? [gitText] : []);
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: isWatching ? finalTheme.colors.success : finalTheme.colors.muted, children: watchText }),
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 helpEnabled = options.help !== false;
1583
- const statusBarHeight = helpEnabled ? 2 : 1;
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: helpEnabled,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ftreeview",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Terminal file explorer with git awareness and live updates",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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 for status bar (2 if help enabled)
216
- const helpEnabled = options.help !== false; // Show help by default
217
- const statusBarHeight = helpEnabled ? 2 : 1;
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={helpEnabled}
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 watchText = `${barIcons.watch} ${isWatching ? 'Watching' : 'Static'}`;
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].concat(gitText ? [gitText] : []);
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={isWatching ? finalTheme.colors.success : finalTheme.colors.muted}>
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(flatList = [], rebuildTree, refreshFlatList = null, ensureChildrenLoaded = null) {
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
 
@@ -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