Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 20 changed files with 1325 additions and 156 deletions.
| @@ -147,13 +147,25 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 147 | 147 | - Remote connections, like T3 Code's environments: turn on **Allow remote connections** on |
| 148 | 148 | one device, and paste its pairing code (address and key) under Settings → Connections on |
| 149 | 149 | another. The host's projects and threads appear in the sidebar tagged with its name; open, |
| 150 | - continue or start threads there and they run on the host. The connection is a WebSocket | |
| 151 | - guarded by the key but not encrypted, so use it on trusted networks or over a VPN. | |
| 150 | + continue or start threads there and they run on the host. The first time a device connects | |
| 151 | + with settings that differ from yours, a dialog asks whether to give it your settings or | |
| 152 | + leave its own as they are. The connection is a WebSocket guarded by the key but not | |
| 153 | + encrypted, so use it on trusted networks or over a VPN. | |
| 152 | 154 | - A settings view (Ctrl+, or **Settings** in the sidebar), laid out like T3 Code's: a |
| 153 | 155 | search field over the pages (press `/` to search, ↑/↓ and Enter to jump to a setting, |
| 154 | 156 | which scrolls into view and pulses), a "Settings / Page" breadcrumb, and every setting as |
| 155 | 157 | a row with its description, its control and, once changed, a button that resets it. |
| 156 | 158 | **Restore device defaults** on General lists what it will reset before it does. |
| 159 | + General, Integrations, Source Control and Storage open with T3 Code's "Applying settings | |
| 160 | + for **All projects** across **All environments**": the settings that shape threads (new | |
| 161 | + thread defaults, auto-settle, worktrees and their cleanup, branch naming, writing style, | |
| 162 | + text generation, browser access) belong to the machine the threads run on, so you can edit | |
| 163 | + this device's, one connected device's, or every connected device's at once. A **Mixed** tag | |
| 164 | + shows where they disagree (hover for each one's value), and picking a value applies it to | |
| 165 | + all of them; offline devices keep theirs. Pick a project to override settings for it on the | |
| 166 | + selected devices: overridden rows say **Override**, and their reset button returns them to | |
| 167 | + the environment's value. Device preferences, such as appearance and confirmations, ignore | |
| 168 | + the selection. | |
| 157 | 169 | - **General**: New threads (model and reasoning, permissions, workspace, submodules); |
| 158 | 170 | Organization (project grouping and order, auto-resume and snooze for usage-limited |
| 159 | 171 | threads, the Working section, auto-settling merged and inactive threads); Behavior |
| @@ -8,7 +8,9 @@ | ||
| 8 | 8 | //! [`ClientMessage::Hello`] with the key, gets the host's projects and |
| 9 | 9 | //! threads in [`ServerMessage::State`], subscribes to the threads it opens, |
| 10 | 10 | //! and receives each change as a [`ServerMessage::Thread`] carrying the |
| 11 | - //! transcript from the first item that changed. | |
| 11 | + //! transcript from the first item that changed. The host also shares its | |
| 12 | + //! environment settings in [`ServerMessage::Settings`], which clients can | |
| 13 | + //! change with [`ClientMessage::UpdateSettings`]. | |
| 12 | 14 | |
| 13 | 15 | pub mod client; |
| 14 | 16 | pub mod server; |
| @@ -20,6 +22,7 @@ use serde::{Deserialize, Serialize}; | ||
| 20 | 22 | use crate::git::TurnChanges; |
| 21 | 23 | use crate::models::Provider; |
| 22 | 24 | use crate::session::{QueuedMessage, SavedItem}; |
| 25 | + use crate::settings::SettingsPatch; | |
| 23 | 26 | use crate::worktree::PullRequest; |
| 24 | 27 | |
| 25 | 28 | /// The port a host listens on unless told otherwise. |
| @@ -165,6 +168,8 @@ pub enum ClientMessage { | ||
| 165 | 168 | text: String, |
| 166 | 169 | attachments: Vec<Upload>, |
| 167 | 170 | }, |
| 171 | + /// Changes the host's environment settings or its projects' overrides. | |
| 172 | + UpdateSettings { patch: SettingsPatch }, | |
| 168 | 173 | } |
| 169 | 174 | |
| 170 | 175 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| @@ -178,6 +183,9 @@ pub enum ServerMessage { | ||
| 178 | 183 | Thread { summary: ThreadSummary, detail: ThreadDetail, from: usize, len: usize, items: Vec<SavedItem> }, |
| 179 | 184 | Created { reference: u64, thread: String }, |
| 180 | 185 | Error { message: String }, |
| 186 | + /// The host's environment settings and project overrides, sent on | |
| 187 | + /// connecting and whenever they change. Older hosts don't send it. | |
| 188 | + Settings { values: serde_json::Map<String, serde_json::Value> }, | |
| 181 | 189 | } |
| 182 | 190 | |
| 183 | 191 | /// A new random key: 24 bytes, hex. |
| @@ -264,6 +272,17 @@ mod tests { | ||
| 264 | 272 | }; |
| 265 | 273 | let text = serde_json::to_string(&message).unwrap(); |
| 266 | 274 | assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), message); |
| 275 | + | |
| 276 | + let patch: SettingsPatch = serde_json::from_str(r#"{"values": {"workspace": "worktree"}}"#).unwrap(); | |
| 277 | + let message = ClientMessage::UpdateSettings { patch }; | |
| 278 | + let text = serde_json::to_string(&message).unwrap(); | |
| 279 | + assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), message); | |
| 280 | + let values = crate::settings::Settings::default().environment_values(); | |
| 281 | + let text = serde_json::to_string(&ServerMessage::Settings { values: values.clone() }).unwrap(); | |
| 282 | + match serde_json::from_str::<ServerMessage>(&text).unwrap() { | |
| 283 | + ServerMessage::Settings { values: received } => assert_eq!(received, values), | |
| 284 | + _ => panic!("not settings"), | |
| 285 | + } | |
| 267 | 286 | } |
| 268 | 287 | |
| 269 | 288 | #[::core::prelude::v1::test] |
| @@ -720,7 +720,8 @@ impl Session { | ||
| 720 | 720 | permission_mode: self.permission_mode, |
| 721 | 721 | effort: self.reasoning.effort(), |
| 722 | 722 | resume: resume.clone(), |
| 723 | - browser_session: settings::get(|settings| settings.agent_browser).then(|| browser::session_name(&self.id)), | |
| 723 | + browser_session: settings::get_for(&self.project, |settings| settings.agent_browser) | |
| 724 | + .then(|| browser::session_name(&self.id)), | |
| 724 | 725 | }; |
| 725 | 726 | let process = match AgentProcess::spawn(&config, tx) { |
| 726 | 727 | Ok(process) => process, |
| @@ -1174,10 +1175,11 @@ impl Session { | ||
| 1174 | 1175 | |
| 1175 | 1176 | /// Asks the text generation model for a title, in the background. |
| 1176 | 1177 | fn generate_title(&mut self, message: String, cx: &mut Context<Self>) { |
| 1177 | - if settings::get(|settings| settings.text_model.is_empty()) { | |
| 1178 | + if settings::get_for(&self.project, |settings| settings.text_model.is_empty()) { | |
| 1178 | 1179 | return; |
| 1179 | 1180 | } |
| 1180 | - let task = cx.background_executor().spawn(async move { crate::textgen::title(&message) }); | |
| 1181 | + let project = self.project.clone(); | |
| 1182 | + let task = cx.background_executor().spawn(async move { crate::textgen::title(&project, &message) }); | |
| 1181 | 1183 | cx.spawn(async move |this, cx| { |
| 1182 | 1184 | let Some(title) = task.await else { return }; |
| 1183 | 1185 | let _ = this.update(cx, |this, cx| { |
| @@ -1270,15 +1272,16 @@ impl Session { | ||
| 1270 | 1272 | /// message, as Settings → Source Control → Worktree branch naming says. |
| 1271 | 1273 | /// The folder keeps its name. |
| 1272 | 1274 | fn name_branch(&mut self, worktree: &Worktree, message: String, cx: &mut Context<Self>) { |
| 1273 | - if settings::get(|settings| settings.text_model.is_empty()) || message.trim().is_empty() { | |
| 1275 | + if settings::get_for(&self.project, |settings| settings.text_model.is_empty()) || message.trim().is_empty() { | |
| 1274 | 1276 | return; |
| 1275 | 1277 | } |
| 1276 | - let (naming, prefix, instructions) = settings::get(|settings| { | |
| 1278 | + let project = self.project.clone(); | |
| 1279 | + let (naming, prefix, instructions) = settings::get_for(&self.project, |settings| { | |
| 1277 | 1280 | (settings.branch_naming, settings.branch_prefix.clone(), settings.branch_instructions.clone()) |
| 1278 | 1281 | }); |
| 1279 | 1282 | let worktree = worktree.clone(); |
| 1280 | 1283 | let task = cx.background_executor().spawn(async move { |
| 1281 | - let suggestion = crate::textgen::branch_name(&message, naming, &instructions)?; | |
| 1284 | + let suggestion = crate::textgen::branch_name(&project, &message, naming, &instructions)?; | |
| 1282 | 1285 | let name = worktree::branch_from_suggestion(&suggestion, naming, &prefix); |
| 1283 | 1286 | worktree::rename_branch(&worktree, &name, naming != BranchNaming::Custom).ok() |
| 1284 | 1287 | }); |
| @@ -1303,7 +1306,8 @@ impl Session { | ||
| 1303 | 1306 | } |
| 1304 | 1307 | self.regenerating_title = true; |
| 1305 | 1308 | cx.notify(); |
| 1306 | - let task = cx.background_executor().spawn(async move { crate::textgen::title(&message) }); | |
| 1309 | + let project = self.project.clone(); | |
| 1310 | + let task = cx.background_executor().spawn(async move { crate::textgen::title(&project, &message) }); | |
| 1307 | 1311 | cx.spawn(async move |this, cx| { |
| 1308 | 1312 | let title = task.await; |
| 1309 | 1313 | let _ = this.update(cx, |this, cx| { |
| @@ -2,7 +2,10 @@ | ||
| 2 | 2 | //! `$XDG_CONFIG_HOME/vectrum-code/settings.json` and edited in the settings |
| 3 | 3 | //! view. They're read once and cached, so rendering can consult them freely. |
| 4 | 4 | |
| 5 | - use std::{path::PathBuf, sync::Mutex}; | |
| 5 | + use std::{ | |
| 6 | + path::{Path, PathBuf}, | |
| 7 | + sync::Mutex, | |
| 8 | + }; | |
| 6 | 9 | |
| 7 | 10 | use serde::{Deserialize, Serialize}; |
| 8 | 11 | |
| @@ -325,6 +328,9 @@ pub struct RemoteHost { | ||
| 325 | 328 | pub address: String, |
| 326 | 329 | pub key: String, |
| 327 | 330 | pub enabled: bool, |
| 331 | + /// Whether the user was asked, on first connecting, to give the device | |
| 332 | + /// this one's environment settings. | |
| 333 | + pub settings_offered: bool, | |
| 328 | 334 | } |
| 329 | 335 | |
| 330 | 336 | impl RemoteHost { |
| @@ -515,6 +521,10 @@ pub struct Settings { | ||
| 515 | 521 | /// Other devices' apps this one connects to. |
| 516 | 522 | pub remotes: Vec<RemoteHost>, |
| 517 | 523 | |
| 524 | + /// Settings a project overrides, by project path: field names from | |
| 525 | + /// [`PROJECT_KEYS`] and their values. | |
| 526 | + pub project_overrides: std::collections::BTreeMap<String, serde_json::Map<String, serde_json::Value>>, | |
| 527 | + | |
| 518 | 528 | /// Superseded by `providers`; read once to carry an old path override over. |
| 519 | 529 | #[serde(skip_serializing)] |
| 520 | 530 | claude_bin: Option<String>, |
| @@ -607,6 +617,7 @@ impl Default for Settings { | ||
| 607 | 617 | remote_port: crate::remote::DEFAULT_PORT, |
| 608 | 618 | remote_key: String::new(), |
| 609 | 619 | remotes: Vec::new(), |
| 620 | + project_overrides: std::collections::BTreeMap::new(), | |
| 610 | 621 | claude_bin: None, |
| 611 | 622 | pi_bin: None, |
| 612 | 623 | colorway: None, |
| @@ -617,7 +628,7 @@ impl Default for Settings { | ||
| 617 | 628 | impl Settings { |
| 618 | 629 | /// Makes sure there's at least one instance of each kind, carrying old |
| 619 | 630 | /// binary overrides over, and turns an old colorway into a theme mode. |
| 620 | - fn normalize(&mut self) { | |
| 631 | + pub(crate) fn normalize(&mut self) { | |
| 621 | 632 | if let Some(colorway) = self.colorway.take() { |
| 622 | 633 | self.theme_mode = match colorway.as_str() { |
| 623 | 634 | "bone" => ThemeMode::Light, |
| @@ -708,6 +719,151 @@ impl Settings { | ||
| 708 | 719 | pub fn instance(&self, id: &str) -> Option<&ProviderInstance> { |
| 709 | 720 | self.providers.iter().find(|instance| instance.id == id) |
| 710 | 721 | } |
| 722 | + | |
| 723 | + /// These settings as one project sees them: with its overrides. | |
| 724 | + pub fn for_project(&self, project: &Path) -> Settings { | |
| 725 | + match self.project_overrides.get(&project.display().to_string()) { | |
| 726 | + Some(overrides) => with_values(self, overrides), | |
| 727 | + None => self.clone(), | |
| 728 | + } | |
| 729 | + } | |
| 730 | + | |
| 731 | + /// The settings an environment shares with the devices connected to it: | |
| 732 | + /// [`ENVIRONMENT_KEYS`] and the project overrides. | |
| 733 | + pub fn environment_values(&self) -> serde_json::Map<String, serde_json::Value> { | |
| 734 | + let serde_json::Value::Object(mut all) = serde_json::to_value(self).unwrap_or_default() else { | |
| 735 | + return Default::default(); | |
| 736 | + }; | |
| 737 | + all.retain(|key, _| ENVIRONMENT_KEYS.contains(&key.as_str()) || key == "project_overrides"); | |
| 738 | + all | |
| 739 | + } | |
| 740 | + | |
| 741 | + /// Applies a change from the settings view or another device, keeping | |
| 742 | + /// to the fields a patch may touch. | |
| 743 | + pub fn apply_patch(&mut self, patch: &SettingsPatch) { | |
| 744 | + let values: serde_json::Map<_, _> = patch | |
| 745 | + .values | |
| 746 | + .iter() | |
| 747 | + .filter(|(key, _)| ENVIRONMENT_KEYS.contains(&key.as_str())) | |
| 748 | + .map(|(key, value)| (key.clone(), value.clone())) | |
| 749 | + .collect(); | |
| 750 | + if !values.is_empty() { | |
| 751 | + *self = with_values(self, &values); | |
| 752 | + } | |
| 753 | + for (project, changes) in &patch.projects { | |
| 754 | + let overrides = self.project_overrides.entry(project.clone()).or_default(); | |
| 755 | + for (key, value) in changes { | |
| 756 | + if !PROJECT_KEYS.contains(&key.as_str()) { | |
| 757 | + continue; | |
| 758 | + } | |
| 759 | + match value { | |
| 760 | + serde_json::Value::Null => overrides.remove(key), | |
| 761 | + value => overrides.insert(key.clone(), value.clone()), | |
| 762 | + }; | |
| 763 | + } | |
| 764 | + if overrides.is_empty() { | |
| 765 | + self.project_overrides.remove(project); | |
| 766 | + } | |
| 767 | + } | |
| 768 | + self.normalize(); | |
| 769 | + } | |
| 770 | + } | |
| 771 | + | |
| 772 | + /// Settings an environment keeps for itself, as T3 Code's server settings: | |
| 773 | + /// they shape the threads that run there, so they're edited on the machine | |
| 774 | + /// the threads run on, one environment or all of them at once. Everything | |
| 775 | + /// else is a preference of the device it was set on. | |
| 776 | + pub const ENVIRONMENT_KEYS: &[&str] = &[ | |
| 777 | + "default_model", | |
| 778 | + "default_reasoning", | |
| 779 | + "default_access", | |
| 780 | + "workspace", | |
| 781 | + "submodules", | |
| 782 | + "start_from_origin", | |
| 783 | + "branch_naming", | |
| 784 | + "branch_prefix", | |
| 785 | + "branch_instructions", | |
| 786 | + "writing_style", | |
| 787 | + "writing_instructions", | |
| 788 | + "follow_pr_templates", | |
| 789 | + "worktrees_dir", | |
| 790 | + "cleanup_deleted_worktrees", | |
| 791 | + "cleanup_inactive_worktrees", | |
| 792 | + "cleanup_merged_worktrees", | |
| 793 | + "cleanup_unchanged_worktrees", | |
| 794 | + "text_model", | |
| 795 | + "text_reasoning", | |
| 796 | + "auto_resume_limited", | |
| 797 | + "snooze_limited", | |
| 798 | + "auto_settle_merged", | |
| 799 | + "auto_settle_inactive", | |
| 800 | + "inactive_days", | |
| 801 | + "continue_after_restart", | |
| 802 | + "update_checks", | |
| 803 | + "agent_browser", | |
| 804 | + ]; | |
| 805 | + | |
| 806 | + /// The environment settings a project can override. | |
| 807 | + pub const PROJECT_KEYS: &[&str] = &[ | |
| 808 | + "default_model", | |
| 809 | + "default_reasoning", | |
| 810 | + "default_access", | |
| 811 | + "workspace", | |
| 812 | + "submodules", | |
| 813 | + "start_from_origin", | |
| 814 | + "branch_naming", | |
| 815 | + "branch_prefix", | |
| 816 | + "branch_instructions", | |
| 817 | + "writing_style", | |
| 818 | + "writing_instructions", | |
| 819 | + "follow_pr_templates", | |
| 820 | + "cleanup_inactive_worktrees", | |
| 821 | + "cleanup_merged_worktrees", | |
| 822 | + "cleanup_unchanged_worktrees", | |
| 823 | + "text_model", | |
| 824 | + "text_reasoning", | |
| 825 | + "auto_settle_merged", | |
| 826 | + "auto_settle_inactive", | |
| 827 | + "inactive_days", | |
| 828 | + "agent_browser", | |
| 829 | + ]; | |
| 830 | + | |
| 831 | + /// A change to an environment's settings: new values for its own fields, | |
| 832 | + /// and per project path, overrides to set or (as `null`) to drop. | |
| 833 | + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 834 | + #[serde(default)] | |
| 835 | + pub struct SettingsPatch { | |
| 836 | + pub values: serde_json::Map<String, serde_json::Value>, | |
| 837 | + pub projects: std::collections::BTreeMap<String, serde_json::Map<String, serde_json::Value>>, | |
| 838 | + } | |
| 839 | + | |
| 840 | + impl SettingsPatch { | |
| 841 | + pub fn is_empty(&self) -> bool { | |
| 842 | + self.values.is_empty() && self.projects.values().all(serde_json::Map::is_empty) | |
| 843 | + } | |
| 844 | + } | |
| 845 | + | |
| 846 | + /// `settings` with some fields replaced, by name. | |
| 847 | + pub fn with_values(settings: &Settings, values: &serde_json::Map<String, serde_json::Value>) -> Settings { | |
| 848 | + let mut all = serde_json::to_value(settings).unwrap_or_default(); | |
| 849 | + if let serde_json::Value::Object(all) = &mut all { | |
| 850 | + for (key, value) in values { | |
| 851 | + all.insert(key.clone(), value.clone()); | |
| 852 | + } | |
| 853 | + } | |
| 854 | + let mut merged: Settings = serde_json::from_value(all).unwrap_or_else(|_| settings.clone()); | |
| 855 | + merged.normalize(); | |
| 856 | + merged | |
| 857 | + } | |
| 858 | + | |
| 859 | + /// The fields that differ between `before` and `after`, with their new values. | |
| 860 | + pub fn changed_values(before: &Settings, after: &Settings) -> serde_json::Map<String, serde_json::Value> { | |
| 861 | + let (serde_json::Value::Object(before), serde_json::Value::Object(after)) = | |
| 862 | + (serde_json::to_value(before).unwrap_or_default(), serde_json::to_value(after).unwrap_or_default()) | |
| 863 | + else { | |
| 864 | + return Default::default(); | |
| 865 | + }; | |
| 866 | + after.into_iter().filter(|(key, value)| before.get(key) != Some(value)).collect() | |
| 711 | 867 | } |
| 712 | 868 | |
| 713 | 869 | static CACHE: Mutex<Option<Settings>> = Mutex::new(None); |
| @@ -749,6 +905,22 @@ pub fn load() -> Settings { | ||
| 749 | 905 | get(Settings::clone) |
| 750 | 906 | } |
| 751 | 907 | |
| 908 | + /// Reads one value as a project sees it, with its overrides. | |
| 909 | + pub fn get_for<T>(project: &Path, read: impl FnOnce(&Settings) -> T) -> T { | |
| 910 | + let mut cache = CACHE.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 911 | + let settings = cache.get_or_insert_with(read_file); | |
| 912 | + match settings.project_overrides.contains_key(&project.display().to_string()) { | |
| 913 | + true => read(&settings.for_project(project)), | |
| 914 | + false => read(settings), | |
| 915 | + } | |
| 916 | + } | |
| 917 | + | |
| 918 | + /// Applies a change to this environment's settings, from the settings view | |
| 919 | + /// or a connected device, and writes them back. | |
| 920 | + pub fn apply(patch: &SettingsPatch) { | |
| 921 | + update(|settings| settings.apply_patch(patch)); | |
| 922 | + } | |
| 923 | + | |
| 752 | 924 | /// The configured agents, enabled or not. |
| 753 | 925 | pub fn instances() -> Vec<ProviderInstance> { |
| 754 | 926 | get(|settings| settings.providers.clone()) |
| @@ -779,8 +951,8 @@ pub fn update(edit: impl FnOnce(&mut Settings)) { | ||
| 779 | 951 | } |
| 780 | 952 | } |
| 781 | 953 | |
| 782 | - /// Puts everything but favorites and model preferences, agents, usage hubs | |
| 783 | - /// and remote connections back to its default. | |
| 954 | + /// Puts everything but favorites and model preferences, agents, usage hubs, | |
| 955 | + /// remote connections and project overrides back to its default. | |
| 784 | 956 | pub fn restore_defaults() { |
| 785 | 957 | update(|settings| { |
| 786 | 958 | let kept = Settings { |
| @@ -793,6 +965,7 @@ pub fn restore_defaults() { | ||
| 793 | 965 | remote_port: settings.remote_port, |
| 794 | 966 | remote_key: std::mem::take(&mut settings.remote_key), |
| 795 | 967 | remotes: std::mem::take(&mut settings.remotes), |
| 968 | + project_overrides: std::mem::take(&mut settings.project_overrides), | |
| 796 | 969 | ..Settings::default() |
| 797 | 970 | }; |
| 798 | 971 | *settings = kept; |
| @@ -905,6 +1078,55 @@ mod tests { | ||
| 905 | 1078 | assert_eq!((fresh.send_shortcut, fresh.legacy_sidebar), (SendShortcut::Enter, false)); |
| 906 | 1079 | } |
| 907 | 1080 | |
| 1081 | + #[test] | |
| 1082 | + fn patches_keep_to_environment_fields() { | |
| 1083 | + let mut settings = parse("{}"); | |
| 1084 | + let patch: super::SettingsPatch = serde_json::from_str( | |
| 1085 | + r#"{"values": {"workspace": "worktree", "remote_key": "stolen", "chat_width": "wide"}, | |
| 1086 | + "projects": {"/src/app": {"submodules": "skip", "worktrees_dir": "/tmp"}}}"#, | |
| 1087 | + ) | |
| 1088 | + .unwrap(); | |
| 1089 | + settings.apply_patch(&patch); | |
| 1090 | + assert_eq!(settings.workspace, super::WorkspaceMode::Worktree); | |
| 1091 | + assert!(settings.remote_key.is_empty()); | |
| 1092 | + assert_eq!(settings.chat_width, super::ChatWidth::Comfortable); | |
| 1093 | + let project = std::path::Path::new("/src/app"); | |
| 1094 | + assert_eq!(settings.for_project(project).submodules, super::Submodules::Skip); | |
| 1095 | + assert_eq!(settings.for_project(project).worktrees_dir, ""); | |
| 1096 | + assert_eq!(settings.submodules, super::Submodules::Recursive); | |
| 1097 | + // Null drops an override, and a project without any is forgotten. | |
| 1098 | + let inherit: super::SettingsPatch = | |
| 1099 | + serde_json::from_str(r#"{"projects": {"/src/app": {"submodules": null}}}"#).unwrap(); | |
| 1100 | + settings.apply_patch(&inherit); | |
| 1101 | + assert!(settings.project_overrides.is_empty()); | |
| 1102 | + let values = settings.environment_values(); | |
| 1103 | + assert!(values.contains_key("workspace") && values.contains_key("project_overrides")); | |
| 1104 | + assert!(!values.contains_key("remote_key") && !values.contains_key("theme_mode")); | |
| 1105 | + } | |
| 1106 | + | |
| 1107 | + #[test] | |
| 1108 | + fn scoped_keys_are_fields() { | |
| 1109 | + let all = serde_json::to_value(parse("{}")).unwrap(); | |
| 1110 | + for key in super::ENVIRONMENT_KEYS { | |
| 1111 | + assert!(all.get(key).is_some(), "{key} isn't a setting"); | |
| 1112 | + } | |
| 1113 | + for key in super::PROJECT_KEYS { | |
| 1114 | + assert!(super::ENVIRONMENT_KEYS.contains(key), "{key} isn't an environment setting"); | |
| 1115 | + } | |
| 1116 | + } | |
| 1117 | + | |
| 1118 | + #[test] | |
| 1119 | + fn changes_are_named_by_field() { | |
| 1120 | + let before = parse("{}"); | |
| 1121 | + let mut after = before.clone(); | |
| 1122 | + after.inactive_days = 9; | |
| 1123 | + after.cleanup_inactive_worktrees = Some(4); | |
| 1124 | + let changed = super::changed_values(&before, &after); | |
| 1125 | + assert_eq!(changed.len(), 2); | |
| 1126 | + assert_eq!(changed["inactive_days"], 9); | |
| 1127 | + assert_eq!(super::with_values(&before, &changed).cleanup_inactive_worktrees, Some(4)); | |
| 1128 | + } | |
| 1129 | + | |
| 908 | 1130 | #[test] |
| 909 | 1131 | fn restore_lists_what_changed() { |
| 910 | 1132 | let mut settings = parse("{}"); |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | //! under Settings → General → Text generation. Each request is a one-off, |
| 3 | 3 | //! tool-less agent run. Everything here blocks; call it from a background task. |
| 4 | 4 | |
| 5 | - use std::process::Stdio; | |
| 5 | + use std::{path::Path, process::Stdio}; | |
| 6 | 6 | |
| 7 | 7 | use crate::claude::{Effort, base_command}; |
| 8 | 8 | use crate::models::{Provider, split_key}; |
| @@ -14,16 +14,16 @@ sentence case, no quotes, no trailing period) for a conversation that starts wit | ||
| 14 | 14 | |
| 15 | 15 | /// A title for a thread that starts with `message`, or `None` when text |
| 16 | 16 | /// generation is off or fails. |
| 17 | - pub fn title(message: &str) -> Option<String> { | |
| 17 | + pub fn title(project: &Path, message: &str) -> Option<String> { | |
| 18 | 18 | let message: String = message.chars().take(2_000).collect(); |
| 19 | - clean_title(&generate(&format!("{TITLE_PROMPT}\n\n<message>\n{message}\n</message>"))?) | |
| 19 | + clean_title(&generate(project, &format!("{TITLE_PROMPT}\n\n<message>\n{message}\n</message>"))?) | |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | 22 | /// A branch name for a worktree whose thread starts with `message`, as |
| 23 | 23 | /// T3 Code asks for one: a fragment the configured prefix goes in front of, |
| 24 | 24 | /// one with a semantic prefix (`feat/…`), or a whole name following the |
| 25 | 25 | /// user's instructions. `None` when text generation is off or fails. |
| 26 | - pub fn branch_name(message: &str, naming: BranchNaming, instructions: &str) -> Option<String> { | |
| 26 | + pub fn branch_name(project: &Path, message: &str, naming: BranchNaming, instructions: &str) -> Option<String> { | |
| 27 | 27 | let mut rules = vec![ |
| 28 | 28 | "Branch should describe the requested work from the user message.", |
| 29 | 29 | "Return a valid Git branch name without spaces.", |
| @@ -51,14 +51,14 @@ pub fn branch_name(message: &str, naming: BranchNaming, instructions: &str) -> O | ||
| 51 | 51 | if naming == BranchNaming::Custom && !instructions.trim().is_empty() { |
| 52 | 52 | prompt.push_str(&format!("\n\nAdditional instructions:\n{}", instructions.trim())); |
| 53 | 53 | } |
| 54 | - let value = json_reply(&generate(&prompt)?)?; | |
| 54 | + let value = json_reply(&generate(project, &prompt)?)?; | |
| 55 | 55 | let branch = value.get("branch")?.as_str()?.trim().to_string(); |
| 56 | 56 | (!branch.is_empty()).then_some(branch) |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | 59 | /// A commit message for what's staged, as T3 Code asks for one: an |
| 60 | 60 | /// imperative subject of at most 72 characters and an optional body. |
| 61 | - pub fn commit_message(branch: Option<&str>, staged_summary: &str, staged_patch: &str, instructions: &str) -> Option<String> { | |
| 61 | + pub fn commit_message(project: &Path, branch: Option<&str>, staged_summary: &str, staged_patch: &str, instructions: &str) -> Option<String> { | |
| 62 | 62 | let mut prompt = [ |
| 63 | 63 | "You write concise git commit messages.", |
| 64 | 64 | "Return a JSON object with keys: subject, body.", |
| @@ -77,7 +77,7 @@ pub fn commit_message(branch: Option<&str>, staged_summary: &str, staged_patch: | ||
| 77 | 77 | ] |
| 78 | 78 | .join("\n"); |
| 79 | 79 | push_instructions(&mut prompt, instructions); |
| 80 | - let value = json_reply(&generate(&prompt)?)?; | |
| 80 | + let value = json_reply(&generate(project, &prompt)?)?; | |
| 81 | 81 | let subject = value.get("subject")?.as_str()?.trim().trim_end_matches('.').to_string(); |
| 82 | 82 | if subject.is_empty() { |
| 83 | 83 | return None; |
| @@ -88,6 +88,7 @@ pub fn commit_message(branch: Option<&str>, staged_summary: &str, staged_patch: | ||
| 88 | 88 | |
| 89 | 89 | /// A pull request's title and body for a branch, as T3 Code asks for them. |
| 90 | 90 | pub fn pull_request_content( |
| 91 | + project: &Path, | |
| 91 | 92 | base: &str, |
| 92 | 93 | head: &str, |
| 93 | 94 | commits: &str, |
| @@ -139,7 +140,7 @@ pub fn pull_request_content( | ||
| 139 | 140 | &limit(diff_patch, 40_000), |
| 140 | 141 | ] |
| 141 | 142 | .join("\n"); |
| 142 | - let value = json_reply(&generate(&prompt)?)?; | |
| 143 | + let value = json_reply(&generate(project, &prompt)?)?; | |
| 143 | 144 | let title = value.get("title")?.as_str()?.trim().to_string(); |
| 144 | 145 | let body = value.get("body").and_then(|body| body.as_str()).unwrap_or_default().to_string(); |
| 145 | 146 | (!title.is_empty()).then_some((title, body)) |
| @@ -169,9 +170,11 @@ fn limit(text: &str, max: usize) -> String { | ||
| 169 | 170 | cut |
| 170 | 171 | } |
| 171 | 172 | |
| 172 | - /// Runs `prompt` through the text generation model and returns its reply. | |
| 173 | - fn generate(prompt: &str) -> Option<String> { | |
| 174 | - let (key, reasoning) = settings::get(|settings| (settings.text_model.clone(), settings.text_reasoning.clone())); | |
| 173 | + /// Runs `prompt` through the project's text generation model and returns | |
| 174 | + /// its reply. | |
| 175 | + fn generate(project: &Path, prompt: &str) -> Option<String> { | |
| 176 | + let (key, reasoning) = | |
| 177 | + settings::get_for(project, |settings| (settings.text_model.clone(), settings.text_reasoning.clone())); | |
| 175 | 178 | let (instance_id, model) = split_key(&key)?; |
| 176 | 179 | let kind = settings::get(|settings| settings.instance(instance_id).map(|instance| instance.kind)) |
| 177 | 180 | .or_else(|| Provider::from_key(instance_id))?; |
| @@ -713,6 +713,8 @@ pub struct MetalButton { | ||
| 713 | 713 | size: CapSize, |
| 714 | 714 | icon: Option<Icon>, |
| 715 | 715 | icon_color: Option<Hsla>, |
| 716 | + /// Drawn where the icon goes, such as a project's badge. | |
| 717 | + leading: Option<AnyElement>, | |
| 716 | 718 | label: Option<SharedString>, |
| 717 | 719 | caret: bool, |
| 718 | 720 | stretch: bool, |
| @@ -730,6 +732,7 @@ impl MetalButton { | ||
| 730 | 732 | size: CapSize::Regular, |
| 731 | 733 | icon: None, |
| 732 | 734 | icon_color: None, |
| 735 | + leading: None, | |
| 733 | 736 | label: None, |
| 734 | 737 | caret: false, |
| 735 | 738 | stretch: false, |
| @@ -772,6 +775,12 @@ impl MetalButton { | ||
| 772 | 775 | self |
| 773 | 776 | } |
| 774 | 777 | |
| 778 | + /// Something other than an icon before the label. | |
| 779 | + pub fn leading(mut self, element: impl IntoElement) -> Self { | |
| 780 | + self.leading = Some(element.into_any_element()); | |
| 781 | + self | |
| 782 | + } | |
| 783 | + | |
| 775 | 784 | pub fn icon_color(mut self, color: Hsla) -> Self { |
| 776 | 785 | self.icon_color = Some(color); |
| 777 | 786 | self |
| @@ -895,6 +904,7 @@ impl RenderOnce for MetalButton { | ||
| 895 | 904 | None => icon, |
| 896 | 905 | }) |
| 897 | 906 | }) |
| 907 | + .children(self.leading) | |
| 898 | 908 | .when_some(self.label, |this, label| { |
| 899 | 909 | this.child( |
| 900 | 910 | div() |
| @@ -48,7 +48,7 @@ impl SettingsView { | ||
| 48 | 48 | SettingRow::new("Diff colors") |
| 49 | 49 | .id("diff-color-scheme") |
| 50 | 50 | .description("Choose colors for additions and deletions, including change counts.") |
| 51 | - .reset(reset(&view, |s| s.diff_colors, |s, colors| s.diff_colors = colors)) | |
| 51 | + .reset(reset(&view, cx, |s| s.diff_colors, |s, colors| s.diff_colors = colors)) | |
| 52 | 52 | .control(h_flex().gap(px(8.)).child(diff_dots).child(choice_of( |
| 53 | 53 | "diff-colors", |
| 54 | 54 | saved.diff_colors, |
| @@ -68,7 +68,7 @@ impl SettingsView { | ||
| 68 | 68 | SettingRow::new("Composer context") |
| 69 | 69 | .id("composer-context") |
| 70 | 70 | .description("Keep branch and worktree controls below the composer after a thread starts.") |
| 71 | - .reset(reset(&view, |s| s.composer_context, |s, on| s.composer_context = on)) | |
| 71 | + .reset(reset(&view, cx, |s| s.composer_context, |s, on| s.composer_context = on)) | |
| 72 | 72 | .control(switch("composer-context", saved.composer_context, &view, p, |settings, on| { |
| 73 | 73 | settings.composer_context = on |
| 74 | 74 | })), |
| @@ -77,7 +77,7 @@ impl SettingsView { | ||
| 77 | 77 | SettingRow::new("Chat width") |
| 78 | 78 | .id("chat-width") |
| 79 | 79 | .description("Set how wide messages and the composer can grow on large screens.") |
| 80 | - .reset(reset(&view, |s| s.chat_width, |s, width| s.chat_width = width)) | |
| 80 | + .reset(reset(&view, cx, |s| s.chat_width, |s, width| s.chat_width = width)) | |
| 81 | 81 | .control(choice("chat-width", saved.chat_width, CHAT_WIDTHS, &view, |settings, width| { |
| 82 | 82 | settings.chat_width = width |
| 83 | 83 | })), |
| @@ -86,7 +86,7 @@ impl SettingsView { | ||
| 86 | 86 | SettingRow::new("Thread cards") |
| 87 | 87 | .id("thread-cards") |
| 88 | 88 | .description("Comfortable cards show the project, branch and agent. Compact ones fit a thread on one line.") |
| 89 | - .reset(reset(&view, |s| s.compact_threads, |s, compact| s.compact_threads = compact)) | |
| 89 | + .reset(reset(&view, cx, |s| s.compact_threads, |s, compact| s.compact_threads = compact)) | |
| 90 | 90 | .control(choice("thread-density", saved.compact_threads, &[(false, "Comfortable"), (true, "Compact")], &view, { |
| 91 | 91 | |settings, compact| settings.compact_threads = compact |
| 92 | 92 | })), |
| @@ -71,7 +71,7 @@ impl SettingsView { | ||
| 71 | 71 | let name = value(&state.connect_name); |
| 72 | 72 | let ids: Vec<String> = settings::get(|settings| settings.remotes.iter().map(|host| host.id.clone()).collect()); |
| 73 | 73 | let id = (1..).map(|n| format!("remote-{n}")).find(|id| !ids.contains(id)).unwrap_or_default(); |
| 74 | - settings::update(|settings| settings.remotes.push(RemoteHost { id, name, address, key, enabled: true })); | |
| 74 | + settings::update(|settings| settings.remotes.push(RemoteHost { id, name, address, key, enabled: true, settings_offered: false })); | |
| 75 | 75 | for input in [&state.connect_name, &state.connect_address, &state.connect_key] { |
| 76 | 76 | input.update(cx, |input, cx| input.set_value("", window, cx)); |
| 77 | 77 | } |
| @@ -15,7 +15,7 @@ use crate::claude::PermissionMode; | ||
| 15 | 15 | use crate::models::Catalog; |
| 16 | 16 | use crate::session::Reasoning; |
| 17 | 17 | use crate::settings::{ |
| 18 | - self, BackgroundActivity, FollowUp, ProjectOrder, QuitShortcut, SendShortcut, Streaming, Submodules, | |
| 18 | + BackgroundActivity, FollowUp, ProjectOrder, QuitShortcut, SendShortcut, Streaming, Submodules, | |
| 19 | 19 | TimeFormat, WorkspaceMode, |
| 20 | 20 | }; |
| 21 | 21 | use crate::ui::metal::{self, MetalButton}; |
| @@ -74,7 +74,7 @@ fn permission_description(mode: PermissionMode) -> &'static str { | ||
| 74 | 74 | impl SettingsView { |
| 75 | 75 | pub(super) fn render_general(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> { |
| 76 | 76 | let p = metal::palette(cx); |
| 77 | - let saved = settings::load(); | |
| 77 | + let saved = super::scope::shown_settings(cx); | |
| 78 | 78 | let view = cx.weak_entity(); |
| 79 | 79 | let row = SettingRow::new; |
| 80 | 80 | |
| @@ -82,7 +82,7 @@ impl SettingsView { | ||
| 82 | 82 | let model_pick = { |
| 83 | 83 | let view = view.clone(); |
| 84 | 84 | move |key: Option<String>, window: &mut Window, cx: &mut App| { |
| 85 | - edit(&view, window, cx, move |settings| settings.default_model = key) | |
| 85 | + edit(&view, window, cx, move |settings| settings.default_model = key.clone()) | |
| 86 | 86 | } |
| 87 | 87 | }; |
| 88 | 88 | let reasoning_pick = { |
| @@ -113,7 +113,7 @@ impl SettingsView { | ||
| 113 | 113 | .id("default-model") |
| 114 | 114 | .description("Default model for new threads. Projects can override it.") |
| 115 | 115 | .when(saved.default_model.is_none(), |row| row.status("Automatic")) |
| 116 | - .reset(reset(&view, |s| (s.default_model.clone(), s.default_reasoning.clone()), |s, (model, reasoning)| { | |
| 116 | + .reset(reset(&view, cx, |s| (s.default_model.clone(), s.default_reasoning.clone()), |s, (model, reasoning)| { | |
| 117 | 117 | s.default_model = model; |
| 118 | 118 | s.default_reasoning = reasoning; |
| 119 | 119 | })) |
| @@ -133,7 +133,7 @@ impl SettingsView { | ||
| 133 | 133 | row("Permissions") |
| 134 | 134 | .id("default-permissions") |
| 135 | 135 | .description("Default permissions for new threads. Pi and jcode threads always run with full access.") |
| 136 | - .reset(reset(&view, |s| s.default_access.clone(), |s, access| s.default_access = access)) | |
| 136 | + .reset(reset(&view, cx, |s| s.default_access.clone(), |s, access| s.default_access = access)) | |
| 137 | 137 | .control(rich_choice("default-access", current_access, permissions, &view, |settings, mode| { |
| 138 | 138 | settings.default_access = mode.map(|mode: PermissionMode| mode.as_cli().to_string()) |
| 139 | 139 | })), |
| @@ -142,14 +142,14 @@ impl SettingsView { | ||
| 142 | 142 | row("Workspace") |
| 143 | 143 | .id("new-threads") |
| 144 | 144 | .description("Where new threads start. The start screen can switch it for each thread.") |
| 145 | - .reset(reset(&view, |s| s.workspace, |s, mode| s.workspace = mode)) | |
| 145 | + .reset(reset(&view, cx, |s| s.workspace, |s, mode| s.workspace = mode)) | |
| 146 | 146 | .control(choice("workspace", saved.workspace, WORKSPACES, &view, |settings, mode| settings.workspace = mode)), |
| 147 | 147 | ) |
| 148 | 148 | .row( |
| 149 | 149 | row("Submodules") |
| 150 | 150 | .id("worktree-submodules") |
| 151 | 151 | .description("How new worktrees populate git submodules.") |
| 152 | - .reset(reset(&view, |s| s.submodules, |s, mode| s.submodules = mode)) | |
| 152 | + .reset(reset(&view, cx, |s| s.submodules, |s, mode| s.submodules = mode)) | |
| 153 | 153 | .control(choice("submodules", saved.submodules, SUBMODULES, &view, |settings, mode| settings.submodules = mode)), |
| 154 | 154 | ) |
| 155 | 155 | .render(cx); |
| @@ -160,7 +160,7 @@ impl SettingsView { | ||
| 160 | 160 | row("Project grouping") |
| 161 | 161 | .id("project-grouping") |
| 162 | 162 | .description("Combine matching repositories across environments.") |
| 163 | - .reset(reset(&view, |s| s.project_grouping, |s, grouping| s.project_grouping = grouping)) | |
| 163 | + .reset(reset(&view, cx, |s| s.project_grouping, |s, grouping| s.project_grouping = grouping)) | |
| 164 | 164 | // On groups by repository, as T3 Code's switch does; off keeps every project apart. |
| 165 | 165 | .control(switch( |
| 166 | 166 | "project-grouping", |
| @@ -180,7 +180,7 @@ impl SettingsView { | ||
| 180 | 180 | row("Project order") |
| 181 | 181 | .id("project-order") |
| 182 | 182 | .description("Order of projects in the sidebar project picker and command palette.") |
| 183 | - .reset(reset(&view, |s| s.project_order, |s, order| s.project_order = order)) | |
| 183 | + .reset(reset(&view, cx, |s| s.project_order, |s, order| s.project_order = order)) | |
| 184 | 184 | .control(choice("project-order", saved.project_order, PROJECT_ORDERS, &view, |settings, order| { |
| 185 | 185 | settings.project_order = order |
| 186 | 186 | })), |
| @@ -191,7 +191,7 @@ impl SettingsView { | ||
| 191 | 191 | .description( |
| 192 | 192 | "Resume usage-limit stops at the reported reset time. Each thread can cancel its scheduled continuation.", |
| 193 | 193 | ) |
| 194 | - .reset(reset(&view, |s| s.auto_resume_limited, |s, on| s.auto_resume_limited = on)) | |
| 194 | + .reset(reset(&view, cx, |s| s.auto_resume_limited, |s, on| s.auto_resume_limited = on)) | |
| 195 | 195 | .control(switch("auto-resume", saved.auto_resume_limited, &view, p, |settings, on| { |
| 196 | 196 | settings.auto_resume_limited = on |
| 197 | 197 | })), |
| @@ -202,7 +202,7 @@ impl SettingsView { | ||
| 202 | 202 | .description( |
| 203 | 203 | "Snooze usage-limit stops until the reported reset time. Combine with auto-resume to continue when they wake.", |
| 204 | 204 | ) |
| 205 | - .reset(reset(&view, |s| s.snooze_limited, |s, on| s.snooze_limited = on)) | |
| 205 | + .reset(reset(&view, cx, |s| s.snooze_limited, |s, on| s.snooze_limited = on)) | |
| 206 | 206 | .control(switch("snooze-limited", saved.snooze_limited, &view, p, |settings, on| settings.snooze_limited = on)), |
| 207 | 207 | ) |
| 208 | 208 | .row( |
| @@ -211,7 +211,7 @@ impl SettingsView { | ||
| 211 | 211 | .description( |
| 212 | 212 | "Fold working and monitoring threads into a Working section. They return to the top of the inbox when they need you.", |
| 213 | 213 | ) |
| 214 | - .reset(reset(&view, |s| s.working_section, |s, on| s.working_section = on)) | |
| 214 | + .reset(reset(&view, cx, |s| s.working_section, |s, on| s.working_section = on)) | |
| 215 | 215 | .control(switch("working-section", saved.working_section, &view, p, |settings, on| { |
| 216 | 216 | settings.working_section = on |
| 217 | 217 | })), |
| @@ -220,7 +220,7 @@ impl SettingsView { | ||
| 220 | 220 | row("Auto-settle merged threads") |
| 221 | 221 | .id("auto-settle-merged-threads") |
| 222 | 222 | .description("Settle a thread when its pull request merges. Closed pull requests still settle automatically.") |
| 223 | - .reset(reset(&view, |s| s.auto_settle_merged, |s, on| s.auto_settle_merged = on)) | |
| 223 | + .reset(reset(&view, cx, |s| s.auto_settle_merged, |s, on| s.auto_settle_merged = on)) | |
| 224 | 224 | .control(switch("auto-settle-merged", saved.auto_settle_merged, &view, p, |settings, on| { |
| 225 | 225 | settings.auto_settle_merged = on |
| 226 | 226 | })), |
| @@ -229,7 +229,7 @@ impl SettingsView { | ||
| 229 | 229 | row("Auto-settle inactive threads") |
| 230 | 230 | .id("auto-settle-inactive-threads") |
| 231 | 231 | .description("Sidebar threads with no activity for this long settle automatically.") |
| 232 | - .reset(reset(&view, |s| (s.auto_settle_inactive, s.inactive_days), |s, (on, days)| { | |
| 232 | + .reset(reset(&view, cx, |s| (s.auto_settle_inactive, s.inactive_days), |s, (on, days)| { | |
| 233 | 233 | s.auto_settle_inactive = on; |
| 234 | 234 | s.inactive_days = days; |
| 235 | 235 | })) |
| @@ -286,7 +286,7 @@ impl SettingsView { | ||
| 286 | 286 | } else { |
| 287 | 287 | "Notifications need notify-send (libnotify), which isn't installed. Sound only is still available." |
| 288 | 288 | }) |
| 289 | - .reset(reset(&view, |s| (s.notify, s.notify_sound), |s, (notify, sound)| { | |
| 289 | + .reset(reset(&view, cx, |s| (s.notify, s.notify_sound), |s, (notify, sound)| { | |
| 290 | 290 | s.notify = notify; |
| 291 | 291 | s.notify_sound = sound; |
| 292 | 292 | })) |
| @@ -309,7 +309,7 @@ impl SettingsView { | ||
| 309 | 309 | row("Time format") |
| 310 | 310 | .id("time-format") |
| 311 | 311 | .description("System default follows your OS clock preference.") |
| 312 | - .reset(reset(&view, |s| s.time_format, |s, format| s.time_format = format)) | |
| 312 | + .reset(reset(&view, cx, |s| s.time_format, |s, format| s.time_format = format)) | |
| 313 | 313 | .control(choice("time-format", saved.time_format, TIME_FORMATS, &view, |settings, format| { |
| 314 | 314 | settings.time_format = format |
| 315 | 315 | })), |
| @@ -318,7 +318,7 @@ impl SettingsView { | ||
| 318 | 318 | row("Response streaming") |
| 319 | 319 | .id("response-streaming") |
| 320 | 320 | .description(streaming_description) |
| 321 | - .reset(reset(&view, |s| s.streaming, |s, streaming| s.streaming = streaming)) | |
| 321 | + .reset(reset(&view, cx, |s| s.streaming, |s, streaming| s.streaming = streaming)) | |
| 322 | 322 | .control(choice("streaming", saved.streaming, STREAMING, &view, |settings, streaming| { |
| 323 | 323 | settings.streaming = streaming |
| 324 | 324 | })), |
| @@ -327,21 +327,21 @@ impl SettingsView { | ||
| 327 | 327 | row("Fold finished turns") |
| 328 | 328 | .id("fold-turns") |
| 329 | 329 | .description("Tuck a finished turn's tool calls and interim messages into one \u{201c}Worked for\u{201d} row.") |
| 330 | - .reset(reset(&view, |s| s.fold_turns, |s, on| s.fold_turns = on)) | |
| 330 | + .reset(reset(&view, cx, |s| s.fold_turns, |s, on| s.fold_turns = on)) | |
| 331 | 331 | .control(switch("fold-turns", saved.fold_turns, &view, p, |settings, on| settings.fold_turns = on)), |
| 332 | 332 | ) |
| 333 | 333 | .row( |
| 334 | 334 | row("Show costs") |
| 335 | 335 | .id("show-costs") |
| 336 | 336 | .description("What each turn and thread cost, in the transcript and the project panel.") |
| 337 | - .reset(reset(&view, |s| s.show_costs, |s, on| s.show_costs = on)) | |
| 337 | + .reset(reset(&view, cx, |s| s.show_costs, |s, on| s.show_costs = on)) | |
| 338 | 338 | .control(switch("show-costs", saved.show_costs, &view, p, |settings, on| settings.show_costs = on)), |
| 339 | 339 | ) |
| 340 | 340 | .row( |
| 341 | 341 | row("Hide whitespace changes") |
| 342 | 342 | .id("hide-whitespace-changes") |
| 343 | 343 | .description("Set whether the diff panel ignores whitespace-only edits by default.") |
| 344 | - .reset(reset(&view, |s| s.hide_whitespace, |s, on| s.hide_whitespace = on)) | |
| 344 | + .reset(reset(&view, cx, |s| s.hide_whitespace, |s, on| s.hide_whitespace = on)) | |
| 345 | 345 | .control(switch("hide-whitespace", saved.hide_whitespace, &view, p, |settings, on| { |
| 346 | 346 | settings.hide_whitespace = on |
| 347 | 347 | })), |
| @@ -350,7 +350,7 @@ impl SettingsView { | ||
| 350 | 350 | row("Default diff file state") |
| 351 | 351 | .id("default-diff-file-state") |
| 352 | 352 | .description("Start with files expanded or collapsed when opening diffs.") |
| 353 | - .reset(reset(&view, |s| s.diff_collapsed, |s, collapsed| s.diff_collapsed = collapsed)) | |
| 353 | + .reset(reset(&view, cx, |s| s.diff_collapsed, |s, collapsed| s.diff_collapsed = collapsed)) | |
| 354 | 354 | .control(choice("diff-collapsed", saved.diff_collapsed, DIFF_STATES, &view, |settings, collapsed| { |
| 355 | 355 | settings.diff_collapsed = collapsed |
| 356 | 356 | })), |
| @@ -359,7 +359,7 @@ impl SettingsView { | ||
| 359 | 359 | row("Diff layout") |
| 360 | 360 | .id("diff-layout") |
| 361 | 361 | .description("Show diffs stacked or side by side. The toggle in the diff toolbar changes this too.") |
| 362 | - .reset(reset(&view, |s| s.diff_side_by_side, |s, split| s.diff_side_by_side = split)) | |
| 362 | + .reset(reset(&view, cx, |s| s.diff_side_by_side, |s, split| s.diff_side_by_side = split)) | |
| 363 | 363 | .control(choice("diff-layout", saved.diff_side_by_side, DIFF_LAYOUTS, &view, |settings, split| { |
| 364 | 364 | settings.diff_side_by_side = split |
| 365 | 365 | })), |
| @@ -368,7 +368,7 @@ impl SettingsView { | ||
| 368 | 368 | row("Proactive panels") |
| 369 | 369 | .id("proactive-panels") |
| 370 | 370 | .description("Open Changes for edits to at least 3 files or 50 lines.") |
| 371 | - .reset(reset(&view, |s| s.proactive_panels, |s, on| s.proactive_panels = on)) | |
| 371 | + .reset(reset(&view, cx, |s| s.proactive_panels, |s, on| s.proactive_panels = on)) | |
| 372 | 372 | .control(switch("proactive-panels", saved.proactive_panels, &view, p, |settings, on| { |
| 373 | 373 | settings.proactive_panels = on |
| 374 | 374 | })), |
| @@ -377,14 +377,14 @@ impl SettingsView { | ||
| 377 | 377 | row("Show skills in slash menu") |
| 378 | 378 | .id("skills-in-slash-menu") |
| 379 | 379 | .description("Also include skills in the / command menu. Skills always appear when you type $.") |
| 380 | - .reset(reset(&view, |s| s.slash_skills, |s, on| s.slash_skills = on)) | |
| 380 | + .reset(reset(&view, cx, |s| s.slash_skills, |s, on| s.slash_skills = on)) | |
| 381 | 381 | .control(switch("slash-skills", saved.slash_skills, &view, p, |settings, on| settings.slash_skills = on)), |
| 382 | 382 | ) |
| 383 | 383 | .row( |
| 384 | 384 | row("Rich text composer") |
| 385 | 385 | .id("composer-rich-text") |
| 386 | 386 | .description("Show formatted Markdown as you type.") |
| 387 | - .reset(reset(&view, |s| s.rich_composer, |s, on| s.rich_composer = on)) | |
| 387 | + .reset(reset(&view, cx, |s| s.rich_composer, |s, on| s.rich_composer = on)) | |
| 388 | 388 | .control(switch("rich-composer", saved.rich_composer, &view, p, |settings, on| settings.rich_composer = on)), |
| 389 | 389 | ) |
| 390 | 390 | .row( |
| @@ -393,7 +393,7 @@ impl SettingsView { | ||
| 393 | 393 | .description( |
| 394 | 394 | "Rest the composer of an existing thread into a single line when you scroll the conversation. Focus the composer or start typing to expand it again.", |
| 395 | 395 | ) |
| 396 | - .reset(reset(&view, |s| s.collapse_composer, |s, on| s.collapse_composer = on)) | |
| 396 | + .reset(reset(&view, cx, |s| s.collapse_composer, |s, on| s.collapse_composer = on)) | |
| 397 | 397 | .control(switch("collapse-composer", saved.collapse_composer, &view, p, |settings, on| { |
| 398 | 398 | settings.collapse_composer = on |
| 399 | 399 | })), |
| @@ -402,7 +402,7 @@ impl SettingsView { | ||
| 402 | 402 | row("Send shortcut") |
| 403 | 403 | .id("send-shortcut") |
| 404 | 404 | .description("Choose when Enter sends a prompt or inserts a new line") |
| 405 | - .reset(reset(&view, |s| s.send_shortcut, |s, shortcut| s.send_shortcut = shortcut)) | |
| 405 | + .reset(reset(&view, cx, |s| s.send_shortcut, |s, shortcut| s.send_shortcut = shortcut)) | |
| 406 | 406 | .control(choice("send-shortcut", saved.send_shortcut, SEND_SHORTCUTS, &view, |settings, shortcut| { |
| 407 | 407 | settings.send_shortcut = shortcut |
| 408 | 408 | })), |
| @@ -411,7 +411,7 @@ impl SettingsView { | ||
| 411 | 411 | row("Follow-up behavior") |
| 412 | 412 | .id("follow-up-behavior") |
| 413 | 413 | .description(follow_up_description) |
| 414 | - .reset(reset(&view, |s| s.follow_up, |s, follow_up| s.follow_up = follow_up)) | |
| 414 | + .reset(reset(&view, cx, |s| s.follow_up, |s, follow_up| s.follow_up = follow_up)) | |
| 415 | 415 | .control(choice("follow-up", saved.follow_up, FOLLOW_UPS, &view, |settings, follow_up| { |
| 416 | 416 | settings.follow_up = follow_up |
| 417 | 417 | })), |
| @@ -420,7 +420,7 @@ impl SettingsView { | ||
| 420 | 420 | row("Provider update checks") |
| 421 | 421 | .id("provider-update-checks") |
| 422 | 422 | .description("Check installed provider CLIs for newer available versions.") |
| 423 | - .reset(reset(&view, |s| s.update_checks, |s, on| s.update_checks = on)) | |
| 423 | + .reset(reset(&view, cx, |s| s.update_checks, |s, on| s.update_checks = on)) | |
| 424 | 424 | .control(switch_then( |
| 425 | 425 | "update-checks", |
| 426 | 426 | saved.update_checks, |
| @@ -434,7 +434,7 @@ impl SettingsView { | ||
| 434 | 434 | row("Continue threads after restarts") |
| 435 | 435 | .id("continue-threads-after-server-update") |
| 436 | 436 | .description("Automatically resume interrupted threads after an update, crash, or machine restart.") |
| 437 | - .reset(reset(&view, |s| s.continue_after_restart, |s, on| s.continue_after_restart = on)) | |
| 437 | + .reset(reset(&view, cx, |s| s.continue_after_restart, |s, on| s.continue_after_restart = on)) | |
| 438 | 438 | .control(switch("continue-after-restart", saved.continue_after_restart, &view, p, |settings, on| { |
| 439 | 439 | settings.continue_after_restart = on |
| 440 | 440 | })), |
| @@ -443,7 +443,7 @@ impl SettingsView { | ||
| 443 | 443 | row("Background activity") |
| 444 | 444 | .id("background-activity") |
| 445 | 445 | .description(background_description) |
| 446 | - .reset(reset(&view, |s| s.background_activity, |s, activity| s.background_activity = activity)) | |
| 446 | + .reset(reset(&view, cx, |s| s.background_activity, |s, activity| s.background_activity = activity)) | |
| 447 | 447 | .control(choice("background-activity", saved.background_activity, BACKGROUND, &view, |settings, activity| { |
| 448 | 448 | settings.background_activity = activity |
| 449 | 449 | })), |
| @@ -456,7 +456,7 @@ impl SettingsView { | ||
| 456 | 456 | row("Start from origin") |
| 457 | 457 | .id("start-from-origin") |
| 458 | 458 | .description("Creates the worktree from the latest matching branch on origin instead of your local branch.") |
| 459 | - .reset(reset(&view, |s| s.start_from_origin, |s, on| s.start_from_origin = on)) | |
| 459 | + .reset(reset(&view, cx, |s| s.start_from_origin, |s, on| s.start_from_origin = on)) | |
| 460 | 460 | .control(switch("start-from-origin", saved.start_from_origin, &view, p, |settings, on| { |
| 461 | 461 | settings.start_from_origin = on |
| 462 | 462 | })), |
| @@ -465,7 +465,7 @@ impl SettingsView { | ||
| 465 | 465 | row("Add project starts in") |
| 466 | 466 | .id("add-project-starts-in") |
| 467 | 467 | .description("Leave empty to use \u{201c}~/\u{201d} when the Add Project browser opens.") |
| 468 | - .reset(reset(&view, |s| s.add_project_dir.clone(), |s, dir| s.add_project_dir = dir)) | |
| 468 | + .reset(reset(&view, cx, |s| s.add_project_dir.clone(), |s, dir| s.add_project_dir = dir)) | |
| 469 | 469 | .control(text_field(&self.add_project_dir, 288., window, cx)), |
| 470 | 470 | ) |
| 471 | 471 | .render(cx); |
| @@ -476,28 +476,28 @@ impl SettingsView { | ||
| 476 | 476 | row("Unpin confirmation") |
| 477 | 477 | .id("unpin-confirmation") |
| 478 | 478 | .description("Ask before unpinning a thread from the pinned section.") |
| 479 | - .reset(reset(&view, |s| s.confirm_unpin, |s, on| s.confirm_unpin = on)) | |
| 479 | + .reset(reset(&view, cx, |s| s.confirm_unpin, |s, on| s.confirm_unpin = on)) | |
| 480 | 480 | .control(switch("confirm-unpin", saved.confirm_unpin, &view, p, |settings, on| settings.confirm_unpin = on)), |
| 481 | 481 | ) |
| 482 | 482 | .row( |
| 483 | 483 | row("Archive confirmation") |
| 484 | 484 | .id("archive-confirmation") |
| 485 | 485 | .description("Require a second click on the inline archive action before a thread is archived.") |
| 486 | - .reset(reset(&view, |s| s.confirm_settle, |s, on| s.confirm_settle = on)) | |
| 486 | + .reset(reset(&view, cx, |s| s.confirm_settle, |s, on| s.confirm_settle = on)) | |
| 487 | 487 | .control(switch("confirm-settle", saved.confirm_settle, &view, p, |settings, on| settings.confirm_settle = on)), |
| 488 | 488 | ) |
| 489 | 489 | .row( |
| 490 | 490 | row("Delete confirmation") |
| 491 | 491 | .id("delete-confirmation") |
| 492 | 492 | .description("Ask before deleting a thread and its chat history.") |
| 493 | - .reset(reset(&view, |s| s.confirm_delete, |s, on| s.confirm_delete = on)) | |
| 493 | + .reset(reset(&view, cx, |s| s.confirm_delete, |s, on| s.confirm_delete = on)) | |
| 494 | 494 | .control(switch("confirm-delete", saved.confirm_delete, &view, p, |settings, on| settings.confirm_delete = on)), |
| 495 | 495 | ) |
| 496 | 496 | .row( |
| 497 | 497 | row("Quit shortcut") |
| 498 | 498 | .id("quit-confirmation") |
| 499 | 499 | .description("Hold mode also quits on two quick presses.") |
| 500 | - .reset(reset(&view, |s| s.quit_shortcut, |s, shortcut| s.quit_shortcut = shortcut)) | |
| 500 | + .reset(reset(&view, cx, |s| s.quit_shortcut, |s, shortcut| s.quit_shortcut = shortcut)) | |
| 501 | 501 | .control(choice("quit-shortcut", saved.quit_shortcut, QUIT_SHORTCUTS, &view, |settings, shortcut| { |
| 502 | 502 | settings.quit_shortcut = shortcut |
| 503 | 503 | })), |
| @@ -508,7 +508,7 @@ impl SettingsView { | ||
| 508 | 508 | let text_pick = { |
| 509 | 509 | let view = view.clone(); |
| 510 | 510 | move |key: Option<String>, window: &mut Window, cx: &mut App| { |
| 511 | - edit(&view, window, cx, move |settings| settings.text_model = key.unwrap_or_default()) | |
| 511 | + edit(&view, window, cx, move |settings| settings.text_model = key.clone().unwrap_or_default()) | |
| 512 | 512 | } |
| 513 | 513 | }; |
| 514 | 514 | let text_reasoning_pick = { |
| @@ -525,7 +525,7 @@ impl SettingsView { | ||
| 525 | 525 | row("Text generation model") |
| 526 | 526 | .id("text-generation-model") |
| 527 | 527 | .description("Used for thread titles and worktree branch names. Off keeps the first line of the first message.") |
| 528 | - .reset(reset(&view, |s| (s.text_model.clone(), s.text_reasoning.clone()), |s, (model, reasoning)| { | |
| 528 | + .reset(reset(&view, cx, |s| (s.text_model.clone(), s.text_reasoning.clone()), |s, (model, reasoning)| { | |
| 529 | 529 | s.text_model = model; |
| 530 | 530 | s.text_reasoning = reasoning; |
| 531 | 531 | })) |
| @@ -590,19 +590,19 @@ impl SettingsView { | ||
| 590 | 590 | row("Plan mode (legacy)") |
| 591 | 591 | .id("legacy-plan-mode") |
| 592 | 592 | .description("Restore Build/Plan, /plan, /default, and Shift+Tab. Off uses build mode.") |
| 593 | - .reset(reset(&view, |s| s.plan_mode, |s, on| s.plan_mode = on)) | |
| 593 | + .reset(reset(&view, cx, |s| s.plan_mode, |s, on| s.plan_mode = on)) | |
| 594 | 594 | .control(switch("plan-mode", saved.plan_mode, &view, p, |settings, on| settings.plan_mode = on)) |
| 595 | 595 | .into_any_element(), |
| 596 | 596 | row("Context window indicator (legacy)") |
| 597 | 597 | .id("legacy-context-window-indicator") |
| 598 | 598 | .description("Shows context window usage as a circular indicator in the composer.") |
| 599 | - .reset(reset(&view, |s| s.context_meter, |s, on| s.context_meter = on)) | |
| 599 | + .reset(reset(&view, cx, |s| s.context_meter, |s, on| s.context_meter = on)) | |
| 600 | 600 | .control(switch("context-meter", saved.context_meter, &view, p, |settings, on| settings.context_meter = on)) |
| 601 | 601 | .into_any_element(), |
| 602 | 602 | row("Sidebar (legacy)") |
| 603 | 603 | .id("legacy-sidebar") |
| 604 | 604 | .description("Restore per-project thread trees instead of the default flat sidebar.") |
| 605 | - .reset(reset(&view, |s| s.legacy_sidebar, |s, on| s.legacy_sidebar = on)) | |
| 605 | + .reset(reset(&view, cx, |s| s.legacy_sidebar, |s, on| s.legacy_sidebar = on)) | |
| 606 | 606 | .control(switch("legacy-sidebar", saved.legacy_sidebar, &view, p, |settings, on| settings.legacy_sidebar = on)) |
| 607 | 607 | .into_any_element(), |
| 608 | 608 | ], |
| @@ -8,7 +8,6 @@ use gpui_kit::*; | ||
| 8 | 8 | use super::SettingsView; |
| 9 | 9 | use super::widgets::{Section, SettingRow, reset, switch}; |
| 10 | 10 | use crate::browser; |
| 11 | - use crate::settings; | |
| 12 | 11 | use crate::ui::icons::Lucide; |
| 13 | 12 | use crate::ui::metal::{self, MetalButton}; |
| 14 | 13 | |
| @@ -16,7 +15,7 @@ impl SettingsView { | ||
| 16 | 15 | pub(super) fn render_integrations(&mut self, cx: &mut Context<Self>) -> Vec<AnyElement> { |
| 17 | 16 | let p = metal::palette(cx); |
| 18 | 17 | let view = cx.weak_entity(); |
| 19 | - let on = settings::get(|settings| settings.agent_browser); | |
| 18 | + let on = super::scope::shown_settings(cx).agent_browser; | |
| 20 | 19 | let installed = browser::find_binary().is_some(); |
| 21 | 20 | |
| 22 | 21 | let browser = Section::new("Browser") |
| @@ -29,7 +28,7 @@ impl SettingsView { | ||
| 29 | 28 | row.status("agent-browser isn't installed: npm install -g agent-browser && agent-browser install") |
| 30 | 29 | }) |
| 31 | 30 | .dimmed(!installed) |
| 32 | - .reset(reset(&view, |s| s.agent_browser, |s, on| s.agent_browser = on)) | |
| 31 | + .reset(reset(&view, cx, |s| s.agent_browser, |s, on| s.agent_browser = on)) | |
| 33 | 32 | .control(switch("agent-browser", on, &view, p, |settings, on| settings.agent_browser = on)), |
| 34 | 33 | ) |
| 35 | 34 | .render(cx); |
| @@ -12,6 +12,7 @@ mod integrations; | ||
| 12 | 12 | mod keybindings; |
| 13 | 13 | mod licenses; |
| 14 | 14 | mod providers; |
| 15 | + mod scope; | |
| 15 | 16 | mod search; |
| 16 | 17 | mod source_control; |
| 17 | 18 | mod storage; |
| @@ -35,6 +36,7 @@ use crate::ui::metal::{self, MetalButton, MetalStyled as _}; | ||
| 35 | 36 | use crate::ui::usage::{HubSettings, UsageView}; |
| 36 | 37 | use crate::ui::workspace::{SHEET_INSET, SHEET_RADIUS, SIDEBAR_WIDTH, Workspace}; |
| 37 | 38 | |
| 39 | + pub(crate) use scope::row_titles; | |
| 38 | 40 | pub(crate) use widgets::{card, radio, section, section_title}; |
| 39 | 41 | use widgets::{EXPANDED_WIDTH, PAGE_PADDING, READABLE_WIDTH, Target, WIDE_WIDTH}; |
| 40 | 42 | |
| @@ -149,6 +151,10 @@ pub struct SettingsView { | ||
| 149 | 151 | source_control: source_control::SourceControlState, |
| 150 | 152 | diagnostics: diagnostics::DiagnosticsState, |
| 151 | 153 | licenses: licenses::LicensesState, |
| 154 | + /// Which environments and project the environment settings edit. | |
| 155 | + scope: scope::Scope, | |
| 156 | + /// The text settings last put into their fields, for the selection. | |
| 157 | + scoped_inputs: Option<[String; 4]>, | |
| 152 | 158 | focus_handle: FocusHandle, |
| 153 | 159 | _subscriptions: Vec<Subscription>, |
| 154 | 160 | } |
| @@ -203,18 +209,16 @@ impl SettingsView { | ||
| 203 | 209 | cx.subscribe_in(&writing_instructions, window, |this, input, event: &InputEvent, window, cx| { |
| 204 | 210 | if matches!(event, InputEvent::Blur) { |
| 205 | 211 | let value = input.read(cx).value().trim().to_string(); |
| 206 | - if settings::get(|settings| settings.writing_instructions != value) { | |
| 207 | - settings::update(|settings| settings.writing_instructions = value); | |
| 208 | - this.changed(window, cx); | |
| 212 | + if this.compute_shown(cx).settings.writing_instructions != value { | |
| 213 | + this.apply(|settings| settings.writing_instructions = value.clone(), window, cx); | |
| 209 | 214 | } |
| 210 | 215 | } |
| 211 | 216 | }), |
| 212 | 217 | cx.subscribe_in(&branch_instructions, window, |this, input, event: &InputEvent, window, cx| { |
| 213 | 218 | if matches!(event, InputEvent::Blur) { |
| 214 | 219 | let value = input.read(cx).value().trim().to_string(); |
| 215 | - if settings::get(|settings| settings.branch_instructions != value) { | |
| 216 | - settings::update(|settings| settings.branch_instructions = value); | |
| 217 | - this.changed(window, cx); | |
| 220 | + if this.compute_shown(cx).settings.branch_instructions != value { | |
| 221 | + this.apply(|settings| settings.branch_instructions = value.clone(), window, cx); | |
| 218 | 222 | } |
| 219 | 223 | } |
| 220 | 224 | }), |
| @@ -242,6 +246,8 @@ impl SettingsView { | ||
| 242 | 246 | source_control: source_control::SourceControlState::default(), |
| 243 | 247 | diagnostics: diagnostics::DiagnosticsState::default(), |
| 244 | 248 | licenses: licenses::LicensesState::new(window, cx), |
| 249 | + scope: scope::Scope::default(), | |
| 250 | + scoped_inputs: None, | |
| 245 | 251 | focus_handle: cx.focus_handle(), |
| 246 | 252 | _subscriptions: subscriptions, |
| 247 | 253 | }; |
| @@ -275,12 +281,8 @@ impl SettingsView { | ||
| 275 | 281 | input.update(cx, |input, cx| input.set_value(value, window, cx)); |
| 276 | 282 | }; |
| 277 | 283 | set(&self.add_project_dir, saved.add_project_dir.clone(), window, cx); |
| 278 | - set(&self.branch_prefix, saved.branch_prefix.clone(), window, cx); | |
| 279 | - set(&self.worktrees_dir, saved.worktrees_dir.clone(), window, cx); | |
| 280 | - self.branch_instructions | |
| 281 | - .update(cx, |input, cx| input.set_value(saved.branch_instructions.clone(), window, cx)); | |
| 282 | - self.writing_instructions | |
| 283 | - .update(cx, |input, cx| input.set_value(saved.writing_instructions.clone(), window, cx)); | |
| 284 | + self.scoped_inputs = None; | |
| 285 | + self.sync_scoped_inputs(window, cx); | |
| 284 | 286 | self.connections.reload(&saved, window, cx); |
| 285 | 287 | self.reload_provider_form(window, cx); |
| 286 | 288 | } |
| @@ -736,7 +738,8 @@ impl SettingsView { | ||
| 736 | 738 | } |
| 737 | 739 | } |
| 738 | 740 | |
| 739 | - /// Saves a text field when it loses focus or Enter is pressed, if it changed. | |
| 741 | + /// Saves a text field when it loses focus or Enter is pressed, if it changed, | |
| 742 | + /// to the selected environments when it's one of their settings. | |
| 740 | 743 | fn commit_on_blur( |
| 741 | 744 | input: &Entity<InputState>, |
| 742 | 745 | window: &mut Window, |
| @@ -747,9 +750,8 @@ fn commit_on_blur( | ||
| 747 | 750 | cx.subscribe_in(input, window, move |this, input, event: &InputEvent, window, cx| { |
| 748 | 751 | if matches!(event, InputEvent::Blur | InputEvent::PressEnter { .. }) { |
| 749 | 752 | let value = input.read(cx).value().trim().to_string(); |
| 750 | - if settings::get(|settings| get(settings) != value) { | |
| 751 | - settings::update(|settings| set(settings, value)); | |
| 752 | - this.changed(window, cx); | |
| 753 | + if get(&this.compute_shown(cx).settings) != value { | |
| 754 | + this.apply(|settings| set(settings, value.clone()), window, cx); | |
| 753 | 755 | } |
| 754 | 756 | } |
| 755 | 757 | }) |
| @@ -764,7 +766,13 @@ impl Focusable for SettingsView { | ||
| 764 | 766 | impl Render for SettingsView { |
| 765 | 767 | fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { |
| 766 | 768 | let p = metal::palette(cx); |
| 767 | - let page = self.render_page(window, cx); | |
| 769 | + let shown = self.compute_shown(cx); | |
| 770 | + cx.set_global(shown); | |
| 771 | + self.sync_scoped_inputs(window, cx); | |
| 772 | + let mut page = self.render_page(window, cx); | |
| 773 | + if self.page_scoped() { | |
| 774 | + page.insert(0, self.render_scope_sentence(cx)); | |
| 775 | + } | |
| 768 | 776 | let width = self.page.width(); |
| 769 | 777 | let wizard = self.render_add_provider(window, cx); |
| 770 | 778 | h_flex() |
| @@ -658,7 +658,7 @@ impl SettingsView { | ||
| 658 | 658 | .id("provider-health-check-interval") |
| 659 | 659 | .page_width(WIDE_WIDTH) |
| 660 | 660 | .description("Refresh provider status, versions, and models in the background. Set to 0 to disable.") |
| 661 | - .reset(reset(&view, |s| s.health_check_secs, |s, secs| s.health_check_secs = secs)) | |
| 661 | + .reset(reset(&view, cx, |s| s.health_check_secs, |s, secs| s.health_check_secs = secs)) | |
| 662 | 662 | .control(stepper("health-interval", health, 30, (0, 86_400), "seconds", &view, p, |settings, secs| { |
| 663 | 663 | settings.health_check_secs = secs |
| 664 | 664 | })), |
| @@ -0,0 +1,641 @@ | ||
| 1 | + //! T3 Code's settings scope: "Applying settings for <project> across | |
| 2 | + //! <environment>" at the top of the pages with environment settings. The | |
| 3 | + //! environment picker edits this device, one connected device, or all of | |
| 4 | + //! them at once; the project picker edits a project's overrides on them. | |
| 5 | + //! Rows that are a preference of this device ignore the selection. | |
| 6 | + //! | |
| 7 | + //! Each render works out what the selection shows ([`Shown`]): the values | |
| 8 | + //! the controls display, where the selected environments disagree, and | |
| 9 | + //! which settings the project overrides. Rows read it by their anchor, and | |
| 10 | + //! every change is turned into a [`SettingsPatch`] for each environment. | |
| 11 | + | |
| 12 | + use std::{ | |
| 13 | + collections::{BTreeMap, HashMap, HashSet}, | |
| 14 | + path::PathBuf, | |
| 15 | + rc::Rc, | |
| 16 | + }; | |
| 17 | + | |
| 18 | + use gpui_kit::component::{ | |
| 19 | + Icon, h_flex, | |
| 20 | + menu::{DropdownMenu as _, PopupMenuItem}, | |
| 21 | + v_flex, | |
| 22 | + }; | |
| 23 | + use gpui_kit::prelude::FluentBuilder as _; | |
| 24 | + use gpui_kit::*; | |
| 25 | + | |
| 26 | + use super::SettingsView; | |
| 27 | + use crate::settings::{self, ENVIRONMENT_KEYS, PROJECT_KEYS, Settings, SettingsPatch}; | |
| 28 | + use crate::ui::icons::Lucide; | |
| 29 | + use crate::ui::metal::{self, MetalButton}; | |
| 30 | + use crate::ui::workspace::{self, SettingsEnvironment}; | |
| 31 | + | |
| 32 | + /// The rows that edit environment settings, by anchor: their title and the | |
| 33 | + /// settings they show. | |
| 34 | + pub(crate) const SCOPED_ROWS: &[(&str, &str, &[&str])] = &[ | |
| 35 | + ("default-model", "Default model", &["default_model", "default_reasoning"]), | |
| 36 | + ("default-permissions", "Permissions", &["default_access"]), | |
| 37 | + ("new-threads", "Workspace", &["workspace"]), | |
| 38 | + ("worktree-submodules", "Submodules", &["submodules"]), | |
| 39 | + ("auto-resume-limited-threads", "Auto-resume limited threads", &["auto_resume_limited"]), | |
| 40 | + ("snooze-limited-threads", "Snooze limited threads", &["snooze_limited"]), | |
| 41 | + ("auto-settle-merged-threads", "Auto-settle merged threads", &["auto_settle_merged"]), | |
| 42 | + ("auto-settle-inactive-threads", "Auto-settle inactive threads", &["auto_settle_inactive"]), | |
| 43 | + ("days-before-auto-settle", "Days of inactivity before auto-settle", &["inactive_days"]), | |
| 44 | + ("provider-update-checks", "Provider update checks", &["update_checks"]), | |
| 45 | + ("continue-threads-after-server-update", "Continue threads after restarts", &["continue_after_restart"]), | |
| 46 | + ("start-from-origin", "Start from origin", &["start_from_origin"]), | |
| 47 | + ("text-generation-model", "Text generation model", &["text_model", "text_reasoning"]), | |
| 48 | + ("agent-browser-access", "Agent browser access", &["agent_browser"]), | |
| 49 | + ("worktree-branch-naming", "Worktree branch naming", &["branch_naming"]), | |
| 50 | + ("branch-prefix", "Branch prefix", &["branch_prefix"]), | |
| 51 | + ("branch-naming-instructions", "Branch naming instructions", &["branch_instructions"]), | |
| 52 | + ("source-control-writing-style", "Source control writing style", &["writing_style", "writing_instructions"]), | |
| 53 | + ("follow-change-request-templates", "Follow change request templates", &["follow_pr_templates"]), | |
| 54 | + ("storage-worktrees-location", "Worktree location", &["worktrees_dir"]), | |
| 55 | + ("storage-worktrees-deleted", "Delete worktrees with deleted threads", &["cleanup_deleted_worktrees"]), | |
| 56 | + ("storage-worktrees-inactive", "Delete inactive worktrees", &["cleanup_inactive_worktrees"]), | |
| 57 | + ("storage-worktrees-merged", "Delete merged worktrees", &["cleanup_merged_worktrees"]), | |
| 58 | + ("storage-worktrees-unchanged", "Delete unchanged worktrees", &["cleanup_unchanged_worktrees"]), | |
| 59 | + ]; | |
| 60 | + | |
| 61 | + /// What the open settings page shows: the selection's values where it | |
| 62 | + /// applies, this device's elsewhere. | |
| 63 | + pub(crate) fn shown_settings(cx: &App) -> Settings { | |
| 64 | + cx.try_global::<Shown>().map(|shown| shown.settings.clone()).unwrap_or_else(settings::load) | |
| 65 | + } | |
| 66 | + | |
| 67 | + fn row_keys(id: &str) -> Option<&'static [&'static str]> { | |
| 68 | + SCOPED_ROWS.iter().find(|(row, _, _)| *row == id).map(|(_, _, keys)| *keys) | |
| 69 | + } | |
| 70 | + | |
| 71 | + /// The titles of the rows showing any of `keys`, for saying what differs. | |
| 72 | + pub(crate) fn row_titles(keys: &[String]) -> Vec<&'static str> { | |
| 73 | + SCOPED_ROWS | |
| 74 | + .iter() | |
| 75 | + .filter(|(_, _, row_keys)| row_keys.iter().any(|key| keys.iter().any(|candidate| candidate == key))) | |
| 76 | + .map(|(_, title, _)| *title) | |
| 77 | + .collect() | |
| 78 | + } | |
| 79 | + | |
| 80 | + /// Which environments a change goes to. | |
| 81 | + #[derive(Clone, Debug, Default, PartialEq, Eq)] | |
| 82 | + pub(crate) enum EnvironmentPick { | |
| 83 | + #[default] | |
| 84 | + All, | |
| 85 | + /// One environment: this device (`None`) or a connection, by id. | |
| 86 | + One(Option<String>), | |
| 87 | + } | |
| 88 | + | |
| 89 | + /// The settings view's selection, kept as pages change. | |
| 90 | + #[derive(Clone, Debug, Default, PartialEq, Eq)] | |
| 91 | + pub(crate) struct Scope { | |
| 92 | + pub environment: EnvironmentPick, | |
| 93 | + /// A project by name: the same project on every environment. | |
| 94 | + pub project: Option<String>, | |
| 95 | + } | |
| 96 | + | |
| 97 | + /// One place a change is written: an environment, and the project's path | |
| 98 | + /// there when a project is picked. | |
| 99 | + #[derive(Clone, Debug)] | |
| 100 | + pub(crate) struct WriteTarget { | |
| 101 | + pub environment: Option<String>, | |
| 102 | + pub name: String, | |
| 103 | + pub project: Option<PathBuf>, | |
| 104 | + /// This device's preferences with the target's settings, as an edit | |
| 105 | + /// there starts from. | |
| 106 | + pub settings: Settings, | |
| 107 | + } | |
| 108 | + | |
| 109 | + /// What the open page shows for the selection. Rows find it as a global. | |
| 110 | + pub(crate) struct Shown { | |
| 111 | + pub view: WeakEntity<SettingsView>, | |
| 112 | + /// The page edits environment settings, so rows apply the selection. | |
| 113 | + pub scoped: bool, | |
| 114 | + /// This device's preferences, with the first selected environment's | |
| 115 | + /// settings (and the project's overrides) in place. | |
| 116 | + pub settings: Settings, | |
| 117 | + pub project: Option<String>, | |
| 118 | + pub targets: Vec<WriteTarget>, | |
| 119 | + /// Why environment settings can't change right now. | |
| 120 | + pub locked: Option<SharedString>, | |
| 121 | + /// Settings the selected environments disagree on: each one's value. | |
| 122 | + pub mixed: HashMap<String, Vec<(String, String)>>, | |
| 123 | + /// Settings the picked project overrides on any selected environment. | |
| 124 | + pub overridden: HashSet<String>, | |
| 125 | + } | |
| 126 | + | |
| 127 | + impl Global for Shown {} | |
| 128 | + | |
| 129 | + impl Shown { | |
| 130 | + pub fn compute( | |
| 131 | + view: WeakEntity<SettingsView>, | |
| 132 | + scope: &Scope, | |
| 133 | + scoped: bool, | |
| 134 | + environments: &[SettingsEnvironment], | |
| 135 | + ) -> Self { | |
| 136 | + let local = settings::load(); | |
| 137 | + let mut shown = Shown { | |
| 138 | + view, | |
| 139 | + scoped, | |
| 140 | + settings: local.clone(), | |
| 141 | + project: scope.project.clone(), | |
| 142 | + targets: vec![WriteTarget { environment: None, name: String::new(), project: None, settings: local.clone() }], | |
| 143 | + locked: None, | |
| 144 | + mixed: HashMap::new(), | |
| 145 | + overridden: HashSet::new(), | |
| 146 | + }; | |
| 147 | + if !scoped { | |
| 148 | + return shown; | |
| 149 | + } | |
| 150 | + let selected: Vec<&SettingsEnvironment> = match &scope.environment { | |
| 151 | + EnvironmentPick::All => environments.iter().filter(|env| env.connected && env.settings.is_some()).collect(), | |
| 152 | + EnvironmentPick::One(id) => match environments.iter().find(|env| env.id == *id) { | |
| 153 | + None => { | |
| 154 | + shown.locked = Some("That environment is no longer connected.".into()); | |
| 155 | + Vec::new() | |
| 156 | + } | |
| 157 | + Some(env) if !env.connected => { | |
| 158 | + shown.locked = Some(format!("{} is offline; its settings stay as they are.", env.name).into()); | |
| 159 | + Vec::new() | |
| 160 | + } | |
| 161 | + Some(env) if env.settings.is_none() => { | |
| 162 | + shown.locked = Some( | |
| 163 | + format!("{} doesn't share its settings yet. Update the app there to edit them here.", env.name) | |
| 164 | + .into(), | |
| 165 | + ); | |
| 166 | + Vec::new() | |
| 167 | + } | |
| 168 | + Some(env) => vec![env], | |
| 169 | + }, | |
| 170 | + }; | |
| 171 | + // Each selected environment's settings as the selection sees them. | |
| 172 | + let mut effective: Vec<(WriteTarget, Settings, Option<serde_json::Map<String, serde_json::Value>>)> = Vec::new(); | |
| 173 | + for env in selected { | |
| 174 | + let Some(settings) = env.settings.as_ref() else { continue }; | |
| 175 | + let target = |project: Option<PathBuf>| WriteTarget { | |
| 176 | + environment: env.id.clone(), | |
| 177 | + name: env.name.clone(), | |
| 178 | + project, | |
| 179 | + settings: Settings::default(), | |
| 180 | + }; | |
| 181 | + match &scope.project { | |
| 182 | + None => effective.push((target(None), settings.clone(), None)), | |
| 183 | + Some(name) => { | |
| 184 | + for (_, path) in env.projects.iter().filter(|(project, _)| project == name) { | |
| 185 | + let overrides = settings.project_overrides.get(&path.display().to_string()).cloned(); | |
| 186 | + effective.push((target(Some(path.clone())), settings.for_project(path), overrides)); | |
| 187 | + } | |
| 188 | + } | |
| 189 | + } | |
| 190 | + } | |
| 191 | + if effective.is_empty() { | |
| 192 | + if shown.locked.is_none() { | |
| 193 | + shown.locked = Some(match &scope.project { | |
| 194 | + Some(name) => format!("{name} isn't a project on the selected environments.").into(), | |
| 195 | + None => "No connected environment shares its settings.".into(), | |
| 196 | + }); | |
| 197 | + } | |
| 198 | + shown.targets.clear(); | |
| 199 | + return shown; | |
| 200 | + } | |
| 201 | + let values: Vec<serde_json::Map<String, serde_json::Value>> = | |
| 202 | + effective.iter().map(|(_, settings, _)| without_overrides(settings.environment_values())).collect(); | |
| 203 | + shown.settings = settings::with_values(&local, &values[0]); | |
| 204 | + shown.settings.project_overrides = local.project_overrides.clone(); | |
| 205 | + for key in ENVIRONMENT_KEYS { | |
| 206 | + let first = values[0].get(*key); | |
| 207 | + if values.iter().any(|other| other.get(*key) != first) { | |
| 208 | + let list = effective | |
| 209 | + .iter() | |
| 210 | + .zip(&values) | |
| 211 | + .map(|((target, _, _), values)| { | |
| 212 | + (target.name.clone(), describe(values.get(*key).unwrap_or(&serde_json::Value::Null))) | |
| 213 | + }) | |
| 214 | + .collect(); | |
| 215 | + shown.mixed.insert(key.to_string(), list); | |
| 216 | + } | |
| 217 | + } | |
| 218 | + shown.overridden = effective.iter().flat_map(|(_, _, overrides)| overrides.iter().flat_map(|map| map.keys().cloned())).collect(); | |
| 219 | + shown.targets = effective | |
| 220 | + .into_iter() | |
| 221 | + .zip(&values) | |
| 222 | + .map(|((target, _, _), values)| WriteTarget { settings: settings::with_values(&local, values), ..target }) | |
| 223 | + .collect(); | |
| 224 | + shown | |
| 225 | + } | |
| 226 | + | |
| 227 | + /// How a row with anchor `id` appears, when the selection touches it. | |
| 228 | + pub fn row(&self, id: &str) -> Option<RowScope> { | |
| 229 | + if !self.scoped { | |
| 230 | + return None; | |
| 231 | + } | |
| 232 | + let keys = row_keys(id)?; | |
| 233 | + let overridable = keys.iter().all(|key| PROJECT_KEYS.contains(key)); | |
| 234 | + let locked = self.locked.clone().or_else(|| { | |
| 235 | + (self.project.is_some() && !overridable) | |
| 236 | + .then(|| "Applies to the whole environment. Pick All projects to change it.".into()) | |
| 237 | + }); | |
| 238 | + let mixed: Vec<(String, String)> = keys.iter().filter_map(|key| self.mixed.get(*key)).flatten().cloned().collect(); | |
| 239 | + Some(RowScope { | |
| 240 | + keys, | |
| 241 | + locked, | |
| 242 | + mixed: (!mixed.is_empty()).then_some(mixed), | |
| 243 | + project: self.project.is_some() && overridable, | |
| 244 | + overridden: keys.iter().any(|key| self.overridden.contains(*key)), | |
| 245 | + view: self.view.clone(), | |
| 246 | + }) | |
| 247 | + } | |
| 248 | + } | |
| 249 | + | |
| 250 | + /// What the selection means for one row. | |
| 251 | + pub(crate) struct RowScope { | |
| 252 | + pub keys: &'static [&'static str], | |
| 253 | + pub locked: Option<SharedString>, | |
| 254 | + /// Each selected environment's value, when they differ. | |
| 255 | + pub mixed: Option<Vec<(String, String)>>, | |
| 256 | + /// The row edits the picked project's override. | |
| 257 | + pub project: bool, | |
| 258 | + pub overridden: bool, | |
| 259 | + pub view: WeakEntity<SettingsView>, | |
| 260 | + } | |
| 261 | + | |
| 262 | + fn without_overrides(mut values: serde_json::Map<String, serde_json::Value>) -> serde_json::Map<String, serde_json::Value> { | |
| 263 | + values.remove("project_overrides"); | |
| 264 | + values | |
| 265 | + } | |
| 266 | + | |
| 267 | + /// A setting's value in a few words, for the Mixed tooltip. | |
| 268 | + fn describe(value: &serde_json::Value) -> String { | |
| 269 | + match value { | |
| 270 | + serde_json::Value::Null => "Automatic".into(), | |
| 271 | + serde_json::Value::Bool(true) => "On".into(), | |
| 272 | + serde_json::Value::Bool(false) => "Off".into(), | |
| 273 | + serde_json::Value::String(text) if text.is_empty() => "Empty".into(), | |
| 274 | + serde_json::Value::String(text) => { | |
| 275 | + let text = text.replace('_', " "); | |
| 276 | + let mut chars = text.chars(); | |
| 277 | + match chars.next() { | |
| 278 | + Some(first) => first.to_uppercase().chain(chars).collect(), | |
| 279 | + None => text, | |
| 280 | + } | |
| 281 | + } | |
| 282 | + other => other.to_string(), | |
| 283 | + } | |
| 284 | + } | |
| 285 | + | |
| 286 | + /// The patch writing `values` to `target`: environment settings, or the | |
| 287 | + /// project's overrides of them. | |
| 288 | + fn patch_for(target: &WriteTarget, values: &serde_json::Map<String, serde_json::Value>) -> SettingsPatch { | |
| 289 | + match &target.project { | |
| 290 | + None => SettingsPatch { values: values.clone(), projects: BTreeMap::new() }, | |
| 291 | + Some(path) => { | |
| 292 | + let overrides = values.iter().filter(|(key, _)| PROJECT_KEYS.contains(&key.as_str())).map(|(k, v)| (k.clone(), v.clone())); | |
| 293 | + SettingsPatch { values: Default::default(), projects: BTreeMap::from([(path.display().to_string(), overrides.collect())]) } | |
| 294 | + } | |
| 295 | + } | |
| 296 | + } | |
| 297 | + | |
| 298 | + impl SettingsView { | |
| 299 | + /// Whether the open page has environment settings, and so the sentence. | |
| 300 | + pub(super) fn page_scoped(&self) -> bool { | |
| 301 | + use super::Page; | |
| 302 | + matches!(self.page, Page::General | Page::Integrations | Page::SourceControl | Page::Storage) | |
| 303 | + } | |
| 304 | + | |
| 305 | + fn environments(&self, cx: &App) -> Vec<SettingsEnvironment> { | |
| 306 | + self.workspace | |
| 307 | + .upgrade() | |
| 308 | + .map(|workspace| workspace.read(cx).settings_environments()) | |
| 309 | + .unwrap_or_default() | |
| 310 | + } | |
| 311 | + | |
| 312 | + /// Works the selection out afresh. | |
| 313 | + pub(super) fn compute_shown(&self, cx: &mut Context<Self>) -> Shown { | |
| 314 | + let environments = self.environments(cx); | |
| 315 | + Shown::compute(cx.weak_entity(), &self.scope, self.page_scoped(), &environments) | |
| 316 | + } | |
| 317 | + | |
| 318 | + /// Applies an edit made on a page: this device's preferences here, and | |
| 319 | + /// environment settings to each selected environment (or the project's | |
| 320 | + /// overrides there). The edit runs on each environment's own settings, | |
| 321 | + /// so picking the shown value of a Mixed setting still reaches the | |
| 322 | + /// environments that differ. | |
| 323 | + pub(super) fn apply(&mut self, change: impl Fn(&mut Settings), window: &mut Window, cx: &mut Context<Self>) { | |
| 324 | + let shown = self.compute_shown(cx); | |
| 325 | + let environment_key = |key: &String| shown.scoped && ENVIRONMENT_KEYS.contains(&key.as_str()); | |
| 326 | + let changes = |before: &Settings| { | |
| 327 | + let mut after = before.clone(); | |
| 328 | + change(&mut after); | |
| 329 | + after.normalize(); | |
| 330 | + settings::changed_values(before, &after) | |
| 331 | + }; | |
| 332 | + let device: serde_json::Map<_, _> = changes(&shown.settings) | |
| 333 | + .into_iter() | |
| 334 | + .filter(|(key, _)| key != "project_overrides" && !environment_key(key)) | |
| 335 | + .collect(); | |
| 336 | + if !device.is_empty() { | |
| 337 | + settings::update(|settings| *settings = settings::with_values(settings, &device)); | |
| 338 | + } | |
| 339 | + if shown.scoped && shown.locked.is_none() { | |
| 340 | + let patches: Vec<(Option<String>, SettingsPatch)> = shown | |
| 341 | + .targets | |
| 342 | + .iter() | |
| 343 | + .map(|target| { | |
| 344 | + let values = changes(&target.settings).into_iter().filter(|(key, _)| environment_key(key)).collect(); | |
| 345 | + (target.environment.clone(), patch_for(target, &values)) | |
| 346 | + }) | |
| 347 | + .collect(); | |
| 348 | + let _ = self.workspace.update(cx, |workspace, cx| { | |
| 349 | + for (environment, patch) in patches { | |
| 350 | + workspace.write_settings(environment.as_deref(), patch, cx); | |
| 351 | + } | |
| 352 | + }); | |
| 353 | + } | |
| 354 | + self.changed(window, cx); | |
| 355 | + } | |
| 356 | + | |
| 357 | + /// Lets the picked project follow its environments again for `keys`. | |
| 358 | + pub(crate) fn inherit(&mut self, keys: &[&str], window: &mut Window, cx: &mut Context<Self>) { | |
| 359 | + let shown = self.compute_shown(cx); | |
| 360 | + let _ = self.workspace.update(cx, |workspace, cx| { | |
| 361 | + for target in &shown.targets { | |
| 362 | + let Some(path) = &target.project else { continue }; | |
| 363 | + let drop = keys.iter().map(|key| (key.to_string(), serde_json::Value::Null)).collect(); | |
| 364 | + let patch = SettingsPatch { values: Default::default(), projects: BTreeMap::from([(path.display().to_string(), drop)]) }; | |
| 365 | + workspace.write_settings(target.environment.as_deref(), patch, cx); | |
| 366 | + } | |
| 367 | + }); | |
| 368 | + self.changed(window, cx); | |
| 369 | + } | |
| 370 | + | |
| 371 | + fn set_scope(&mut self, scope: Scope, window: &mut Window, cx: &mut Context<Self>) { | |
| 372 | + let _ = window; | |
| 373 | + self.scope = scope; | |
| 374 | + // The next render puts the new selection's values in the fields. | |
| 375 | + self.scoped_inputs = None; | |
| 376 | + cx.notify(); | |
| 377 | + } | |
| 378 | + | |
| 379 | + /// "Applying settings for <project> across <environment>". | |
| 380 | + pub(super) fn render_scope_sentence(&self, cx: &mut Context<Self>) -> AnyElement { | |
| 381 | + let p = metal::palette(cx); | |
| 382 | + let environments = self.environments(cx); | |
| 383 | + let view = cx.weak_entity(); | |
| 384 | + | |
| 385 | + // Projects by name, across every environment. | |
| 386 | + let mut projects: Vec<String> = Vec::new(); | |
| 387 | + for (name, _) in environments.iter().flat_map(|env| &env.projects) { | |
| 388 | + if !projects.contains(name) { | |
| 389 | + projects.push(name.clone()); | |
| 390 | + } | |
| 391 | + } | |
| 392 | + let project_icon = |name: &str| metal::badge(workspace::initials(name), workspace::tint_for(name), p); | |
| 393 | + let project_label: SharedString = match &self.scope.project { | |
| 394 | + Some(name) if projects.contains(name) => name.clone().into(), | |
| 395 | + Some(_) => "Unavailable project".into(), | |
| 396 | + None => "All projects".into(), | |
| 397 | + }; | |
| 398 | + let project_menu = { | |
| 399 | + let view = view.clone(); | |
| 400 | + let scope = self.scope.clone(); | |
| 401 | + let projects = projects.clone(); | |
| 402 | + picker("scope-project", self.scope.project.as_deref().map(|name| project_icon(name).into_any_element()), project_label) | |
| 403 | + .dropdown_menu_with_anchor(Anchor::TopLeft, move |mut menu, _, cx| { | |
| 404 | + let p = metal::palette(cx); | |
| 405 | + let pick = |project: Option<String>| { | |
| 406 | + let view = view.clone(); | |
| 407 | + let scope = Scope { project, ..scope.clone() }; | |
| 408 | + move |_: &ClickEvent, window: &mut Window, cx: &mut App| { | |
| 409 | + let scope = scope.clone(); | |
| 410 | + let _ = view.update(cx, |view, cx| view.set_scope(scope, window, cx)); | |
| 411 | + } | |
| 412 | + }; | |
| 413 | + menu = menu | |
| 414 | + .min_w(px(220.)) | |
| 415 | + .max_h(px(420.)) | |
| 416 | + .scrollable(true) | |
| 417 | + .item(PopupMenuItem::new("All projects").checked(scope.project.is_none()).on_click(pick(None))); | |
| 418 | + if !projects.is_empty() { | |
| 419 | + menu = menu.separator(); | |
| 420 | + } | |
| 421 | + for name in &projects { | |
| 422 | + let label = name.clone(); | |
| 423 | + let (initials, tint) = (workspace::initials(name), workspace::tint_for(name)); | |
| 424 | + menu = menu.item( | |
| 425 | + PopupMenuItem::element(move |_, _| { | |
| 426 | + h_flex() | |
| 427 | + .gap(px(8.)) | |
| 428 | + .child(metal::badge(initials.clone(), tint, p)) | |
| 429 | + .child(div().text_size(px(13.)).child(label.clone())) | |
| 430 | + }) | |
| 431 | + .checked(scope.project.as_deref() == Some(name.as_str())) | |
| 432 | + .on_click(pick(Some(name.clone()))), | |
| 433 | + ); | |
| 434 | + } | |
| 435 | + menu | |
| 436 | + }) | |
| 437 | + }; | |
| 438 | + | |
| 439 | + let single = environments.len() < 2; | |
| 440 | + let selected = match &self.scope.environment { | |
| 441 | + EnvironmentPick::One(id) => Some(environments.iter().find(|env| env.id == *id)), | |
| 442 | + EnvironmentPick::All => None, | |
| 443 | + }; | |
| 444 | + let environment_label: SharedString = match selected { | |
| 445 | + Some(Some(env)) => env.name.clone().into(), | |
| 446 | + Some(None) => "Unavailable environment".into(), | |
| 447 | + None if single => environments.first().map(|env| env.name.clone()).unwrap_or_default().into(), | |
| 448 | + None => "All environments".into(), | |
| 449 | + }; | |
| 450 | + let environment_icon = match selected { | |
| 451 | + Some(Some(env)) => Some(machine_icon(env.id.is_none())), | |
| 452 | + None if single => Some(machine_icon(true)), | |
| 453 | + _ => None, | |
| 454 | + }; | |
| 455 | + let entries: Rc<Vec<(Option<String>, String, bool)>> = | |
| 456 | + Rc::new(environments.iter().map(|env| (env.id.clone(), env.name.clone(), env.connected)).collect()); | |
| 457 | + let environment_menu = { | |
| 458 | + let view = view.clone(); | |
| 459 | + let scope = self.scope.clone(); | |
| 460 | + let entries = entries.clone(); | |
| 461 | + picker("scope-environment", environment_icon.map(|icon| icon.size(px(13.)).text_color(p.ink2).into_any_element()), environment_label) | |
| 462 | + .dropdown_menu_with_anchor(Anchor::TopLeft, move |mut menu, _, cx| { | |
| 463 | + let p = metal::palette(cx); | |
| 464 | + let pick = |environment: EnvironmentPick| { | |
| 465 | + let view = view.clone(); | |
| 466 | + let scope = Scope { environment, ..scope.clone() }; | |
| 467 | + move |_: &ClickEvent, window: &mut Window, cx: &mut App| { | |
| 468 | + let scope = scope.clone(); | |
| 469 | + let _ = view.update(cx, |view, cx| view.set_scope(scope, window, cx)); | |
| 470 | + } | |
| 471 | + }; | |
| 472 | + menu = menu.min_w(px(240.)); | |
| 473 | + if entries.len() > 1 { | |
| 474 | + menu = menu | |
| 475 | + .item( | |
| 476 | + PopupMenuItem::new("All environments") | |
| 477 | + .icon(Icon::new(Lucide::Layers)) | |
| 478 | + .checked(scope.environment == EnvironmentPick::All) | |
| 479 | + .on_click(pick(EnvironmentPick::All)), | |
| 480 | + ) | |
| 481 | + .separator(); | |
| 482 | + } | |
| 483 | + for (id, name, connected) in entries.iter() { | |
| 484 | + let (label, connected, local) = (name.clone(), *connected, id.is_none()); | |
| 485 | + let checked = scope.environment == EnvironmentPick::One(id.clone()) | |
| 486 | + || (entries.len() == 1 && scope.environment == EnvironmentPick::All); | |
| 487 | + menu = menu.item( | |
| 488 | + PopupMenuItem::element(move |_, _| { | |
| 489 | + h_flex() | |
| 490 | + .w_full() | |
| 491 | + .gap(px(8.)) | |
| 492 | + .child(machine_icon(local).size(px(13.)).text_color(p.ink2)) | |
| 493 | + .child(div().flex_1().text_size(px(13.)).child(label.clone())) | |
| 494 | + .when(!connected, |this| { | |
| 495 | + this.child(div().text_size(px(11.5)).text_color(p.ink3).child("Offline")) | |
| 496 | + }) | |
| 497 | + }) | |
| 498 | + .checked(checked) | |
| 499 | + .on_click(pick(EnvironmentPick::One(id.clone()))), | |
| 500 | + ); | |
| 501 | + } | |
| 502 | + menu | |
| 503 | + }) | |
| 504 | + }; | |
| 505 | + let connective = if matches!(self.scope.environment, EnvironmentPick::One(_)) || single { "on" } else { "across" }; | |
| 506 | + | |
| 507 | + let text = |text: &'static str| div().flex_none().text_size(px(14.)).text_color(p.ink3).child(text); | |
| 508 | + v_flex() | |
| 509 | + .gap(px(6.)) | |
| 510 | + .child( | |
| 511 | + h_flex() | |
| 512 | + .id("settings-scope") | |
| 513 | + .flex_wrap() | |
| 514 | + .px(px(16.)) | |
| 515 | + .gap(px(6.)) | |
| 516 | + .child(h_flex().gap(px(4.)).child(text("Applying settings for")).child(project_menu)) | |
| 517 | + .child(h_flex().gap(px(4.)).child(text(connective)).child(environment_menu)), | |
| 518 | + ) | |
| 519 | + .into_any_element() | |
| 520 | + } | |
| 521 | + | |
| 522 | + /// Puts the selection's text settings into their fields when they | |
| 523 | + /// changed: on picking another scope, or when another device changed them. | |
| 524 | + /// Reads what the last render worked out, as the workspace may be busy. | |
| 525 | + pub(super) fn sync_scoped_inputs(&mut self, window: &mut Window, cx: &mut Context<Self>) { | |
| 526 | + let shown = shown_settings(cx); | |
| 527 | + let values = [ | |
| 528 | + shown.branch_prefix.clone(), | |
| 529 | + shown.worktrees_dir.clone(), | |
| 530 | + shown.branch_instructions.clone(), | |
| 531 | + shown.writing_instructions.clone(), | |
| 532 | + ]; | |
| 533 | + if self.scoped_inputs.as_ref() == Some(&values) { | |
| 534 | + return; | |
| 535 | + } | |
| 536 | + let [prefix, worktrees, branch_instructions, writing_instructions] = values.clone(); | |
| 537 | + self.branch_prefix.update(cx, |input, cx| input.set_value(prefix, window, cx)); | |
| 538 | + self.worktrees_dir.update(cx, |input, cx| input.set_value(worktrees, window, cx)); | |
| 539 | + self.branch_instructions.update(cx, |input, cx| input.set_value(branch_instructions, window, cx)); | |
| 540 | + self.writing_instructions.update(cx, |input, cx| input.set_value(writing_instructions, window, cx)); | |
| 541 | + self.scoped_inputs = Some(values); | |
| 542 | + } | |
| 543 | + } | |
| 544 | + | |
| 545 | + /// The laptop for this device, a server for the others. | |
| 546 | + fn machine_icon(local: bool) -> Icon { | |
| 547 | + Icon::new(if local { Lucide::Laptop } else { Lucide::Server }) | |
| 548 | + } | |
| 549 | + | |
| 550 | + /// One of the sentence's pickers: a quiet button with a caret. | |
| 551 | + fn picker(id: &'static str, icon: Option<AnyElement>, label: SharedString) -> MetalButton { | |
| 552 | + let button = MetalButton::new(id).ghost().compact().label(label).caret().max_w(px(288.)); | |
| 553 | + match icon { | |
| 554 | + Some(icon) => button.leading(icon), | |
| 555 | + None => button, | |
| 556 | + } | |
| 557 | + } | |
| 558 | + | |
| 559 | + #[cfg(test)] | |
| 560 | + mod tests { | |
| 561 | + use super::*; | |
| 562 | + use crate::ui::settings::search::ITEMS; | |
| 563 | + | |
| 564 | + #[::core::prelude::v1::test] | |
| 565 | + fn scoped_rows_cover_the_environment_settings() { | |
| 566 | + for (id, _, keys) in SCOPED_ROWS { | |
| 567 | + assert!(ITEMS.iter().any(|item| item.id == *id), "{id} isn't a settings row"); | |
| 568 | + for key in *keys { | |
| 569 | + assert!(ENVIRONMENT_KEYS.contains(key), "{key} isn't an environment setting"); | |
| 570 | + } | |
| 571 | + } | |
| 572 | + for key in ENVIRONMENT_KEYS { | |
| 573 | + assert!(SCOPED_ROWS.iter().any(|(_, _, keys)| keys.contains(key)), "no row shows {key}"); | |
| 574 | + } | |
| 575 | + } | |
| 576 | + | |
| 577 | + fn environment(id: Option<&str>, settings: Settings, projects: &[(&str, &str)]) -> SettingsEnvironment { | |
| 578 | + SettingsEnvironment { | |
| 579 | + id: id.map(str::to_string), | |
| 580 | + name: id.unwrap_or("here").to_string(), | |
| 581 | + connected: true, | |
| 582 | + settings: Some(settings), | |
| 583 | + projects: projects.iter().map(|(name, path)| (name.to_string(), PathBuf::from(path))).collect(), | |
| 584 | + } | |
| 585 | + } | |
| 586 | + | |
| 587 | + #[::core::prelude::v1::test] | |
| 588 | + fn environments_disagreeing_show_as_mixed() { | |
| 589 | + let mut other = Settings::default(); | |
| 590 | + other.workspace = settings::WorkspaceMode::Worktree; | |
| 591 | + let environments = [environment(None, Settings::default(), &[]), environment(Some("box"), other, &[])]; | |
| 592 | + let shown = Shown::compute(WeakEntity::new_invalid(), &Scope::default(), true, &environments); | |
| 593 | + assert_eq!(shown.targets.len(), 2); | |
| 594 | + assert_eq!(shown.mixed["workspace"], [("here".to_string(), "Local".to_string()), ("box".into(), "Worktree".into())]); | |
| 595 | + assert!(shown.row("new-threads").unwrap().mixed.is_some()); | |
| 596 | + assert!(shown.row("worktree-submodules").unwrap().mixed.is_none()); | |
| 597 | + // A device preference is no environment's business. | |
| 598 | + assert!(shown.row("time-format").is_none()); | |
| 599 | + } | |
| 600 | + | |
| 601 | + #[::core::prelude::v1::test] | |
| 602 | + fn a_project_writes_its_overrides_where_it_exists() { | |
| 603 | + let mut box_settings = Settings::default(); | |
| 604 | + box_settings | |
| 605 | + .project_overrides | |
| 606 | + .insert("/srv/app".into(), serde_json::Map::from_iter([("submodules".to_string(), serde_json::json!("skip"))])); | |
| 607 | + let environments = [ | |
| 608 | + environment(None, Settings::default(), &[("app", "/home/me/app")]), | |
| 609 | + environment(Some("box"), box_settings, &[("app", "/srv/app"), ("other", "/srv/other")]), | |
| 610 | + ]; | |
| 611 | + let scope = Scope { environment: EnvironmentPick::All, project: Some("app".into()) }; | |
| 612 | + let shown = Shown::compute(WeakEntity::new_invalid(), &scope, true, &environments); | |
| 613 | + let paths: Vec<_> = shown.targets.iter().filter_map(|target| target.project.clone()).collect(); | |
| 614 | + assert_eq!(paths, [PathBuf::from("/home/me/app"), PathBuf::from("/srv/app")]); | |
| 615 | + assert!(shown.overridden.contains("submodules")); | |
| 616 | + let row = shown.row("worktree-submodules").unwrap(); | |
| 617 | + assert!(row.project && row.overridden && row.mixed.is_some() && row.locked.is_none()); | |
| 618 | + // Environment-wide settings can't be overridden. | |
| 619 | + assert!(shown.row("storage-worktrees-location").unwrap().locked.is_some()); | |
| 620 | + | |
| 621 | + let values = serde_json::Map::from_iter([ | |
| 622 | + ("workspace".to_string(), serde_json::json!("worktree")), | |
| 623 | + ("worktrees_dir".to_string(), serde_json::json!("/tmp")), | |
| 624 | + ]); | |
| 625 | + let patch = patch_for(&shown.targets[1], &values); | |
| 626 | + assert!(patch.values.is_empty()); | |
| 627 | + assert_eq!(patch.projects["/srv/app"].keys().collect::<Vec<_>>(), ["workspace"]); | |
| 628 | + } | |
| 629 | + | |
| 630 | + #[::core::prelude::v1::test] | |
| 631 | + fn an_offline_environment_is_left_alone() { | |
| 632 | + let mut offline = environment(Some("box"), Settings::default(), &[]); | |
| 633 | + offline.connected = false; | |
| 634 | + let environments = [environment(None, Settings::default(), &[]), offline]; | |
| 635 | + let all = Shown::compute(WeakEntity::new_invalid(), &Scope::default(), true, &environments); | |
| 636 | + assert_eq!(all.targets.len(), 1); | |
| 637 | + let one = Scope { environment: EnvironmentPick::One(Some("box".into())), project: None }; | |
| 638 | + let shown = Shown::compute(WeakEntity::new_invalid(), &one, true, &environments); | |
| 639 | + assert!(shown.targets.is_empty() && shown.locked.is_some()); | |
| 640 | + } | |
| 641 | + } | |
| @@ -10,7 +10,7 @@ use gpui_kit::*; | ||
| 10 | 10 | |
| 11 | 11 | use super::SettingsView; |
| 12 | 12 | use super::widgets::{Section, SettingRow, choice, code, empty_row, reset, switch, text_field}; |
| 13 | - use crate::settings::{self, BranchNaming, WritingStyle}; | |
| 13 | + use crate::settings::{BranchNaming, WritingStyle}; | |
| 14 | 14 | use crate::ui::icons::Lucide; |
| 15 | 15 | use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _}; |
| 16 | 16 | use crate::ui::workspace; |
| @@ -96,7 +96,7 @@ impl SettingsView { | ||
| 96 | 96 | |
| 97 | 97 | pub(super) fn render_source_control(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> { |
| 98 | 98 | let p = metal::palette(cx); |
| 99 | - let saved = settings::load(); | |
| 99 | + let saved = super::scope::shown_settings(cx); | |
| 100 | 100 | let view = cx.weak_entity(); |
| 101 | 101 | if self.source_control.git.is_none() && !self.source_control.scanning { |
| 102 | 102 | self.scan_source_control(cx); |
| @@ -138,7 +138,7 @@ impl SettingsView { | ||
| 138 | 138 | "The model chooses a prefix that describes the work, such as feat/add-search, fix/login-timeout, or refactor/auth.", |
| 139 | 139 | ) |
| 140 | 140 | }) |
| 141 | - .reset(reset(&view, |s| s.branch_naming, |s, naming| s.branch_naming = naming)) | |
| 141 | + .reset(reset(&view, cx, |s| s.branch_naming, |s, naming| s.branch_naming = naming)) | |
| 142 | 142 | .control(choice("branch-naming", saved.branch_naming, BRANCH_NAMING, &view, |settings, naming| { |
| 143 | 143 | settings.branch_naming = naming |
| 144 | 144 | })), |
| @@ -149,7 +149,7 @@ impl SettingsView { | ||
| 149 | 149 | SettingRow::new("Branch prefix") |
| 150 | 150 | .id("branch-prefix") |
| 151 | 151 | .description("For example, vectrum or vectrum/ produces vectrum/add-search. Leave empty for no prefix.") |
| 152 | - .reset(reset(&view, |s| s.branch_prefix.clone(), |s, prefix| s.branch_prefix = prefix)) | |
| 152 | + .reset(reset(&view, cx, |s| s.branch_prefix.clone(), |s, prefix| s.branch_prefix = prefix)) | |
| 153 | 153 | .control(text_field(&self.branch_prefix, 224., window, cx)), |
| 154 | 154 | ); |
| 155 | 155 | } |
| @@ -160,7 +160,7 @@ impl SettingsView { | ||
| 160 | 160 | .description( |
| 161 | 161 | "Appended to the naming prompt. The model returns the complete branch name; no prefix or suffix is added.", |
| 162 | 162 | ) |
| 163 | - .reset(reset(&view, |s| s.branch_instructions.clone(), |s, text| s.branch_instructions = text)) | |
| 163 | + .reset(reset(&view, cx, |s| s.branch_instructions.clone(), |s, text| s.branch_instructions = text)) | |
| 164 | 164 | .below( |
| 165 | 165 | div() |
| 166 | 166 | .px(px(2.)) |
| @@ -185,7 +185,7 @@ impl SettingsView { | ||
| 185 | 185 | WritingStyle::Conventional => "Use Conventional Commit prefixes and keep change request text concise.", |
| 186 | 186 | WritingStyle::Custom => "Use your instructions for change descriptions and change requests in every project.", |
| 187 | 187 | }) |
| 188 | - .reset(reset(&view, |s| s.writing_style, |s, style| s.writing_style = style)) | |
| 188 | + .reset(reset(&view, cx, |s| s.writing_style, |s, style| s.writing_style = style)) | |
| 189 | 189 | .control(choice("writing-style", saved.writing_style, WRITING_STYLES, &view, |settings, style| { |
| 190 | 190 | settings.writing_style = style |
| 191 | 191 | })) |
| @@ -206,7 +206,7 @@ impl SettingsView { | ||
| 206 | 206 | SettingRow::new("Follow change request templates") |
| 207 | 207 | .id("follow-change-request-templates") |
| 208 | 208 | .description("Use the repository's template for change request descriptions when available.") |
| 209 | - .reset(reset(&view, |s| s.follow_pr_templates, |s, on| s.follow_pr_templates = on)) | |
| 209 | + .reset(reset(&view, cx, |s| s.follow_pr_templates, |s, on| s.follow_pr_templates = on)) | |
| 210 | 210 | .control(switch("follow-pr-templates", saved.follow_pr_templates, &view, p, |settings, on| { |
| 211 | 211 | settings.follow_pr_templates = on |
| 212 | 212 | })), |
| @@ -20,7 +20,7 @@ impl SettingsView { | ||
| 20 | 20 | pub(super) fn render_storage(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> { |
| 21 | 21 | let view = cx.weak_entity(); |
| 22 | 22 | let p = metal::palette(cx); |
| 23 | - let saved = settings::load(); | |
| 23 | + let saved = super::scope::shown_settings(cx); | |
| 24 | 24 | let data = settings::data_dir(); |
| 25 | 25 | let default_worktrees = data.as_ref().map(|data| data.join("worktrees")); |
| 26 | 26 | let worktrees = Section::new("Worktrees") |
| @@ -32,12 +32,12 @@ impl SettingsView { | ||
| 32 | 32 | "Folder where new worktrees are created, on any drive, such as ~/worktrees. Existing worktrees stay where they are. Leave empty to use the app's data folder.", |
| 33 | 33 | ) |
| 34 | 34 | .status(match &default_worktrees { |
| 35 | - Some(path) if settings::get(|settings| settings.worktrees_dir.trim().is_empty()) => { | |
| 35 | + Some(path) if saved.worktrees_dir.trim().is_empty() => { | |
| 36 | 36 | format!("Default: {}", display_path(path)) |
| 37 | 37 | } |
| 38 | 38 | _ => String::new(), |
| 39 | 39 | }) |
| 40 | - .reset(reset(&view, |s| s.worktrees_dir.clone(), |s, dir| s.worktrees_dir = dir)) | |
| 40 | + .reset(reset(&view, cx, |s| s.worktrees_dir.clone(), |s, dir| s.worktrees_dir = dir)) | |
| 41 | 41 | .control(text_field(&self.worktrees_dir, 288., window, cx)), |
| 42 | 42 | ) |
| 43 | 43 | .row( |
| @@ -46,7 +46,7 @@ impl SettingsView { | ||
| 46 | 46 | .description( |
| 47 | 47 | "Remove unused worktrees when active or archived threads are deleted. Worktrees with local changes are kept.", |
| 48 | 48 | ) |
| 49 | - .reset(reset(&view, |s| s.cleanup_deleted_worktrees, |s, on| s.cleanup_deleted_worktrees = on)) | |
| 49 | + .reset(reset(&view, cx, |s| s.cleanup_deleted_worktrees, |s, on| s.cleanup_deleted_worktrees = on)) | |
| 50 | 50 | .control(switch("cleanup-deleted", saved.cleanup_deleted_worktrees, &view, p, |settings, on| { |
| 51 | 51 | settings.cleanup_deleted_worktrees = on |
| 52 | 52 | })), |
| @@ -57,14 +57,14 @@ impl SettingsView { | ||
| 57 | 57 | .description( |
| 58 | 58 | "Remove worktrees after their threads have been inactive for this many days. Branches and thread history are kept.", |
| 59 | 59 | ) |
| 60 | - .reset(reset(&view, |s| s.cleanup_inactive_worktrees, |s, days| s.cleanup_inactive_worktrees = days)) | |
| 60 | + .reset(reset(&view, cx, |s| s.cleanup_inactive_worktrees, |s, days| s.cleanup_inactive_worktrees = days)) | |
| 61 | 61 | .control(retention("cleanup-inactive", saved.cleanup_inactive_worktrees, &view, p)), |
| 62 | 62 | ) |
| 63 | 63 | .row( |
| 64 | 64 | SettingRow::new("Delete merged worktrees") |
| 65 | 65 | .id("storage-worktrees-merged") |
| 66 | 66 | .description("Remove worktrees whose pull request is merged and whose commits are included in the default branch.") |
| 67 | - .reset(reset(&view, |s| s.cleanup_merged_worktrees, |s, on| s.cleanup_merged_worktrees = on)) | |
| 67 | + .reset(reset(&view, cx, |s| s.cleanup_merged_worktrees, |s, on| s.cleanup_merged_worktrees = on)) | |
| 68 | 68 | .control(switch("cleanup-merged", saved.cleanup_merged_worktrees, &view, p, |settings, on| { |
| 69 | 69 | settings.cleanup_merged_worktrees = on |
| 70 | 70 | })), |
| @@ -73,7 +73,7 @@ impl SettingsView { | ||
| 73 | 73 | SettingRow::new("Delete unchanged worktrees") |
| 74 | 74 | .id("storage-worktrees-unchanged") |
| 75 | 75 | .description("Remove worktrees with no commits beyond the default branch.") |
| 76 | - .reset(reset(&view, |s| s.cleanup_unchanged_worktrees, |s, on| s.cleanup_unchanged_worktrees = on)) | |
| 76 | + .reset(reset(&view, cx, |s| s.cleanup_unchanged_worktrees, |s, on| s.cleanup_unchanged_worktrees = on)) | |
| 77 | 77 | .control(switch("cleanup-unchanged", saved.cleanup_unchanged_worktrees, &view, p, |settings, on| { |
| 78 | 78 | settings.cleanup_unchanged_worktrees = on |
| 79 | 79 | })), |
| @@ -21,7 +21,7 @@ use super::SettingsView; | ||
| 21 | 21 | use crate::claude::Effort; |
| 22 | 22 | use crate::models::{Catalog, ProbeStatus, Provider, split_key}; |
| 23 | 23 | use crate::session::Reasoning; |
| 24 | - use crate::settings::{self, Settings}; | |
| 24 | + use crate::settings::Settings; | |
| 25 | 25 | use crate::ui::icons::Lucide; |
| 26 | 26 | use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette}; |
| 27 | 27 | use crate::ui::pickers; |
| @@ -196,10 +196,38 @@ impl SettingRow { | ||
| 196 | 196 | } |
| 197 | 197 | |
| 198 | 198 | impl RenderOnce for SettingRow { |
| 199 | - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { | |
| 199 | + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { | |
| 200 | 200 | let p = metal::palette(cx); |
| 201 | 201 | let stacked = stacked(window, self.max_width); |
| 202 | 202 | let title = self.title.clone(); |
| 203 | + // The settings scope: whether the row can change, and where its value comes from. | |
| 204 | + let scope = self.id.and_then(|id| cx.try_global::<super::scope::Shown>()?.row(id)); | |
| 205 | + let mut scope_tags: Vec<AnyElement> = Vec::new(); | |
| 206 | + let mut lock_note = None; | |
| 207 | + if let Some(scope) = &scope { | |
| 208 | + if scope.project { | |
| 209 | + // Reset means following the environments again. | |
| 210 | + self.reset = scope.overridden.then(|| -> Reset { | |
| 211 | + let (view, keys) = (scope.view.clone(), scope.keys); | |
| 212 | + Rc::new(move |window, cx| { | |
| 213 | + let _ = view.update(cx, |view, cx| view.inherit(keys, window, cx)); | |
| 214 | + }) | |
| 215 | + }); | |
| 216 | + if scope.overridden { | |
| 217 | + scope_tags.push(scope_tag("Override", "This project overrides the environment's value.".into(), p.blue_ink, p)); | |
| 218 | + } | |
| 219 | + } | |
| 220 | + if let Some(values) = &scope.mixed { | |
| 221 | + let list = values.iter().map(|(env, value)| format!("{env}: {value}")).collect::<Vec<_>>().join("\n"); | |
| 222 | + scope_tags.push(scope_tag("Mixed", format!("The selected environments differ. Picking a value applies it to all of them.\n{list}").into(), p.amber_ink, p)); | |
| 223 | + } | |
| 224 | + if let Some(reason) = &scope.locked { | |
| 225 | + self.reset = None; | |
| 226 | + self.dimmed = true; | |
| 227 | + lock_note = Some(reason.clone()); | |
| 228 | + } | |
| 229 | + } | |
| 230 | + let reset_tooltip = if scope.as_ref().is_some_and(|scope| scope.project) { "Use the environment's value" } else { "Reset to default" }; | |
| 203 | 231 | let text = v_flex() |
| 204 | 232 | .flex_1() |
| 205 | 233 | .min_w_0() |
| @@ -214,7 +242,8 @@ impl RenderOnce for SettingRow { | ||
| 214 | 242 | this.child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(self.title)) |
| 215 | 243 | }) |
| 216 | 244 | .children(self.title_suffix) |
| 217 | - .when_some(self.reset, |this, reset| this.child(reset_button(&title, reset, p))), | |
| 245 | + .children(scope_tags) | |
| 246 | + .when_some(self.reset, |this, reset| this.child(reset_button(&title, reset_tooltip, reset, p))), | |
| 218 | 247 | ) |
| 219 | 248 | .when_some(self.description, |this, description| { |
| 220 | 249 | this.child( |
| @@ -228,12 +257,19 @@ impl RenderOnce for SettingRow { | ||
| 228 | 257 | }) |
| 229 | 258 | .when_some(self.status, |this, status| { |
| 230 | 259 | this.child(div().pt(px(2.)).text_size(px(12.)).line_height(px(17.)).text_color(p.ink3).child(status)) |
| 260 | + }) | |
| 261 | + .when_some(lock_note.clone(), |this, note| { | |
| 262 | + this.child(div().pt(px(2.)).text_size(px(12.)).line_height(px(17.)).text_color(p.ink2).child(note)) | |
| 231 | 263 | }); |
| 264 | + let locked = lock_note.is_some(); | |
| 232 | 265 | let control = self.control.map(|control| { |
| 233 | 266 | h_flex() |
| 234 | 267 | .gap(px(8.)) |
| 235 | 268 | .map(|this| if stacked { this.w_full() } else { this.flex_none().justify_end() }) |
| 269 | + .when(locked, |this| this.relative().opacity(0.5)) | |
| 236 | 270 | .child(control) |
| 271 | + // A locked row's control can't be used. | |
| 272 | + .when(locked, |this| this.child(div().id("locked").absolute().inset_0().occlude().cursor_not_allowed())) | |
| 237 | 273 | }); |
| 238 | 274 | let row = div() |
| 239 | 275 | .px(px(16.)) |
| @@ -257,8 +293,29 @@ impl RenderOnce for SettingRow { | ||
| 257 | 293 | } |
| 258 | 294 | } |
| 259 | 295 | |
| 296 | + /// A small tag after a row's title about where its value comes from. | |
| 297 | + fn scope_tag(label: &'static str, tooltip: SharedString, color: Hsla, p: &Palette) -> AnyElement { | |
| 298 | + div() | |
| 299 | + .id(SharedString::from(format!("scope-tag-{label}"))) | |
| 300 | + .flex() | |
| 301 | + .flex_none() | |
| 302 | + .items_center() | |
| 303 | + .gap(px(4.)) | |
| 304 | + .h(px(18.)) | |
| 305 | + .px(px(6.)) | |
| 306 | + .rounded(px(6.)) | |
| 307 | + .bg(color.opacity(if p.dark { 0.16 } else { 0.1 })) | |
| 308 | + .text_size(px(11.)) | |
| 309 | + .font_weight(FontWeight::MEDIUM) | |
| 310 | + .text_color(color) | |
| 311 | + .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) | |
| 312 | + .child(Icon::new(Lucide::Layers).size(px(11.))) | |
| 313 | + .child(label) | |
| 314 | + .into_any_element() | |
| 315 | + } | |
| 316 | + | |
| 260 | 317 | /// T3 Code's per-row reset: a small ghost button with an undo arrow. |
| 261 | - fn reset_button(title: &SharedString, reset: Reset, p: &Palette) -> impl IntoElement { | |
| 318 | + fn reset_button(title: &SharedString, tooltip: &'static str, reset: Reset, p: &Palette) -> impl IntoElement { | |
| 262 | 319 | div() |
| 263 | 320 | .id(SharedString::from(format!("reset-{title}"))) |
| 264 | 321 | .flex() |
| @@ -270,7 +327,7 @@ fn reset_button(title: &SharedString, reset: Reset, p: &Palette) -> impl IntoEle | ||
| 270 | 327 | .cursor_pointer() |
| 271 | 328 | .text_color(p.ink3) |
| 272 | 329 | .hover(|style| style.bg(p.row_hover).text_color(p.ink)) |
| 273 | - .tooltip(|window, cx| Tooltip::new("Reset to default").build(window, cx)) | |
| 330 | + .tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx)) | |
| 274 | 331 | .on_click(move |_, window, cx| reset(window, cx)) |
| 275 | 332 | .child(Icon::new(Lucide::Undo2).size(px(12.))) |
| 276 | 333 | } |
| @@ -279,10 +336,11 @@ fn reset_button(title: &SharedString, reset: Reset, p: &Palette) -> impl IntoEle | ||
| 279 | 336 | /// default, and putting the default back with `set`. |
| 280 | 337 | pub(crate) fn reset<T: PartialEq + 'static>( |
| 281 | 338 | view: &WeakEntity<SettingsView>, |
| 339 | + cx: &App, | |
| 282 | 340 | get: fn(&Settings) -> T, |
| 283 | 341 | set: fn(&mut Settings, T), |
| 284 | 342 | ) -> Option<Reset> { |
| 285 | - let differs = settings::get(|settings| get(settings) != get(&Settings::default())); | |
| 343 | + let differs = get(&super::scope::shown_settings(cx)) != get(&Settings::default()); | |
| 286 | 344 | let view = view.clone(); |
| 287 | 345 | differs.then(|| -> Reset { |
| 288 | 346 | Rc::new(move |window, cx| edit(&view, window, cx, |settings| set(settings, get(&Settings::default())))) |
| @@ -495,10 +553,10 @@ pub(crate) fn note(text: impl Into<SharedString>, p: &Palette) -> Div { | ||
| 495 | 553 | |
| 496 | 554 | // ---- controls ------------------------------------------------------------------ |
| 497 | 555 | |
| 498 | - /// Saves an edit, then repaints the view and tells the conversations. | |
| 499 | - pub(crate) fn edit(view: &WeakEntity<SettingsView>, window: &mut Window, cx: &mut App, change: impl FnOnce(&mut Settings)) { | |
| 500 | - settings::update(change); | |
| 501 | - let _ = view.update(cx, |view, cx| view.changed(window, cx)); | |
| 556 | + /// Saves an edit (to the selected environments, when it changes their | |
| 557 | + /// settings), then repaints the view and tells the conversations. | |
| 558 | + pub(crate) fn edit(view: &WeakEntity<SettingsView>, window: &mut Window, cx: &mut App, change: impl Fn(&mut Settings)) { | |
| 559 | + let _ = view.update(cx, |view, cx| view.apply(change, window, cx)); | |
| 502 | 560 | } |
| 503 | 561 | |
| 504 | 562 | pub(crate) fn switch( |
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | use std::{ |
| 8 | 8 | collections::{HashMap, HashSet}, |
| 9 | 9 | hash::{Hash as _, Hasher as _}, |
| 10 | - path::PathBuf, | |
| 10 | + path::{Path, PathBuf}, | |
| 11 | 11 | process::{Command, Stdio}, |
| 12 | 12 | rc::Rc, |
| 13 | 13 | time::{Duration, Instant, SystemTime}, |
| @@ -56,6 +56,7 @@ mod terminal_drawer; | ||
| 56 | 56 | use actions::UndoEntry; |
| 57 | 57 | use palette::CommandPalette; |
| 58 | 58 | use remote::RemoteState; |
| 59 | + pub use remote::SettingsEnvironment; | |
| 59 | 60 | use terminal_drawer::TerminalDrawer; |
| 60 | 61 | |
| 61 | 62 | pub(crate) const SIDEBAR_WIDTH: f32 = 280.; |
| @@ -434,7 +435,7 @@ impl Workspace { | ||
| 434 | 435 | workspace.ensure_project(cwd.canonicalize().unwrap_or(cwd), None) |
| 435 | 436 | } |
| 436 | 437 | }; |
| 437 | - let settings = workspace.new_thread_settings(cx); | |
| 438 | + let settings = workspace.new_thread_settings(ix, cx); | |
| 438 | 439 | workspace.replace_thread(ix, settings, window, cx); |
| 439 | 440 | } |
| 440 | 441 | } |
| @@ -666,9 +667,6 @@ impl Workspace { | ||
| 666 | 667 | /// idle ones, and checks worktree threads' pull requests. |
| 667 | 668 | fn housekeeping(&mut self, allowed: bool, cx: &mut Context<Self>) { |
| 668 | 669 | let now = SystemTime::now(); |
| 669 | - let (settle_inactive, inactive_days) = | |
| 670 | - settings::get(|settings| (settings.auto_settle_inactive, settings.inactive_days.max(1))); | |
| 671 | - let inactive = Duration::from_secs(u64::from(inactive_days) * 86_400); | |
| 672 | 670 | // Other devices look after their own threads. |
| 673 | 671 | let sessions: Vec<Entity<Session>> = self |
| 674 | 672 | .projects |
| @@ -680,6 +678,9 @@ impl Workspace { | ||
| 680 | 678 | let (wake, resume, settle) = { |
| 681 | 679 | let state = session.read(cx); |
| 682 | 680 | let idle = !state.is_working() && !state.needs_attention() && !state.items.is_empty(); |
| 681 | + let (settle_inactive, inactive_days) = | |
| 682 | + settings::get_for(&state.project, |settings| (settings.auto_settle_inactive, settings.inactive_days.max(1))); | |
| 683 | + let inactive = Duration::from_secs(u64::from(inactive_days) * 86_400); | |
| 683 | 684 | ( |
| 684 | 685 | state.snoozed_until.is_some_and(|until| until <= now), |
| 685 | 686 | state.resume_at.is_some_and(|at| at <= now) && !state.is_working(), |
| @@ -723,10 +724,14 @@ impl Workspace { | ||
| 723 | 724 | /// branch, and with no agent at work there; the thread and its branch |
| 724 | 725 | /// stay, and the worktree comes back with the thread's next message. |
| 725 | 726 | fn clean_up_worktrees(&mut self, cx: &mut Context<Self>) { |
| 726 | - let (inactive_days, merged, unchanged) = settings::get(|settings| { | |
| 727 | - (settings.cleanup_inactive_worktrees, settings.cleanup_merged_worktrees, settings.cleanup_unchanged_worktrees) | |
| 728 | - }); | |
| 729 | - if inactive_days.is_none() && !merged && !unchanged { | |
| 727 | + let rules = |project: &Path| { | |
| 728 | + settings::get_for(project, |settings| { | |
| 729 | + (settings.cleanup_inactive_worktrees, settings.cleanup_merged_worktrees, settings.cleanup_unchanged_worktrees) | |
| 730 | + }) | |
| 731 | + }; | |
| 732 | + let projects: Vec<&Path> = | |
| 733 | + self.projects.iter().filter(|project| project.environment.is_none()).map(|project| project.path.as_path()).collect(); | |
| 734 | + if projects.iter().all(|project| rules(project) == (None, false, false)) { | |
| 730 | 735 | return; |
| 731 | 736 | } |
| 732 | 737 | let Some(managed) = worktree::worktrees_dir() else { return }; |
| @@ -739,10 +744,12 @@ impl Workspace { | ||
| 739 | 744 | .filter(|state| state.is_working() || state.preparing) |
| 740 | 745 | .filter_map(|state| state.worktree.as_ref().map(|worktree| worktree.root.clone())) |
| 741 | 746 | .collect(); |
| 742 | - let mut candidates: Vec<(Worktree, bool, bool)> = Vec::new(); | |
| 747 | + // Each worktree, whether it's due, merged, and removable unchanged. | |
| 748 | + let mut candidates: Vec<(Worktree, bool, bool, bool)> = Vec::new(); | |
| 743 | 749 | for session in sessions { |
| 744 | 750 | let state = session.read(cx); |
| 745 | 751 | let Some(worktree) = state.worktree.clone() else { continue }; |
| 752 | + let (inactive_days, merged, unchanged) = rules(&state.project); | |
| 746 | 753 | if !worktree.root.starts_with(&managed) || !worktree.root.exists() || busy.contains(&worktree.root) { |
| 747 | 754 | continue; |
| 748 | 755 | } |
| @@ -751,7 +758,7 @@ impl Workspace { | ||
| 751 | 758 | }); |
| 752 | 759 | let pr_merged = merged && state.pull_request.as_ref().is_some_and(|pr| pr.state == "MERGED"); |
| 753 | 760 | if inactive || pr_merged || unchanged { |
| 754 | - candidates.push((worktree, inactive || pr_merged, pr_merged)); | |
| 761 | + candidates.push((worktree, inactive || pr_merged, pr_merged, unchanged)); | |
| 755 | 762 | } |
| 756 | 763 | } |
| 757 | 764 | if candidates.is_empty() { |
| @@ -759,7 +766,7 @@ impl Workspace { | ||
| 759 | 766 | } |
| 760 | 767 | cx.background_executor() |
| 761 | 768 | .spawn(async move { |
| 762 | - for (worktree, due, merged) in candidates { | |
| 769 | + for (worktree, due, merged, unchanged) in candidates { | |
| 763 | 770 | let status = git::status(&worktree.root); |
| 764 | 771 | let safe = status |
| 765 | 772 | .as_ref() |
| @@ -801,10 +808,10 @@ impl Workspace { | ||
| 801 | 808 | }); |
| 802 | 809 | cx.spawn(async move |_, cx| { |
| 803 | 810 | let results = task.await; |
| 804 | - let merged_settles = settings::get(|settings| settings.auto_settle_merged); | |
| 805 | 811 | for ((session, _, _), pull_request) in threads.into_iter().zip(results) { |
| 806 | 812 | let Some(pull_request) = pull_request else { continue }; |
| 807 | 813 | let _ = session.update(cx, |session, cx| { |
| 814 | + let merged_settles = settings::get_for(&session.project, |settings| settings.auto_settle_merged); | |
| 808 | 815 | let was_open = session.pull_request.as_ref().is_none_or(|before| before.is_open()); |
| 809 | 816 | let settle = was_open |
| 810 | 817 | && session.auto_settle |
| @@ -873,12 +880,11 @@ impl Workspace { | ||
| 873 | 880 | settings |
| 874 | 881 | } |
| 875 | 882 | |
| 876 | - /// What a fresh thread starts with: the open thread's picks, overridden | |
| 877 | - /// by the defaults chosen in the settings. | |
| 878 | - fn new_thread_settings(&self, cx: &App) -> ThreadSettings { | |
| 879 | - let (model, reasoning, access) = settings::get(|settings| { | |
| 880 | - (settings.default_model.clone(), settings.default_reasoning.clone(), settings.default_access.clone()) | |
| 881 | - }); | |
| 883 | + /// What a fresh thread in this project starts with: the open thread's | |
| 884 | + /// picks, overridden by the defaults its environment and project chose. | |
| 885 | + fn new_thread_settings(&self, project_ix: usize, cx: &App) -> ThreadSettings { | |
| 886 | + let defaults = self.project_settings(project_ix); | |
| 887 | + let (model, reasoning, access) = (defaults.default_model, defaults.default_reasoning, defaults.default_access); | |
| 882 | 888 | let mut settings = self.current_settings(cx); |
| 883 | 889 | if let Some(key) = model { |
| 884 | 890 | match Catalog::global(cx).find_key(&key) { |
| @@ -918,12 +924,13 @@ impl Workspace { | ||
| 918 | 924 | if mode != WorkspaceMode::Worktree || project.environment.is_some() || Some(&project.path) == scratch_dir().as_ref() { |
| 919 | 925 | return None; |
| 920 | 926 | } |
| 921 | - let (from_origin, submodules) = settings::get(|settings| (settings.start_from_origin, settings.submodules)); | |
| 927 | + let (from_origin, submodules) = | |
| 928 | + settings::get_for(&project.path, |settings| (settings.start_from_origin, settings.submodules)); | |
| 922 | 929 | Some(WorktreePlan { project: project.path.clone(), base: git.branch.clone(), from_origin, submodules }) |
| 923 | 930 | } |
| 924 | 931 | |
| 925 | 932 | fn new_thread(&mut self, project_ix: usize, window: &mut Window, cx: &mut Context<Self>) { |
| 926 | - let settings = self.new_thread_settings(cx); | |
| 933 | + let settings = self.new_thread_settings(project_ix, cx); | |
| 927 | 934 | self.new_thread_with(project_ix, settings, window, cx); |
| 928 | 935 | } |
| 929 | 936 | |
| @@ -934,8 +941,8 @@ impl Workspace { | ||
| 934 | 941 | window: &mut Window, |
| 935 | 942 | cx: &mut Context<Self>, |
| 936 | 943 | ) -> Option<Entity<ChatView>> { |
| 944 | + let mode = self.project_settings(project_ix).workspace; | |
| 937 | 945 | let project = self.projects.get(project_ix)?; |
| 938 | - let mode = settings::get(|settings| settings.workspace); | |
| 939 | 946 | let session = match project.environment.clone() { |
| 940 | 947 | Some(env) => { |
| 941 | 948 | let worktree = mode == WorkspaceMode::Worktree && project.git.is_some(); |
| @@ -1108,7 +1115,7 @@ impl Workspace { | ||
| 1108 | 1115 | } |
| 1109 | 1116 | ChatEvent::OpenProviderSettings => self.open_settings(window, cx), |
| 1110 | 1117 | ChatEvent::ImplementInNewThread(plan) => { |
| 1111 | - let settings = self.new_thread_settings(cx); | |
| 1118 | + let settings = self.new_thread_settings(project_ix, cx); | |
| 1112 | 1119 | if let Some(chat) = self.new_thread_with(project_ix, settings, window, cx) { |
| 1113 | 1120 | let prompt = format!("Implement this plan:\n\n{plan}"); |
| 1114 | 1121 | chat.read(cx).session().clone().update(cx, |session, cx| session.send_message(prompt, Vec::new(), cx)); |
| @@ -1674,6 +1681,7 @@ impl Workspace { | ||
| 1674 | 1681 | /// Lets every open conversation pick up changed settings. |
| 1675 | 1682 | pub fn settings_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) { |
| 1676 | 1683 | self.sync_remote(window, cx); |
| 1684 | + self.broadcast_settings(); | |
| 1677 | 1685 | for chat in self.chats.values() { |
| 1678 | 1686 | chat.update(cx, |chat, cx| chat.settings_changed(window, cx)); |
| 1679 | 1687 | } |
| @@ -2267,7 +2275,7 @@ impl Render for Workspace { | ||
| 2267 | 2275 | |
| 2268 | 2276 | /// Two letters for a project's badge: the first letters of its first two |
| 2269 | 2277 | /// words (`vectrum.git` → `VG`), or of its name (`T3-2` → `T3`). |
| 2270 | - fn initials(name: &str) -> String { | |
| 2278 | + pub(crate) fn initials(name: &str) -> String { | |
| 2271 | 2279 | let words: Vec<&str> = name.split(|c: char| !c.is_alphanumeric()).filter(|word| !word.is_empty()).collect(); |
| 2272 | 2280 | let letters: String = match words.as_slice() { |
| 2273 | 2281 | [first, second, ..] if second.starts_with(|c: char| c.is_alphabetic()) => { |
| @@ -2279,7 +2287,7 @@ fn initials(name: &str) -> String { | ||
| 2279 | 2287 | letters.to_uppercase() |
| 2280 | 2288 | } |
| 2281 | 2289 | |
| 2282 | - fn tint_for(name: &str) -> usize { | |
| 2290 | + pub(crate) fn tint_for(name: &str) -> usize { | |
| 2283 | 2291 | let mut hasher = std::collections::hash_map::DefaultHasher::new(); |
| 2284 | 2292 | name.hash(&mut hasher); |
| 2285 | 2293 | hasher.finish() as usize |
| @@ -475,7 +475,8 @@ impl Workspace { | ||
| 475 | 475 | } |
| 476 | 476 | |
| 477 | 477 | pub(super) fn regenerate_titles(&mut self, sessions: Vec<Entity<Session>>, window: &mut Window, cx: &mut Context<Self>) { |
| 478 | - if settings::get(|settings| settings.text_model.is_empty()) { | |
| 478 | + let off = |session: &Entity<Session>| settings::get_for(&session.read(cx).project, |settings| settings.text_model.is_empty()); | |
| 479 | + if sessions.iter().all(off) { | |
| 479 | 480 | error_toast( |
| 480 | 481 | if sessions.len() == 1 { "Failed to regenerate thread title" } else { "Failed to regenerate thread titles" }, |
| 481 | 482 | "Pick a text generation model under Settings → General first.", |
| @@ -498,7 +499,7 @@ impl Workspace { | ||
| 498 | 499 | let state = session.read(cx); |
| 499 | 500 | (state.worktree.clone(), state.cwd.clone()) |
| 500 | 501 | }; |
| 501 | - let settings = self.new_thread_settings(cx); | |
| 502 | + let settings = self.new_thread_settings(project_ix, cx); | |
| 502 | 503 | if self.projects[project_ix].environment.is_some() { |
| 503 | 504 | self.new_thread_with(project_ix, settings, window, cx); |
| 504 | 505 | return; |
| @@ -155,6 +155,7 @@ struct Outcome { | ||
| 155 | 155 | /// Blocks; run it in the background. |
| 156 | 156 | fn run( |
| 157 | 157 | path: &Path, |
| 158 | + project: &Path, | |
| 158 | 159 | action: GitAction, |
| 159 | 160 | files: Option<Vec<String>>, |
| 160 | 161 | message: String, |
| @@ -201,7 +202,7 @@ fn run( | ||
| 201 | 202 | let message = if message.trim().is_empty() { |
| 202 | 203 | let (summary, patch) = git::staged(path); |
| 203 | 204 | let branch = git::status(path).and_then(|status| status.branch); |
| 204 | - crate::textgen::commit_message(branch.as_deref(), &summary, &patch, &writing_instructions(path)) | |
| 205 | + crate::textgen::commit_message(project, branch.as_deref(), &summary, &patch, &writing_instructions(path, project)) | |
| 205 | 206 | .unwrap_or_else(|| "Update files".into()) |
| 206 | 207 | } else { |
| 207 | 208 | message |
| @@ -231,9 +232,9 @@ fn run( | ||
| 231 | 232 | let patch = git::git(path, &["diff", &format!("{base_ref}...HEAD")]).unwrap_or_default(); |
| 232 | 233 | let mut command = std::process::Command::new(gh); |
| 233 | 234 | command.current_dir(path).args(["pr", "create", "--base", &base, "--head", &branch]); |
| 234 | - let template = pr_template(path); | |
| 235 | - let instructions = writing_instructions(path); | |
| 236 | - match crate::textgen::pull_request_content(&base, &branch, &commits, &stat, &patch, template.as_deref(), &instructions) { | |
| 235 | + let template = pr_template(path, project); | |
| 236 | + let instructions = writing_instructions(path, project); | |
| 237 | + match crate::textgen::pull_request_content(project, &base, &branch, &commits, &stat, &patch, template.as_deref(), &instructions) { | |
| 237 | 238 | Some((pr_title, body)) => command.args(["--title", &pr_title, "--body", &body]), |
| 238 | 239 | None => command.arg("--fill"), |
| 239 | 240 | }; |
| @@ -250,9 +251,9 @@ fn run( | ||
| 250 | 251 | } |
| 251 | 252 | |
| 252 | 253 | /// The instructions Settings → Source Control → writing style adds to |
| 253 | - /// commit message and pull request prompts. | |
| 254 | - fn writing_instructions(path: &Path) -> String { | |
| 255 | - let (style, custom) = crate::settings::get(|settings| (settings.writing_style, settings.writing_instructions.clone())); | |
| 254 | + /// commit message and pull request prompts, as `project` has it. | |
| 255 | + fn writing_instructions(path: &Path, project: &Path) -> String { | |
| 256 | + let (style, custom) = crate::settings::get_for(project, |settings| (settings.writing_style, settings.writing_instructions.clone())); | |
| 256 | 257 | match style { |
| 257 | 258 | crate::settings::WritingStyle::RepoConventions => { |
| 258 | 259 | let recent = git::git(path, &["log", "-15", "--format=%s"]).unwrap_or_default(); |
| @@ -271,8 +272,8 @@ fn writing_instructions(path: &Path) -> String { | ||
| 271 | 272 | |
| 272 | 273 | /// The repository's pull request template, when it has one and the |
| 273 | 274 | /// settings say to follow it. |
| 274 | - fn pr_template(path: &Path) -> Option<String> { | |
| 275 | - if !crate::settings::get(|settings| settings.follow_pr_templates) { | |
| 275 | + fn pr_template(path: &Path, project: &Path) -> Option<String> { | |
| 276 | + if !crate::settings::get_for(project, |settings| settings.follow_pr_templates) { | |
| 276 | 277 | return None; |
| 277 | 278 | } |
| 278 | 279 | let root = PathBuf::from(git::git(path, &["rev-parse", "--show-toplevel"]).ok()?.trim()); |
| @@ -324,6 +325,7 @@ impl Workspace { | ||
| 324 | 325 | cx: &mut Context<Self>, |
| 325 | 326 | ) { |
| 326 | 327 | let Some(path) = self.git_path(cx) else { return }; |
| 328 | + let Some(project) = self.active_project().map(|project| project.path.clone()) else { return }; | |
| 327 | 329 | let status = match action { |
| 328 | 330 | GitAction::Pull => "Pulling latest changes...", |
| 329 | 331 | _ if action.commits() && message.trim().is_empty() => "Generating commit message...", |
| @@ -335,7 +337,7 @@ impl Workspace { | ||
| 335 | 337 | cx.notify(); |
| 336 | 338 | let task = cx.background_executor().spawn({ |
| 337 | 339 | let path = path.clone(); |
| 338 | - async move { run(&path, action, files, message, feature_branch) } | |
| 340 | + async move { run(&path, &project, action, files, message, feature_branch) } | |
| 339 | 341 | }); |
| 340 | 342 | cx.spawn_in(window, async move |this, cx| { |
| 341 | 343 | let result = task.await; |
| @@ -25,7 +25,8 @@ use crate::remote::{ | ||
| 25 | 25 | use crate::session::{ |
| 26 | 26 | NewSession, PermissionDecision, Reasoning, RemoteLink, Session, from_millis, store_uploads, |
| 27 | 27 | }; |
| 28 | - use crate::settings::{self, RemoteHost, WorkspaceMode}; | |
| 28 | + use crate::settings::{self, RemoteHost, Settings, SettingsPatch, WorkspaceMode}; | |
| 29 | + use crate::ui::metal::MetalButton; | |
| 29 | 30 | |
| 30 | 31 | /// How often changed threads are sent to subscribed clients. |
| 31 | 32 | pub(super) const FLUSH: Duration = Duration::from_millis(150); |
| @@ -35,6 +36,8 @@ pub(super) struct Environment { | ||
| 35 | 36 | pub(super) host: RemoteHost, |
| 36 | 37 | pub(super) status: ConnectionStatus, |
| 37 | 38 | pub(super) client: Rc<RemoteClient>, |
| 39 | + /// Its environment settings, once it shared them. | |
| 40 | + pub(super) settings: Option<Settings>, | |
| 38 | 41 | _events: Task<()>, |
| 39 | 42 | } |
| 40 | 43 | |
| @@ -53,6 +56,8 @@ pub(super) struct RemoteState { | ||
| 53 | 56 | dirty_threads: HashSet<String>, |
| 54 | 57 | state_dirty: bool, |
| 55 | 58 | last_state: String, |
| 59 | + /// The environment settings clients were last sent. | |
| 60 | + last_settings: String, | |
| 56 | 61 | pub(super) environments: Vec<Environment>, |
| 57 | 62 | _server_events: Option<Task<()>>, |
| 58 | 63 | } |
| @@ -67,6 +72,18 @@ pub struct RemoteOverview { | ||
| 67 | 72 | pub environments: Vec<(String, ConnectionStatus)>, |
| 68 | 73 | } |
| 69 | 74 | |
| 75 | + /// An environment the settings view edits: this device or a connected one. | |
| 76 | + pub struct SettingsEnvironment { | |
| 77 | + /// The connection's id in the settings; `None` for this device. | |
| 78 | + pub id: Option<String>, | |
| 79 | + pub name: String, | |
| 80 | + pub connected: bool, | |
| 81 | + /// `None` until the device shared them; older versions never do. | |
| 82 | + pub settings: Option<Settings>, | |
| 83 | + /// Its projects, by name and path there. | |
| 84 | + pub projects: Vec<(String, PathBuf)>, | |
| 85 | + } | |
| 86 | + | |
| 70 | 87 | impl Workspace { |
| 71 | 88 | /// Starts, stops or restarts the server and the connections to match the |
| 72 | 89 | /// settings. |
| @@ -137,7 +154,13 @@ impl Workspace { | ||
| 137 | 154 | } |
| 138 | 155 | } |
| 139 | 156 | }); |
| 140 | - self.remote.environments.push(Environment { host, status: ConnectionStatus::Connecting, client, _events: task }); | |
| 157 | + self.remote.environments.push(Environment { | |
| 158 | + host, | |
| 159 | + status: ConnectionStatus::Connecting, | |
| 160 | + client, | |
| 161 | + settings: None, | |
| 162 | + _events: task, | |
| 163 | + }); | |
| 141 | 164 | } |
| 142 | 165 | |
| 143 | 166 | /// Disconnects from a device and takes its projects and threads away. |
| @@ -177,6 +200,150 @@ impl Workspace { | ||
| 177 | 200 | } |
| 178 | 201 | } |
| 179 | 202 | |
| 203 | + /// This device, then every connection, with the settings each one keeps. | |
| 204 | + pub fn settings_environments(&self) -> Vec<SettingsEnvironment> { | |
| 205 | + let projects = |env: Option<&str>| -> Vec<(String, PathBuf)> { | |
| 206 | + let scratch = super::scratch_dir(); | |
| 207 | + self.projects | |
| 208 | + .iter() | |
| 209 | + .filter(|project| project.environment.as_deref() == env && Some(&project.path) != scratch.as_ref()) | |
| 210 | + .map(|project| (project.name.to_string(), project.path.clone())) | |
| 211 | + .collect() | |
| 212 | + }; | |
| 213 | + let this_device = SettingsEnvironment { | |
| 214 | + id: None, | |
| 215 | + name: hostname(), | |
| 216 | + connected: true, | |
| 217 | + settings: Some(settings::load()), | |
| 218 | + projects: projects(None), | |
| 219 | + }; | |
| 220 | + std::iter::once(this_device) | |
| 221 | + .chain(self.remote.environments.iter().map(|env| SettingsEnvironment { | |
| 222 | + id: Some(env.host.id.clone()), | |
| 223 | + name: env.host.label(), | |
| 224 | + connected: matches!(env.status, ConnectionStatus::Connected(_)), | |
| 225 | + settings: env.settings.clone(), | |
| 226 | + projects: projects(Some(&env.host.id)), | |
| 227 | + })) | |
| 228 | + .collect() | |
| 229 | + } | |
| 230 | + | |
| 231 | + /// Changes an environment's settings: this device's (`None`) at once, | |
| 232 | + /// another's by asking it. The caller tells the open conversations. | |
| 233 | + pub fn write_settings(&mut self, env: Option<&str>, patch: SettingsPatch, cx: &mut Context<Self>) { | |
| 234 | + if patch.is_empty() { | |
| 235 | + return; | |
| 236 | + } | |
| 237 | + match env { | |
| 238 | + None => settings::apply(&patch), | |
| 239 | + Some(env) => { | |
| 240 | + let Some(environment) = self.remote.environments.iter_mut().find(|candidate| candidate.host.id == env) else { | |
| 241 | + return; | |
| 242 | + }; | |
| 243 | + environment.client.send(&ClientMessage::UpdateSettings { patch: patch.clone() }); | |
| 244 | + // Show the change now; the device confirms it with its settings. | |
| 245 | + if let Some(settings) = environment.settings.as_mut() { | |
| 246 | + settings.apply_patch(&patch); | |
| 247 | + } | |
| 248 | + } | |
| 249 | + } | |
| 250 | + cx.notify(); | |
| 251 | + } | |
| 252 | + | |
| 253 | + /// The first time a device shares its settings and they differ from | |
| 254 | + /// this device's, asks whether to give it this device's settings or | |
| 255 | + /// leave its own as they are. | |
| 256 | + fn offer_settings(&mut self, env: &str, window: &mut Window, cx: &mut Context<Self>) { | |
| 257 | + let Some(environment) = self.remote.environments.iter_mut().find(|candidate| candidate.host.id == env) else { return }; | |
| 258 | + let Some(theirs) = environment.settings.as_ref().map(Settings::environment_values) else { return }; | |
| 259 | + // Asked once, whatever the answer. | |
| 260 | + environment.host.settings_offered = true; | |
| 261 | + let name = environment.host.label(); | |
| 262 | + settings::update(|settings| { | |
| 263 | + if let Some(host) = settings.remotes.iter_mut().find(|host| host.id == env) { | |
| 264 | + host.settings_offered = true; | |
| 265 | + } | |
| 266 | + }); | |
| 267 | + let mut ours = settings::get(Settings::environment_values); | |
| 268 | + ours.remove("project_overrides"); | |
| 269 | + let differing: Vec<String> = | |
| 270 | + ours.iter().filter(|(key, value)| theirs.get(*key) != Some(value)).map(|(key, _)| key.clone()).collect(); | |
| 271 | + if differing.is_empty() { | |
| 272 | + return; | |
| 273 | + } | |
| 274 | + let list = crate::ui::settings::row_titles(&differing).join(", "); | |
| 275 | + let workspace = cx.weak_entity(); | |
| 276 | + let env = env.to_string(); | |
| 277 | + window.open_dialog(cx, move |dialog, _, cx| { | |
| 278 | + let p = crate::ui::metal::palette(cx); | |
| 279 | + let (workspace, env, ours) = (workspace.clone(), env.clone(), ours.clone()); | |
| 280 | + let text = |text: String| div().text_size(px(13.)).line_height(px(19.)).text_color(p.ink2).child(text); | |
| 281 | + dialog | |
| 282 | + .title(format!("Use your settings on {name}?")) | |
| 283 | + .w(px(480.)) | |
| 284 | + .child( | |
| 285 | + gpui_kit::component::v_flex() | |
| 286 | + .gap(px(10.)) | |
| 287 | + .child(text(format!( | |
| 288 | + "{name} is connected. Its settings for new threads, worktrees and source control differ from this device's: {list}." | |
| 289 | + ))) | |
| 290 | + .child(text(format!( | |
| 291 | + "Use my settings gives {name} this device's. Leave as is keeps {name}'s own. Either way, you can change them later in Settings with {name} picked." | |
| 292 | + ))), | |
| 293 | + ) | |
| 294 | + .footer( | |
| 295 | + gpui_kit::component::h_flex() | |
| 296 | + .w_full() | |
| 297 | + .justify_end() | |
| 298 | + .gap(px(8.)) | |
| 299 | + .child( | |
| 300 | + MetalButton::new("offer-settings-leave") | |
| 301 | + .ghost() | |
| 302 | + .compact() | |
| 303 | + .label("Leave as is") | |
| 304 | + .on_click(|_, window, cx| window.close_dialog(cx)), | |
| 305 | + ) | |
| 306 | + .child(MetalButton::new("offer-settings-use").primary().compact().label("Use my settings").on_click( | |
| 307 | + move |_, window, cx| { | |
| 308 | + window.close_dialog(cx); | |
| 309 | + let patch = SettingsPatch { values: ours.clone(), ..Default::default() }; | |
| 310 | + let _ = workspace.update(cx, |workspace, cx| workspace.write_settings(Some(&env), patch, cx)); | |
| 311 | + }, | |
| 312 | + )), | |
| 313 | + ) | |
| 314 | + }); | |
| 315 | + } | |
| 316 | + | |
| 317 | + /// Sends connected devices this device's environment settings, if they | |
| 318 | + /// changed since they were last sent. | |
| 319 | + pub(super) fn broadcast_settings(&mut self) { | |
| 320 | + let Some(server) = &self.remote.server else { return }; | |
| 321 | + let values = settings::get(Settings::environment_values); | |
| 322 | + let text = serde_json::to_string(&values).unwrap_or_default(); | |
| 323 | + if text != self.remote.last_settings { | |
| 324 | + self.remote.last_settings = text; | |
| 325 | + server.broadcast(&ServerMessage::Settings { values }); | |
| 326 | + } | |
| 327 | + } | |
| 328 | + | |
| 329 | + /// The settings a thread in this project starts with: its environment's, | |
| 330 | + /// with the project's overrides. | |
| 331 | + pub(super) fn project_settings(&self, project_ix: usize) -> Settings { | |
| 332 | + let Some(project) = self.projects.get(project_ix) else { return settings::load() }; | |
| 333 | + match &project.environment { | |
| 334 | + None => settings::get(|settings| settings.for_project(&project.path)), | |
| 335 | + Some(env) => self | |
| 336 | + .remote | |
| 337 | + .environments | |
| 338 | + .iter() | |
| 339 | + .find(|candidate| candidate.host.id == *env) | |
| 340 | + .and_then(|env| env.settings.as_ref()) | |
| 341 | + .map(|settings| settings.for_project(&project.path)) | |
| 342 | + // Until the device says, its threads start as this one's would. | |
| 343 | + .unwrap_or_else(settings::load), | |
| 344 | + } | |
| 345 | + } | |
| 346 | + | |
| 180 | 347 | pub fn disconnect_peer(&mut self, conn: u64, cx: &mut Context<Self>) { |
| 181 | 348 | if let Some(server) = &self.remote.server { |
| 182 | 349 | server.disconnect(conn); |
| @@ -238,6 +405,7 @@ impl Workspace { | ||
| 238 | 405 | match event { |
| 239 | 406 | ServerEvent::Connected { conn, name, address } => { |
| 240 | 407 | server.send(conn, &self.host_state(cx)); |
| 408 | + server.send(conn, &ServerMessage::Settings { values: settings::get(Settings::environment_values) }); | |
| 241 | 409 | self.remote.peers.push(Peer { conn, name: name.clone(), address }); |
| 242 | 410 | self.remote.subscriptions.insert(conn, HashMap::new()); |
| 243 | 411 | if settings::get(|settings| settings.in_app_notifications) { |
| @@ -317,6 +485,11 @@ impl Workspace { | ||
| 317 | 485 | self.remote.state_dirty = true; |
| 318 | 486 | self.reply(conn, ServerMessage::Created { reference, thread: id }); |
| 319 | 487 | } |
| 488 | + ClientMessage::UpdateSettings { patch } => { | |
| 489 | + settings::apply(&patch); | |
| 490 | + // Tells the open conversations, and every client, this one too. | |
| 491 | + self.settings_changed(window, cx); | |
| 492 | + } | |
| 320 | 493 | } |
| 321 | 494 | } |
| 322 | 495 | |
| @@ -473,6 +646,15 @@ impl Workspace { | ||
| 473 | 646 | let name = self.environment_name(env); |
| 474 | 647 | window.push_notification(Notification::error(message).title(name), cx); |
| 475 | 648 | } |
| 649 | + ClientEvent::Message(ServerMessage::Settings { values }) => { | |
| 650 | + if let Some(environment) = self.remote.environments.iter_mut().find(|candidate| candidate.host.id == env) { | |
| 651 | + environment.settings = Some(settings::with_values(&Settings::default(), &values)); | |
| 652 | + if !environment.host.settings_offered { | |
| 653 | + self.offer_settings(env, window, cx); | |
| 654 | + } | |
| 655 | + } | |
| 656 | + cx.notify(); | |
| 657 | + } | |
| 476 | 658 | ClientEvent::Message(ServerMessage::Welcome { .. } | ServerMessage::Denied { .. }) => {} |
| 477 | 659 | } |
| 478 | 660 | } |