Skip to content

perf: optimize file tree loading performance#151

Merged
bbsngg merged 2 commits intomainfrom
perf/file-tree-loading-optimization
Apr 9, 2026
Merged

perf: optimize file tree loading performance#151
bbsngg merged 2 commits intomainfrom
perf/file-tree-loading-optimization

Conversation

@bbsngg
Copy link
Copy Markdown
Contributor

@bbsngg bbsngg commented Apr 9, 2026

Summary

  • Parallelize stat() calls in getFileTree() — replaced sequential for...of + await with Promise.all(), yielding 5-20x speedup for large directories
  • Reduce initial traversal depth from 10 to 2 with lazy loading on directory expand — initial API response is near-instant, deeper directories load on demand
  • Add search debounce (200ms) and batch setExpandedDirs updates — eliminates jank and redundant re-renders during typing
  • Support metadata=false API param — simple view mode skips all stat() calls entirely, making traversal purely readdir-based
  • Prevent depth regression for consumers needing full tree (useFileMentions, ResearchLab, useSurveyData) by passing explicit maxDepth: 10

Test plan

  • Open a project with 1000+ files, verify file tree loads significantly faster
  • Expand deeply nested directories, confirm lazy loading works with spinner indicator
  • Switch between simple/detailed/compact view modes
  • Type in the file search box, confirm no jank and results appear after debounce
  • Use @ file mentions in chat, confirm deeply nested files still appear
  • Open Research Lab, confirm file-based features still work correctly
  • Check browser DevTools Network tab — FileTree should use maxDepth=2, other consumers maxDepth=10

🤖 Generated with Claude Code

@bbsngg bbsngg requested a review from liuyixin-louis April 9, 2026 02:25
Copy link
Copy Markdown
Collaborator

@Zhang-Henry Zhang-Henry left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: perf: optimize file tree loading performance

Overall a solid performance optimization PR with clear wins. Here is the detailed review.

👍 Highlights

  1. Promise.all parallelization — Converting sequential for...of + await stat() to Promise.all(filteredEntries.map(...)) is the correct approach and the single biggest perf win.
  2. Default depth 2 + lazy loading — Good trade-off. Initial load is fast; deeper content loads on demand.
  3. Search debounce + batched setExpandedDirs — Replacing per-item setExpandedDirs calls inside a loop with a single batch update (collectMatches → one setExpandedDirs) eliminates N re-renders. Nice catch.
  4. metadata=false skip-stat path — Skipping all stat() calls in simple view mode is a clean optimization.
  5. Explicit maxDepth: 10 for full-tree consumers — Prevents regression in useFileMentions, ResearchLab, useSurveyData.

🔴 Must Fix

1. Search misses unloaded directories (medium severity)

With maxDepth: 2, files deeper than 2 levels are not in the files state until lazy-loaded. filterFiles only searches the loaded tree, so search will silently miss deeply nested files. The old behavior (depth 10) returned nearly everything, so search was complete. Now there is a silent gap.

Suggestions:

  • When the search box is active, fetch with a higher maxDepth (or use a dedicated server-side search endpoint).
  • At minimum, indicate this limitation in the UI (e.g., "showing results from loaded directories only").

2. View mode switch does not re-fetch metadata (medium severity)

fetchFiles passes metadata: viewMode !== 'simple'. But if the user starts in "simple" mode (no metadata) then switches to "detailed" or "compact", the file items lack size, modified, permissions. I do not see a re-fetch triggered by viewMode changes — fetchFiles appears to only run on selectedProject changes. This would cause undefined values in detailed/compact views.

Fix: either re-fetch when viewMode changes, or always include metadata and simply skip rendering it in simple mode.


🟡 Suggestions

3. children === null vs undefined convention (low severity)

The backend now sets item.children = null for not-yet-loaded directories. The frontend checks item.children === null correctly in toggleDirectory, and the render conditions (item.children && item.children.length > 0) handle null as falsy. This works, but consider making the convention explicit at the type level to avoid future confusion.

4. Unbounded parallelism in Promise.all (low severity)

For directories with thousands of entries, Promise.all fires all stat() calls simultaneously. On macOS this is usually fine (high fd limits), but on constrained environments it could hit EMFILE. Consider batching (e.g., p-limit or chunked Promise.all) if this ever becomes an issue. Not urgent.

5. Loading spinner duplication (nit)

The loading spinner block is duplicated identically across renderFileTree, renderDetailedView, and renderCompactView (3 copies). Consider extracting a small helper component.


Verdict

Approve with minor changes — Fix #1 (search accuracy) and #2 (view mode switch), the rest are nice-to-haves.

@bbsngg
Copy link
Copy Markdown
Contributor Author

bbsngg commented Apr 9, 2026

Thanks for the detailed review @Zhang-Henry! All issues have been addressed in e9f17ce:

🔴 Must Fix:

  1. Search misses unloaded directories — Fixed. When the search box is active, the component now fetches the full tree (maxDepth=10) in the background and filters against it. The cached full tree is cleared on project switch or when search is cleared, so normal browsing still benefits from the shallow initial load.

  2. View mode switch missing metadata — Fixed. Removed the metadata=false optimization from fetchFiles entirely — metadata is now always included. The simple view just doesn't render it. This avoids the complexity of re-fetching on view mode changes.

🟡 Suggestions:

  1. Noted — will revisit when we add TypeScript types to this component.
  2. Agreed p-limit is a good safeguard. Keeping as-is for now since macOS fd limits are high enough, but will add if we see EMFILE in the wild.
  3. Fixed. Extracted the duplicated spinner into a shared renderDirChildren(item, level, renderFn) helper used by all 3 view modes.

bbsngg and others added 2 commits April 9, 2026 11:28
… search debounce

File tree loading was slow for large projects due to sequential stat() calls,
deep recursive traversal, and unoptimized frontend rendering. This commit
addresses multiple bottlenecks:

- Parallelize stat() calls using Promise.all() instead of sequential awaits
- Reduce default maxDepth from 10 to 2 with on-demand lazy loading on expand
- Add 200ms search debounce and batch expandedDirs state updates
- Support metadata=false API param to skip stat() in simple view mode
- Explicitly pass maxDepth: 10 for consumers needing full tree (mentions, research lab)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tract spinner

- Search now fetches full tree (maxDepth=10) when search box is active,
  so deeply nested files are no longer silently missed
- Removed metadata=false optimization from fetchFiles to avoid missing
  fields when switching from simple to detailed/compact view
- Extracted duplicated loading spinner into shared renderDirChildren helper
- Clear fullTreeForSearch cache on project switch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@bbsngg bbsngg force-pushed the perf/file-tree-loading-optimization branch from e9f17ce to e5396d6 Compare April 9, 2026 15:28
@bbsngg bbsngg merged commit 2c4c005 into main Apr 9, 2026
1 check passed
@bbsngg bbsngg deleted the perf/file-tree-loading-optimization branch April 9, 2026 15:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants