DEV Community

CBT Tools
CBT Tools

Posted on

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

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
Enter fullscreen mode Exit fullscreen mode

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

The core: 30 lines

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];
}
Enter fullscreen mode Exit fullscreen mode

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.

Building the virtual tree

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;
}
Enter fullscreen mode Exit fullscreen mode

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)!;
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

The separator problem

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.

Physical directories pass through

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

File operations work on the flat file

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

Right-click auth/login/ 鈫?New File 鈫?type handler.ts:

  • Segments: ["auth", "login", "handler.ts"]
  • Flat name: auth_login_handler.ts (segments joined with _)
  • Created on disk in the same physical directory as the other files

Rename handler.ts to middleware.ts:

  • Old flat name: auth_login_handler.ts
  • New flat name: auth_login_middleware.ts
  • fs.rename on the physical file

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

Why not just use real folders?

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.

Install

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

Top comments (0)