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
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useTree - FileNode Tree Builder Hook
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities for building and managing a hierarchical file tree structure.
|
|
5
|
+
* This hook creates FileNode objects from the file system and handles tree traversal.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readdirSync, statSync, lstatSync, readlinkSync } from 'node:fs';
|
|
9
|
+
import { resolve, basename, relative, sep } from 'node:path';
|
|
10
|
+
import { useState, useCallback, useRef } from 'react';
|
|
11
|
+
import { DEFAULT_CONFIG } from '../lib/constants.js';
|
|
12
|
+
import {
|
|
13
|
+
mapGitStatusToChangeStatus,
|
|
14
|
+
mergeChangeStatus,
|
|
15
|
+
normalizeChangeStatus,
|
|
16
|
+
} from '../lib/changeStatus.js';
|
|
17
|
+
import { createIgnoreFilter } from './useIgnore.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Creates a FileNode object from file system entry information
|
|
21
|
+
* Handles permission errors and symlink detection
|
|
22
|
+
*
|
|
23
|
+
* @param {string} absPath - Absolute path to the file/directory
|
|
24
|
+
* @param {string} rootPath - Root directory path for relative path calculation
|
|
25
|
+
* @param {number} depth - Nesting level in the tree
|
|
26
|
+
* @param {boolean} isDir - Whether this is a directory
|
|
27
|
+
* @param {boolean} isSymlink - Whether this is a symbolic link
|
|
28
|
+
* @returns {FileNode} A FileNode object
|
|
29
|
+
*/
|
|
30
|
+
function createFileNode(absPath, rootPath, depth, isDir, isSymlink = false) {
|
|
31
|
+
let stats;
|
|
32
|
+
let targetPath = null;
|
|
33
|
+
let hasError = false;
|
|
34
|
+
let error = null;
|
|
35
|
+
|
|
36
|
+
// Handle permission errors
|
|
37
|
+
try {
|
|
38
|
+
// Use lstatSync for symlinks (doesn't follow them)
|
|
39
|
+
stats = isSymlink ? lstatSync(absPath) : statSync(absPath);
|
|
40
|
+
|
|
41
|
+
// For symlinks, try to read the target
|
|
42
|
+
if (isSymlink) {
|
|
43
|
+
try {
|
|
44
|
+
targetPath = readlinkSync(absPath);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// Can't read symlink target - mark as error but keep node
|
|
47
|
+
hasError = true;
|
|
48
|
+
error = 'Cannot read symlink';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {
|
|
52
|
+
if (e.code === 'EACCES' || e.code === 'EPERM') {
|
|
53
|
+
// Permission denied - create error node
|
|
54
|
+
return {
|
|
55
|
+
name: basename(absPath),
|
|
56
|
+
path: relative(rootPath, absPath).split(sep).join('/'),
|
|
57
|
+
absPath,
|
|
58
|
+
isDir,
|
|
59
|
+
children: null,
|
|
60
|
+
expanded: false,
|
|
61
|
+
depth,
|
|
62
|
+
size: 0,
|
|
63
|
+
mode: 0,
|
|
64
|
+
gitStatus: '',
|
|
65
|
+
changeStatus: '',
|
|
66
|
+
hasError: true,
|
|
67
|
+
error: 'Permission denied',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// Re-throw other errors
|
|
71
|
+
throw e;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
name: basename(absPath),
|
|
76
|
+
path: relative(rootPath, absPath).split(sep).join('/'),
|
|
77
|
+
absPath,
|
|
78
|
+
isDir,
|
|
79
|
+
children: isDir ? [] : null,
|
|
80
|
+
expanded: false,
|
|
81
|
+
depth,
|
|
82
|
+
size: stats.size,
|
|
83
|
+
mode: stats.mode,
|
|
84
|
+
gitStatus: '', // Will be populated by git status hook (T10)
|
|
85
|
+
changeStatus: '', // Combined A/M indicator from git + watcher states
|
|
86
|
+
isSymlink,
|
|
87
|
+
targetPath,
|
|
88
|
+
hasError,
|
|
89
|
+
error,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Compare function for sorting FileNodes
|
|
95
|
+
* Directories come first, then files, both sorted case-insensitively
|
|
96
|
+
*
|
|
97
|
+
* @param {FileNode} a - First node to compare
|
|
98
|
+
* @param {FileNode} b - Second node to compare
|
|
99
|
+
* @returns {number} Comparison result for sort
|
|
100
|
+
*/
|
|
101
|
+
function compareNodes(a, b) {
|
|
102
|
+
// Directories always come before files
|
|
103
|
+
if (a.isDir && !b.isDir) return -1;
|
|
104
|
+
if (!a.isDir && b.isDir) return 1;
|
|
105
|
+
|
|
106
|
+
// Within the same type, sort case-insensitively
|
|
107
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Recursively builds a FileNode tree from the file system
|
|
112
|
+
*
|
|
113
|
+
* @param {string} rootPath - Absolute path to the directory to scan (root on initial call)
|
|
114
|
+
* @param {object} options - Configuration options
|
|
115
|
+
* @param {number} [options.depth] - Maximum depth to traverse (default: from DEFAULT_CONFIG)
|
|
116
|
+
* @param {number} [currentDepth=0] - Current traversal depth (internal)
|
|
117
|
+
* @param {object} [ignoreFilter] - Ignore filter instance (internal)
|
|
118
|
+
* @param {string|null} [baseRootPath] - Absolute root path used for node.path (internal)
|
|
119
|
+
* @returns {FileNode} The root FileNode of the tree
|
|
120
|
+
*/
|
|
121
|
+
export function buildTree(rootPath, options = {}, currentDepth = 0, ignoreFilter = null, baseRootPath = null) {
|
|
122
|
+
const { depth = DEFAULT_CONFIG.maxDepth, showHidden = false } = options;
|
|
123
|
+
const finalBaseRootPath = baseRootPath || rootPath;
|
|
124
|
+
|
|
125
|
+
// Create ignore filter on initial call
|
|
126
|
+
if (ignoreFilter === null) {
|
|
127
|
+
ignoreFilter = createIgnoreFilter(finalBaseRootPath, options);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Create root node
|
|
131
|
+
const rootNode = createFileNode(rootPath, finalBaseRootPath, currentDepth, true, false);
|
|
132
|
+
rootNode.expanded = true; // Root always starts expanded
|
|
133
|
+
|
|
134
|
+
// Check if we've reached the depth limit
|
|
135
|
+
if (currentDepth >= depth) {
|
|
136
|
+
return rootNode;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
// Load .gitignore patterns for this directory
|
|
141
|
+
if (ignoreFilter.loadPatternsForDirectory) {
|
|
142
|
+
ignoreFilter.loadPatternsForDirectory(rootPath);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Read directory entries with file type information
|
|
146
|
+
const entries = readdirSync(rootPath, { withFileTypes: true });
|
|
147
|
+
|
|
148
|
+
// Process each entry
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
const entryName = entry.name;
|
|
151
|
+
const entryAbsPath = resolve(rootPath, entryName);
|
|
152
|
+
|
|
153
|
+
// Always hide .git directory (even if ignore filtering is disabled)
|
|
154
|
+
if (entryName === '.git') {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Skip hidden files if not showing them
|
|
159
|
+
if (!showHidden && entryName.startsWith('.')) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const isDir = entry.isDirectory();
|
|
164
|
+
const isSymlink = entry.isSymbolicLink();
|
|
165
|
+
|
|
166
|
+
// Calculate relative path for ignore filtering
|
|
167
|
+
const entryRelativePath = relative(finalBaseRootPath, entryAbsPath);
|
|
168
|
+
|
|
169
|
+
// Check if this entry should be ignored
|
|
170
|
+
if (ignoreFilter.shouldIgnore(entryRelativePath)) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Create child node with symlink detection
|
|
175
|
+
let childNode;
|
|
176
|
+
try {
|
|
177
|
+
childNode = createFileNode(
|
|
178
|
+
entryAbsPath,
|
|
179
|
+
finalBaseRootPath,
|
|
180
|
+
currentDepth + 1,
|
|
181
|
+
isDir,
|
|
182
|
+
isSymlink,
|
|
183
|
+
);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
// Handle permission errors for individual entries
|
|
186
|
+
if (e.code === 'EACCES' || e.code === 'EPERM') {
|
|
187
|
+
childNode = {
|
|
188
|
+
name: entryName,
|
|
189
|
+
path: relative(finalBaseRootPath, entryAbsPath).split(sep).join('/'),
|
|
190
|
+
absPath: entryAbsPath,
|
|
191
|
+
isDir,
|
|
192
|
+
children: null,
|
|
193
|
+
expanded: false,
|
|
194
|
+
depth: currentDepth + 1,
|
|
195
|
+
size: 0,
|
|
196
|
+
mode: 0,
|
|
197
|
+
gitStatus: '',
|
|
198
|
+
changeStatus: '',
|
|
199
|
+
hasError: true,
|
|
200
|
+
error: 'Permission denied',
|
|
201
|
+
isSymlink,
|
|
202
|
+
targetPath: null,
|
|
203
|
+
};
|
|
204
|
+
} else {
|
|
205
|
+
// For other errors, skip this entry
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Recursively build subtree for directories (but not for symlinks to directories)
|
|
211
|
+
if (isDir && !isSymlink) {
|
|
212
|
+
const childOptions = { ...options, showHidden };
|
|
213
|
+
const subtree = buildTree(
|
|
214
|
+
entryAbsPath,
|
|
215
|
+
childOptions,
|
|
216
|
+
currentDepth + 1,
|
|
217
|
+
ignoreFilter,
|
|
218
|
+
finalBaseRootPath,
|
|
219
|
+
);
|
|
220
|
+
childNode.children = subtree.children;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
rootNode.children.push(childNode);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Sort children: directories first, then case-insensitive alphabetical
|
|
227
|
+
rootNode.children.sort(compareNodes);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
// If we can't read a directory, handle permission errors
|
|
230
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') {
|
|
231
|
+
rootNode.hasError = true;
|
|
232
|
+
rootNode.error = 'Permission denied';
|
|
233
|
+
rootNode.children = [];
|
|
234
|
+
} else {
|
|
235
|
+
// For other errors, also leave children empty
|
|
236
|
+
rootNode.children = [];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return rootNode;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Flattens a FileNode tree into an array of visible nodes
|
|
245
|
+
* Only includes children of expanded directories for rendering
|
|
246
|
+
*
|
|
247
|
+
* @param {FileNode} root - The root FileNode of the tree
|
|
248
|
+
* @returns {FileNode[]} Array of visible FileNodes in traversal order
|
|
249
|
+
*/
|
|
250
|
+
export function flattenTree(root) {
|
|
251
|
+
const result = [];
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Recursive helper to traverse and collect visible nodes
|
|
255
|
+
* @param {FileNode} node - Current node being traversed
|
|
256
|
+
*/
|
|
257
|
+
function traverse(node) {
|
|
258
|
+
// Add current node to result
|
|
259
|
+
result.push(node);
|
|
260
|
+
|
|
261
|
+
// If node is expanded and has children, traverse them
|
|
262
|
+
if (node.expanded && node.children && node.children.length > 0) {
|
|
263
|
+
for (const child of node.children) {
|
|
264
|
+
traverse(child);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
traverse(root);
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Applies git status from statusMap to tree nodes
|
|
275
|
+
* Propagates git status through the tree with aggregation for directories
|
|
276
|
+
*
|
|
277
|
+
* Priority order for aggregation (highest to lowest):
|
|
278
|
+
* D (deleted) > M (modified) > A (added) > U (untracked) > R (renamed) > clean
|
|
279
|
+
*
|
|
280
|
+
* @param {FileNode} root - The root FileNode of the tree
|
|
281
|
+
* @param {Map<string, string>} statusMap - Map of file paths to git status codes
|
|
282
|
+
* @returns {FileNode} The modified root node with gitStatus set
|
|
283
|
+
*/
|
|
284
|
+
export function applyGitStatus(root, statusMap) {
|
|
285
|
+
/**
|
|
286
|
+
* Walk the tree recursively and set gitStatus on nodes
|
|
287
|
+
* @param {FileNode} node - Current node being processed
|
|
288
|
+
*/
|
|
289
|
+
function walk(node) {
|
|
290
|
+
if (!node.isDir) {
|
|
291
|
+
// File: lookup in statusMap, default to empty string (clean)
|
|
292
|
+
node.gitStatus = statusMap.get(node.path) || '';
|
|
293
|
+
} else {
|
|
294
|
+
// Directory: aggregate from children
|
|
295
|
+
let aggregatedStatus = '';
|
|
296
|
+
|
|
297
|
+
// First walk all children to populate their gitStatus
|
|
298
|
+
for (const child of node.children) {
|
|
299
|
+
walk(child);
|
|
300
|
+
|
|
301
|
+
// Aggregate child status with priority ordering
|
|
302
|
+
const childStatus = child.gitStatus;
|
|
303
|
+
|
|
304
|
+
if (childStatus === 'D') {
|
|
305
|
+
// Deleted has highest priority
|
|
306
|
+
aggregatedStatus = 'D';
|
|
307
|
+
} else if (childStatus === 'M' && aggregatedStatus !== 'D') {
|
|
308
|
+
// Modified has second priority
|
|
309
|
+
aggregatedStatus = 'M';
|
|
310
|
+
} else if (childStatus === 'A' && aggregatedStatus !== 'D' && aggregatedStatus !== 'M') {
|
|
311
|
+
// Added has third priority
|
|
312
|
+
aggregatedStatus = 'A';
|
|
313
|
+
} else if (childStatus === 'U' && !aggregatedStatus) {
|
|
314
|
+
// Untracked (no status yet)
|
|
315
|
+
aggregatedStatus = 'U';
|
|
316
|
+
} else if (childStatus === 'R' && !aggregatedStatus) {
|
|
317
|
+
// Renamed (no status yet)
|
|
318
|
+
aggregatedStatus = 'R';
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
node.gitStatus = aggregatedStatus;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
walk(root);
|
|
327
|
+
return root;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Applies combined added/modified indicators to tree nodes.
|
|
332
|
+
* Uses git + watcher status sources and propagates to directories.
|
|
333
|
+
*
|
|
334
|
+
* Priority order: M > A > clean
|
|
335
|
+
*
|
|
336
|
+
* @param {FileNode} root - The root FileNode of the tree
|
|
337
|
+
* @param {Map<string, string>} gitStatusMap - Map of git statuses by path
|
|
338
|
+
* @param {Map<string, string>} watcherStatusMap - Map of watcher statuses by path
|
|
339
|
+
* @returns {FileNode} The modified root node with changeStatus set
|
|
340
|
+
*/
|
|
341
|
+
export function applyChangeIndicators(root, gitStatusMap = new Map(), watcherStatusMap = new Map()) {
|
|
342
|
+
/**
|
|
343
|
+
* Walk the tree recursively and set changeStatus on nodes.
|
|
344
|
+
*
|
|
345
|
+
* @param {FileNode} node - Current node being processed
|
|
346
|
+
* @returns {''|'A'|'M'} Node indicator status
|
|
347
|
+
*/
|
|
348
|
+
function walk(node) {
|
|
349
|
+
if (!node.isDir) {
|
|
350
|
+
const gitChangeStatus = mapGitStatusToChangeStatus(
|
|
351
|
+
gitStatusMap.get(node.path) || node.gitStatus || ''
|
|
352
|
+
);
|
|
353
|
+
const watcherChangeStatus = normalizeChangeStatus(
|
|
354
|
+
watcherStatusMap.get(node.path) || ''
|
|
355
|
+
);
|
|
356
|
+
const fileChangeStatus = mergeChangeStatus(gitChangeStatus, watcherChangeStatus);
|
|
357
|
+
node.changeStatus = fileChangeStatus;
|
|
358
|
+
return fileChangeStatus;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Allow direct watcher events on directories (e.g., addDir)
|
|
362
|
+
let aggregatedStatus = normalizeChangeStatus(watcherStatusMap.get(node.path) || '');
|
|
363
|
+
|
|
364
|
+
if (node.children && node.children.length > 0) {
|
|
365
|
+
for (const child of node.children) {
|
|
366
|
+
const childStatus = walk(child);
|
|
367
|
+
aggregatedStatus = mergeChangeStatus(aggregatedStatus, childStatus);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Never show the indicator on the root "parent" folder (depth 0).
|
|
372
|
+
const finalStatus = node.depth === 0 ? '' : aggregatedStatus;
|
|
373
|
+
node.changeStatus = finalStatus;
|
|
374
|
+
return finalStatus;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
walk(root);
|
|
378
|
+
return root;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Merge expanded state from old tree into new tree
|
|
383
|
+
* This preserves the expand/collapse state across rebuilds
|
|
384
|
+
*
|
|
385
|
+
* @param {FileNode} oldTree - The old tree structure before rebuild
|
|
386
|
+
* @param {FileNode} newTree - The newly built tree structure
|
|
387
|
+
* @returns {FileNode} The new tree with expanded states applied
|
|
388
|
+
*/
|
|
389
|
+
export function mergeExpandState(oldTree, newTree) {
|
|
390
|
+
if (!oldTree || !newTree) return newTree;
|
|
391
|
+
|
|
392
|
+
// Build a map of expanded paths from old tree
|
|
393
|
+
const expandedPaths = new Set();
|
|
394
|
+
|
|
395
|
+
function collectExpanded(node) {
|
|
396
|
+
if (node.isDir && node.expanded) {
|
|
397
|
+
expandedPaths.add(node.path);
|
|
398
|
+
}
|
|
399
|
+
if (node.children) {
|
|
400
|
+
for (const child of node.children) {
|
|
401
|
+
collectExpanded(child);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
collectExpanded(oldTree);
|
|
407
|
+
|
|
408
|
+
// Apply expanded state to new tree
|
|
409
|
+
function applyExpanded(node) {
|
|
410
|
+
if (node.isDir && expandedPaths.has(node.path)) {
|
|
411
|
+
node.expanded = true;
|
|
412
|
+
}
|
|
413
|
+
if (node.children) {
|
|
414
|
+
for (const child of node.children) {
|
|
415
|
+
applyExpanded(child);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
applyExpanded(newTree);
|
|
421
|
+
return newTree;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* React hook for building and managing a file tree
|
|
426
|
+
* Provides state management and rebuild functionality for the file tree
|
|
427
|
+
*
|
|
428
|
+
* @param {string} rootPath - Root directory path to explore
|
|
429
|
+
* @param {object} options - Configuration options
|
|
430
|
+
* @returns {{tree: FileNode, flatList: FileNode[], refreshFlatList: Function, rebuildTree: Function, getTree: Function}} The tree structure and tree management functions
|
|
431
|
+
*/
|
|
432
|
+
export function useTree(rootPath, options = {}) {
|
|
433
|
+
const absPath = resolve(rootPath);
|
|
434
|
+
|
|
435
|
+
// Initial tree build
|
|
436
|
+
const [tree, setTree] = useState(() => buildTree(absPath, options));
|
|
437
|
+
|
|
438
|
+
// Keep ref to current tree to avoid closure staleness
|
|
439
|
+
const treeRef = useRef(tree);
|
|
440
|
+
treeRef.current = tree;
|
|
441
|
+
|
|
442
|
+
// Flattened list for rendering
|
|
443
|
+
const [flatList, setFlatList] = useState(() => flattenTree(tree));
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Refresh only the flattened visible list from the current tree state.
|
|
447
|
+
* Useful for UI-only changes (expand/collapse) without filesystem rescans.
|
|
448
|
+
*/
|
|
449
|
+
const refreshFlatList = useCallback(() => {
|
|
450
|
+
const currentTree = treeRef.current;
|
|
451
|
+
if (!currentTree) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
setFlatList(flattenTree(currentTree));
|
|
455
|
+
}, []);
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Rebuild the tree, optionally preserving expanded states
|
|
459
|
+
* This is called when:
|
|
460
|
+
* - Filesystem state changes (watcher events)
|
|
461
|
+
* - User refreshes with 'r' key (rebuilds fresh from filesystem)
|
|
462
|
+
* - Any operation that needs a full rescan from disk
|
|
463
|
+
*
|
|
464
|
+
* @param {boolean} preserveState - Whether to preserve expanded states
|
|
465
|
+
*/
|
|
466
|
+
const rebuildTree = useCallback((preserveState = true) => {
|
|
467
|
+
// Get current tree from ref (always fresh)
|
|
468
|
+
const oldTree = treeRef.current;
|
|
469
|
+
|
|
470
|
+
// Rebuild tree
|
|
471
|
+
const newTree = buildTree(absPath, options);
|
|
472
|
+
|
|
473
|
+
// Merge expanded state if requested and old tree exists
|
|
474
|
+
const mergedTree = preserveState && oldTree
|
|
475
|
+
? mergeExpandState(oldTree, newTree)
|
|
476
|
+
: newTree;
|
|
477
|
+
|
|
478
|
+
// DEBUG: Log what we're doing
|
|
479
|
+
if (process.env.FTREE_DEBUG) {
|
|
480
|
+
const flat = flattenTree(mergedTree);
|
|
481
|
+
console.log(`[DEBUG] rebuildTree: BEFORE setTree, flatList has ${flat.length} items`);
|
|
482
|
+
console.log(`[DEBUG] rebuildTree: root has ${mergedTree.children?.length || 0} direct children`);
|
|
483
|
+
console.log(`[DEBUG] rebuildTree: newTree root has ${newTree.children?.length || 0} direct children`);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
setTree(mergedTree);
|
|
487
|
+
setFlatList(flattenTree(mergedTree));
|
|
488
|
+
|
|
489
|
+
// DEBUG: Log after setTree/setFlatList
|
|
490
|
+
if (process.env.FTREE_DEBUG) {
|
|
491
|
+
console.log(`[DEBUG] rebuildTree: AFTER setTree/setFlatList, flatList state updated`);
|
|
492
|
+
}
|
|
493
|
+
}, [absPath, options]);
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Get the current tree state
|
|
497
|
+
* Useful for external code to read the tree before rebuild
|
|
498
|
+
*/
|
|
499
|
+
const getTree = useCallback(() => treeRef.current, []);
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
tree,
|
|
503
|
+
flatList,
|
|
504
|
+
refreshFlatList,
|
|
505
|
+
rebuildTree,
|
|
506
|
+
getTree,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useWatcher - File System Watcher Hook
|
|
3
|
+
*
|
|
4
|
+
* Provides file system watching capabilities using chokidar.
|
|
5
|
+
* Detects file changes, additions, and deletions with debouncing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useState, useRef } from 'react';
|
|
9
|
+
import chokidar from 'chokidar';
|
|
10
|
+
import { DEFAULT_CONFIG } from '../lib/constants.js';
|
|
11
|
+
|
|
12
|
+
function shouldUsePolling(rootPath) {
|
|
13
|
+
// Allow override via env var for tricky filesystems (WSL /mnt, network drives, etc.)
|
|
14
|
+
const envOverride = process.env.FTREE_USE_POLLING;
|
|
15
|
+
if (envOverride === '1' || envOverride === 'true') {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
if (envOverride === '0' || envOverride === 'false') {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// WSL often needs polling for /mnt/* paths.
|
|
23
|
+
const isWsl = process.platform === 'linux' && (process.env.WSL_INTEROP || process.env.WSL_DISTRO_NAME);
|
|
24
|
+
if (isWsl && typeof rootPath === 'string' && rootPath.startsWith('/mnt/')) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useWatcher(rootPath, onTreeChange, options = {}) {
|
|
32
|
+
const debounceMs = options.debounceMs ?? DEFAULT_CONFIG.debounceDelay;
|
|
33
|
+
const [isWatching, setIsWatching] = useState(false);
|
|
34
|
+
const watcherRef = useRef(null);
|
|
35
|
+
const onTreeChangeRef = useRef(onTreeChange);
|
|
36
|
+
const mountedRef = useRef(true);
|
|
37
|
+
const debounceTimerRef = useRef(null);
|
|
38
|
+
const pendingChangesRef = useRef(new Map()); // path -> event
|
|
39
|
+
const sawAnyEventRef = useRef(false);
|
|
40
|
+
|
|
41
|
+
// Keep callback ref in sync
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
onTreeChangeRef.current = onTreeChange;
|
|
44
|
+
}, [onTreeChange]);
|
|
45
|
+
|
|
46
|
+
// Setup and teardown watcher
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
mountedRef.current = true;
|
|
49
|
+
|
|
50
|
+
if (options.noWatch || !rootPath) {
|
|
51
|
+
setIsWatching(false);
|
|
52
|
+
return () => {
|
|
53
|
+
mountedRef.current = false;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const usePolling = shouldUsePolling(rootPath);
|
|
58
|
+
const watcher = chokidar.watch(rootPath, {
|
|
59
|
+
ignored: /(^|[\/\\])\.\./,
|
|
60
|
+
persistent: true,
|
|
61
|
+
ignoreInitial: true,
|
|
62
|
+
followSymlinks: false,
|
|
63
|
+
...(usePolling ? { usePolling: true, interval: 300 } : {}),
|
|
64
|
+
});
|
|
65
|
+
watcherRef.current = watcher;
|
|
66
|
+
|
|
67
|
+
const flushPendingChange = () => {
|
|
68
|
+
debounceTimerRef.current = null;
|
|
69
|
+
const hadAnyEvent = sawAnyEventRef.current;
|
|
70
|
+
const pendingMap = pendingChangesRef.current;
|
|
71
|
+
sawAnyEventRef.current = false;
|
|
72
|
+
pendingChangesRef.current = new Map();
|
|
73
|
+
|
|
74
|
+
if (hadAnyEvent && mountedRef.current && onTreeChangeRef.current) {
|
|
75
|
+
const batch = Array.from(pendingMap.entries()).map(([path, event]) => ({ event, path }));
|
|
76
|
+
onTreeChangeRef.current(batch);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
watcher.on('all', (event, path) => {
|
|
81
|
+
sawAnyEventRef.current = true;
|
|
82
|
+
|
|
83
|
+
// Keep a best-effort batch of "change indicator relevant" events.
|
|
84
|
+
// Prefer add/addDir over change for the same path within the debounce window.
|
|
85
|
+
if (event === 'add' || event === 'addDir') {
|
|
86
|
+
pendingChangesRef.current.set(path, event);
|
|
87
|
+
} else if (event === 'change') {
|
|
88
|
+
if (!pendingChangesRef.current.has(path)) {
|
|
89
|
+
pendingChangesRef.current.set(path, event);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (debounceTimerRef.current) {
|
|
94
|
+
clearTimeout(debounceTimerRef.current);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
debounceTimerRef.current = setTimeout(flushPendingChange, debounceMs);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
watcher.on('error', () => {
|
|
101
|
+
setIsWatching(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
watcher.on('ready', () => {
|
|
105
|
+
setIsWatching(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
mountedRef.current = false;
|
|
110
|
+
|
|
111
|
+
if (debounceTimerRef.current) {
|
|
112
|
+
clearTimeout(debounceTimerRef.current);
|
|
113
|
+
debounceTimerRef.current = null;
|
|
114
|
+
}
|
|
115
|
+
pendingChangesRef.current = new Map();
|
|
116
|
+
sawAnyEventRef.current = false;
|
|
117
|
+
|
|
118
|
+
if (watcherRef.current) {
|
|
119
|
+
watcherRef.current.close();
|
|
120
|
+
watcherRef.current = null;
|
|
121
|
+
}
|
|
122
|
+
setIsWatching(false);
|
|
123
|
+
};
|
|
124
|
+
}, [rootPath, options.noWatch, debounceMs]);
|
|
125
|
+
|
|
126
|
+
return { isWatching };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Only default export to avoid conflicts with bundling
|
package/src/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ftree - Main entry point re-export
|
|
3
|
+
*
|
|
4
|
+
* This module re-exports the public API for the ftree library.
|
|
5
|
+
* When imported programmatically, it provides access to the core components.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Re-export CLI for programmatic usage
|
|
9
|
+
export { default as CLI } from './cli.js';
|
|
10
|
+
|
|
11
|
+
// Re-export constants for use in components
|
|
12
|
+
export * from './lib/constants.js';
|
|
13
|
+
|
|
14
|
+
// TODO: Export TreeView component when implemented in T2
|
|
15
|
+
// export { TreeView } from './components/TreeView.jsx';
|
|
16
|
+
|
|
17
|
+
// Export icon and color utilities
|
|
18
|
+
export * from './lib/icons.js';
|
|
19
|
+
|
|
20
|
+
// TODO: Export core utilities when implemented
|
|
21
|
+
// export * from './lib/scanner.js';
|
|
22
|
+
// export * from './lib/git.js';
|