cd /news/developer-tools/how-i-built-a-virtual-folder-tree-fr… · home topics developer-tools article
[ARTICLE · art-115557] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer built Logical Folders, a VSCode and IntelliJ plugin that displays flat filenames as a virtual directory tree without moving files on disk. The plugin uses a parsePath function to split filenames on a separator (default underscore) and constructs a virtual hierarchy, with an inverse function to create new files. It aims to help developers navigate projects where AI agents generate many flat files.

read5 min views1 publishedAug 30, 2026

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:

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:

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

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

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).

── more in #developer-tools 4 stories · sorted by recency
── more on @logical folders 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-i-built-a-virtua…] indexed:0 read:5min 2026-08-30 ·