Vectrum
Sign In

Merge settings for every environment at once

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 2 parents 3d9f92e a3b190ecommit31037aa

Showing 20 changed files with 1325 additions and 156 deletions.

Merge commit — showing changes against the first parent.

+14−2README.mdView File
@@ -147,13 +147,25 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
147147 - Remote connections, like T3 Code's environments: turn on **Allow remote connections** on
148148 one device, and paste its pairing code (address and key) under Settings → Connections on
149149 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.
152154 - A settings view (Ctrl+, or **Settings** in the sidebar), laid out like T3 Code's: a
153155 search field over the pages (press `/` to search, ↑/↓ and Enter to jump to a setting,
154156 which scrolls into view and pulses), a "Settings / Page" breadcrumb, and every setting as
155157 a row with its description, its control and, once changed, a button that resets it.
156158 **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.
157169 - **General**: New threads (model and reasoning, permissions, workspace, submodules);
158170 Organization (project grouping and order, auto-resume and snooze for usage-limited
159171 threads, the Working section, auto-settling merged and inactive threads); Behavior
+20−1src/remote/mod.rsView File
@@ -8,7 +8,9 @@
88 //! [`ClientMessage::Hello`] with the key, gets the host's projects and
99 //! threads in [`ServerMessage::State`], subscribes to the threads it opens,
1010 //! 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`].
1214
1315 pub mod client;
1416 pub mod server;
@@ -20,6 +22,7 @@ use serde::{Deserialize, Serialize};
2022 use crate::git::TurnChanges;
2123 use crate::models::Provider;
2224 use crate::session::{QueuedMessage, SavedItem};
25+ use crate::settings::SettingsPatch;
2326 use crate::worktree::PullRequest;
2427
2528 /// The port a host listens on unless told otherwise.
@@ -165,6 +168,8 @@ pub enum ClientMessage {
165168 text: String,
166169 attachments: Vec<Upload>,
167170 },
171+ /// Changes the host's environment settings or its projects' overrides.
172+ UpdateSettings { patch: SettingsPatch },
168173 }
169174
170175 #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -178,6 +183,9 @@ pub enum ServerMessage {
178183 Thread { summary: ThreadSummary, detail: ThreadDetail, from: usize, len: usize, items: Vec<SavedItem> },
179184 Created { reference: u64, thread: String },
180185 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> },
181189 }
182190
183191 /// A new random key: 24 bytes, hex.
@@ -264,6 +272,17 @@ mod tests {
264272 };
265273 let text = serde_json::to_string(&message).unwrap();
266274 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+ }
267286 }
268287
269288 #[::core::prelude::v1::test]
+11−7src/session.rsView File
@@ -720,7 +720,8 @@ impl Session {
720720 permission_mode: self.permission_mode,
721721 effort: self.reasoning.effort(),
722722 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)),
724725 };
725726 let process = match AgentProcess::spawn(&config, tx) {
726727 Ok(process) => process,
@@ -1174,10 +1175,11 @@ impl Session {
11741175
11751176 /// Asks the text generation model for a title, in the background.
11761177 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()) {
11781179 return;
11791180 }
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) });
11811183 cx.spawn(async move |this, cx| {
11821184 let Some(title) = task.await else { return };
11831185 let _ = this.update(cx, |this, cx| {
@@ -1270,15 +1272,16 @@ impl Session {
12701272 /// message, as Settings → Source Control → Worktree branch naming says.
12711273 /// The folder keeps its name.
12721274 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() {
12741276 return;
12751277 }
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| {
12771280 (settings.branch_naming, settings.branch_prefix.clone(), settings.branch_instructions.clone())
12781281 });
12791282 let worktree = worktree.clone();
12801283 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)?;
12821285 let name = worktree::branch_from_suggestion(&suggestion, naming, &prefix);
12831286 worktree::rename_branch(&worktree, &name, naming != BranchNaming::Custom).ok()
12841287 });
@@ -1303,7 +1306,8 @@ impl Session {
13031306 }
13041307 self.regenerating_title = true;
13051308 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) });
13071311 cx.spawn(async move |this, cx| {
13081312 let title = task.await;
13091313 let _ = this.update(cx, |this, cx| {
+226−4src/settings.rsView File
@@ -2,7 +2,10 @@
22 //! `$XDG_CONFIG_HOME/vectrum-code/settings.json` and edited in the settings
33 //! view. They're read once and cached, so rendering can consult them freely.
44
5- use std::{path::PathBuf, sync::Mutex};
5+ use std::{
6+ path::{Path, PathBuf},
7+ sync::Mutex,
8+ };
69
710 use serde::{Deserialize, Serialize};
811
@@ -325,6 +328,9 @@ pub struct RemoteHost {
325328 pub address: String,
326329 pub key: String,
327330 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,
328334 }
329335
330336 impl RemoteHost {
@@ -515,6 +521,10 @@ pub struct Settings {
515521 /// Other devices' apps this one connects to.
516522 pub remotes: Vec<RemoteHost>,
517523
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+
518528 /// Superseded by `providers`; read once to carry an old path override over.
519529 #[serde(skip_serializing)]
520530 claude_bin: Option<String>,
@@ -607,6 +617,7 @@ impl Default for Settings {
607617 remote_port: crate::remote::DEFAULT_PORT,
608618 remote_key: String::new(),
609619 remotes: Vec::new(),
620+ project_overrides: std::collections::BTreeMap::new(),
610621 claude_bin: None,
611622 pi_bin: None,
612623 colorway: None,
@@ -617,7 +628,7 @@ impl Default for Settings {
617628 impl Settings {
618629 /// Makes sure there's at least one instance of each kind, carrying old
619630 /// binary overrides over, and turns an old colorway into a theme mode.
620- fn normalize(&mut self) {
631+ pub(crate) fn normalize(&mut self) {
621632 if let Some(colorway) = self.colorway.take() {
622633 self.theme_mode = match colorway.as_str() {
623634 "bone" => ThemeMode::Light,
@@ -708,6 +719,151 @@ impl Settings {
708719 pub fn instance(&self, id: &str) -> Option<&ProviderInstance> {
709720 self.providers.iter().find(|instance| instance.id == id)
710721 }
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()
711867 }
712868
713869 static CACHE: Mutex<Option<Settings>> = Mutex::new(None);
@@ -749,6 +905,22 @@ pub fn load() -> Settings {
749905 get(Settings::clone)
750906 }
751907
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+
752924 /// The configured agents, enabled or not.
753925 pub fn instances() -> Vec<ProviderInstance> {
754926 get(|settings| settings.providers.clone())
@@ -779,8 +951,8 @@ pub fn update(edit: impl FnOnce(&mut Settings)) {
779951 }
780952 }
781953
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.
784956 pub fn restore_defaults() {
785957 update(|settings| {
786958 let kept = Settings {
@@ -793,6 +965,7 @@ pub fn restore_defaults() {
793965 remote_port: settings.remote_port,
794966 remote_key: std::mem::take(&mut settings.remote_key),
795967 remotes: std::mem::take(&mut settings.remotes),
968+ project_overrides: std::mem::take(&mut settings.project_overrides),
796969 ..Settings::default()
797970 };
798971 *settings = kept;
@@ -905,6 +1078,55 @@ mod tests {
9051078 assert_eq!((fresh.send_shortcut, fresh.legacy_sidebar), (SendShortcut::Enter, false));
9061079 }
9071080
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+
9081130 #[test]
9091131 fn restore_lists_what_changed() {
9101132 let mut settings = parse("{}");
+14−11src/textgen.rsView File
@@ -2,7 +2,7 @@
22 //! under Settings → General → Text generation. Each request is a one-off,
33 //! tool-less agent run. Everything here blocks; call it from a background task.
44
5- use std::process::Stdio;
5+ use std::{path::Path, process::Stdio};
66
77 use crate::claude::{Effort, base_command};
88 use crate::models::{Provider, split_key};
@@ -14,16 +14,16 @@ sentence case, no quotes, no trailing period) for a conversation that starts wit
1414
1515 /// A title for a thread that starts with `message`, or `None` when text
1616 /// generation is off or fails.
17- pub fn title(message: &str) -> Option<String> {
17+ pub fn title(project: &Path, message: &str) -> Option<String> {
1818 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>"))?)
2020 }
2121
2222 /// A branch name for a worktree whose thread starts with `message`, as
2323 /// T3 Code asks for one: a fragment the configured prefix goes in front of,
2424 /// one with a semantic prefix (`feat/…`), or a whole name following the
2525 /// 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> {
2727 let mut rules = vec![
2828 "Branch should describe the requested work from the user message.",
2929 "Return a valid Git branch name without spaces.",
@@ -51,14 +51,14 @@ pub fn branch_name(message: &str, naming: BranchNaming, instructions: &str) -> O
5151 if naming == BranchNaming::Custom && !instructions.trim().is_empty() {
5252 prompt.push_str(&format!("\n\nAdditional instructions:\n{}", instructions.trim()));
5353 }
54- let value = json_reply(&generate(&prompt)?)?;
54+ let value = json_reply(&generate(project, &prompt)?)?;
5555 let branch = value.get("branch")?.as_str()?.trim().to_string();
5656 (!branch.is_empty()).then_some(branch)
5757 }
5858
5959 /// A commit message for what's staged, as T3 Code asks for one: an
6060 /// 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> {
6262 let mut prompt = [
6363 "You write concise git commit messages.",
6464 "Return a JSON object with keys: subject, body.",
@@ -77,7 +77,7 @@ pub fn commit_message(branch: Option<&str>, staged_summary: &str, staged_patch:
7777 ]
7878 .join("\n");
7979 push_instructions(&mut prompt, instructions);
80- let value = json_reply(&generate(&prompt)?)?;
80+ let value = json_reply(&generate(project, &prompt)?)?;
8181 let subject = value.get("subject")?.as_str()?.trim().trim_end_matches('.').to_string();
8282 if subject.is_empty() {
8383 return None;
@@ -88,6 +88,7 @@ pub fn commit_message(branch: Option<&str>, staged_summary: &str, staged_patch:
8888
8989 /// A pull request's title and body for a branch, as T3 Code asks for them.
9090 pub fn pull_request_content(
91+ project: &Path,
9192 base: &str,
9293 head: &str,
9394 commits: &str,
@@ -139,7 +140,7 @@ pub fn pull_request_content(
139140 &limit(diff_patch, 40_000),
140141 ]
141142 .join("\n");
142- let value = json_reply(&generate(&prompt)?)?;
143+ let value = json_reply(&generate(project, &prompt)?)?;
143144 let title = value.get("title")?.as_str()?.trim().to_string();
144145 let body = value.get("body").and_then(|body| body.as_str()).unwrap_or_default().to_string();
145146 (!title.is_empty()).then_some((title, body))
@@ -169,9 +170,11 @@ fn limit(text: &str, max: usize) -> String {
169170 cut
170171 }
171172
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()));
175178 let (instance_id, model) = split_key(&key)?;
176179 let kind = settings::get(|settings| settings.instance(instance_id).map(|instance| instance.kind))
177180 .or_else(|| Provider::from_key(instance_id))?;
+10−0src/ui/metal.rsView File
@@ -713,6 +713,8 @@ pub struct MetalButton {
713713 size: CapSize,
714714 icon: Option<Icon>,
715715 icon_color: Option<Hsla>,
716+ /// Drawn where the icon goes, such as a project's badge.
717+ leading: Option<AnyElement>,
716718 label: Option<SharedString>,
717719 caret: bool,
718720 stretch: bool,
@@ -730,6 +732,7 @@ impl MetalButton {
730732 size: CapSize::Regular,
731733 icon: None,
732734 icon_color: None,
735+ leading: None,
733736 label: None,
734737 caret: false,
735738 stretch: false,
@@ -772,6 +775,12 @@ impl MetalButton {
772775 self
773776 }
774777
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+
775784 pub fn icon_color(mut self, color: Hsla) -> Self {
776785 self.icon_color = Some(color);
777786 self
@@ -895,6 +904,7 @@ impl RenderOnce for MetalButton {
895904 None => icon,
896905 })
897906 })
907+ .children(self.leading)
898908 .when_some(self.label, |this, label| {
899909 this.child(
900910 div()
+4−4src/ui/settings/appearance.rsView File
@@ -48,7 +48,7 @@ impl SettingsView {
4848 SettingRow::new("Diff colors")
4949 .id("diff-color-scheme")
5050 .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))
5252 .control(h_flex().gap(px(8.)).child(diff_dots).child(choice_of(
5353 "diff-colors",
5454 saved.diff_colors,
@@ -68,7 +68,7 @@ impl SettingsView {
6868 SettingRow::new("Composer context")
6969 .id("composer-context")
7070 .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))
7272 .control(switch("composer-context", saved.composer_context, &view, p, |settings, on| {
7373 settings.composer_context = on
7474 })),
@@ -77,7 +77,7 @@ impl SettingsView {
7777 SettingRow::new("Chat width")
7878 .id("chat-width")
7979 .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))
8181 .control(choice("chat-width", saved.chat_width, CHAT_WIDTHS, &view, |settings, width| {
8282 settings.chat_width = width
8383 })),
@@ -86,7 +86,7 @@ impl SettingsView {
8686 SettingRow::new("Thread cards")
8787 .id("thread-cards")
8888 .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))
9090 .control(choice("thread-density", saved.compact_threads, &[(false, "Comfortable"), (true, "Compact")], &view, {
9191 |settings, compact| settings.compact_threads = compact
9292 })),
+1−1src/ui/settings/connections.rsView File
@@ -71,7 +71,7 @@ impl SettingsView {
7171 let name = value(&state.connect_name);
7272 let ids: Vec<String> = settings::get(|settings| settings.remotes.iter().map(|host| host.id.clone()).collect());
7373 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 }));
7575 for input in [&state.connect_name, &state.connect_address, &state.connect_key] {
7676 input.update(cx, |input, cx| input.set_value("", window, cx));
7777 }
+42−42src/ui/settings/general.rsView File
@@ -15,7 +15,7 @@ use crate::claude::PermissionMode;
1515 use crate::models::Catalog;
1616 use crate::session::Reasoning;
1717 use crate::settings::{
18- self, BackgroundActivity, FollowUp, ProjectOrder, QuitShortcut, SendShortcut, Streaming, Submodules,
18+ BackgroundActivity, FollowUp, ProjectOrder, QuitShortcut, SendShortcut, Streaming, Submodules,
1919 TimeFormat, WorkspaceMode,
2020 };
2121 use crate::ui::metal::{self, MetalButton};
@@ -74,7 +74,7 @@ fn permission_description(mode: PermissionMode) -> &'static str {
7474 impl SettingsView {
7575 pub(super) fn render_general(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> {
7676 let p = metal::palette(cx);
77- let saved = settings::load();
77+ let saved = super::scope::shown_settings(cx);
7878 let view = cx.weak_entity();
7979 let row = SettingRow::new;
8080
@@ -82,7 +82,7 @@ impl SettingsView {
8282 let model_pick = {
8383 let view = view.clone();
8484 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())
8686 }
8787 };
8888 let reasoning_pick = {
@@ -113,7 +113,7 @@ impl SettingsView {
113113 .id("default-model")
114114 .description("Default model for new threads. Projects can override it.")
115115 .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)| {
117117 s.default_model = model;
118118 s.default_reasoning = reasoning;
119119 }))
@@ -133,7 +133,7 @@ impl SettingsView {
133133 row("Permissions")
134134 .id("default-permissions")
135135 .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))
137137 .control(rich_choice("default-access", current_access, permissions, &view, |settings, mode| {
138138 settings.default_access = mode.map(|mode: PermissionMode| mode.as_cli().to_string())
139139 })),
@@ -142,14 +142,14 @@ impl SettingsView {
142142 row("Workspace")
143143 .id("new-threads")
144144 .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))
146146 .control(choice("workspace", saved.workspace, WORKSPACES, &view, |settings, mode| settings.workspace = mode)),
147147 )
148148 .row(
149149 row("Submodules")
150150 .id("worktree-submodules")
151151 .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))
153153 .control(choice("submodules", saved.submodules, SUBMODULES, &view, |settings, mode| settings.submodules = mode)),
154154 )
155155 .render(cx);
@@ -160,7 +160,7 @@ impl SettingsView {
160160 row("Project grouping")
161161 .id("project-grouping")
162162 .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))
164164 // On groups by repository, as T3 Code's switch does; off keeps every project apart.
165165 .control(switch(
166166 "project-grouping",
@@ -180,7 +180,7 @@ impl SettingsView {
180180 row("Project order")
181181 .id("project-order")
182182 .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))
184184 .control(choice("project-order", saved.project_order, PROJECT_ORDERS, &view, |settings, order| {
185185 settings.project_order = order
186186 })),
@@ -191,7 +191,7 @@ impl SettingsView {
191191 .description(
192192 "Resume usage-limit stops at the reported reset time. Each thread can cancel its scheduled continuation.",
193193 )
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))
195195 .control(switch("auto-resume", saved.auto_resume_limited, &view, p, |settings, on| {
196196 settings.auto_resume_limited = on
197197 })),
@@ -202,7 +202,7 @@ impl SettingsView {
202202 .description(
203203 "Snooze usage-limit stops until the reported reset time. Combine with auto-resume to continue when they wake.",
204204 )
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))
206206 .control(switch("snooze-limited", saved.snooze_limited, &view, p, |settings, on| settings.snooze_limited = on)),
207207 )
208208 .row(
@@ -211,7 +211,7 @@ impl SettingsView {
211211 .description(
212212 "Fold working and monitoring threads into a Working section. They return to the top of the inbox when they need you.",
213213 )
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))
215215 .control(switch("working-section", saved.working_section, &view, p, |settings, on| {
216216 settings.working_section = on
217217 })),
@@ -220,7 +220,7 @@ impl SettingsView {
220220 row("Auto-settle merged threads")
221221 .id("auto-settle-merged-threads")
222222 .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))
224224 .control(switch("auto-settle-merged", saved.auto_settle_merged, &view, p, |settings, on| {
225225 settings.auto_settle_merged = on
226226 })),
@@ -229,7 +229,7 @@ impl SettingsView {
229229 row("Auto-settle inactive threads")
230230 .id("auto-settle-inactive-threads")
231231 .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)| {
233233 s.auto_settle_inactive = on;
234234 s.inactive_days = days;
235235 }))
@@ -286,7 +286,7 @@ impl SettingsView {
286286 } else {
287287 "Notifications need notify-send (libnotify), which isn't installed. Sound only is still available."
288288 })
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)| {
290290 s.notify = notify;
291291 s.notify_sound = sound;
292292 }))
@@ -309,7 +309,7 @@ impl SettingsView {
309309 row("Time format")
310310 .id("time-format")
311311 .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))
313313 .control(choice("time-format", saved.time_format, TIME_FORMATS, &view, |settings, format| {
314314 settings.time_format = format
315315 })),
@@ -318,7 +318,7 @@ impl SettingsView {
318318 row("Response streaming")
319319 .id("response-streaming")
320320 .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))
322322 .control(choice("streaming", saved.streaming, STREAMING, &view, |settings, streaming| {
323323 settings.streaming = streaming
324324 })),
@@ -327,21 +327,21 @@ impl SettingsView {
327327 row("Fold finished turns")
328328 .id("fold-turns")
329329 .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))
331331 .control(switch("fold-turns", saved.fold_turns, &view, p, |settings, on| settings.fold_turns = on)),
332332 )
333333 .row(
334334 row("Show costs")
335335 .id("show-costs")
336336 .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))
338338 .control(switch("show-costs", saved.show_costs, &view, p, |settings, on| settings.show_costs = on)),
339339 )
340340 .row(
341341 row("Hide whitespace changes")
342342 .id("hide-whitespace-changes")
343343 .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))
345345 .control(switch("hide-whitespace", saved.hide_whitespace, &view, p, |settings, on| {
346346 settings.hide_whitespace = on
347347 })),
@@ -350,7 +350,7 @@ impl SettingsView {
350350 row("Default diff file state")
351351 .id("default-diff-file-state")
352352 .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))
354354 .control(choice("diff-collapsed", saved.diff_collapsed, DIFF_STATES, &view, |settings, collapsed| {
355355 settings.diff_collapsed = collapsed
356356 })),
@@ -359,7 +359,7 @@ impl SettingsView {
359359 row("Diff layout")
360360 .id("diff-layout")
361361 .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))
363363 .control(choice("diff-layout", saved.diff_side_by_side, DIFF_LAYOUTS, &view, |settings, split| {
364364 settings.diff_side_by_side = split
365365 })),
@@ -368,7 +368,7 @@ impl SettingsView {
368368 row("Proactive panels")
369369 .id("proactive-panels")
370370 .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))
372372 .control(switch("proactive-panels", saved.proactive_panels, &view, p, |settings, on| {
373373 settings.proactive_panels = on
374374 })),
@@ -377,14 +377,14 @@ impl SettingsView {
377377 row("Show skills in slash menu")
378378 .id("skills-in-slash-menu")
379379 .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))
381381 .control(switch("slash-skills", saved.slash_skills, &view, p, |settings, on| settings.slash_skills = on)),
382382 )
383383 .row(
384384 row("Rich text composer")
385385 .id("composer-rich-text")
386386 .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))
388388 .control(switch("rich-composer", saved.rich_composer, &view, p, |settings, on| settings.rich_composer = on)),
389389 )
390390 .row(
@@ -393,7 +393,7 @@ impl SettingsView {
393393 .description(
394394 "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.",
395395 )
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))
397397 .control(switch("collapse-composer", saved.collapse_composer, &view, p, |settings, on| {
398398 settings.collapse_composer = on
399399 })),
@@ -402,7 +402,7 @@ impl SettingsView {
402402 row("Send shortcut")
403403 .id("send-shortcut")
404404 .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))
406406 .control(choice("send-shortcut", saved.send_shortcut, SEND_SHORTCUTS, &view, |settings, shortcut| {
407407 settings.send_shortcut = shortcut
408408 })),
@@ -411,7 +411,7 @@ impl SettingsView {
411411 row("Follow-up behavior")
412412 .id("follow-up-behavior")
413413 .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))
415415 .control(choice("follow-up", saved.follow_up, FOLLOW_UPS, &view, |settings, follow_up| {
416416 settings.follow_up = follow_up
417417 })),
@@ -420,7 +420,7 @@ impl SettingsView {
420420 row("Provider update checks")
421421 .id("provider-update-checks")
422422 .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))
424424 .control(switch_then(
425425 "update-checks",
426426 saved.update_checks,
@@ -434,7 +434,7 @@ impl SettingsView {
434434 row("Continue threads after restarts")
435435 .id("continue-threads-after-server-update")
436436 .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))
438438 .control(switch("continue-after-restart", saved.continue_after_restart, &view, p, |settings, on| {
439439 settings.continue_after_restart = on
440440 })),
@@ -443,7 +443,7 @@ impl SettingsView {
443443 row("Background activity")
444444 .id("background-activity")
445445 .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))
447447 .control(choice("background-activity", saved.background_activity, BACKGROUND, &view, |settings, activity| {
448448 settings.background_activity = activity
449449 })),
@@ -456,7 +456,7 @@ impl SettingsView {
456456 row("Start from origin")
457457 .id("start-from-origin")
458458 .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))
460460 .control(switch("start-from-origin", saved.start_from_origin, &view, p, |settings, on| {
461461 settings.start_from_origin = on
462462 })),
@@ -465,7 +465,7 @@ impl SettingsView {
465465 row("Add project starts in")
466466 .id("add-project-starts-in")
467467 .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))
469469 .control(text_field(&self.add_project_dir, 288., window, cx)),
470470 )
471471 .render(cx);
@@ -476,28 +476,28 @@ impl SettingsView {
476476 row("Unpin confirmation")
477477 .id("unpin-confirmation")
478478 .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))
480480 .control(switch("confirm-unpin", saved.confirm_unpin, &view, p, |settings, on| settings.confirm_unpin = on)),
481481 )
482482 .row(
483483 row("Archive confirmation")
484484 .id("archive-confirmation")
485485 .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))
487487 .control(switch("confirm-settle", saved.confirm_settle, &view, p, |settings, on| settings.confirm_settle = on)),
488488 )
489489 .row(
490490 row("Delete confirmation")
491491 .id("delete-confirmation")
492492 .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))
494494 .control(switch("confirm-delete", saved.confirm_delete, &view, p, |settings, on| settings.confirm_delete = on)),
495495 )
496496 .row(
497497 row("Quit shortcut")
498498 .id("quit-confirmation")
499499 .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))
501501 .control(choice("quit-shortcut", saved.quit_shortcut, QUIT_SHORTCUTS, &view, |settings, shortcut| {
502502 settings.quit_shortcut = shortcut
503503 })),
@@ -508,7 +508,7 @@ impl SettingsView {
508508 let text_pick = {
509509 let view = view.clone();
510510 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())
512512 }
513513 };
514514 let text_reasoning_pick = {
@@ -525,7 +525,7 @@ impl SettingsView {
525525 row("Text generation model")
526526 .id("text-generation-model")
527527 .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)| {
529529 s.text_model = model;
530530 s.text_reasoning = reasoning;
531531 }))
@@ -590,19 +590,19 @@ impl SettingsView {
590590 row("Plan mode (legacy)")
591591 .id("legacy-plan-mode")
592592 .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))
594594 .control(switch("plan-mode", saved.plan_mode, &view, p, |settings, on| settings.plan_mode = on))
595595 .into_any_element(),
596596 row("Context window indicator (legacy)")
597597 .id("legacy-context-window-indicator")
598598 .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))
600600 .control(switch("context-meter", saved.context_meter, &view, p, |settings, on| settings.context_meter = on))
601601 .into_any_element(),
602602 row("Sidebar (legacy)")
603603 .id("legacy-sidebar")
604604 .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))
606606 .control(switch("legacy-sidebar", saved.legacy_sidebar, &view, p, |settings, on| settings.legacy_sidebar = on))
607607 .into_any_element(),
608608 ],
+2−3src/ui/settings/integrations.rsView File
@@ -8,7 +8,6 @@ use gpui_kit::*;
88 use super::SettingsView;
99 use super::widgets::{Section, SettingRow, reset, switch};
1010 use crate::browser;
11- use crate::settings;
1211 use crate::ui::icons::Lucide;
1312 use crate::ui::metal::{self, MetalButton};
1413
@@ -16,7 +15,7 @@ impl SettingsView {
1615 pub(super) fn render_integrations(&mut self, cx: &mut Context<Self>) -> Vec<AnyElement> {
1716 let p = metal::palette(cx);
1817 let view = cx.weak_entity();
19- let on = settings::get(|settings| settings.agent_browser);
18+ let on = super::scope::shown_settings(cx).agent_browser;
2019 let installed = browser::find_binary().is_some();
2120
2221 let browser = Section::new("Browser")
@@ -29,7 +28,7 @@ impl SettingsView {
2928 row.status("agent-browser isn't installed: npm install -g agent-browser && agent-browser install")
3029 })
3130 .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))
3332 .control(switch("agent-browser", on, &view, p, |settings, on| settings.agent_browser = on)),
3433 )
3534 .render(cx);
+25−17src/ui/settings/mod.rsView File
@@ -12,6 +12,7 @@ mod integrations;
1212 mod keybindings;
1313 mod licenses;
1414 mod providers;
15+ mod scope;
1516 mod search;
1617 mod source_control;
1718 mod storage;
@@ -35,6 +36,7 @@ use crate::ui::metal::{self, MetalButton, MetalStyled as _};
3536 use crate::ui::usage::{HubSettings, UsageView};
3637 use crate::ui::workspace::{SHEET_INSET, SHEET_RADIUS, SIDEBAR_WIDTH, Workspace};
3738
39+ pub(crate) use scope::row_titles;
3840 pub(crate) use widgets::{card, radio, section, section_title};
3941 use widgets::{EXPANDED_WIDTH, PAGE_PADDING, READABLE_WIDTH, Target, WIDE_WIDTH};
4042
@@ -149,6 +151,10 @@ pub struct SettingsView {
149151 source_control: source_control::SourceControlState,
150152 diagnostics: diagnostics::DiagnosticsState,
151153 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]>,
152158 focus_handle: FocusHandle,
153159 _subscriptions: Vec<Subscription>,
154160 }
@@ -203,18 +209,16 @@ impl SettingsView {
203209 cx.subscribe_in(&writing_instructions, window, |this, input, event: &InputEvent, window, cx| {
204210 if matches!(event, InputEvent::Blur) {
205211 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);
209214 }
210215 }
211216 }),
212217 cx.subscribe_in(&branch_instructions, window, |this, input, event: &InputEvent, window, cx| {
213218 if matches!(event, InputEvent::Blur) {
214219 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);
218222 }
219223 }
220224 }),
@@ -242,6 +246,8 @@ impl SettingsView {
242246 source_control: source_control::SourceControlState::default(),
243247 diagnostics: diagnostics::DiagnosticsState::default(),
244248 licenses: licenses::LicensesState::new(window, cx),
249+ scope: scope::Scope::default(),
250+ scoped_inputs: None,
245251 focus_handle: cx.focus_handle(),
246252 _subscriptions: subscriptions,
247253 };
@@ -275,12 +281,8 @@ impl SettingsView {
275281 input.update(cx, |input, cx| input.set_value(value, window, cx));
276282 };
277283 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);
284286 self.connections.reload(&saved, window, cx);
285287 self.reload_provider_form(window, cx);
286288 }
@@ -736,7 +738,8 @@ impl SettingsView {
736738 }
737739 }
738740
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.
740743 fn commit_on_blur(
741744 input: &Entity<InputState>,
742745 window: &mut Window,
@@ -747,9 +750,8 @@ fn commit_on_blur(
747750 cx.subscribe_in(input, window, move |this, input, event: &InputEvent, window, cx| {
748751 if matches!(event, InputEvent::Blur | InputEvent::PressEnter { .. }) {
749752 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);
753755 }
754756 }
755757 })
@@ -764,7 +766,13 @@ impl Focusable for SettingsView {
764766 impl Render for SettingsView {
765767 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
766768 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+ }
768776 let width = self.page.width();
769777 let wizard = self.render_add_provider(window, cx);
770778 h_flex()
+1−1src/ui/settings/providers.rsView File
@@ -658,7 +658,7 @@ impl SettingsView {
658658 .id("provider-health-check-interval")
659659 .page_width(WIDE_WIDTH)
660660 .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))
662662 .control(stepper("health-interval", health, 30, (0, 86_400), "seconds", &view, p, |settings, secs| {
663663 settings.health_check_secs = secs
664664 })),
+641−0src/ui/settings/scope.rsaddedView File
@@ -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+ }
+7−7src/ui/settings/source_control.rsView File
@@ -10,7 +10,7 @@ use gpui_kit::*;
1010
1111 use super::SettingsView;
1212 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};
1414 use crate::ui::icons::Lucide;
1515 use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _};
1616 use crate::ui::workspace;
@@ -96,7 +96,7 @@ impl SettingsView {
9696
9797 pub(super) fn render_source_control(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> {
9898 let p = metal::palette(cx);
99- let saved = settings::load();
99+ let saved = super::scope::shown_settings(cx);
100100 let view = cx.weak_entity();
101101 if self.source_control.git.is_none() && !self.source_control.scanning {
102102 self.scan_source_control(cx);
@@ -138,7 +138,7 @@ impl SettingsView {
138138 "The model chooses a prefix that describes the work, such as feat/add-search, fix/login-timeout, or refactor/auth.",
139139 )
140140 })
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))
142142 .control(choice("branch-naming", saved.branch_naming, BRANCH_NAMING, &view, |settings, naming| {
143143 settings.branch_naming = naming
144144 })),
@@ -149,7 +149,7 @@ impl SettingsView {
149149 SettingRow::new("Branch prefix")
150150 .id("branch-prefix")
151151 .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))
153153 .control(text_field(&self.branch_prefix, 224., window, cx)),
154154 );
155155 }
@@ -160,7 +160,7 @@ impl SettingsView {
160160 .description(
161161 "Appended to the naming prompt. The model returns the complete branch name; no prefix or suffix is added.",
162162 )
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))
164164 .below(
165165 div()
166166 .px(px(2.))
@@ -185,7 +185,7 @@ impl SettingsView {
185185 WritingStyle::Conventional => "Use Conventional Commit prefixes and keep change request text concise.",
186186 WritingStyle::Custom => "Use your instructions for change descriptions and change requests in every project.",
187187 })
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))
189189 .control(choice("writing-style", saved.writing_style, WRITING_STYLES, &view, |settings, style| {
190190 settings.writing_style = style
191191 }))
@@ -206,7 +206,7 @@ impl SettingsView {
206206 SettingRow::new("Follow change request templates")
207207 .id("follow-change-request-templates")
208208 .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))
210210 .control(switch("follow-pr-templates", saved.follow_pr_templates, &view, p, |settings, on| {
211211 settings.follow_pr_templates = on
212212 })),
+7−7src/ui/settings/storage.rsView File
@@ -20,7 +20,7 @@ impl SettingsView {
2020 pub(super) fn render_storage(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> {
2121 let view = cx.weak_entity();
2222 let p = metal::palette(cx);
23- let saved = settings::load();
23+ let saved = super::scope::shown_settings(cx);
2424 let data = settings::data_dir();
2525 let default_worktrees = data.as_ref().map(|data| data.join("worktrees"));
2626 let worktrees = Section::new("Worktrees")
@@ -32,12 +32,12 @@ impl SettingsView {
3232 "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.",
3333 )
3434 .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() => {
3636 format!("Default: {}", display_path(path))
3737 }
3838 _ => String::new(),
3939 })
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))
4141 .control(text_field(&self.worktrees_dir, 288., window, cx)),
4242 )
4343 .row(
@@ -46,7 +46,7 @@ impl SettingsView {
4646 .description(
4747 "Remove unused worktrees when active or archived threads are deleted. Worktrees with local changes are kept.",
4848 )
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))
5050 .control(switch("cleanup-deleted", saved.cleanup_deleted_worktrees, &view, p, |settings, on| {
5151 settings.cleanup_deleted_worktrees = on
5252 })),
@@ -57,14 +57,14 @@ impl SettingsView {
5757 .description(
5858 "Remove worktrees after their threads have been inactive for this many days. Branches and thread history are kept.",
5959 )
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))
6161 .control(retention("cleanup-inactive", saved.cleanup_inactive_worktrees, &view, p)),
6262 )
6363 .row(
6464 SettingRow::new("Delete merged worktrees")
6565 .id("storage-worktrees-merged")
6666 .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))
6868 .control(switch("cleanup-merged", saved.cleanup_merged_worktrees, &view, p, |settings, on| {
6969 settings.cleanup_merged_worktrees = on
7070 })),
@@ -73,7 +73,7 @@ impl SettingsView {
7373 SettingRow::new("Delete unchanged worktrees")
7474 .id("storage-worktrees-unchanged")
7575 .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))
7777 .control(switch("cleanup-unchanged", saved.cleanup_unchanged_worktrees, &view, p, |settings, on| {
7878 settings.cleanup_unchanged_worktrees = on
7979 })),
+68−10src/ui/settings/widgets.rsView File
@@ -21,7 +21,7 @@ use super::SettingsView;
2121 use crate::claude::Effort;
2222 use crate::models::{Catalog, ProbeStatus, Provider, split_key};
2323 use crate::session::Reasoning;
24- use crate::settings::{self, Settings};
24+ use crate::settings::Settings;
2525 use crate::ui::icons::Lucide;
2626 use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette};
2727 use crate::ui::pickers;
@@ -196,10 +196,38 @@ impl SettingRow {
196196 }
197197
198198 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 {
200200 let p = metal::palette(cx);
201201 let stacked = stacked(window, self.max_width);
202202 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" };
203231 let text = v_flex()
204232 .flex_1()
205233 .min_w_0()
@@ -214,7 +242,8 @@ impl RenderOnce for SettingRow {
214242 this.child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(self.title))
215243 })
216244 .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))),
218247 )
219248 .when_some(self.description, |this, description| {
220249 this.child(
@@ -228,12 +257,19 @@ impl RenderOnce for SettingRow {
228257 })
229258 .when_some(self.status, |this, status| {
230259 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))
231263 });
264+ let locked = lock_note.is_some();
232265 let control = self.control.map(|control| {
233266 h_flex()
234267 .gap(px(8.))
235268 .map(|this| if stacked { this.w_full() } else { this.flex_none().justify_end() })
269+ .when(locked, |this| this.relative().opacity(0.5))
236270 .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()))
237273 });
238274 let row = div()
239275 .px(px(16.))
@@ -257,8 +293,29 @@ impl RenderOnce for SettingRow {
257293 }
258294 }
259295
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+
260317 /// 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 {
262319 div()
263320 .id(SharedString::from(format!("reset-{title}")))
264321 .flex()
@@ -270,7 +327,7 @@ fn reset_button(title: &SharedString, reset: Reset, p: &Palette) -> impl IntoEle
270327 .cursor_pointer()
271328 .text_color(p.ink3)
272329 .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))
274331 .on_click(move |_, window, cx| reset(window, cx))
275332 .child(Icon::new(Lucide::Undo2).size(px(12.)))
276333 }
@@ -279,10 +336,11 @@ fn reset_button(title: &SharedString, reset: Reset, p: &Palette) -> impl IntoEle
279336 /// default, and putting the default back with `set`.
280337 pub(crate) fn reset<T: PartialEq + 'static>(
281338 view: &WeakEntity<SettingsView>,
339+ cx: &App,
282340 get: fn(&Settings) -> T,
283341 set: fn(&mut Settings, T),
284342 ) -> 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());
286344 let view = view.clone();
287345 differs.then(|| -> Reset {
288346 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 {
495553
496554 // ---- controls ------------------------------------------------------------------
497555
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));
502560 }
503561
504562 pub(crate) fn switch(
+33−25src/ui/workspace.rsView File
@@ -7,7 +7,7 @@
77 use std::{
88 collections::{HashMap, HashSet},
99 hash::{Hash as _, Hasher as _},
10- path::PathBuf,
10+ path::{Path, PathBuf},
1111 process::{Command, Stdio},
1212 rc::Rc,
1313 time::{Duration, Instant, SystemTime},
@@ -56,6 +56,7 @@ mod terminal_drawer;
5656 use actions::UndoEntry;
5757 use palette::CommandPalette;
5858 use remote::RemoteState;
59+ pub use remote::SettingsEnvironment;
5960 use terminal_drawer::TerminalDrawer;
6061
6162 pub(crate) const SIDEBAR_WIDTH: f32 = 280.;
@@ -434,7 +435,7 @@ impl Workspace {
434435 workspace.ensure_project(cwd.canonicalize().unwrap_or(cwd), None)
435436 }
436437 };
437- let settings = workspace.new_thread_settings(cx);
438+ let settings = workspace.new_thread_settings(ix, cx);
438439 workspace.replace_thread(ix, settings, window, cx);
439440 }
440441 }
@@ -666,9 +667,6 @@ impl Workspace {
666667 /// idle ones, and checks worktree threads' pull requests.
667668 fn housekeeping(&mut self, allowed: bool, cx: &mut Context<Self>) {
668669 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);
672670 // Other devices look after their own threads.
673671 let sessions: Vec<Entity<Session>> = self
674672 .projects
@@ -680,6 +678,9 @@ impl Workspace {
680678 let (wake, resume, settle) = {
681679 let state = session.read(cx);
682680 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);
683684 (
684685 state.snoozed_until.is_some_and(|until| until <= now),
685686 state.resume_at.is_some_and(|at| at <= now) && !state.is_working(),
@@ -723,10 +724,14 @@ impl Workspace {
723724 /// branch, and with no agent at work there; the thread and its branch
724725 /// stay, and the worktree comes back with the thread's next message.
725726 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)) {
730735 return;
731736 }
732737 let Some(managed) = worktree::worktrees_dir() else { return };
@@ -739,10 +744,12 @@ impl Workspace {
739744 .filter(|state| state.is_working() || state.preparing)
740745 .filter_map(|state| state.worktree.as_ref().map(|worktree| worktree.root.clone()))
741746 .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();
743749 for session in sessions {
744750 let state = session.read(cx);
745751 let Some(worktree) = state.worktree.clone() else { continue };
752+ let (inactive_days, merged, unchanged) = rules(&state.project);
746753 if !worktree.root.starts_with(&managed) || !worktree.root.exists() || busy.contains(&worktree.root) {
747754 continue;
748755 }
@@ -751,7 +758,7 @@ impl Workspace {
751758 });
752759 let pr_merged = merged && state.pull_request.as_ref().is_some_and(|pr| pr.state == "MERGED");
753760 if inactive || pr_merged || unchanged {
754- candidates.push((worktree, inactive || pr_merged, pr_merged));
761+ candidates.push((worktree, inactive || pr_merged, pr_merged, unchanged));
755762 }
756763 }
757764 if candidates.is_empty() {
@@ -759,7 +766,7 @@ impl Workspace {
759766 }
760767 cx.background_executor()
761768 .spawn(async move {
762- for (worktree, due, merged) in candidates {
769+ for (worktree, due, merged, unchanged) in candidates {
763770 let status = git::status(&worktree.root);
764771 let safe = status
765772 .as_ref()
@@ -801,10 +808,10 @@ impl Workspace {
801808 });
802809 cx.spawn(async move |_, cx| {
803810 let results = task.await;
804- let merged_settles = settings::get(|settings| settings.auto_settle_merged);
805811 for ((session, _, _), pull_request) in threads.into_iter().zip(results) {
806812 let Some(pull_request) = pull_request else { continue };
807813 let _ = session.update(cx, |session, cx| {
814+ let merged_settles = settings::get_for(&session.project, |settings| settings.auto_settle_merged);
808815 let was_open = session.pull_request.as_ref().is_none_or(|before| before.is_open());
809816 let settle = was_open
810817 && session.auto_settle
@@ -873,12 +880,11 @@ impl Workspace {
873880 settings
874881 }
875882
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);
882888 let mut settings = self.current_settings(cx);
883889 if let Some(key) = model {
884890 match Catalog::global(cx).find_key(&key) {
@@ -918,12 +924,13 @@ impl Workspace {
918924 if mode != WorkspaceMode::Worktree || project.environment.is_some() || Some(&project.path) == scratch_dir().as_ref() {
919925 return None;
920926 }
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));
922929 Some(WorktreePlan { project: project.path.clone(), base: git.branch.clone(), from_origin, submodules })
923930 }
924931
925932 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);
927934 self.new_thread_with(project_ix, settings, window, cx);
928935 }
929936
@@ -934,8 +941,8 @@ impl Workspace {
934941 window: &mut Window,
935942 cx: &mut Context<Self>,
936943 ) -> Option<Entity<ChatView>> {
944+ let mode = self.project_settings(project_ix).workspace;
937945 let project = self.projects.get(project_ix)?;
938- let mode = settings::get(|settings| settings.workspace);
939946 let session = match project.environment.clone() {
940947 Some(env) => {
941948 let worktree = mode == WorkspaceMode::Worktree && project.git.is_some();
@@ -1108,7 +1115,7 @@ impl Workspace {
11081115 }
11091116 ChatEvent::OpenProviderSettings => self.open_settings(window, cx),
11101117 ChatEvent::ImplementInNewThread(plan) => {
1111- let settings = self.new_thread_settings(cx);
1118+ let settings = self.new_thread_settings(project_ix, cx);
11121119 if let Some(chat) = self.new_thread_with(project_ix, settings, window, cx) {
11131120 let prompt = format!("Implement this plan:\n\n{plan}");
11141121 chat.read(cx).session().clone().update(cx, |session, cx| session.send_message(prompt, Vec::new(), cx));
@@ -1674,6 +1681,7 @@ impl Workspace {
16741681 /// Lets every open conversation pick up changed settings.
16751682 pub fn settings_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
16761683 self.sync_remote(window, cx);
1684+ self.broadcast_settings();
16771685 for chat in self.chats.values() {
16781686 chat.update(cx, |chat, cx| chat.settings_changed(window, cx));
16791687 }
@@ -2267,7 +2275,7 @@ impl Render for Workspace {
22672275
22682276 /// Two letters for a project's badge: the first letters of its first two
22692277 /// 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 {
22712279 let words: Vec<&str> = name.split(|c: char| !c.is_alphanumeric()).filter(|word| !word.is_empty()).collect();
22722280 let letters: String = match words.as_slice() {
22732281 [first, second, ..] if second.starts_with(|c: char| c.is_alphabetic()) => {
@@ -2279,7 +2287,7 @@ fn initials(name: &str) -> String {
22792287 letters.to_uppercase()
22802288 }
22812289
2282- fn tint_for(name: &str) -> usize {
2290+ pub(crate) fn tint_for(name: &str) -> usize {
22832291 let mut hasher = std::collections::hash_map::DefaultHasher::new();
22842292 name.hash(&mut hasher);
22852293 hasher.finish() as usize
+3−2src/ui/workspace/actions.rsView File
@@ -475,7 +475,8 @@ impl Workspace {
475475 }
476476
477477 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) {
479480 error_toast(
480481 if sessions.len() == 1 { "Failed to regenerate thread title" } else { "Failed to regenerate thread titles" },
481482 "Pick a text generation model under Settings → General first.",
@@ -498,7 +499,7 @@ impl Workspace {
498499 let state = session.read(cx);
499500 (state.worktree.clone(), state.cwd.clone())
500501 };
501- let settings = self.new_thread_settings(cx);
502+ let settings = self.new_thread_settings(project_ix, cx);
502503 if self.projects[project_ix].environment.is_some() {
503504 self.new_thread_with(project_ix, settings, window, cx);
504505 return;
+12−10src/ui/workspace/git_actions.rsView File
@@ -155,6 +155,7 @@ struct Outcome {
155155 /// Blocks; run it in the background.
156156 fn run(
157157 path: &Path,
158+ project: &Path,
158159 action: GitAction,
159160 files: Option<Vec<String>>,
160161 message: String,
@@ -201,7 +202,7 @@ fn run(
201202 let message = if message.trim().is_empty() {
202203 let (summary, patch) = git::staged(path);
203204 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))
205206 .unwrap_or_else(|| "Update files".into())
206207 } else {
207208 message
@@ -231,9 +232,9 @@ fn run(
231232 let patch = git::git(path, &["diff", &format!("{base_ref}...HEAD")]).unwrap_or_default();
232233 let mut command = std::process::Command::new(gh);
233234 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) {
237238 Some((pr_title, body)) => command.args(["--title", &pr_title, "--body", &body]),
238239 None => command.arg("--fill"),
239240 };
@@ -250,9 +251,9 @@ fn run(
250251 }
251252
252253 /// 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()));
256257 match style {
257258 crate::settings::WritingStyle::RepoConventions => {
258259 let recent = git::git(path, &["log", "-15", "--format=%s"]).unwrap_or_default();
@@ -271,8 +272,8 @@ fn writing_instructions(path: &Path) -> String {
271272
272273 /// The repository's pull request template, when it has one and the
273274 /// 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) {
276277 return None;
277278 }
278279 let root = PathBuf::from(git::git(path, &["rev-parse", "--show-toplevel"]).ok()?.trim());
@@ -324,6 +325,7 @@ impl Workspace {
324325 cx: &mut Context<Self>,
325326 ) {
326327 let Some(path) = self.git_path(cx) else { return };
328+ let Some(project) = self.active_project().map(|project| project.path.clone()) else { return };
327329 let status = match action {
328330 GitAction::Pull => "Pulling latest changes...",
329331 _ if action.commits() && message.trim().is_empty() => "Generating commit message...",
@@ -335,7 +337,7 @@ impl Workspace {
335337 cx.notify();
336338 let task = cx.background_executor().spawn({
337339 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) }
339341 });
340342 cx.spawn_in(window, async move |this, cx| {
341343 let result = task.await;
+184−2src/ui/workspace/remote.rsView File
@@ -25,7 +25,8 @@ use crate::remote::{
2525 use crate::session::{
2626 NewSession, PermissionDecision, Reasoning, RemoteLink, Session, from_millis, store_uploads,
2727 };
28- use crate::settings::{self, RemoteHost, WorkspaceMode};
28+ use crate::settings::{self, RemoteHost, Settings, SettingsPatch, WorkspaceMode};
29+ use crate::ui::metal::MetalButton;
2930
3031 /// How often changed threads are sent to subscribed clients.
3132 pub(super) const FLUSH: Duration = Duration::from_millis(150);
@@ -35,6 +36,8 @@ pub(super) struct Environment {
3536 pub(super) host: RemoteHost,
3637 pub(super) status: ConnectionStatus,
3738 pub(super) client: Rc<RemoteClient>,
39+ /// Its environment settings, once it shared them.
40+ pub(super) settings: Option<Settings>,
3841 _events: Task<()>,
3942 }
4043
@@ -53,6 +56,8 @@ pub(super) struct RemoteState {
5356 dirty_threads: HashSet<String>,
5457 state_dirty: bool,
5558 last_state: String,
59+ /// The environment settings clients were last sent.
60+ last_settings: String,
5661 pub(super) environments: Vec<Environment>,
5762 _server_events: Option<Task<()>>,
5863 }
@@ -67,6 +72,18 @@ pub struct RemoteOverview {
6772 pub environments: Vec<(String, ConnectionStatus)>,
6873 }
6974
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+
7087 impl Workspace {
7188 /// Starts, stops or restarts the server and the connections to match the
7289 /// settings.
@@ -137,7 +154,13 @@ impl Workspace {
137154 }
138155 }
139156 });
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+ });
141164 }
142165
143166 /// Disconnects from a device and takes its projects and threads away.
@@ -177,6 +200,150 @@ impl Workspace {
177200 }
178201 }
179202
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+
180347 pub fn disconnect_peer(&mut self, conn: u64, cx: &mut Context<Self>) {
181348 if let Some(server) = &self.remote.server {
182349 server.disconnect(conn);
@@ -238,6 +405,7 @@ impl Workspace {
238405 match event {
239406 ServerEvent::Connected { conn, name, address } => {
240407 server.send(conn, &self.host_state(cx));
408+ server.send(conn, &ServerMessage::Settings { values: settings::get(Settings::environment_values) });
241409 self.remote.peers.push(Peer { conn, name: name.clone(), address });
242410 self.remote.subscriptions.insert(conn, HashMap::new());
243411 if settings::get(|settings| settings.in_app_notifications) {
@@ -317,6 +485,11 @@ impl Workspace {
317485 self.remote.state_dirty = true;
318486 self.reply(conn, ServerMessage::Created { reference, thread: id });
319487 }
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+ }
320493 }
321494 }
322495
@@ -473,6 +646,15 @@ impl Workspace {
473646 let name = self.environment_name(env);
474647 window.push_notification(Notification::error(message).title(name), cx);
475648 }
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+ }
476658 ClientEvent::Message(ServerMessage::Welcome { .. } | ServerMessage::Denied { .. }) => {}
477659 }
478660 }