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/LICENSE +21 -0
- package/README.md +235 -0
- package/dist/cli.js +1619 -0
- package/package.json +58 -0
- package/src/App.jsx +243 -0
- package/src/cli.js +228 -0
- package/src/components/StatusBar.jsx +270 -0
- package/src/components/TreeLine.jsx +190 -0
- package/src/components/TreeView.jsx +129 -0
- package/src/hooks/useChangedFiles.js +82 -0
- package/src/hooks/useGitStatus.js +347 -0
- package/src/hooks/useIgnore.js +182 -0
- package/src/hooks/useNavigation.js +247 -0
- package/src/hooks/useTree.js +508 -0
- package/src/hooks/useWatcher.js +129 -0
- package/src/index.js +22 -0
- package/src/lib/changeStatus.js +79 -0
- package/src/lib/connectors.js +233 -0
- package/src/lib/constants.js +64 -0
- package/src/lib/icons.js +658 -0
- package/src/lib/theme.js +102 -0
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();
|