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.
@@ -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
- // Recursively build subtree for directories (but not for symlinks to directories)
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 childOptions = { ...options, showHidden };
213
- const subtree = buildTree(
214
- entryAbsPath,
215
- childOptions,
216
- currentDepth + 1,
217
- ignoreFilter,
218
- finalBaseRootPath,
219
- );
220
- childNode.children = subtree.children;
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
- 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 = '';
305
+ // Status maps can contain both file and directory paths (pre-aggregated).
306
+ node.gitStatus = statusMap.get(node.path) || '';
296
307
 
297
- // First walk all children to populate their gitStatus
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
- 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
- }
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
- // Allow direct watcher events on directories (e.g., addDir)
362
- let aggregatedStatus = normalizeChangeStatus(watcherStatusMap.get(node.path) || '');
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
- const childStatus = walk(child);
367
- aggregatedStatus = mergeChangeStatus(aggregatedStatus, childStatus);
351
+ walk(child);
368
352
  }
369
353
  }
370
354
 
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;
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
  }
@@ -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);