ftreeview 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -17
- package/dist/cli.js +311 -153
- package/package.json +2 -2
- package/src/App.jsx +28 -12
- package/src/cli.js +10 -10
- package/src/components/StatusBar.jsx +2 -2
- package/src/hooks/useChangedFiles.js +50 -27
- package/src/hooks/useGitStatus.js +47 -0
- package/src/hooks/useIgnore.js +139 -182
- package/src/hooks/useNavigation.js +12 -4
- package/src/hooks/useTree.js +178 -61
- package/src/hooks/useWatcher.js +11 -2
package/src/hooks/useTree.js
CHANGED
|
@@ -57,6 +57,7 @@ function createFileNode(absPath, rootPath, depth, isDir, isSymlink = false) {
|
|
|
57
57
|
absPath,
|
|
58
58
|
isDir,
|
|
59
59
|
children: null,
|
|
60
|
+
childrenLoaded: false,
|
|
60
61
|
expanded: false,
|
|
61
62
|
depth,
|
|
62
63
|
size: 0,
|
|
@@ -77,6 +78,7 @@ function createFileNode(absPath, rootPath, depth, isDir, isSymlink = false) {
|
|
|
77
78
|
absPath,
|
|
78
79
|
isDir,
|
|
79
80
|
children: isDir ? [] : null,
|
|
81
|
+
childrenLoaded: isDir ? false : undefined,
|
|
80
82
|
expanded: false,
|
|
81
83
|
depth,
|
|
82
84
|
size: stats.size,
|
|
@@ -116,9 +118,10 @@ function compareNodes(a, b) {
|
|
|
116
118
|
* @param {number} [currentDepth=0] - Current traversal depth (internal)
|
|
117
119
|
* @param {object} [ignoreFilter] - Ignore filter instance (internal)
|
|
118
120
|
* @param {string|null} [baseRootPath] - Absolute root path used for node.path (internal)
|
|
121
|
+
* @param {Set<string>|null} [expandedPaths] - Directory paths to pre-load (internal)
|
|
119
122
|
* @returns {FileNode} The root FileNode of the tree
|
|
120
123
|
*/
|
|
121
|
-
export function buildTree(rootPath, options = {}, currentDepth = 0, ignoreFilter = null, baseRootPath = null) {
|
|
124
|
+
export function buildTree(rootPath, options = {}, currentDepth = 0, ignoreFilter = null, baseRootPath = null, expandedPaths = null) {
|
|
122
125
|
const { depth = DEFAULT_CONFIG.maxDepth, showHidden = false } = options;
|
|
123
126
|
const finalBaseRootPath = baseRootPath || rootPath;
|
|
124
127
|
|
|
@@ -136,6 +139,9 @@ export function buildTree(rootPath, options = {}, currentDepth = 0, ignoreFilter
|
|
|
136
139
|
return rootNode;
|
|
137
140
|
}
|
|
138
141
|
|
|
142
|
+
// Root directory children are always loaded (we're scanning it now).
|
|
143
|
+
rootNode.childrenLoaded = true;
|
|
144
|
+
|
|
139
145
|
try {
|
|
140
146
|
// Load .gitignore patterns for this directory
|
|
141
147
|
if (ignoreFilter.loadPatternsForDirectory) {
|
|
@@ -207,17 +213,26 @@ export function buildTree(rootPath, options = {}, currentDepth = 0, ignoreFilter
|
|
|
207
213
|
}
|
|
208
214
|
}
|
|
209
215
|
|
|
210
|
-
//
|
|
216
|
+
// Lazy-load subtrees:
|
|
217
|
+
// - Always load the current directory's children
|
|
218
|
+
// - Only recurse into subdirectories that are already expanded (from a preserved state rebuild)
|
|
211
219
|
if (isDir && !isSymlink) {
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
220
|
+
const shouldRecurse = expandedPaths && expandedPaths.has(childNode.path);
|
|
221
|
+
if (shouldRecurse) {
|
|
222
|
+
const childOptions = { ...options, showHidden };
|
|
223
|
+
const subtree = buildTree(
|
|
224
|
+
entryAbsPath,
|
|
225
|
+
childOptions,
|
|
226
|
+
currentDepth + 1,
|
|
227
|
+
ignoreFilter,
|
|
228
|
+
finalBaseRootPath,
|
|
229
|
+
expandedPaths,
|
|
230
|
+
);
|
|
231
|
+
childNode.children = subtree.children;
|
|
232
|
+
childNode.childrenLoaded = true;
|
|
233
|
+
} else {
|
|
234
|
+
childNode.childrenLoaded = false;
|
|
235
|
+
}
|
|
221
236
|
}
|
|
222
237
|
|
|
223
238
|
rootNode.children.push(childNode);
|
|
@@ -287,39 +302,13 @@ export function applyGitStatus(root, statusMap) {
|
|
|
287
302
|
* @param {FileNode} node - Current node being processed
|
|
288
303
|
*/
|
|
289
304
|
function walk(node) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
node.gitStatus = statusMap.get(node.path) || '';
|
|
293
|
-
} else {
|
|
294
|
-
// Directory: aggregate from children
|
|
295
|
-
let aggregatedStatus = '';
|
|
305
|
+
// Status maps can contain both file and directory paths (pre-aggregated).
|
|
306
|
+
node.gitStatus = statusMap.get(node.path) || '';
|
|
296
307
|
|
|
297
|
-
|
|
308
|
+
if (node.children && node.children.length > 0) {
|
|
298
309
|
for (const child of node.children) {
|
|
299
310
|
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
311
|
}
|
|
321
|
-
|
|
322
|
-
node.gitStatus = aggregatedStatus;
|
|
323
312
|
}
|
|
324
313
|
}
|
|
325
314
|
|
|
@@ -346,32 +335,24 @@ export function applyChangeIndicators(root, gitStatusMap = new Map(), watcherSta
|
|
|
346
335
|
* @returns {''|'A'|'M'} Node indicator status
|
|
347
336
|
*/
|
|
348
337
|
function walk(node) {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const fileChangeStatus = mergeChangeStatus(gitChangeStatus, watcherChangeStatus);
|
|
357
|
-
node.changeStatus = fileChangeStatus;
|
|
358
|
-
return fileChangeStatus;
|
|
359
|
-
}
|
|
338
|
+
const gitChangeStatus = mapGitStatusToChangeStatus(
|
|
339
|
+
gitStatusMap.get(node.path) || node.gitStatus || ''
|
|
340
|
+
);
|
|
341
|
+
const watcherChangeStatus = normalizeChangeStatus(
|
|
342
|
+
watcherStatusMap.get(node.path) || ''
|
|
343
|
+
);
|
|
344
|
+
const merged = mergeChangeStatus(gitChangeStatus, watcherChangeStatus);
|
|
360
345
|
|
|
361
|
-
//
|
|
362
|
-
|
|
346
|
+
// Never show the indicator on the root "parent" folder (depth 0).
|
|
347
|
+
node.changeStatus = node.depth === 0 ? '' : merged;
|
|
363
348
|
|
|
364
349
|
if (node.children && node.children.length > 0) {
|
|
365
350
|
for (const child of node.children) {
|
|
366
|
-
|
|
367
|
-
aggregatedStatus = mergeChangeStatus(aggregatedStatus, childStatus);
|
|
351
|
+
walk(child);
|
|
368
352
|
}
|
|
369
353
|
}
|
|
370
354
|
|
|
371
|
-
|
|
372
|
-
const finalStatus = node.depth === 0 ? '' : aggregatedStatus;
|
|
373
|
-
node.changeStatus = finalStatus;
|
|
374
|
-
return finalStatus;
|
|
355
|
+
return node.changeStatus;
|
|
375
356
|
}
|
|
376
357
|
|
|
377
358
|
walk(root);
|
|
@@ -431,9 +412,13 @@ export function mergeExpandState(oldTree, newTree) {
|
|
|
431
412
|
*/
|
|
432
413
|
export function useTree(rootPath, options = {}) {
|
|
433
414
|
const absPath = resolve(rootPath);
|
|
415
|
+
const ignoreFilterRef = useRef(null);
|
|
416
|
+
if (!ignoreFilterRef.current) {
|
|
417
|
+
ignoreFilterRef.current = createIgnoreFilter(absPath, options);
|
|
418
|
+
}
|
|
434
419
|
|
|
435
420
|
// Initial tree build
|
|
436
|
-
const [tree, setTree] = useState(() => buildTree(absPath, options));
|
|
421
|
+
const [tree, setTree] = useState(() => buildTree(absPath, options, 0, ignoreFilterRef.current));
|
|
437
422
|
|
|
438
423
|
// Keep ref to current tree to avoid closure staleness
|
|
439
424
|
const treeRef = useRef(tree);
|
|
@@ -467,8 +452,25 @@ export function useTree(rootPath, options = {}) {
|
|
|
467
452
|
// Get current tree from ref (always fresh)
|
|
468
453
|
const oldTree = treeRef.current;
|
|
469
454
|
|
|
455
|
+
// Collect expanded directory paths so rebuilds only load expanded subtrees.
|
|
456
|
+
let expandedPaths = null;
|
|
457
|
+
if (preserveState && oldTree) {
|
|
458
|
+
expandedPaths = new Set();
|
|
459
|
+
(function collectExpanded(node) {
|
|
460
|
+
if (!node) return;
|
|
461
|
+
if (node.isDir && node.expanded) {
|
|
462
|
+
expandedPaths.add(node.path);
|
|
463
|
+
}
|
|
464
|
+
if (node.children) {
|
|
465
|
+
for (const child of node.children) {
|
|
466
|
+
collectExpanded(child);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
})(oldTree);
|
|
470
|
+
}
|
|
471
|
+
|
|
470
472
|
// Rebuild tree
|
|
471
|
-
const newTree = buildTree(absPath, options);
|
|
473
|
+
const newTree = buildTree(absPath, options, 0, ignoreFilterRef.current, null, expandedPaths);
|
|
472
474
|
|
|
473
475
|
// Merge expanded state if requested and old tree exists
|
|
474
476
|
const mergedTree = preserveState && oldTree
|
|
@@ -492,6 +494,120 @@ export function useTree(rootPath, options = {}) {
|
|
|
492
494
|
}
|
|
493
495
|
}, [absPath, options]);
|
|
494
496
|
|
|
497
|
+
/**
|
|
498
|
+
* Ensure a directory node has its immediate children loaded.
|
|
499
|
+
* This enables fast startup (root-only scan) while still allowing on-demand expansion.
|
|
500
|
+
*
|
|
501
|
+
* @param {FileNode} node - Directory node to load
|
|
502
|
+
* @returns {boolean} True if a load occurred
|
|
503
|
+
*/
|
|
504
|
+
const ensureChildrenLoaded = useCallback((node) => {
|
|
505
|
+
if (!node || !node.isDir || node.childrenLoaded) {
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const { depth = DEFAULT_CONFIG.maxDepth, showHidden = false } = options;
|
|
510
|
+
if (node.depth >= depth) {
|
|
511
|
+
node.childrenLoaded = true;
|
|
512
|
+
node.children = [];
|
|
513
|
+
setTree(prev => ({ ...prev }));
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const ignoreFilter = ignoreFilterRef.current || createIgnoreFilter(absPath, options);
|
|
518
|
+
ignoreFilterRef.current = ignoreFilter;
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
if (ignoreFilter.loadPatternsForDirectory) {
|
|
522
|
+
ignoreFilter.loadPatternsForDirectory(node.absPath);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const entries = readdirSync(node.absPath, { withFileTypes: true });
|
|
526
|
+
const children = [];
|
|
527
|
+
|
|
528
|
+
for (const entry of entries) {
|
|
529
|
+
const entryName = entry.name;
|
|
530
|
+
const entryAbsPath = resolve(node.absPath, entryName);
|
|
531
|
+
|
|
532
|
+
if (entryName === '.git') {
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (!showHidden && entryName.startsWith('.')) {
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const isDir = entry.isDirectory();
|
|
540
|
+
const isSymlink = entry.isSymbolicLink();
|
|
541
|
+
const entryRelativePath = relative(absPath, entryAbsPath);
|
|
542
|
+
|
|
543
|
+
if (ignoreFilter.shouldIgnore(entryRelativePath)) {
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
let childNode;
|
|
548
|
+
try {
|
|
549
|
+
childNode = createFileNode(
|
|
550
|
+
entryAbsPath,
|
|
551
|
+
absPath,
|
|
552
|
+
node.depth + 1,
|
|
553
|
+
isDir,
|
|
554
|
+
isSymlink,
|
|
555
|
+
);
|
|
556
|
+
} catch (e) {
|
|
557
|
+
if (e.code === 'EACCES' || e.code === 'EPERM') {
|
|
558
|
+
childNode = {
|
|
559
|
+
name: entryName,
|
|
560
|
+
path: relative(absPath, entryAbsPath).split(sep).join('/'),
|
|
561
|
+
absPath: entryAbsPath,
|
|
562
|
+
isDir,
|
|
563
|
+
children: null,
|
|
564
|
+
childrenLoaded: false,
|
|
565
|
+
expanded: false,
|
|
566
|
+
depth: node.depth + 1,
|
|
567
|
+
size: 0,
|
|
568
|
+
mode: 0,
|
|
569
|
+
gitStatus: '',
|
|
570
|
+
changeStatus: '',
|
|
571
|
+
hasError: true,
|
|
572
|
+
error: 'Permission denied',
|
|
573
|
+
isSymlink,
|
|
574
|
+
targetPath: null,
|
|
575
|
+
};
|
|
576
|
+
} else {
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (isDir && !isSymlink) {
|
|
582
|
+
childNode.children = [];
|
|
583
|
+
childNode.childrenLoaded = false;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
children.push(childNode);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
children.sort(compareNodes);
|
|
590
|
+
node.children = children;
|
|
591
|
+
node.childrenLoaded = true;
|
|
592
|
+
node.hasError = false;
|
|
593
|
+
node.error = null;
|
|
594
|
+
} catch (error) {
|
|
595
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') {
|
|
596
|
+
node.hasError = true;
|
|
597
|
+
node.error = 'Permission denied';
|
|
598
|
+
} else {
|
|
599
|
+
node.hasError = true;
|
|
600
|
+
node.error = 'Cannot read directory';
|
|
601
|
+
}
|
|
602
|
+
node.children = [];
|
|
603
|
+
node.childrenLoaded = true;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Change the root object reference so App's memoized decorators re-run.
|
|
607
|
+
setTree(prev => ({ ...prev }));
|
|
608
|
+
return true;
|
|
609
|
+
}, [absPath, options]);
|
|
610
|
+
|
|
495
611
|
/**
|
|
496
612
|
* Get the current tree state
|
|
497
613
|
* Useful for external code to read the tree before rebuild
|
|
@@ -504,5 +620,6 @@ export function useTree(rootPath, options = {}) {
|
|
|
504
620
|
refreshFlatList,
|
|
505
621
|
rebuildTree,
|
|
506
622
|
getTree,
|
|
623
|
+
ensureChildrenLoaded,
|
|
507
624
|
};
|
|
508
625
|
}
|
package/src/hooks/useWatcher.js
CHANGED
|
@@ -37,6 +37,7 @@ export function useWatcher(rootPath, onTreeChange, options = {}) {
|
|
|
37
37
|
const debounceTimerRef = useRef(null);
|
|
38
38
|
const pendingChangesRef = useRef(new Map()); // path -> event
|
|
39
39
|
const sawAnyEventRef = useRef(false);
|
|
40
|
+
const ignoredPathRef = useRef((watchPath) => /(^|[\/\\])\.\./.test(watchPath) || /(^|[\/\\])\.git([\/\\]|$)/.test(watchPath));
|
|
40
41
|
|
|
41
42
|
// Keep callback ref in sync
|
|
42
43
|
useEffect(() => {
|
|
@@ -56,7 +57,7 @@ export function useWatcher(rootPath, onTreeChange, options = {}) {
|
|
|
56
57
|
|
|
57
58
|
const usePolling = shouldUsePolling(rootPath);
|
|
58
59
|
const watcher = chokidar.watch(rootPath, {
|
|
59
|
-
ignored:
|
|
60
|
+
ignored: ignoredPathRef.current,
|
|
60
61
|
persistent: true,
|
|
61
62
|
ignoreInitial: true,
|
|
62
63
|
followSymlinks: false,
|
|
@@ -78,12 +79,20 @@ export function useWatcher(rootPath, onTreeChange, options = {}) {
|
|
|
78
79
|
};
|
|
79
80
|
|
|
80
81
|
watcher.on('all', (event, path) => {
|
|
82
|
+
if (ignoredPathRef.current(path)) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
81
85
|
sawAnyEventRef.current = true;
|
|
82
86
|
|
|
83
87
|
// Keep a best-effort batch of "change indicator relevant" events.
|
|
84
|
-
// Prefer add/addDir over change for the same path within the debounce window.
|
|
88
|
+
// Prefer add/addDir over unlink/unlinkDir over change for the same path within the debounce window.
|
|
85
89
|
if (event === 'add' || event === 'addDir') {
|
|
86
90
|
pendingChangesRef.current.set(path, event);
|
|
91
|
+
} else if (event === 'unlink' || event === 'unlinkDir') {
|
|
92
|
+
const existing = pendingChangesRef.current.get(path);
|
|
93
|
+
if (existing !== 'add' && existing !== 'addDir') {
|
|
94
|
+
pendingChangesRef.current.set(path, event);
|
|
95
|
+
}
|
|
87
96
|
} else if (event === 'change') {
|
|
88
97
|
if (!pendingChangesRef.current.has(path)) {
|
|
89
98
|
pendingChangesRef.current.set(path, event);
|