| @@ -45,11 +45,47 @@ pub enum BranchNaming { |
| 45 | 45 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 46 | 46 | #[serde(rename_all = "snake_case")] |
| 47 | 47 | pub enum QuitShortcut { |
| 48 | | - /// One press quits. |
| 48 | + /// One press quits ("Direct"). |
| 49 | 49 | Press, |
| 50 | 50 | /// Holding the keys, or pressing them twice quickly, quits. |
| 51 | 51 | #[default] |
| 52 | 52 | Hold, |
| 53 | + /// Two quick presses quit. |
| 54 | + Double, |
| 55 | + } |
| 56 | + |
| 57 | + /// Which keys send a prompt from the composer. |
| 58 | + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 59 | + #[serde(rename_all = "snake_case")] |
| 60 | + pub enum SendShortcut { |
| 61 | + /// Enter sends, Shift+Enter starts a new line. |
| 62 | + #[default] |
| 63 | + Enter, |
| 64 | + /// Enter sends a one-line prompt; once the prompt has several lines, |
| 65 | + /// Enter starts a new line and Ctrl+Enter sends. |
| 66 | + ModEnterMultiline, |
| 67 | + /// Ctrl+Enter sends, Enter starts a new line. |
| 68 | + ModEnter, |
| 69 | + } |
| 70 | + |
| 71 | + /// The colors diffs and change counts use for additions and deletions. |
| 72 | + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 73 | + #[serde(rename_all = "snake_case")] |
| 74 | + pub enum DiffColors { |
| 75 | + #[default] |
| 76 | + RedGreen, |
| 77 | + /// Easier to tell apart with red-green color blindness. |
| 78 | + BlueOrange, |
| 79 | + } |
| 80 | + |
| 81 | + /// How wide the conversation and the composer may grow on large screens. |
| 82 | + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 83 | + #[serde(rename_all = "snake_case")] |
| 84 | + pub enum ChatWidth { |
| 85 | + #[default] |
| 86 | + Comfortable, |
| 87 | + Wide, |
| 88 | + Full, |
| 53 | 89 | } |
| 54 | 90 | |
| 55 | 91 | /// How projects are ordered in the sidebar and the project menus. |
| @@ -88,10 +124,13 @@ pub enum TimeFormat { |
| 88 | 124 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 89 | 125 | #[serde(rename_all = "snake_case")] |
| 90 | 126 | pub enum Streaming { |
| 91 | | - #[default] |
| 127 | + /// Every token as it arrives. |
| 92 | 128 | Tokens, |
| 93 | 129 | /// Each paragraph or code block once it's complete. |
| 130 | + #[default] |
| 94 | 131 | Paragraphs, |
| 132 | + /// The whole answer once the turn ends. |
| 133 | + Turn, |
| 95 | 134 | } |
| 96 | 135 | |
| 97 | 136 | /// What a message sent while the agent works does. |
| @@ -99,9 +138,9 @@ pub enum Streaming { |
| 99 | 138 | #[serde(rename_all = "snake_case")] |
| 100 | 139 | pub enum FollowUp { |
| 101 | 140 | /// Hand it to the agent at once, so it can change course mid-turn. |
| 102 | | - #[default] |
| 103 | 141 | Steer, |
| 104 | 142 | /// Hold it until the turn ends. |
| 143 | + #[default] |
| 105 | 144 | Queue, |
| 106 | 145 | } |
| 107 | 146 | |
| @@ -122,7 +161,22 @@ pub enum BackgroundActivity { |
| 122 | 161 | #[serde(default)] |
| 123 | 162 | pub struct EnvVar { |
| 124 | 163 | pub key: String, |
| 164 | + /// Empty for a sensitive variable: its value lives in the secrets file. |
| 125 | 165 | pub value: String, |
| 166 | + /// Kept out of `settings.json`, in a file only this user can read, and |
| 167 | + /// never shown again once saved. |
| 168 | + pub sensitive: bool, |
| 169 | + } |
| 170 | + |
| 171 | + /// A model added by hand to an instance's list, for models the agent |
| 172 | + /// doesn't report. |
| 173 | + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] |
| 174 | + #[serde(default)] |
| 175 | + pub struct CustomModel { |
| 176 | + /// What the agent is launched with: `claude-sonnet-5`, `anthropic/claude-sonnet-5`. |
| 177 | + pub slug: String, |
| 178 | + /// Empty shows the slug. |
| 179 | + pub name: String, |
| 126 | 180 | } |
| 127 | 181 | |
| 128 | 182 | /// One configured agent: Claude Code or Pi, with its own binary, home |
| @@ -145,6 +199,13 @@ pub struct ProviderInstance { |
| 145 | 199 | /// Extra arguments, split like a shell would. |
| 146 | 200 | pub args: String, |
| 147 | 201 | pub env: Vec<EnvVar>, |
| 202 | + /// A hex color (`#3e9bfa`) marking the instance in lists and pickers; |
| 203 | + /// empty for none. |
| 204 | + pub accent: String, |
| 205 | + /// Claude: compact the conversation after this many tokens (100,000 to |
| 206 | + /// 1,000,000); empty keeps Claude's default. |
| 207 | + pub auto_compact: String, |
| 208 | + pub custom_models: Vec<CustomModel>, |
| 148 | 209 | } |
| 149 | 210 | |
| 150 | 211 | impl Default for ProviderInstance { |
| @@ -164,12 +225,33 @@ impl ProviderInstance { |
| 164 | 225 | home: String::new(), |
| 165 | 226 | args: String::new(), |
| 166 | 227 | env: Vec::new(), |
| 228 | + accent: String::new(), |
| 229 | + auto_compact: String::new(), |
| 230 | + custom_models: Vec::new(), |
| 167 | 231 | } |
| 168 | 232 | } |
| 169 | 233 | |
| 170 | 234 | pub fn label(&self) -> String { |
| 171 | 235 | if self.name.trim().is_empty() { self.kind.label().to_string() } else { self.name.trim().to_string() } |
| 172 | 236 | } |
| 237 | + |
| 238 | + /// The variables the agent runs with, sensitive values read from the |
| 239 | + /// secrets file. |
| 240 | + pub fn env_vars(&self) -> Vec<(String, String)> { |
| 241 | + let secrets = if self.env.iter().any(|var| var.sensitive) { read_secrets() } else { Default::default() }; |
| 242 | + self.env |
| 243 | + .iter() |
| 244 | + .filter(|var| !var.key.trim().is_empty()) |
| 245 | + .map(|var| { |
| 246 | + let key = var.key.trim().to_string(); |
| 247 | + let value = match var.sensitive { |
| 248 | + true => secrets.get(&secret_key(&self.id, &key)).cloned().unwrap_or_default(), |
| 249 | + false => var.value.clone(), |
| 250 | + }; |
| 251 | + (key, value) |
| 252 | + }) |
| 253 | + .collect() |
| 254 | + } |
| 173 | 255 | } |
| 174 | 256 | |
| 175 | 257 | /// Another device's app this one connects to. |
| @@ -213,6 +295,11 @@ pub struct Settings { |
| 213 | 295 | /// Model keys (`claude:opus`, `pi:anthropic/claude-sonnet-5-5`), in the |
| 214 | 296 | /// order they were starred. |
| 215 | 297 | pub favorites: Vec<String>, |
| 298 | + /// Model keys the model picker leaves out. |
| 299 | + pub hidden_models: Vec<String>, |
| 300 | + /// Model keys in the order the picker lists them; models not in it |
| 301 | + /// follow in the agent's order. |
| 302 | + pub model_order: Vec<String>, |
| 216 | 303 | |
| 217 | 304 | // ---- new threads ---- |
| 218 | 305 | /// The model key new threads start on. `None` follows the open thread. |
| @@ -257,7 +344,9 @@ pub struct Settings { |
| 257 | 344 | pub fold_turns: bool, |
| 258 | 345 | /// Show what threads and turns cost. |
| 259 | 346 | pub show_costs: bool, |
| 260 | | - /// Send with Ctrl+Enter, so Enter starts a new line. |
| 347 | + pub send_shortcut: SendShortcut, |
| 348 | + /// Send with Ctrl+Enter, so Enter starts a new line. Follows |
| 349 | + /// `send_shortcut` (true for [`SendShortcut::ModEnter`]). |
| 261 | 350 | pub send_with_ctrl_enter: bool, |
| 262 | 351 | pub follow_up: FollowUp, |
| 263 | 352 | pub streaming: Streaming, |
| @@ -276,11 +365,20 @@ pub struct Settings { |
| 276 | 365 | pub diff_collapsed: bool, |
| 277 | 366 | pub diff_side_by_side: bool, |
| 278 | 367 | |
| 368 | + // ---- appearance ---- |
| 369 | + pub diff_colors: DiffColors, |
| 370 | + pub chat_width: ChatWidth, |
| 371 | + |
| 279 | 372 | // ---- sidebar ---- |
| 280 | 373 | /// One-line thread cards in the sidebar. |
| 281 | 374 | pub compact_threads: bool, |
| 282 | | - /// Group sidebar threads under their project. |
| 375 | + /// Per-project thread trees instead of the flat thread list. |
| 376 | + pub legacy_sidebar: bool, |
| 377 | + /// Group sidebar threads under their project. Follows `legacy_sidebar`. |
| 283 | 378 | pub group_by_project: bool, |
| 379 | + /// Show a project open on several devices (this one and remote |
| 380 | + /// connections) as one project when they're the same repository. |
| 381 | + pub project_grouping: bool, |
| 284 | 382 | pub project_order: ProjectOrder, |
| 285 | 383 | /// Fold working threads into their own section until they need you. |
| 286 | 384 | pub working_section: bool, |
| @@ -318,6 +416,8 @@ pub struct Settings { |
| 318 | 416 | pub health_check_secs: u64, |
| 319 | 417 | |
| 320 | 418 | // ---- integrations ---- |
| 419 | + /// Let agents use the browser panel's shared browser. |
| 420 | + pub agent_browser: bool, |
| 321 | 421 | /// The editor "Open in" uses, by name (`Zed`). |
| 322 | 422 | pub editor: Option<String>, |
| 323 | 423 | pub providers: Vec<ProviderInstance>, |
| @@ -349,12 +449,14 @@ impl Default for Settings { |
| 349 | 449 | light_theme: "bone".into(), |
| 350 | 450 | dark_theme: "graphite".into(), |
| 351 | 451 | favorites: Vec::new(), |
| 452 | + hidden_models: Vec::new(), |
| 453 | + model_order: Vec::new(), |
| 352 | 454 | default_model: None, |
| 353 | 455 | default_reasoning: None, |
| 354 | 456 | default_access: None, |
| 355 | 457 | workspace: WorkspaceMode::Local, |
| 356 | 458 | submodules: Submodules::Recursive, |
| 357 | | - start_from_origin: false, |
| 459 | + start_from_origin: true, |
| 358 | 460 | branch_naming: BranchNaming::Static, |
| 359 | 461 | branch_prefix: "vectrum".into(), |
| 360 | 462 | branch_instructions: String::new(), |
| @@ -367,19 +469,24 @@ impl Default for Settings { |
| 367 | 469 | text_reasoning: Some("low".into()), |
| 368 | 470 | fold_turns: true, |
| 369 | 471 | show_costs: true, |
| 472 | + send_shortcut: SendShortcut::Enter, |
| 370 | 473 | send_with_ctrl_enter: false, |
| 371 | | - follow_up: FollowUp::Steer, |
| 372 | | - streaming: Streaming::Tokens, |
| 474 | + follow_up: FollowUp::Queue, |
| 475 | + streaming: Streaming::Paragraphs, |
| 373 | 476 | slash_skills: true, |
| 374 | 477 | rich_composer: true, |
| 375 | | - collapse_composer: false, |
| 478 | + collapse_composer: true, |
| 376 | 479 | time_format: TimeFormat::System, |
| 377 | 480 | proactive_panels: false, |
| 378 | 481 | hide_whitespace: true, |
| 379 | | - diff_collapsed: false, |
| 482 | + diff_collapsed: true, |
| 380 | 483 | diff_side_by_side: false, |
| 484 | + diff_colors: DiffColors::RedGreen, |
| 485 | + chat_width: ChatWidth::Comfortable, |
| 381 | 486 | compact_threads: false, |
| 487 | + legacy_sidebar: false, |
| 382 | 488 | group_by_project: false, |
| 489 | + project_grouping: true, |
| 383 | 490 | project_order: ProjectOrder::LastMessage, |
| 384 | 491 | working_section: false, |
| 385 | 492 | auto_resume_limited: false, |
| @@ -395,9 +502,10 @@ impl Default for Settings { |
| 395 | 502 | notify_sound: false, |
| 396 | 503 | in_app_notifications: false, |
| 397 | 504 | continue_after_restart: false, |
| 398 | | - update_checks: false, |
| 505 | + update_checks: true, |
| 399 | 506 | background_activity: BackgroundActivity::Balanced, |
| 400 | 507 | health_check_secs: 300, |
| 508 | + agent_browser: true, |
| 401 | 509 | editor: None, |
| 402 | 510 | providers: Vec::new(), |
| 403 | 511 | usage_hubs: Vec::new(), |
| @@ -430,6 +538,77 @@ impl Settings { |
| 430 | 538 | self.providers.push(instance); |
| 431 | 539 | } |
| 432 | 540 | } |
| 541 | + // Fields older code reads, kept in step with the ones replacing them. |
| 542 | + self.send_with_ctrl_enter = self.send_shortcut == SendShortcut::ModEnter; |
| 543 | + self.group_by_project = self.legacy_sidebar; |
| 544 | + } |
| 545 | + |
| 546 | + /// The settings "Restore device defaults" would change, by their |
| 547 | + /// labels on the settings pages, in the order T3 Code lists them. |
| 548 | + pub fn differing_defaults(&self) -> Vec<&'static str> { |
| 549 | + let d = Settings::default(); |
| 550 | + let theme = (self.theme_mode, &self.light_theme, &self.dark_theme) != (d.theme_mode, &d.light_theme, &d.dark_theme); |
| 551 | + [ |
| 552 | + ("Theme", theme), |
| 553 | + ("Diff colors", self.diff_colors != d.diff_colors), |
| 554 | + ("Chat width", self.chat_width != d.chat_width), |
| 555 | + ("Time format", self.time_format != d.time_format), |
| 556 | + ("Thread notifications", (self.notify, self.notify_sound) != (d.notify, d.notify_sound)), |
| 557 | + ("In-app notifications", self.in_app_notifications != d.in_app_notifications), |
| 558 | + ("Project Grouping", self.project_grouping != d.project_grouping), |
| 559 | + ("Project order", self.project_order != d.project_order), |
| 560 | + ("Working section", self.working_section != d.working_section), |
| 561 | + ( |
| 562 | + "Auto-settle inactive threads", |
| 563 | + (self.auto_settle_inactive, self.inactive_days) != (d.auto_settle_inactive, d.inactive_days), |
| 564 | + ), |
| 565 | + ("Auto-settle merged threads", self.auto_settle_merged != d.auto_settle_merged), |
| 566 | + ("Auto-resume limited threads", self.auto_resume_limited != d.auto_resume_limited), |
| 567 | + ("Snooze limited threads", self.snooze_limited != d.snooze_limited), |
| 568 | + ("Composer context", self.composer_context != d.composer_context), |
| 569 | + ("Thread cards", self.compact_threads != d.compact_threads), |
| 570 | + ("Default diff file state", self.diff_collapsed != d.diff_collapsed), |
| 571 | + ("Diff whitespace changes", self.hide_whitespace != d.hide_whitespace), |
| 572 | + ("Diff layout", self.diff_side_by_side != d.diff_side_by_side), |
| 573 | + ("Proactive panels", self.proactive_panels != d.proactive_panels), |
| 574 | + ("Show skills in slash menu", self.slash_skills != d.slash_skills), |
| 575 | + ("Collapse composer on scroll", self.collapse_composer != d.collapse_composer), |
| 576 | + ("Rich text composer", self.rich_composer != d.rich_composer), |
| 577 | + ("Send shortcut", self.send_shortcut != d.send_shortcut), |
| 578 | + ("Follow-up behavior", self.follow_up != d.follow_up), |
| 579 | + ("Context window indicator", self.context_meter != d.context_meter), |
| 580 | + ("Plan mode", self.plan_mode != d.plan_mode), |
| 581 | + ("Sidebar", self.legacy_sidebar != d.legacy_sidebar), |
| 582 | + ("Response streaming", self.streaming != d.streaming), |
| 583 | + ("Fold finished turns", self.fold_turns != d.fold_turns), |
| 584 | + ("Show costs", self.show_costs != d.show_costs), |
| 585 | + ("Provider update checks", self.update_checks != d.update_checks), |
| 586 | + ("Continue threads after restarts", self.continue_after_restart != d.continue_after_restart), |
| 587 | + ("Background activity", self.background_activity != d.background_activity), |
| 588 | + ("Health check interval", self.health_check_secs != d.health_check_secs), |
| 589 | + ("Default model", (&self.default_model, &self.default_reasoning) != (&d.default_model, &d.default_reasoning)), |
| 590 | + ("Permissions", self.default_access != d.default_access), |
| 591 | + ("New thread mode", self.workspace != d.workspace), |
| 592 | + ("Submodules", self.submodules != d.submodules), |
| 593 | + ("New worktrees start from origin", self.start_from_origin != d.start_from_origin), |
| 594 | + ("Add project base directory", self.add_project_dir != d.add_project_dir), |
| 595 | + ("Unpin confirmation", self.confirm_unpin != d.confirm_unpin), |
| 596 | + ("Archive confirmation", self.confirm_settle != d.confirm_settle), |
| 597 | + ("Delete confirmation", self.confirm_delete != d.confirm_delete), |
| 598 | + ("Quit shortcut", self.quit_shortcut != d.quit_shortcut), |
| 599 | + ("Text generation model", (&self.text_model, &self.text_reasoning) != (&d.text_model, &d.text_reasoning)), |
| 600 | + ( |
| 601 | + "Worktree branch naming", |
| 602 | + (self.branch_naming, &self.branch_prefix, &self.branch_instructions) |
| 603 | + != (d.branch_naming, &d.branch_prefix, &d.branch_instructions), |
| 604 | + ), |
| 605 | + ("Worktree location", self.worktrees_dir != d.worktrees_dir), |
| 606 | + ("Agent browser access", self.agent_browser != d.agent_browser), |
| 607 | + ("Open in", self.editor != d.editor), |
| 608 | + ] |
| 609 | + .into_iter() |
| 610 | + .filter_map(|(label, differs)| differs.then_some(label)) |
| 611 | + .collect() |
| 433 | 612 | } |
| 434 | 613 | |
| 435 | 614 | pub fn instance(&self, id: &str) -> Option<&ProviderInstance> { |
| @@ -452,7 +631,16 @@ fn read_file() -> Settings { |
| 452 | 631 | |
| 453 | 632 | /// Reads a settings file's text, falling back to the defaults. |
| 454 | 633 | fn parse(text: &str) -> Settings { |
| 455 | | - let mut settings: Settings = serde_json::from_str(text).unwrap_or_default(); |
| 634 | + let raw: serde_json::Value = serde_json::from_str(text).unwrap_or_default(); |
| 635 | + let mut settings: Settings = serde_json::from_value(raw.clone()).unwrap_or_default(); |
| 636 | + // Carry settings over from the fields that came before these. |
| 637 | + let missing = |key: &str| raw.get(key).is_none(); |
| 638 | + if missing("send_shortcut") && settings.send_with_ctrl_enter { |
| 639 | + settings.send_shortcut = SendShortcut::ModEnter; |
| 640 | + } |
| 641 | + if missing("legacy_sidebar") { |
| 642 | + settings.legacy_sidebar = settings.group_by_project; |
| 643 | + } |
| 456 | 644 | settings.normalize(); |
| 457 | 645 | settings |
| 458 | 646 | } |
| @@ -497,12 +685,14 @@ pub fn update(edit: impl FnOnce(&mut Settings)) { |
| 497 | 685 | } |
| 498 | 686 | } |
| 499 | 687 | |
| 500 | | - /// Puts everything but favorites, agents, usage hubs and remote connections |
| 501 | | - /// back to its default. |
| 688 | + /// Puts everything but favorites and model preferences, agents, usage hubs |
| 689 | + /// and remote connections back to its default. |
| 502 | 690 | pub fn restore_defaults() { |
| 503 | 691 | update(|settings| { |
| 504 | 692 | let kept = Settings { |
| 505 | 693 | favorites: std::mem::take(&mut settings.favorites), |
| 694 | + hidden_models: std::mem::take(&mut settings.hidden_models), |
| 695 | + model_order: std::mem::take(&mut settings.model_order), |
| 506 | 696 | providers: std::mem::take(&mut settings.providers), |
| 507 | 697 | usage_hubs: std::mem::take(&mut settings.usage_hubs), |
| 508 | 698 | remote_server: settings.remote_server, |
| @@ -515,6 +705,64 @@ pub fn restore_defaults() { |
| 515 | 705 | }); |
| 516 | 706 | } |
| 517 | 707 | |
| 708 | + // ---- secrets --------------------------------------------------------------- |
| 709 | + |
| 710 | + /// Where sensitive environment variables are kept: next to the settings, |
| 711 | + /// readable by this user only. |
| 712 | + fn secrets_path() -> Option<PathBuf> { |
| 713 | + Some(path()?.with_file_name("secrets.json")) |
| 714 | + } |
| 715 | + |
| 716 | + fn secret_key(instance: &str, var: &str) -> String { |
| 717 | + format!("{instance}/{var}") |
| 718 | + } |
| 719 | + |
| 720 | + fn read_secrets() -> std::collections::BTreeMap<String, String> { |
| 721 | + secrets_path() |
| 722 | + .and_then(|path| std::fs::read_to_string(path).ok()) |
| 723 | + .and_then(|text| serde_json::from_str(&text).ok()) |
| 724 | + .unwrap_or_default() |
| 725 | + } |
| 726 | + |
| 727 | + /// Whether a sensitive variable has a stored value. |
| 728 | + pub fn has_secret(instance: &str, var: &str) -> bool { |
| 729 | + read_secrets().contains_key(&secret_key(instance, var.trim())) |
| 730 | + } |
| 731 | + |
| 732 | + /// Stores a sensitive variable's value, or forgets it with `None`. |
| 733 | + pub fn set_secret(instance: &str, var: &str, value: Option<&str>) { |
| 734 | + let mut secrets = read_secrets(); |
| 735 | + let key = secret_key(instance, var.trim()); |
| 736 | + match value { |
| 737 | + Some(value) => secrets.insert(key, value.to_string()), |
| 738 | + None => secrets.remove(&key), |
| 739 | + }; |
| 740 | + write_secrets(&secrets); |
| 741 | + } |
| 742 | + |
| 743 | + /// Keeps a sensitive variable's value when the variable is renamed. |
| 744 | + pub fn rename_secret(instance: &str, from: &str, to: &str) { |
| 745 | + let mut secrets = read_secrets(); |
| 746 | + if let Some(value) = secrets.remove(&secret_key(instance, from.trim())) { |
| 747 | + if !to.trim().is_empty() { |
| 748 | + secrets.insert(secret_key(instance, to.trim()), value); |
| 749 | + } |
| 750 | + write_secrets(&secrets); |
| 751 | + } |
| 752 | + } |
| 753 | + |
| 754 | + fn write_secrets(secrets: &std::collections::BTreeMap<String, String>) { |
| 755 | + let Some(path) = secrets_path() else { return }; |
| 756 | + let written = path.parent().map(std::fs::create_dir_all).unwrap_or(Ok(())).and_then(|_| { |
| 757 | + use std::{io::Write as _, os::unix::fs::OpenOptionsExt as _}; |
| 758 | + let mut file = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&path)?; |
| 759 | + file.write_all(serde_json::to_string_pretty(&secrets).unwrap_or_default().as_bytes()) |
| 760 | + }); |
| 761 | + if let Err(err) = written { |
| 762 | + eprintln!("vectrum-code: couldn't save {}: {err}", path.display()); |
| 763 | + } |
| 764 | + } |
| 765 | + |
| 518 | 766 | /// `~/bin/claude` → `/home/me/bin/claude`. |
| 519 | 767 | pub fn expand_home(path: &str) -> PathBuf { |
| 520 | 768 | match (path.strip_prefix("~/"), std::env::var_os("HOME")) { |
| @@ -534,7 +782,7 @@ pub fn data_dir() -> Option<PathBuf> { |
| 534 | 782 | |
| 535 | 783 | #[cfg(test)] |
| 536 | 784 | mod tests { |
| 537 | | - use super::{ThemeMode, parse}; |
| 785 | + use super::{SendShortcut, ThemeMode, parse}; |
| 538 | 786 | |
| 539 | 787 | #[test] |
| 540 | 788 | fn old_colorways_become_theme_modes() { |
| @@ -549,4 +797,26 @@ mod tests { |
| 549 | 797 | let saved = parse(r#"{"theme_mode": "dark", "dark_theme": "nord"}"#); |
| 550 | 798 | assert_eq!((saved.theme_mode, saved.dark_theme.as_str()), (ThemeMode::Dark, "nord")); |
| 551 | 799 | } |
| 800 | + |
| 801 | + #[test] |
| 802 | + fn older_fields_carry_over() { |
| 803 | + let old = parse(r#"{"send_with_ctrl_enter": true, "group_by_project": true}"#); |
| 804 | + assert_eq!(old.send_shortcut, SendShortcut::ModEnter); |
| 805 | + assert!(old.legacy_sidebar && old.group_by_project); |
| 806 | + // The new fields win once they're saved, and the old ones follow them. |
| 807 | + let new = parse(r#"{"send_with_ctrl_enter": true, "send_shortcut": "mod_enter_multiline", "legacy_sidebar": false}"#); |
| 808 | + assert_eq!(new.send_shortcut, SendShortcut::ModEnterMultiline); |
| 809 | + assert!(!new.send_with_ctrl_enter && !new.group_by_project && !new.legacy_sidebar); |
| 810 | + let fresh = parse("{}"); |
| 811 | + assert_eq!((fresh.send_shortcut, fresh.legacy_sidebar), (SendShortcut::Enter, false)); |
| 812 | + } |
| 813 | + |
| 814 | + #[test] |
| 815 | + fn restore_lists_what_changed() { |
| 816 | + let mut settings = parse("{}"); |
| 817 | + assert!(settings.differing_defaults().is_empty()); |
| 818 | + settings.chat_width = super::ChatWidth::Wide; |
| 819 | + settings.dark_theme = "nord".into(); |
| 820 | + assert_eq!(settings.differing_defaults(), ["Theme", "Chat width"]); |
| 821 | + } |
| 552 | 822 | } |