Vectrum
Sign In

Edit settings for every environment at once, like T3 Code

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

The first time a device connects with different settings, a dialog asks
whether to give it this device's settings or leave its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 3d9f92ecommita3b190e

Showing 20 changed files with 1325 additions and 156 deletions.

+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 }