ftreeview 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,1619 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.js
4
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "node:fs";
5
+ import { resolve as resolve4, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { statSync as statSync3, readdirSync as readdirSync3 } from "node:fs";
8
+ import { render } from "ink";
9
+ import React5 from "react";
10
+
11
+ // src/App.jsx
12
+ import React4, { useEffect as useEffect5, useCallback as useCallback5, useMemo, useState as useState6, useRef as useRef5 } from "react";
13
+ import { isAbsolute, relative as relative3, resolve as resolve3, sep as sep3 } from "node:path";
14
+ import { Box as Box4 } from "ink";
15
+
16
+ // src/components/TreeView.jsx
17
+ import React2 from "react";
18
+ import { Box as Box2, Text as Text2 } from "ink";
19
+
20
+ // src/components/TreeLine.jsx
21
+ import React from "react";
22
+ import { Box, Text } from "ink";
23
+ import { getIcon, detectFileType } from "../src/lib/icons.js";
24
+
25
+ // src/lib/changeStatus.js
26
+ var CHANGE_STATUS = {
27
+ ADDED: "A",
28
+ MODIFIED: "M"
29
+ };
30
+ function normalizeChangeStatus(status = "") {
31
+ if (status === CHANGE_STATUS.MODIFIED) {
32
+ return CHANGE_STATUS.MODIFIED;
33
+ }
34
+ if (status === CHANGE_STATUS.ADDED) {
35
+ return CHANGE_STATUS.ADDED;
36
+ }
37
+ return "";
38
+ }
39
+ function mergeChangeStatus(first = "", second = "") {
40
+ const left = normalizeChangeStatus(first);
41
+ const right = normalizeChangeStatus(second);
42
+ if (left === CHANGE_STATUS.MODIFIED || right === CHANGE_STATUS.MODIFIED) {
43
+ return CHANGE_STATUS.MODIFIED;
44
+ }
45
+ if (left === CHANGE_STATUS.ADDED || right === CHANGE_STATUS.ADDED) {
46
+ return CHANGE_STATUS.ADDED;
47
+ }
48
+ return "";
49
+ }
50
+ function mapGitStatusToChangeStatus(gitStatus = "") {
51
+ if (gitStatus === "U") {
52
+ return CHANGE_STATUS.ADDED;
53
+ }
54
+ return normalizeChangeStatus(gitStatus);
55
+ }
56
+ function mapWatcherEventToChangeStatus(event = "") {
57
+ if (event === "add" || event === "addDir") {
58
+ return CHANGE_STATUS.ADDED;
59
+ }
60
+ if (event === "change") {
61
+ return CHANGE_STATUS.MODIFIED;
62
+ }
63
+ return "";
64
+ }
65
+
66
+ // src/components/TreeLine.jsx
67
+ import { getPrefix } from "../src/lib/connectors.js";
68
+
69
+ // src/lib/theme.js
70
+ var ICON_SET = {
71
+ AUTO: "auto",
72
+ EMOJI: "emoji",
73
+ NERD: "nerd",
74
+ ASCII: "ascii"
75
+ };
76
+ var THEME = {
77
+ VSCODE: "vscode"
78
+ };
79
+ var VSCODE_THEME = {
80
+ name: THEME.VSCODE,
81
+ colors: {
82
+ fg: "white",
83
+ muted: "gray",
84
+ accent: "cyan",
85
+ success: "green",
86
+ warning: "yellow",
87
+ danger: "red"
88
+ },
89
+ tree: {
90
+ connector: { color: "gray", dimColor: true },
91
+ cursor: { backgroundColor: "blue", color: "white" },
92
+ dirName: { color: "cyan", bold: true },
93
+ fileName: { color: "white" },
94
+ hidden: { color: "gray", dimColor: true },
95
+ symlink: { color: "magenta" },
96
+ error: { color: "red" }
97
+ },
98
+ statusBar: {
99
+ separator: "\u2022",
100
+ separatorStyle: { color: "gray", dimColor: true },
101
+ hintStyle: { color: "gray", dimColor: true }
102
+ }
103
+ };
104
+ function resolveIconSet({ cliIconSet, noIcons = false, env = process.env } = {}) {
105
+ const fromCli = (cliIconSet || "").toLowerCase();
106
+ const fromEnv = (env.FTREE_ICON_SET || "").toLowerCase();
107
+ if (noIcons && !fromCli) {
108
+ return ICON_SET.ASCII;
109
+ }
110
+ const requested = fromCli || fromEnv || ICON_SET.EMOJI;
111
+ if (requested === ICON_SET.NERD) return ICON_SET.NERD;
112
+ if (requested === ICON_SET.ASCII) return ICON_SET.ASCII;
113
+ return ICON_SET.EMOJI;
114
+ }
115
+ function resolveThemeName({ cliTheme, env = process.env } = {}) {
116
+ const requested = (cliTheme || env.FTREE_THEME || THEME.VSCODE).toLowerCase();
117
+ if (requested === THEME.VSCODE) {
118
+ return THEME.VSCODE;
119
+ }
120
+ return THEME.VSCODE;
121
+ }
122
+ function getTheme(themeName = THEME.VSCODE) {
123
+ if (themeName === THEME.VSCODE) {
124
+ return VSCODE_THEME;
125
+ }
126
+ return VSCODE_THEME;
127
+ }
128
+
129
+ // src/components/TreeLine.jsx
130
+ import { jsx, jsxs } from "react/jsx-runtime";
131
+ function truncateFilename(name, maxWidth) {
132
+ if (maxWidth >= name.length) {
133
+ return name;
134
+ }
135
+ const endLength = Math.min(4, Math.floor(maxWidth / 2));
136
+ const startLength = maxWidth - endLength - 1;
137
+ if (startLength <= 0) {
138
+ return "\u2026" + name.slice(Math.max(0, name.length - maxWidth + 1));
139
+ }
140
+ return name.slice(0, startLength) + "\u2026" + name.slice(name.length - endLength);
141
+ }
142
+ function getGitBadgeColor(status) {
143
+ const colorMap = {
144
+ "M": "yellow",
145
+ "A": "green",
146
+ "D": "red",
147
+ "U": "green",
148
+ "R": "cyan"
149
+ };
150
+ return colorMap[status] || "white";
151
+ }
152
+ function getChangeIndicator(status) {
153
+ const normalizedStatus = normalizeChangeStatus(status);
154
+ if (normalizedStatus === "M") {
155
+ return { icon: "\u25CF", color: "yellow" };
156
+ }
157
+ if (normalizedStatus === "A") {
158
+ return { icon: "\u271A", color: "green" };
159
+ }
160
+ return null;
161
+ }
162
+ function TreeLine({
163
+ node,
164
+ isLast = false,
165
+ ancestorIsLast = [],
166
+ isCursor = false,
167
+ termWidth = 80,
168
+ iconSet = "emoji",
169
+ theme = VSCODE_THEME,
170
+ gitStatus = "",
171
+ changeStatus = ""
172
+ }) {
173
+ const finalTheme = theme || VSCODE_THEME;
174
+ const prefix = getPrefix(node, isLast, ancestorIsLast);
175
+ const fileType = detectFileType(node.mode);
176
+ const icon = getIcon(
177
+ node.name,
178
+ node.isDir,
179
+ node.expanded,
180
+ node.isSymlink,
181
+ node.hasError,
182
+ fileType,
183
+ { iconSet }
184
+ );
185
+ const changeIndicator = getChangeIndicator(changeStatus);
186
+ const baseNameStyle = (() => {
187
+ if (node.hasError) return finalTheme.tree.error;
188
+ if (node.isSymlink) return finalTheme.tree.symlink;
189
+ if (node.name?.startsWith(".")) return finalTheme.tree.hidden;
190
+ if (node.isDir) return finalTheme.tree.dirName;
191
+ return finalTheme.tree.fileName;
192
+ })();
193
+ const cursorStyle = isCursor ? finalTheme.tree.cursor : null;
194
+ const badgeCursorStyle = cursorStyle?.backgroundColor ? { backgroundColor: cursorStyle.backgroundColor } : null;
195
+ const connectorStyle = finalTheme.tree.connector;
196
+ const iconWidth = icon ? 2 : 0;
197
+ const changeIndicatorWidth = changeIndicator ? 3 : 0;
198
+ const gitBadgeWidth = gitStatus ? 3 : 0;
199
+ const reservedWidth = prefix.length + iconWidth + changeIndicatorWidth + gitBadgeWidth + 2;
200
+ const maxFilenameWidth = Math.max(4, termWidth - reservedWidth);
201
+ const displayName = truncateFilename(node.name, maxFilenameWidth);
202
+ const mainContent = /* @__PURE__ */ jsxs(Box, { children: [
203
+ /* @__PURE__ */ jsx(Text, { ...connectorStyle, ...cursorStyle || {}, children: prefix }),
204
+ icon && /* @__PURE__ */ jsxs(Text, { ...cursorStyle || {}, children: [
205
+ icon,
206
+ " "
207
+ ] }),
208
+ /* @__PURE__ */ jsx(
209
+ Text,
210
+ {
211
+ ...baseNameStyle,
212
+ ...cursorStyle || {},
213
+ children: displayName
214
+ }
215
+ )
216
+ ] });
217
+ if (gitStatus || changeIndicator) {
218
+ return /* @__PURE__ */ jsxs(Box, { justifyContent: "spaceBetween", width: termWidth, children: [
219
+ mainContent,
220
+ /* @__PURE__ */ jsxs(Box, { children: [
221
+ gitStatus && /* @__PURE__ */ jsxs(Text, { color: getGitBadgeColor(gitStatus), ...badgeCursorStyle || {}, children: [
222
+ " ",
223
+ gitStatus,
224
+ " "
225
+ ] }),
226
+ changeIndicator && /* @__PURE__ */ jsxs(Text, { color: changeIndicator.color, ...badgeCursorStyle || {}, children: [
227
+ " ",
228
+ changeIndicator.icon,
229
+ " "
230
+ ] })
231
+ ] })
232
+ ] });
233
+ }
234
+ return mainContent;
235
+ }
236
+
237
+ // src/components/TreeView.jsx
238
+ import { computeAncestorPrefixes } from "../src/lib/connectors.js";
239
+ import { jsx as jsx2 } from "react/jsx-runtime";
240
+ function isLastChild(flatList, index) {
241
+ if (index >= flatList.length - 1) {
242
+ return true;
243
+ }
244
+ const currentNode = flatList[index];
245
+ const nextNode = flatList[index + 1];
246
+ return nextNode.depth <= currentNode.depth;
247
+ }
248
+ function EmptyDirectory() {
249
+ return /* @__PURE__ */ jsx2(Box2, { justifyContent: "center", paddingY: 1, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "(empty directory)" }) });
250
+ }
251
+ function TreeView({
252
+ flatList = [],
253
+ cursor = 0,
254
+ viewportStart = 0,
255
+ viewportHeight = 20,
256
+ termWidth = 80,
257
+ iconSet = "emoji",
258
+ theme = null
259
+ }) {
260
+ if (flatList.length === 0) {
261
+ return /* @__PURE__ */ jsx2(EmptyDirectory, {});
262
+ }
263
+ const safeViewportStart = Math.max(0, Math.min(viewportStart, flatList.length - 1));
264
+ const safeViewportHeight = Math.min(viewportHeight, flatList.length - safeViewportStart);
265
+ const visibleNodes = flatList.slice(safeViewportStart, safeViewportStart + safeViewportHeight);
266
+ if (process.env.FTREE_DEBUG) {
267
+ console.log(`[DEBUG] TreeView rendering: flatList.length=${flatList.length}, viewportHeight=${viewportHeight}`);
268
+ console.log(`[DEBUG] TreeView: safeViewportStart=${safeViewportStart}, safeViewportHeight=${safeViewportHeight}`);
269
+ console.log(`[DEBUG] TreeView: visibleNodes count=${visibleNodes.length}`);
270
+ }
271
+ return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", width: termWidth, children: visibleNodes.map((node, visibleIndex) => {
272
+ const actualIndex = safeViewportStart + visibleIndex;
273
+ const isLast = isLastChild(flatList, actualIndex);
274
+ const ancestorIsLast = computeAncestorPrefixes(flatList, actualIndex);
275
+ const isCursor = actualIndex === cursor;
276
+ return /* @__PURE__ */ jsx2(
277
+ TreeLine,
278
+ {
279
+ node,
280
+ isLast,
281
+ ancestorIsLast,
282
+ isCursor,
283
+ termWidth,
284
+ iconSet,
285
+ theme,
286
+ gitStatus: node.gitStatus || "",
287
+ changeStatus: node.changeStatus || ""
288
+ },
289
+ node.path
290
+ );
291
+ }) });
292
+ }
293
+ var TreeView_default = TreeView;
294
+
295
+ // src/components/StatusBar.jsx
296
+ import React3 from "react";
297
+ import { Box as Box3, Text as Text3 } from "ink";
298
+ import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
299
+ function truncatePath(path, maxWidth) {
300
+ if (maxWidth >= path.length) {
301
+ return path;
302
+ }
303
+ const endLength = Math.min(15, maxWidth - 3);
304
+ const startLength = Math.max(3, maxWidth - endLength - 3);
305
+ if (startLength + endLength + 3 > maxWidth) {
306
+ return "..." + path.slice(Math.max(0, path.length - maxWidth + 3));
307
+ }
308
+ return path.slice(0, startLength) + "..." + path.slice(path.length - endLength);
309
+ }
310
+ function formatPath(path, maxWidth) {
311
+ const segments = path.split("/");
312
+ const segmentCount = segments.length;
313
+ if (path.length <= maxWidth || segmentCount <= 3) {
314
+ return truncatePath(path, maxWidth);
315
+ }
316
+ const first = segments[0];
317
+ const lastTwo = segments.slice(-2).join("/");
318
+ const ellipsisPath = `${first}/.../${lastTwo}`;
319
+ if (ellipsisPath.length <= maxWidth) {
320
+ return ellipsisPath;
321
+ }
322
+ return truncatePath(ellipsisPath, maxWidth);
323
+ }
324
+ function calculateGitStatus(statusMap) {
325
+ if (!statusMap || statusMap.size === 0) {
326
+ return { isRepo: false, summary: "" };
327
+ }
328
+ const counts = { M: 0, A: 0, D: 0, U: 0, R: 0 };
329
+ for (const status of statusMap.values()) {
330
+ if (counts.hasOwnProperty(status)) {
331
+ counts[status]++;
332
+ }
333
+ }
334
+ const parts = [];
335
+ if (counts.D) parts.push(`${counts.D}D`);
336
+ if (counts.M) parts.push(`${counts.M}M`);
337
+ if (counts.A) parts.push(`${counts.A}A`);
338
+ if (counts.U) parts.push(`${counts.U}U`);
339
+ if (counts.R) parts.push(`${counts.R}R`);
340
+ const summary = parts.length > 0 ? parts.join(" ") : "clean";
341
+ return { isRepo: true, summary };
342
+ }
343
+ function StatusBar({
344
+ version: version2 = "v0.1.0",
345
+ rootPath = ".",
346
+ fileCount = 0,
347
+ dirCount = 0,
348
+ isWatching = false,
349
+ gitEnabled = false,
350
+ isGitRepo = false,
351
+ gitSummary = "",
352
+ showHelp = true,
353
+ termWidth = 80,
354
+ iconSet = "emoji",
355
+ theme = VSCODE_THEME
356
+ }) {
357
+ const finalTheme = theme || VSCODE_THEME;
358
+ const barIcons = (() => {
359
+ if (iconSet === "nerd") {
360
+ return {
361
+ file: "\uF15B",
362
+ folder: "\uE5FF",
363
+ watch: "\uF06E",
364
+ git: "\uE702"
365
+ };
366
+ }
367
+ if (iconSet === "ascii") {
368
+ return {
369
+ file: "F",
370
+ folder: "D",
371
+ watch: "W",
372
+ git: "git"
373
+ };
374
+ }
375
+ return {
376
+ file: "\u{1F4C4}",
377
+ folder: "\u{1F4C1}",
378
+ watch: "\u{1F441}",
379
+ git: "\u{1F33F}"
380
+ };
381
+ })();
382
+ const sep4 = ` ${finalTheme.statusBar.separator} `;
383
+ const paddingWidth = 2;
384
+ const versionText = `ftree ${version2}`;
385
+ const countsText = `${barIcons.file} ${fileCount} ${barIcons.folder} ${dirCount}`;
386
+ const watchText = `${barIcons.watch} ${isWatching ? "Watching" : "Static"}`;
387
+ const gitText = gitEnabled && isGitRepo ? `${barIcons.git} ${gitSummary || "clean"}` : "";
388
+ const fixedSegments = [versionText, countsText, watchText].concat(gitText ? [gitText] : []);
389
+ const fixedWidth = fixedSegments.reduce((sum, part) => sum + part.length, 0) + fixedSegments.length * sep4.length + paddingWidth;
390
+ const pathMaxWidth = Math.max(8, termWidth - fixedWidth);
391
+ const displayPath = formatPath(rootPath, pathMaxWidth);
392
+ return /* @__PURE__ */ jsxs2(Box3, { flexDirection: "column", width: termWidth, children: [
393
+ /* @__PURE__ */ jsxs2(
394
+ Box3,
395
+ {
396
+ width: termWidth,
397
+ paddingX: 1,
398
+ children: [
399
+ /* @__PURE__ */ jsx3(Text3, { color: finalTheme.colors.accent, bold: true, children: "ftree" }),
400
+ /* @__PURE__ */ jsxs2(Text3, { color: finalTheme.colors.muted, dimColor: true, children: [
401
+ " ",
402
+ version2
403
+ ] }),
404
+ /* @__PURE__ */ jsx3(Text3, { ...finalTheme.statusBar.separatorStyle, children: sep4 }),
405
+ /* @__PURE__ */ jsx3(Text3, { color: finalTheme.colors.fg, bold: true, children: displayPath }),
406
+ /* @__PURE__ */ jsx3(Text3, { ...finalTheme.statusBar.separatorStyle, children: sep4 }),
407
+ /* @__PURE__ */ jsx3(Text3, { color: finalTheme.colors.muted, children: countsText }),
408
+ /* @__PURE__ */ jsx3(Text3, { ...finalTheme.statusBar.separatorStyle, children: sep4 }),
409
+ /* @__PURE__ */ jsx3(Text3, { color: isWatching ? finalTheme.colors.success : finalTheme.colors.muted, children: watchText }),
410
+ gitEnabled && isGitRepo && /* @__PURE__ */ jsxs2(Fragment, { children: [
411
+ /* @__PURE__ */ jsx3(Text3, { ...finalTheme.statusBar.separatorStyle, children: sep4 }),
412
+ /* @__PURE__ */ jsx3(Text3, { color: gitSummary === "clean" ? finalTheme.colors.success : finalTheme.colors.warning, children: gitText })
413
+ ] })
414
+ ]
415
+ }
416
+ ),
417
+ showHelp && /* @__PURE__ */ jsx3(
418
+ Box3,
419
+ {
420
+ width: termWidth,
421
+ 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" })
423
+ }
424
+ )
425
+ ] });
426
+ }
427
+ function calculateCounts(flatList = []) {
428
+ let fileCount = 0;
429
+ let dirCount = 0;
430
+ for (const node of flatList) {
431
+ if (node.depth > 0 && node.isDir) {
432
+ dirCount++;
433
+ } else if (node.depth > 0) {
434
+ fileCount++;
435
+ }
436
+ }
437
+ return { fileCount, dirCount };
438
+ }
439
+ var StatusBar_default = StatusBar;
440
+
441
+ // src/hooks/useTree.js
442
+ import { readdirSync as readdirSync2, statSync as statSync2, lstatSync, readlinkSync } from "node:fs";
443
+ import { resolve as resolve2, basename, relative as relative2, sep as sep2 } from "node:path";
444
+ import { useState, useCallback, useRef } from "react";
445
+
446
+ // src/lib/constants.js
447
+ var DEFAULT_CONFIG = {
448
+ /** Maximum depth to traverse the directory tree */
449
+ maxDepth: 10,
450
+ /** Show hidden files (starting with .) */
451
+ showHidden: false,
452
+ /** Show ignored files (--no-ignore flag) */
453
+ showIgnored: false,
454
+ /** Files/directories to ignore (gitignore-style patterns) */
455
+ ignorePatterns: [
456
+ "node_modules/**",
457
+ ".git/**",
458
+ "dist/**",
459
+ "build/**",
460
+ "*.log"
461
+ ],
462
+ /** Watch for file system changes and update tree */
463
+ watch: true,
464
+ /** Show git status (modified, added, deleted, untracked files) */
465
+ showGitStatus: true,
466
+ /** Number of concurrent file system operations */
467
+ concurrency: 50,
468
+ /** Update interval for git status (ms) */
469
+ gitStatusInterval: 2e3,
470
+ /** Debounce delay for file system events (ms) */
471
+ debounceDelay: 100
472
+ };
473
+
474
+ // src/hooks/useIgnore.js
475
+ import { existsSync, readdirSync, statSync, readFileSync } from "node:fs";
476
+ import { resolve, join, relative, sep } from "node:path";
477
+ import ignore from "ignore";
478
+ var DEFAULT_PATTERNS = [
479
+ ".git",
480
+ ".git/",
481
+ ".git/**",
482
+ "node_modules",
483
+ "node_modules/"
484
+ ];
485
+ function findGitignoreFiles(rootPath) {
486
+ const gitignoreMap = /* @__PURE__ */ new Map();
487
+ function scanDirectory(dirPath) {
488
+ try {
489
+ const entries = readdirSync(dirPath, { withFileTypes: true });
490
+ const gitignorePath = join(dirPath, ".gitignore");
491
+ if (existsSync(gitignorePath)) {
492
+ gitignoreMap.set(dirPath, gitignorePath);
493
+ }
494
+ for (const entry of entries) {
495
+ if (entry.isDirectory() && entry.name !== ".git") {
496
+ const subPath = join(dirPath, entry.name);
497
+ scanDirectory(subPath);
498
+ }
499
+ }
500
+ } catch (error) {
501
+ }
502
+ }
503
+ scanDirectory(rootPath);
504
+ return gitignoreMap;
505
+ }
506
+ function createIgnoreFilter(rootPath, options = {}) {
507
+ const { showIgnored = false, extraIgnorePatterns = [] } = options;
508
+ if (showIgnored) {
509
+ return {
510
+ shouldIgnore: (relativePath) => false,
511
+ addPattern: () => {
512
+ },
513
+ filter: (paths) => paths
514
+ };
515
+ }
516
+ const ig = ignore();
517
+ for (const pattern of DEFAULT_PATTERNS) {
518
+ ig.add(pattern);
519
+ }
520
+ if (extraIgnorePatterns.length > 0) {
521
+ ig.add(extraIgnorePatterns);
522
+ }
523
+ const gitignoreMap = findGitignoreFiles(rootPath);
524
+ const loadedIgnores = /* @__PURE__ */ new Set();
525
+ function loadPatternsForDirectory(dirAbsPath) {
526
+ if (loadedIgnores.has(dirAbsPath)) {
527
+ return;
528
+ }
529
+ const gitignorePath = gitignoreMap.get(dirAbsPath);
530
+ if (gitignorePath && existsSync(gitignorePath)) {
531
+ try {
532
+ const content = readFileSync(gitignorePath, "utf-8");
533
+ ig.add(content);
534
+ loadedIgnores.add(dirAbsPath);
535
+ } catch (error) {
536
+ }
537
+ }
538
+ }
539
+ function shouldIgnore(relativePath) {
540
+ if (relativePath === "") {
541
+ return false;
542
+ }
543
+ const normalizedPath = relativePath.split(sep).join("/");
544
+ return ig.ignores(normalizedPath);
545
+ }
546
+ function filter(paths) {
547
+ return ig.filter(paths);
548
+ }
549
+ function addPattern(pattern) {
550
+ ig.add(pattern);
551
+ }
552
+ return {
553
+ shouldIgnore,
554
+ filter,
555
+ addPattern,
556
+ loadPatternsForDirectory
557
+ };
558
+ }
559
+
560
+ // src/hooks/useTree.js
561
+ function createFileNode(absPath, rootPath, depth, isDir, isSymlink = false) {
562
+ let stats;
563
+ let targetPath = null;
564
+ let hasError = false;
565
+ let error = null;
566
+ try {
567
+ stats = isSymlink ? lstatSync(absPath) : statSync2(absPath);
568
+ if (isSymlink) {
569
+ try {
570
+ targetPath = readlinkSync(absPath);
571
+ } catch (e) {
572
+ hasError = true;
573
+ error = "Cannot read symlink";
574
+ }
575
+ }
576
+ } catch (e) {
577
+ if (e.code === "EACCES" || e.code === "EPERM") {
578
+ return {
579
+ name: basename(absPath),
580
+ path: relative2(rootPath, absPath).split(sep2).join("/"),
581
+ absPath,
582
+ isDir,
583
+ children: null,
584
+ expanded: false,
585
+ depth,
586
+ size: 0,
587
+ mode: 0,
588
+ gitStatus: "",
589
+ changeStatus: "",
590
+ hasError: true,
591
+ error: "Permission denied"
592
+ };
593
+ }
594
+ throw e;
595
+ }
596
+ return {
597
+ name: basename(absPath),
598
+ path: relative2(rootPath, absPath).split(sep2).join("/"),
599
+ absPath,
600
+ isDir,
601
+ children: isDir ? [] : null,
602
+ expanded: false,
603
+ depth,
604
+ size: stats.size,
605
+ mode: stats.mode,
606
+ gitStatus: "",
607
+ // Will be populated by git status hook (T10)
608
+ changeStatus: "",
609
+ // Combined A/M indicator from git + watcher states
610
+ isSymlink,
611
+ targetPath,
612
+ hasError,
613
+ error
614
+ };
615
+ }
616
+ function compareNodes(a, b) {
617
+ if (a.isDir && !b.isDir) return -1;
618
+ if (!a.isDir && b.isDir) return 1;
619
+ return a.name.localeCompare(b.name, void 0, { sensitivity: "base" });
620
+ }
621
+ function buildTree(rootPath, options = {}, currentDepth = 0, ignoreFilter = null, baseRootPath = null) {
622
+ const { depth = DEFAULT_CONFIG.maxDepth, showHidden = false } = options;
623
+ const finalBaseRootPath = baseRootPath || rootPath;
624
+ if (ignoreFilter === null) {
625
+ ignoreFilter = createIgnoreFilter(finalBaseRootPath, options);
626
+ }
627
+ const rootNode = createFileNode(rootPath, finalBaseRootPath, currentDepth, true, false);
628
+ rootNode.expanded = true;
629
+ if (currentDepth >= depth) {
630
+ return rootNode;
631
+ }
632
+ try {
633
+ if (ignoreFilter.loadPatternsForDirectory) {
634
+ ignoreFilter.loadPatternsForDirectory(rootPath);
635
+ }
636
+ const entries = readdirSync2(rootPath, { withFileTypes: true });
637
+ for (const entry of entries) {
638
+ const entryName = entry.name;
639
+ const entryAbsPath = resolve2(rootPath, entryName);
640
+ if (entryName === ".git") {
641
+ continue;
642
+ }
643
+ if (!showHidden && entryName.startsWith(".")) {
644
+ continue;
645
+ }
646
+ const isDir = entry.isDirectory();
647
+ const isSymlink = entry.isSymbolicLink();
648
+ const entryRelativePath = relative2(finalBaseRootPath, entryAbsPath);
649
+ if (ignoreFilter.shouldIgnore(entryRelativePath)) {
650
+ continue;
651
+ }
652
+ let childNode;
653
+ try {
654
+ childNode = createFileNode(
655
+ entryAbsPath,
656
+ finalBaseRootPath,
657
+ currentDepth + 1,
658
+ isDir,
659
+ isSymlink
660
+ );
661
+ } catch (e) {
662
+ if (e.code === "EACCES" || e.code === "EPERM") {
663
+ childNode = {
664
+ name: entryName,
665
+ path: relative2(finalBaseRootPath, entryAbsPath).split(sep2).join("/"),
666
+ absPath: entryAbsPath,
667
+ isDir,
668
+ children: null,
669
+ expanded: false,
670
+ depth: currentDepth + 1,
671
+ size: 0,
672
+ mode: 0,
673
+ gitStatus: "",
674
+ changeStatus: "",
675
+ hasError: true,
676
+ error: "Permission denied",
677
+ isSymlink,
678
+ targetPath: null
679
+ };
680
+ } else {
681
+ continue;
682
+ }
683
+ }
684
+ if (isDir && !isSymlink) {
685
+ const childOptions = { ...options, showHidden };
686
+ const subtree = buildTree(
687
+ entryAbsPath,
688
+ childOptions,
689
+ currentDepth + 1,
690
+ ignoreFilter,
691
+ finalBaseRootPath
692
+ );
693
+ childNode.children = subtree.children;
694
+ }
695
+ rootNode.children.push(childNode);
696
+ }
697
+ rootNode.children.sort(compareNodes);
698
+ } catch (error) {
699
+ if (error.code === "EACCES" || error.code === "EPERM") {
700
+ rootNode.hasError = true;
701
+ rootNode.error = "Permission denied";
702
+ rootNode.children = [];
703
+ } else {
704
+ rootNode.children = [];
705
+ }
706
+ }
707
+ return rootNode;
708
+ }
709
+ function flattenTree(root) {
710
+ const result = [];
711
+ function traverse(node) {
712
+ result.push(node);
713
+ if (node.expanded && node.children && node.children.length > 0) {
714
+ for (const child of node.children) {
715
+ traverse(child);
716
+ }
717
+ }
718
+ }
719
+ traverse(root);
720
+ return result;
721
+ }
722
+ function applyGitStatus(root, statusMap) {
723
+ function walk(node) {
724
+ if (!node.isDir) {
725
+ node.gitStatus = statusMap.get(node.path) || "";
726
+ } else {
727
+ let aggregatedStatus = "";
728
+ for (const child of node.children) {
729
+ walk(child);
730
+ const childStatus = child.gitStatus;
731
+ if (childStatus === "D") {
732
+ aggregatedStatus = "D";
733
+ } else if (childStatus === "M" && aggregatedStatus !== "D") {
734
+ aggregatedStatus = "M";
735
+ } else if (childStatus === "A" && aggregatedStatus !== "D" && aggregatedStatus !== "M") {
736
+ aggregatedStatus = "A";
737
+ } else if (childStatus === "U" && !aggregatedStatus) {
738
+ aggregatedStatus = "U";
739
+ } else if (childStatus === "R" && !aggregatedStatus) {
740
+ aggregatedStatus = "R";
741
+ }
742
+ }
743
+ node.gitStatus = aggregatedStatus;
744
+ }
745
+ }
746
+ walk(root);
747
+ return root;
748
+ }
749
+ function applyChangeIndicators(root, gitStatusMap = /* @__PURE__ */ new Map(), watcherStatusMap = /* @__PURE__ */ new Map()) {
750
+ function walk(node) {
751
+ if (!node.isDir) {
752
+ const gitChangeStatus = mapGitStatusToChangeStatus(
753
+ gitStatusMap.get(node.path) || node.gitStatus || ""
754
+ );
755
+ const watcherChangeStatus = normalizeChangeStatus(
756
+ watcherStatusMap.get(node.path) || ""
757
+ );
758
+ const fileChangeStatus = mergeChangeStatus(gitChangeStatus, watcherChangeStatus);
759
+ node.changeStatus = fileChangeStatus;
760
+ return fileChangeStatus;
761
+ }
762
+ let aggregatedStatus = normalizeChangeStatus(watcherStatusMap.get(node.path) || "");
763
+ if (node.children && node.children.length > 0) {
764
+ for (const child of node.children) {
765
+ const childStatus = walk(child);
766
+ aggregatedStatus = mergeChangeStatus(aggregatedStatus, childStatus);
767
+ }
768
+ }
769
+ const finalStatus = node.depth === 0 ? "" : aggregatedStatus;
770
+ node.changeStatus = finalStatus;
771
+ return finalStatus;
772
+ }
773
+ walk(root);
774
+ return root;
775
+ }
776
+ function mergeExpandState(oldTree, newTree) {
777
+ if (!oldTree || !newTree) return newTree;
778
+ const expandedPaths = /* @__PURE__ */ new Set();
779
+ function collectExpanded(node) {
780
+ if (node.isDir && node.expanded) {
781
+ expandedPaths.add(node.path);
782
+ }
783
+ if (node.children) {
784
+ for (const child of node.children) {
785
+ collectExpanded(child);
786
+ }
787
+ }
788
+ }
789
+ collectExpanded(oldTree);
790
+ function applyExpanded(node) {
791
+ if (node.isDir && expandedPaths.has(node.path)) {
792
+ node.expanded = true;
793
+ }
794
+ if (node.children) {
795
+ for (const child of node.children) {
796
+ applyExpanded(child);
797
+ }
798
+ }
799
+ }
800
+ applyExpanded(newTree);
801
+ return newTree;
802
+ }
803
+ function useTree(rootPath, options = {}) {
804
+ const absPath = resolve2(rootPath);
805
+ const [tree, setTree] = useState(() => buildTree(absPath, options));
806
+ const treeRef = useRef(tree);
807
+ treeRef.current = tree;
808
+ const [flatList, setFlatList] = useState(() => flattenTree(tree));
809
+ const refreshFlatList = useCallback(() => {
810
+ const currentTree = treeRef.current;
811
+ if (!currentTree) {
812
+ return;
813
+ }
814
+ setFlatList(flattenTree(currentTree));
815
+ }, []);
816
+ const rebuildTree = useCallback((preserveState = true) => {
817
+ const oldTree = treeRef.current;
818
+ const newTree = buildTree(absPath, options);
819
+ const mergedTree = preserveState && oldTree ? mergeExpandState(oldTree, newTree) : newTree;
820
+ if (process.env.FTREE_DEBUG) {
821
+ const flat = flattenTree(mergedTree);
822
+ console.log(`[DEBUG] rebuildTree: BEFORE setTree, flatList has ${flat.length} items`);
823
+ console.log(`[DEBUG] rebuildTree: root has ${mergedTree.children?.length || 0} direct children`);
824
+ console.log(`[DEBUG] rebuildTree: newTree root has ${newTree.children?.length || 0} direct children`);
825
+ }
826
+ setTree(mergedTree);
827
+ setFlatList(flattenTree(mergedTree));
828
+ if (process.env.FTREE_DEBUG) {
829
+ console.log(`[DEBUG] rebuildTree: AFTER setTree/setFlatList, flatList state updated`);
830
+ }
831
+ }, [absPath, options]);
832
+ const getTree = useCallback(() => treeRef.current, []);
833
+ return {
834
+ tree,
835
+ flatList,
836
+ refreshFlatList,
837
+ rebuildTree,
838
+ getTree
839
+ };
840
+ }
841
+
842
+ // src/hooks/useNavigation.js
843
+ import { useState as useState2, useEffect, useCallback as useCallback2 } from "react";
844
+ import { useInput, useApp } from "ink";
845
+ var VIEWPORT_MARGIN = 3;
846
+ function useNavigation(flatList = [], rebuildTree, refreshFlatList = null) {
847
+ const { exit } = useApp();
848
+ const viewportHeight = process.stdout.rows - 2;
849
+ const [cursor, setCursor] = useState2(0);
850
+ const [viewportStart, setViewportStart] = useState2(0);
851
+ const refreshVisibleTree = useCallback2(() => {
852
+ if (typeof refreshFlatList === "function") {
853
+ refreshFlatList();
854
+ return;
855
+ }
856
+ rebuildTree(true);
857
+ }, [refreshFlatList, rebuildTree]);
858
+ const setCursorByPath = useCallback2((path) => {
859
+ if (!path || flatList.length === 0) return -1;
860
+ const newIndex = flatList.findIndex((node) => node.path === path);
861
+ if (newIndex !== -1) {
862
+ setCursor(newIndex);
863
+ return newIndex;
864
+ }
865
+ return -1;
866
+ }, [flatList]);
867
+ const findParentIndex = useCallback2((currentIndex) => {
868
+ if (currentIndex <= 0) return null;
869
+ const currentDepth = flatList[currentIndex]?.depth ?? 0;
870
+ for (let i = currentIndex - 1; i >= 0; i--) {
871
+ if (flatList[i]?.depth < currentDepth) {
872
+ return i;
873
+ }
874
+ }
875
+ return null;
876
+ }, [flatList]);
877
+ const moveUp = useCallback2(() => {
878
+ setCursor((prev) => Math.max(0, prev - 1));
879
+ }, []);
880
+ const moveDown = useCallback2(() => {
881
+ setCursor((prev) => Math.min(flatList.length - 1, prev + 1));
882
+ }, [flatList.length]);
883
+ const expand = useCallback2(() => {
884
+ if (flatList.length === 0) return;
885
+ const node = flatList[cursor];
886
+ if (!node) return;
887
+ if (node.isDir && !node.expanded) {
888
+ node.expanded = true;
889
+ refreshVisibleTree();
890
+ }
891
+ }, [cursor, flatList, refreshVisibleTree]);
892
+ const smartCollapse = useCallback2(() => {
893
+ if (flatList.length === 0) return;
894
+ const node = flatList[cursor];
895
+ if (!node) return;
896
+ if (node.expanded && node.isDir) {
897
+ node.expanded = false;
898
+ refreshVisibleTree();
899
+ } else {
900
+ const parentIndex = findParentIndex(cursor);
901
+ if (parentIndex !== null) {
902
+ setCursor(parentIndex);
903
+ }
904
+ }
905
+ }, [cursor, flatList, refreshVisibleTree, findParentIndex]);
906
+ const toggle = useCallback2(() => {
907
+ if (flatList.length === 0) return;
908
+ const node = flatList[cursor];
909
+ if (!node || !node.isDir) return;
910
+ node.expanded = !node.expanded;
911
+ refreshVisibleTree();
912
+ }, [cursor, flatList, refreshVisibleTree]);
913
+ const jumpToTop = useCallback2(() => {
914
+ setCursor(0);
915
+ }, []);
916
+ const jumpToBottom = useCallback2(() => {
917
+ setCursor(Math.max(0, flatList.length - 1));
918
+ }, [flatList.length]);
919
+ const refresh = useCallback2(() => {
920
+ rebuildTree(true);
921
+ }, [rebuildTree]);
922
+ useInput((input, key) => {
923
+ if (key.upArrow || input === "k") {
924
+ moveUp();
925
+ } else if (key.downArrow || input === "j") {
926
+ moveDown();
927
+ } else if (key.rightArrow || input === "l" || key.return) {
928
+ expand();
929
+ } else if (key.leftArrow || input === "h") {
930
+ smartCollapse();
931
+ } else if (key.ctrl && input === "c") {
932
+ exit();
933
+ } else if (input === "q") {
934
+ exit();
935
+ } else if (input === " ") {
936
+ toggle();
937
+ } else if (input === "g") {
938
+ jumpToTop();
939
+ } else if (input === "G") {
940
+ jumpToBottom();
941
+ } else if (input === "r") {
942
+ refresh();
943
+ }
944
+ });
945
+ useEffect(() => {
946
+ if (flatList.length === 0) {
947
+ setViewportStart(0);
948
+ return;
949
+ }
950
+ if (cursor < viewportStart) {
951
+ setViewportStart(cursor);
952
+ } else if (cursor >= viewportStart + viewportHeight) {
953
+ setViewportStart(cursor - viewportHeight + 1);
954
+ }
955
+ if (cursor - viewportStart < VIEWPORT_MARGIN && viewportStart > 0) {
956
+ setViewportStart(Math.max(0, cursor - VIEWPORT_MARGIN));
957
+ } else if (viewportStart + viewportHeight - cursor < VIEWPORT_MARGIN) {
958
+ const maxStart = Math.max(0, flatList.length - viewportHeight);
959
+ setViewportStart(Math.min(maxStart, cursor - viewportHeight + VIEWPORT_MARGIN + 1));
960
+ }
961
+ }, [cursor, flatList.length, viewportHeight]);
962
+ useEffect(() => {
963
+ if (flatList.length === 0) {
964
+ setCursor(0);
965
+ setViewportStart(0);
966
+ }
967
+ }, [flatList.length]);
968
+ return {
969
+ cursor,
970
+ viewportStart,
971
+ exit,
972
+ setCursorByPath,
973
+ setCursor
974
+ };
975
+ }
976
+
977
+ // src/hooks/useGitStatus.js
978
+ import { execFile } from "node:child_process";
979
+ import { useEffect as useEffect2, useState as useState3, useCallback as useCallback3, useRef as useRef2 } from "react";
980
+ function parsePorcelain(output) {
981
+ const statusMap = /* @__PURE__ */ new Map();
982
+ const lines = output.split("\n").filter(Boolean);
983
+ for (const line of lines) {
984
+ if (line.startsWith("!!")) {
985
+ continue;
986
+ }
987
+ const stagedStatus = line[0];
988
+ const unstagedStatus = line[1];
989
+ let filePath;
990
+ if (line[2] === '"') {
991
+ const endQuote = line.indexOf('"', 3);
992
+ filePath = line.slice(3, endQuote);
993
+ } else {
994
+ filePath = line.slice(3);
995
+ }
996
+ let statusCode = "";
997
+ if (stagedStatus === "R") {
998
+ statusCode = "R";
999
+ if (filePath.includes(" -> ")) {
1000
+ filePath = filePath.split(" -> ")[1];
1001
+ }
1002
+ } else if (stagedStatus === "D" || unstagedStatus === "D") {
1003
+ statusCode = "D";
1004
+ } else if (stagedStatus === "A") {
1005
+ statusCode = "A";
1006
+ } else if (stagedStatus === "M" || unstagedStatus === "M") {
1007
+ statusCode = "M";
1008
+ } else if (stagedStatus === "?" && unstagedStatus === "?") {
1009
+ statusCode = "U";
1010
+ } else if (stagedStatus === "D" && unstagedStatus === "D" || stagedStatus === "A" && unstagedStatus === "A" || (stagedStatus === "U" || unstagedStatus === "U")) {
1011
+ statusCode = "C";
1012
+ }
1013
+ if (statusCode) {
1014
+ if (filePath.endsWith("/") || filePath.endsWith("\\")) {
1015
+ filePath = filePath.slice(0, -1);
1016
+ }
1017
+ const normalizedPath = filePath.replace(/\\/g, "/");
1018
+ statusMap.set(normalizedPath, statusCode);
1019
+ }
1020
+ }
1021
+ return statusMap;
1022
+ }
1023
+ function areStatusMapsEqual(first, second) {
1024
+ if (first === second) return true;
1025
+ if (!first || !second) return false;
1026
+ if (first.size !== second.size) return false;
1027
+ for (const [path, status] of first.entries()) {
1028
+ if (second.get(path) !== status) {
1029
+ return false;
1030
+ }
1031
+ }
1032
+ return true;
1033
+ }
1034
+ function getGitStatus(rootPath) {
1035
+ return new Promise((resolve5, reject) => {
1036
+ execFile(
1037
+ "git",
1038
+ ["status", "--porcelain=v1", "-uall"],
1039
+ { cwd: rootPath, timeout: 1e4 },
1040
+ (error, stdout) => {
1041
+ if (error) {
1042
+ reject(error);
1043
+ return;
1044
+ }
1045
+ resolve5(stdout);
1046
+ }
1047
+ );
1048
+ });
1049
+ }
1050
+ function useGitStatus(rootPath, enabled = true) {
1051
+ const minRefreshMs = Math.max(100, DEFAULT_CONFIG.debounceDelay * 2);
1052
+ const [statusMap, setStatusMap] = useState3(/* @__PURE__ */ new Map());
1053
+ const [isGitRepo, setIsGitRepo] = useState3(false);
1054
+ const [isLoading, setIsLoading] = useState3(false);
1055
+ const [error, setError] = useState3(null);
1056
+ const mountedRef = useRef2(true);
1057
+ const inFlightRef = useRef2(false);
1058
+ const pendingRefreshRef = useRef2(false);
1059
+ const refreshTimerRef = useRef2(null);
1060
+ const lastFetchAtRef = useRef2(0);
1061
+ const fetchGitStatus = useCallback3(async () => {
1062
+ if (inFlightRef.current) {
1063
+ pendingRefreshRef.current = true;
1064
+ return;
1065
+ }
1066
+ inFlightRef.current = true;
1067
+ lastFetchAtRef.current = Date.now();
1068
+ if (!enabled || !rootPath) {
1069
+ setStatusMap((prev) => prev.size === 0 ? prev : /* @__PURE__ */ new Map());
1070
+ setIsGitRepo(false);
1071
+ setError(null);
1072
+ inFlightRef.current = false;
1073
+ pendingRefreshRef.current = false;
1074
+ return;
1075
+ }
1076
+ setIsLoading(true);
1077
+ setError(null);
1078
+ try {
1079
+ const repoCheck = await isGitRepo(rootPath);
1080
+ if (!mountedRef.current) return;
1081
+ if (!repoCheck) {
1082
+ setIsGitRepo(false);
1083
+ setStatusMap((prev) => prev.size === 0 ? prev : /* @__PURE__ */ new Map());
1084
+ setIsLoading(false);
1085
+ return;
1086
+ }
1087
+ setIsGitRepo(true);
1088
+ const output = await getGitStatus(rootPath);
1089
+ if (!mountedRef.current) return;
1090
+ const map = parsePorcelain(output);
1091
+ setStatusMap((prev) => areStatusMapsEqual(prev, map) ? prev : map);
1092
+ } catch (err) {
1093
+ if (!mountedRef.current) return;
1094
+ setError(err);
1095
+ setIsGitRepo(false);
1096
+ setStatusMap((prev) => prev.size === 0 ? prev : /* @__PURE__ */ new Map());
1097
+ } finally {
1098
+ if (mountedRef.current) {
1099
+ setIsLoading(false);
1100
+ }
1101
+ inFlightRef.current = false;
1102
+ if (pendingRefreshRef.current && mountedRef.current && enabled && rootPath) {
1103
+ pendingRefreshRef.current = false;
1104
+ const elapsedMs = Date.now() - lastFetchAtRef.current;
1105
+ const delayMs = Math.max(0, minRefreshMs - elapsedMs);
1106
+ if (refreshTimerRef.current) {
1107
+ clearTimeout(refreshTimerRef.current);
1108
+ }
1109
+ refreshTimerRef.current = setTimeout(() => {
1110
+ refreshTimerRef.current = null;
1111
+ fetchGitStatus();
1112
+ }, delayMs);
1113
+ }
1114
+ }
1115
+ }, [rootPath, enabled, minRefreshMs]);
1116
+ const refresh = useCallback3(() => {
1117
+ if (!enabled || !rootPath) {
1118
+ return;
1119
+ }
1120
+ if (inFlightRef.current) {
1121
+ pendingRefreshRef.current = true;
1122
+ return;
1123
+ }
1124
+ const elapsedMs = Date.now() - lastFetchAtRef.current;
1125
+ if (elapsedMs < minRefreshMs) {
1126
+ pendingRefreshRef.current = true;
1127
+ if (refreshTimerRef.current) {
1128
+ return;
1129
+ }
1130
+ const delayMs = minRefreshMs - elapsedMs;
1131
+ refreshTimerRef.current = setTimeout(() => {
1132
+ refreshTimerRef.current = null;
1133
+ pendingRefreshRef.current = false;
1134
+ fetchGitStatus();
1135
+ }, delayMs);
1136
+ return;
1137
+ }
1138
+ pendingRefreshRef.current = false;
1139
+ if (refreshTimerRef.current) {
1140
+ clearTimeout(refreshTimerRef.current);
1141
+ refreshTimerRef.current = null;
1142
+ }
1143
+ fetchGitStatus();
1144
+ }, [enabled, rootPath, minRefreshMs, fetchGitStatus]);
1145
+ useEffect2(() => {
1146
+ mountedRef.current = true;
1147
+ fetchGitStatus();
1148
+ return () => {
1149
+ mountedRef.current = false;
1150
+ inFlightRef.current = false;
1151
+ pendingRefreshRef.current = false;
1152
+ if (refreshTimerRef.current) {
1153
+ clearTimeout(refreshTimerRef.current);
1154
+ refreshTimerRef.current = null;
1155
+ }
1156
+ };
1157
+ }, [fetchGitStatus]);
1158
+ return {
1159
+ statusMap,
1160
+ isGitRepo,
1161
+ refresh,
1162
+ isLoading,
1163
+ error
1164
+ };
1165
+ }
1166
+
1167
+ // src/hooks/useWatcher.js
1168
+ import { useEffect as useEffect3, useState as useState4, useRef as useRef3 } from "react";
1169
+ import chokidar from "chokidar";
1170
+ function shouldUsePolling(rootPath) {
1171
+ const envOverride = process.env.FTREE_USE_POLLING;
1172
+ if (envOverride === "1" || envOverride === "true") {
1173
+ return true;
1174
+ }
1175
+ if (envOverride === "0" || envOverride === "false") {
1176
+ return false;
1177
+ }
1178
+ const isWsl = process.platform === "linux" && (process.env.WSL_INTEROP || process.env.WSL_DISTRO_NAME);
1179
+ if (isWsl && typeof rootPath === "string" && rootPath.startsWith("/mnt/")) {
1180
+ return true;
1181
+ }
1182
+ return false;
1183
+ }
1184
+ function useWatcher(rootPath, onTreeChange, options = {}) {
1185
+ const debounceMs = options.debounceMs ?? DEFAULT_CONFIG.debounceDelay;
1186
+ const [isWatching, setIsWatching] = useState4(false);
1187
+ const watcherRef = useRef3(null);
1188
+ const onTreeChangeRef = useRef3(onTreeChange);
1189
+ const mountedRef = useRef3(true);
1190
+ const debounceTimerRef = useRef3(null);
1191
+ const pendingChangesRef = useRef3(/* @__PURE__ */ new Map());
1192
+ const sawAnyEventRef = useRef3(false);
1193
+ useEffect3(() => {
1194
+ onTreeChangeRef.current = onTreeChange;
1195
+ }, [onTreeChange]);
1196
+ useEffect3(() => {
1197
+ mountedRef.current = true;
1198
+ if (options.noWatch || !rootPath) {
1199
+ setIsWatching(false);
1200
+ return () => {
1201
+ mountedRef.current = false;
1202
+ };
1203
+ }
1204
+ const usePolling = shouldUsePolling(rootPath);
1205
+ const watcher = chokidar.watch(rootPath, {
1206
+ ignored: /(^|[\/\\])\.\./,
1207
+ persistent: true,
1208
+ ignoreInitial: true,
1209
+ followSymlinks: false,
1210
+ ...usePolling ? { usePolling: true, interval: 300 } : {}
1211
+ });
1212
+ watcherRef.current = watcher;
1213
+ const flushPendingChange = () => {
1214
+ debounceTimerRef.current = null;
1215
+ const hadAnyEvent = sawAnyEventRef.current;
1216
+ const pendingMap = pendingChangesRef.current;
1217
+ sawAnyEventRef.current = false;
1218
+ pendingChangesRef.current = /* @__PURE__ */ new Map();
1219
+ if (hadAnyEvent && mountedRef.current && onTreeChangeRef.current) {
1220
+ const batch = Array.from(pendingMap.entries()).map(([path, event]) => ({ event, path }));
1221
+ onTreeChangeRef.current(batch);
1222
+ }
1223
+ };
1224
+ watcher.on("all", (event, path) => {
1225
+ sawAnyEventRef.current = true;
1226
+ if (event === "add" || event === "addDir") {
1227
+ pendingChangesRef.current.set(path, event);
1228
+ } else if (event === "change") {
1229
+ if (!pendingChangesRef.current.has(path)) {
1230
+ pendingChangesRef.current.set(path, event);
1231
+ }
1232
+ }
1233
+ if (debounceTimerRef.current) {
1234
+ clearTimeout(debounceTimerRef.current);
1235
+ }
1236
+ debounceTimerRef.current = setTimeout(flushPendingChange, debounceMs);
1237
+ });
1238
+ watcher.on("error", () => {
1239
+ setIsWatching(false);
1240
+ });
1241
+ watcher.on("ready", () => {
1242
+ setIsWatching(true);
1243
+ });
1244
+ return () => {
1245
+ mountedRef.current = false;
1246
+ if (debounceTimerRef.current) {
1247
+ clearTimeout(debounceTimerRef.current);
1248
+ debounceTimerRef.current = null;
1249
+ }
1250
+ pendingChangesRef.current = /* @__PURE__ */ new Map();
1251
+ sawAnyEventRef.current = false;
1252
+ if (watcherRef.current) {
1253
+ watcherRef.current.close();
1254
+ watcherRef.current = null;
1255
+ }
1256
+ setIsWatching(false);
1257
+ };
1258
+ }, [rootPath, options.noWatch, debounceMs]);
1259
+ return { isWatching };
1260
+ }
1261
+
1262
+ // src/hooks/useChangedFiles.js
1263
+ import { useState as useState5, useCallback as useCallback4, useEffect as useEffect4, useRef as useRef4 } from "react";
1264
+ var MODIFY_HIGHLIGHT_DURATION = 6e4;
1265
+ var ADD_HIGHLIGHT_DURATION = 12e4;
1266
+ function useChangedFiles() {
1267
+ const [changedFiles, setChangedFiles] = useState5(/* @__PURE__ */ new Map());
1268
+ const timeoutMapRef = useRef4(/* @__PURE__ */ new Map());
1269
+ const markChanged = useCallback4((filePath, status = "M") => {
1270
+ const normalizedStatus = normalizeChangeStatus(status);
1271
+ if (!filePath || !normalizedStatus) {
1272
+ return;
1273
+ }
1274
+ let mergedStatus = normalizedStatus;
1275
+ setChangedFiles((prev) => {
1276
+ const next = new Map(prev);
1277
+ const existingStatus = next.get(filePath) || "";
1278
+ mergedStatus = mergeChangeStatus(existingStatus, normalizedStatus);
1279
+ next.set(filePath, mergedStatus);
1280
+ return next;
1281
+ });
1282
+ const existingTimer = timeoutMapRef.current.get(filePath);
1283
+ if (existingTimer) {
1284
+ clearTimeout(existingTimer);
1285
+ }
1286
+ const durationMs = mergedStatus === "A" ? ADD_HIGHLIGHT_DURATION : MODIFY_HIGHLIGHT_DURATION;
1287
+ const timer = setTimeout(() => {
1288
+ setChangedFiles((prev) => {
1289
+ if (!prev.has(filePath)) {
1290
+ return prev;
1291
+ }
1292
+ const next = new Map(prev);
1293
+ next.delete(filePath);
1294
+ return next;
1295
+ });
1296
+ timeoutMapRef.current.delete(filePath);
1297
+ }, durationMs);
1298
+ timeoutMapRef.current.set(filePath, timer);
1299
+ }, []);
1300
+ useEffect4(() => {
1301
+ return () => {
1302
+ for (const timer of timeoutMapRef.current.values()) {
1303
+ clearTimeout(timer);
1304
+ }
1305
+ timeoutMapRef.current.clear();
1306
+ };
1307
+ }, []);
1308
+ return {
1309
+ changedFiles,
1310
+ markChanged,
1311
+ getStatus: (filePath) => normalizeChangeStatus(changedFiles.get(filePath) || "")
1312
+ };
1313
+ }
1314
+
1315
+ // src/App.jsx
1316
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
1317
+ function setupCleanExit() {
1318
+ const onExit = () => {
1319
+ process.stdout.write("\n");
1320
+ };
1321
+ process.on("exit", onExit);
1322
+ process.on("SIGINT", () => {
1323
+ onExit();
1324
+ process.exit(0);
1325
+ });
1326
+ return () => {
1327
+ process.off("exit", onExit);
1328
+ process.off("SIGINT");
1329
+ };
1330
+ }
1331
+ function App({ rootPath, options = {}, version: version2 }) {
1332
+ const rootAbsPath = resolve3(rootPath);
1333
+ const iconSet = resolveIconSet({ cliIconSet: options.iconSet, noIcons: options.noIcons });
1334
+ const theme = getTheme(resolveThemeName({ cliTheme: options.theme }));
1335
+ const [selectedPath, setSelectedPath] = useState6(null);
1336
+ useEffect5(() => setupCleanExit(), []);
1337
+ const { tree, flatList, refreshFlatList, rebuildTree } = useTree(rootPath, options);
1338
+ const { statusMap, isGitRepo, refresh: refreshGit } = useGitStatus(
1339
+ rootPath,
1340
+ !options.noGit
1341
+ );
1342
+ const { cursor, viewportStart, setCursor } = useNavigation(
1343
+ flatList,
1344
+ rebuildTree,
1345
+ refreshFlatList
1346
+ );
1347
+ const cursorRef = useRef5(cursor);
1348
+ cursorRef.current = cursor;
1349
+ const { changedFiles, markChanged } = useChangedFiles();
1350
+ useMemo(() => {
1351
+ if (!tree) {
1352
+ return;
1353
+ }
1354
+ const gitStatuses = isGitRepo ? statusMap : /* @__PURE__ */ new Map();
1355
+ applyGitStatus(tree, gitStatuses);
1356
+ applyChangeIndicators(tree, gitStatuses, changedFiles);
1357
+ }, [statusMap, tree, isGitRepo, changedFiles]);
1358
+ const [termSize, setTermSize] = useState6({
1359
+ width: process.stdout.columns,
1360
+ height: process.stdout.rows
1361
+ });
1362
+ useEffect5(() => {
1363
+ const handleResize = () => {
1364
+ setTermSize({
1365
+ width: process.stdout.columns,
1366
+ height: process.stdout.rows
1367
+ });
1368
+ };
1369
+ process.stdout.on("resize", handleResize);
1370
+ return () => {
1371
+ process.stdout.off("resize", handleResize);
1372
+ };
1373
+ }, []);
1374
+ useEffect5(() => {
1375
+ if (flatList.length > 0 && cursor >= 0 && cursor < flatList.length) {
1376
+ const currentNode = flatList[cursor];
1377
+ if (currentNode) {
1378
+ setSelectedPath((prev) => prev === currentNode.path ? prev : currentNode.path);
1379
+ }
1380
+ }
1381
+ }, [cursor, flatList]);
1382
+ useEffect5(() => {
1383
+ if (!selectedPath || flatList.length === 0) {
1384
+ return;
1385
+ }
1386
+ const newIndex = flatList.findIndex((node) => node.path === selectedPath);
1387
+ if (newIndex === -1) {
1388
+ setCursor((prev) => Math.min(prev, flatList.length - 1));
1389
+ return;
1390
+ }
1391
+ if (newIndex !== cursorRef.current) {
1392
+ setCursor(newIndex);
1393
+ }
1394
+ }, [flatList, selectedPath, setCursor]);
1395
+ const toTreeRelativePath = useCallback5((watchPath) => {
1396
+ if (!watchPath) {
1397
+ return "";
1398
+ }
1399
+ const absolutePath = isAbsolute(watchPath) ? watchPath : resolve3(rootAbsPath, watchPath);
1400
+ const relativePath = relative3(rootAbsPath, absolutePath);
1401
+ if (!relativePath || relativePath.startsWith("..")) {
1402
+ return "";
1403
+ }
1404
+ return relativePath.split(sep3).join("/");
1405
+ }, [rootAbsPath]);
1406
+ const handleTreeChange = useCallback5((eventsOrEvent, watchPath) => {
1407
+ const batch = Array.isArray(eventsOrEvent) ? eventsOrEvent : [{ event: eventsOrEvent, path: watchPath }];
1408
+ for (const item of batch) {
1409
+ if (!item) continue;
1410
+ const changeStatus = mapWatcherEventToChangeStatus(item.event);
1411
+ const relativePath = toTreeRelativePath(item.path);
1412
+ if (changeStatus && relativePath) {
1413
+ markChanged(relativePath, changeStatus);
1414
+ }
1415
+ }
1416
+ rebuildTree(true);
1417
+ if (isGitRepo) {
1418
+ refreshGit();
1419
+ }
1420
+ }, [rebuildTree, isGitRepo, refreshGit, markChanged, toTreeRelativePath]);
1421
+ const { isWatching } = useWatcher(rootPath, handleTreeChange, {
1422
+ noWatch: options.noWatch
1423
+ });
1424
+ const helpEnabled = options.help !== false;
1425
+ const statusBarHeight = helpEnabled ? 2 : 1;
1426
+ const viewportHeight = termSize.height - statusBarHeight;
1427
+ const termWidth = termSize.width;
1428
+ const { fileCount, dirCount } = calculateCounts(flatList);
1429
+ const { summary: gitSummary } = calculateGitStatus(statusMap);
1430
+ return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", height: "100%", children: [
1431
+ /* @__PURE__ */ jsx4(Box4, { flexGrow: 1, children: /* @__PURE__ */ jsx4(
1432
+ TreeView_default,
1433
+ {
1434
+ flatList,
1435
+ cursor,
1436
+ viewportStart,
1437
+ viewportHeight,
1438
+ termWidth,
1439
+ iconSet,
1440
+ theme
1441
+ }
1442
+ ) }),
1443
+ /* @__PURE__ */ jsx4(
1444
+ StatusBar_default,
1445
+ {
1446
+ version: version2,
1447
+ rootPath,
1448
+ fileCount,
1449
+ dirCount,
1450
+ isWatching,
1451
+ gitEnabled: !options.noGit,
1452
+ isGitRepo,
1453
+ gitSummary,
1454
+ showHelp: helpEnabled,
1455
+ termWidth,
1456
+ iconSet,
1457
+ theme
1458
+ }
1459
+ )
1460
+ ] });
1461
+ }
1462
+
1463
+ // src/cli.js
1464
+ var __dirname = dirname(fileURLToPath(import.meta.url));
1465
+ var pkgPath = resolve4(__dirname, "../package.json");
1466
+ var { version } = JSON.parse(readFileSync2(pkgPath, "utf-8"));
1467
+ function printHelp() {
1468
+ console.log(`
1469
+ ftree - Terminal file explorer with git awareness and live updates
1470
+
1471
+ USAGE:
1472
+ ftree [path]
1473
+
1474
+ ARGUMENTS:
1475
+ [path] Path to directory to explore (default: current directory)
1476
+
1477
+ OPTIONS:
1478
+ -h, --help Print this help message
1479
+ -v, --version Print version information
1480
+ --no-git Disable git integration
1481
+ --no-icons Use ASCII icons (disable emoji/nerd icons)
1482
+ --no-watch Disable file watching
1483
+ --icon-set <emoji|nerd|ascii> Icon set (default: emoji). You can also set FTREE_ICON_SET.
1484
+ --theme <vscode> UI theme (default: vscode). You can also set FTREE_THEME.
1485
+
1486
+ EXAMPLES:
1487
+ ftree # Explore current directory
1488
+ ftree ~/projects # Explore specific directory
1489
+ ftree /path/to/project # Explore project path
1490
+
1491
+ EXIT CODES:
1492
+ 0 - Success
1493
+ 1 - Invalid path or error
1494
+ `);
1495
+ }
1496
+ function printVersion() {
1497
+ console.log(`ftree v${version}`);
1498
+ }
1499
+ function validatePath(path) {
1500
+ const resolvedPath = resolve4(path);
1501
+ if (!existsSync2(resolvedPath)) {
1502
+ return {
1503
+ valid: false,
1504
+ error: `Error: Path does not exist: ${resolvedPath}`
1505
+ };
1506
+ }
1507
+ try {
1508
+ const stats = statSync3(resolvedPath);
1509
+ if (!stats.isDirectory()) {
1510
+ return {
1511
+ valid: false,
1512
+ error: `Error: Path is not a directory: ${resolvedPath}`
1513
+ };
1514
+ }
1515
+ } catch (err) {
1516
+ return {
1517
+ valid: false,
1518
+ error: `Error: Cannot access path: ${resolvedPath} (${err.message})`
1519
+ };
1520
+ }
1521
+ try {
1522
+ readdirSync3(resolvedPath);
1523
+ } catch (err) {
1524
+ return {
1525
+ valid: false,
1526
+ error: `Error: Cannot read directory: ${resolvedPath} (${err.message})`
1527
+ };
1528
+ }
1529
+ return { valid: true };
1530
+ }
1531
+ function parseArgs() {
1532
+ const args = process.argv.slice(2);
1533
+ const result = {
1534
+ help: false,
1535
+ version: false,
1536
+ options: {
1537
+ noIcons: false,
1538
+ noGit: false,
1539
+ noWatch: false,
1540
+ help: true,
1541
+ // show help line by default
1542
+ iconSet: void 0,
1543
+ theme: void 0
1544
+ }
1545
+ };
1546
+ for (let i = 0; i < args.length; i++) {
1547
+ const arg = args[i];
1548
+ if (arg === "-h" || arg === "--help") {
1549
+ result.help = true;
1550
+ continue;
1551
+ }
1552
+ if (arg === "-v" || arg === "--version") {
1553
+ result.version = true;
1554
+ continue;
1555
+ }
1556
+ if (arg === "--no-git") {
1557
+ result.options.noGit = true;
1558
+ continue;
1559
+ }
1560
+ if (arg === "--no-icons") {
1561
+ result.options.noIcons = true;
1562
+ continue;
1563
+ }
1564
+ if (arg === "--no-watch") {
1565
+ result.options.noWatch = true;
1566
+ continue;
1567
+ }
1568
+ if (arg === "--no-help") {
1569
+ result.options.help = false;
1570
+ continue;
1571
+ }
1572
+ if (arg === "--icon-set" || arg.startsWith("--icon-set=")) {
1573
+ const value = arg.includes("=") ? arg.split("=")[1] : args[++i];
1574
+ if (!value) {
1575
+ console.error("Error: --icon-set requires a value (emoji|nerd|ascii)");
1576
+ process.exit(1);
1577
+ }
1578
+ result.options.iconSet = value;
1579
+ continue;
1580
+ }
1581
+ if (arg === "--theme" || arg.startsWith("--theme=")) {
1582
+ const value = arg.includes("=") ? arg.split("=")[1] : args[++i];
1583
+ if (!value) {
1584
+ console.error("Error: --theme requires a value");
1585
+ process.exit(1);
1586
+ }
1587
+ result.options.theme = value;
1588
+ continue;
1589
+ }
1590
+ if (!arg.startsWith("-")) {
1591
+ result.path = arg;
1592
+ continue;
1593
+ }
1594
+ console.error(`Error: Unknown option: ${arg}`);
1595
+ console.error("Use --help for usage information");
1596
+ process.exit(1);
1597
+ }
1598
+ return result;
1599
+ }
1600
+ function main() {
1601
+ const { path, help, version: version2, options } = parseArgs();
1602
+ if (help) {
1603
+ printHelp();
1604
+ process.exit(0);
1605
+ }
1606
+ if (version2) {
1607
+ printVersion();
1608
+ process.exit(0);
1609
+ }
1610
+ const targetPath = path || ".";
1611
+ const validation = validatePath(targetPath);
1612
+ if (!validation.valid) {
1613
+ console.error(validation.error);
1614
+ process.exit(1);
1615
+ }
1616
+ const resolvedPath = resolve4(targetPath);
1617
+ render(React5.createElement(App, { rootPath: resolvedPath, options, version: `v${version2}` }));
1618
+ }
1619
+ main();