VS Code: a versioned mirror, not an immutable source
Edit on GitHubmodules/home/apps/vscode.nix. The package (an app WITH a config of its own owns its package) plus the
THREE user config JSONs (settings, keybindings, mcp) versioned here and linked into
~/.config/Code/User. The rest of that directory (globalStorage, History,
workspaceStorage, sync) is STATE and stays out on purpose: it goes to restic, not to git.
Why mkOutOfStoreSymlink and not programs.vscode.profiles.default.userSettings
The home-manager module GENERATES settings.json in the store, and the store is read-only. The
app, which writes to that file on every UI toggle and on every Settings Sync pull, would start
failing with "Unable to write into user settings", and every tweak would cost a rebuild.
Here the target is the REAL file in the repo, mutable: editing applies RIGHT AWAY (VS Code watches
the file, no window reload) and a toggle in the UI lands as a git diff. The same contract as
hyprland.lua and quickshell, for the same reason.
A third gain comes for free: the format here is JSONC, so the settings.json COMMENTS survive,
whereas a Nix-generated userSettings would be pure JSON and would erase them all (the "why" of
nix.enableLanguageServer, of modernUI, of externalUriOpeners and so on).
What makes this safe, and it is the detail that decides the design
VS Code writes settings.json ATOMICALLY: it writes a .vsctmp and renames over it. A rename
would REPLACE the symlink with a regular file, silently disconnecting the repo.
But it checks the target first: canWriteFileAtomic stats it and, if it is a symbolic link,
returns false and falls back to the direct write, THROUGH the link. VERIFIED on this machine's
1.132.0
(resources/app/out/vs/code/electron-utility/sharedProcess/sharedProcessMain.js, the shared
process being exactly where Settings Sync runs).
If VS Code ever loses that guard, the symptom is the symlink turning into a regular file and the repo no longer receiving the changes.
Settings Sync stays ON, on purpose
The same account serves the FAI Windows machine (the settings.json has
terminal.integrated.profiles.windows and the UNC host FAIADM6246), and turning the "Settings"
feature off would freeze that machine forever.
The consequence to accept: this file is a versioned MIRROR, not an immutable source. A change made
on another machine arrives here as a diff, and an extension that writes to the config (the
fileNesting "//": "Last update at …" is the worst case) shows up in git status.
And that is the FUNCTION, not the price: the repo has to mirror what the system IS, and since the
linked file is the LIVE one, git status became a drift detector for the editor's config.
Anyone who wants Nix ENFORCING swaps the xdg.configFile for
programs.vscode.profiles.default.userSettings and turns "Settings"/"Keybindings" off in Sync.
That is the opposite decision, not a fix.
Extensions: mirrored, not governed
EXTENSIONS keep being INSTALLED by Sync (the account), not declared here. Declaring them would
require the nix-vscode-extensions input (the nixpkgs set lags) plus mutableExtensionsDir = false, which breaks the UI's install button and auto-update.
But the repo does RECORD which ones are installed, in modules/home/apps/vscode/extensions.txt, written by
vscode-extensions-dump. Without that, extensions were the only corner of VS Code invisible to
git. Same contract as settings.json, one level up: mirror without governing.
The file format is deliberate:
- Only IDs, one per line, sorted.
sortbecause the CLI's order is arbitrary, and without it the diff would be shuffling instead of information. - IDs and NOT
--show-versions, because the version is the marketplace's decision (auto-update), so it would churn every day without carrying any decision of mine. - A plain format with no header, so it keeps serving as INPUT:
xargs -n1 code --install-extension < extensions.txton a new machine.
Two guards in the script:
- The
|| trueoncode --list-extensionsis mandatory:writeShellApplicationruns withset -euo pipefail, so acodethat fails would kill the script BEFORE the guard below could explain why. - An empty list is this CLI's REAL failure (it cannot find the extensions directory), and
writing it would put the lie "I uninstalled everything" into the diff, exactly the opposite of
mirroring. It warns and exits 0: the
updatethat calls this should not die because the mirror failed, but it should not lie in silence either.
Shell highlighting inside Nix strings
nix-ide paints a ''...'' string as one flat string, so the shell inside a writeShellApplication
reads as a wall of one color. Nothing ready does better for this repo, checked on 22/10/2026:
nix-ide 0.5.13 has no embedded languages (its "embedded" is ${...} only), and Better Nix Syntax
paints shell only in shellHook, buildPhase, installPhase, buildCommand and initContent,
never text, and replaces nix-ide's grammar instead of adding to it.
So it is a local extension of two TextMate injection grammars, in vscode/nix-embedded-shell/:
injection.jsonopens a shell block ontext,script, the systemdpre/posthooks, the build phases and their hooks,writeShellScript "name", and any string marked/* bash */. An attribute PATH ending in.text(xdg.configFile."x".text) is excluded, since it holds a config file and not a script.nix-in-shell.jsonkeeps Nix visible inside the shell:${...}is Nix interpolation, and Nix's escapes (a tripled quote, two quotes before a backslash, and two quotes before${, which hands${to the shell) read as escapes.
Only a multi-line string, by design. The opening '' must end its line and the closing ''
must start one, which is how every script here is written. That is what lets the block be a
begin/while rule, the way markdown fences are: a line opening with the closing '' ends the block
AND drops every shell rule still open. A plain begin/end leaked the first time it was tried: a
shell statement still open at the last line read the closing '' as a quote, and the rest of the
file turned into shell.
Known false positives, three bare text = '' that hold no shell (fastfetch.nix,
deathadder-v2.nix, caddy.nix): they paint as shell and still close where they should.
How it is delivered. home.file links the folder into ~/.vscode/extensions/, named from its
own package.json (rule 11). VS Code registers an unknown folder there in extensions.json on its
next scan (measured 22/10/2026 with a probe extension), so it needs no .vsix and no marketplace.
settingsSync.ignoredExtensions keeps Sync from pushing an ID the other machines cannot install.
MEASURED with vscode-textmate and vscode-oniguruma, the libraries VS Code paints with, over the
real nix-ide and shell grammars: across the 200 .nix files, 3418 lines paint as shell, NO file
ends inside a shell block, and NO line painted as shell lies outside a '' string by nix-ide's own
reading.
The package
The unstable recipe with the SRC swapped for the official tarball
(pkgs/vscode/ plus overlayVscode in flake/overlays.nix), ahead of
whatever nixpkgs bumped to. The URL is versioned, and what raises the number is vscode-bump,
run by update/upgrade: in practice, ALWAYS the latest stable.
--password-store=gnome-libsecret because under Hyprland Electron does not autodetect the secret
backend and shows "couldn't identify OS keyring".
vscode-extensions-dump is on the PATH because the update alias (modules/home/shell/zsh.nix) calls
it by NAME, and it is not a service. vscode-bump is NOT: it is the passthru.updateScript the
overlay puts on the package, and vendored-bump runs it by store path
(version-bumps).
mcp.json
Which MCP servers VS Code's chat sees (context7, playwright, markitdown). It is a symlink for the
SAME reason as the other two: it is config the app REWRITES, since adding a server through the
gallery writes to this file. programs.vscode.userMcp exists but would generate into the store.
NO SECRET here, and that is what makes versioning it safe: context7's API key is not in the
file, it is ${input:context7_api_key}, VS Code's NATIVE indirection, which asks for the value at
runtime and keeps it in globalStorage (state, so restic).
CHECK THIS AGAIN when adding a new server: the day one asks for an INLINE token, this file stops being versionable in the clear and the path becomes sops, not a commit.
Two loose ends
The repo path cannot be derived from inside the evaluation: the flake is copied into the store,
and what we need is the working directory. So it is read from programs.nh.flake, the clone's
one declared path (rule 11), like every other hot-reload link in modules/home/. If the repo is not
there, the symlink dangles and VS Code cannot save settings, identically to hyprland.lua.
The nixd config that carries a PATH (nixd.options/nixpkgs) lives in this repo's root
.vscode/settings.json; it only holds with this flake as the workspace.