diff --git a/AGENTS.md b/AGENTS.md index 2c815c4..7d0c7d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,8 +68,8 @@ Large-diff work is a separate evaluation track, not part of the default live-rep - **VCR caching** — record/replay LLM calls for deterministic CI - **LLM-as-judge** — evaluator that scores analysis quality across 5 criteria - **Eval suite** — 5 synthetic fixture codebases, deterministic scoring, 0.89 avg score -- **Config** — `.diffcore.toml` with entrypoint globs, layer names, ignore patterns, LLM settings, refinement settings -- **Logging** — `tracing` + `tracing-subscriber` behind `diffcore-core`'s `logging` feature; `RUST_LOG` sets the filter (default `info`), `DIFFCORE_LOG_FORMAT=json` switches to line-delimited JSON, `DIFFCORE_LOG_FILE` redirects to a file (the desktop app falls back to `~/.diffcore/desktop.log` when stderr is not a terminal, since GUI bundles discard it). Backend activity logs on the `activity` target, IR cache on `ir_cache`. `log`-crate call sites bridge in automatically. +- **Config** — `.diffcore.toml` with entrypoint globs, layer names, ignore patterns, LLM settings, refinement settings; per-user config/cache/state resolved via XDG base dirs in `diffcore-core`'s `paths` module (`$XDG_CONFIG_HOME/diffcore/config.toml`, with `~/.diffcore/config.toml` as a read-only legacy fallback). Theme and panel preferences live in a separate `ui.toml` (`UiConfig`) so high-frequency UI writes never race the credential-bearing `config.toml`; review comments are user data and live under `$XDG_DATA_HOME/diffcore/comments/` with a read-fallback to the pre-XDG location +- **Logging** — `tracing` + `tracing-subscriber` behind `diffcore-core`'s `logging` feature; `RUST_LOG` sets the filter (default `info`), `DIFFCORE_LOG_FORMAT=json` switches to line-delimited JSON, `DIFFCORE_LOG_FILE` redirects to a file (the desktop app falls back to `$XDG_STATE_HOME/diffcore/desktop.log` when stderr is not a terminal, since GUI bundles discard it). Backend activity logs on the `activity` target, IR cache on `ir_cache`. `log`-crate call sites bridge in automatically. ## Tests diff --git a/README.md b/README.md index d578fbd..f07d829 100644 --- a/README.md +++ b/README.md @@ -148,9 +148,14 @@ The app auto-discovers git branches, worktrees, and push status on launch. Enter Diffcore now splits configuration into: -- `~/.diffcore/config.toml` for shared LLM/onboarding settings across all repos +- `$XDG_CONFIG_HOME/diffcore/config.toml` (defaults to `~/.config/diffcore/config.toml`) + for shared LLM/onboarding settings across all repos - `.diffcore.toml` in the repo root for project-specific analysis settings +Every other path — UI preferences, comments, caches, logs — is listed in +[docs/file-locations.md](docs/file-locations.md), along with the migration +notes for pre-XDG installs. + New users can usually skip API keys entirely: the desktop app auto-detects `codex` and `claude`, and will use those subscriptions when available. Example global config: @@ -197,8 +202,8 @@ uncertainty = 0.20 When using direct API providers (`anthropic`, `openai`, `gemini`), Diffcore checks for API keys in this order: -1. `key_cmd` in `~/.diffcore/config.toml` or `.diffcore.toml` -2. `key` in `~/.diffcore/config.toml` or `.diffcore.toml` +1. `key_cmd` in `~/.config/diffcore/config.toml` or `.diffcore.toml` +2. `key` in `~/.config/diffcore/config.toml` or `.diffcore.toml` 3. `DIFFCORE_API_KEY` 4. Provider specific env var: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GEMINI_API_KEY` @@ -220,7 +225,8 @@ DIFFCORE_LOG_FORMAT=json diffcore analyze --base main 2> analyze.log.jsonl Launched from a terminal the desktop app logs there like the others; launched from Finder or a `.desktop` entry — where stderr is discarded — it falls back to -`~/.diffcore/desktop.log`. `DIFFCORE_LOG_FILE` overrides the path for any binary. +`~/.local/state/diffcore/desktop.log`. `DIFFCORE_LOG_FILE` overrides the path +for any binary. ## Architecture diff --git a/crates/diffcore-core/src/cache.rs b/crates/diffcore-core/src/cache.rs index 0df9f5b..f886955 100644 --- a/crates/diffcore-core/src/cache.rs +++ b/crates/diffcore-core/src/cache.rs @@ -114,20 +114,14 @@ pub fn clear_cache(workdir: &Path) -> std::io::Result<()> { Ok(()) } -// ── Global refinement cache (~/.diffcore/cache/refinements/) ── +// ── Global refinement cache ($XDG_CACHE_HOME/diffcore/refinements/) ── /// Resolve the global refinement cache directory. +/// Respects `DIFFCORE_REFINEMENT_CACHE_DIR` for testing. fn refinement_cache_dir() -> Option { std::env::var_os("DIFFCORE_REFINEMENT_CACHE_DIR") .map(PathBuf::from) - .or_else(|| { - std::env::var_os("HOME").map(|home| { - PathBuf::from(home) - .join(".diffcore") - .join("cache") - .join("refinements") - }) - }) + .or_else(|| crate::paths::cache_dir().map(|dir| dir.join("refinements"))) } /// Load a cached refinement result for the given analysis cache key. diff --git a/crates/diffcore-core/src/config.rs b/crates/diffcore-core/src/config.rs index 26ec91b..b529bea 100644 --- a/crates/diffcore-core/src/config.rs +++ b/crates/diffcore-core/src/config.rs @@ -6,7 +6,6 @@ //! See spec §6.2 for the full config file format. use std::collections::HashMap; -use std::env; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -141,6 +140,149 @@ impl Default for DiffConfig { } } +/// UI preferences, shared across the desktop app and the web build. +/// +/// Deliberately its own file (`ui.toml`), not a section of `config.toml`. +/// UI prefs are written on every theme toggle and panel resize; `config.toml` +/// holds a plaintext API key and is written rarely. Sharing one +/// read-modify-write file would mean a theme toggle could drop a credential +/// written concurrently by another instance. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct UiConfig { + /// Theme mode. + #[serde(default)] + pub theme_mode: ThemeMode, + /// Theme id used when resolving to light. + #[serde(default = "default_theme_light")] + pub theme_light: String, + /// Theme id used when resolving to dark. + #[serde(default = "default_theme_dark")] + pub theme_dark: String, + /// Width of the right-hand panel in pixels. + #[serde(default = "default_right_panel_width")] + pub right_panel_width: u32, + /// Whether the right-hand panel is collapsed. + #[serde(default)] + pub right_panel_collapsed: bool, +} + +/// How the active theme is chosen. An enum so serde rejects garbage at parse +/// time instead of leaving the frontend to sanitise a free-form string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ThemeMode { + Light, + #[default] + Dark, + System, +} + +fn default_theme_light() -> String { + "catppuccin-latte".to_string() +} + +fn default_theme_dark() -> String { + "catppuccin-mocha".to_string() +} + +fn default_right_panel_width() -> u32 { + 320 +} + +/// Panel width bounds, mirroring the drag clamp in the UI. A width outside +/// this range renders an unusable layout, so it is clamped on both read and +/// write rather than trusted. +const MIN_PANEL_WIDTH: u32 = 200; +const MAX_PANEL_WIDTH: u32 = 800; + +impl Default for UiConfig { + fn default() -> Self { + Self { + theme_mode: ThemeMode::default(), + theme_light: default_theme_light(), + theme_dark: default_theme_dark(), + right_panel_width: default_right_panel_width(), + right_panel_collapsed: false, + } + } +} + +impl UiConfig { + /// Path to `ui.toml` in the XDG config dir. + pub fn path() -> Option { + crate::paths::config_dir().map(|dir| dir.join("ui.toml")) + } + + /// Load UI preferences, falling back to defaults. + /// + /// Never returns an error: a corrupt or hand-mangled prefs file must not + /// be able to take down the app, so it degrades to defaults. + pub fn load() -> Self { + let Some(path) = Self::path() else { + return Self::default(); + }; + let Ok(contents) = std::fs::read_to_string(&path) else { + return Self::default(); + }; + match toml::from_str::(&contents) { + Ok(mut config) => { + config.clamp(); + config + } + Err(e) => { + tracing::warn!("ignoring unparseable {}: {e}", path.display()); + Self::default() + } + } + } + + /// Write UI preferences to `ui.toml`. + pub fn save(&self) -> Result<(), ConfigError> { + let Some(path) = Self::path() else { + return Err(ConfigError::Validation( + "Could not resolve a config directory for UI settings".to_string(), + )); + }; + let mut config = self.clone(); + config.clamp(); + let toml_str = toml::to_string_pretty(&config).map_err(|e| { + ConfigError::Validation(format!("Failed to serialize UI settings: {}", e)) + })?; + write_atomically(&path, &toml_str) + } + + fn clamp(&mut self) { + self.right_panel_width = self + .right_panel_width + .clamp(MIN_PANEL_WIDTH, MAX_PANEL_WIDTH); + } +} + +/// Write to a sibling temp file then rename, so a reader never observes a +/// half-written file and a crash mid-write cannot truncate the old one. +fn write_atomically(path: &Path, contents: &str) -> Result<(), ConfigError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("toml.tmp"); + std::fs::write(&tmp, contents)?; + restrict_permissions(&tmp); + std::fs::rename(&tmp, path)?; + Ok(()) +} + +/// Owner-only permissions. `config.toml` carries a plaintext API key, and +/// `fs::write` would otherwise create it 0644. +fn restrict_permissions(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } + #[cfg(not(unix))] + let _ = path; +} + /// LLM provider configuration. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct LlmConfig { @@ -276,27 +418,47 @@ pub enum ConfigError { } impl DiffcoreConfig { - /// Return the global diffcore config path in the user's home directory. + /// Return the global diffcore config path, following XDG base directories. /// - /// Resolution order: - /// 1. `DIFFCORE_CONFIG_HOME` - /// 2. `HOME/.diffcore` + /// Resolution order: `DIFFCORE_CONFIG_HOME`, `XDG_CONFIG_HOME/diffcore`, + /// `HOME/.config/diffcore`. This is always where writes go; see + /// [`Self::load_global`] for the pre-XDG read fallback. pub fn global_config_path() -> Option { - diffcore_config_home().map(|dir| dir.join("config.toml")) + crate::paths::config_dir().map(|dir| dir.join("config.toml")) + } + + /// Pre-XDG global config (`~/.diffcore/config.toml`), read-only. + fn legacy_global_config_path() -> Option { + crate::paths::legacy_dir().map(|dir| dir.join("config.toml")) + } + + /// The global config file actually in use, or `None` when none exists yet. + /// + /// Resolution order matches [`Self::load_global`]: the XDG path, then the + /// pre-XDG `~/.diffcore/config.toml`. Callers displaying "your key lives + /// here" must use this, not [`Self::global_config_path`], which always + /// names the write target and so lies during the migration window. + pub fn global_config_source() -> Option { + Self::global_config_path() + .filter(|path| path.exists()) + .or_else(|| Self::legacy_global_config_path().filter(|path| path.exists())) + } + + /// Whether settings are still being read from the pre-XDG location. + pub fn using_legacy_global_config() -> bool { + Self::global_config_path().is_none_or(|path| !path.exists()) + && Self::legacy_global_config_path().is_some_and(|path| path.exists()) } /// Load configuration from the user's global config file. /// - /// Returns defaults when the file is missing or the home directory cannot be resolved. + /// Reads the XDG path when it exists, otherwise falls back to the pre-XDG + /// `~/.diffcore/config.toml` so existing installs keep their settings. + /// Returns defaults when neither is present. pub fn load_global() -> Result { - if let Some(path) = Self::global_config_path() { - if path.exists() { - Self::from_file(&path) - } else { - Ok(Self::default()) - } - } else { - Ok(Self::default()) + match Self::global_config_source() { + Some(path) => Self::from_file(&path), + None => Ok(Self::default()), } } @@ -327,7 +489,7 @@ impl DiffcoreConfig { } } - /// Load repo-local config and merge in global LLM defaults from `~/.diffcore/config.toml`. + /// Load repo-local config and merge in global LLM defaults from the global config. /// /// Repo-local project settings remain authoritative; only the `[llm]` section falls back /// to the global config when values are not set locally. @@ -338,7 +500,7 @@ impl DiffcoreConfig { Ok(local) } - /// Save configuration to the global diffcore config in the user's home directory. + /// Save configuration to the global diffcore config (always the XDG path). pub fn save_global(&self) -> Result<(), ConfigError> { let Some(config_path) = Self::global_config_path() else { return Err(ConfigError::Validation( @@ -346,14 +508,10 @@ impl DiffcoreConfig { )); }; - if let Some(parent) = config_path.parent() { - std::fs::create_dir_all(parent)?; - } - let toml_str = toml::to_string_pretty(self) .map_err(|e| ConfigError::Validation(format!("Failed to serialize config: {}", e)))?; - std::fs::write(&config_path, toml_str)?; - Ok(()) + // Atomic + owner-only: this file holds a plaintext API key. + write_atomically(&config_path, &toml_str) } /// Validate the configuration for consistency. @@ -572,12 +730,6 @@ impl DiffcoreConfig { } } -pub(crate) fn diffcore_config_home() -> Option { - env::var_os("DIFFCORE_CONFIG_HOME") - .map(PathBuf::from) - .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".diffcore"))) -} - #[cfg(test)] #[allow( clippy::unwrap_used, @@ -1534,22 +1686,142 @@ provider = "openai" assert!(local.llm.refinement.enabled); } + /// The whole global-config surface in one test. + /// + /// `DIFFCORE_CONFIG_HOME` and `HOME` are process-global, so every case that + /// needs them lives here rather than in parallel tests that would race. #[test] - fn test_save_global_roundtrip_annotations_enabled() { - // Test that annotations_enabled survives save_global / load_global cycle - let dir = tempfile::tempdir().unwrap(); - std::env::set_var("DIFFCORE_GLOBAL_CONFIG_DIR", dir.path().to_str().unwrap()); + fn test_global_config_roundtrip_legacy_fallback_and_ui() { + let xdg = tempfile::tempdir().unwrap(); + let fake_home = tempfile::tempdir().unwrap(); + let prev_home = std::env::var_os("HOME"); + std::env::set_var("DIFFCORE_CONFIG_HOME", xdg.path()); + std::env::set_var("HOME", fake_home.path()); + + // 1. Nothing anywhere: defaults. + assert_eq!(DiffcoreConfig::global_config_source(), None); + assert_eq!(UiConfig::load(), UiConfig::default()); + + // 2. Only the pre-XDG `~/.diffcore/config.toml` exists: it is read. + let legacy_dir = fake_home.path().join(".diffcore"); + std::fs::create_dir_all(&legacy_dir).unwrap(); + std::fs::write( + legacy_dir.join("config.toml"), + "[llm]\nkey = \"sk-legacy\"\n", + ) + .unwrap(); + let loaded = DiffcoreConfig::load_global().unwrap(); + assert_eq!( + loaded.llm.key.as_deref(), + Some("sk-legacy"), + "legacy config should be read when no XDG config exists" + ); + assert!(DiffcoreConfig::using_legacy_global_config()); + assert_eq!( + DiffcoreConfig::global_config_source(), + Some(legacy_dir.join("config.toml")), + "the migration window must report the file actually in use" + ); - let mut config = DiffcoreConfig::default(); + // 3. Saving writes to the XDG path and leaves the legacy file alone. + let mut config = loaded; config.llm.annotations_enabled = false; config.llm.refinement.enabled = false; config.save_global().unwrap(); + assert!(xdg.path().join("config.toml").exists()); + assert!( + legacy_dir.join("config.toml").exists(), + "legacy config must never be moved or deleted" + ); + + // 4. Everything survives the round trip, and XDG now wins over legacy. let loaded = DiffcoreConfig::load_global().unwrap(); - assert!(!loaded.llm.annotations_enabled, "annotations_enabled should survive save/load"); - assert!(!loaded.llm.refinement.enabled, "refinement.enabled should survive save/load"); + assert!(!loaded.llm.annotations_enabled); + assert!(!loaded.llm.refinement.enabled); + assert_eq!(loaded.llm.key.as_deref(), Some("sk-legacy")); + + // 5. UI prefs round-trip through their own file, untouched by the above. + let ui = UiConfig { + theme_mode: ThemeMode::Light, + right_panel_width: 612, + right_panel_collapsed: true, + ..Default::default() + }; + ui.save().unwrap(); + assert_eq!(UiConfig::load(), ui); + assert!(xdg.path().join("ui.toml").exists()); - std::env::remove_var("DIFFCORE_GLOBAL_CONFIG_DIR"); + // 6. A corrupt prefs file degrades to defaults instead of erroring. + std::fs::write(xdg.path().join("ui.toml"), "not = [valid").unwrap(); + assert_eq!(UiConfig::load(), UiConfig::default()); + + // 7. The config file holding the API key is owner-only. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(xdg.path().join("config.toml")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "config.toml must not be world-readable"); + } + + // 8. During the migration window the reported source is the real one. + assert_eq!( + DiffcoreConfig::global_config_source(), + Some(xdg.path().join("config.toml")) + ); + assert!(!DiffcoreConfig::using_legacy_global_config()); + + std::env::remove_var("DIFFCORE_CONFIG_HOME"); + match prev_home { + Some(home) => std::env::set_var("HOME", home), + None => std::env::remove_var("HOME"), + } + } + + #[test] + fn ui_config_partial_fields_fall_back_to_defaults() { + let ui: UiConfig = toml::from_str("theme_mode = \"system\"\n").unwrap(); + assert_eq!(ui.theme_mode, ThemeMode::System); + assert_eq!(ui.right_panel_width, 320); + assert_eq!(ui.theme_dark, "catppuccin-mocha"); + } + + #[test] + fn ui_config_rejects_unknown_theme_mode() { + assert!(toml::from_str::("theme_mode = \"neon\"\n").is_err()); + } + + #[test] + fn ui_config_clamps_absurd_panel_width() { + let mut wide: UiConfig = toml::from_str("right_panel_width = 4000000\n").unwrap(); + wide.clamp(); + assert_eq!(wide.right_panel_width, MAX_PANEL_WIDTH); + let mut narrow: UiConfig = toml::from_str("right_panel_width = 1\n").unwrap(); + narrow.clamp(); + assert_eq!(narrow.right_panel_width, MIN_PANEL_WIDTH); + } + + /// A negative width must not be able to break the whole prefs file. + #[test] + fn ui_config_survives_invalid_toml() { + assert!(toml::from_str::("right_panel_width = -5\n").is_err()); + } + + /// `.diffcore.toml` comes from a potentially untrusted cloned repo; it must + /// not be able to carry UI preferences at all. + #[test] + fn project_config_does_not_carry_ui_prefs() { + // Unknown sections are ignored by serde, which is what we want: a + // cloned repo's `[ui]` is inert rather than an error the user must fix. + let config = DiffcoreConfig::from_str("[ui]\ntheme_mode = \"light\"\n").unwrap(); + let reserialized = toml::to_string_pretty(&config).unwrap(); + assert!( + !reserialized.contains("[ui]"), + "UI prefs must not round-trip through project config" + ); } // ── auto_subtrees Tests ── diff --git a/crates/diffcore-core/src/embeddings.rs b/crates/diffcore-core/src/embeddings.rs index f8c764d..1ecd0be 100644 --- a/crates/diffcore-core/src/embeddings.rs +++ b/crates/diffcore-core/src/embeddings.rs @@ -73,10 +73,9 @@ impl EmbeddingCache { } } - /// Default cache location: `~/.cache/diffcore/embeddings/` + /// Default cache location: `$XDG_CACHE_HOME/diffcore/embeddings/` pub fn default_cache() -> Self { - let dir = dirs_fallback().join("diffcore").join("embeddings"); - Self::new(&dir) + Self::new(&embeddings_cache_dir()) } /// Compute a cache key for a file path + content pair. @@ -136,12 +135,11 @@ impl EmbeddingCache { } } -/// Fallback for cache directory — uses `~/.cache` on all platforms. -fn dirs_fallback() -> PathBuf { - if let Some(home) = std::env::var_os("HOME") { - PathBuf::from(home).join(".cache") - } else { - PathBuf::from("/tmp") +/// Embeddings cache directory, honouring XDG like every other per-user path. +fn embeddings_cache_dir() -> PathBuf { + match crate::paths::cache_dir() { + Some(dir) => dir.join("embeddings"), + None => std::env::temp_dir().join("diffcore-embeddings"), } } diff --git a/crates/diffcore-core/src/lib.rs b/crates/diffcore-core/src/lib.rs index 3142bed..4c196a2 100644 --- a/crates/diffcore-core/src/lib.rs +++ b/crates/diffcore-core/src/lib.rs @@ -23,6 +23,7 @@ pub mod llm; #[cfg(feature = "logging")] pub mod logging; pub mod output; +pub mod paths; pub mod pipeline; pub mod pr_url; pub mod query_engine; diff --git a/crates/diffcore-core/src/llm/mod.rs b/crates/diffcore-core/src/llm/mod.rs index bda7507..c452b58 100644 --- a/crates/diffcore-core/src/llm/mod.rs +++ b/crates/diffcore-core/src/llm/mod.rs @@ -35,7 +35,7 @@ use schema::{ /// Errors that can occur during LLM operations. #[derive(Debug, thiserror::Error)] pub enum LlmError { - #[error("No API key found. Set DIFFCORE_API_KEY, configure key_cmd in ~/.diffcore/config.toml or .diffcore.toml, or set provider-specific env var ({0})")] + #[error("No API key found. Set DIFFCORE_API_KEY, configure key_cmd in the global config or .diffcore.toml, or set provider-specific env var ({0})")] NoApiKey(String), #[error("HTTP request failed: {0}")] diff --git a/crates/diffcore-core/src/paths.rs b/crates/diffcore-core/src/paths.rs new file mode 100644 index 0000000..96f9da8 --- /dev/null +++ b/crates/diffcore-core/src/paths.rs @@ -0,0 +1,147 @@ +//! XDG base directory resolution for diffcore's per-user state. +//! +//! Config, cache and state each get their own root, following the XDG Base +//! Directory spec on every unix (macOS included — this matches git/nvim +//! convention rather than `~/Library/Application Support`). `USERPROFILE` is +//! the last resort so Windows resolves to something instead of nothing. +//! +//! Repo-local `/.diffcore/` is project state and is not resolved here. + +use std::path::PathBuf; + +/// Per-user config root: `$DIFFCORE_CONFIG_HOME`, `$XDG_CONFIG_HOME/diffcore`, +/// or `$HOME/.config/diffcore`. +pub fn config_dir() -> Option { + resolve("DIFFCORE_CONFIG_HOME", "XDG_CONFIG_HOME", &[".config"]) +} + +/// Per-user cache root: `$DIFFCORE_CACHE_HOME`, `$XDG_CACHE_HOME/diffcore`, +/// or `$HOME/.cache/diffcore`. +pub fn cache_dir() -> Option { + resolve("DIFFCORE_CACHE_HOME", "XDG_CACHE_HOME", &[".cache"]) +} + +/// Per-user data root: `$DIFFCORE_DATA_HOME`, `$XDG_DATA_HOME/diffcore`, +/// or `$HOME/.local/share/diffcore`. +/// +/// For state the user authored and would be upset to lose. Unlike +/// [`cache_dir`], which the XDG spec declares safe for anything to delete. +pub fn data_dir() -> Option { + resolve("DIFFCORE_DATA_HOME", "XDG_DATA_HOME", &[".local", "share"]) +} + +/// Per-user state root: `$DIFFCORE_STATE_HOME`, `$XDG_STATE_HOME/diffcore`, +/// or `$HOME/.local/state/diffcore`. +pub fn state_dir() -> Option { + resolve( + "DIFFCORE_STATE_HOME", + "XDG_STATE_HOME", + &[".local", "state"], + ) +} + +/// Pre-XDG location (`$HOME/.diffcore`), kept as a read-only fallback so +/// existing installs keep their config. Never written to. +pub fn legacy_dir() -> Option { + home().map(|home| home.join(".diffcore")) +} + +fn home() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) +} + +fn resolve(override_var: &str, xdg_var: &str, home_suffix: &[&str]) -> Option { + resolve_from( + std::env::var_os(override_var).map(PathBuf::from), + std::env::var_os(xdg_var).map(PathBuf::from), + home(), + home_suffix, + ) +} + +/// Pure core of [`resolve`], with the environment passed in. +/// +/// The XDG spec says a relative `$XDG_*_HOME` is invalid and must be ignored. +fn resolve_from( + override_home: Option, + xdg_home: Option, + home: Option, + home_suffix: &[&str], +) -> Option { + if let Some(path) = override_home.filter(|path| !path.as_os_str().is_empty()) { + return Some(path); + } + if let Some(path) = xdg_home.filter(|path| path.is_absolute()) { + return Some(path.join("diffcore")); + } + let mut path = home?; + for segment in home_suffix { + path.push(segment); + } + path.push("diffcore"); + Some(path) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::path::Path; + + fn config( + override_home: Option<&str>, + xdg: Option<&str>, + home: Option<&str>, + ) -> Option { + resolve_from( + override_home.map(PathBuf::from), + xdg.map(PathBuf::from), + home.map(PathBuf::from), + &[".config"], + ) + } + + #[test] + fn override_wins_over_everything() { + let got = config(Some("/opt/dc"), Some("/xdg"), Some("/home/u")).unwrap(); + assert_eq!(got, Path::new("/opt/dc")); + } + + #[test] + fn xdg_home_gets_diffcore_suffix() { + let got = config(None, Some("/xdg"), Some("/home/u")).unwrap(); + assert_eq!(got, Path::new("/xdg/diffcore")); + } + + #[test] + fn relative_xdg_home_is_ignored() { + let got = config(None, Some("relative/xdg"), Some("/home/u")).unwrap(); + assert_eq!(got, Path::new("/home/u/.config/diffcore")); + } + + #[test] + fn empty_override_falls_through() { + let got = config(Some(""), None, Some("/home/u")).unwrap(); + assert_eq!(got, Path::new("/home/u/.config/diffcore")); + } + + #[test] + fn no_home_resolves_to_nothing() { + assert!(config(None, None, None).is_none()); + } + + #[test] + fn state_suffix_is_nested() { + let got = resolve_from( + None, + None, + Some(PathBuf::from("/home/u")), + &[".local", "state"], + ) + .unwrap(); + assert_eq!(got, Path::new("/home/u/.local/state/diffcore")); + } +} diff --git a/crates/diffcore-core/src/pr_url.rs b/crates/diffcore-core/src/pr_url.rs index a3c5e7f..b8e02d9 100644 --- a/crates/diffcore-core/src/pr_url.rs +++ b/crates/diffcore-core/src/pr_url.rs @@ -528,7 +528,7 @@ fn lock_cache(dir: &Path) -> Result { pub fn resolve(pr: &PrUrl) -> Result { let root = std::env::var_os("DIFFCORE_REPO_CACHE_DIR") .map(PathBuf::from) - .or_else(|| crate::config::diffcore_config_home().map(|h| h.join("cache").join("repos"))) + .or_else(|| crate::paths::cache_dir().map(|dir| dir.join("repos"))) .ok_or(PrUrlError::NoCacheDir)?; resolve_in(pr, &root) } diff --git a/crates/diffcore-tauri/src/commands.rs b/crates/diffcore-tauri/src/commands.rs index 89fa27e..72f84cc 100644 --- a/crates/diffcore-tauri/src/commands.rs +++ b/crates/diffcore-tauri/src/commands.rs @@ -3,7 +3,7 @@ //! Each `#[cfg_attr(feature = "desktop", tauri::command)]` function is callable from the frontend via `invoke()`. use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -19,7 +19,7 @@ use crate::state_shim::State; use crate::activity_stream::{self, ActivityEntry, JobHandle}; use diffcore_core::cache; use diffcore_core::cluster; -use diffcore_core::config::DiffcoreConfig; +use diffcore_core::config::{DiffcoreConfig, UiConfig}; use diffcore_core::entrypoint; use diffcore_core::flow::{self, FlowConfig}; use diffcore_core::git; @@ -1674,7 +1674,7 @@ pub fn get_cached_refinement( Ok(None) } -/// Store a refinement result in the global cache (~/.diffcore/cache/refinements/). +/// Store a refinement result in the global cache ($XDG_CACHE_HOME/diffcore/refinements/). /// /// Stores under both diff-hash key and branch-based key for cross-worktree access. #[cfg_attr(feature = "desktop", tauri::command)] @@ -1793,7 +1793,7 @@ pub fn check_api_key(repo_path: Option) -> Result { /// Get LLM settings from the shared global config plus repo-local overrides. /// -/// Reads `~/.diffcore/config.toml`, merges in any repo-local `[llm]` overrides, resolves +/// Reads the global config, merges in any repo-local `[llm]` overrides, resolves /// CLI/API availability, and returns a unified `LlmSettings` struct for the settings panel. #[cfg_attr(feature = "desktop", tauri::command)] pub fn get_llm_settings(repo_path: Option) -> Result { @@ -1813,6 +1813,8 @@ pub fn get_llm_settings(repo_path: Option) -> Result llm::resolve_api_key(&config.llm, &provider).is_ok(), }; + let api_key_in_config = config.llm.key.as_ref().is_some_and(|k| !k.is_empty()); + let api_key_source = match provider.as_str() { "codex" => match (codex_status.installed, codex_status.authenticated) { (true, true) => "Codex CLI login".to_string(), @@ -1825,9 +1827,7 @@ pub fn get_llm_settings(repo_path: Option) -> Result "Claude Code not installed".to_string(), }, _ if config.llm.key_cmd.is_some() => "key_cmd".to_string(), - _ if config.llm.key.as_ref().is_some_and(|k| !k.is_empty()) => { - "~/.diffcore/config.toml".to_string() - } + _ if api_key_in_config => display_global_config_path(), _ if std::env::var("DIFFCORE_API_KEY").is_ok() => "DIFFCORE_API_KEY".to_string(), _ => { let env_var = match provider.as_str() { @@ -1839,7 +1839,7 @@ pub fn get_llm_settings(repo_path: Option) -> Result) -> Result) -> Result Result<(), CommandError> { let mut config = @@ -1916,7 +1917,7 @@ pub fn save_llm_settings(_repo_path: String, settings: LlmSettings) -> Result<() Ok(()) } -/// Save an API key to `~/.diffcore/config.toml` under `[llm] key = "..."`. +/// Save an API key to the global config under `[llm] key = "..."`. /// /// The key is stored directly in the config file. Precedence is maintained: /// `key_cmd` > `key` (config) > env vars. @@ -1934,7 +1935,7 @@ pub fn save_api_key(_repo_path: String, api_key: String) -> Result<(), CommandEr Ok(()) } -/// Remove the stored API key from `~/.diffcore/config.toml`. +/// Remove the stored API key from the global config. #[cfg_attr(feature = "desktop", tauri::command)] pub fn clear_api_key(_repo_path: String) -> Result<(), CommandError> { let mut config = @@ -1949,6 +1950,26 @@ pub fn clear_api_key(_repo_path: String) -> Result<(), CommandError> { Ok(()) } +/// Get UI preferences from `ui.toml`. +/// +/// Per-user, not per-repo: the same theme and layout apply to every repository +/// and to both the desktop app and the web build. +#[cfg_attr(feature = "desktop", tauri::command)] +pub fn get_ui_settings() -> Result { + Ok(UiConfig::load()) +} + +/// Save UI preferences to `ui.toml`. +/// +/// Deliberately does not touch `config.toml`: that file holds the API key and +/// must not be rewritten on every theme toggle. +#[cfg_attr(feature = "desktop", tauri::command)] +pub fn save_ui_settings(settings: UiConfig) -> Result<(), CommandError> { + settings + .save() + .map_err(|e| CommandError::Config(format!("Failed to save UI settings: {}", e))) +} + /// Get the current ignore paths from `.diffcore.toml`. #[cfg_attr(feature = "desktop", tauri::command)] pub fn get_ignore_paths(repo_path: Option) -> Result, CommandError> { @@ -2301,10 +2322,32 @@ fn preferred_model_for_runtime( } } +/// Path to show the user for "where your settings live". +/// +/// Reports the file actually being read, which during the migration window is +/// the pre-XDG one — [`DiffcoreConfig::global_config_path`] names the write +/// target and would point at a file that does not exist yet. fn display_global_config_path() -> String { - DiffcoreConfig::global_config_path() - .map(|path| path.to_string_lossy().to_string()) - .unwrap_or_else(|| "~/.diffcore/config.toml".to_string()) + DiffcoreConfig::global_config_source() + .or_else(DiffcoreConfig::global_config_path) + .map(|path| abbreviate_home(&path)) + .unwrap_or_else(|| "~/.config/diffcore/config.toml".to_string()) +} + +/// Render `/home/you/.config/...` as `~/.config/...` for display. +fn abbreviate_home(path: &Path) -> String { + let display = path.to_string_lossy().to_string(); + let Some(home) = std::env::var_os("HOME") else { + return display; + }; + let home = home.to_string_lossy().to_string(); + if home.is_empty() { + return display; + } + match display.strip_prefix(&home) { + Some(rest) => format!("~{}", rest), + None => display, + } } /// LLM settings for the UI — surface for the settings panel. @@ -2334,6 +2377,9 @@ pub struct LlmSettings { pub refinement_model: String, /// Where shared LLM settings are stored. pub global_config_path: String, + /// Whether a key is stored in the config file (so the UI can offer to + /// clear it). A flag, not a string comparison against a display path. + pub api_key_in_config: bool, /// Whether Codex CLI is installed. pub codex_available: bool, /// Whether Codex CLI is logged in and ready. @@ -2696,7 +2742,7 @@ fn load_comments_from_file(path: &PathBuf, analysis_hash: &str) -> CommentsFile } // ══════════════════════════════════════════════════════════════════════ -// Branch-based comment cache (~/.diffcore/cache/comments/) +// Branch-based comment store ($XDG_DATA_HOME/diffcore/comments/) // ══════════════════════════════════════════════════════════════════════ /// Resolve the global comment cache directory. @@ -2704,14 +2750,28 @@ fn load_comments_from_file(path: &PathBuf, analysis_hash: &str) -> CommentsFile fn comment_cache_dir() -> Option { std::env::var_os("DIFFCORE_COMMENT_CACHE_DIR") .map(PathBuf::from) - .or_else(|| { - std::env::var_os("HOME").map(|home| { - PathBuf::from(home) - .join(".diffcore") - .join("cache") - .join("comments") - }) - }) + .or_else(|| diffcore_core::paths::data_dir().map(|dir| dir.join("comments"))) +} + +/// Pre-XDG comment location, read-only. Comments are user-authored review +/// notes; silently orphaning them on upgrade would look like data loss. +fn legacy_comment_cache_dir() -> Option { + diffcore_core::paths::legacy_dir().map(|dir| dir.join("cache").join("comments")) +} + +/// Resolve the file holding comments for `cache_key`, preferring the current +/// location and falling back to the pre-XDG one. Writes always use the former. +fn comment_cache_file_for_read(cache_key: &str) -> Option { + let name = format!("{}.json", cache_key); + let current = comment_cache_dir().map(|dir| dir.join(&name)); + if current.as_ref().is_some_and(|path| path.exists()) { + return current; + } + let legacy = legacy_comment_cache_dir().map(|dir| dir.join(&name)); + if legacy.as_ref().is_some_and(|path| path.exists()) { + return legacy; + } + current } /// Compute a cache key for a repo+branch combo. @@ -2776,10 +2836,9 @@ struct CachedCommentsFile { /// Load comments for the current repo+branch from the global cache. fn load_cached_comments_file(cache_key: &str) -> CachedCommentsFile { - let Some(dir) = comment_cache_dir() else { + let Some(path) = comment_cache_file_for_read(cache_key) else { return CachedCommentsFile { comments: vec![] }; }; - let path = dir.join(format!("{}.json", cache_key)); match std::fs::read_to_string(&path) { Ok(data) => serde_json::from_str(&data).unwrap_or(CachedCommentsFile { comments: vec![] }), Err(_) => CachedCommentsFile { comments: vec![] }, @@ -3242,6 +3301,7 @@ mod tests { refinement_provider: "claude".to_string(), refinement_model: "default".to_string(), global_config_path: "~/.diffcore/config.toml".to_string(), + api_key_in_config: false, codex_available: true, codex_authenticated: true, claude_available: true, @@ -4128,6 +4188,7 @@ mod tests { refinement_provider: "codex".to_string(), refinement_model: "default".to_string(), global_config_path: "~/.diffcore/config.toml".to_string(), + api_key_in_config: false, codex_available: false, codex_authenticated: false, claude_available: false, @@ -4153,6 +4214,7 @@ mod tests { refinement_provider: "anthropic".to_string(), refinement_model: "claude-sonnet-4-6".to_string(), global_config_path: "/tmp/config.toml".to_string(), + api_key_in_config: false, codex_available: true, codex_authenticated: true, claude_available: true, @@ -4178,6 +4240,7 @@ mod tests { refinement_provider: "gemini".to_string(), refinement_model: "gemini-2.5-flash".to_string(), global_config_path: "~/.diffcore/config.toml".to_string(), + api_key_in_config: false, codex_available: true, codex_authenticated: false, claude_available: true, diff --git a/crates/diffcore-tauri/src/main.rs b/crates/diffcore-tauri/src/main.rs index 54a4041..f8c7eed 100644 --- a/crates/diffcore-tauri/src/main.rs +++ b/crates/diffcore-tauri/src/main.rs @@ -42,9 +42,10 @@ fn main() { // Used only when stderr is not a terminal: GUI bundles discard it (no // console on Windows release, Finder/.desktop launches drop it). Launched // from a terminal, the desktop app still logs there. - // `~/.diffcore` matches the cache convention and is not world-writable. - let log_file = std::env::var_os("HOME") - .map(|home| std::path::PathBuf::from(home).join(".diffcore").join("desktop.log")) + // `$XDG_STATE_HOME/diffcore` is the XDG home for logs and is not + // world-writable; the temp dir is a last resort when it cannot be resolved. + let log_file = diffcore_core::paths::state_dir() + .map(|dir| dir.join("desktop.log")) .unwrap_or_else(|| std::env::temp_dir().join("diffcore-desktop.log")); diffcore_core::logging::init(Some(log_file)); if let Err(e) = tauri::Builder::default() @@ -87,6 +88,8 @@ fn main() { commands::delete_comment, commands::load_comments, commands::export_comments, + commands::get_ui_settings, + commands::save_ui_settings, commands::get_ignore_paths, commands::save_ignore_paths, commands::get_cached_refinement, diff --git a/crates/diffcore-tauri/src/web_server.rs b/crates/diffcore-tauri/src/web_server.rs index 76de917..120ccc6 100644 --- a/crates/diffcore-tauri/src/web_server.rs +++ b/crates/diffcore-tauri/src/web_server.rs @@ -363,6 +363,8 @@ fn dispatch_sync(cmd: &str, args: &mut Args, app: &AppState) -> Result ok(commands::clear_api_key(opt(args, "repoPath")?)?), + "get_ui_settings" => ok(commands::get_ui_settings()?), + "save_ui_settings" => ok(commands::save_ui_settings(req(args, "settings")?)?), "get_ignore_paths" => ok(commands::get_ignore_paths(opt(args, "repoPath")?)?), "save_ignore_paths" => ok(commands::save_ignore_paths( req(args, "repoPath")?, diff --git a/crates/diffcore-tauri/tests/comment_integration.rs b/crates/diffcore-tauri/tests/comment_integration.rs index 721bb1c..063538a 100644 --- a/crates/diffcore-tauri/tests/comment_integration.rs +++ b/crates/diffcore-tauri/tests/comment_integration.rs @@ -3,7 +3,7 @@ //! //! Tests the full lifecycle: save → load → delete → export //! using real git repositories and the `.diffcore/comments.json` file. -//! Also tests branch-based comment caching in `~/.diffcore/cache/comments/`. +//! Also tests the branch-based comment store in `$XDG_DATA_HOME/diffcore/comments/`. //! //! Run with: //! cargo test --test comment_integration @@ -830,3 +830,78 @@ fn refinement_cache_branch_key_deterministic() { assert_eq!(key1, key2); assert_eq!(key1.len(), 64); // SHA-256 hex } + +// ── Pre-XDG comment migration ─────────────────────────────────────── +// +// Comments are user-authored review notes, not regenerable cache. Moving the +// store must not orphan them: `load_cached_comments_file` returns an empty +// list on any read failure, so an unreachable file looks like deletion. + +/// Set HOME + XDG_DATA_HOME (no `DIFFCORE_COMMENT_CACHE_DIR`, which would +/// short-circuit the fallback being tested). Shares ENV_LOCK with the others. +fn with_home_and_data_dir(home: &std::path::Path, data: &std::path::Path, f: F) { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev_home = std::env::var_os("HOME"); + let prev_data = std::env::var_os("XDG_DATA_HOME"); + std::env::remove_var("DIFFCORE_COMMENT_CACHE_DIR"); + std::env::set_var("HOME", home.as_os_str()); + std::env::set_var("XDG_DATA_HOME", data.as_os_str()); + f(); + match prev_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match prev_data { + Some(v) => std::env::set_var("XDG_DATA_HOME", v), + None => std::env::remove_var("XDG_DATA_HOME"), + } +} + +#[test] +fn legacy_comments_survive_the_move_to_xdg_data_home() { + let home = tempfile::tempdir().unwrap(); + let data = tempfile::tempdir().unwrap(); + let (_repo_tmp, repo_path) = create_test_repo_on_branch("feature-legacy"); + + with_home_and_data_dir(home.path(), data.path(), || { + let key = comment_cache_key(&repo_path).unwrap(); + + // A comment written by a pre-XDG install. + let legacy_dir = home.path().join(".diffcore").join("cache").join("comments"); + std::fs::create_dir_all(&legacy_dir).unwrap(); + // Serialised from the real struct: a hand-written shape that fails to + // deserialise would silently read as "no comments" and pass a weaker test. + let legacy_payload = serde_json::json!({ + "comments": [make_comment("old", "file", "pre-XDG note")], + }); + std::fs::write( + legacy_dir.join(format!("{}.json", key)), + serde_json::to_string(&legacy_payload).unwrap(), + ) + .unwrap(); + + let loaded = load_comments_cached(repo_path.clone()).unwrap(); + assert_eq!(loaded.len(), 1, "pre-XDG comments must remain reachable"); + assert_eq!(loaded[0].text, "pre-XDG note"); + + // Writing goes to the new location; the old file is left alone. + let comment = make_comment("new", "code", "post-XDG note"); + save_comment_cached(repo_path.clone(), comment).unwrap(); + assert!( + data.path() + .join("diffcore") + .join("comments") + .join(format!("{}.json", key)) + .exists(), + "writes must go to XDG_DATA_HOME" + ); + assert!( + legacy_dir.join(format!("{}.json", key)).exists(), + "the pre-XDG file must never be moved or deleted" + ); + + // Once the new file exists it wins. + let loaded = load_comments_cached(repo_path.clone()).unwrap(); + assert!(loaded.iter().any(|c| c.id == "new")); + }); +} diff --git a/crates/diffcore-tauri/ui/src/App.tsx b/crates/diffcore-tauri/ui/src/App.tsx index 1c197bd..7b2c173 100644 --- a/crates/diffcore-tauri/ui/src/App.tsx +++ b/crates/diffcore-tauri/ui/src/App.tsx @@ -18,6 +18,7 @@ import type { ReviewComment, CommentInput, InfraSubGroup, + UiConfig, } from "./types"; import { LLM_PROVIDERS, MODELS_BY_PROVIDER } from "./types"; import DiffViewer, { type DiffViewerHandle } from "./components/DiffViewer"; @@ -27,11 +28,14 @@ import SourceExplorer, { type SourceFocusRequest } from "./components/SourceExpl // import RiskHeatmap from "./components/RiskHeatmap"; import ErrorBoundary from "./components/ErrorBoundary"; import { buildManifestPrompt } from "./buildManifestPrompt"; -import { THEMES, applyTheme, getTheme, loadThemePrefs, saveThemePrefs, resolveThemeId, type ThemeMode, type ThemePrefs } from "./themes"; +import { THEMES, applyTheme, getTheme, loadThemePrefs, saveThemePrefs, normalizeThemePrefs, resolveThemeId, type ThemeMode, type ThemePrefs } from "./themes"; import { MOCK_ANALYSIS, MOCK_DIFFS, MOCK_PASS2, MOCK_REPO_INFO, MOCK_LLM_SETTINGS, MOCK_REFINEMENT, MOCK_RESOLVED_PR } from "./mock"; import { IS_TAURI, HAS_BACKEND, DEFAULT_REPO, invoke as tauriInvoke } from "./backend"; +/** Panel width bounds, mirroring MIN/MAX_PANEL_WIDTH in diffcore-core. */ +const clampPanelWidth = (width: number) => Math.max(200, Math.min(800, width)); + const PROVIDER_LABELS: Record = { codex: "Codex CLI", claude: "Claude Code", @@ -286,13 +290,70 @@ export default function App() { applyTheme(activeThemeId); }, [activeThemeId]); + // Global config is the source of truth for UI prefs; localStorage is only a + // synchronous cache so the first paint uses the right theme. Demo mode has no + // backend, so it stays localStorage-only. + const uiSettingsRef = useRef(null); + // Set as soon as the user changes a pref. Guards both directions of the + // load race: before the fetch lands we must not write (we'd overwrite the + // fields we haven't read yet), and once the user has acted the resolved + // fetch must not revert them. + const uiTouchedRef = useRef(false); + + const persistUiSettings = useCallback((patch: Partial) => { + if (!HAS_BACKEND) return; + uiTouchedRef.current = true; + const current = uiSettingsRef.current; + // No baseline yet: writing would clobber unread fields with guessed + // defaults. localStorage still holds the theme, and the next change + // after load persists everything. + if (!current) return; + const next = { ...current, ...patch }; + uiSettingsRef.current = next; + // Fire-and-forget: a failed write must never block the interaction. + tauriInvoke("save_ui_settings", { settings: next }).catch((e) => { + console.warn("Failed to persist UI settings", e); + }); + }, []); + + useEffect(() => { + if (!HAS_BACKEND) return; + let cancelled = false; + tauriInvoke("get_ui_settings") + .then((ui) => { + if (cancelled) return; + uiSettingsRef.current = ui; + if (uiTouchedRef.current) return; + const prefs = normalizeThemePrefs({ + mode: ui.theme_mode, + light: ui.theme_light, + dark: ui.theme_dark, + }); + setThemePrefs(prefs); + saveThemePrefs(prefs); + setRightPanelWidth(clampPanelWidth(ui.right_panel_width)); + setRightPanelCollapsed(ui.right_panel_collapsed); + }) + .catch((e) => { + console.warn("Failed to load UI settings; using local defaults", e); + }); + return () => { + cancelled = true; + }; + }, []); + const updateThemePrefs = useCallback((patch: Partial) => { setThemePrefs((prev) => { const next = { ...prev, ...patch }; saveThemePrefs(next); + persistUiSettings({ + theme_mode: next.mode, + theme_light: next.light, + theme_dark: next.dark, + }); return next; }); - }, []); + }, [persistUiSettings]); // LLM settings const [llmSettings, setLlmSettings] = useState(null); @@ -359,11 +420,21 @@ export default function App() { // Right panel collapse/resize state const [rightPanelCollapsed, setRightPanelCollapsed] = useState(false); + // Every collapse/expand routes through here so the pref is persisted once, + // not at each of the three call sites. + const updateRightPanelCollapsed = useCallback( + (collapsed: boolean) => { + setRightPanelCollapsed(collapsed); + persistUiSettings({ right_panel_collapsed: collapsed }); + }, + [persistUiSettings], + ); const [rightPanelWidth, setRightPanelWidth] = useState(320); const rightPanelDragging = useRef(false); const rightPanelStartX = useRef(0); const rightPanelStartWidth = useRef(0); const rightPanelRafId = useRef(0); + const rightPanelLatestWidth = useRef(320); // Groups manifest watching state const [watchedManifestPath, setWatchedManifestPath] = useState(null); @@ -384,7 +455,8 @@ export default function App() { cancelAnimationFrame(rightPanelRafId.current); rightPanelRafId.current = requestAnimationFrame(() => { const delta = rightPanelStartX.current - clientX; - const newWidth = Math.max(200, Math.min(800, rightPanelStartWidth.current + delta)); + const newWidth = clampPanelWidth(rightPanelStartWidth.current + delta); + rightPanelLatestWidth.current = newWidth; setRightPanelWidth(newWidth); }); }; @@ -395,6 +467,7 @@ export default function App() { document.body.style.cursor = ""; document.body.style.userSelect = ""; document.querySelector(".panel-right")?.classList.remove("panel-right-dragging"); + persistUiSettings({ right_panel_width: rightPanelLatestWidth.current }); } }; window.addEventListener("mousemove", onMouseMove); @@ -403,13 +476,14 @@ export default function App() { window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); }; - }, []); + }, [persistUiSettings]); const startRightPanelDrag = useCallback((e: React.MouseEvent) => { e.preventDefault(); rightPanelDragging.current = true; rightPanelStartX.current = e.clientX; rightPanelStartWidth.current = rightPanelWidth; + rightPanelLatestWidth.current = rightPanelWidth; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; document.querySelector(".panel-right")?.classList.add("panel-right-dragging"); @@ -1513,7 +1587,8 @@ export default function App() { annotations_enabled: true, provider: apiProviderDraft, model: selectedApiModel, - api_key_source: "~/.diffcore/config.toml", + api_key_source: llmSettings.global_config_path, + api_key_in_config: true, has_api_key: true, refinement_provider: apiProviderDraft, refinement_model: selectedApiModel, @@ -3649,11 +3724,11 @@ export default function App() { className="btn btn-save-key" disabled={!apiKeyInput.trim()} onClick={handleSaveApiKey} - title="Save API key to ~/.diffcore/config.toml" + title={`Save API key to ${llmSettings.global_config_path}`} > Save - {llmSettings.api_key_source === "~/.diffcore/config.toml" && ( + {llmSettings.api_key_in_config && (