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 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];
}
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;
}
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.
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.renameon 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)