Skip to content

feat(config): XDG base directories and persistent UI preferences - #15

Open
jakob1379 wants to merge 1 commit into
jamesaphoenix:mainfrom
jakob1379:t3code/persist-settings-xdg-config
Open

feat(config): XDG base directories and persistent UI preferences#15
jakob1379 wants to merge 1 commit into
jamesaphoenix:mainfrom
jakob1379:t3code/persist-settings-xdg-config

Conversation

@jakob1379

Copy link
Copy Markdown
Contributor

Why

Per-user state was hand-rolled as $HOME/.diffcore/... in five independent places — global config, refinement cache, comment store, desktop log, embeddings cache — with no XDG support and no Windows fallback. Since release.yml ships Windows MSI/NSIS bundles and HOME is normally unset there, none of it resolved on Windows at all.

Separately, theme and panel layout lived only in localStorage and React state. localStorage is scoped to a browser origin, so running diffcore-web on a different --port silently lost the theme; panel width and collapse were lost on every reload.

What

paths module resolving config/data/cache/state roots, with all call sites routed through it. XDG variables are honoured as-is on every unix including macOS (~/.config/diffcore, not ~/Library/Application Support) — which is why this hand-rolls resolution rather than pulling in dirs. Relative $XDG_*_HOME is ignored per spec. The pure resolve_from() core takes the environment as arguments, so unit tests never touch process env.

pr_url's repo clone cache, which landed while this was in progress, moves along with the rest — it was the sixth such path.

Migration is read-fallback only, for the two things a user would notice losing: the global config and review comments. Writes always go to the new paths; nothing is moved or deleted, so a downgrade still works.

Comments are user-authored review notes, not regenerable data, so they land in $XDG_DATA_HOME rather than the cache root the spec declares safe to delete. load_cached_comments_file returns an empty list on any read error, so an unreachable file would have looked like deletion rather than an error.

UI preferences persist in ui.toml, served by get_ui_settings/save_ui_settings over both the Tauri IPC and the web dispatch. localStorage is kept purely as a synchronous first-paint cache so the theme doesn't flash.

ui.toml is deliberately separate from config.toml. UI prefs are written on every theme toggle and resize; config.toml holds a plaintext API key and is written rarely. Sharing one file makes each of those writes a read-modify-write of the credential — with the desktop app and diffcore-web both running (the configuration this feature exists to support), a theme change could drop a key the other instance just wrote. Keeping them apart removes that interleaving rather than mitigating it.

config.toml is now written atomically (temp + rename) and created 0600; fs::write previously left the key world-readable.

Notable behaviour change

LlmSettings gains api_key_in_config. The settings panel gated the "Clear API key" button on api_key_source equalling the literal "~/.diffcore/config.toml", so any change to where the config lives silently removed the only way to delete a stored plaintext key from the UI. A displayed path is not a sentinel. Three Playwright cases cover it, one asserting the control survives an unrecognised path.

display_global_config_path() now reports the file actually being read, which during the migration window is the pre-XDG one — it previously named the write target, pointing at a file that didn't exist yet.

Hardening

  • theme_mode is an enum, so serde rejects unknown values at parse time instead of leaving the frontend to sanitise a free-form string
  • right_panel_width clamps on read and write — a hand-edited absurd value rendered an unusable layout, and a negative one failed u32 deserialization and took the whole config down with it
  • a corrupt ui.toml degrades to defaults; preferences must not be able to break startup

Drive-by

  • a test set DIFFCORE_GLOBAL_CONFIG_DIR, a variable nothing reads, and therefore overwrote the developer's real global config with defaults on everycargo test
  • pkgs.clippy and pkgs.rustfmt added to the devShell; neither was available, so cargo clippy could not run at all

Verification

  • cargo test --workspace — 2269 pass
  • npx playwright test — 228 pass, 2 skipped
  • npx tsc --noEmit, npm run build — clean
  • nix build .#diffcore-web — builds; smoke-tested the resulting binary end to end (save prefs, restart on a different port, read back)
  • cargo clippy --all-targets — no new findings vs. baseline

Migration paths were verified against a fake $HOME: legacy config still read, key carried into the new file on first save, old file untouched. Both new regression guards were confirmed to fail against the bug they cover before being kept.

Docs live in docs/file-locations.md; the README section stays short.

Per-user state was hand-rolled as $HOME/.diffcore/... in five independent
places -- the global config, the refinement cache, the comment store, the
desktop log and the embeddings cache -- with no XDG support and no Windows
fallback, so on Windows (which release.yml ships as MSI and NSIS bundles)
HOME is normally unset and none of it resolved at all.
Add a paths module resolving config/data/cache/state roots, and route all
five call sites through it. XDG variables are honoured as-is on every unix
including macOS: ~/.config/diffcore rather than ~/Library/Application
Support, matching git and most CLI tooling, which is why this hand-rolls the
resolution instead of pulling in the dirs crate. Relative $XDG_*_HOME values
are ignored per spec. The pure resolve_from() core takes the environment as
arguments so the unit tests never touch process env.
Migration is read-fallback only, for the two things a user would notice
losing: the global config and review comments. Writes always go to the new
paths and nothing is moved or deleted, so a downgrade still works. Comments
are user-authored review notes rather than regenerable data, so they land in
$XDG_DATA_HOME, not the cache root the spec declares safe to delete;
load_cached_comments_file returns an empty list on any read error, so an
unreachable file would have looked like deletion rather than an error.
Persist theme and panel layout, which previously lived only in localStorage
and React state. localStorage is scoped to a browser origin, so running
diffcore-web on a different --port silently lost the theme, and panel width
and collapse state were lost on every reload. These now live in ui.toml,
served by get_ui_settings/save_ui_settings over both the Tauri IPC and the
web dispatch. localStorage is kept purely as a synchronous first-paint cache
so the theme does not flash on load.
ui.toml is deliberately a separate file from config.toml. UI preferences are
written on every theme toggle and panel resize; config.toml holds a plaintext
API key and is written rarely. Sharing one file would make each of those
writes a read-modify-write of the credential, and with the desktop app and
diffcore-web both running -- the configuration this feature exists to support
-- a theme change could drop a key the other instance had just written.
Keeping them apart removes that interleaving rather than mitigating it.
config.toml is now written atomically (temp file + rename) and created 0600;
fs::write previously left the key world-readable.
theme_mode is an enum so serde rejects unknown values at parse time instead
of leaving the frontend to sanitise a free-form string, and right_panel_width
is clamped on both read and write -- a hand-edited absurd value rendered an
unusable layout, and a negative one failed u32 deserialization and took the
whole config down with it. A corrupt ui.toml degrades to defaults rather than
erroring; preferences must not be able to break startup.
LlmSettings gains api_key_in_config. The settings panel gated the "Clear API
key" button on api_key_source equalling the literal "~/.diffcore/config.toml",
so any change to where the config lives silently removed the only way to
delete a stored plaintext key from the UI. A displayed path is not a sentinel;
the flag says what the UI actually needs to know. Covered by three Playwright
cases, one of which asserts the control survives an unrecognised path.
Relatedly, display_global_config_path() now reports the file actually being
read, which during the migration window is the pre-XDG one. It previously
named the write target, so the panel pointed at a file that did not exist yet.
Fixes a test that set DIFFCORE_GLOBAL_CONFIG_DIR, a variable nothing reads,
and therefore overwrote the developer's real global config with defaults on
every cargo test.
pr_url's repo clone cache, which landed upstream while this was in progress,
moves to $XDG_CACHE_HOME/diffcore/repos with the rest; it was the sixth
hand-rolled $HOME/.diffcore path.
Add pkgs.clippy and pkgs.rustfmt to the devShell; neither was available, so
cargo clippy could not run at all.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jakob1379