//! Preferences that outlive the app, kept in //! `$XDG_CONFIG_HOME/vectrum-code/settings.json` and edited in the settings //! view. They're read once and cached, so rendering can consult them freely. use std::{path::PathBuf, sync::Mutex}; use serde::{Deserialize, Serialize}; use crate::models::Provider; /// Where a new thread starts: in the project folder, or in a git worktree of /// its own on a fresh branch. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum WorkspaceMode { #[default] Local, Worktree, } /// How a new worktree fills in git submodules. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Submodules { Skip, TopLevel, #[default] Recursive, } /// How a new worktree's branch is named from the thread's first message. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum BranchNaming { /// `branch_prefix` plus a few words the text model picks: `vectrum/add-search`. #[default] Static, /// The model picks a prefix that describes the work: `feat/add-search`. Semantic, /// `branch_instructions` tell the model the whole name. Custom, } /// What Ctrl+Q takes. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum QuitShortcut { /// One press quits ("Direct"). Press, /// Holding the keys, or pressing them twice quickly, quits. #[default] Hold, /// Two quick presses quit. Double, } /// Which keys send a prompt from the composer. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SendShortcut { /// Enter sends, Shift+Enter starts a new line. #[default] Enter, /// Enter sends a one-line prompt; once the prompt has several lines, /// Enter starts a new line and Ctrl+Enter sends. ModEnterMultiline, /// Ctrl+Enter sends, Enter starts a new line. ModEnter, } /// The colors diffs and change counts use for additions and deletions. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DiffColors { #[default] RedGreen, /// Easier to tell apart with red-green color blindness. BlueOrange, } /// How wide the conversation and the composer may grow on large screens. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ChatWidth { #[default] Comfortable, Wide, Full, } /// How projects are ordered in the sidebar and the project menus. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ProjectOrder { /// The project with the most recent message first. #[default] LastMessage, Name, Added, } /// Which of the two chosen themes shows. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ThemeMode { Light, Dark, /// Whichever the desktop is using. #[default] System, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TimeFormat { /// Whatever the locale prefers. #[default] System, Hour12, Hour24, } /// How an answer appears while the agent writes it. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Streaming { /// Every token as it arrives. Tokens, /// Each paragraph or code block once it's complete. #[default] Paragraphs, /// The whole answer once the turn ends. Turn, } /// What a message sent while the agent works does. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FollowUp { /// Hand it to the agent at once, so it can change course mid-turn. Steer, /// Hold it until the turn ends. #[default] Queue, } /// How eagerly the app checks on things in the background. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum BackgroundActivity { Always, /// Pauses while the window has been in the background for a while, or /// the machine saves power. #[default] Balanced, /// Pauses whenever the window is in the background. Minimal, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct EnvVar { pub key: String, /// Empty for a sensitive variable: its value lives in the secrets file. pub value: String, /// Kept out of `settings.json`, in a file only this user can read, and /// never shown again once saved. pub sensitive: bool, } /// A model added by hand to an instance's list, for models the agent /// doesn't report. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct CustomModel { /// What the agent is launched with: `claude-sonnet-5`, `anthropic/claude-sonnet-5`. pub slug: String, /// Empty shows the slug. pub name: String, } /// One configured agent: Claude Code or Pi, with its own binary, home /// folder, arguments and environment. There can be several of a kind, say /// two Claude accounts. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ProviderInstance { /// Stable id, used in model keys (`claude:opus`). The first instance of a /// kind is named after it. pub id: String, pub kind: Provider, /// Empty shows the kind's name. pub name: String, pub enabled: bool, /// Empty looks the binary up on PATH. pub binary: String, /// `CLAUDE_CONFIG_DIR` or `PI_CODING_AGENT_DIR`; empty is the default. pub home: String, /// Extra arguments, split like a shell would. pub args: String, pub env: Vec, /// A hex color (`#3e9bfa`) marking the instance in lists and pickers; /// empty for none. pub accent: String, /// Claude: compact the conversation after this many tokens (100,000 to /// 1,000,000); empty keeps Claude's default. pub auto_compact: String, pub custom_models: Vec, } impl Default for ProviderInstance { fn default() -> Self { Self::new(Provider::Claude, "claude") } } impl ProviderInstance { pub fn new(kind: Provider, id: &str) -> Self { Self { id: id.to_string(), kind, name: String::new(), enabled: true, binary: String::new(), home: String::new(), args: String::new(), env: Vec::new(), accent: String::new(), auto_compact: String::new(), custom_models: Vec::new(), } } pub fn label(&self) -> String { if self.name.trim().is_empty() { self.kind.label().to_string() } else { self.name.trim().to_string() } } /// The variables the agent runs with, sensitive values read from the /// secrets file. pub fn env_vars(&self) -> Vec<(String, String)> { let secrets = if self.env.iter().any(|var| var.sensitive) { read_secrets() } else { Default::default() }; self.env .iter() .filter(|var| !var.key.trim().is_empty()) .map(|var| { let key = var.key.trim().to_string(); let value = match var.sensitive { true => secrets.get(&secret_key(&self.id, &key)).cloned().unwrap_or_default(), false => var.value.clone(), }; (key, value) }) .collect() } } /// Another device's app this one connects to. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct RemoteHost { pub id: String, pub name: String, /// `host` or `host:port`. pub address: String, pub key: String, pub enabled: bool, } impl RemoteHost { pub fn label(&self) -> String { if self.name.trim().is_empty() { self.address.clone() } else { self.name.trim().to_string() } } } /// A usage hub the Usage page also reads, such as a CLIProxyAPI server. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct UsageHub { pub name: String, /// `cliproxy`. pub kind: String, pub url: String, /// The hub's management key. pub key: String, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default)] pub struct Settings { pub theme_mode: ThemeMode, /// The theme light mode uses, by id (`bone`, `catppuccin-latte`). pub light_theme: String, /// The theme dark mode uses, by id (`graphite`, `tokyo-night`). pub dark_theme: String, /// Model keys (`claude:opus`, `pi:anthropic/claude-sonnet-5-5`), in the /// order they were starred. pub favorites: Vec, /// Model keys the model picker leaves out. pub hidden_models: Vec, /// Model keys in the order the picker lists them; models not in it /// follow in the agent's order. pub model_order: Vec, // ---- new threads ---- /// The model key new threads start on. `None` follows the open thread. pub default_model: Option, /// `default`, an effort level (`high`), `ultracode` or `ultrathink`. /// `None` follows the open thread. pub default_reasoning: Option, /// A permission mode as the CLI spells it. `None` follows the open thread. pub default_access: Option, pub workspace: WorkspaceMode, pub submodules: Submodules, /// Base new worktrees on the branch as origin has it. pub start_from_origin: bool, /// How new worktree branches are named. pub branch_naming: BranchNaming, /// The prefix static branch names start with; empty for none. pub branch_prefix: String, /// What custom branch naming tells the model. pub branch_instructions: String, /// Where new worktrees go; empty is the app's data folder. pub worktrees_dir: String, /// Keep the branch and worktree strip under the composer once a thread /// has started, not only on new threads. pub composer_context: bool, /// Claude's plan mode as a Build/Plan toggle in the composer, with /// Shift+Tab to switch. pub plan_mode: bool, /// The ring showing how full the context window is. pub context_meter: bool, /// Where the Add project browser opens; empty is the home folder. pub add_project_dir: String, // ---- text generation ---- /// The model key thread titles are written with; empty keeps the first /// line of the first message. pub text_model: String, /// The reasoning level for generated text, as [`Settings::default_reasoning`]. pub text_reasoning: Option, // ---- conversation ---- /// Fold a finished turn's tool calls into one "Worked for" row. pub fold_turns: bool, /// Show what threads and turns cost. pub show_costs: bool, pub send_shortcut: SendShortcut, /// Send with Ctrl+Enter, so Enter starts a new line. Follows /// `send_shortcut` (true for [`SendShortcut::ModEnter`]). pub send_with_ctrl_enter: bool, pub follow_up: FollowUp, pub streaming: Streaming, /// List skills in the `/` menu too; `$` always lists them. pub slash_skills: bool, /// Highlight Markdown in the composer as you type. pub rich_composer: bool, /// Rest the composer to one line while scrolling a conversation. pub collapse_composer: bool, pub time_format: TimeFormat, /// Open the diff after a turn that changed enough. pub proactive_panels: bool, /// Ignore whitespace-only edits in diffs. pub hide_whitespace: bool, /// Open diffs with every file folded. pub diff_collapsed: bool, pub diff_side_by_side: bool, // ---- appearance ---- pub diff_colors: DiffColors, pub chat_width: ChatWidth, // ---- sidebar ---- /// One-line thread cards in the sidebar. pub compact_threads: bool, /// Per-project thread trees instead of the flat thread list. pub legacy_sidebar: bool, /// Group sidebar threads under their project. Follows `legacy_sidebar`. pub group_by_project: bool, /// Show a project open on several devices (this one and remote /// connections) as one project when they're the same repository. pub project_grouping: bool, pub project_order: ProjectOrder, /// Fold working threads into their own section until they need you. pub working_section: bool, /// Continue a thread stopped by a usage limit once the limit resets. pub auto_resume_limited: bool, /// Snooze a thread stopped by a usage limit until the limit resets. pub snooze_limited: bool, /// Settle a worktree thread once its pull request merges. pub auto_settle_merged: bool, pub auto_settle_inactive: bool, pub inactive_days: u32, // ---- confirmations ---- pub confirm_unpin: bool, /// Settling from a thread card takes a second click. pub confirm_settle: bool, pub confirm_delete: bool, pub quit_shortcut: QuitShortcut, // ---- notifications and background work ---- /// A desktop notification when a thread finishes or needs you while the /// window is in the background. pub notify: bool, /// Play a sound with each desktop notification. pub notify_sound: bool, /// A toast when another thread finishes or needs you while the window /// has focus. pub in_app_notifications: bool, /// Resume threads a restart or crash interrupted. pub continue_after_restart: bool, /// Check installed agents for newer versions. pub update_checks: bool, pub background_activity: BackgroundActivity, /// Seconds between provider health checks; 0 turns them off. pub health_check_secs: u64, // ---- integrations ---- /// Let agents use the browser panel's shared browser. pub agent_browser: bool, /// The editor "Open in" uses, by name (`Zed`). pub editor: Option, pub providers: Vec, pub usage_hubs: Vec, // ---- remote connections ---- /// Let other devices' apps connect to this one. pub remote_server: bool, pub remote_port: u16, /// The key other devices connect with; made when first needed. pub remote_key: String, /// Other devices' apps this one connects to. pub remotes: Vec, /// Superseded by `providers`; read once to carry an old path override over. #[serde(skip_serializing)] claude_bin: Option, #[serde(skip_serializing)] pi_bin: Option, /// Superseded by the theme fields: `bone`, `auto` or `graphite`. #[serde(skip_serializing)] colorway: Option, } impl Default for Settings { fn default() -> Self { Self { theme_mode: ThemeMode::System, light_theme: "bone".into(), dark_theme: "graphite".into(), favorites: Vec::new(), hidden_models: Vec::new(), model_order: Vec::new(), default_model: None, default_reasoning: None, default_access: None, workspace: WorkspaceMode::Local, submodules: Submodules::Recursive, start_from_origin: true, branch_naming: BranchNaming::Static, branch_prefix: "vectrum".into(), branch_instructions: String::new(), worktrees_dir: String::new(), composer_context: false, plan_mode: false, context_meter: false, add_project_dir: String::new(), text_model: "claude:haiku".into(), text_reasoning: Some("low".into()), fold_turns: true, show_costs: true, send_shortcut: SendShortcut::Enter, send_with_ctrl_enter: false, follow_up: FollowUp::Queue, streaming: Streaming::Paragraphs, slash_skills: true, rich_composer: true, collapse_composer: true, time_format: TimeFormat::System, proactive_panels: false, hide_whitespace: true, diff_collapsed: true, diff_side_by_side: false, diff_colors: DiffColors::RedGreen, chat_width: ChatWidth::Comfortable, compact_threads: false, legacy_sidebar: false, group_by_project: false, project_grouping: true, project_order: ProjectOrder::LastMessage, working_section: false, auto_resume_limited: false, snooze_limited: false, auto_settle_merged: true, auto_settle_inactive: false, inactive_days: 3, confirm_unpin: false, confirm_settle: false, confirm_delete: true, quit_shortcut: QuitShortcut::Hold, notify: false, notify_sound: false, in_app_notifications: false, continue_after_restart: false, update_checks: true, background_activity: BackgroundActivity::Balanced, health_check_secs: 300, agent_browser: true, editor: None, providers: Vec::new(), usage_hubs: Vec::new(), remote_server: false, remote_port: crate::remote::DEFAULT_PORT, remote_key: String::new(), remotes: Vec::new(), claude_bin: None, pi_bin: None, colorway: None, } } } impl Settings { /// Makes sure there's at least one instance of each kind, carrying old /// binary overrides over, and turns an old colorway into a theme mode. fn normalize(&mut self) { if let Some(colorway) = self.colorway.take() { self.theme_mode = match colorway.as_str() { "bone" => ThemeMode::Light, "graphite" => ThemeMode::Dark, _ => ThemeMode::System, }; } for (kind, old_bin) in [(Provider::Claude, self.claude_bin.take()), (Provider::Pi, self.pi_bin.take())] { if !self.providers.iter().any(|instance| instance.kind == kind) { let mut instance = ProviderInstance::new(kind, kind.key()); instance.binary = old_bin.unwrap_or_default(); self.providers.push(instance); } } // Fields older code reads, kept in step with the ones replacing them. self.send_with_ctrl_enter = self.send_shortcut == SendShortcut::ModEnter; self.group_by_project = self.legacy_sidebar; } /// The settings "Restore device defaults" would change, by their /// labels on the settings pages, in the order T3 Code lists them. pub fn differing_defaults(&self) -> Vec<&'static str> { let d = Settings::default(); let theme = (self.theme_mode, &self.light_theme, &self.dark_theme) != (d.theme_mode, &d.light_theme, &d.dark_theme); [ ("Theme", theme), ("Diff colors", self.diff_colors != d.diff_colors), ("Chat width", self.chat_width != d.chat_width), ("Time format", self.time_format != d.time_format), ("Thread notifications", (self.notify, self.notify_sound) != (d.notify, d.notify_sound)), ("In-app notifications", self.in_app_notifications != d.in_app_notifications), ("Project Grouping", self.project_grouping != d.project_grouping), ("Project order", self.project_order != d.project_order), ("Working section", self.working_section != d.working_section), ( "Auto-settle inactive threads", (self.auto_settle_inactive, self.inactive_days) != (d.auto_settle_inactive, d.inactive_days), ), ("Auto-settle merged threads", self.auto_settle_merged != d.auto_settle_merged), ("Auto-resume limited threads", self.auto_resume_limited != d.auto_resume_limited), ("Snooze limited threads", self.snooze_limited != d.snooze_limited), ("Composer context", self.composer_context != d.composer_context), ("Thread cards", self.compact_threads != d.compact_threads), ("Default diff file state", self.diff_collapsed != d.diff_collapsed), ("Diff whitespace changes", self.hide_whitespace != d.hide_whitespace), ("Diff layout", self.diff_side_by_side != d.diff_side_by_side), ("Proactive panels", self.proactive_panels != d.proactive_panels), ("Show skills in slash menu", self.slash_skills != d.slash_skills), ("Collapse composer on scroll", self.collapse_composer != d.collapse_composer), ("Rich text composer", self.rich_composer != d.rich_composer), ("Send shortcut", self.send_shortcut != d.send_shortcut), ("Follow-up behavior", self.follow_up != d.follow_up), ("Context window indicator", self.context_meter != d.context_meter), ("Plan mode", self.plan_mode != d.plan_mode), ("Sidebar", self.legacy_sidebar != d.legacy_sidebar), ("Response streaming", self.streaming != d.streaming), ("Fold finished turns", self.fold_turns != d.fold_turns), ("Show costs", self.show_costs != d.show_costs), ("Provider update checks", self.update_checks != d.update_checks), ("Continue threads after restarts", self.continue_after_restart != d.continue_after_restart), ("Background activity", self.background_activity != d.background_activity), ("Health check interval", self.health_check_secs != d.health_check_secs), ("Default model", (&self.default_model, &self.default_reasoning) != (&d.default_model, &d.default_reasoning)), ("Permissions", self.default_access != d.default_access), ("New thread mode", self.workspace != d.workspace), ("Submodules", self.submodules != d.submodules), ("New worktrees start from origin", self.start_from_origin != d.start_from_origin), ("Add project base directory", self.add_project_dir != d.add_project_dir), ("Unpin confirmation", self.confirm_unpin != d.confirm_unpin), ("Archive confirmation", self.confirm_settle != d.confirm_settle), ("Delete confirmation", self.confirm_delete != d.confirm_delete), ("Quit shortcut", self.quit_shortcut != d.quit_shortcut), ("Text generation model", (&self.text_model, &self.text_reasoning) != (&d.text_model, &d.text_reasoning)), ( "Worktree branch naming", (self.branch_naming, &self.branch_prefix, &self.branch_instructions) != (d.branch_naming, &d.branch_prefix, &d.branch_instructions), ), ("Worktree location", self.worktrees_dir != d.worktrees_dir), ("Agent browser access", self.agent_browser != d.agent_browser), ("Open in", self.editor != d.editor), ] .into_iter() .filter_map(|(label, differs)| differs.then_some(label)) .collect() } pub fn instance(&self, id: &str) -> Option<&ProviderInstance> { self.providers.iter().find(|instance| instance.id == id) } } static CACHE: Mutex> = Mutex::new(None); pub fn path() -> Option { let base = std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?; Some(base.join("vectrum-code").join("settings.json")) } fn read_file() -> Settings { parse(&path().and_then(|path| std::fs::read_to_string(path).ok()).unwrap_or_default()) } /// Reads a settings file's text, falling back to the defaults. fn parse(text: &str) -> Settings { let raw: serde_json::Value = serde_json::from_str(text).unwrap_or_default(); let mut settings: Settings = serde_json::from_value(raw.clone()).unwrap_or_default(); // Carry settings over from the fields that came before these. let missing = |key: &str| raw.get(key).is_none(); if missing("send_shortcut") && settings.send_with_ctrl_enter { settings.send_shortcut = SendShortcut::ModEnter; } if missing("legacy_sidebar") { settings.legacy_sidebar = settings.group_by_project; } settings.normalize(); settings } /// Reads one value out of the settings. pub fn get(read: impl FnOnce(&Settings) -> T) -> T { let mut cache = CACHE.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); read(cache.get_or_insert_with(read_file)) } pub fn load() -> Settings { get(Settings::clone) } /// The configured agents, enabled or not. pub fn instances() -> Vec { get(|settings| settings.providers.clone()) } /// One agent's configuration, falling back to a default for its kind. pub fn instance(id: &str, kind: Provider) -> ProviderInstance { get(|settings| settings.instance(id).cloned()).unwrap_or_else(|| ProviderInstance::new(kind, id)) } /// Applies `edit` to the settings and writes them back. pub fn update(edit: impl FnOnce(&mut Settings)) { let settings = { let mut cache = CACHE.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); let settings = cache.get_or_insert_with(read_file); edit(settings); settings.normalize(); settings.clone() }; let Some(path) = path() else { return }; let written = path .parent() .map(std::fs::create_dir_all) .unwrap_or(Ok(())) .and_then(|_| std::fs::write(&path, serde_json::to_string_pretty(&settings).unwrap_or_default())); if let Err(err) = written { eprintln!("vectrum-code: couldn't save {}: {err}", path.display()); } } /// Puts everything but favorites and model preferences, agents, usage hubs /// and remote connections back to its default. pub fn restore_defaults() { update(|settings| { let kept = Settings { favorites: std::mem::take(&mut settings.favorites), hidden_models: std::mem::take(&mut settings.hidden_models), model_order: std::mem::take(&mut settings.model_order), providers: std::mem::take(&mut settings.providers), usage_hubs: std::mem::take(&mut settings.usage_hubs), remote_server: settings.remote_server, remote_port: settings.remote_port, remote_key: std::mem::take(&mut settings.remote_key), remotes: std::mem::take(&mut settings.remotes), ..Settings::default() }; *settings = kept; }); } // ---- secrets --------------------------------------------------------------- /// Where sensitive environment variables are kept: next to the settings, /// readable by this user only. fn secrets_path() -> Option { Some(path()?.with_file_name("secrets.json")) } fn secret_key(instance: &str, var: &str) -> String { format!("{instance}/{var}") } fn read_secrets() -> std::collections::BTreeMap { secrets_path() .and_then(|path| std::fs::read_to_string(path).ok()) .and_then(|text| serde_json::from_str(&text).ok()) .unwrap_or_default() } /// Whether a sensitive variable has a stored value. pub fn has_secret(instance: &str, var: &str) -> bool { read_secrets().contains_key(&secret_key(instance, var.trim())) } /// Stores a sensitive variable's value, or forgets it with `None`. pub fn set_secret(instance: &str, var: &str, value: Option<&str>) { let mut secrets = read_secrets(); let key = secret_key(instance, var.trim()); match value { Some(value) => secrets.insert(key, value.to_string()), None => secrets.remove(&key), }; write_secrets(&secrets); } /// Keeps a sensitive variable's value when the variable is renamed. pub fn rename_secret(instance: &str, from: &str, to: &str) { let mut secrets = read_secrets(); if let Some(value) = secrets.remove(&secret_key(instance, from.trim())) { if !to.trim().is_empty() { secrets.insert(secret_key(instance, to.trim()), value); } write_secrets(&secrets); } } fn write_secrets(secrets: &std::collections::BTreeMap) { let Some(path) = secrets_path() else { return }; let written = path.parent().map(std::fs::create_dir_all).unwrap_or(Ok(())).and_then(|_| { use std::{io::Write as _, os::unix::fs::OpenOptionsExt as _}; let mut file = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&path)?; file.write_all(serde_json::to_string_pretty(&secrets).unwrap_or_default().as_bytes()) }); if let Err(err) = written { eprintln!("vectrum-code: couldn't save {}: {err}", path.display()); } } /// `~/bin/claude` → `/home/me/bin/claude`. pub fn expand_home(path: &str) -> PathBuf { match (path.strip_prefix("~/"), std::env::var_os("HOME")) { (Some(rest), Some(home)) => PathBuf::from(home).join(rest), _ if path == "~" => std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(path)), _ => PathBuf::from(path), } } /// Where the app keeps its own data: threads, worktrees, scratch space. pub fn data_dir() -> Option { let base = std::env::var_os("XDG_DATA_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))?; Some(base.join("vectrum-code")) } #[cfg(test)] mod tests { use super::{SendShortcut, ThemeMode, parse}; #[test] fn old_colorways_become_theme_modes() { for (colorway, mode) in [("bone", ThemeMode::Light), ("graphite", ThemeMode::Dark), ("auto", ThemeMode::System)] { let settings = parse(&format!(r#"{{"colorway": "{colorway}"}}"#)); assert_eq!(settings.theme_mode, mode); assert_eq!(settings.light_theme, "bone"); assert_eq!(settings.dark_theme, "graphite"); assert!(!serde_json::to_string(&settings).unwrap().contains("colorway")); } assert_eq!(parse("{}").theme_mode, ThemeMode::System); let saved = parse(r#"{"theme_mode": "dark", "dark_theme": "nord"}"#); assert_eq!((saved.theme_mode, saved.dark_theme.as_str()), (ThemeMode::Dark, "nord")); } #[test] fn older_fields_carry_over() { let old = parse(r#"{"send_with_ctrl_enter": true, "group_by_project": true}"#); assert_eq!(old.send_shortcut, SendShortcut::ModEnter); assert!(old.legacy_sidebar && old.group_by_project); // The new fields win once they're saved, and the old ones follow them. let new = parse(r#"{"send_with_ctrl_enter": true, "send_shortcut": "mod_enter_multiline", "legacy_sidebar": false}"#); assert_eq!(new.send_shortcut, SendShortcut::ModEnterMultiline); assert!(!new.send_with_ctrl_enter && !new.group_by_project && !new.legacy_sidebar); let fresh = parse("{}"); assert_eq!((fresh.send_shortcut, fresh.legacy_sidebar), (SendShortcut::Enter, false)); } #[test] fn restore_lists_what_changed() { let mut settings = parse("{}"); assert!(settings.differing_defaults().is_empty()); settings.chat_width = super::ChatWidth::Wide; settings.dark_theme = "nord".into(); assert_eq!(settings.differing_defaults(), ["Theme", "Chat width"]); } }