//! T3 Code's settings scope: "Applying settings for across //! " at the top of the pages with environment settings. The //! environment picker edits this device, one connected device, or all of //! them at once; the project picker edits a project's overrides on them. //! Rows that are a preference of this device ignore the selection. //! //! Each render works out what the selection shows ([`Shown`]): the values //! the controls display, where the selected environments disagree, and //! which settings the project overrides. Rows read it by their anchor, and //! every change is turned into a [`SettingsPatch`] for each environment. use std::{ collections::{BTreeMap, HashMap, HashSet}, path::PathBuf, rc::Rc, }; use gpui_kit::component::{ Icon, h_flex, menu::{DropdownMenu as _, PopupMenuItem}, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use super::SettingsView; use crate::settings::{self, ENVIRONMENT_KEYS, PROJECT_KEYS, Settings, SettingsPatch}; use crate::ui::icons::Lucide; use crate::ui::metal::{self, MetalButton}; use crate::ui::workspace::{self, SettingsEnvironment}; /// The rows that edit environment settings, by anchor: their title and the /// settings they show. pub(crate) const SCOPED_ROWS: &[(&str, &str, &[&str])] = &[ ("default-model", "Default model", &["default_model", "default_reasoning"]), ("default-permissions", "Permissions", &["default_access"]), ("new-threads", "Workspace", &["workspace"]), ("worktree-submodules", "Submodules", &["submodules"]), ("auto-resume-limited-threads", "Auto-resume limited threads", &["auto_resume_limited"]), ("snooze-limited-threads", "Snooze limited threads", &["snooze_limited"]), ("auto-settle-merged-threads", "Auto-settle merged threads", &["auto_settle_merged"]), ("auto-settle-inactive-threads", "Auto-settle inactive threads", &["auto_settle_inactive"]), ("days-before-auto-settle", "Days of inactivity before auto-settle", &["inactive_days"]), ("provider-update-checks", "Provider update checks", &["update_checks"]), ("continue-threads-after-server-update", "Continue threads after restarts", &["continue_after_restart"]), ("start-from-origin", "Start from origin", &["start_from_origin"]), ("text-generation-model", "Text generation model", &["text_model", "text_reasoning"]), ("agent-browser-access", "Agent browser access", &["agent_browser"]), ("worktree-branch-naming", "Worktree branch naming", &["branch_naming"]), ("branch-prefix", "Branch prefix", &["branch_prefix"]), ("branch-naming-instructions", "Branch naming instructions", &["branch_instructions"]), ("source-control-writing-style", "Source control writing style", &["writing_style", "writing_instructions"]), ("follow-change-request-templates", "Follow change request templates", &["follow_pr_templates"]), ("storage-worktrees-location", "Worktree location", &["worktrees_dir"]), ("storage-worktrees-deleted", "Delete worktrees with deleted threads", &["cleanup_deleted_worktrees"]), ("storage-worktrees-inactive", "Delete inactive worktrees", &["cleanup_inactive_worktrees"]), ("storage-worktrees-merged", "Delete merged worktrees", &["cleanup_merged_worktrees"]), ("storage-worktrees-unchanged", "Delete unchanged worktrees", &["cleanup_unchanged_worktrees"]), ]; /// What the open settings page shows: the selection's values where it /// applies, this device's elsewhere. pub(crate) fn shown_settings(cx: &App) -> Settings { cx.try_global::().map(|shown| shown.settings.clone()).unwrap_or_else(settings::load) } fn row_keys(id: &str) -> Option<&'static [&'static str]> { SCOPED_ROWS.iter().find(|(row, _, _)| *row == id).map(|(_, _, keys)| *keys) } /// The titles of the rows showing any of `keys`, for saying what differs. pub(crate) fn row_titles(keys: &[String]) -> Vec<&'static str> { SCOPED_ROWS .iter() .filter(|(_, _, row_keys)| row_keys.iter().any(|key| keys.iter().any(|candidate| candidate == key))) .map(|(_, title, _)| *title) .collect() } /// Which environments a change goes to. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) enum EnvironmentPick { #[default] All, /// One environment: this device (`None`) or a connection, by id. One(Option), } /// The settings view's selection, kept as pages change. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct Scope { pub environment: EnvironmentPick, /// A project by name: the same project on every environment. pub project: Option, } /// One place a change is written: an environment, and the project's path /// there when a project is picked. #[derive(Clone, Debug)] pub(crate) struct WriteTarget { pub environment: Option, pub name: String, pub project: Option, /// This device's preferences with the target's settings, as an edit /// there starts from. pub settings: Settings, } /// What the open page shows for the selection. Rows find it as a global. pub(crate) struct Shown { pub view: WeakEntity, /// The page edits environment settings, so rows apply the selection. pub scoped: bool, /// This device's preferences, with the first selected environment's /// settings (and the project's overrides) in place. pub settings: Settings, pub project: Option, pub targets: Vec, /// Why environment settings can't change right now. pub locked: Option, /// Settings the selected environments disagree on: each one's value. pub mixed: HashMap>, /// Settings the picked project overrides on any selected environment. pub overridden: HashSet, } impl Global for Shown {} impl Shown { pub fn compute( view: WeakEntity, scope: &Scope, scoped: bool, environments: &[SettingsEnvironment], ) -> Self { let local = settings::load(); let mut shown = Shown { view, scoped, settings: local.clone(), project: scope.project.clone(), targets: vec![WriteTarget { environment: None, name: String::new(), project: None, settings: local.clone() }], locked: None, mixed: HashMap::new(), overridden: HashSet::new(), }; if !scoped { return shown; } let selected: Vec<&SettingsEnvironment> = match &scope.environment { EnvironmentPick::All => environments.iter().filter(|env| env.connected && env.settings.is_some()).collect(), EnvironmentPick::One(id) => match environments.iter().find(|env| env.id == *id) { None => { shown.locked = Some("That environment is no longer connected.".into()); Vec::new() } Some(env) if !env.connected => { shown.locked = Some(format!("{} is offline; its settings stay as they are.", env.name).into()); Vec::new() } Some(env) if env.settings.is_none() => { shown.locked = Some( format!("{} doesn't share its settings yet. Update the app there to edit them here.", env.name) .into(), ); Vec::new() } Some(env) => vec![env], }, }; // Each selected environment's settings as the selection sees them. let mut effective: Vec<(WriteTarget, Settings, Option>)> = Vec::new(); for env in selected { let Some(settings) = env.settings.as_ref() else { continue }; let target = |project: Option| WriteTarget { environment: env.id.clone(), name: env.name.clone(), project, settings: Settings::default(), }; match &scope.project { None => effective.push((target(None), settings.clone(), None)), Some(name) => { for (_, path) in env.projects.iter().filter(|(project, _)| project == name) { let overrides = settings.project_overrides.get(&path.display().to_string()).cloned(); effective.push((target(Some(path.clone())), settings.for_project(path), overrides)); } } } } if effective.is_empty() { if shown.locked.is_none() { shown.locked = Some(match &scope.project { Some(name) => format!("{name} isn't a project on the selected environments.").into(), None => "No connected environment shares its settings.".into(), }); } shown.targets.clear(); return shown; } let values: Vec> = effective.iter().map(|(_, settings, _)| without_overrides(settings.environment_values())).collect(); shown.settings = settings::with_values(&local, &values[0]); shown.settings.project_overrides = local.project_overrides.clone(); for key in ENVIRONMENT_KEYS { let first = values[0].get(*key); if values.iter().any(|other| other.get(*key) != first) { let list = effective .iter() .zip(&values) .map(|((target, _, _), values)| { (target.name.clone(), describe(values.get(*key).unwrap_or(&serde_json::Value::Null))) }) .collect(); shown.mixed.insert(key.to_string(), list); } } shown.overridden = effective.iter().flat_map(|(_, _, overrides)| overrides.iter().flat_map(|map| map.keys().cloned())).collect(); shown.targets = effective .into_iter() .zip(&values) .map(|((target, _, _), values)| WriteTarget { settings: settings::with_values(&local, values), ..target }) .collect(); shown } /// How a row with anchor `id` appears, when the selection touches it. pub fn row(&self, id: &str) -> Option { if !self.scoped { return None; } let keys = row_keys(id)?; let overridable = keys.iter().all(|key| PROJECT_KEYS.contains(key)); let locked = self.locked.clone().or_else(|| { (self.project.is_some() && !overridable) .then(|| "Applies to the whole environment. Pick All projects to change it.".into()) }); let mixed: Vec<(String, String)> = keys.iter().filter_map(|key| self.mixed.get(*key)).flatten().cloned().collect(); Some(RowScope { keys, locked, mixed: (!mixed.is_empty()).then_some(mixed), project: self.project.is_some() && overridable, overridden: keys.iter().any(|key| self.overridden.contains(*key)), view: self.view.clone(), }) } } /// What the selection means for one row. pub(crate) struct RowScope { pub keys: &'static [&'static str], pub locked: Option, /// Each selected environment's value, when they differ. pub mixed: Option>, /// The row edits the picked project's override. pub project: bool, pub overridden: bool, pub view: WeakEntity, } fn without_overrides(mut values: serde_json::Map) -> serde_json::Map { values.remove("project_overrides"); values } /// A setting's value in a few words, for the Mixed tooltip. fn describe(value: &serde_json::Value) -> String { match value { serde_json::Value::Null => "Automatic".into(), serde_json::Value::Bool(true) => "On".into(), serde_json::Value::Bool(false) => "Off".into(), serde_json::Value::String(text) if text.is_empty() => "Empty".into(), serde_json::Value::String(text) => { let text = text.replace('_', " "); let mut chars = text.chars(); match chars.next() { Some(first) => first.to_uppercase().chain(chars).collect(), None => text, } } other => other.to_string(), } } /// The patch writing `values` to `target`: environment settings, or the /// project's overrides of them. fn patch_for(target: &WriteTarget, values: &serde_json::Map) -> SettingsPatch { match &target.project { None => SettingsPatch { values: values.clone(), projects: BTreeMap::new() }, Some(path) => { let overrides = values.iter().filter(|(key, _)| PROJECT_KEYS.contains(&key.as_str())).map(|(k, v)| (k.clone(), v.clone())); SettingsPatch { values: Default::default(), projects: BTreeMap::from([(path.display().to_string(), overrides.collect())]) } } } } impl SettingsView { /// Whether the open page has environment settings, and so the sentence. pub(super) fn page_scoped(&self) -> bool { use super::Page; matches!(self.page, Page::General | Page::Integrations | Page::SourceControl | Page::Storage) } fn environments(&self, cx: &App) -> Vec { self.workspace .upgrade() .map(|workspace| workspace.read(cx).settings_environments()) .unwrap_or_default() } /// Works the selection out afresh. pub(super) fn compute_shown(&self, cx: &mut Context) -> Shown { let environments = self.environments(cx); Shown::compute(cx.weak_entity(), &self.scope, self.page_scoped(), &environments) } /// Applies an edit made on a page: this device's preferences here, and /// environment settings to each selected environment (or the project's /// overrides there). The edit runs on each environment's own settings, /// so picking the shown value of a Mixed setting still reaches the /// environments that differ. pub(super) fn apply(&mut self, change: impl Fn(&mut Settings), window: &mut Window, cx: &mut Context) { let shown = self.compute_shown(cx); let environment_key = |key: &String| shown.scoped && ENVIRONMENT_KEYS.contains(&key.as_str()); let changes = |before: &Settings| { let mut after = before.clone(); change(&mut after); after.normalize(); settings::changed_values(before, &after) }; let device: serde_json::Map<_, _> = changes(&shown.settings) .into_iter() .filter(|(key, _)| key != "project_overrides" && !environment_key(key)) .collect(); if !device.is_empty() { settings::update(|settings| *settings = settings::with_values(settings, &device)); } if shown.scoped && shown.locked.is_none() { let patches: Vec<(Option, SettingsPatch)> = shown .targets .iter() .map(|target| { let values = changes(&target.settings).into_iter().filter(|(key, _)| environment_key(key)).collect(); (target.environment.clone(), patch_for(target, &values)) }) .collect(); let _ = self.workspace.update(cx, |workspace, cx| { for (environment, patch) in patches { workspace.write_settings(environment.as_deref(), patch, cx); } }); } self.changed(window, cx); } /// Lets the picked project follow its environments again for `keys`. pub(crate) fn inherit(&mut self, keys: &[&str], window: &mut Window, cx: &mut Context) { let shown = self.compute_shown(cx); let _ = self.workspace.update(cx, |workspace, cx| { for target in &shown.targets { let Some(path) = &target.project else { continue }; let drop = keys.iter().map(|key| (key.to_string(), serde_json::Value::Null)).collect(); let patch = SettingsPatch { values: Default::default(), projects: BTreeMap::from([(path.display().to_string(), drop)]) }; workspace.write_settings(target.environment.as_deref(), patch, cx); } }); self.changed(window, cx); } fn set_scope(&mut self, scope: Scope, window: &mut Window, cx: &mut Context) { let _ = window; self.scope = scope; // The next render puts the new selection's values in the fields. self.scoped_inputs = None; cx.notify(); } /// "Applying settings for across ". pub(super) fn render_scope_sentence(&self, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let environments = self.environments(cx); let view = cx.weak_entity(); // Projects by name, across every environment. let mut projects: Vec = Vec::new(); for (name, _) in environments.iter().flat_map(|env| &env.projects) { if !projects.contains(name) { projects.push(name.clone()); } } let project_icon = |name: &str| metal::badge(workspace::initials(name), workspace::tint_for(name), p); let project_label: SharedString = match &self.scope.project { Some(name) if projects.contains(name) => name.clone().into(), Some(_) => "Unavailable project".into(), None => "All projects".into(), }; let project_menu = { let view = view.clone(); let scope = self.scope.clone(); let projects = projects.clone(); picker("scope-project", self.scope.project.as_deref().map(|name| project_icon(name).into_any_element()), project_label) .dropdown_menu_with_anchor(Anchor::TopLeft, move |mut menu, _, cx| { let p = metal::palette(cx); let pick = |project: Option| { let view = view.clone(); let scope = Scope { project, ..scope.clone() }; move |_: &ClickEvent, window: &mut Window, cx: &mut App| { let scope = scope.clone(); let _ = view.update(cx, |view, cx| view.set_scope(scope, window, cx)); } }; menu = menu .min_w(px(220.)) .max_h(px(420.)) .scrollable(true) .item(PopupMenuItem::new("All projects").checked(scope.project.is_none()).on_click(pick(None))); if !projects.is_empty() { menu = menu.separator(); } for name in &projects { let label = name.clone(); let (initials, tint) = (workspace::initials(name), workspace::tint_for(name)); menu = menu.item( PopupMenuItem::element(move |_, _| { h_flex() .gap(px(8.)) .child(metal::badge(initials.clone(), tint, p)) .child(div().text_size(px(13.)).child(label.clone())) }) .checked(scope.project.as_deref() == Some(name.as_str())) .on_click(pick(Some(name.clone()))), ); } menu }) }; let single = environments.len() < 2; let selected = match &self.scope.environment { EnvironmentPick::One(id) => Some(environments.iter().find(|env| env.id == *id)), EnvironmentPick::All => None, }; let environment_label: SharedString = match selected { Some(Some(env)) => env.name.clone().into(), Some(None) => "Unavailable environment".into(), None if single => environments.first().map(|env| env.name.clone()).unwrap_or_default().into(), None => "All environments".into(), }; let environment_icon = match selected { Some(Some(env)) => Some(machine_icon(env.id.is_none())), None if single => Some(machine_icon(true)), _ => None, }; let entries: Rc, String, bool)>> = Rc::new(environments.iter().map(|env| (env.id.clone(), env.name.clone(), env.connected)).collect()); let environment_menu = { let view = view.clone(); let scope = self.scope.clone(); let entries = entries.clone(); picker("scope-environment", environment_icon.map(|icon| icon.size(px(13.)).text_color(p.ink2).into_any_element()), environment_label) .dropdown_menu_with_anchor(Anchor::TopLeft, move |mut menu, _, cx| { let p = metal::palette(cx); let pick = |environment: EnvironmentPick| { let view = view.clone(); let scope = Scope { environment, ..scope.clone() }; move |_: &ClickEvent, window: &mut Window, cx: &mut App| { let scope = scope.clone(); let _ = view.update(cx, |view, cx| view.set_scope(scope, window, cx)); } }; menu = menu.min_w(px(240.)); if entries.len() > 1 { menu = menu .item( PopupMenuItem::new("All environments") .icon(Icon::new(Lucide::Layers)) .checked(scope.environment == EnvironmentPick::All) .on_click(pick(EnvironmentPick::All)), ) .separator(); } for (id, name, connected) in entries.iter() { let (label, connected, local) = (name.clone(), *connected, id.is_none()); let checked = scope.environment == EnvironmentPick::One(id.clone()) || (entries.len() == 1 && scope.environment == EnvironmentPick::All); menu = menu.item( PopupMenuItem::element(move |_, _| { h_flex() .w_full() .gap(px(8.)) .child(machine_icon(local).size(px(13.)).text_color(p.ink2)) .child(div().flex_1().text_size(px(13.)).child(label.clone())) .when(!connected, |this| { this.child(div().text_size(px(11.5)).text_color(p.ink3).child("Offline")) }) }) .checked(checked) .on_click(pick(EnvironmentPick::One(id.clone()))), ); } menu }) }; let connective = if matches!(self.scope.environment, EnvironmentPick::One(_)) || single { "on" } else { "across" }; let text = |text: &'static str| div().flex_none().text_size(px(14.)).text_color(p.ink3).child(text); v_flex() .gap(px(6.)) .child( h_flex() .id("settings-scope") .flex_wrap() .px(px(16.)) .gap(px(6.)) .child(h_flex().gap(px(4.)).child(text("Applying settings for")).child(project_menu)) .child(h_flex().gap(px(4.)).child(text(connective)).child(environment_menu)), ) .into_any_element() } /// Puts the selection's text settings into their fields when they /// changed: on picking another scope, or when another device changed them. /// Reads what the last render worked out, as the workspace may be busy. pub(super) fn sync_scoped_inputs(&mut self, window: &mut Window, cx: &mut Context) { let shown = shown_settings(cx); let values = [ shown.branch_prefix.clone(), shown.worktrees_dir.clone(), shown.branch_instructions.clone(), shown.writing_instructions.clone(), ]; if self.scoped_inputs.as_ref() == Some(&values) { return; } let [prefix, worktrees, branch_instructions, writing_instructions] = values.clone(); self.branch_prefix.update(cx, |input, cx| input.set_value(prefix, window, cx)); self.worktrees_dir.update(cx, |input, cx| input.set_value(worktrees, window, cx)); self.branch_instructions.update(cx, |input, cx| input.set_value(branch_instructions, window, cx)); self.writing_instructions.update(cx, |input, cx| input.set_value(writing_instructions, window, cx)); self.scoped_inputs = Some(values); } } /// The laptop for this device, a server for the others. fn machine_icon(local: bool) -> Icon { Icon::new(if local { Lucide::Laptop } else { Lucide::Server }) } /// One of the sentence's pickers: a quiet button with a caret. fn picker(id: &'static str, icon: Option, label: SharedString) -> MetalButton { let button = MetalButton::new(id).ghost().compact().label(label).caret().max_w(px(288.)); match icon { Some(icon) => button.leading(icon), None => button, } } #[cfg(test)] mod tests { use super::*; use crate::ui::settings::search::ITEMS; #[::core::prelude::v1::test] fn scoped_rows_cover_the_environment_settings() { for (id, _, keys) in SCOPED_ROWS { assert!(ITEMS.iter().any(|item| item.id == *id), "{id} isn't a settings row"); for key in *keys { assert!(ENVIRONMENT_KEYS.contains(key), "{key} isn't an environment setting"); } } for key in ENVIRONMENT_KEYS { assert!(SCOPED_ROWS.iter().any(|(_, _, keys)| keys.contains(key)), "no row shows {key}"); } } fn environment(id: Option<&str>, settings: Settings, projects: &[(&str, &str)]) -> SettingsEnvironment { SettingsEnvironment { id: id.map(str::to_string), name: id.unwrap_or("here").to_string(), connected: true, settings: Some(settings), projects: projects.iter().map(|(name, path)| (name.to_string(), PathBuf::from(path))).collect(), } } #[::core::prelude::v1::test] fn environments_disagreeing_show_as_mixed() { let mut other = Settings::default(); other.workspace = settings::WorkspaceMode::Worktree; let environments = [environment(None, Settings::default(), &[]), environment(Some("box"), other, &[])]; let shown = Shown::compute(WeakEntity::new_invalid(), &Scope::default(), true, &environments); assert_eq!(shown.targets.len(), 2); assert_eq!(shown.mixed["workspace"], [("here".to_string(), "Local".to_string()), ("box".into(), "Worktree".into())]); assert!(shown.row("new-threads").unwrap().mixed.is_some()); assert!(shown.row("worktree-submodules").unwrap().mixed.is_none()); // A device preference is no environment's business. assert!(shown.row("time-format").is_none()); } #[::core::prelude::v1::test] fn a_project_writes_its_overrides_where_it_exists() { let mut box_settings = Settings::default(); box_settings .project_overrides .insert("/srv/app".into(), serde_json::Map::from_iter([("submodules".to_string(), serde_json::json!("skip"))])); let environments = [ environment(None, Settings::default(), &[("app", "/home/me/app")]), environment(Some("box"), box_settings, &[("app", "/srv/app"), ("other", "/srv/other")]), ]; let scope = Scope { environment: EnvironmentPick::All, project: Some("app".into()) }; let shown = Shown::compute(WeakEntity::new_invalid(), &scope, true, &environments); let paths: Vec<_> = shown.targets.iter().filter_map(|target| target.project.clone()).collect(); assert_eq!(paths, [PathBuf::from("/home/me/app"), PathBuf::from("/srv/app")]); assert!(shown.overridden.contains("submodules")); let row = shown.row("worktree-submodules").unwrap(); assert!(row.project && row.overridden && row.mixed.is_some() && row.locked.is_none()); // Environment-wide settings can't be overridden. assert!(shown.row("storage-worktrees-location").unwrap().locked.is_some()); let values = serde_json::Map::from_iter([ ("workspace".to_string(), serde_json::json!("worktree")), ("worktrees_dir".to_string(), serde_json::json!("/tmp")), ]); let patch = patch_for(&shown.targets[1], &values); assert!(patch.values.is_empty()); assert_eq!(patch.projects["/srv/app"].keys().collect::>(), ["workspace"]); } #[::core::prelude::v1::test] fn an_offline_environment_is_left_alone() { let mut offline = environment(Some("box"), Settings::default(), &[]); offline.connected = false; let environments = [environment(None, Settings::default(), &[]), offline]; let all = Shown::compute(WeakEntity::new_invalid(), &Scope::default(), true, &environments); assert_eq!(all.targets.len(), 1); let one = Scope { environment: EnvironmentPick::One(Some("box".into())), project: None }; let shown = Shown::compute(WeakEntity::new_invalid(), &one, true, &environments); assert!(shown.targets.is_empty() && shown.locked.is_some()); } }