General, Integrations, Source Control and Storage open with "Applying settings for <project> across <environment>". The settings that shape threads belong to the machine they run on: hosts share them with their clients and take changes back, so one menu edits this device, one connected device, or all of them. Rows show Mixed where the environments disagree, and picking a value applies it to each. A project can override them on the selected environments, and new threads, worktrees, branch naming, text generation, auto-settle and cleanup read those overrides. The first time a device connects with different settings, a dialog asks whether to give it this device's settings or leave its own. 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 | } |