{"slug": "how-i-built-a-virtual-folder-tree-from-flat-filenames-no-files-moved-no-symlinks", "title": "How I Built a Virtual Folder Tree from Flat Filenames — No Files Moved, No Symlinks, Just 300 Lines of TypeScript", "summary": "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.", "body_md": "I let an AI agent write code in my project for a week. By Friday there were 340 files in one directory.\n\n`auth_login_handler.ts`\n\n. `auth_login_session.ts`\n\n. `utils_helpers.ts`\n\n. `config_env.ts`\n\n. All flat. All in the root.\n\nThe agent loved it. No path ambiguity, no directory hops, no \"which folder was that in?\" 鈥?every file one `read_file`\n\ncall away. Flat is the agent's native habitat.\n\nI hated it. Scrolling through 340 files looking for the one auth handler I needed. My brain doesn't grep.\n\nSo 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.\n\n```\nOn disk:                    In the tree:\nauth_login_handler.ts       auth/\nauth_login_session.ts         login/\nutils_helpers.ts                handler.ts\nconfig_env.ts                   session.ts\n                            utils/\n                              helpers.ts\n                            config/\n                              env.ts\n```\n\nThe agent keeps its flat playground. I get my tree.\n\nThe whole thing hinges on one function 鈥?`parsePath`\n\n. Split a filename on a separator, keep the extension glued to the last segment:\n\n``` js\nexport function parsePath(relPath: string, sep: string): string[] {\n    const parts = relPath.split(path.sep);\n    const filename = parts.pop()!;\n\n    // Dotfiles (.gitignore, .env) never split 鈥?returned as-is\n    if (!sep || filename.startsWith('.') || !filename.includes(sep)) {\n        return [...parts, filename];\n    }\n\n    const ext = path.extname(filename);\n    const base = ext ? filename.slice(0, -ext.length) : filename;\n    const segs = base.split(sep);\n\n    if (ext && segs.length > 0) {\n        segs[segs.length - 1] += ext;\n    }\n\n    return [...parts, ...segs];\n}\n```\n\n`auth_login_handler.ts`\n\nwith `_`\n\n鈫?`[\"auth\", \"login\", \"handler.ts\"]`\n\n. That's it. The extension stays on the last segment so `handler.ts`\n\ndoesn't become `handler`\n\n+ `.ts`\n\nas a fake folder.\n\nThe inverse is `constructFlatName`\n\n鈥?join segments with the separator, reattach the extension. When you right-click `auth/login/`\n\nand create `handler.ts`\n\n, the plugin joins them back to `auth_login_handler.ts`\n\nand writes the flat file.\n\nThe tree is a lie. The disk is the truth.\n\nVSCode's `TreeDataProvider`\n\ninterface needs `getChildren(element?)`\n\n. I scan the workspace once, parse every file path, and insert into a tree of `LogicalNode`\n\nobjects:\n\n``` js\nprivate async buildTree(): Promise<LogicalNode> {\n    const cfg = vscode.workspace.getConfiguration('logicalFolders');\n    const separator = cfg.get<string>('separator', '_');\n    const exclude = cfg.get<string[]>('exclude', []);\n    const maxFiles = cfg.get<number>('maxFiles', 10000);\n\n    const folder = vscode.workspace.workspaceFolders?.[0];\n    const rootPath = folder?.uri.fsPath ?? '';\n\n    const root = new LogicalNode('', undefined, rootPath, [], new Map(), ...);\n    if (!folder) return root;\n\n    const excludePattern = exclude.length > 0 ? `{${exclude.join(',')}}` : null;\n    const files = await vscode.workspace.findFiles('**/*', excludePattern, maxFiles);\n\n    for (const uri of files) {\n        const rel = path.relative(rootPath, uri.fsPath);\n        const segments = parsePath(rel, separator);\n        this.insert(root, segments, uri.fsPath);\n    }\n    return root;\n}\n```\n\n`vscode.workspace.findFiles`\n\nrespects glob exclude patterns. The `maxFiles`\n\ncap (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.\n\nThe `insert`\n\nmethod walks the tree along the parsed segments, creating virtual folder nodes as needed:\n\n```\nprivate insert(root: LogicalNode, segments: string[], physical: string): void {\n    let current = root;\n    for (let i = 0; i < segments.length; i++) {\n        const seg = segments[i];\n        const isLeaf = i === segments.length - 1;\n\n        if (!current.children.has(seg)) {\n            current.children.set(seg, new LogicalNode(\n                seg,\n                isLeaf ? physical : undefined,      // file nodes get a real path\n                isLeaf ? path.dirname(physical) : current.physicalDir,\n                segments.slice(0, i + 1),\n                new Map(),\n                isLeaf\n                    ? vscode.TreeItemCollapsibleState.None\n                    : vscode.TreeItemCollapsibleState.Collapsed\n            ));\n        }\n        current = current.children.get(seg)!;\n    }\n}\n```\n\nFolder nodes have `physicalPath = undefined`\n\n. 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`\n\nis set to the real path, so VSCode's built-in operations (git diff, search, go-to-definition) all work normally.\n\nDefault is `_`\n\n鈥?the convention AI agents use when writing flat files. But different teams use different conventions:\n\n| Separator | Flat file | Logical tree |\n|---|---|---|\n`_` (default) |\n`auth_login_handler.ts` |\n`auth/ > login/ > handler.ts` |\n`__` |\n`auth__login__handler.ts` |\n`auth/ > login/ > handler.ts` |\n`-` |\n`auth-login-handler.ts` |\n`auth/ > login/ > handler.ts` |\n`.` |\n`auth.login.handler.ts` |\n`auth/ > login/ > handler.ts` |\n`::` |\n`auth::login::handler.ts` |\n`auth/ > login/ > handler.ts` |\n\nThe `.`\n\nseparator has a footgun: `auth.login.handler.test.ts`\n\nsplits to `auth/ > login/ > handler/ > test.ts`\n\n鈥?the `.test`\n\npart becomes a virtual folder. Use `_`\n\nor `-`\n\nif your test files have dots in the name. I documented this in the README rather than trying to be clever about it.\n\nDotfiles (`.gitignore`\n\n, `.env`\n\n) are never split. They start with a dot, so the first check in `parsePath`\n\nbails out and returns them as-is.\n\nIf you have a real `src/`\n\ndirectory AND a flat `src_auth.ts`\n\nfile, both appear under `src/`\n\nin 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).\n\nCreate, rename, delete 鈥?all operate on the physical flat file, not the virtual tree.\n\nRight-click `auth/login/`\n\n鈫?New File 鈫?type `handler.ts`\n\n:\n\n`[\"auth\", \"login\", \"handler.ts\"]`\n\n`auth_login_handler.ts`\n\n(segments joined with `_`\n\n)Rename `handler.ts`\n\nto `middleware.ts`\n\n:\n\n`auth_login_handler.ts`\n\n`auth_login_middleware.ts`\n\n`fs.rename`\n\non the physical fileThe tree refreshes (500ms debounce on the file watcher) and shows the new structure.\n\nBecause the agent breaks them.\n\nI 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`\n\n+ `mv`\n\nwas undone within minutes.\n\nSymlinks? The agent follows them, resolves the real path, and writes to the flat location. Same problem.\n\nThe 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.\n\n**VSCode** 鈥?search \"Logical Folders\" in the extensions panel, or:\n\n[Marketplace link](https://marketplace.visualstudio.com/items?itemName=alexcoledev.logical-folders)\n\n**IntelliJ IDEA** 鈥?pending JetBrains review (plugin ID 33928), live within 2 business days at:\n\n`plugins.jetbrains.com/plugin/33928-logical-folders`\n\n**Source**: [github.com/alexcoledev/logical-folders](https://github.com/alexcoledev/logical-folders)\n\nThe VSCode extension is ~300 lines of TypeScript. The IntelliJ plugin is Kotlin 鈥?same algorithm, different tree API. No runtime dependencies beyond the editor SDK.\n\nConfig: `logicalFolders.separator`\n\n(default `_`\n\n), `logicalFolders.exclude`\n\n(default `[\"**/node_modules/**\", \"**/.git/**\", \"**/out/**\", \"**/dist/**\"]`\n\n), `logicalFolders.maxFiles`\n\n(default 10000).", "url": "https://wpnews.pro/news/how-i-built-a-virtual-folder-tree-from-flat-filenames-no-files-moved-no-symlinks", "canonical_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_at": "2026-08-30 05:38:58+00:00", "updated_at": "2026-08-30 05:51:59.223151+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Logical Folders", "VSCode", "IntelliJ", "TypeScript"], "alternates": {"html": "https://wpnews.pro/news/how-i-built-a-virtual-folder-tree-from-flat-filenames-no-files-moved-no-symlinks", "markdown": "https://wpnews.pro/news/how-i-built-a-virtual-folder-tree-from-flat-filenames-no-files-moved-no-symlinks.md", "text": "https://wpnews.pro/news/how-i-built-a-virtual-folder-tree-from-flat-filenames-no-files-moved-no-symlinks.txt", "jsonld": "https://wpnews.pro/news/how-i-built-a-virtual-folder-tree-from-flat-filenames-no-files-moved-no-symlinks.jsonld"}}