dotfiles
Apps

VS Code: a versioned mirror, not an immutable source

Edit on GitHub

modules/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.

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. sort because 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.txt on a new machine.

Two guards in the script:

  • The || true on code --list-extensions is mandatory: writeShellApplication runs with set -euo pipefail, so a code that 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 update that 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.json opens a shell block on text, script, the systemd pre/post hooks, 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.json keeps 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.