# How I Built a Virtual Folder Tree from Flat Filenames — No Files Moved, No Symlinks, Just 300 Lines of TypeScript

> Source: <https://dev.to/473185670/how-i-built-a-virtual-folder-tree-from-flat-filenames-no-files-moved-no-symlinks-just-300-lines-4gil>
> Published: 2026-08-30 05:38:58+00:00

I let an AI agent write code in my project for a week. By Friday there were 340 files in one directory.

`auth_login_handler.ts`

. `auth_login_session.ts`

. `utils_helpers.ts`

. `config_env.ts`

. All flat. All in the root.

The agent loved it. No path ambiguity, no directory hops, no "which folder was that in?" 鈥?every file one `read_file`

call away. Flat is the agent's native habitat.

I hated it. Scrolling through 340 files looking for the one auth handler I needed. My brain doesn't grep.

So I built **Logical Folders** 鈥?a VSCode and IntelliJ plugin that displays flat files as a virtual directory tree. The files don't move. Nothing changes on disk. The hierarchy is purely visual.

```
On disk:                    In the tree:
auth_login_handler.ts       auth/
auth_login_session.ts         login/
utils_helpers.ts                handler.ts
config_env.ts                   session.ts
                            utils/
                              helpers.ts
                            config/
                              env.ts
```

The agent keeps its flat playground. I get my tree.

The whole thing hinges on one function 鈥?`parsePath`

. Split a filename on a separator, keep the extension glued to the last segment:

``` js
export function parsePath(relPath: string, sep: string): string[] {
    const parts = relPath.split(path.sep);
    const filename = parts.pop()!;

    // Dotfiles (.gitignore, .env) never split 鈥?returned as-is
    if (!sep || filename.startsWith('.') || !filename.includes(sep)) {
        return [...parts, filename];
    }

    const ext = path.extname(filename);
    const base = ext ? filename.slice(0, -ext.length) : filename;
    const segs = base.split(sep);

    if (ext && segs.length > 0) {
        segs[segs.length - 1] += ext;
    }

    return [...parts, ...segs];
}
```

`auth_login_handler.ts`

with `_`

鈫?`["auth", "login", "handler.ts"]`

. That's it. The extension stays on the last segment so `handler.ts`

doesn't become `handler`

+ `.ts`

as a fake folder.

The inverse is `constructFlatName`

鈥?join segments with the separator, reattach the extension. When you right-click `auth/login/`

and create `handler.ts`

, the plugin joins them back to `auth_login_handler.ts`

and writes the flat file.

The tree is a lie. The disk is the truth.

VSCode's `TreeDataProvider`

interface needs `getChildren(element?)`

. I scan the workspace once, parse every file path, and insert into a tree of `LogicalNode`

objects:

``` js
private async buildTree(): Promise<LogicalNode> {
    const cfg = vscode.workspace.getConfiguration('logicalFolders');
    const separator = cfg.get<string>('separator', '_');
    const exclude = cfg.get<string[]>('exclude', []);
    const maxFiles = cfg.get<number>('maxFiles', 10000);

    const folder = vscode.workspace.workspaceFolders?.[0];
    const rootPath = folder?.uri.fsPath ?? '';

    const root = new LogicalNode('', undefined, rootPath, [], new Map(), ...);
    if (!folder) return root;

    const excludePattern = exclude.length > 0 ? `{${exclude.join(',')}}` : null;
    const files = await vscode.workspace.findFiles('**/*', excludePattern, maxFiles);

    for (const uri of files) {
        const rel = path.relative(rootPath, uri.fsPath);
        const segments = parsePath(rel, separator);
        this.insert(root, segments, uri.fsPath);
    }
    return root;
}
```

`vscode.workspace.findFiles`

respects glob exclude patterns. The `maxFiles`

cap (default 10,000) is a performance guard 鈥?I learned the hard way that scanning a monorepo with 50k files freezes the tree for two seconds.

The `insert`

method walks the tree along the parsed segments, creating virtual folder nodes as needed:

```
private insert(root: LogicalNode, segments: string[], physical: string): void {
    let current = root;
    for (let i = 0; i < segments.length; i++) {
        const seg = segments[i];
        const isLeaf = i === segments.length - 1;

        if (!current.children.has(seg)) {
            current.children.set(seg, new LogicalNode(
                seg,
                isLeaf ? physical : undefined,      // file nodes get a real path
                isLeaf ? path.dirname(physical) : current.physicalDir,
                segments.slice(0, i + 1),
                new Map(),
                isLeaf
                    ? vscode.TreeItemCollapsibleState.None
                    : vscode.TreeItemCollapsibleState.Collapsed
            ));
        }
        current = current.children.get(seg)!;
    }
}
```

Folder nodes have `physicalPath = undefined`

. File nodes point to the real file on disk. When you click a file, it opens the actual file 鈥?no symlink, no redirect. The `resourceUri`

is set to the real path, so VSCode's built-in operations (git diff, search, go-to-definition) all work normally.

Default is `_`

鈥?the convention AI agents use when writing flat files. But different teams use different conventions:

| Separator | Flat file | Logical tree |
|---|---|---|
`_` (default) |
`auth_login_handler.ts` |
`auth/ > login/ > handler.ts` |
`__` |
`auth__login__handler.ts` |
`auth/ > login/ > handler.ts` |
`-` |
`auth-login-handler.ts` |
`auth/ > login/ > handler.ts` |
`.` |
`auth.login.handler.ts` |
`auth/ > login/ > handler.ts` |
`::` |
`auth::login::handler.ts` |
`auth/ > login/ > handler.ts` |

The `.`

separator has a footgun: `auth.login.handler.test.ts`

splits to `auth/ > login/ > handler/ > test.ts`

鈥?the `.test`

part becomes a virtual folder. Use `_`

or `-`

if your test files have dots in the name. I documented this in the README rather than trying to be clever about it.

Dotfiles (`.gitignore`

, `.env`

) are never split. They start with a dot, so the first check in `parsePath`

bails out and returns them as-is.

If you have a real `src/`

directory AND a flat `src_auth.ts`

file, both appear under `src/`

in the tree. The plugin doesn't force everything flat 鈥?it merges physical and logical. This matters because most real projects are a mix: some directories are real (created by humans), some files are flat (created by agents).

Create, rename, delete 鈥?all operate on the physical flat file, not the virtual tree.

Right-click `auth/login/`

鈫?New File 鈫?type `handler.ts`

:

`["auth", "login", "handler.ts"]`

`auth_login_handler.ts`

(segments joined with `_`

)Rename `handler.ts`

to `middleware.ts`

:

`auth_login_handler.ts`

`auth_login_middleware.ts`

`fs.rename`

on the physical fileThe tree refreshes (500ms debounce on the file watcher) and shows the new structure.

Because the agent breaks them.

I tried organizing the 340 files into real directories. The agent immediately flattened them again on the next edit 鈥?it resolves paths, writes flat, and doesn't preserve directory structure. Every `mkdir`

+ `mv`

was undone within minutes.

Symlinks? The agent follows them, resolves the real path, and writes to the flat location. Same problem.

The only structure the agent respects is the filename itself. So I made the structure live in the filename 鈥?and built a viewer that reads it back.

**VSCode** 鈥?search "Logical Folders" in the extensions panel, or:

[Marketplace link](https://marketplace.visualstudio.com/items?itemName=alexcoledev.logical-folders)

**IntelliJ IDEA** 鈥?pending JetBrains review (plugin ID 33928), live within 2 business days at:

`plugins.jetbrains.com/plugin/33928-logical-folders`

**Source**: [github.com/alexcoledev/logical-folders](https://github.com/alexcoledev/logical-folders)

The VSCode extension is ~300 lines of TypeScript. The IntelliJ plugin is Kotlin 鈥?same algorithm, different tree API. No runtime dependencies beyond the editor SDK.

Config: `logicalFolders.separator`

(default `_`

), `logicalFolders.exclude`

(default `["**/node_modules/**", "**/.git/**", "**/out/**", "**/dist/**"]`

), `logicalFolders.maxFiles`

(default 10000).
