DEV Community

๐Ÿงญ 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

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 , },

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 }

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.