Vectrum
Sign In
641 lines · 29.7 KB · Rust
//! T3 Code's settings scope: "Applying settings for <project> across
//! <environment>" 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::<Shown>().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<String>),
}

/// 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<String>,
}

/// 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<String>,
    pub name: String,
    pub project: Option<PathBuf>,
    /// 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<SettingsView>,
    /// 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<String>,
    pub targets: Vec<WriteTarget>,
    /// Why environment settings can't change right now.
    pub locked: Option<SharedString>,
    /// Settings the selected environments disagree on: each one's value.
    pub mixed: HashMap<String, Vec<(String, String)>>,
    /// Settings the picked project overrides on any selected environment.
    pub overridden: HashSet<String>,
}

impl Global for Shown {}

impl Shown {
    pub fn compute(
        view: WeakEntity<SettingsView>,
        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<serde_json::Map<String, serde_json::Value>>)> = Vec::new();
        for env in selected {
            let Some(settings) = env.settings.as_ref() else { continue };
            let target = |project: Option<PathBuf>| 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<serde_json::Map<String, serde_json::Value>> =
            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<RowScope> {
        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<SharedString>,
    /// Each selected environment's value, when they differ.
    pub mixed: Option<Vec<(String, String)>>,
    /// The row edits the picked project's override.
    pub project: bool,
    pub overridden: bool,
    pub view: WeakEntity<SettingsView>,
}

fn without_overrides(mut values: serde_json::Map<String, serde_json::Value>) -> serde_json::Map<String, serde_json::Value> {
    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<String, serde_json::Value>) -> 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<SettingsEnvironment> {
        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<Self>) -> 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<Self>) {
        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<String>, 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<Self>) {
        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<Self>) {
        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 <project> across <environment>".
    pub(super) fn render_scope_sentence(&self, cx: &mut Context<Self>) -> 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<String> = 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<String>| {
                        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<Vec<(Option<String>, 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<Self>) {
        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<AnyElement>, 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::<Vec<_>>(), ["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());
    }
}