Skip to content

fix(editor): render file tree from normalized children#2113

Open
Drakaniia wants to merge 1 commit into
generalaction:mainfrom
Drakaniia:finish-collapsible-file-explorer
Open

fix(editor): render file tree from normalized children#2113
Drakaniia wants to merge 1 commit into
generalaction:mainfrom
Drakaniia:finish-collapsible-file-explorer

Conversation

@Drakaniia
Copy link
Copy Markdown

@Drakaniia Drakaniia commented May 19, 2026

File tree QA Fixes #1952.

Summary

  • Refactor the task editor file tree to use a normalized hierarchical node model with stable POSIX paths and per-folder children.
  • Keep initial file loading root-only, with directory contents loaded lazily on expansion or reveal.
  • Normalize local filesystem relative paths on Windows so nested files do not render as duplicated root-level wrappers.

Root Cause

The renderer tree was backed by a flat parent index and Windows local file paths could arrive with backslashes. That allowed nested entries such as src\routes\+page.svelte to be treated as a single root identity instead of src -> routes -> +page.svelte, producing repeated folder/file-looking rows.

Implementation

  • Add children to FileNode and derive visible rows from rootNodes by walking each expanded directory's own children.
  • Normalize file-tree paths before storing, loading, revealing, adding, removing, or processing watch events.
  • Preserve task-scoped expansion state through existing expandedPaths snapshots.
  • Update LocalFileSystem.relPath() to return POSIX-style relative paths.
  • Add focused unit tests for path normalization, collapsed/expanded visibility, directory-first sorting, leaf file behavior, lazy loading, and reveal loading.

Validation

  • pnpm run format passed
  • pnpm run lint passed
  • pnpm run typecheck passed
  • pnpm exec vitest run --project node src/renderer/features/tasks/editor/stores/files-store.test.ts src/renderer/features/tasks/editor/stores/files-store-utils.test.ts src/main/core/fs/impl/local-fs.test.ts passed: 61 tests
  • Electron rendered QA passed: root-only initially, src expands to direct children, routes expands to direct children, and no duplicated folder/file wrappers appeared.

Notes

  • pnpm run test still has unrelated existing Windows/environment-sensitive failures in migration fixture paths, MCP config path separator expectations, dependency probe mocks, user env mocks, PTY env, and worktree path assertions.

@Drakaniia Drakaniia marked this pull request as ready for review May 19, 2026 12:13
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 19, 2026

Greptile Summary

This PR fixes the Windows file-tree duplication bug (#1952) by replacing the flat childIndex map with a proper per-node children array, switching from recursive eager loading to single-level lazy loading, and normalising all paths to POSIX separators at every ingress point.

  • FileNode gains a children: FileNode[] field; buildVisibleRows and FilesStore now walk the tree hierarchically instead of via a separate index, eliminating the root-wrapper duplication caused by backslash-delimited paths.
  • LocalFileSystem.relPath() and every public/private entry point in FilesStore now call normalizeFileTreePath before storing or querying any path, closing the backslash-identity gap on Windows.
  • Directory contents are loaded lazily on expand or reveal, with _applyEntries reconciling server state against existing children (adding new, removing stale) each time a directory is loaded.

Confidence Score: 4/5

Safe to merge for the targeted Windows path-duplication fix; the lazy-loading refactor is well-tested and the reconciliation logic is sound for normal usage.

The path normalisation and hierarchical children model work correctly for all documented scenarios. Two narrower issues exist in _addNode: when a directory is swapped for a same-name file without going through watch-event delete/create, the former children linger in _nodes and _loadedPaths, which could prevent a later same-name directory from being lazily loaded; and sorting runs once per insertion inside _applyEntries rather than once at the end, producing unnecessary quadratic work for large directory listings. Neither affects the primary fix path.

src/renderer/features/tasks/editor/stores/files-store.ts — specifically the _addNode method's type-change branch and the per-insertion _sortChildren call inside _applyEntries.

Important Files Changed

Filename Overview
src/renderer/features/tasks/editor/stores/files-store.ts Core store refactored from flat childIndex to per-node children arrays with lazy loading; two issues found: orphaned _nodes/_loadedPaths on directory→file type change, and O(N²) sorting inside _applyEntries.
src/renderer/features/tasks/editor/stores/files-store-utils.ts Clean refactor: normalizeFileTreePath, makeNode (now sets children:[]), sortFileNodes, and buildVisibleRows all look correct; logic is simpler and better tested than before.
src/shared/fs.ts Only adds the required children: FileNode[] field to the FileNode interface; no issues.
src/main/core/fs/impl/local-fs.ts Single-line fix: appends .replace(/\\/g, '/') to relPath() to normalise Windows backslashes to POSIX; correct and minimal.
src/renderer/features/tasks/editor/editor-file-tree.tsx Consumer updated to pass files.rootNodes instead of files.nodes + files.childIndex; no other callers of the old signature remain in this file.
src/renderer/features/tasks/editor/stores/files-store.test.ts New integration tests cover root-only initial load, lazy child loading, Windows-path normalisation, and revealFile ancestor expansion; all scenarios are meaningful.
src/renderer/features/tasks/editor/stores/files-store-utils.test.ts Unit tests for path normalisation, visibility derivation, sibling sorting, and leaf-file non-expandability are all well-structured and cover the key logic paths.

Sequence Diagram

sequenceDiagram
    participant UI as EditorFileTree
    participant Store as FilesStore
    participant Utils as files-store-utils
    participant RPC as rpc.fs

    UI->>Store: tree.load() → _fetchAll()
    Store->>RPC: "listFiles(projectId, workspaceId, '.', {recursive:false})"
    RPC-->>Store: entries (root level only)
    Store->>Store: _applyEntries('', entries)
    Store-->>UI: "tree.data = {nodes, rootNodes}"
    UI->>Utils: buildVisibleRows(rootNodes, expandedPaths)
    Utils-->>UI: visible rows (collapsed dirs hide children)

    UI->>Store: loadDir('src') on expand
    Store->>RPC: "listFiles(projectId, workspaceId, 'src', {recursive:false})"
    RPC-->>Store: entries (src/ children)
    Store->>Store: _applyEntries('src', entries) reconcile
    Store-->>UI: tree.data updated → re-render

    UI->>Store: revealFile('src/a/b.ts', expandedPaths)
    Store->>RPC: listFiles(..., 'src', ...)
    Store->>RPC: listFiles(..., 'src/a', ...)
    Store->>Store: expandedPaths.add ancestors
    Store-->>UI: tree updated with ancestors expanded
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
src/renderer/features/tasks/editor/stores/files-store.ts:265-282
**Orphaned children in `_nodes` / `_loadedPaths` on directory→file type change**

When `_addNode` is called with a `file` node for a path that previously existed as a loaded `directory`, it clears `existing.children = []` but never removes those child nodes from `this._nodes` or `this._loadedPaths`. After the type-change, stale subtree entries (e.g. `src/components/Button.tsx`) remain in `_nodes`, and `_loadedPaths` still contains the directory path. Two concrete consequences: (1) any subsequent `loadDir('src/components')` call returns early because `_loadedPaths.has('src/components')` is still `true`, and (2) incoming watch-`create` events for the old children are silently dropped because `_nodes.has(path)` is still `true`. The fix is to call `_removeNodeFromMaps` on the existing node before overwriting it when the type changes from `directory` to `file`.

### Issue 2 of 2
src/renderer/features/tasks/editor/stores/files-store.ts:277-281
**Quadratic sorting inside `_applyEntries`**

`_addNode` calls `_sortChildren` on every insertion, so loading N entries for the same parent triggers N sort passes, each O(k log k) on a growing array — O(N² log N) total. The explicit `_sortChildren(parent)` at the end of `_applyEntries` is already sufficient to produce a correctly-ordered result; the intermediate sorts only keep the array sorted during the loop, which provides no observable benefit here. Deferring sorting to `_addNode` callers that actually care about order (i.e. `_applyWatchEventsInternal`) resolves the quadratic behaviour.

```suggestion
    this._nodes.set(node.path, node);
    const parent = node.parentPath ? this._nodes.get(node.parentPath) : null;
    const siblings = parent?.children ?? this._rootNodes;
    if (!siblings.some((child) => child.path === node.path)) siblings.push(node);
```

Reviews (1): Last reviewed commit: "fix(editor): render file tree from norma..." | Re-trigger Greptile

Comment on lines 265 to 282
private _addNode(node: FileNode): void {
this._nodes.set(node.path, node);
const parent = node.parentPath;
const existing = this._childIndex.get(parent) ?? [];
if (!existing.includes(node.path)) {
this._childIndex.set(parent, sortedChildPaths([...existing, node.path], this._nodes));
const existing = this._nodes.get(node.path);
if (existing) {
existing.type = node.type;
existing.mtime = node.mtime;
existing.extension = node.extension;
existing.isHidden = node.isHidden;
if (node.type === 'file') existing.children = [];
this._sortChildren(existing.parentPath ? this._nodes.get(existing.parentPath) : null);
return;
}

this._nodes.set(node.path, node);
const parent = node.parentPath ? this._nodes.get(node.parentPath) : null;
const siblings = parent?.children ?? this._rootNodes;
if (!siblings.some((child) => child.path === node.path)) siblings.push(node);
this._sortChildren(parent);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Orphaned children in _nodes / _loadedPaths on directory→file type change

When _addNode is called with a file node for a path that previously existed as a loaded directory, it clears existing.children = [] but never removes those child nodes from this._nodes or this._loadedPaths. After the type-change, stale subtree entries (e.g. src/components/Button.tsx) remain in _nodes, and _loadedPaths still contains the directory path. Two concrete consequences: (1) any subsequent loadDir('src/components') call returns early because _loadedPaths.has('src/components') is still true, and (2) incoming watch-create events for the old children are silently dropped because _nodes.has(path) is still true. The fix is to call _removeNodeFromMaps on the existing node before overwriting it when the type changes from directory to file.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/renderer/features/tasks/editor/stores/files-store.ts
Line: 265-282

Comment:
**Orphaned children in `_nodes` / `_loadedPaths` on directory→file type change**

When `_addNode` is called with a `file` node for a path that previously existed as a loaded `directory`, it clears `existing.children = []` but never removes those child nodes from `this._nodes` or `this._loadedPaths`. After the type-change, stale subtree entries (e.g. `src/components/Button.tsx`) remain in `_nodes`, and `_loadedPaths` still contains the directory path. Two concrete consequences: (1) any subsequent `loadDir('src/components')` call returns early because `_loadedPaths.has('src/components')` is still `true`, and (2) incoming watch-`create` events for the old children are silently dropped because `_nodes.has(path)` is still `true`. The fix is to call `_removeNodeFromMaps` on the existing node before overwriting it when the type changes from `directory` to `file`.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +277 to +281
this._nodes.set(node.path, node);
const parent = node.parentPath ? this._nodes.get(node.parentPath) : null;
const siblings = parent?.children ?? this._rootNodes;
if (!siblings.some((child) => child.path === node.path)) siblings.push(node);
this._sortChildren(parent);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Quadratic sorting inside _applyEntries

_addNode calls _sortChildren on every insertion, so loading N entries for the same parent triggers N sort passes, each O(k log k) on a growing array — O(N² log N) total. The explicit _sortChildren(parent) at the end of _applyEntries is already sufficient to produce a correctly-ordered result; the intermediate sorts only keep the array sorted during the loop, which provides no observable benefit here. Deferring sorting to _addNode callers that actually care about order (i.e. _applyWatchEventsInternal) resolves the quadratic behaviour.

Suggested change
this._nodes.set(node.path, node);
const parent = node.parentPath ? this._nodes.get(node.parentPath) : null;
const siblings = parent?.children ?? this._rootNodes;
if (!siblings.some((child) => child.path === node.path)) siblings.push(node);
this._sortChildren(parent);
this._nodes.set(node.path, node);
const parent = node.parentPath ? this._nodes.get(node.parentPath) : null;
const siblings = parent?.children ?? this._rootNodes;
if (!siblings.some((child) => child.path === node.path)) siblings.push(node);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/renderer/features/tasks/editor/stores/files-store.ts
Line: 277-281

Comment:
**Quadratic sorting inside `_applyEntries`**

`_addNode` calls `_sortChildren` on every insertion, so loading N entries for the same parent triggers N sort passes, each O(k log k) on a growing array — O(N² log N) total. The explicit `_sortChildren(parent)` at the end of `_applyEntries` is already sufficient to produce a correctly-ordered result; the intermediate sorts only keep the array sorted during the loop, which provides no observable benefit here. Deferring sorting to `_addNode` callers that actually care about order (i.e. `_applyWatchEventsInternal`) resolves the quadratic behaviour.

```suggestion
    this._nodes.set(node.path, node);
    const parent = node.parentPath ? this._nodes.get(node.parentPath) : null;
    const siblings = parent?.children ?? this._rootNodes;
    if (!siblings.some((child) => child.path === node.path)) siblings.push(node);
```

How can I resolve this? If you propose a fix, please make it concise.

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.

[feat]: add collapsible folder tree to file explorer

1 participant