DEV Community

sanskar arora
sanskar arora

Posted on

๐Ÿงญ Neovim Thinks in One Directory

Hey all ๐Ÿ‘‹

Here is an assumption so deep in Neovim that you stop seeing it: there is one project, and it is wherever :pwd points. Telescope greps the cwd. nvim-tree roots itself at the cwd. Neogit opens the repo that contains the cwd. git-worktree.nvim is a cwd changer with a picker on top. Harpoon keys its marks by cwd. Every one of these plugins is excellent, and every one of them believes the same small lie.

My work does not look like that. On a normal day it is a service, the shared library it imports, and the infra repo that deploys it - three repositories, one problem. Or it is three git worktrees of the same repo, side by side, because a review and a hotfix and a feature branch all want to be open at once. VS Code has had an answer since forever: the multi-root workspace, a .code-workspace file with N folders in it, and every search, tree and git view spanning all of them.

Neovim's project plugins mostly answer a different question - "switch me between projects" - which is one root at a time with better bookkeeping. I wanted the VS Code shape without giving up any of the plugins above, and without a plugin that replaces them. That is what workspaces.nvim is: a name, an ordered list of folders, a session, and a set of careful conversations with plugins that only understand one directory.

This post is the design: the decisions, why they went that way, and the three or four places where the mechanism was more interesting than the feature. Code is at the end.


๐Ÿงจ Why "just cd somewhere" does not work

The naive answer to multi-root is :cd to the common parent and let everything fall out. Three things go wrong at once, and they have different fixes.

1. The common parent is usually garbage. ~/github/service, ~/github/shared-lib and ~/infra/deploy have ~ in common. A tree rooted at your home directory and a grep across it are not a workspace, they are a punishment.

2. The plugins disagree about what "where" means. Telescope will happily take a list of directories (search_dirs). Neogit takes a cwd option per call. nvim-tree takes exactly one root and will :lcd its own window to get there. git-worktree resolves the repository from the process cwd, synchronously, at the top of every function. There is no single knob that moves all four.

3. Sessions and folders are different things. :mksession remembers windows, buffers, tabs and the cwd. It knows nothing about "these three folders belong together", and if you let it be the only persistence you get a session that reopens your buffers in the wrong place with no memory of why.

So the design has to carry its own notion of folders, hand it to each plugin in the dialect that plugin speaks, and keep its hands off anything that would make a small workspace worse than no plugin at all.

That last clause became the rule everything else answers to:

The fallback rule. With zero folders, every feature behaves exactly like stock Neovim. An empty workspace is never worse than not having the plugin.

It sounds like a nicety. It is actually the thing that made the design converge, because every integration got the same first question: what do you do when there is nothing to do? The answer is always "what you did before".


๐Ÿงญ Decision 1: what is a workspace?

A workspace is three things, stored as two files:

Thing Where it lives Why
A name the filenames validated: 1-200 chars, no / or \, no control characters, no leading .
An ordered folder list <name>.json plain data, read with vim.json.decode, never executed
A session <name>.vim the unmodified output of :mksession, restored with :source

Two choices in that table are worth defending.

Reuse :mksession instead of inventing a session format. Neovim's own session machinery already handles windows, splits, tabs, folds, the argument list and cwd, and it is maintained by people who are not me. A plugin-specific layout format would be a worse version of it within a month. The cost is that a session file is Vimscript that gets executed on load, which has a security consequence I will come back to.

The folder list is data, not code. An early version stored the folders as a Lua file and loaded it with dofile. That is one line and it works, and it also means anyone who can write to your session directory can run arbitrary Lua in your editor the next time you open a workspace. The current version writes JSON. Old .vars.lua files are migrated on first read through load(src, "=workspace-folders", "t", {}) - text-only chunk, empty environment, so a hostile file cannot reach os, io or vim. The test for this was a legacy file containing os.execute("touch PWNED"). Nothing ran; the folder list came back empty; the file was converted and removed.

LLD lesson worth generalising: if a file is persistence, it is data. The moment you load persistence with a code loader because it is convenient, you have an execution path you did not design, and you will not remember it is there.

Storage is one directory, stdpath("data")/workspaces/, created 0700. Switching is: save the current workspace (:wall, then mksession!), wipe every buffer, :source the target session, read its folder list, then tell every integration the folders changed. Every one of those steps is under pcall with a message, because the failure modes are mundane - a read-only directory, a session whose saved cwd was deleted last week - and none of them should abort a switch halfway. :source runs with silent! for the same reason: one dead cd line inside a session should not stop the twenty buffer lines after it from loading.


๐Ÿ” Decision 2: search is the easy part, so make it nice

Telescope already speaks multi-root; it just calls it search_dirs. find_files, live_grep, grep_string and oldfiles all get the workspace folder list, or nothing when the list is empty (the fallback rule, applied for the first time).

What was worth building on top is a ripgrep prompt with a small grammar:

TODO                              every file in every folder
TODO |:| *.lua                    only Lua files
TODO |:| *.lua, !**/tests/**      Lua files, but not tests
Enter fullscreen mode Exit fullscreen mode

Everything after |:| becomes -g globs. The command that runs is rg -e <pattern> -g <glob>... --color=never --no-heading --with-filename --line-number --column --smart-case -- <folders>. The sorter is empty on purpose: ripgrep already did the filtering, so Telescope sorting the results again is CPU spent on nothing.

Then there is the picky version. <leader>fs greps every folder. <leader>fns first asks where: a picker listing every workspace folder and every subdirectory under them, multi-select with <Tab>, then the same grep over exactly the marked paths. Three decisions make it fast enough to open without thinking:

  • Scan once, stream in. fd --type d --hidden --no-ignore . <folders> runs as an async job and entries appear as they arrive. The picker is usable before the scan finishes, and the process is killed when the picker closes. On a six-core laptop, 1,060 directories under ~/github take 26 ms; 45 plugin repos with 1,287 directories take 30 ms.
  • Ignore .gitignore, keep the exclude list. --no-ignore is deliberate, so a build directory or a venv can be grepped on purpose. .git, node_modules, target and dist are still skipped, with an in-picker toggle to bring them back that keeps your marks.
  • Remember the last selection in memory only. The marked paths are pre-marked next time, survive a workspace switch, and are never written to disk. Between uses, the whole feature holds one short list of strings and one boolean.

There is a trap in the scanning that is worth its own sentence. fd --max-results is the obvious way to cap a scan on a slow machine. It does not exist on fd 7.4, which is what Ubuntu 20.04 ships, and an unknown flag makes fd exit with an error and an empty picker. The cap moved into the finder: count entries as they stream, close the job at N. Works for fd, works for the find fallback, works on the old box.


๐ŸŒณ Decision 3: nvim-tree has one root, and that is not negotiable

This is the compromise at the heart of the plugin, and I would rather say so than pretend. nvim-tree shows exactly one root. VS Code's explorer shows N. There is no setting to change that, and forking nvim-tree was never on the table.

The workaround has two halves.

Root the tree at the deepest common ancestor of the folders. For ~/github/service and ~/github/shared-lib, that is ~/github. For folders that share nothing, it is /. Yes, really / - the alternative was special-casing, and a tree rooted at / with the filter below applied is actually fine.

Filter everything that is not on the way to a folder. nvim-tree reads a filters.custom function from its own setup(), and the plugin exports one:

filters = {
  custom = function(path)
    local ok, ws = pcall(require, "workspaces")
    return ok and ws.nvim_tree_filter(path) or false
  end,
},
Enter fullscreen mode Exit fullscreen mode

With two or more folders, a path is shown only if it is a workspace folder, is inside one, or is an ancestor of one. Everything else is hidden. The result is surprisingly close to VS Code: your folders, nested under whatever they share, siblings gone.

Then two details that took longer than the feature.

The tree must never move your cwd. nvim-tree's change_root runs :lcd on its own window by default (actions.change_dir), so after re-rooting, the tree window has a different working directory from everything else. Harmless until you press a key in the tree that opens a picker, and the picker scans somewhere you did not expect - and until :mksession saves that :lcd into the session. The fix is plain Vim: right after the plugin's own change_root, every tree window with a local directory gets noautocmd cd <global cwd>, and :cd inside a window clears its window-local directory. No nvim-tree internals touched, and manual navigation in the tree behaves exactly as before.

The tree must never be in the session. :mksession with the tree open records a dead NvimTree_N buffer, which comes back on load as an empty window with a filetype and no explorer behind it. So every save closes the tree first, runs mksession!, then reopens it and puts focus back where it was. Switching closes it once and opens it once; a first version reopened, wiped, and reopened again, which was three flickers for the price of one.

LLD lesson worth generalising: when you integrate with a plugin, enumerate the side effects it has on shared state - cwd, buffers, focus - not just its API. The API was fine. The :lcd and the stray buffer were the actual work.


๐Ÿ“ Decision 4: who owns the cwd?

The first real-use surprise: switch workspace, run :!pwd, same directory as before. Nothing in the plugin had ever :cd'd. The only cwd change on a switch came from :source restoring whatever cwd the session was saved with, and every session had been saved from the same launch directory.

The rule that replaced the accident: the cwd follows the common root of the folders, on every switch and on every folder add or remove. One folder, the cwd is that folder. Two related folders, their shared parent. Folders that share nothing, / - and it does cd / anyway, because the searches use the folder list and are unaffected, and because a rule with an exception is two rules. Zero folders, the cwd is left alone (fallback rule again).

It is a plain :cd, so DirChanged fires and every plugin that keys on it gets the news. The add-folder picker now starts at the common root. Sessions save the common root. The tree's :lcd is still undone. It is one option (cwd_follows_root) and it is on by default, because a workspace whose :pwd lies to you is not finished.


๐Ÿ”€ Decision 5: plugins that take a cwd versus plugins that are the cwd

This is the distinction I would hand anyone integrating with the Neovim git ecosystem.

Neogit takes a cwd option. That makes routing a pure function of the current buffer. If you are in a real file (buftype == "", named, readable) inside a workspace folder, Neogit opens in that file's repository, found with vim.fs.root(file, ".git") - stat calls, no git process, no lag. From anything else - the tree, a Telescope prompt, a [No Name] buffer, a file outside the workspace - you get a repo picker, always, even with one repo, because guessing wrong about which repo you meant is worse than one extra keypress.

The repo picker itself earned some care because everything uses it. Entries look like work/app/ (main, 3 changed). Discovery is pure Lua (vim.fs.root per folder, deduplicated, so two folders in one repo collapse to one entry and linked worktrees count separately). The picker opens instantly with (โ€ฆ) placeholders and fills in as one async git status --porcelain=v1 -b --untracked-files=no per repo returns; the jobs are killed if you close it early. Tracked changes only, by choice - untracked counts are noise on any repo with a build directory.

git-worktree.nvim is the cwd. Its model is: create a worktree, cd into it, swap your buffers. Resolving the repository happens synchronously from the process cwd at the top of each function. In a multi-root workspace that model is simply wrong: a worktree is a folder, and the thing you want is to add it.

So, inside a workspace, three functions on the git-worktree module table are wrapped - create_worktree, switch_worktree, delete_worktree. Its Telescope extension calls them through the module table, so no fork is needed. The wrappers change the meaning of each verb:

git-worktree verb Upstream Inside a workspace
create create, then cd into it create, then add it as a folder; no cd
switch (picker <CR>) cd + buffer swap add it as a folder (warn if already present)
delete (picker <C-d>) git worktree remove in cwd git -C <repo> worktree remove [--force] -- <path>, then drop the folder (and any folder nested inside it)

The one wrinkle: because git-worktree reads the repo from the cwd synchronously at the start, and every job it spawns afterwards carries an explicit cwd, a momentary noautocmd cd <repo> โ†’ call โ†’ cd back is enough and is invisible to everything else. A small context object { kind, repo } is set when the plugin's own worktree picker opens, consumed by the first wrapped call it applies to, and reset on every worktree key press, so a half-finished flow can never hijack a later real one. Outside a workspace none of this happens and git-worktree is byte-for-byte upstream.

There is also a cross-repo changed-files picker (<leader>gs): one git status --porcelain=v1 -z per workspace repo, two-letter XY status plus folder label plus path, git diff HEAD -- <file> as the preview. The preview kills the previous diff job before starting the next, because holding j in a picker should not fork thirty git processes.

LLD lesson worth generalising: sort your integrations by what they assume, not by what they do. "Takes a cwd" is a parameter. "Is the cwd" is a worldview, and you integrate with a worldview by wrapping the three functions where it touches reality.


๐Ÿ”Œ Everything else rides on hooks the plugins already offer

The remaining integrations needed no cleverness, only using what was there:

  • LSP. Every running server that advertises workspace.workspaceFolders.supported gets a workspace/didChangeWorkspaceFolders notification with the workspace folders. Only folders the plugin added are ever removed again; a server's own root is never touched. Workspace symbols and references now span repos. It adapts to the client API change between Neovim 0.10 (client.notify) and 0.11+ (client:notify), because the dot form logs a deprecation on one and the colon form crashes on the other.
  • Harpoon 2. Its settings.key hook decides which mark list is active. require("workspaces").harpoon_key() returns workspace:<name> inside a workspace and the cwd otherwise, so marks become per-workspace and Harpoon's own behaviour returns the moment no workspace is active.
  • Statusline. statusline() returns <name> (<folder count>) or an empty string. Drop it in lualine as a component; styling is yours.

And then the piece that turned "optional" from a comment into a contract. Every external integration has a switch, on by default:

integrations = { nvim_tree = true, neogit = true, git_worktree = true, harpoon = true },
lsp = { enabled = true, servers = nil },
Enter fullscreen mode Exit fullscreen mode

Off means the plugin never even requires that plugin: its default keymaps are not registered, calling its API warns, and the hooks your own config calls (harpoon_key, nvim_tree_filter) fall back to stock behaviour so your snippets can stay in place. pcall(require, ...) handles "not installed". A switch handles "installed, and I do not want you touching it", which is a different thing.


๐Ÿ”’ Hardening, because some of these machines are bastion hosts

Some of the hosts this runs on are small, slow, and not entirely mine. That forced a round of reading every line with three questions: can this run something it should not, can this crash the editor, and can this be cheaper?

Execution surface. Nothing in the plugin runs a shell; every external command is an argv list, with paths after -- where the tool supports it. The folder list is JSON (above). Workspace names are validated everywhere a name arrives, which closes the :WorkspaceDelete ../something shape of path traversal. The session directory is created 0700.

And the one that cannot be fixed, stated plainly: <name>.vim is Vimscript and :source executes it. That is inherent to :mksession. Whoever can write to your session directory can run code in your editor. The directory defaults to stdpath("data"), is private by default, and should never be a synced or shared folder.

Crash surface. The things that used to throw now report: mksession into a read-only directory, :source of a session whose cwd is gone, :cd to nowhere, %bd! on a stubborn buffer. Missing Neogit, missing git-worktree, missing its Telescope extension, missing rg, missing Telescope itself all warn instead of erroring. The startup picker only opens on a clean interactive launch - a UI attached, no file arguments, no -S, nothing piped in - because the first version opened Telescope inside nvim --headless, which is a wonderful way to hang a scripted run on a server.

Three bugs from that round were the kind that pass every test and lie to you:

  • vim.fn.expand on a typed path. ~ and $VAR expand, which is what you want. % and # also expand, to the current and alternate buffer names, which is not. A directory with a % in its name became a path to nowhere. vim.fs.normalize does the first job without the second.
  • find -path "*/.*" -prune as the fallback for machines without fd. It prunes any path containing a dot-directory - including the scan base itself when the cwd is under ~/.config. Result: an empty picker, only on machines without fd, only from inside a dotfiles directory. Replaced with -mindepth 1 and -name prunes.
  • fd --max-results as described above: present on the laptop, absent on the server.

Footprint. One mkdir per session instead of one per storage call. readdir with a filter instead of glob. The fd/rg lookups cached after the first PATH scan. Recent-files filtered by path prefix before any stat. One tree close and one open per switch. The previous diff job killed before the next preview. Auto-saves silent, because a notification after every folder change produces a hit-enter prompt on a 24-line terminal.

None of these are clever. All of them are the difference between a plugin that is fine on a Ryzen and one that is fine on a two-core VM you reach through a jump host.


๐Ÿšง Honest gaps

  • nvim-tree is an approximation. Common ancestor plus filter looks like multi-root. It is not. Unrelated folders live under /.
  • Switching loses unnamed buffers. :wall cannot save [No Name], and %bd! then discards it, along with terminals and Neogit buffers.
  • Two pickers lean on Telescope internals - async_oneshot_finder for seeding results, and the _multi option for sharing a multi-select across a refresh. A Telescope refactor could break them.
  • A deleted cwd breaks Telescope previews until you :cd elsewhere. That is Neovim behaviour (vim.fs.abspath asserting inside the buffer previewer), not plugin code, but you will meet it if you remove a workspace's only folder from disk while it is open.
  • The name. natecraddock/workspaces.nvim already answers to require("workspaces"). The two cannot be installed together as things stand.

๐Ÿง  What I would tell the next person

  • Write the fallback rule before the first feature. "Zero folders means stock Neovim" decided a dozen edge cases before they were edge cases.
  • Reuse the host's persistence; own only your data. :mksession for layout, a JSON list for the thing Neovim does not know about. Inventing a session format is a month of bugs for no feature.
  • Persistence is data. Load it as data. dofile on a config file is an execution path you did not design.
  • Integrate with a plugin's side effects, not just its API. The :lcd, the stray buffer, the focus steal - that was the work. The API calls were two lines.
  • Sort plugins by what they assume about the world. "Takes a cwd" is a parameter. "Is the cwd" means wrapping the three functions where that belief meets reality.
  • A switch is not a pcall. "Not installed" and "installed, hands off" are different requirements, and a plugin that is going to live in other people's configs needs both.
  • Test on the machine that has the old tools. fd 7.4, no fd at all, a dot-directory as cwd, nvim --headless. The laptop passes everything; the bastion host is where the real test suite lives.

๐Ÿ“‹ Quick Reference (For Skimmers)

Action Key Command
New workspace (name popup) <leader>wn :WorkspaceNew
Open / switch (picker, <C-d> deletes) <leader>wo :WorkspaceOpen
Delete (picker, or by name) <leader>wD :WorkspaceDelete [name]
Save now - :WorkspaceSave
Add folder (picker: <C-o> browse mode, <C-y> typed path) <leader>wa :WorkspaceAddFolder [dir]
Remove folders (multi-select, <C-s> applies) <leader>wd :WorkspaceRemoveFolder
Find files / recent / live grep / word <leader>ff fr feus grep -
Grep with `pattern \ :\ globs`, all folders
Same, but pick folders first <leader>fns :WorkspaceGrepSelective
Neogit (file's repo, else picker) <leader>gt -
Changed files across repos <leader>gs -
Worktrees: list โ†’ add as folder / create โ†’ add as folder <leader>gwt / <leader>gwa -

Install with lazy.nvim:

{ "snskArora/workspaces.nvim", dependencies = { "nvim-telescope/telescope.nvim" }, opts = {} }
Enter fullscreen mode Exit fullscreen mode

Needs Neovim 0.10+ and rg; fd is recommended and find is the fallback. The plugin is at snskArora/workspaces.nvim, and the config it lives in - lualine pill, nvim-tree filter, Harpoon hook, the lot - is snskArora/nvim_configs.

๐Ÿ’ฌ Final Thoughts

Neovim still thinks in one directory. It just has a plugin now that speaks to each of its friends in the dialect they understand, and keeps a list of the directories it should have been thinking about all along.

One window, several repos, and :pwd finally telling the truth.

Top comments (0)